theseus/theseus-id-preload.js

67 lines
3.6 KiB
JavaScript
Raw Normal View History

Theseus ID in Theseus: window.theseusId.signIn and Settings › Theseus ID Pages of Silent Mode projects can now sign the user in with their Theseus ID instead of a wallet phrase typed into the page. Theseus writes the sign-in message itself, takes the origin from the committed top frame, and signs as a project only on an origin that project's list includes, so a phishing page cannot get another project's signature and no page can use the ID key to sign anything else. - lib/theseus-id.cjs: the policy (first sign-in always asks and lets the user pick a private or One ID; Silent Mode projects are silent after that while the vault is open; per-site "always"; 10 silent signatures per minute per origin), the per-project record encrypted under a key derived from the vault, origin-list fetching with a 1 h cache and a 7-day stale fallback, and ID moves that send a proof signed by both keys and only finish once the project confirms. - A locked vault is unlocked only for a page the user just clicked or typed in: navigator.userActivation alone is true on load for pages opened with loadURL, which would let a page pop the vault prompt by itself. - Settings › Theseus ID: default mode, One ID, automatic sign-in toggle, signed-in projects (always, change ID, new ID, revoke) and a recovery key behind a fresh PIN / password check. - TheseusID/registry/projects.json is the first-party list (Hephaestus, Sirius, Pithos); it and TheseusID/lib ship as extraResources. - Token-aware cashaddrs (BNS owners) now decode for owner-signed lists. Verified on a scratch profile against a local test project whose server checks signatures with TheseusID/lib/verify.mjs: locked vault on load gives "locked" with no prompt, first sign-in prompt, silent second sign-in, a claimed foreign project refused without a prompt, an ID move that keeps the project's account, and the recovery key behind the confirm prompt.
2026-10-04 20:48:07 +02:00
// 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 {}
}