// Theseus ID inside Theseus's main process (DESIGN-theseus-id.md §5, §6). // // What lives here: the sign-in policy (which origin may sign as which // project, when to prompt, when to stay silent), the encrypted record of // which ID each project got, and the origin-list cache. What does not: any // UI or Electron object. main.js passes in the vault, the prompts and the // fetchers, which keeps this testable with fakes (dev/theseus-id.test.cjs). // // Identity keys are derived per signature and zeroed after it. They never // leave this module: callers get a message and a signature. // // State file: { v: 1, iv, ct } — AES-256-GCM under HKDF(idRoot, // "theseus-id/v1/state-key"), so it reads only with the vault open, and only // on a vault with the same seed. "use strict"; const fs = require("node:fs"); const nodeCrypto = require("node:crypto"); const LIST_TTL_MS = 60 * 60 * 1000; // origin lists: refresh after an hour const LIST_STALE_MS = 7 * 24 * 3600 * 1000; // ...and use a cached copy up to a week if refresh fails const SILENT_PER_MIN = 10; // prompt-free signatures per origin per minute const MOVE_GIVE_UP_MS = 30 * 24 * 3600 * 1000; const DEFAULT_TTL_S = 300; const err = (code, message) => Object.assign(new Error(message || code), { code }); function createTheseusIdHost({ file, loadLib, // async () => { lib, crypto } (TheseusID/lib + createCrypto(noble)) getPurposeRoot, // () => Uint8Array(32) | null (null = vault locked) hasVault, // () => boolean bundledRegistry, // the registry document shipped with Theseus (trusted as shipped) fetchOriginDoc, // async (authority) => { doc, bns: boolean, owner?: string } ui, // { unlock: async ({ reason }) => boolean, confirm: async (req) => { ok, always, mode } } log = () => {}, now = () => Date.now(), }) { let libP = null; const lib = () => (libP ||= loadLib().catch((e) => { libP = null; log("library failed to load:", e?.message || e); throw e; })); let registry = null; const listCache = new Map(); // authority -> { list, at, error } const busy = new Set(); // origins with a request in flight const silentLog = new Map(); // origin -> [timestamps] let stateCache = null; // { rootHex, state } async function getRegistry() { if (registry) return registry; const { lib: L } = await lib(); registry = L.verifyRegistry(bundledRegistry, { trusted: true }); return registry; } // §3.3 / §3.4 — null when the project has no list we can trust. async function originList(projectId) { const { lib: L, crypto } = await lib(); const pid = L.parseProjectId(projectId); if (!pid) throw err("bad-request", "bad project id"); if (pid.kind === "first-party") return (await getRegistry()).projects.get(projectId) || null; const key = projectId; const hit = listCache.get(key); if (hit && hit.list && now() - hit.at < LIST_TTL_MS) return hit.list; try { const { doc, bns, owner } = await fetchOriginDoc(pid.authority); const opts = { expectProject: projectId, sharedOrigins: (await getRegistry()).shared, now: now() }; const list = bns ? L.verifyOwnerSignedList(doc, { ...opts, crypto, owner }) : L.parseOriginList(doc, opts); listCache.set(key, { list, at: now() }); return list; } catch (e) { log("origin list for", projectId, "failed:", e?.message || e); if (hit && hit.list && now() - hit.at < LIST_STALE_MS) return hit.list; throw err("origin-list-unavailable", `could not load ${projectId}'s origin list`); } } // ---- encrypted state ---- async function keys() { const pr = getPurposeRoot(); if (!pr) return null; const { crypto } = await lib(); const idRoot = crypto.idRoot(pr); return { idRoot, rootHex: Buffer.from(idRoot).toString("hex"), stateKey: crypto.stateKey(idRoot) }; } const fresh = () => ({ v: 1, defaultMode: "project", autoFirstParty: true, projects: {} }); async function loadState() { const k = await keys(); if (!k) throw err("locked", "the vault is locked"); if (stateCache && stateCache.rootHex === k.rootHex) return { k, state: stateCache.state }; let state = fresh(); try { const rec = JSON.parse(fs.readFileSync(file, "utf8")); const d = nodeCrypto.createDecipheriv("aes-256-gcm", Buffer.from(k.stateKey), Buffer.from(rec.iv, "base64")); const ct = Buffer.from(rec.ct, "base64"); d.setAuthTag(ct.subarray(ct.length - 16)); const pt = JSON.parse(Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8")); if (pt && pt.v === 1 && pt.projects && typeof pt.projects === "object") state = { ...fresh(), ...pt }; } catch (e) { if (e && e.code !== "ENOENT") log("theseus-id state unreadable (other vault, or damaged); starting empty:", e.message); } stateCache = { rootHex: k.rootHex, state }; return { k, state }; } async function saveState() { const k = await keys(); if (!k || !stateCache || stateCache.rootHex !== k.rootHex) throw err("locked", "the vault is locked"); const iv = nodeCrypto.randomBytes(12); const c = nodeCrypto.createCipheriv("aes-256-gcm", Buffer.from(k.stateKey), iv); const ct = Buffer.concat([c.update(JSON.stringify(stateCache.state), "utf8"), c.final(), c.getAuthTag()]); const tmp = file + ".tmp"; fs.writeFileSync(tmp, JSON.stringify({ v: 1, iv: iv.toString("base64"), ct: ct.toString("base64") }), { mode: 0o600 }); fs.renameSync(tmp, file); } // Drop the decrypted copy when the vault locks. function forget() { stateCache = null; } async function accountFor(k, spec) { const { crypto } = await lib(); const priv = crypto.key(k.idRoot, crypto.scope(spec)); try { return crypto.account(priv); } finally { priv.fill(0); } } async function signWith(k, spec, text) { const { crypto } = await lib(); const priv = crypto.key(k.idRoot, crypto.scope(spec)); try { return crypto.sign(priv, text); } finally { priv.fill(0); } } function silentAllowed(origin) { const t = now(); const recent = (silentLog.get(origin) || []).filter((x) => t - x < 60_000); silentLog.set(origin, recent); return recent.length < SILENT_PER_MIN; } // ---- the page API (§5.1, §5.2) ---- // req: { projectId, nonce, statement?, requestId?, resources?, expiresIn?, // origin, uri, gesture, ctx } origin/uri/gesture come from main, never the page; // ctx is passed through to the prompts (main uses it for the tab). async function signIn(req) { const { lib: L } = await lib(); const origin = L.normOrigin(req.origin); if (!origin) throw err("origin-not-listed", "this page cannot use Theseus ID"); if (typeof req.projectId !== "string" || typeof req.nonce !== "string") throw err("bad-request", "projectId and nonce are required"); if (busy.has(origin)) throw err("busy", "a sign-in is already waiting on this page"); busy.add(origin); try { const list = await originList(req.projectId); if (!list || !L.originAllowed(list, origin)) throw err("origin-not-listed", `${origin} is not on ${req.projectId}'s origin list`); if (!hasVault()) throw err("no-vault", "set up the Theseus Vault first"); if (!getPurposeRoot()) { if (!req.gesture) throw err("locked", "the vault is locked"); const ok = await ui.unlock({ reason: `Sign in to ${list.name} with your Theseus ID.`, ctx: req.ctx }); if (!ok || !getPurposeRoot()) throw err("locked", "the vault stayed locked"); } const { k, state } = await loadState(); let rec = state.projects[req.projectId] || null; const firstParty = list.kind === "first-party"; const silent = !!rec && (rec.always || (firstParty && state.autoFirstParty)) && silentAllowed(origin); let mode = rec ? rec.mode : state.defaultMode; if (!silent) { const preview = { mode: "project", gen: 0, projectId: req.projectId }; const accounts = { project: await accountFor(k, preview), one: await accountFor(k, { mode: "one", gen: 0 }), }; const answer = await ui.confirm({ projectId: req.projectId, name: list.name, origin, firstParty, first: !rec, ctx: req.ctx, mode, account: rec ? rec.account : accounts[mode], accounts, }); if (!answer || !answer.ok) throw err("denied", "the user declined"); if (!rec && (answer.mode === "one" || answer.mode === "project")) mode = answer.mode; if (!rec) { rec = { mode, gen: 0, account: accounts[mode], firstSeen: new Date(now()).toISOString(), lastUsed: null, origins: [], always: false, move: null, wallets: [] }; state.projects[req.projectId] = rec; } if (answer.always) rec.always = true; } else { silentLog.get(origin).push(now()); } // Which key signs: the current one, or the new one while a move is pending. const cur = { mode: rec.mode, gen: rec.gen, projectId: req.projectId }; const curAccount = await accountFor(k, cur); if (curAccount !== rec.account) throw err("state-mismatch", "this project's saved ID does not match the vault"); let signer = cur, account = rec.account, move; const issuedAt = new Date(now()).toISOString(); if (rec.move) { if (now() - Date.parse(rec.move.since) > MOVE_GIVE_UP_MS) { finishMove(rec); } else { signer = { mode: rec.move.to.mode, gen: rec.move.to.gen, projectId: req.projectId }; account = rec.move.to.account; } } const ttl = Math.min(600, Math.max(30, Number(req.expiresIn) || DEFAULT_TTL_S)); const message = L.buildMessage({ kind: "signin", origin, account, uri: String(req.uri || origin).slice(0, 2048), chainId: L.ID_CHAIN, projectId: req.projectId, nonce: req.nonce, issuedAt, expirationTime: new Date(now() + ttl * 1000).toISOString(), statement: req.statement || undefined, requestId: req.requestId || undefined, resources: Array.isArray(req.resources) && req.resources.length ? req.resources : undefined, }); const signature = await signWith(k, signer, message); if (rec.move && account === rec.move.to.account) { const text = L.buildMoveStatement({ projectId: req.projectId, from: rec.account, to: account, origin, nonce: req.nonce, issuedAt }); move = { from: rec.account, issuedAt, message: text, signatures: { old: await signWith(k, cur, text), new: await signWith(k, signer, text) } }; } rec.lastUsed = issuedAt; if (!rec.origins.includes(origin)) rec.origins = [...rec.origins, origin].slice(-16); await saveState(); const out = { v: 1, projectId: req.projectId, origin, account, message, signature, scheme: "bip137" }; if (move) out.move = move; return out; } finally { busy.delete(origin); } } function finishMove(rec) { rec.mode = rec.move.to.mode; rec.gen = rec.move.to.gen; rec.account = rec.move.to.account; rec.move = null; } // The page tells Theseus its server accepted the move (§6.3 step 4). async function moved({ projectId, origin, account }) { const { lib: L } = await lib(); const list = await originList(projectId); if (!list || !L.originAllowed(list, L.normOrigin(origin))) throw err("origin-not-listed"); const { state } = await loadState(); const rec = state.projects[projectId]; if (!rec || !rec.move || rec.move.to.account !== account) return { ok: false }; finishMove(rec); await saveState(); return { ok: true }; } // ---- Settings › Theseus ID (§5.5) ---- async function overview() { if (!hasVault()) return { vault: "none" }; if (!getPurposeRoot()) return { vault: "locked" }; const { k, state } = await loadState(); const reg = await getRegistry(); const projects = []; for (const [projectId, rec] of Object.entries(state.projects)) { const list = reg.projects.get(projectId) || listCache.get(projectId)?.list || null; projects.push({ projectId, name: list ? list.name : projectId, firstParty: !!(list && list.kind === "first-party"), mode: rec.mode, gen: rec.gen, account: rec.account, firstSeen: rec.firstSeen, lastUsed: rec.lastUsed, origins: rec.origins, always: !!rec.always, moving: rec.move ? { to: rec.move.to, since: rec.move.since } : null, supportsMove: !!(list && list.supports.includes("move")), wallets: rec.wallets || [], }); } projects.sort((a, b) => String(b.lastUsed || "").localeCompare(String(a.lastUsed || ""))); return { vault: "unlocked", defaultMode: state.defaultMode, autoFirstParty: state.autoFirstParty, oneId: await accountFor(k, { mode: "one", gen: 0 }), projects, }; } async function update(fn) { const { k, state } = await loadState(); const r = await fn(state, k); await saveState(); return r === undefined ? { ok: true } : r; } const setDefaultMode = (mode) => { if (mode !== "one" && mode !== "project") throw err("bad-request", "mode"); return update((s) => { s.defaultMode = mode; }); // existing projects keep theirs (§6.3) }; const setAutoFirstParty = (on) => update((s) => { s.autoFirstParty = !!on; }); const setAlways = (projectId, on) => update((s) => { if (!s.projects[projectId]) throw err("unknown-project"); s.projects[projectId].always = !!on; }); const revoke = (projectId) => update((s) => { delete s.projects[projectId]; }); // Change a project's mode or generation. Never silent: the next sign-in // carries a move proof signed by both keys, and only projects that list // "move" can take one (§6.3). async function requestMove(projectId, { mode, gen }) { const list = await originList(projectId); return update(async (s, k) => { const rec = s.projects[projectId]; if (!rec) throw err("unknown-project"); if (!list || !list.supports.includes("move")) throw err("move-unsupported", `${list ? list.name : projectId} cannot move accounts to a new ID yet`); const to = { mode: mode || rec.mode, gen: Number.isInteger(gen) ? gen : rec.gen }; if (to.mode !== "one" && to.mode !== "project") throw err("bad-request", "mode"); to.account = await accountFor(k, { ...to, projectId }); if (to.account === rec.account) { rec.move = null; return { ok: true, unchanged: true }; } rec.move = { to, since: new Date(now()).toISOString() }; return { ok: true, to }; }); } const cancelMove = (projectId) => update((s) => { if (s.projects[projectId]) s.projects[projectId].move = null; }); const rotate = (projectId) => update((s) => s.projects[projectId] || null).then((rec) => { if (!rec) throw err("unknown-project"); return requestMove(projectId, { mode: rec.mode, gen: rec.gen + 1 }); }); async function recoveryKey() { const k = await keys(); if (!k) throw err("locked"); return k.rootHex; } return { signIn, moved, originList, overview, setDefaultMode, setAutoFirstParty, setAlways, revoke, requestMove, cancelMove, rotate, recoveryKey, forget, _test: { loadState, listCache }, }; } module.exports = { createTheseusIdHost, SILENT_PER_MIN };