diff --git a/DESIGN-hosted.md b/DESIGN-hosted.md
index 5ae57f7..e4237fe 100644
--- a/DESIGN-hosted.md
+++ b/DESIGN-hosted.md
@@ -161,3 +161,36 @@ storage (Silent Mode's own 500 MB free web hosting and paid tiers would be one
deployment of it). It can reuse pieces built here: the per-account s3d
supervisor, the gateway's SigV4 and rule enforcement, client-side encryption.
Pithos stays free of quotas and billing.
+
+## Theseus ID
+
+A hosted account is its Ed25519 storage key, and stays so: that key owns and
+decrypts the data. A Theseus ID (DESIGN-theseus-id.md §11.3) is added as a way
+to look at the account from a browser that does not hold the key.
+
+- **Linking.** In Theseus, right after Pithos signs in to Silent Mode (and
+ once per run for an account already signed in), the add-on asks
+ `api.theseusId.signIn` for project `pithos`, signed as origin
+ `theseus-addon://pithos`, and posts it to `POST /hosted/v1/theseus-id` with
+ its owner session. Theseus asks the person once. A Theseus without the
+ "theseus-id" add-on API skips this; nothing else changes.
+- **The console.** `GET /console` on the gateway's own origin
+ (s3.silentmode.st, on the `pithos` origin list) signs in with
+ `window.theseusId` through `POST /hosted/v1/console/session` and gets a
+ session with scope `console`. That scope reaches `GET /hosted/v1/me`, its
+ own sign-out and the link status, and nothing else: no files, rules, moves
+ or forwarded owner API. Console sessions last 7 days.
+- **Routes.** `GET /hosted/v1/theseus-id/challenge` (nonce),
+ `POST /hosted/v1/console/session`, and with an owner session
+ `GET|POST|DELETE /hosted/v1/theseus-id`. `tid-links.json` at the root maps
+ each linked ID to its account; `account.json` carries `theseusId`.
+- **Verification** is `hosted/theseus-id/`: TheseusID/lib's `message.mjs` and
+ `verify.mjs` copied verbatim (a test fails if they drift) with libauth doing
+ the curve maths.
+- **Deploying.** The server now has two sub-folders, `hosted/theseus-id/` and
+ `hosted/console/`. Copying `hosted/*.js` alone misses them; copy both
+ folders too.
+- **Not yet.** Signing in to the console from other browsers through
+ id.theseus.x (the `pithos` client on the login service), and a browser-app
+ sign-in that also unlocks the drives: that needs the encryption keys, which
+ a console session must never carry.
diff --git a/core/hosted-routes.js b/core/hosted-routes.js
index 29fa733..1c87c24 100644
--- a/core/hosted-routes.js
+++ b/core/hosted-routes.js
@@ -49,6 +49,24 @@ export function installHosted(ctx) {
if (!h?.token) throw new HttpError(401, 'sign in to Silent Mode first');
return gateway(h.url, h.token);
};
+ // Theseus ID (DESIGN-theseus-id.md §11.3): in Theseus, the account gets the
+ // person's Theseus ID for project "pithos" linked once, so they can look at
+ // it from a browser without the key, and Pithos appears under Settings ›
+ // Theseus ID. Quiet and best effort: Pithos works the same without it.
+ let tidTried = false;
+ async function linkTheseusId(me) {
+ if (tidTried || typeof secrets.theseusIdSignIn !== 'function' || !me || me.theseusId !== null) return;
+ tidTried = true;
+ try {
+ const g = gw();
+ const { nonce, projectId } = await g.call('GET', '/hosted/v1/theseus-id/challenge');
+ const signed = await secrets.theseusIdSignIn({ projectId, nonce });
+ await g.call('POST', '/hosted/v1/theseus-id', { body: { message: signed.message, signature: signed.signature, move: signed.move } });
+ } catch (e) {
+ // "denied" is the person saying no; ask again next time Pithos starts.
+ console.warn(`[pithos] Theseus ID not linked: ${e.code || e.message}`);
+ }
+ }
const wrap = async (fn) => {
try { return await fn(); } catch (e) {
if (e instanceof HostedError) {
@@ -149,6 +167,7 @@ export function installHosted(ctx) {
try { const warn = await settleMoves(); if (warn) out.warning = warn; } catch {}
out.mode = isHosted() ? 'hosted' : 'local';
try { out.me = await gw().call('GET', '/hosted/v1/me'); } catch (e) { out.error = e.message; if (e.status === 401) out.signedIn = false; }
+ if (out.me) linkTheseusId(out.me);
}
if (!out.signedIn) {
try { out.info = await gateway(out.url).call('GET', '/hosted/v1/info'); } catch (e) { out.error = `Silent Mode is not reachable: ${e.message}`; }
@@ -162,6 +181,7 @@ export function installHosted(ctx) {
const r = await wrap(() => signIn(url.replace(/\/$/, ''), secrets, signup));
if (r.needsSignup) return r;
writePrefs(configFile, { hosted: { url: url.replace(/\/$/, ''), id: r.id, token: r.token } });
+ gw().call('GET', '/hosted/v1/me').then(linkTheseusId, () => {});
return { ok: true, id: r.id, state: r.state };
});
diff --git a/hosted/console/console.css b/hosted/console/console.css
new file mode 100644
index 0000000..8c4e80d
--- /dev/null
+++ b/hosted/console/console.css
@@ -0,0 +1,25 @@
+:root {
+ color-scheme: light dark;
+ --bg: #f6f7f9; --card: #ffffff; --ink: #1a1f2b; --mut: #5c6577; --line: rgba(0, 0, 0, .12);
+ --accent: #1e8f6a; --accent-ink: #ffffff; --danger: #b32d2e;
+}
+@media (prefers-color-scheme: dark) {
+ :root { --bg: #0f1621; --card: #1c222c; --ink: #e7eaf1; --mut: #8b98a9; --line: rgba(255, 255, 255, .12); --accent: #3ddc97; --accent-ink: #0b1f18; --danger: #f6768a; }
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+main { max-width: 560px; margin: 48px auto; padding: 24px; background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
+@media (max-width: 600px) { main { margin: 0; border-radius: 0; border-width: 0 0 1px; padding: 20px 16px; } }
+h1 { font-size: 22px; margin: 0 0 8px; }
+.lead, .note { color: var(--mut); }
+.note { font-size: 13.5px; }
+button { font: inherit; font-weight: 600; padding: 10px 16px; border-radius: 8px; border: 1px solid transparent; background: var(--accent); color: var(--accent-ink); cursor: pointer; }
+button.secondary { background: transparent; color: var(--ink); border-color: var(--line); font-weight: 500; }
+button:disabled { opacity: .6; cursor: default; }
+button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+[hidden] { display: none !important; }
+dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 16px 0 20px; }
+dt { color: var(--mut); }
+dd { margin: 0; overflow-wrap: anywhere; }
+#status { min-height: 1.5em; font-size: 14px; }
+#status.error { color: var(--danger); font-weight: 600; }
diff --git a/hosted/console/console.js b/hosted/console/console.js
new file mode 100644
index 0000000..1a292f8
--- /dev/null
+++ b/hosted/console/console.js
@@ -0,0 +1,111 @@
+// The read-only account console (DESIGN-theseus-id.md §11.3). Theseus signs a
+// Theseus ID sign-in for project "pithos" on this origin; the gateway answers
+// with a "console" session that sees /hosted/v1/me and nothing else.
+
+const $ = (id) => document.getElementById(id);
+const KEY = 'pithos-console-token';
+const store = {
+ get() { try { return sessionStorage.getItem(KEY); } catch { return null; } },
+ set(v) { try { if (v) sessionStorage.setItem(KEY, v); else sessionStorage.removeItem(KEY); } catch {} },
+};
+
+async function call(method, path, { body, token } = {}) {
+ const res = await fetch(path, {
+ method,
+ headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
+ return data;
+}
+
+function say(text, error = false) {
+ $('status').textContent = text || '';
+ $('status').className = error ? 'error' : '';
+}
+
+const bytes = (n) => {
+ if (!Number.isFinite(n)) return '—';
+ const u = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
+ return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
+};
+const GATES = { bns: 'a BNS name', deposit: 'a deposit', invite: 'an invite code' };
+const STATES = { empty: 'Ready, no drives moved in yet', active: 'Running', 'moving-out': 'Moving out', released: 'Released' };
+
+function show(me) {
+ const rows = [
+ ['Account', me.id],
+ ['Theseus ID', me.theseusId || '—'],
+ ['State', STATES[me.state] || me.state],
+ ['Opened', me.createdAt ? new Date(me.createdAt).toLocaleDateString() : '—'],
+ ['Opened with', me.gate?.name ? `${GATES[me.gate.type] || me.gate.type} (${me.gate.name})` : (GATES[me.gate?.type] || '—')],
+ ['Downloaded this month', me.downloadCapBytes ? `${bytes(me.usage?.down)} of ${bytes(me.downloadCapBytes)}` : bytes(me.usage?.down)],
+ ['Uploaded this month', bytes(me.usage?.up)],
+ ];
+ $('facts').replaceChildren(...rows.flatMap(([k, v]) => {
+ const dt = document.createElement('dt');
+ dt.textContent = k;
+ const dd = document.createElement('dd');
+ dd.textContent = v;
+ return [dt, dd];
+ }));
+ $('signed-out').hidden = true;
+ $('signed-in').hidden = false;
+}
+
+function signedOut() {
+ $('signed-in').hidden = true;
+ $('signed-out').hidden = false;
+ const inTheseus = !!window.theseusId;
+ $('signin').hidden = !inTheseus;
+ $('no-theseus').hidden = inTheseus;
+}
+
+async function refresh() {
+ const token = store.get();
+ if (!token) return signedOut();
+ try {
+ show(await call('GET', '/hosted/v1/me', { token }));
+ } catch (e) {
+ store.set(null);
+ signedOut();
+ if (e.status !== 401) say(e.message, true);
+ }
+}
+
+const THESEUS_ERRORS = {
+ denied: '',
+ locked: 'Unlock your Theseus Vault, then try again.',
+ 'no-vault': 'Set up the Theseus Vault to get a Theseus ID.',
+ 'origin-not-listed': 'Theseus does not list this page as part of Pithos, so it will not sign in here.',
+};
+
+$('signin').addEventListener('click', async () => {
+ $('signin').disabled = true;
+ say('Signing in…');
+ try {
+ const { projectId, nonce } = await call('GET', '/hosted/v1/theseus-id/challenge');
+ const signed = await window.theseusId.signIn({ projectId, nonce });
+ const s = await call('POST', '/hosted/v1/console/session', { body: { message: signed.message, signature: signed.signature, move: signed.move } });
+ if (signed.move && s.moved && window.theseusId.moved) await window.theseusId.moved({ projectId, account: signed.account }).catch(() => {});
+ store.set(s.token);
+ say('');
+ await refresh();
+ } catch (e) {
+ say(e.code in THESEUS_ERRORS ? THESEUS_ERRORS[e.code] : e.message, true);
+ } finally {
+ $('signin').disabled = false;
+ }
+});
+
+$('signout').addEventListener('click', async () => {
+ const token = store.get();
+ store.set(null);
+ if (token) await call('DELETE', '/hosted/v1/session', { token }).catch(() => {});
+ signedOut();
+});
+
+refresh();
diff --git a/hosted/console/index.html b/hosted/console/index.html
new file mode 100644
index 0000000..0a32327
--- /dev/null
+++ b/hosted/console/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+Pithos account
+
+
+
+
+
+
Pithos account
+
Your Pithos on Silent Mode account: how much it has used and how it was opened. Signing in with your Theseus ID shows the account only. Your files stay locked to the key in your Theseus Vault, so nothing here can open them.
+
+
+
+
Open this page in Theseus to sign in. Signing in from other browsers through the Theseus ID login service is coming.
+
The first time, open Pithos in Theseus and sign in to your drives on Silent Mode: that links your Theseus ID to the account.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hosted/server.js b/hosted/server.js
index 94a6d79..a89b7ee 100644
--- a/hosted/server.js
+++ b/hosted/server.js
@@ -18,6 +18,7 @@ import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { pipeline } from 'node:stream/promises';
+import { fileURLToPath } from 'node:url';
import { readConfig } from '../core/config.js';
import { objectPath } from '../core/s3.js';
import { createAuth, accountId, challengeText, verifyEd25519 } from './auth.js';
@@ -488,6 +489,26 @@ export async function createHosted(cfg, log = console.log) {
res.end(req.method === 'HEAD' ? undefined : body);
}
+ // The account console (hosted/console/, DESIGN-theseus-id.md §11.3): three
+ // fixed files on the gateway's own origin, which is on the "pithos" origin
+ // list, so Theseus will sign a Theseus ID sign-in for it here.
+ const CONSOLE_DIR = fileURLToPath(new URL('./console/', import.meta.url));
+ const CONSOLE_FILES = {
+ '/console': ['index.html', 'text/html; charset=utf-8'],
+ '/console/console.js': ['console.js', 'text/javascript; charset=utf-8'],
+ '/console/console.css': ['console.css', 'text/css; charset=utf-8'],
+ };
+ const CONSOLE_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
+ function serveConsole(req, res, p) {
+ const [file, type] = CONSOLE_FILES[p];
+ const body = fs.readFileSync(path.join(CONSOLE_DIR, file));
+ res.writeHead(200, {
+ 'content-type': type, 'content-length': body.length, 'cache-control': 'no-cache',
+ 'content-security-policy': CONSOLE_CSP, 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
+ });
+ res.end(req.method === 'HEAD' ? undefined : body);
+ }
+
// ---- routing -------------------------------------------------------------------
async function handle(req, res) {
const url = new URL(req.url, cfg.publicUrl);
@@ -499,6 +520,7 @@ export async function createHosted(cfg, log = console.log) {
// requests were routed above, so a drive named "_app" is unaffected.
if (appHost(req) && (req.method === 'GET' || req.method === 'HEAD') && (p === '/' || p.startsWith('/_app/'))) return serveApp(req, res, p);
+ if (Object.hasOwn(CONSOLE_FILES, p) && (req.method === 'GET' || req.method === 'HEAD')) return serveConsole(req, res, p);
if (p.startsWith('/p/') && (req.method === 'GET' || req.method === 'HEAD')) return servePublic(req, res, url);
if (p.startsWith('/l/') && (req.method === 'GET' || req.method === 'HEAD')) return serveLink(req, res, url);
diff --git a/hosted/test/theseus-id.test.js b/hosted/test/theseus-id.test.js
index c328d4c..b8867dd 100644
--- a/hosted/test/theseus-id.test.js
+++ b/hosted/test/theseus-id.test.js
@@ -132,3 +132,24 @@ test('link a Theseus ID, then a console session that cannot touch data', { skip:
// Old owner sessions (written before scopes) stay owner sessions.
assert.equal((await call(port, 'GET', '/hosted/v1/rules', { token: owner })).status, 200);
});
+
+test('the console page: three fixed files with a strict CSP', async (t) => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-console-'));
+ const port = 19500 + Math.floor(Math.random() * 200);
+ const h = await createHosted({
+ listen: `127.0.0.1:${port}`, publicUrl: `http://127.0.0.1:${port}`, root, s3dBinary: null, portBase: 23600, maxRunning: 1, idleMinutes: 1, gate: { invites: [] },
+ }, () => {});
+ t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
+ const get = (p) => new Promise((resolve, reject) => http.get({ host: '127.0.0.1', port, path: p }, (res) => {
+ let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
+ }).on('error', reject));
+ const page = await get('/console');
+ assert.equal(page.status, 200);
+ assert.match(page.headers['content-type'], /text\/html/);
+ assert.match(page.headers['content-security-policy'], /default-src 'none'; script-src 'self'/);
+ assert.match(page.headers['content-security-policy'], /frame-ancestors 'none'/);
+ assert.match(page.body, /\/console\/console\.js/);
+ assert.equal((await get('/console/console.js')).status, 200);
+ assert.equal((await get('/console/console.css')).status, 200);
+ for (const p of ['/console/', '/console/index.html', '/console/../server.js', '/console/%2e%2e/server.js']) assert.equal((await get(p)).status, 404, p);
+});
diff --git a/test/theseus-id.test.js b/test/theseus-id.test.js
new file mode 100644
index 0000000..256a8d0
--- /dev/null
+++ b/test/theseus-id.test.js
@@ -0,0 +1,81 @@
+// Pithos links the person's Theseus ID to their hosted account by itself,
+// once, right after signing in (DESIGN-theseus-id.md §11.3), and does nothing
+// on hosts that do not offer Theseus ID. Runs the real gateway and the real
+// control server; Theseus is played by the reference library in TheseusID/.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { createHosted } from '../hosted/server.js';
+import { createPithos } from '../core/server.js';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const tidDir = path.join(here, '..', '..', 'TheseusID');
+const haveRef = fs.existsSync(path.join(tidDir, 'node_modules', '@noble', 'curves')) && fs.existsSync(path.join(here, '..', 'hosted', 'node_modules', '@bitauth'));
+const ref = haveRef ? await import(pathToFileURL(path.join(tidDir, 'lib', 'index.mjs')).href) : null;
+const deps = haveRef ? await import(pathToFileURL(path.join(tidDir, 'test', 'deps.mjs')).href) : null;
+
+const iso = (ms) => new Date(ms).toISOString();
+// What Theseus's api.theseusId.signIn returns for the Pithos add-on.
+const theseusFor = (priv, calls) => async ({ projectId, nonce }) => {
+ calls.push(projectId);
+ const now = Date.now();
+ const message = ref.buildMessage({
+ kind: 'signin', origin: 'theseus-addon://pithos', account: deps.crypto.account(priv), uri: 'theseus-addon://pithos/',
+ chainId: 'tid:1', projectId, nonce, issuedAt: iso(now), expirationTime: iso(now + 300_000),
+ });
+ return { message, signature: deps.crypto.sign(priv, message) };
+};
+
+async function until(fn, ms = 5000) {
+ const end = Date.now() + ms;
+ for (;;) {
+ const v = await fn();
+ if (v || Date.now() > end) return v;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+}
+
+test('signing in links the Theseus ID once; without the API nothing happens', { skip: !haveRef && 'TheseusID/node_modules or hosted/node_modules missing' }, async (t) => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-tid-client-'));
+ const port = 19100 + Math.floor(Math.random() * 200);
+ const gwUrl = `http://127.0.0.1:${port}`;
+ const h = await createHosted({
+ listen: `127.0.0.1:${port}`, publicUrl: gwUrl, root: path.join(root, 'gw'), s3dBinary: null, portBase: 23400, maxRunning: 1, idleMinutes: 1,
+ gate: { invites: ['a', 'b'] },
+ }, () => {});
+ t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
+
+ async function pithosWith(name, theseusIdSignIn) {
+ const p = await createPithos({
+ configFile: path.join(root, name, 'pithos.yml'), handoffPort: null, hostName: 'theseus',
+ secrets: { kind: 'vault', identitySeed: async () => crypto.randomBytes(32), encryptionKey: async () => crypto.randomBytes(32), theseusIdSignIn },
+ });
+ t.after(() => p.close());
+ const cookie = p.internalSession();
+ return (method, apiPath, body) => fetch(new URL(apiPath, p.url), {
+ method, headers: { cookie, 'x-pithos': '1', ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined,
+ }).then(async (r) => ({ status: r.status, data: await r.json().catch(() => null) }));
+ }
+
+ // In Theseus: sign up, and the ID is linked without being asked for.
+ const tidKey = crypto.randomBytes(32);
+ const calls = [];
+ const api = await pithosWith('in-theseus', theseusFor(tidKey, calls));
+ const signin = await api('POST', '/api/hosted/signin', { url: gwUrl, signup: { type: 'invite', code: 'a' } });
+ assert.equal(signin.status, 200, JSON.stringify(signin.data));
+ const linked = await until(async () => (await api('GET', '/api/hosted')).data?.me?.theseusId);
+ assert.equal(linked, deps.crypto.account(tidKey));
+ assert.deepEqual(calls, ['pithos'], 'signed for project "pithos", once');
+ await api('GET', '/api/hosted');
+ assert.equal(calls.length, 1, 'not again once linked');
+
+ // Elsewhere (no Theseus ID API): sign-in works and nothing is linked.
+ const plain = await pithosWith('elsewhere', null);
+ assert.equal((await plain('POST', '/api/hosted/signin', { url: gwUrl, signup: { type: 'invite', code: 'b' } })).status, 200);
+ await new Promise((r) => setTimeout(r, 300));
+ assert.equal((await plain('GET', '/api/hosted')).data?.me?.theseusId, null);
+});
diff --git a/theseus/addon.json b/theseus/addon.json
index 465d99c..0c22b6a 100644
--- a/theseus/addon.json
+++ b/theseus/addon.json
@@ -10,7 +10,8 @@
"sidebar-panel",
"vault-derive",
"site-route",
- "approval-modal"
+ "approval-modal",
+ "theseus-id"
],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json",
"siteRoutes": [
diff --git a/theseus/index.js b/theseus/index.js
index 1595b5c..17c8e15 100644
--- a/theseus/index.js
+++ b/theseus/index.js
@@ -63,6 +63,11 @@ module.exports = {
kind: "vault",
identitySeed: () => vaultKey("pithos/hosted-identity/v1", "Sign in to your drives on Silent Mode."),
encryptionKey: () => vaultKey("pithos/hosted-encryption/v1", "Open your encrypted files on Silent Mode."),
+ // Theseus ID sign-in as theseus-addon://pithos; absent on a
+ // Theseus without the "theseus-id" add-on API.
+ theseusIdSignIn: api.theseusId
+ ? ({ projectId, nonce }) => api.theseusId.signIn({ projectId, nonce, statement: "Link your Theseus ID to your Pithos account." })
+ : null,
},
});
cookie = p.internalSession();