-
🧩${esc(req.addonName || req.addonId)}·asks for your approval
+ ${req.builtin
+ ? `
🔑${esc(req.addonName || "Theseus")}
`
+ : `
🧩${esc(req.addonName || req.addonId)}·asks for your approval
`}
${esc(req.title || "Approve?")}
${req.origin ? `
from${esc(req.origin)}
` : ""}
${req.body ? `
${esc(req.body)}
` : ""}
diff --git a/dev/signin-sites.test.cjs b/dev/signin-sites.test.cjs
new file mode 100644
index 00000000..dc4b96fe
--- /dev/null
+++ b/dev/signin-sites.test.cjs
@@ -0,0 +1,49 @@
+// node --test TheseusNavigator/dev/signin-sites.test.cjs
+"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 { createSigninSites, keepOrigins, normHost } = require("../lib/signin-sites.cjs");
+
+const fakeSafe = {
+ isEncryptionAvailable: () => true,
+ getSelectedStorageBackend: () => "gnome_libsecret",
+ encryptString: (s) => Buffer.from("SEALED:" + s),
+ decryptString: (b) => { const s = b.toString(); if (!s.startsWith("SEALED:")) throw new Error("bad seal"); return s.slice(7); },
+};
+const tmpFile = () => path.join(fs.mkdtempSync(path.join(os.tmpdir(), "sis-")), "signin-sites.json");
+
+test("keepOrigins: www twin for names, none for IPs or one-label hosts", () => {
+ assert.deepEqual(keepOrigins(["github.com"]).sort(),
+ ["http://github.com", "http://www.github.com", "https://github.com", "https://www.github.com"]);
+ assert.deepEqual(keepOrigins(["www.example.org"]).sort(),
+ ["http://example.org", "http://www.example.org", "https://example.org", "https://www.example.org"]);
+ assert.deepEqual(keepOrigins(["127.0.0.1"]).sort(), ["http://127.0.0.1", "https://127.0.0.1"]);
+ assert.deepEqual(keepOrigins(["localhost"]).sort(), ["http://localhost", "https://localhost"]);
+ assert.deepEqual(keepOrigins(["bad host", "", "a/b", "evil.com:80"]), []);
+});
+
+test("normHost lower-cases and trims a trailing dot", () => {
+ assert.equal(normHost("GitHub.COM."), "github.com");
+ assert.equal(normHost("x y"), "");
+});
+
+test("the list is sealed on disk and survives a reload", () => {
+ const file = tmpFile();
+ const a = createSigninSites({ file, safeStorage: fakeSafe });
+ assert.equal(a.save(["b.com", "a.com", "a.com", "nope nope"]), true);
+ assert.ok(!fs.readFileSync(file, "utf8").includes("a.com"), "hostnames must not be readable in the file");
+ const b = createSigninSites({ file, safeStorage: fakeSafe });
+ assert.deepEqual(b.load(), ["a.com", "b.com"]);
+});
+
+test("without an OS keystore nothing is stored", () => {
+ const file = tmpFile();
+ const noSafe = { isEncryptionAvailable: () => false };
+ const s = createSigninSites({ file, safeStorage: noSafe });
+ assert.equal(s.save(["a.com"]), false);
+ assert.equal(fs.existsSync(file), false);
+ assert.deepEqual(createSigninSites({ file, safeStorage: noSafe }).load(), []);
+});
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/dev/vault-pin.test.cjs b/dev/vault-pin.test.cjs
new file mode 100644
index 00000000..6406e769
--- /dev/null
+++ b/dev/vault-pin.test.cjs
@@ -0,0 +1,66 @@
+// node --test TheseusNavigator/dev/vault-pin.test.cjs
+// lib/vault-pin.cjs with a fake OS keystore and no TPM, so a wrong PIN here
+// never costs a real TPM dictionary-attack strike.
+"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 { createVaultPin, MAX_FAILS, PIN_MIN, PIN_MAX } = require("../lib/vault-pin.cjs");
+
+const fakeSafe = {
+ isEncryptionAvailable: () => true,
+ getSelectedStorageBackend: () => "gnome_libsecret",
+ encryptString: (s) => Buffer.from("SEALED:" + s),
+ decryptString: (b) => { const s = b.toString(); if (!s.startsWith("SEALED:")) throw new Error("bad seal"); return s.slice(7); },
+};
+const noTpm = { supported: () => false };
+const make = () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vpin-"));
+ const file = path.join(dir, "vault-pin.json");
+ return Object.assign(createVaultPin({ file, safeStorage: fakeSafe, tpm: noTpm }), { file });
+};
+
+test("lengths 6 to 8 are accepted, others refused", async () => {
+ assert.equal(PIN_MIN, 6); assert.equal(PIN_MAX, 8);
+ const p = make();
+ for (const bad of ["12345", "123456789", "12a456", ""]) await assert.rejects(p.set(bad, "pw"), /6 to 8 digits/);
+ for (const good of ["123456", "1234567", "12345678"]) {
+ await p.set(good, "master");
+ assert.equal(p.status().length, good.length);
+ assert.equal(await p.open(good), "master");
+ }
+});
+
+test("a wrong-length PIN costs no strike", async () => {
+ const p = make();
+ await p.set("12345678", "master");
+ for (let i = 0; i < MAX_FAILS + 2; i++) {
+ await assert.rejects(p.open("123456"), (e) => e.code === "wrong-length" && e.length === 8);
+ }
+ assert.equal(p.status().fails, 0);
+ assert.equal(await p.open("12345678"), "master");
+});
+
+test("wrong PINs of the right length still lock after MAX_FAILS", async () => {
+ const p = make();
+ await p.set("1234567", "master");
+ for (let i = 1; i < MAX_FAILS; i++) await assert.rejects(p.open("7654321"), (e) => e.code === "wrong-pin" && e.remaining === MAX_FAILS - i);
+ await assert.rejects(p.open("7654321"), (e) => e.code === "locked");
+ await assert.rejects(p.open("1234567"), (e) => e.code === "locked");
+});
+
+test("records from 6-only builds (no len) read as 6 digits", async () => {
+ const p = make();
+ await p.set("123456", "master");
+ // Strip len, as an older build would have written it.
+ const rec = JSON.parse(fs.readFileSync(p.file, "utf8"));
+ const blob = JSON.parse(fakeSafe.decryptString(Buffer.from(rec.data, "base64")));
+ delete blob.len;
+ rec.data = fakeSafe.encryptString(JSON.stringify(blob)).toString("base64");
+ fs.writeFileSync(p.file, JSON.stringify(rec));
+ assert.equal(p.status().length, 6);
+ assert.equal(await p.open("123456"), "master");
+ await assert.rejects(p.open("1234567"), (e) => e.code === "wrong-length");
+});
diff --git a/home-preload.js b/home-preload.js
index 10d76a37..5e94f633 100644
--- a/home-preload.js
+++ b/home-preload.js
@@ -31,3 +31,76 @@ contextBridge.exposeInMainWorld("errorpage", {
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
});
+
+// ---- Password manager hooks -------------------------------------------------
+// Runs in this preload's isolated world on every web page in a tab; nothing
+// is exposed to the page. Two reports go to main, which takes the site from
+// the tab's committed URL, never from here:
+// pw-form a login field got focus -> main may offer saved logins under it
+// pw-capture a form carrying a password was sent -> main may offer to save it
+(() => {
+ if (!/^(https?|bns):$/.test(location.protocol)) return;
+ const visible = (el) => {
+ const r = el.getBoundingClientRect();
+ if (r.width < 4 || r.height < 4) return false;
+ const cs = getComputedStyle(el);
+ return cs.visibility !== "hidden" && cs.display !== "none";
+ };
+ const TEXTY = /^(text|email|tel|)$/i;
+ const passwords = (scope) => [...(scope || document).querySelectorAll("input[type=password]")].filter((el) => !el.disabled && visible(el));
+ // The username is the closest text-like field before the password in the
+ // same form (or page), which is how almost every login form is laid out.
+ function usernameFor(pw) {
+ const scope = pw.form || document;
+ const inputs = [...scope.querySelectorAll("input")].filter((el) => !el.disabled && visible(el));
+ const at = inputs.indexOf(pw);
+ for (let i = at - 1; i >= 0; i--) if (TEXTY.test(inputs[i].type || "text")) return inputs[i];
+ return null;
+ }
+ function capture() {
+ try {
+ const pws = passwords().filter((el) => el.value);
+ if (!pws.length) return;
+ // Sign-up: password + confirmation (same value). Change-password:
+ // current, new[, confirm] -> the new one is second.
+ const signup = pws.length >= 2 || /new-password/i.test(pws[0].autocomplete || "");
+ const pw = pws.length >= 3 ? pws[1] : pws.length === 2 && pws[0].value !== pws[1].value ? pws[1] : pws[0];
+ const user = usernameFor(pws[0]);
+ ipcRenderer.send("pw-capture", { username: user ? user.value : "", password: pw.value, signup });
+ } catch {}
+ }
+ document.addEventListener("submit", capture, true);
+ // Script-driven logins never fire submit: catch the button press and Enter.
+ document.addEventListener("click", (e) => {
+ const b = e.target instanceof Element ? e.target.closest("button, input[type=submit], input[type=button], [role=button]") : null;
+ if (b && passwords().some((el) => el.value)) capture();
+ }, true);
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && e.target instanceof HTMLInputElement && (e.target.type === "password" || TEXTY.test(e.target.type))) {
+ if (passwords().some((el) => el.value)) capture();
+ }
+ }, true);
+
+ // A focused username or password field of a login form (one password
+ // field, empty) asks main to show saved logins under it.
+ function loginField(el) {
+ if (!(el instanceof HTMLInputElement) || el.disabled || el.readOnly) return null;
+ if (el.type === "password") return passwords(el.form || document).length === 1 ? el : null;
+ if (!TEXTY.test(el.type || "text")) return null;
+ const pws = passwords(el.form || document);
+ return pws.length === 1 && usernameFor(pws[0]) === el ? el : null;
+ }
+ function offer(el) {
+ if (!el || el.value) return;
+ const r = el.getBoundingClientRect();
+ ipcRenderer.send("pw-form", { x: r.left, y: r.top, h: r.height });
+ }
+ document.addEventListener("focusin", (e) => offer(loginField(e.target)), true);
+ // Typing means the user is not taking the offer.
+ document.addEventListener("input", (e) => { if (loginField(e.target)) ipcRenderer.send("pw-form-dismiss"); }, true);
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") ipcRenderer.send("pw-form-dismiss"); }, true);
+ // An autofocused field is already focused when this runs.
+ const early = () => offer(loginField(document.activeElement));
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", early, { once: true });
+ else early();
+})();
diff --git a/lib/signin-sites.cjs b/lib/signin-sites.cjs
new file mode 100644
index 00000000..218f6c69
--- /dev/null
+++ b/lib/signin-sites.cjs
@@ -0,0 +1,83 @@
+// Sites whose sign-in survives "Clear cookies on quit".
+//
+// The vault knows which sites have a saved login, but it is locked by the
+// time Theseus quits (and often for the whole session). So main keeps a copy
+// of just the hostnames here, refreshed whenever the vault is open, sealed
+// with Electron safeStorage (DPAPI / Keychain / libsecret) so it is not a
+// plain-text list of the user's accounts on disk. Without a real OS keystore
+// nothing is stored and every cookie is cleared as before.
+//
+// keepOrigins() turns the list into the origins Session.clearData() should
+// leave alone. Chromium matches cookies at the registrable-domain level, so
+// one origin per host covers the site's cookies; storage (localStorage,
+// IndexedDB) is per origin, so the bare and www. forms are both listed.
+//
+// File: { v: 1, data:
}
+
+"use strict";
+
+const fs = require("node:fs");
+
+const HOST_RE = /^(?=.{1,253}$)[a-z0-9-]+(\.[a-z0-9-]+)*$/;
+
+function normHost(h) {
+ const s = String(h || "").trim().toLowerCase().replace(/\.$/, "");
+ return HOST_RE.test(s) ? s : "";
+}
+
+function keepOrigins(hosts) {
+ const out = new Set();
+ for (const raw of hosts || []) {
+ const h = normHost(raw);
+ if (!h) continue;
+ // No www. twin for an IP address or a one-label host: Chromium rejects
+ // "www.127.0.0.1" as an origin, and one bad origin fails the whole call.
+ const plain = /^\d+(\.\d+){3}$/.test(h) || !h.includes(".");
+ for (const host of plain ? [h] : h.startsWith("www.") ? [h, h.slice(4)] : [h, "www." + h]) {
+ out.add("https://" + host);
+ out.add("http://" + host);
+ }
+ }
+ return [...out];
+}
+
+function createSigninSites({ file, safeStorage, log = () => {} }) {
+ const sealOk = () => {
+ try {
+ if (!safeStorage || !safeStorage.isEncryptionAvailable()) return false;
+ if (process.platform === "linux") {
+ const b = typeof safeStorage.getSelectedStorageBackend === "function" ? safeStorage.getSelectedStorageBackend() : "unknown";
+ if (b === "basic_text" || b === "unknown") return false;
+ }
+ return true;
+ } catch { return false; }
+ };
+ let cache = null;
+
+ function load() {
+ if (cache) return cache.slice();
+ let list = [];
+ try {
+ const rec = JSON.parse(fs.readFileSync(file, "utf8"));
+ if (rec && rec.v === 1 && rec.data && sealOk()) list = JSON.parse(safeStorage.decryptString(Buffer.from(rec.data, "base64")));
+ } catch { list = []; }
+ cache = Array.isArray(list) ? list.map(normHost).filter(Boolean) : [];
+ return cache.slice();
+ }
+
+ function save(hosts) {
+ const list = [...new Set((hosts || []).map(normHost).filter(Boolean))].sort();
+ cache = list;
+ if (!sealOk()) { try { fs.unlinkSync(file); } catch {} return false; }
+ try {
+ const tmp = file + ".tmp";
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, data: safeStorage.encryptString(JSON.stringify(list)).toString("base64") }), { mode: 0o600 });
+ fs.renameSync(tmp, file);
+ return true;
+ } catch (e) { log("signin-sites: save failed:", e?.message || e); return false; }
+ }
+
+ return { load, save, keepOrigins: () => keepOrigins(load()) };
+}
+
+module.exports = { createSigninSites, keepOrigins, normHost };
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/lib/vault-pin.cjs b/lib/vault-pin.cjs
index 918a590b..1f679857 100644
--- a/lib/vault-pin.cjs
+++ b/lib/vault-pin.cjs
@@ -9,7 +9,7 @@
// useless on another machine or OS account.
//
// The OS seal does not stop anything that runs as this OS user, nor a disk
-// image plus the Windows password; for those a 6-digit PIN falls to an
+// image plus the Windows password; for those a 6-8 digit PIN falls to an
// offline search in minutes. Where a TPM is available the PIN is therefore
// also the authorization value of a TPM key (lib/tpm-pin.cjs) whose secret is
// mixed into the AES key, and the chip's own lockout limits guesses to about
@@ -29,7 +29,8 @@
// attacker on the machine.
//
// File: { v: 1, sealed: true, data: , fails, last }
-// blob = { salt, iv, ct, iters, hw? } (all b64 except iters)
+// blob = { salt, iv, ct, iters, len?, hw? } (all b64 except iters; len is
+// the PIN's digit count, absent = 6 from builds that took only 6)
// hw = { kind: "tpm", key: , wrapped: }
"use strict";
@@ -41,7 +42,9 @@ const tpmPin = require("./tpm-pin.cjs");
const MAX_FAILS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
const ITERATIONS = 600_000;
-const PIN_RE = /^\d{6}$/;
+const PIN_MIN = 6;
+const PIN_MAX = 8;
+const PIN_RE = /^\d{6,8}$/;
function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now = () => Date.now() }) {
const sealAvailable = () => {
@@ -101,13 +104,16 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
lockedMs: lockedMsOf(rec),
sealed: !!(rec && rec.sealed),
hardware: b ? (b.hw ? "tpm" : "none") : null,
+ // So PIN pads can draw the right number of dots and submit on the
+ // last digit. Knowing it saves an attacker under 12% of the search.
+ length: b ? b.len || PIN_MIN : null,
storable: sealAvailable(),
};
},
// Caller must have verified masterPassword against the vault first.
async set(pin, masterPassword) {
- if (!PIN_RE.test(String(pin || ""))) throw new Error("the PIN must be 6 digits");
+ if (!PIN_RE.test(String(pin || ""))) throw new Error(`the PIN must be ${PIN_MIN} to ${PIN_MAX} digits`);
if (!masterPassword) throw new Error("master password required");
if (!sealAvailable()) throw new Error("this system has no protected keystore, so a PIN cannot be stored safely");
const old = blobOrNull(read());
@@ -122,7 +128,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
if (hw) key = tpm.mixKey(hw.secret, key);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(String(masterPassword), "utf8"), cipher.final(), cipher.getAuthTag()]);
- const blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS };
+ const blob = { salt: salt.toString("base64"), iv: iv.toString("base64"), ct: ct.toString("base64"), iters: ITERATIONS, len: String(pin).length };
if (hw) blob.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped };
write({
v: 1,
@@ -148,6 +154,14 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin", remaining: 0, lockedMs: 0 });
const locked = lockedMsOf(rec);
if (locked > 0) throw Object.assign(new Error("too many wrong PINs"), { code: "locked", remaining: 0, lockedMs: locked });
+ // A PIN of the wrong length cannot be right. Refuse it without a
+ // strike or a TPM attempt: an older Aegis pad that submits at six
+ // digits would otherwise burn the count against an 8-digit PIN.
+ const want = (blobOrNull(rec) || {}).len || PIN_MIN;
+ if (String(pin || "").length !== want) {
+ throw Object.assign(new Error(`your PIN has ${want} digits`),
+ { code: "wrong-length", length: want, remaining: Math.max(0, MAX_FAILS - (rec.fails || 0)), lockedMs: 0 });
+ }
// Count the guess before trying it, so a crash mid-check still costs one.
rec.fails = (rec.fails || 0) + 1;
rec.last = now();
@@ -205,4 +219,4 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
return self;
}
-module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS };
+module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS, PIN_MIN, PIN_MAX };
diff --git a/main.js b/main.js
index 7f33b489..8d374888 100644
--- a/main.js
+++ b/main.js
@@ -491,6 +491,11 @@ const SETTINGS_DEFAULTS = {
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit: true, // drop navigation history (+ saved tabs unless restoreSession)
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API
+ keepSignInsOnQuit: true, // ...except for sites with a login saved in the vault (lib/signin-sites.cjs)
+ // Password manager offers (Settings › Passwords).
+ pwOfferSave: true, // ask to save a password after a login or sign-up form is sent
+ pwOfferFill: true, // offer saved logins when a login form appears
+ pwNeverSave: [], // hosts the user answered "Never" for
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
languageMode: "show", languageSpoof: "en-US", languageValue: "en-GB", // spoof = top-10 pick, manual = free text (English = UK original; US variant retired from the picker)
@@ -897,9 +902,12 @@ 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", "sidebar-left-panels", "tor-state",
+ "settings-open-panel", "settings-section", "sidebar-left-panels",
+ "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",
"webapps-list", "webapps-open", "webapps-remove",
- "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
// vaultImports shim in main), but an unguarded handler is reachable by any
// renderer that gets code execution.
@@ -1220,9 +1228,35 @@ function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.w
// This wipes whichever the caller asked for. The `storages` list mirrors
// Chromium's clearStorageData taxonomy — we group them into a small user-
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
-async function clearBrowsingData({ cookies = false, cache = false, storage = false } = {}) {
+async function clearBrowsingData({ cookies = false, cache = false, storage = false, keep = [] } = {}) {
const ses = session.defaultSession;
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } }
+ // keep: origins whose sign-in survives (sites with a login in the vault).
+ // clearData can spare them; clearStorageData cannot. Falls through to the
+ // full clear if clearData refuses.
+ if (keep.length && (cookies || storage)) {
+ const dataTypes = [];
+ if (cookies) dataTypes.push("cookies");
+ if (storage) dataTypes.push("localStorage", "indexedDB", "serviceWorkers", "fileSystems", "webSQL");
+ // One origin Chromium refuses fails the whole call, so drop the one it
+ // names and try again rather than losing every kept sign-in.
+ let excludeOrigins = keep.slice();
+ for (let attempt = 0; attempt < 5 && excludeOrigins.length; attempt++) {
+ try {
+ await ses.clearData({ dataTypes, excludeOrigins });
+ if (storage) { try { await ses.clearStorageData({ storages: ["cachestorage", "shadercache"] }); } catch {} }
+ return;
+ } catch (e) {
+ console.warn("clearData (keeping sign-ins):", e.message);
+ const bad = /Invalid origin: '([^']+)'/.exec(e.message || "");
+ if (!bad) break;
+ const drop = bad[1].replace(/\/$/, "");
+ const before = excludeOrigins.length;
+ excludeOrigins = excludeOrigins.filter((o) => o !== drop);
+ if (excludeOrigins.length === before) break;
+ }
+ }
+ }
const storages = [];
if (cookies) storages.push("cookies");
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
@@ -1783,14 +1817,18 @@ function certFp(cert) {
const fp = cert && cert.fingerprint256;
return fp ? fp.toLowerCase().replace(/:/g, "") : "";
}
-async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
+// init: { method, headers, body } for a page's own request (forms, fetch POSTs);
+// a bare call is the GET it always was. Headers are already filtered by the
+// caller (forwardHeaders); host and user-agent are always ours.
+async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp, init = {}) {
const useTor = torState === "on";
if (useTor) await loadSocks();
return new Promise((resolve, reject) => {
const opts = {
- host: ip, port, servername, method: "GET", path: reqPath,
- headers: { host: servername, "user-agent": "theseus/1" },
+ host: ip, port, servername, method: init.method || "GET", path: reqPath,
+ headers: { ...(init.headers || {}), host: servername, "user-agent": "theseus/1" },
};
+ if (init.body) opts.headers["content-length"] = String(init.body.length);
opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate.
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
const req = https.request(opts, (res) => {
@@ -1801,39 +1839,68 @@ async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
- res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
+ res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null,
+ setCookie: res.headers["set-cookie"] || null, buffer: Buffer.concat(chunks) }));
});
req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); });
req.on("error", reject);
- req.end();
+ req.end(init.body || undefined);
});
}
-async function httpGetByIp(ip, reqPath, hostHeader) {
+async function httpGetByIp(ip, reqPath, hostHeader, init = {}) {
const useTor = torState === "on";
if (useTor) await loadSocks();
return new Promise((resolve, reject) => {
const opts = {
- host: ip, port: 80, method: "GET", path: reqPath,
- headers: { host: hostHeader, "user-agent": "theseus/1" },
+ host: ip, port: 80, method: init.method || "GET", path: reqPath,
+ headers: { ...(init.headers || {}), host: hostHeader, "user-agent": "theseus/1" },
};
+ if (init.body) opts.headers["content-length"] = String(init.body.length);
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
const req = http.request(opts, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
- res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null, buffer: Buffer.concat(chunks) }));
+ res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null,
+ setCookie: res.headers["set-cookie"] || null, buffer: Buffer.concat(chunks) }));
res.on("error", reject);
});
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
- req.on("error", reject); req.end();
+ req.on("error", reject); req.end(init.body || undefined);
});
}
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
// "not the site the chain says it is" and returning HTTP anyway would defeat
// the pin.
-async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) {
- if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase());
- return await httpGetByIp(ip, reqPath, hostHeader);
+async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint, init = {}) {
+ if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase(), init);
+ return await httpGetByIp(ip, reqPath, hostHeader, init);
+}
+// What of a page's own request reaches its ip-record server: method, body
+// and the headers a site needs to answer it (forms, JSON APIs, its own
+// cookies). The page's bns:// origin is presented as https://, the
+// same mapping Theseus uses for that origin everywhere else (pageOriginOf),
+// so a server can check Origin like any other site.
+// x-*: a site's own custom request headers (CSRF tokens and the like).
+const FORWARD_HEADERS = ["accept", "accept-language", "content-type", "authorization", "cookie", "origin", "if-none-match", "if-modified-since", "range"];
+const FORWARD_HEADER_RE = /^x-[a-z0-9-]{1,40}$/;
+async function forwardInit(request) {
+ const method = String(request.method || "GET").toUpperCase();
+ const headers = {};
+ for (const k of FORWARD_HEADERS) {
+ let v = request.headers.get(k);
+ if (v == null) continue;
+ if (k === "origin") v = v.replace(/^bns:\/\//i, "https://");
+ headers[k] = v;
+ }
+ for (const [k, v] of request.headers.entries()) if (FORWARD_HEADER_RE.test(k.toLowerCase())) headers[k.toLowerCase()] = v;
+ let body = null;
+ if (method !== "GET" && method !== "HEAD") {
+ const buf = Buffer.from(await request.arrayBuffer());
+ if (buf.length > 8 * 1024 * 1024) throw new Error("request body too large");
+ body = buf;
+ }
+ return { method, headers, body };
}
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
// search template into our { name, url-with-%s } form.
@@ -2065,8 +2132,10 @@ const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application
// upstream 204/304 into the 502 page.
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
function upstreamResponse(up, contentType) {
- const headers = { "content-type": contentType };
- if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location;
+ const headers = new Headers({ "content-type": contentType });
+ if (up.location && up.status >= 300 && up.status < 400) headers.set("location", up.location);
+ // The site's own cookies (sessions on ip-record sites) come back to its bns:// origin.
+ for (const c of up.setCookie || []) headers.append("set-cookie", c);
return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers });
}
@@ -2123,7 +2192,7 @@ async function serveBns(request) {
// record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the
// redirect target because it isn't in ICANN DNS).
- const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls);
+ const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls, await forwardInit(request));
return upstreamResponse(up, up.contentType || guessType(reqPath));
};
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays
@@ -2759,8 +2828,9 @@ function initAddons() {
let pw;
try { pw = await vaultPin().open(String(pin || "")); }
catch (err) {
- return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0,
- error: err.code === "tpm-locked" || err.code === "pin-gone" ? err.message : undefined };
+ return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0, length: err.length,
+ error: err.code === "tpm-locked" || err.code === "pin-gone" ? err.message
+ : err.code === "wrong-length" ? `Your PIN has ${err.length} digits. Use the master password, or update Aegis.` : undefined };
}
try { await unlockVaultWithMaster(pw); }
catch {
@@ -3556,25 +3626,38 @@ function setSidebar(show, panelId) {
emitSidebarState();
}
}
-function showPwFill(show, matches) {
+function showPwFill(show, matches, extra = {}) {
if (!pwFillPop) return;
if (show) {
- if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches))) return;
+ if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches, extra))) return;
positionPwFill();
win.contentView.removeChildView(pwFillPop);
win.contentView.addChildView(pwFillPop);
pwFillPop.setVisible(true); pwfVisible = true;
- pwFillPop.webContents.send("pw-matches", { matches: matches || [] });
+ pwFillPop.webContents.send("pw-matches", { matches: matches || [], locked: !!extra.locked, host: extra.host || "" });
} else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; }
}
// Compute credential matches for a host. Exact hostname match in phase-1;
// eTLD+1 upgrade queued for A.2.5 (needs the public-suffix-list snapshot).
+// Hostnames with a saved login, kept outside the vault (sealed) so the quit
+// clear can spare their sign-ins while the vault is locked. Rewritten only
+// when the vault is open and the set changed.
+const { createSigninSites, normHost: normSigninHost } = require("./lib/signin-sites.cjs");
+let signinSitesInst = null;
+const signinSites = () => (signinSitesInst ||= createSigninSites({ file: path.join(app.getPath("userData"), "signin-sites.json"), safeStorage, log: (...a) => console.log("[signin-sites]", ...a) }));
+function refreshSigninSites() {
+ if (!vaultState) return;
+ const want = [...new Set((vaultState.entries || []).map((e) => normSigninHost(e.domain)).filter(Boolean))].sort();
+ const have = signinSites().load();
+ if (want.length === have.length && want.every((h, i) => h === have[i])) return;
+ signinSites().save(want);
+}
function pwMatchesForHost(host) {
if (!vaultState || !host) return [];
const h = String(host).toLowerCase();
return (vaultState.entries || [])
.filter((e) => e.domain === h)
- .map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" }));
+ .map((e) => ({ id: e.id, domain: e.domain, username: e.username || "", confirm: !!e.confirm }));
}
// The host of what the tab is showing right now. t.prov.host is set by our own
// navigations only, so it goes stale on Back/Forward and server redirects —
@@ -3589,6 +3672,8 @@ function liveHost(t) {
// Emit the current tab's match count to chrome so the toolbar chip can
// 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;
@@ -3837,6 +3922,7 @@ function setActive(id) {
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false);
if (lpVisible) showLangPicker(false);
+ if (pwfVisible && switching) showPwFill(false); // the login offer belongs to the tab left behind
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
// A user action that switches to a different tab (New Tab, Settings,
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
@@ -6481,7 +6567,9 @@ function showApprovalModal(opts, addonId, tabId = null) {
const req = {
reqId: ++approvalSeq,
addonId,
- addonName: a ? a.manifest.name : addonId,
+ // addonId null = Theseus itself asking (the password manager, Theseus ID).
+ addonName: a ? a.manifest.name : addonId || String(opts.from || "Theseus"),
+ builtin: !addonId,
title: String(opts.title || "Approve?"),
body: opts.body == null ? "" : String(opts.body),
origin: opts.origin == null ? "" : String(opts.origin),
@@ -7347,21 +7435,23 @@ let unlockCurrent = null;
let unlockSeq = 0;
// Resolves { ok: true } once the vault is unlocked, { ok: false, reason }
// when there is no vault or the user cancels.
-function requestVaultUnlock({ reason, addonId } = {}) {
- if (vaultState) return Promise.resolve({ ok: true, already: true });
+// confirm: ask for the PIN or master password even when the vault is already
+// open — the check a saved login can require before it is filled.
+function requestVaultUnlock({ reason, addonId, confirm = false } = {}) {
+ if (vaultState && !confirm) return Promise.resolve({ ok: true, already: true });
if (!fs.existsSync(vaultFile())) return Promise.resolve({ ok: false, reason: "no-vault" });
const a = addonId && addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
- const req = { reqId: ++unlockSeq, addonName: a ? a.manifest.name : "Theseus", reason: String(reason || "").slice(0, 200) };
+ const req = { reqId: ++unlockSeq, addonName: a ? a.manifest.name : "Theseus", reason: String(reason || "").slice(0, 200), confirm: !!confirm };
return new Promise((resolve) => { unlockQueue.push({ req, resolve }); pumpUnlock(); });
}
function pumpUnlock() {
if (unlockCurrent || !unlockQueue.length || !unlockPop) return;
const next = unlockQueue.shift();
- if (vaultState) { next.resolve({ ok: true, already: true }); pumpUnlock(); return; }
+ if (vaultState && !next.req.confirm) { next.resolve({ ok: true, already: true }); pumpUnlock(); return; }
unlockCurrent = next;
const st = vaultPin().status();
overlayReady(unlockPop).then(() => {
- unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, lockedMs: st.lockedMs, fails: st.fails, maxFails: vaultPin().MAX_FAILS });
+ unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, pinLength: st.length || 6, lockedMs: st.lockedMs, fails: st.fails, maxFails: vaultPin().MAX_FAILS });
try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front
unlockPop.setVisible(true);
unlockPop.webContents.focus();
@@ -7389,12 +7479,21 @@ ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
catch (err) {
// Same words as Aegis's PIN pads: one PIN, one policy, one wording.
if (err.code === "wrong-pin") return { ok: false, mode: "pin", error: `Wrong PIN. ${err.remaining} attempt${err.remaining === 1 ? "" : "s"} left before a 15 min lockout.` };
+ if (err.code === "wrong-length") return { ok: false, mode: "pin", pinLength: err.length, error: `Your PIN has ${err.length} digits.` };
if (err.code === "locked") return { ok: false, mode: "password", lockedMs: err.lockedMs, error: `Too many failed attempts. Try again in ${Math.max(1, Math.ceil((err.lockedMs || 0) / 60000))} min or use the master password.` };
return { ok: false, mode: "password", error: err.message };
}
}
try {
- await unlockVaultWithMaster(masterPassword);
+ if (unlockCurrent.req.confirm && vaultState) {
+ // Already open: only prove the password, without re-reading the vault
+ // over the live state.
+ const v = await loadVaultLib();
+ await v.unlockVault(vaultFile(), masterPassword);
+ try { vaultPin().resetFails(); } catch {}
+ } else {
+ await unlockVaultWithMaster(masterPassword);
+ }
} catch {
if (mode === "pin") {
// The PIN opened, but its master password no longer opens the vault
@@ -7424,7 +7523,18 @@ ipcMain.handle("vault-pin-set", async (_e, { pin, masterPassword } = {}) => {
catch (e) { return vaultErr(e.message); }
});
ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); });
+// Step 1 of the PIN setup dialog: prove the master password before the user
+// picks a PIN. Opens the vault file only to test it; vault-pin-set checks again.
+ipcMain.handle("vault-check-master", async (_e, masterPassword) => {
+ try {
+ if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
+ const v = await loadVaultLib();
+ await v.unlockVault(vaultFile(), String(masterPassword || ""));
+ return vaultOk();
+ } catch { await new Promise((r) => setTimeout(r, 600)); return vaultErr("wrong master password"); }
+});
ipcMain.handle("vault-pin-unlock", () => requestVaultUnlock({ reason: "Open your saved passwords." }));
+ipcMain.handle("vault-confirm", (_e, reason) => requestVaultUnlock({ confirm: true, reason: String(reason || "").slice(0, 200) }));
ipcMain.handle("password-status", () => ({
setup: fs.existsSync(vaultFile()),
@@ -7497,6 +7607,11 @@ ipcMain.handle("password-get", async (_e, id) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
+ const entry = vaultState.entries.find((x) => x.id === id);
+ if (entry && entry.confirm) {
+ const c = await requestVaultUnlock({ confirm: true, reason: `Confirm it's you to show your ${entry.domain} password.` });
+ if (!c.ok || !vaultState) return vaultErr("cancelled");
+ }
const password = await v.resolvePassword(vaultState, id);
return { ok: true, password };
} catch (e) { return vaultErr(e?.message || e); }
@@ -7509,6 +7624,7 @@ ipcMain.handle("password-add", async (_e, spec) => {
const entry = v.newEntry(spec || {});
vaultState.entries.push(entry);
await v.saveVault(vaultFile(), vaultState);
+ emitPwAvailability();
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
@@ -7521,10 +7637,13 @@ ipcMain.handle("password-update", async (_e, id, patch) => {
if (!e) return vaultErr("no such entry");
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
for (const k of ["domain", "username", "literal", "generated"]) if (patch && k in patch) e[k] = patch[k];
+ // Ask for the PIN or password before this login is filled.
+ if (patch && "confirm" in patch) { if (patch.confirm) e.confirm = true; else delete e.confirm; }
// Switching between literal and generated: drop the other field.
if (patch && "literal" in patch) delete e.generated;
if (patch && "generated" in patch) delete e.literal;
await v.saveVault(vaultFile(), vaultState);
+ emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
@@ -7535,6 +7654,7 @@ ipcMain.handle("password-remove", async (_e, id) => {
const v = await loadVaultLib();
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
await v.saveVault(vaultFile(), vaultState);
+ emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
@@ -7800,15 +7920,340 @@ ipcMain.handle("pw-fill-resize", (_e, h) => {
ipcMain.handle("pw-fill-pick", async (e, id) => {
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
showPwFill(false);
+ const t = activeTab();
+ const host = t ? liveHost(t) : "";
+ let justUnlocked = false; // a PIN / password typed for this very fill also answers the per-login check
+ if (id === "__unlock") {
+ // Offered on a locked vault: unlock, then fill at once when there is one
+ // login for the site, or show the choice again.
+ const u = await requestVaultUnlock({ reason: host ? `Sign in to ${host} with a saved login.` : "" });
+ if (!u.ok) return { ok: false, err: u.reason || "cancelled" };
+ justUnlocked = !u.already;
+ const matches = pwMatchesForHost(host);
+ if (matches.length === 1) id = matches[0].id;
+ else { if (matches.length) showPwFill(true, matches, { host }); return { ok: true, shown: matches.length }; }
+ }
if (!vaultState) return { ok: false, err: "locked" };
try {
const v = await loadVaultLib();
const entry = vaultState.entries.find((x) => x.id === id);
if (!entry) return { ok: false, err: "no such entry" };
+ if (entry.confirm && !justUnlocked) {
+ // The user asked for a check before this login is filled.
+ const c = await requestVaultUnlock({ confirm: true, reason: `Confirm it's you to fill your ${entry.domain} login.` });
+ if (!c.ok) return { ok: false, err: "cancelled" };
+ }
const password = await v.resolvePassword(vaultState, id);
return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password });
} catch (e) { return { ok: false, err: e?.message || String(e) }; }
});
+
+// ---- Password manager: offer saved logins, offer to save new ones ----------
+// home-preload.js runs in the top frame of every web tab, in its isolated
+// world, and reports two things: a login field got focus ("pw-form"), and a
+// form carrying a password was sent ("pw-capture"). It exposes nothing to
+// the page. The host is always taken from the tab's committed URL, never
+// from the message.
+function webTabForEvent(e) {
+ const t = tabForSender(e.sender);
+ if (!t || t.settings || t.addonId) return null;
+ if (e.senderFrame !== e.sender.mainFrame) return null;
+ return t;
+}
+let pwfOfferTab = null;
+const pwfNavHooked = new WeakSet();
+ipcMain.on("pw-form", (e, rect) => {
+ try {
+ if (!settings.pwOfferFill) return;
+ const t = webTabForEvent(e);
+ if (!t || t.id !== activeId) return;
+ const host = liveHost(t);
+ if (!host || !fs.existsSync(vaultFile())) return;
+ let matches = [], locked = false;
+ if (vaultState) {
+ matches = pwMatchesForHost(host);
+ if (!matches.length) return;
+ } else {
+ // Locked: the sealed list of sites with a login says whether there is
+ // anything to offer, without opening the vault.
+ if (!signinSites().load().includes(host)) return;
+ locked = true;
+ }
+ const b = t.view.getBounds();
+ const z = t.view.webContents.getZoomFactor() || 1;
+ const r = rect && typeof rect === "object" ? rect : {};
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
+ const x = Math.round(b.x + num(r.x) * z);
+ const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4);
+ pwfPos = { x, y: Math.max(b.y, Math.min(y, b.y + b.height - 90)) };
+ pwfOfferTab = t.id;
+ const wc = t.view.webContents;
+ if (!pwfNavHooked.has(wc)) {
+ pwfNavHooked.add(wc);
+ wc.on("did-start-navigation", (_ev, _url, inPage, isMain) => {
+ if (isMain && !inPage && pwfVisible && pwfOfferTab === t.id) showPwFill(false);
+ });
+ }
+ showPwFill(true, matches, { locked, host });
+ } catch (err) { console.warn("pw-form:", err?.message); }
+});
+ipcMain.on("pw-form-dismiss", (e) => {
+ const t = webTabForEvent(e);
+ if (t && pwfVisible && pwfOfferTab === t.id) showPwFill(false);
+});
+
+const pwCaptureSeen = new Map(); // tabId -> { key, at }: one offer per login, however many events report it
+ipcMain.on("pw-capture", (e, data) => {
+ try {
+ if (!settings.pwOfferSave) return;
+ const t = webTabForEvent(e);
+ if (!t) return;
+ const host = liveHost(t);
+ if (!host || (settings.pwNeverSave || []).includes(host)) return;
+ const username = String((data && data.username) || "").trim().slice(0, 256);
+ const password = String((data && data.password) || "");
+ if (!password || password.length > 1024) return;
+ const key = require("node:crypto").createHash("sha256").update(host + "\n" + username + "\n" + password).digest("hex");
+ const seen = pwCaptureSeen.get(t.id);
+ if (seen && seen.key === key && Date.now() - seen.at < 60_000) return;
+ pwCaptureSeen.set(t.id, { key, at: Date.now() });
+ // A beat later, so a login that navigates away shows the offer on the
+ // page it lands on rather than flashing over the form.
+ setTimeout(() => offerSavePassword(t.id, host, username, password).catch((err) => console.warn("pw save offer:", err?.message)), 900);
+ } catch (err) { console.warn("pw-capture:", err?.message); }
+});
+
+// True once the login looks done: the page moved to another site, or no
+// password field is left on it. A password field still showing after a few
+// seconds (filled, or emptied by a "wrong password" page) means the login
+// did not go through, and a wrong password is not worth saving.
+async function pwLoginSettled(tabId, host) {
+ for (let i = 0; i < 8; i++) {
+ const t = tabs.find((x) => x.id === tabId);
+ if (!t) return false;
+ if (liveHost(t) !== host) return true;
+ let pwField = false;
+ try {
+ pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{
+ code: "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)",
+ }]);
+ } catch { return true; } // navigating: the page is going away
+ if (!pwField) return true;
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ return false;
+}
+async function offerSavePassword(tabId, host, username, password) {
+ if (!tabs.some((x) => x.id === tabId)) return;
+ if (!(await pwLoginSettled(tabId, host))) return;
+ if (!fs.existsSync(vaultFile())) {
+ const pick = await showApprovalModal({
+ from: "Theseus Vault",
+ title: "Save your passwords in Theseus?",
+ body: "Theseus can remember this login and fill it next time. Set up the Theseus Vault first: it is encrypted with a master password only you know, and nothing leaves this computer.",
+ origin: host,
+ actions: [{ id: "setup", label: "Set up the vault", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }],
+ }, null, tabId);
+ if (pick === "setup") openSettingsTab("passwords");
+ else if (pick === "never") pwNeverFor(host);
+ return;
+ }
+ // Already saved, unchanged: nothing to ask. (Only knowable while open.)
+ let update = false;
+ if (vaultState) {
+ const v = await loadVaultLib();
+ const same = (vaultState.entries || []).find((x) => x.domain === host && (x.username || "") === username);
+ if (same) {
+ if ((await v.resolvePassword(vaultState, same.id).catch(() => null)) === password) return;
+ update = true;
+ }
+ }
+ const pick = await showApprovalModal({
+ from: "Theseus Vault",
+ title: update ? "Update the saved password?" : "Save this password?",
+ origin: host,
+ rows: [
+ { label: "Username", value: username || "(none)" },
+ { label: "Password", value: "•".repeat(Math.min(12, password.length)) },
+ ],
+ checkbox: { id: "confirm", label: "Ask for my PIN or password before filling it" },
+ actions: update
+ ? [{ id: "save", label: "Update", primary: true }, { id: "cancel", label: "Not now" }]
+ : [{ id: "save", label: "Save", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }],
+ }, null, tabId);
+ if (pick === "never") return pwNeverFor(host);
+ if (!pick.startsWith("save")) return;
+ const confirm = pick.split("+").includes("confirm");
+ const u = await requestVaultUnlock({ reason: `Save your ${host} login in the vault.` });
+ if (!u.ok || !vaultState) return;
+ const v = await loadVaultLib();
+ const same = vaultState.entries.find((x) => x.domain === host && (x.username || "") === username);
+ if (same) {
+ same.literal = password;
+ delete same.generated;
+ if (confirm) same.confirm = true; else delete same.confirm;
+ } else {
+ const entry = v.newEntry({ domain: host, username, literal: password });
+ if (confirm) entry.confirm = true;
+ vaultState.entries.push(entry);
+ }
+ await v.saveVault(vaultFile(), vaultState);
+ emitPwAvailability();
+}
+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) => {
@@ -8808,13 +9253,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); }
@@ -8897,6 +9344,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
cookies: settings.clearCookiesOnQuit,
cache: settings.clearCacheOnQuit,
storage: settings.clearStorageOnQuit,
+ keep: settings.keepSignInsOnQuit ? signinSites().keepOrigins() : [],
});
// Session file AND the address-bar history (history.json).
if (settings.clearHistoryOnQuit) {
diff --git a/package.json b/package.json
index e7191622..e5598980 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",
@@ -127,6 +128,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/pw-fill.html b/pw-fill.html
index 5402ad77..9f5e1313 100644
--- a/pw-fill.html
+++ b/pw-fill.html
@@ -25,14 +25,18 @@