theseus/lib/theseus-id.cjs
Local Dev 3243add70e Theseus ID in Theseus: window.theseusId.signIn and Settings › Theseus ID
Pages of Silent Mode projects can now sign the user in with their Theseus
ID instead of a wallet phrase typed into the page. Theseus writes the
sign-in message itself, takes the origin from the committed top frame, and
signs as a project only on an origin that project's list includes, so a
phishing page cannot get another project's signature and no page can use
the ID key to sign anything else.

- lib/theseus-id.cjs: the policy (first sign-in always asks and lets the
  user pick a private or One ID; Silent Mode projects are silent after
  that while the vault is open; per-site "always"; 10 silent signatures per
  minute per origin), the per-project record encrypted under a key derived
  from the vault, origin-list fetching with a 1 h cache and a 7-day stale
  fallback, and ID moves that send a proof signed by both keys and only
  finish once the project confirms.
- A locked vault is unlocked only for a page the user just clicked or typed
  in: navigator.userActivation alone is true on load for pages opened with
  loadURL, which would let a page pop the vault prompt by itself.
- Settings › Theseus ID: default mode, One ID, automatic sign-in toggle,
  signed-in projects (always, change ID, new ID, revoke) and a recovery key
  behind a fresh PIN / password check.
- TheseusID/registry/projects.json is the first-party list (Hephaestus,
  Sirius, Pithos); it and TheseusID/lib ship as extraResources.
- Token-aware cashaddrs (BNS owners) now decode for owner-signed lists.

Verified on a scratch profile against a local test project whose server
checks signatures with TheseusID/lib/verify.mjs: locked vault on load gives
"locked" with no prompt, first sign-in prompt, silent second sign-in, a
claimed foreign project refused without a prompt, an ID move that keeps the
project's account, and the recovery key behind the confirm prompt.
2026-10-04 20:48:07 +02:00

310 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };