311 lines
15 KiB
JavaScript
311 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 };
|