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.
This commit is contained in:
Local Dev 2026-10-04 20:48:07 +02:00
parent a6f7b335a5
commit 3243add70e
7 changed files with 847 additions and 3 deletions

153
dev/theseus-id.test.cjs Normal file
View file

@ -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" });
});

310
lib/theseus-id.cjs Normal file
View file

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

162
main.js
View file

@ -893,7 +893,10 @@ const SETTINGS_ONLY = new Set([
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove", "password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
"password-setup", "password-status", "password-unlock", "password-update", "password-setup", "password-status", "password-unlock", "password-update",
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order", "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", "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 // 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 // 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. // show/hide + display the count. Cheap; called on nav + vault unlock/lock.
function emitPwAvailability() { function emitPwAvailability() {
refreshSigninSites(); refreshSigninSites();
if (!vaultState && theseusIdInst) theseusIdInst.forget(); // drop the decrypted Theseus ID record on lock
const t = activeTab(); const t = activeTab();
const host = t ? liveHost(t) : ""; const host = t ? liveHost(t) : "";
const count = pwMatchesForHost(host).length; const count = pwMatchesForHost(host).length;
@ -8025,6 +8029,156 @@ function pwNeverFor(host) {
const list = Array.isArray(settings.pwNeverSave) ? settings.pwNeverSave : []; const list = Array.isArray(settings.pwNeverSave) ? settings.pwNeverSave : [];
if (!list.includes(host)) { settings.pwNeverSave = [...list, host].slice(-500); saveSettings(); } 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 // The chrome sends arrow-up/down/enter through so the picker can move its
// selection cursor without stealing focus from the address input. // selection cursor without stealing focus from the address input.
ipcMain.handle("address-cursor", (_e, dir) => { 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 // 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. // preload asks main which (if any) apply to the tab it runs in.
const injectPreload = path.join(__dirname, "addon-inject-preload.js"); 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; const ses = session.defaultSession;
if (typeof ses.registerPreloadScript === "function") { if (typeof ses.registerPreloadScript === "function") {
// Electron ≥ 35: setPreloads is deprecated in favour of per-script registration. // 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 { } else {
const existing = ses.getPreloads(); 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]); if (wanted.length) ses.setPreloads([...existing, ...wanted]);
} }
} catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); } } catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); }

View file

@ -80,6 +80,7 @@
"approval.html", "approval.html",
"unlock.html", "unlock.html",
"unlock-preload.js", "unlock-preload.js",
"theseus-id-preload.js",
"auth-prompt-preload.js", "auth-prompt-preload.js",
"auth-prompt.html", "auth-prompt.html",
"addon-inject-preload.js", "addon-inject-preload.js",
@ -125,6 +126,14 @@
"from": "../Argus/src/lib/password-vault.js", "from": "../Argus/src/lib/password-vault.js",
"to": "password-vault.mjs" "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", "from": "../Argus/src/lib/bns-index-core.js",
"to": "bns-index-core.mjs" "to": "bns-index-core.mjs"

View file

@ -32,6 +32,17 @@ contextBridge.exposeInMainWorld("cfg", {
pinCheckMaster: (masterPassword) => ipcRenderer.invoke("vault-check-master", masterPassword), pinCheckMaster: (masterPassword) => ipcRenderer.invoke("vault-check-master", masterPassword),
// Asks for the PIN or master password even while the vault is open. // Asks for the PIN or master password even while the vault is open.
pwConfirm: (reason) => ipcRenderer.invoke("vault-confirm", reason), 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"), pinUnlock: () => ipcRenderer.invoke("vault-pin-unlock"),
// Main asks settings to jump to a specific sidebar section (e.g. from the // 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. // engine picker's "Search settings…" click). Emits the section id string.

View file

@ -289,6 +289,7 @@
<a data-sec="language">Language</a> <a data-sec="language">Language</a>
<a data-sec="search">Search</a> <a data-sec="search">Search</a>
<a data-sec="passwords">Passwords</a> <a data-sec="passwords">Passwords</a>
<a data-sec="theseusid">Theseus ID</a>
<a data-sec="performance">Performance</a> <a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a> <a data-sec="privacy">Privacy</a>
<a data-sec="plugins">Plug-ins</a> <a data-sec="plugins">Plug-ins</a>
@ -684,6 +685,47 @@
<div class="ctl"><button id="pwNeverClear" class="btn ghost" type="button">Clear list</button></div> <div class="ctl"><button id="pwNeverClear" class="btn ghost" type="button">Clear list</button></div>
</div> </div>
</section> </section>
<!-- THESEUS ID: DESIGN-theseus-id.md §5.5 -->
<section id="theseusid" hidden>
<h1>Theseus ID</h1>
<p class="lede">Your Silent Mode account, kept in your Theseus Vault. Sign in once and Silent Mode projects know you. A project gets a signature from your ID, never a key, and it can't see your wallets unless you link one.</p>
<div id="tidNoVault" hidden>
<div class="row">
<div class="txt"><div class="t">Set up the Theseus Vault first</div><div class="d">Your Theseus ID is made from your vault, so the same recovery phrase gives you the same ID on every device.</div></div>
<div class="ctl"><button class="btn" type="button" data-go="passwords">Set up the vault</button></div>
</div>
</div>
<div id="tidLocked" hidden>
<div class="row">
<div class="txt"><div class="t">Your vault is locked</div><div class="d">Unlock it to see the projects you sign in to.</div></div>
<div class="ctl"><button id="tidUnlockBtn" class="btn" type="button">Unlock</button></div>
</div>
</div>
<div id="tidMain" hidden>
<h2 class="sub">How projects see you</h2>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<label class="polrow"><input type="radio" name="tidMode" value="project"><span><b>A different ID for each project</b> <span class="pmuted">— recommended. Projects can't tell your IDs belong to the same person.</span></span></label>
<label class="polrow"><input type="radio" name="tidMode" value="one"><span><b>One ID for every project</b> <span class="pmuted">— projects can recognise you as the same person.</span></span></label>
<div class="pmuted" style="font-size:12.5px">This applies to projects you haven't signed in to yet. A project you already use keeps its ID unless you change it below.</div>
</div>
<div class="row">
<div class="txt"><div class="t">Your One ID</div><div class="d"><code id="tidOneId" style="word-break:break-all"></code></div></div>
<div class="ctl"><button id="tidCopyOne" class="btn ghost" type="button">Copy</button></div>
</div>
<div class="row">
<div class="txt"><div class="t">Sign in to Silent Mode projects automatically</div><div class="d">After the first time, Hephaestus, Sirius, Pithos and other Silent Mode projects sign you in without asking while your vault is unlocked. Other sites always ask, unless you tick "Always sign me in" for them.</div></div>
<label class="sw"><input type="checkbox" id="tidAuto"><span class="track"><span class="knob"></span></span></label>
</div>
<h2 class="sub">Signed-in projects</h2>
<div id="tidProjects"></div>
<h2 class="sub">Recovery</h2>
<div class="row">
<div class="txt"><div class="t">If you lose this device</div><div class="d" id="tidRecoveryText"></div></div>
<div class="ctl"><button id="tidRecoveryBtn" class="btn ghost" type="button">Show recovery key</button></div>
</div>
<div id="tidRecoveryOut" class="row" hidden style="flex-direction:column;align-items:stretch"><code id="tidRecoveryKey" style="word-break:break-all"></code><div class="pmuted" style="font-size:12.5px">This 64-character key restores your Theseus IDs. Keep it somewhere safe and private: anyone with it can sign in as you. It is a secret key, not a recovery phrase.</div></div>
</div>
</section>
<!-- NAMING --> <!-- NAMING -->
<!-- Registries moved into General (above). --> <!-- Registries moved into General (above). -->
@ -2410,6 +2452,103 @@
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh); 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 ---- // ---- Add-ons management ----
const addonsList = document.getElementById("addonsList"); const addonsList = document.getElementById("addonsList");
// Per-addon update state, keyed by addon id. Populated by loadAddonUpdates() // Per-addon update state, keyed by addon id. Populated by loadAddonUpdates()

66
theseus-id-preload.js Normal file
View file

@ -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 {}
}