-
π§©${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/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 aa3b2c9b..85a8c838 100644
--- a/home-preload.js
+++ b/home-preload.js
@@ -28,3 +28,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/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 ead3e359..3479be67 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)
@@ -889,7 +894,7 @@ const SETTINGS_ONLY = new Set([
"password-setup", "password-status", "password-unlock", "password-update",
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
"settings-open-panel", "settings-section", "tor-state",
- "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.
@@ -1210,9 +1215,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");
@@ -2740,8 +2771,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 {
@@ -3528,25 +3560,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 β
@@ -3561,6 +3606,7 @@ 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();
const t = activeTab();
const host = t ? liveHost(t) : "";
const count = pwMatchesForHost(host).length;
@@ -3809,6 +3855,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
@@ -6441,7 +6488,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),
@@ -7307,21 +7356,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();
@@ -7349,12 +7400,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
@@ -7384,7 +7444,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()),
@@ -7457,6 +7528,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); }
@@ -7469,6 +7545,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); }
});
@@ -7481,10 +7558,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); }
});
@@ -7495,6 +7575,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); }
});
@@ -7760,15 +7841,190 @@ 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(); }
+}
// 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) => {
@@ -8753,6 +9009,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/pw-fill.html b/pw-fill.html
index 5402ad77..9f5e1313 100644
--- a/pw-fill.html
+++ b/pw-fill.html
@@ -25,14 +25,18 @@