pithos-hosted: link a Theseus ID; a console session that cannot read data

A hosted account is its Ed25519 storage key, and that stays so: the key is
what owns and decrypts the data. A Theseus ID is added as a way to look at
the account (usage, sign-up, sign-out) from a browser that does not hold the
key, which is the account-level login DESIGN-theseus-id.md §11.3 asks for.

The owner links an ID with a normal session plus a Theseus ID sign-in for
project "pithos" (POST /hosted/v1/theseus-id). The ID then signs in to
POST /hosted/v1/console/session and gets a "console" token, which reaches
/hosted/v1/me, its own sign-out and the link status, and is refused
everything else: files, rules, moves and the forwarded owner API. Sessions
stored before scopes existed read as owner sessions. A move to a new ID in
Theseus carries the account along.

The verifier is TheseusID/lib's message.mjs and verify.mjs, copied verbatim
into hosted/theseus-id/ with libauth doing the curve maths, so the gateway
adds no dependency; a test fails if the copy drifts.
This commit is contained in:
Local Dev 2026-10-06 20:34:52 +02:00
parent df2afcf5df
commit 1673e8f018
6 changed files with 532 additions and 9 deletions

View file

@ -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)];

View file

@ -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/<id>/<drive>/.. public files, /l/<token> share links (no auth)
@ -26,6 +27,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 +52,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 +97,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) {
@ -503,8 +526,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 +555,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 });

View file

@ -0,0 +1,134 @@
// 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);
});

View file

@ -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,
});
}

View file

@ -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://<id> 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);
}

View file

@ -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 };
},
};
}