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");
|
|
|
|
|
|
2026-10-05 20:34:17 +02:00
|
|
|
// Top frame only (main refuses iframes anyway): web tabs run preloads in
|
|
|
|
|
// iframes too, for the password hooks.
|
|
|
|
|
if (window.top === window && /^(https?|bns):$/.test(location.protocol)) {
|
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
|
|
|
// 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 {}
|
|
|
|
|
}
|