From 3243add70e25376eb0d673ce7fa5628cd4ada184 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 20:48:07 +0200 Subject: [PATCH] =?UTF-8?q?Theseus=20ID=20in=20Theseus:=20window.theseusId?= =?UTF-8?q?.signIn=20and=20Settings=20=E2=80=BA=20Theseus=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dev/theseus-id.test.cjs | 153 ++++++++++++++++++++ lib/theseus-id.cjs | 310 ++++++++++++++++++++++++++++++++++++++++ main.js | 162 ++++++++++++++++++++- package.json | 9 ++ settings-preload.js | 11 ++ settings.html | 139 ++++++++++++++++++ theseus-id-preload.js | 66 +++++++++ 7 files changed, 847 insertions(+), 3 deletions(-) create mode 100644 dev/theseus-id.test.cjs create mode 100644 lib/theseus-id.cjs create mode 100644 theseus-id-preload.js diff --git a/dev/theseus-id.test.cjs b/dev/theseus-id.test.cjs new file mode 100644 index 00000000..7a8c591f --- /dev/null +++ b/dev/theseus-id.test.cjs @@ -0,0 +1,153 @@ +// node --test TheseusNavigator/dev/theseus-id.test.cjs +// lib/theseus-id.cjs with a fake vault, fake prompts and fake fetchers. +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); +const { createTheseusIdHost, SILENT_PER_MIN } = require("../lib/theseus-id.cjs"); + +const LIB = pathToFileURL(path.join(__dirname, "..", "..", "TheseusID", "lib", "index.mjs")).href; +async function loadLib() { + const lib = await import(LIB); + const { secp256k1 } = await import("@noble/curves/secp256k1.js"); + const { sha256 } = await import("@noble/hashes/sha2.js"); + const { ripemd160 } = await import("@noble/hashes/legacy.js"); + const { hkdf } = await import("@noble/hashes/hkdf.js"); + return { lib, crypto: lib.createCrypto({ secp256k1, sha256, ripemd160, hkdf }) }; +} + +const REGISTRY = { + v: 1, seq: 1, + projects: [ + { project: "hephaestus", name: "Hephaestus", origins: ["https://code.silentmode.st"], provider: { issuer: "https://accounts.silentmode.st", client_id: "hephaestus" }, supports: ["move"] }, + { project: "sirius", name: "Sirius", origins: ["https://sirius.x"] }, + ], +}; + +function setup({ unlocked = true, confirm, docs = {} } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tid-")); + const vault = { root: unlocked ? new Uint8Array(32).fill(5) : null }; + const prompts = []; + const host = createTheseusIdHost({ + file: path.join(dir, "theseus-id.json"), + loadLib, + getPurposeRoot: () => vault.root, + hasVault: () => true, + bundledRegistry: REGISTRY, + fetchOriginDoc: async (authority) => { if (!docs[authority]) throw new Error("404"); return docs[authority]; }, + ui: { + unlock: async () => { vault.root = new Uint8Array(32).fill(5); return true; }, + confirm: async (req) => { prompts.push(req); return confirm ? confirm(req) : { ok: true }; }, + }, + }); + return { host, vault, prompts, dir }; +} +const req = (over = {}) => ({ projectId: "hephaestus", nonce: "nonce-0123456789abcdef", origin: "https://code.silentmode.st", uri: "https://code.silentmode.st/login", gesture: true, ...over }); + +test("first sign-in prompts, later first-party sign-ins are silent", async () => { + const { host, prompts } = setup(); + const a = await host.signIn(req()); + assert.equal(prompts.length, 1); + assert.equal(prompts[0].first, true); + assert.match(a.account, /^tid:/); + const { lib, crypto } = await loadLib(); + assert.ok(crypto.verifyAddress(a.message, a.signature, a.account)); + assert.equal(lib.parseMessage(a.message).origin, "https://code.silentmode.st"); + const b = await host.signIn(req({ nonce: "nonce-0123456789abcdeg" })); + assert.equal(prompts.length, 1, "no second prompt"); + assert.equal(b.account, a.account, "same ID every time"); +}); + +test("origins outside the list are refused without any prompt", async () => { + const { host, prompts } = setup(); + await assert.rejects(host.signIn(req({ origin: "https://evil.example" })), { code: "origin-not-listed" }); + await assert.rejects(host.signIn(req({ origin: "https://navigate.st" })), { code: "origin-not-listed" }); + await assert.rejects(host.signIn(req({ projectId: "unknown" })), { code: "origin-not-listed" }); + assert.equal(prompts.length, 0); + // The listed provider may ask for Hephaestus's signature. + const viaProvider = await host.signIn(req({ origin: "https://accounts.silentmode.st" })); + assert.match(viaProvider.message, /Origin: https:\/\/accounts\.silentmode\.st/); +}); + +test("per-project IDs differ; One ID is shared", async () => { + const { host } = setup({ confirm: (r) => ({ ok: true, mode: r.projectId === "sirius" ? "one" : "project" }) }); + const h = await host.signIn(req()); + const s = await host.signIn(req({ projectId: "sirius", origin: "https://sirius.x", uri: "https://sirius.x/" })); + assert.notEqual(h.account, s.account); + const ov = await host.overview(); + assert.equal(ov.oneId, s.account, "sirius chose One ID"); + assert.deepEqual(ov.projects.map((p) => p.mode).sort(), ["one", "project"]); +}); + +test("locked vault: no gesture, no prompt; with a gesture, unlock first", async () => { + const { host, vault } = setup({ unlocked: false }); + await assert.rejects(host.signIn(req({ gesture: false })), { code: "locked" }); + assert.equal(vault.root, null); + const r = await host.signIn(req()); + assert.match(r.account, /^tid:/); +}); + +test("declining, busy and the silent-rate limit", async () => { + const { host, prompts } = setup({ confirm: () => ({ ok: false }) }); + await assert.rejects(host.signIn(req()), { code: "denied" }); + const s2 = setup(); + await s2.host.signIn(req()); + const both = await Promise.allSettled([s2.host.signIn(req({ nonce: "nonce-aaaaaaaaaaaaaaaa" })), s2.host.signIn(req({ nonce: "nonce-bbbbbbbbbbbbbbbb" }))]); + assert.ok(both.some((x) => x.status === "rejected" && x.reason.code === "busy")); + for (let i = 0; i < SILENT_PER_MIN + 2; i++) await s2.host.signIn(req({ nonce: "nonce-cccccccccccccc" + String(i).padStart(2, "0") })); + assert.ok(s2.prompts.length >= 2, "beyond the limit, sign-ins prompt again"); +}); + +test("a mode switch never changes the ID silently: move proof, then finish", async () => { + const { host } = setup(); + const first = await host.signIn(req()); + await host.setDefaultMode("one"); + const still = await host.signIn(req({ nonce: "nonce-dddddddddddddddd" })); + assert.equal(still.account, first.account, "default mode change leaves existing projects alone"); + await host.requestMove("hephaestus", { mode: "one" }); + const moving = await host.signIn(req({ nonce: "nonce-eeeeeeeeeeeeeeee" })); + assert.notEqual(moving.account, first.account); + assert.equal(moving.move.from, first.account); + const { lib, crypto } = await loadLib(); + const v = lib.createVerifier({ crypto, projectId: "hephaestus", origins: ["https://code.silentmode.st"], consumeNonce: () => true }); + const ok = await v.verifySignIn({ message: moving.message, signature: moving.signature, move: moving.move }); + assert.equal(ok.movedFrom, first.account); + await host.moved({ projectId: "hephaestus", origin: "https://code.silentmode.st", account: moving.account }); + const after = await host.signIn(req({ nonce: "nonce-ffffffffffffffff" })); + assert.equal(after.account, moving.account); + assert.equal(after.move, undefined); + // Sirius does not list "move": refused. + await host.signIn(req({ projectId: "sirius", origin: "https://sirius.x" })); + await assert.rejects(host.requestMove("sirius", { mode: "one" }), { code: "move-unsupported" }); +}); + +test("state is encrypted, survives a restart, and is unreadable with another vault", async () => { + const s = setup(); + const a = await s.host.signIn(req()); + const raw = fs.readFileSync(path.join(s.dir, "theseus-id.json"), "utf8"); + assert.ok(!raw.includes("hephaestus") && !raw.includes(a.account), "no plain project names or IDs on disk"); + s.host.forget(); + const ov = await s.host.overview(); + assert.equal(ov.projects[0].account, a.account); + s.vault.root = new Uint8Array(32).fill(9); s.host.forget(); + assert.deepEqual((await s.host.overview()).projects, []); +}); + +test("name-scoped projects fetch their list; BNS lists must be owner-signed", async () => { + const { lib, crypto } = await loadLib(); + const ownerKey = new Uint8Array(32).fill(3); + const owner = crypto.account(ownerKey, "bitcoincash"); + const body = { v: 1, project: "game.x", origins: ["https://game.x"] }; + const signed = { ...body, sig: crypto.sign(ownerKey, lib.canonicalJson(body)) }; + const { host } = setup({ docs: { + "game.x": { doc: signed, bns: true, owner }, + "blog.example.org": { doc: { v: 1, project: "sirius-press:blog.example.org", origins: ["https://blog.example.org"] }, bns: false }, + "bad.x": { doc: body, bns: true, owner }, + } }); + assert.match((await host.signIn(req({ projectId: "game.x", origin: "https://game.x" }))).account, /^tid:/); + assert.match((await host.signIn(req({ projectId: "sirius-press:blog.example.org", origin: "https://blog.example.org" }))).account, /^tid:/); + await assert.rejects(host.signIn(req({ projectId: "bad.x", origin: "https://bad.x" })), { code: "origin-list-unavailable" }); +}); diff --git a/lib/theseus-id.cjs b/lib/theseus-id.cjs new file mode 100644 index 00000000..f778e124 --- /dev/null +++ b/lib/theseus-id.cjs @@ -0,0 +1,310 @@ +// 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 }; diff --git a/main.js b/main.js index 3479be67..40669cba 100644 --- a/main.js +++ b/main.js @@ -893,7 +893,10 @@ const SETTINGS_ONLY = new Set([ "password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove", "password-setup", "password-status", "password-unlock", "password-update", "recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order", - "settings-open-panel", "settings-section", "tor-state", + "settings-open-panel", "settings-section", + "theseus-id-cancel-move", "theseus-id-move", "theseus-id-overview", "theseus-id-recovery-key", "theseus-id-revoke", + "theseus-id-rotate", "theseus-id-set-always", "theseus-id-set-auto", "theseus-id-set-default-mode", "theseus-id-unlock", + "tor-state", "vault-check-master", "vault-confirm", "vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock", // Raw seeds and WIFs. No page uses these (add-ons go through the // vaultImports shim in main), but an unguarded handler is reachable by any @@ -3607,6 +3610,7 @@ function liveHost(t) { // show/hide + display the count. Cheap; called on nav + vault unlock/lock. function emitPwAvailability() { refreshSigninSites(); + if (!vaultState && theseusIdInst) theseusIdInst.forget(); // drop the decrypted Theseus ID record on lock const t = activeTab(); const host = t ? liveHost(t) : ""; const count = pwMatchesForHost(host).length; @@ -8025,6 +8029,156 @@ function pwNeverFor(host) { const list = Array.isArray(settings.pwNeverSave) ? settings.pwNeverSave : []; if (!list.includes(host)) { settings.pwNeverSave = [...list, host].slice(-500); saveSettings(); } } + +// ---- Theseus ID (DESIGN-theseus-id.md) -------------------------------------- +// window.theseusId.signIn() on web pages (theseus-id-preload.js) and +// Settings › Theseus ID. The policy and the encrypted per-project record +// live in lib/theseus-id.cjs; the derivation, message and origin-list rules +// in TheseusID/lib (copied to resources/theseus-id/ in a packaged build). +const THESEUS_ID_LIB = app.isPackaged + ? path.join(RES_DIR, "theseus-id", "index.mjs") + : path.join(__dirname, "..", "TheseusID", "lib", "index.mjs"); +const THESEUS_ID_REGISTRY = app.isPackaged + ? path.join(RES_DIR, "theseus-id-projects.json") + : path.join(__dirname, "..", "TheseusID", "registry", "projects.json"); +let theseusIdInst = null; +function theseusId() { + if (theseusIdInst) return theseusIdInst; + const { createTheseusIdHost } = require("./lib/theseus-id.cjs"); + const registry = JSON.parse(fs.readFileSync(THESEUS_ID_REGISTRY, "utf8")); + // Development only: extra first-party test projects (e.g. a local page). + if (!app.isPackaged && process.env.THESEUS_ID_DEV_REGISTRY) { + try { registry.projects.push(...JSON.parse(fs.readFileSync(process.env.THESEUS_ID_DEV_REGISTRY, "utf8")).projects); } + catch (e) { console.warn("[theseus-id] dev registry:", e.message); } + } + theseusIdInst = createTheseusIdHost({ + file: path.join(app.getPath("userData"), "theseus-id.json"), + loadLib: async () => { + // ESM-only deps: resolve from the app tree, then import the file URL + // (the same way addons-host's hostImport does). + const { pathToFileURL } = require("node:url"); + const imp = (name) => import(pathToFileURL(require.resolve(name)).href); + const t0 = Date.now(); + const lib = await import(pathToFileURL(THESEUS_ID_LIB).href); + const [{ secp256k1 }, { sha256 }, { ripemd160 }, { hkdf }] = await Promise.all([ + imp("@noble/curves/secp256k1.js"), imp("@noble/hashes/sha2.js"), imp("@noble/hashes/legacy.js"), imp("@noble/hashes/hkdf.js"), + ]); + console.log(`[theseus-id] library loaded in ${Date.now() - t0} ms`); + return { lib, crypto: lib.createCrypto({ secp256k1, sha256, ripemd160, hkdf }) }; + }, + getPurposeRoot: () => (vaultState && vaultState.purposeRoot ? new Uint8Array(Buffer.from(vaultState.purposeRoot, "hex")) : null), + hasVault: () => fs.existsSync(vaultFile()), + bundledRegistry: registry, + fetchOriginDoc: theseusIdFetchOriginDoc, + ui: { + unlock: async ({ reason }) => (await requestVaultUnlock({ reason })).ok, + confirm: theseusIdConfirm, + }, + log: (...a) => console.log("[theseus-id]", ...a), + }); + return theseusIdInst; +} +// A name-scoped project's origin list: BNS first (owner-signed, owner from +// our own index), the web over TLS otherwise (§3.4). +async function theseusIdFetchOriginDoc(authority) { + const getJson = async (url) => { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), 10_000); + try { + const r = await contentFetch(url, { signal: ctl.signal, redirect: "error", headers: { accept: "application/json" } }); + if (r.status < 200 || r.status >= 300) throw new Error(`HTTP ${r.status}`); + if (r.buffer.length > 64 * 1024) throw new Error("origin list too large"); + return JSON.parse(r.buffer.toString("utf8")); + } finally { clearTimeout(timer); } + }; + const entry = isBnsHost(authority) ? await resolveHost(authority).catch(() => null) : null; + if (entry) { + if (!entry.owner) throw new Error(`no owner known for ${authority}`); + return { doc: await getJson(`${GATEWAY}/bns/${authority}/.well-known/theseus-id.json`), bns: true, owner: entry.owner }; + } + return { doc: await getJson(`https://${authority}/.well-known/theseus-id.json`), bns: false }; +} +const tidShort = (a) => (a && a.length > 20 ? `${a.slice(0, 12)}…${a.slice(-4)}` : a); +async function theseusIdConfirm(r) { + const modeLabel = (m) => (m === "one" ? `${tidShort(r.accounts ? r.accounts.one : r.account)} — your One ID` : `${tidShort(r.accounts ? r.accounts.project : r.account)} — an ID only ${r.name} sees`); + const opts = { + from: "Theseus ID", + title: `Sign in to ${r.name}?`, + origin: r.origin, + body: r.first + ? `A Theseus ID is your Silent Mode account, kept in your Theseus Vault. ${r.name} gets a signature from it, never a key, and can't see your wallets.` + : "", + rows: [ + ...(r.first ? [] : [{ label: "As", value: modeLabel(r.mode), mono: true }]), + { label: "Project", value: `${r.projectId}${r.firstParty ? " · Silent Mode" : ""} · verified for this site` }, + ], + checkbox: { id: "always", label: `Always sign me in to ${r.name}` }, + actions: [{ id: "signin", label: "Sign in", primary: true }, { id: "cancel", label: "Cancel" }], + }; + // The first sign-in is where the user picks which ID this project gets. + if (r.first) { + const order = r.mode === "one" ? ["one", "project"] : ["project", "one"]; + opts.select = { id: "mode", label: "Sign in as", options: order.map((m) => ({ value: m, label: modeLabel(m) })) }; + } + const pick = await showApprovalModal(opts, null, r.ctx && r.ctx.tabId != null ? r.ctx.tabId : null); + const parts = String(pick || "cancel").split("+"); + if (parts[0] !== "signin") return { ok: false }; + const modePart = parts.find((p) => p.startsWith("mode=")); + return { ok: true, always: parts.includes("always"), mode: modePart ? modePart.slice(5) : r.mode }; +} +function tidErr(err) { + const known = ["no-vault", "locked", "denied", "origin-not-listed", "origin-list-unavailable", "bad-request", "busy", "not-top-frame", "state-mismatch", "move-unsupported", "unknown-project"]; + const code = known.includes(err && err.code) ? err.code : "error"; + if (code === "error") console.warn("[theseus-id]", err && err.message); + return { ok: false, error: { code, message: code === "error" ? "Theseus ID failed" : String(err.message || code) } }; +} +// The caller must be the top frame of a web tab; the origin and URL come +// from what that frame has committed, never from the payload. +function tidCaller(e) { + const t = tabForSender(e.sender); + if (!t || t.settings || t.addonId) return { error: { code: "origin-not-listed", message: "this page cannot use Theseus ID" } }; + if (e.senderFrame !== e.sender.mainFrame) return { error: { code: "not-top-frame", message: "Theseus ID works only in the top frame" } }; + const url = e.sender.getURL(); + return { t, origin: pageOriginOf(url), uri: String(url).split("#")[0].replace(/^bns:\/\//i, "https://") }; +} +ipcMain.handle("theseus-id:signIn", async (e, payload) => { + const c = tidCaller(e); + if (c.error) return { ok: false, error: c.error }; + const p = payload && typeof payload === "object" ? payload : {}; + if (JSON.stringify(p).length > 4096) return { ok: false, error: { code: "bad-request", message: "request too large" } }; + try { + const result = await theseusId().signIn({ + projectId: p.projectId, nonce: p.nonce, statement: p.statement, requestId: p.requestId, + resources: p.resources, expiresIn: p.expiresIn, gesture: !!p.gesture, + origin: c.origin, uri: c.uri, ctx: { tabId: c.t.id }, + }); + return { ok: true, result }; + } catch (err) { return tidErr(err); } +}); +ipcMain.handle("theseus-id:moved", async (e, payload) => { + const c = tidCaller(e); + if (c.error) return { ok: false, error: c.error }; + try { return { ok: true, result: await theseusId().moved({ projectId: String(payload?.projectId || ""), account: String(payload?.account || ""), origin: c.origin }) }; } + catch (err) { return tidErr(err); } +}); +// Settings › Theseus ID +const tidSettings = (fn) => async (_e, ...args) => { try { return { ok: true, result: await fn(...args) }; } catch (err) { return tidErr(err); } }; +// mnemonicVault: whether a recovery phrase can rebuild the IDs (only mnemonic vaults carry messengerRoot). +ipcMain.handle("theseus-id-overview", tidSettings(async () => ({ ...(await theseusId().overview()), mnemonicVault: !!(vaultState && vaultState.messengerRoot) }))); +ipcMain.handle("theseus-id-set-default-mode", tidSettings((mode) => theseusId().setDefaultMode(mode))); +ipcMain.handle("theseus-id-set-auto", tidSettings((on) => theseusId().setAutoFirstParty(on))); +ipcMain.handle("theseus-id-set-always", tidSettings((pid, on) => theseusId().setAlways(String(pid), on))); +ipcMain.handle("theseus-id-revoke", tidSettings((pid) => theseusId().revoke(String(pid)))); +ipcMain.handle("theseus-id-move", tidSettings((pid, to) => theseusId().requestMove(String(pid), { mode: to && to.mode, gen: to && to.gen }))); +ipcMain.handle("theseus-id-cancel-move", tidSettings((pid) => theseusId().cancelMove(String(pid)))); +ipcMain.handle("theseus-id-rotate", tidSettings((pid) => theseusId().rotate(String(pid)))); +ipcMain.handle("theseus-id-unlock", tidSettings(() => requestVaultUnlock({ reason: "Open your Theseus ID." }))); +// The recovery key is the identity root: behind a fresh PIN / password check. +ipcMain.handle("theseus-id-recovery-key", tidSettings(async () => { + const c = await requestVaultUnlock({ confirm: true, reason: "Confirm it's you to show your Theseus ID recovery key." }); + if (!c.ok) throw Object.assign(new Error("cancelled"), { code: "denied" }); + return theseusId().recoveryKey(); +})); // The chrome sends arrow-up/down/enter through so the picker can move its // selection cursor without stealing focus from the address input. ipcMain.handle("address-cursor", (_e, dir) => { @@ -8924,13 +9078,15 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { // Add-on page-inject bridges ride the same session-wide slot; the // preload asks main which (if any) apply to the tab it runs in. const injectPreload = path.join(__dirname, "addon-inject-preload.js"); + // window.theseusId (DESIGN-theseus-id.md §5.1); main answers only top frames of web tabs. + const tidPreload = path.join(__dirname, "theseus-id-preload.js"); const ses = session.defaultSession; if (typeof ses.registerPreloadScript === "function") { // Electron ≥ 35: setPreloads is deprecated in favour of per-script registration. - for (const filePath of [bcnrPreload, injectPreload]) ses.registerPreloadScript({ type: "frame", filePath }); + for (const filePath of [bcnrPreload, injectPreload, tidPreload]) ses.registerPreloadScript({ type: "frame", filePath }); } else { const existing = ses.getPreloads(); - const wanted = [bcnrPreload, injectPreload].filter((p) => !existing.includes(p)); + const wanted = [bcnrPreload, injectPreload, tidPreload].filter((p) => !existing.includes(p)); if (wanted.length) ses.setPreloads([...existing, ...wanted]); } } catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); } diff --git a/package.json b/package.json index 1eee05ab..b90d1bbc 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "approval.html", "unlock.html", "unlock-preload.js", + "theseus-id-preload.js", "auth-prompt-preload.js", "auth-prompt.html", "addon-inject-preload.js", @@ -125,6 +126,14 @@ "from": "../Argus/src/lib/password-vault.js", "to": "password-vault.mjs" }, + { + "from": "../TheseusID/lib", + "to": "theseus-id" + }, + { + "from": "../TheseusID/registry/projects.json", + "to": "theseus-id-projects.json" + }, { "from": "../Argus/src/lib/bns-index-core.js", "to": "bns-index-core.mjs" diff --git a/settings-preload.js b/settings-preload.js index cbe8af67..ce36d635 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -32,6 +32,17 @@ contextBridge.exposeInMainWorld("cfg", { pinCheckMaster: (masterPassword) => ipcRenderer.invoke("vault-check-master", masterPassword), // Asks for the PIN or master password even while the vault is open. pwConfirm: (reason) => ipcRenderer.invoke("vault-confirm", reason), + // Settings › Theseus ID. Each resolves { ok, result } or { ok: false, error: { code, message } }. + tidOverview: () => ipcRenderer.invoke("theseus-id-overview"), + tidSetDefaultMode: (mode) => ipcRenderer.invoke("theseus-id-set-default-mode", mode), + tidSetAuto: (on) => ipcRenderer.invoke("theseus-id-set-auto", !!on), + tidSetAlways: (projectId, on) => ipcRenderer.invoke("theseus-id-set-always", projectId, !!on), + tidRevoke: (projectId) => ipcRenderer.invoke("theseus-id-revoke", projectId), + tidMove: (projectId, to) => ipcRenderer.invoke("theseus-id-move", projectId, to), + tidCancelMove: (projectId) => ipcRenderer.invoke("theseus-id-cancel-move", projectId), + tidRotate: (projectId) => ipcRenderer.invoke("theseus-id-rotate", projectId), + tidUnlock: () => ipcRenderer.invoke("theseus-id-unlock"), + tidRecoveryKey: () => ipcRenderer.invoke("theseus-id-recovery-key"), pinUnlock: () => ipcRenderer.invoke("vault-pin-unlock"), // Main asks settings to jump to a specific sidebar section (e.g. from the // engine picker's "Search settings…" click). Emits the section id string. diff --git a/settings.html b/settings.html index e5dfe9b1..56e1dbad 100644 --- a/settings.html +++ b/settings.html @@ -289,6 +289,7 @@ Language Search Passwords + Theseus ID Performance Privacy Plug-ins @@ -684,6 +685,47 @@
+ + @@ -2410,6 +2452,103 @@ document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh); }); + // ---- Settings › Theseus ID (DESIGN-theseus-id.md §5.5) ---- + // Built with createElement/textContent only: project names come from + // origin lists fetched from the projects themselves. + (() => { + const $ = (id) => document.getElementById(id); + const el = (tag, attrs = {}, ...kids) => { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") n.className = v; + else if (k === "style") n.style.cssText = v; + else if (k.startsWith("on")) n.addEventListener(k.slice(2), v); + else n.setAttribute(k, v); + } + for (const c of kids.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c)); + return n; + }; + const short = (a) => (a && a.length > 20 ? `${a.slice(0, 12)}…${a.slice(-4)}` : a || ""); + const when = (iso) => { if (!iso) return "never"; const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }; + const unwrap = async (p) => { const r = await p; if (!r || !r.ok) throw Object.assign(new Error(r?.error?.message || "failed"), { code: r?.error?.code }); return r.result; }; + let view = null; + function show(which) { for (const id of ["tidNoVault", "tidLocked", "tidMain"]) $(id).hidden = id !== which; } + async function refresh() { + let ov; + try { ov = await unwrap(C.tidOverview()); } catch (e) { show("tidLocked"); return; } + view = ov; + if (ov.vault === "none") return show("tidNoVault"); + if (ov.vault === "locked") return show("tidLocked"); + show("tidMain"); + for (const r of document.querySelectorAll('input[name="tidMode"]')) r.checked = r.value === ov.defaultMode; + $("tidOneId").textContent = ov.oneId; + $("tidAuto").checked = !!ov.autoFirstParty; + $("tidRecoveryText").textContent = ov.mnemonicVault + ? "Your IDs come back from your vault's recovery phrase on any device. The recovery key below does the same job if you keep it instead." + : "Your vault was made without a recovery phrase, so only this key (or a backup of the vault file with its master password) brings your IDs back. Write it down somewhere safe."; + renderProjects(ov.projects || []); + } + function renderProjects(list) { + const box = $("tidProjects"); + box.replaceChildren(); + if (!list.length) { box.append(el("div", { class: "cempty", style: "padding:12px 0" }, "No projects yet. When a site asks you to sign in with Theseus ID, it shows up here.")); return; } + for (const p of list) { + const other = p.mode === "one" ? "project" : "one"; + const act = async (fn, after) => { + try { await unwrap(fn()); } catch (e) { alert(e.message); } + if (after) after(); + refresh(); + }; + const row = el("div", { class: "row", style: "flex-direction:column;align-items:stretch;gap:6px" }, + el("div", { style: "display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap" }, + el("div", {}, el("b", {}, p.name), " ", el("span", { class: "pmuted", style: "font-size:12px" }, p.projectId, p.firstParty ? " · Silent Mode" : "")), + el("span", { class: "pmuted", style: "font-size:12px" }, "last used ", when(p.lastUsed))), + el("div", { class: "pmuted", style: "font-size:12.5px" }, + p.mode === "one" ? "Uses your One ID " : "Uses an ID only this project sees ", + el("code", { title: p.account }, short(p.account)), + p.origins && p.origins.length ? ` · from ${p.origins.join(", ")}` : ""), + p.moving ? el("div", { style: "font-size:12.5px;color:var(--acid-text)" }, + `Moving to ${short(p.moving.to.account)} — ${p.name} confirms the move the next time you sign in. `, + el("button", { class: "btn ghost", type: "button", style: "padding:2px 8px;font-size:12px", onclick: () => act(() => C.tidCancelMove(p.projectId)) }, "Cancel move")) : null, + el("div", { style: "display:flex;gap:6px;flex-wrap:wrap;align-items:center" }, + el("label", { class: "polrow", style: "margin-right:auto" }, + (() => { const c = el("input", { type: "checkbox" }); c.checked = p.always; c.addEventListener("change", () => act(() => C.tidSetAlways(p.projectId, c.checked))); return c; })(), + el("span", {}, "Always sign me in")), + el("button", { class: "btn ghost", type: "button", title: p.supportsMove ? "" : `${p.name} can't move accounts to a new ID yet`, + ...(p.supportsMove ? {} : { disabled: "" }), + onclick: () => { if (confirm(`Move ${p.name} to ${other === "one" ? "your One ID" : "an ID only it sees"}? Your account there moves with you: the next sign-in proves both IDs are yours.`)) act(() => C.tidMove(p.projectId, { mode: other })); } }, + other === "one" ? "Use my One ID" : "Use a private ID"), + el("button", { class: "btn ghost", type: "button", ...(p.supportsMove ? {} : { disabled: "" }), + onclick: () => { if (confirm(`Give ${p.name} a brand-new ID? Do this if you think this ID's key leaked. Your account moves to the new ID at the next sign-in.`)) act(() => C.tidRotate(p.projectId)); } }, "New ID"), + el("button", { class: "btn ghost", type: "button", + onclick: () => { if (confirm(`Stop signing in to ${p.name}? Theseus forgets this project and won't sign you in there again until you approve it. Your account at ${p.name} still exists.`)) act(() => C.tidRevoke(p.projectId)); } }, "Revoke"))); + box.append(row); + } + } + for (const r of document.querySelectorAll('input[name="tidMode"]')) r.addEventListener("change", async () => { + try { await unwrap(C.tidSetDefaultMode(r.value)); } catch (e) { alert(e.message); } + const using = (view && view.projects || []).filter((p) => p.mode !== r.value); + if (using.length) alert(`New projects will use ${r.value === "one" ? "your One ID" : "a private ID each"}. The ${using.length} project${using.length === 1 ? "" : "s"} you already use keep their IDs; change them one by one below.`); + refresh(); + }); + $("tidAuto").addEventListener("change", async () => { try { await unwrap(C.tidSetAuto($("tidAuto").checked)); } catch (e) { alert(e.message); } refresh(); }); + $("tidCopyOne").onclick = async () => { try { await navigator.clipboard.writeText($("tidOneId").textContent); $("tidCopyOne").textContent = "Copied"; setTimeout(() => ($("tidCopyOne").textContent = "Copy"), 1500); } catch {} }; + $("tidUnlockBtn").onclick = async () => { await C.tidUnlock(); refresh(); }; + $("tidRecoveryBtn").onclick = async () => { + try { + const key = await unwrap(C.tidRecoveryKey()); + $("tidRecoveryKey").textContent = key; + $("tidRecoveryOut").hidden = false; + } catch (e) { if (e.code !== "denied") alert(e.message); } + }; + // The recovery key never stays on screen once the user leaves the page. + document.addEventListener("section", (e) => { + $("tidRecoveryOut").hidden = true; $("tidRecoveryKey").textContent = ""; + if (e.detail === "theseusid") refresh(); + }); + if (location.hash === "#theseusid") refresh(); + })(); + // ---- Add-ons management ---- const addonsList = document.getElementById("addonsList"); // Per-addon update state, keyed by addon id. Populated by loadAddonUpdates() diff --git a/theseus-id-preload.js b/theseus-id-preload.js new file mode 100644 index 00000000..8adb0af2 --- /dev/null +++ b/theseus-id-preload.js @@ -0,0 +1,66 @@ +// window.theseusId — Theseus ID for web pages (DESIGN-theseus-id.md §5.1). +// +// const r = await theseusId.signIn({ projectId: "hephaestus", nonce }); +// // r = { v, projectId, origin, account: "tid:q…", message, signature, scheme: "bip137", move? } +// // send { message, signature, move } to your server; verify with TheseusID/lib/verify.mjs +// +// Registered session-wide. The page passes fields, never message text; +// Theseus writes the message, takes the origin from the frame it committed, +// and answers only the top frame of a web tab. Failures reject with an Error +// whose `code` is one of: no-vault, locked, denied, origin-not-listed, +// origin-list-unavailable, bad-request, busy, not-top-frame, error. +const { contextBridge, ipcRenderer } = require("electron"); + +if (/^(https?|bns):$/.test(location.protocol)) { + // Errors lose custom properties crossing the bridge, so the code rides in + // the message as "[code] text" and is copied back onto the Error here, in + // the page's world. + const call = async (channel, payload) => { + const r = await ipcRenderer.invoke(channel, payload); + if (r && r.ok) return r.result; + const code = (r && r.error && r.error.code) || "error"; + throw new Error(`[${code}] ${(r && r.error && r.error.message) || "Theseus ID failed"}`); + }; + const str = (v, max) => (v == null ? undefined : String(v).slice(0, max)); + // A locked vault is only unlocked for a page the user just clicked or + // typed in. navigator.userActivation is not enough: a page Theseus opens + // with loadURL starts out "activated", so it could pop the vault prompt on + // load. These listeners run in the preload's world and count only trusted + // events, which a page cannot synthesise. + let lastInput = 0; + for (const type of ["pointerdown", "keydown"]) { + window.addEventListener(type, (e) => { if (e.isTrusted) lastInput = Date.now(); }, true); + } + const recentInput = () => Date.now() - lastInput < 5000; + const api = { + version: 1, + signIn: (opts = {}) => call("theseus-id:signIn", { + projectId: str(opts.projectId, 300) ?? "", + nonce: str(opts.nonce, 200) ?? "", + statement: str(opts.statement, 200), + requestId: str(opts.requestId, 64), + resources: Array.isArray(opts.resources) ? opts.resources.slice(0, 8).map((x) => String(x).slice(0, 2048)) : undefined, + expiresIn: Number.isFinite(Number(opts.expiresIn)) ? Number(opts.expiresIn) : undefined, + // Read here, in the preload's world, so a page cannot claim a click it did not get. + gesture: recentInput() && !!(navigator.userActivation && navigator.userActivation.isActive), + }), + // After the project's server accepted a move proof (§6.3). + moved: (opts = {}) => call("theseus-id:moved", { projectId: str(opts.projectId, 300) ?? "", account: str(opts.account, 100) ?? "" }), + }; + try { contextBridge.exposeInMainWorld("theseusIdBridge", api); } catch {} + // A thin wrapper in the page's own world turns "[code] text" into err.code. + try { + const { webFrame } = require("electron"); + webFrame.executeJavaScript(`(() => { + const b = window.theseusIdBridge; if (!b || window.theseusId) return; + const wrap = (fn) => (...a) => fn(...a).catch((e) => { + const m = /^\\[([a-z-]+)\\] ([\\s\\S]*)$/.exec(String(e && e.message || "")); + const err = new Error(m ? m[2] : String(e && e.message || e)); + err.code = m ? m[1] : "error"; + throw err; + }); + Object.defineProperty(window, "theseusId", { value: Object.freeze({ version: b.version, signIn: wrap(b.signIn), moved: wrap(b.moved) }), enumerable: true }); + try { delete window.theseusIdBridge; } catch {} + })()`); + } catch {} +}