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/auth.js b/hosted/auth.js
index 14ccaf0..445bb1a 100644
--- a/hosted/auth.js
+++ b/hosted/auth.js
@@ -1,6 +1,11 @@
// Sign-in: a person is an Ed25519 key (from their Theseus vault, or a key file
// on hosts without one). The gateway hands out a nonce, the client signs it,
// and gets a session token back. Only token hashes are stored.
+//
+// Two scopes. "owner" comes from the Ed25519 key and can do everything the
+// account can. "console" comes from a linked Theseus ID: it can see the
+// account (usage, sign-up, sessions) but never reach files, rules or the
+// owner's API, because the ID never held the key that encrypts the data.
import crypto from 'node:crypto';
import path from 'node:path';
@@ -8,6 +13,7 @@ import { readJson, writeJson } from './store.js';
const SPKI_ED25519 = Buffer.from('302a300506032b6570032100', 'hex');
const SESSION_DAYS = 90;
+const CONSOLE_DAYS = 7;
export const accountId = (pubkeyHex) => crypto.createHash('sha256').update(Buffer.from(pubkeyHex, 'hex')).digest('hex').slice(0, 32);
@@ -45,17 +51,19 @@ export function createAuth(root) {
nonces.delete(nonce);
return !!exp && exp > Date.now();
},
- issue(id) {
+ issue(id, scope = 'owner') {
const token = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
for (const [h, s] of Object.entries(sessions)) if (s.exp < now) delete sessions[h];
- sessions[hash(token)] = { id, exp: now + SESSION_DAYS * 86_400_000 };
+ const days = scope === 'console' ? CONSOLE_DAYS : SESSION_DAYS;
+ sessions[hash(token)] = { id, exp: now + days * 86_400_000, ...(scope === 'owner' ? {} : { scope }) };
writeJson(file, sessions);
return token;
},
+ // Sessions written before scopes existed are owner sessions.
lookup(token) {
const s = token && sessions[hash(token)];
- return s && s.exp > Date.now() ? s.id : null;
+ return s && s.exp > Date.now() ? { id: s.id, scope: s.scope || 'owner' } : null;
},
revoke(token) {
delete sessions[hash(token)];
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 865bd04..a89b7ee 100644
--- a/hosted/server.js
+++ b/hosted/server.js
@@ -3,7 +3,8 @@
// One HTTP listener (behind nginx/TLS) serves four kinds of traffic:
//
// /hosted/v1/... sign-in, sign-up gate, access rules, moving a data
-// folder in and out (Bearer session token)
+// folder in and out (Bearer session token); Theseus ID
+// linking and the read-only account console
// /api/... the owner's own Pithos API, forwarded to their
// per-account control server (Bearer session token)
// /p///.. public files, /l/ share links (no auth)
@@ -17,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';
@@ -26,6 +28,7 @@ import { parseAuth, verify, SigError } from './sigv4.js';
import { validateRule, classify, decideGrant, publicRuleFor, live, forOwner } from './rules.js';
import { readJson, writeJson } from './store.js';
import { isTransferPath, transferTarget, hashFile } from '../core/hosted.js';
+import { createPithosVerifier, PROJECT as TID_PROJECT } from './theseus-id/index.js';
// Owner API calls the gateway forwards. Everything that would act on the
// server itself (binary, s3d.yml, Sia login, account switching) is the
@@ -50,6 +53,10 @@ export async function createHosted(cfg, log = console.log) {
const grants = readJson(grantsFile, {}); // accessKeyId -> { id, rule }
const links = readJson(linksFile, {}); // token -> { id, rule }
const gate = createGate(cfg.root, cfg.gate || {});
+ // Theseus ID -> account id, for the console sign-in (DESIGN-theseus-id.md §11.3).
+ const tidFile = path.join(cfg.root, 'tid-links.json');
+ const tidLinks = readJson(tidFile, {});
+ const tidVerifier = createPithosVerifier((nonce) => auth.takeNonce(nonce));
// Grant key ids are the gateway's: no account's s3d may route the same id.
const sup = createSupervisor(cfg, log, { reservedKey: (akid) => /^PG/.test(akid) || Object.hasOwn(grants, akid) });
@@ -91,9 +98,26 @@ export async function createHosted(cfg, log = console.log) {
const clientIp = (req) => (cfg.trustProxy && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || '';
function bearer(req) {
const m = /^Bearer\s+(\S+)$/.exec(req.headers.authorization || '');
- const id = m && auth.lookup(m[1]);
- if (!id) throw httpError(401, 'sign in first');
- return { id, token: m[1] };
+ const s = m && auth.lookup(m[1]);
+ if (!s) throw httpError(401, 'sign in first');
+ return { id: s.id, scope: s.scope, token: m[1] };
+ }
+
+ // A Theseus ID sign-in for project "pithos", checked like every other
+ // Theseus ID verifier (§4.3) against nonces this gateway handed out.
+ async function theseusIdSignIn(body) {
+ try {
+ return await tidVerifier.verifySignIn({ message: String(body.message || ''), signature: String(body.signature || ''), move: body.move });
+ } catch (e) {
+ throw httpError(401, e.code === 'bad-nonce' ? 'the sign-in request expired; try again' : `Theseus ID: ${e.code || e.message}`);
+ }
+ }
+ function linkTheseusId(id, account) {
+ const a = sup.account(id);
+ if (a.theseusId && a.theseusId.account !== account) delete tidLinks[a.theseusId.account];
+ tidLinks[account] = id;
+ writeJson(tidFile, tidLinks);
+ sup.saveAccount({ ...a, theseusId: { account, linkedAt: new Date().toISOString() } });
}
async function forwardApi(req, res, url, id) {
@@ -465,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);
@@ -476,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);
@@ -503,8 +548,28 @@ export async function createHosted(cfg, log = console.log) {
return json(res, 200, { token: auth.issue(id), id, state: a.state });
}
+ if (p === '/hosted/v1/theseus-id/challenge' && req.method === 'GET') {
+ return json(res, 200, { projectId: TID_PROJECT, nonce: auth.challenge() });
+ }
+ // The console: a linked Theseus ID signs in to see the account, never its files.
+ if (p === '/hosted/v1/console/session' && req.method === 'POST') {
+ const v = await theseusIdSignIn(await jsonBody(req));
+ let id = tidLinks[v.account];
+ if (!id && v.movedFrom && tidLinks[v.movedFrom]) {
+ // The person moved to a new ID in Theseus (§6.3); the account follows.
+ id = tidLinks[v.movedFrom];
+ delete tidLinks[v.movedFrom];
+ linkTheseusId(id, v.account);
+ }
+ if (!id || !sup.account(id)) throw httpError(404, 'no hosted account is linked to this Theseus ID; sign in to Pithos in Theseus once to link it');
+ return json(res, 200, { token: auth.issue(id, 'console'), id, scope: 'console', moved: !!v.movedFrom });
+ }
+
if (p.startsWith('/hosted/v1/') || p.startsWith('/api/')) {
- const { id, token } = bearer(req);
+ const { id, scope, token } = bearer(req);
+ // Console sessions see the account and nothing that touches its data.
+ const CONSOLE_OK = ['GET /hosted/v1/me', 'DELETE /hosted/v1/session', 'GET /hosted/v1/theseus-id'];
+ if (scope !== 'owner' && !CONSOLE_OK.includes(`${req.method} ${p}`)) throw httpError(403, 'sign in with your Pithos key for that; a Theseus ID console session cannot reach files or settings');
if (p.startsWith('/api/')) {
if (!FORWARD.some(([m, re]) => m === req.method && re.test(p))) throw httpError(403, 'not available on a hosted account');
return forwardApi(req, res, url, id);
@@ -512,8 +577,25 @@ export async function createHosted(cfg, log = console.log) {
const a = sup.account(id);
const q = url.searchParams;
switch (`${req.method} ${p}`) {
+ case 'GET /hosted/v1/theseus-id':
+ return json(res, 200, { account: a.theseusId?.account || null, linkedAt: a.theseusId?.linkedAt || null });
+ case 'POST /hosted/v1/theseus-id': {
+ // Link: the owner session proves the Ed25519 key, the sign-in proves the ID.
+ const v = await theseusIdSignIn(await jsonBody(req));
+ if (tidLinks[v.account] && tidLinks[v.account] !== id) throw httpError(409, 'this Theseus ID is already linked to another hosted account');
+ linkTheseusId(id, v.account);
+ return json(res, 200, { account: v.account });
+ }
+ case 'DELETE /hosted/v1/theseus-id':
+ if (a.theseusId) {
+ delete tidLinks[a.theseusId.account];
+ writeJson(tidFile, tidLinks);
+ const { theseusId: _gone, ...rest } = a;
+ sup.saveAccount(rest);
+ }
+ return json(res, 200, { ok: true });
case 'GET /hosted/v1/me':
- return json(res, 200, { id, state: a.state, gate: { type: a.gate?.type, name: a.gate?.name || null }, createdAt: a.createdAt, usage: usageOf(id), downloadCapBytes: cfg.downloadCapBytes || null, publicUrl: cfg.publicUrl });
+ return json(res, 200, { id, scope, theseusId: a.theseusId?.account || null, state: a.state, gate: { type: a.gate?.type, name: a.gate?.name || null }, createdAt: a.createdAt, usage: usageOf(id), downloadCapBytes: cfg.downloadCapBytes || null, publicUrl: cfg.publicUrl });
case 'DELETE /hosted/v1/session':
auth.revoke(token);
return json(res, 200, { ok: true });
diff --git a/hosted/test/theseus-id.test.js b/hosted/test/theseus-id.test.js
new file mode 100644
index 0000000..b8867dd
--- /dev/null
+++ b/hosted/test/theseus-id.test.js
@@ -0,0 +1,155 @@
+// Theseus ID on the gateway (DESIGN-theseus-id.md §11.3): the owner links an
+// ID with a normal session; the ID then signs in to a console session that can
+// see the account but never its files, rules or owner API.
+//
+// Sign-ins are built and signed with the reference library in TheseusID/, so
+// this also proves the vendored verifier and its libauth curve maths agree
+// with what Theseus produces.
+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 http from 'node:http';
+import crypto from 'node:crypto';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { createHosted } from '../server.js';
+import { challengeText } from '../auth.js';
+import { verifyAddress } from '../theseus-id/index.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'));
+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;
+
+test('the vendored verifier is a verbatim copy of TheseusID/lib', { skip: !fs.existsSync(tidDir) && 'TheseusID/ not in this checkout' }, () => {
+ for (const f of ['message.mjs', 'verify.mjs']) {
+ assert.equal(fs.readFileSync(path.join(here, '..', 'theseus-id', f), 'utf8'), fs.readFileSync(path.join(tidDir, 'lib', f), 'utf8'), `${f} drifted from TheseusID/lib; copy it again`);
+ }
+});
+
+const call = (port, method, p, { body, token } = {}) => new Promise((resolve, reject) => {
+ const data = body === undefined ? null : JSON.stringify(body);
+ const req = http.request({ host: '127.0.0.1', port, path: p, method, headers: { ...(data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) } }, (res) => {
+ let b = '';
+ res.on('data', (d) => { b += d; });
+ res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+});
+
+const iso = (ms) => new Date(ms).toISOString();
+function tidSignIn(priv, nonce, { origin = 'theseus-addon://pithos', projectId = 'pithos' } = {}) {
+ const now = Date.now();
+ const message = ref.buildMessage({
+ kind: 'signin', origin, account: deps.crypto.account(priv), uri: origin + '/', chainId: 'tid:1',
+ projectId, nonce, issuedAt: iso(now), expirationTime: iso(now + 5 * 60_000),
+ });
+ return { message, signature: deps.crypto.sign(priv, message) };
+}
+
+test('verifyAddress agrees with the reference library', { skip: !haveRef && 'TheseusID/node_modules missing' }, () => {
+ const priv = crypto.randomBytes(32);
+ const s = tidSignIn(priv, 'n'.repeat(20));
+ const me = deps.crypto.account(priv);
+ assert.equal(verifyAddress(s.message, s.signature, me), true);
+ assert.equal(verifyAddress(s.message + 'x', s.signature, me), false, 'other text');
+ assert.equal(verifyAddress(s.message, s.signature, deps.crypto.account(crypto.randomBytes(32))), false, 'other account');
+});
+
+test('link a Theseus ID, then a console session that cannot touch data', { skip: !haveRef && 'TheseusID/node_modules missing' }, async (t) => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-tid-'));
+ const port = 18900 + 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: 23200, maxRunning: 1, idleMinutes: 1,
+ gate: { invites: ['inv-1', 'inv-2'] },
+ }, () => {});
+ t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
+
+ // An owner: an Ed25519 key, signed up with an invite.
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
+ const pubkey = publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('hex');
+ const ownerSession = async (signup) => {
+ const c = (await call(port, 'GET', '/hosted/v1/challenge')).body;
+ const signature = crypto.sign(null, Buffer.from(challengeText(`http://127.0.0.1:${port}`, c.nonce)), privateKey).toString('base64');
+ return call(port, 'POST', '/hosted/v1/session', { body: { pubkey, nonce: c.nonce, signature, signup } });
+ };
+ const owner = (await ownerSession({ type: 'invite', code: 'inv-1' })).body.token;
+ assert.ok(owner);
+
+ const tidKey = crypto.randomBytes(32);
+ const tid = deps.crypto.account(tidKey);
+ const fresh = async (opts) => tidSignIn(tidKey, (await call(port, 'GET', '/hosted/v1/theseus-id/challenge')).body.nonce, opts);
+
+ // Before linking, the ID has nothing to sign in to.
+ assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh() })).status, 404);
+
+ // Linking needs the owner session.
+ assert.equal((await call(port, 'POST', '/hosted/v1/theseus-id', { body: await fresh() })).status, 401);
+ const linked = await call(port, 'POST', '/hosted/v1/theseus-id', { body: await fresh(), token: owner });
+ assert.equal(linked.status, 200);
+ assert.equal(linked.body.account, tid);
+ assert.equal((await call(port, 'GET', '/hosted/v1/theseus-id', { token: owner })).body.account, tid);
+
+ // A sign-in for another project, or made on an origin outside the list, is refused.
+ assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh({ projectId: 'hephaestus' }) })).status, 401);
+ assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh({ origin: 'https://navigate.st' }) })).status, 401);
+ // A nonce is good once.
+ const once = await fresh();
+ const c1 = await call(port, 'POST', '/hosted/v1/console/session', { body: once });
+ assert.equal(c1.status, 200);
+ assert.equal(c1.body.scope, 'console');
+ assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: once })).status, 401);
+
+ // The console sees the account...
+ const consoleTok = c1.body.token;
+ const me = await call(port, 'GET', '/hosted/v1/me', { token: consoleTok });
+ assert.equal(me.status, 200);
+ assert.equal(me.body.scope, 'console');
+ assert.equal(me.body.theseusId, tid);
+ // ...and nothing that touches data, rules, the owner API or the link itself.
+ for (const [m, p] of [['GET', '/hosted/v1/rules'], ['POST', '/hosted/v1/data/checkout'], ['GET', '/hosted/v1/data/file?path=x'], ['GET', '/api/status'], ['GET', '/api/keys'], ['DELETE', '/hosted/v1/theseus-id'], ['POST', '/hosted/v1/theseus-id']]) {
+ assert.equal((await call(port, m, p, { token: consoleTok, body: m === 'POST' ? {} : undefined })).status, 403, `${m} ${p}`);
+ }
+ // It can sign itself out.
+ assert.equal((await call(port, 'DELETE', '/hosted/v1/session', { token: consoleTok })).status, 200);
+ assert.equal((await call(port, 'GET', '/hosted/v1/me', { token: consoleTok })).status, 401);
+
+ // Another account cannot take the same ID.
+ const other = crypto.generateKeyPairSync('ed25519');
+ const otherPub = other.publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('hex');
+ const oc = (await call(port, 'GET', '/hosted/v1/challenge')).body;
+ const os2 = await call(port, 'POST', '/hosted/v1/session', { body: { pubkey: otherPub, nonce: oc.nonce, signature: crypto.sign(null, Buffer.from(challengeText(`http://127.0.0.1:${port}`, oc.nonce)), other.privateKey).toString('base64'), signup: { type: 'invite', code: 'inv-2' } } });
+ assert.equal(os2.status, 200);
+ assert.equal((await call(port, 'POST', '/hosted/v1/theseus-id', { body: await fresh(), token: os2.body.token })).status, 409);
+
+ // The owner unlinks; the ID no longer signs in.
+ assert.equal((await call(port, 'DELETE', '/hosted/v1/theseus-id', { token: owner })).status, 200);
+ assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh() })).status, 404);
+ // 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/hosted/theseus-id/index.js b/hosted/theseus-id/index.js
new file mode 100644
index 0000000..e46ef78
--- /dev/null
+++ b/hosted/theseus-id/index.js
@@ -0,0 +1,58 @@
+// Theseus ID for the gateway: checks sign-ins for project "pithos"
+// (DESIGN-theseus-id.md §4.3, §11.3).
+//
+// message.mjs and verify.mjs are verbatim copies of TheseusID/lib, so the
+// gateway agrees with Theseus and the login service byte for byte; a test
+// fails if they drift. The curve maths is libauth's, which the gateway
+// already carries for the sign-up gate, instead of the noble packages the
+// reference library is usually given.
+
+import { sha256, hash160, secp256k1, utf8ToBin, base64ToBin, decodeCashAddress, binToHex } from '@bitauth/libauth';
+import { createVerifier } from './verify.mjs';
+
+export const PROJECT = 'pithos';
+
+// The registry entry's origins (TheseusID/registry/projects.json) plus the
+// login service. Theseus fills Origin from what it saw, so a sign-in made
+// anywhere else does not verify here.
+export const ORIGINS = ['https://pithos.sia', 'https://s3.silentmode.st', 'theseus-addon://pithos'];
+export const PROVIDER_ORIGIN = 'https://id.theseus.x';
+
+function varInt(n) {
+ if (n < 0xfd) return [n];
+ if (n <= 0xffff) return [0xfd, n & 0xff, n >> 8];
+ return [0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff];
+}
+
+// BIP-137 over the real varint (sign-in messages run past 252 bytes), then
+// the recovered key's hash compared with the claimed address. Recovery alone
+// proves nothing: any signature recovers to some key. Theseus ID keys are
+// compressed, so only headers 31-34 are accepted.
+export function verifyAddress(message, signatureB64, address) {
+ let sig;
+ try { sig = base64ToBin(String(signatureB64 || '')); } catch { return false; }
+ if (!(sig instanceof Uint8Array) || sig.length !== 65 || sig[0] < 31 || sig[0] > 34) return false;
+ const magic = utf8ToBin('Bitcoin Signed Message:\n');
+ const msg = utf8ToBin(String(message));
+ const digest = sha256.hash(sha256.hash(Uint8Array.from([...varInt(magic.length), ...magic, ...varInt(msg.length), ...msg])));
+ const pub = secp256k1.recoverPublicKeyCompressed(sig.slice(1), (sig[0] - 31) & 3, digest);
+ if (typeof pub === 'string') return false;
+ const want = decodeCashAddress(String(address || ''));
+ if (typeof want === 'string' || want.type !== 'p2pkh') return false;
+ return binToHex(want.payload) === binToHex(hash160(pub));
+}
+
+/**
+ * @param {(nonce: string) => boolean} consumeNonce true once for a nonce this
+ * gateway issued; called last, so a bad request cannot burn a good nonce.
+ */
+export function createPithosVerifier(consumeNonce, now = () => Date.now()) {
+ return createVerifier({
+ crypto: { verifyAddress },
+ projectId: PROJECT,
+ origins: ORIGINS,
+ providerOrigin: PROVIDER_ORIGIN,
+ consumeNonce,
+ now,
+ });
+}
diff --git a/hosted/theseus-id/message.mjs b/hosted/theseus-id/message.mjs
new file mode 100644
index 0000000..01be3f2
--- /dev/null
+++ b/hosted/theseus-id/message.mjs
@@ -0,0 +1,207 @@
+// Theseus ID — the sign-in message (DESIGN-theseus-id.md §4), project IDs and
+// origins (§3). Pure: no crypto, no I/O, so the same file runs in Theseus,
+// the provider, browsers and tests.
+//
+// The message is built from fields and parsed back with an exact grammar.
+// Signers never sign text a page wrote; verifiers never accept a message
+// that does not round-trip through build(parse(text)) byte for byte.
+
+export const MESSAGE_VERSION = "1";
+export const ID_CHAIN = "tid:1";
+export const WALLET_CHAINS = ["bch:mainnet", "bch:chipnet", "bch:regtest"];
+export const MAX_WINDOW_MS = 10 * 60 * 1000;
+export const BACK_SKEW_MS = 60 * 1000;
+
+const LABEL_RE = /^[a-z][a-z0-9-]{0,31}$/;
+const HOST_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?:\.(?!-)[a-z0-9-]{1,63})+$/;
+const NONCE_RE = /^[A-Za-z0-9_-]{16,128}$/;
+const REQID_RE = /^[A-Za-z0-9._-]{1,64}$/;
+const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
+const ACCOUNT_RE = /^(tid|bitcoincash|bchtest|bchreg):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{42}$/;
+
+// project-id = label / hostname / label ":" hostname (§3.1)
+export function parseProjectId(id) {
+ const s = String(id ?? "");
+ if (s !== s.toLowerCase()) return null;
+ if (LABEL_RE.test(s)) return { id: s, kind: "first-party", authority: null };
+ if (HOST_RE.test(s)) return { id: s, kind: "name", authority: s };
+ const i = s.indexOf(":");
+ if (i > 0 && LABEL_RE.test(s.slice(0, i)) && HOST_RE.test(s.slice(i + 1))) {
+ return { id: s, kind: "family", family: s.slice(0, i), authority: s.slice(i + 1) };
+ }
+ return null;
+}
+export const isProjectId = (id) => parseProjectId(id) !== null;
+
+// Exact origins only: scheme://host[:port], https, or http on a loopback host
+// (development). theseus-addon:// is the pseudo-origin of a built-in add-on.
+export function normOrigin(o) {
+ const s = String(o ?? "");
+ if (/^theseus-addon:\/\/[a-z0-9][a-z0-9-]{0,63}$/.test(s)) return s;
+ let u;
+ try { u = new URL(s); } catch { return null; }
+ if (u.username || u.password || u.search || u.hash || (u.pathname && u.pathname !== "/")) return null;
+ if (s.endsWith("/")) return null; // written without a trailing slash, as an origin
+ const host = u.hostname.toLowerCase();
+ if (u.protocol === "https:") return u.origin;
+ if (u.protocol === "http:" && (host === "localhost" || host === "127.0.0.1" || host === "[::1]")) return u.origin;
+ return null;
+}
+
+function domainOf(origin) {
+ if (origin.startsWith("theseus-addon://")) return origin;
+ return new URL(origin).host; // host[:port], port only when not the default
+}
+
+function oneLine(s, max) {
+ const t = String(s ?? "");
+ if (t.length > max || [...t].some((c) => { const n = c.codePointAt(0); return n < 0x20 || n === 0x7f || n === 0x2028 || n === 0x2029; })) return null;
+ return t;
+}
+
+const HEADLINES = {
+ signin: "wants you to sign in with your Theseus ID:",
+ link: "wants you to link a wallet to your Theseus ID:",
+};
+
+// fields: { kind: "signin" | "link", origin, account, statement?, uri, chainId,
+// projectId, nonce, issuedAt, expirationTime, requestId?, resources?,
+// wallet?, walletChainId? (link only) }
+export function validateFields(f) {
+ const errs = [];
+ const kind = f.kind || "signin";
+ if (!HEADLINES[kind]) errs.push("kind");
+ const origin = normOrigin(f.origin);
+ if (!origin) errs.push("origin");
+ if (!ACCOUNT_RE.test(String(f.account || ""))) errs.push("account");
+ if (!isProjectId(f.projectId)) errs.push("projectId");
+ if (!NONCE_RE.test(String(f.nonce || ""))) errs.push("nonce");
+ if (!ISO_RE.test(String(f.issuedAt || "")) || isNaN(Date.parse(f.issuedAt))) errs.push("issuedAt");
+ if (!ISO_RE.test(String(f.expirationTime || "")) || isNaN(Date.parse(f.expirationTime))) errs.push("expirationTime");
+ else if (!errs.includes("issuedAt")) {
+ const span = Date.parse(f.expirationTime) - Date.parse(f.issuedAt);
+ if (span <= 0 || span > MAX_WINDOW_MS) errs.push("expirationTime");
+ }
+ // A statement that looks like a field line would parse back differently.
+ if (f.statement != null && f.statement !== "" &&
+ (oneLine(f.statement, 200) == null || /^(URI|Wallet): /.test(f.statement))) errs.push("statement");
+ if (oneLine(f.uri, 2048) == null || !f.uri) errs.push("uri");
+ if (f.requestId != null && f.requestId !== "" && !REQID_RE.test(String(f.requestId))) errs.push("requestId");
+ const res = f.resources || [];
+ if (!Array.isArray(res) || res.length > 8 || res.some((r) => oneLine(r, 2048) == null || !r)) errs.push("resources");
+ const account = String(f.account || "");
+ if (kind === "signin") {
+ const isId = account.startsWith("tid:");
+ if (isId ? f.chainId !== ID_CHAIN : !WALLET_CHAINS.includes(f.chainId)) errs.push("chainId");
+ } else if (kind === "link") {
+ if (!account.startsWith("tid:") || f.chainId !== ID_CHAIN) errs.push("chainId");
+ if (!/^(bitcoincash|bchtest|bchreg):/.test(String(f.wallet || "")) || !ACCOUNT_RE.test(String(f.wallet))) errs.push("wallet");
+ if (!WALLET_CHAINS.includes(f.walletChainId)) errs.push("walletChainId");
+ }
+ return errs;
+}
+
+export function buildMessage(f) {
+ const errs = validateFields(f);
+ if (errs.length) throw Object.assign(new Error("bad message fields: " + errs.join(", ")), { code: "bad-request", fields: errs });
+ const kind = f.kind || "signin";
+ const origin = normOrigin(f.origin);
+ const lines = [`${domainOf(origin)} ${HEADLINES[kind]}`, f.account, ""];
+ if (f.statement) lines.push(f.statement, "");
+ if (kind === "link") lines.push(`Wallet: ${f.wallet}`, `Wallet Chain ID: ${f.walletChainId}`);
+ lines.push(
+ `URI: ${f.uri}`,
+ `Version: ${MESSAGE_VERSION}`,
+ `Chain ID: ${f.chainId}`,
+ `Project: ${f.projectId}`,
+ `Origin: ${origin}`,
+ `Nonce: ${f.nonce}`,
+ `Issued At: ${f.issuedAt}`,
+ `Expiration Time: ${f.expirationTime}`,
+ );
+ if (f.requestId) lines.push(`Request ID: ${f.requestId}`);
+ if (f.resources && f.resources.length) lines.push("Resources:", ...f.resources.map((r) => `- ${r}`));
+ return lines.join("\n");
+}
+
+// Strict parse: returns the fields, or throws. Accepts only what buildMessage
+// produces for those fields.
+export function parseMessage(text) {
+ const t = String(text ?? "");
+ const lines = t.split("\n");
+ const fail = (why) => { throw Object.assign(new Error("malformed message: " + why), { code: "malformed" }); };
+ if (t.includes("\r")) fail("CR");
+ let i = 0;
+ const head = lines[i++] || "";
+ let kind = null;
+ for (const [k, h] of Object.entries(HEADLINES)) if (head.endsWith(" " + h)) kind = k;
+ if (!kind) fail("headline");
+ const account = lines[i++];
+ if (lines[i++] !== "") fail("blank after account");
+ let statement;
+ // A statement is any line that is not a field line, followed by a blank.
+ const fieldStart = kind === "link" ? "Wallet: " : "URI: ";
+ if (lines[i] !== undefined && !lines[i].startsWith(fieldStart)) {
+ statement = lines[i++];
+ if (lines[i++] !== "") fail("blank after statement");
+ }
+ const take = (label) => {
+ const l = lines[i];
+ if (l === undefined || !l.startsWith(label + ": ")) fail("expected " + label);
+ i++;
+ return l.slice(label.length + 2);
+ };
+ const f = { kind, account, statement };
+ if (kind === "link") { f.wallet = take("Wallet"); f.walletChainId = take("Wallet Chain ID"); }
+ f.uri = take("URI");
+ if (take("Version") !== MESSAGE_VERSION) fail("version");
+ f.chainId = take("Chain ID");
+ f.projectId = take("Project");
+ f.origin = take("Origin");
+ f.nonce = take("Nonce");
+ f.issuedAt = take("Issued At");
+ f.expirationTime = take("Expiration Time");
+ if (lines[i] !== undefined && lines[i].startsWith("Request ID: ")) f.requestId = take("Request ID");
+ if (lines[i] === "Resources:") {
+ i++;
+ f.resources = [];
+ while (i < lines.length && lines[i].startsWith("- ")) f.resources.push(lines[i++].slice(2));
+ }
+ if (i !== lines.length) fail("trailing lines");
+ let rebuilt;
+ try { rebuilt = buildMessage(f); } catch (e) { fail(e.message); }
+ if (rebuilt !== t) fail("not canonical");
+ return f;
+}
+
+// Move statement (§6.3): signed by both the old and the new key.
+export function buildMoveStatement({ projectId, from, to, origin, nonce, issuedAt }) {
+ if (!isProjectId(projectId) || !normOrigin(origin) || !NONCE_RE.test(String(nonce || "")) || !ISO_RE.test(String(issuedAt || ""))) {
+ throw Object.assign(new Error("bad move fields"), { code: "bad-request" });
+ }
+ if (!/^tid:/.test(from) || !/^tid:/.test(to) || !ACCOUNT_RE.test(from) || !ACCOUNT_RE.test(to) || from === to) {
+ throw Object.assign(new Error("bad move accounts"), { code: "bad-request" });
+ }
+ return ["Theseus ID move", `Project: ${projectId}`, `From: ${from}`, `To: ${to}`,
+ `Origin: ${normOrigin(origin)}`, `Nonce: ${nonce}`, `Issued At: ${issuedAt}`].join("\n");
+}
+
+// Time check for a parsed message (§4.3 step 5).
+export function checkTime(f, now = Date.now()) {
+ const iat = Date.parse(f.issuedAt), exp = Date.parse(f.expirationTime);
+ if (!(now >= iat - BACK_SKEW_MS)) return "issued-in-future";
+ if (!(now <= exp)) return "expired";
+ return null;
+}
+
+// Canonical JSON (sorted keys, no whitespace, nulls dropped) for signed
+// origin lists and the registry.
+export function canonicalJson(v) {
+ if (v === null || v === undefined) return undefined;
+ if (Array.isArray(v)) return "[" + v.map((x) => canonicalJson(x) ?? "null").join(",") + "]";
+ if (typeof v === "object") {
+ const keys = Object.keys(v).filter((k) => v[k] !== null && v[k] !== undefined).sort();
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(v[k])).join(",") + "}";
+ }
+ return JSON.stringify(v);
+}
diff --git a/hosted/theseus-id/verify.mjs b/hosted/theseus-id/verify.mjs
new file mode 100644
index 0000000..0b15f9d
--- /dev/null
+++ b/hosted/theseus-id/verify.mjs
@@ -0,0 +1,56 @@
+// Theseus ID — what a project's server checks (DESIGN §4.3, §6.3, §7).
+//
+// const v = createVerifier({ crypto, projectId: "hephaestus",
+// origins: ["https://code.silentmode.st"], providerOrigin: "https://accounts.silentmode.st",
+// consumeNonce: async (nonce) => true_if_issued_by_me_and_unused });
+// const { account, origin } = await v.verifySignIn({ message, signature });
+
+import { parseMessage, checkTime, buildMoveStatement, normOrigin, ID_CHAIN, WALLET_CHAINS } from "./message.mjs";
+
+const bad = (code, msg) => Object.assign(new Error(msg || code), { code });
+
+export function createVerifier({ crypto, projectId, origins = [], providerOrigin = null, consumeNonce, acceptWallets = false, now = () => Date.now() }) {
+ if (!crypto || !projectId || typeof consumeNonce !== "function") throw new Error("createVerifier: crypto, projectId and consumeNonce are required");
+ const allowed = new Set(origins.map(normOrigin).filter(Boolean));
+ if (providerOrigin) allowed.add(normOrigin(providerOrigin));
+
+ async function check(message, signature, wantKind) {
+ let f;
+ try { f = parseMessage(message); } catch (e) { throw bad("malformed", e.message); }
+ if (f.kind !== wantKind) throw bad("malformed", "wrong message kind");
+ if (f.projectId !== projectId) throw bad("wrong-project", `signed for ${f.projectId}`);
+ if (!allowed.has(f.origin)) throw bad("wrong-origin", `origin ${f.origin} is not ${projectId}'s`);
+ const late = checkTime(f, now());
+ if (late) throw bad(late);
+ const isId = f.account.startsWith("tid:");
+ if (wantKind === "signin") {
+ if (isId ? f.chainId !== ID_CHAIN : !(acceptWallets && WALLET_CHAINS.includes(f.chainId))) throw bad("wrong-chain");
+ }
+ if (!crypto.verifyAddress(message, signature, f.account)) throw bad("bad-signature");
+ // Last, so a bad request cannot burn a good nonce.
+ if (!(await consumeNonce(f.nonce, f))) throw bad("bad-nonce", "nonce unknown or already used");
+ return f;
+ }
+
+ return {
+ async verifySignIn({ message, signature, move }) {
+ const f = await check(message, signature, "signin");
+ const out = { account: f.account, projectId: f.projectId, origin: f.origin, kind: f.account.startsWith("tid:") ? "tid" : "wallet" };
+ if (move) {
+ // §6.3: the new key signs in; a statement signed by old AND new moves the account.
+ const text = buildMoveStatement({ projectId, from: move.from, to: f.account, origin: f.origin, nonce: f.nonce, issuedAt: move.issuedAt });
+ if (text !== move.message) throw bad("bad-move", "move statement does not match the sign-in");
+ if (!crypto.verifyAddress(text, move.signatures?.old, move.from)) throw bad("bad-move", "old key did not sign the move");
+ if (!crypto.verifyAddress(text, move.signatures?.new, f.account)) throw bad("bad-move", "new key did not sign the move");
+ out.movedFrom = move.from;
+ }
+ return out;
+ },
+ // §7: { message, signatures: { id, wallet } } — both keys sign the same text.
+ async verifyLink({ message, signatures }) {
+ const f = await check(message, signatures?.id, "link");
+ if (!crypto.verifyAddress(message, signatures?.wallet, f.wallet)) throw bad("bad-signature", "wallet did not sign the link");
+ return { account: f.account, wallet: f.wallet, walletChainId: f.walletChainId, projectId: f.projectId, origin: f.origin };
+ },
+ };
+}
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 61ad32f..f7545be 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();