theseus/lib/theseus-id.cjs

311 lines
15 KiB
JavaScript
Raw Normal View History

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