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/server.js b/hosted/server.js index 865bd04..94a6d79 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) @@ -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 }); diff --git a/hosted/test/theseus-id.test.js b/hosted/test/theseus-id.test.js new file mode 100644 index 0000000..c328d4c --- /dev/null +++ b/hosted/test/theseus-id.test.js @@ -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); +}); 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 }; + }, + }; +}