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.
310 lines
15 KiB
JavaScript
310 lines
15 KiB
JavaScript
// 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 };
|