Vault: PIN setup steps, 6-8 digit PINs, save and offer logins, keep sign-ins

The PIN could only be six digits and was set from three bare inputs; the
unlock prompt sat at the top of the page; and the password manager only
filled when you found the key chip, never offered to save, and "Clear
cookies on quit" signed you out of every site, including the ones whose
login the vault already holds.

- PINs are 6 to 8 digits. The PIN record stores its length so pads draw the
  right number of dots and submit on the last digit; a PIN of the wrong
  length is refused without a strike, so an older Aegis pad cannot burn the
  count against an 8-digit PIN.
- Settings sets a PIN in steps: master password, choose the PIN on a pad
  (6/7/8), repeat it, done. The locked vault opens Theseus's own prompt,
  which is now centred, with the PIN pad or the master password field.
- After a sign-in or sign-up form is sent and the page moves on, Theseus
  offers to save (or update) the login, with an optional "ask for my PIN or
  password before filling it". Focusing a login form offers the saved
  logins under it; on a locked vault it offers to unlock first. A failed
  login (the password field still showing) gets no offer.
- "Keep sign-ins for sites in your vault" (on): the quit clear spares the
  cookies and site storage of sites with a saved login. Their hostnames are
  kept sealed with the OS keystore so the list is readable at quit while
  the vault is locked. Verified end to end on a scratch profile: signed in,
  restarted, still signed in; another site's cookie was cleared.
This commit is contained in:
Local Dev 2026-10-04 20:23:43 +02:00
parent 45c7ca90d7
commit a6f7b335a5
11 changed files with 783 additions and 81 deletions

View file

@ -75,7 +75,9 @@
const hasCancel = actions.some((a) => a.id === "cancel"); const hasCancel = actions.some((a) => a.id === "cancel");
document.body.innerHTML = document.body.innerHTML =
`<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true"> `<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true">
<div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div> ${req.builtin
? `<div class="who"><span>🔑</span><span class="addon">${esc(req.addonName || "Theseus")}</span></div>`
: `<div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div>`}
<h1 class="title">${esc(req.title || "Approve?")}</h1> <h1 class="title">${esc(req.title || "Approve?")}</h1>
${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""} ${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""}
${req.body ? `<div class="body">${esc(req.body)}</div>` : ""} ${req.body ? `<div class="body">${esc(req.body)}</div>` : ""}

49
dev/signin-sites.test.cjs Normal file
View file

@ -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(), []);
});

66
dev/vault-pin.test.cjs Normal file
View file

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

View file

@ -28,3 +28,76 @@ contextBridge.exposeInMainWorld("errorpage", {
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host), registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url), 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();
})();

83
lib/signin-sites.cjs Normal file
View file

@ -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: <b64 safeStorage blob of JSON string[]> }
"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 };

View file

@ -9,7 +9,7 @@
// useless on another machine or OS account. // useless on another machine or OS account.
// //
// The OS seal does not stop anything that runs as this OS user, nor a disk // 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 // 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 // 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 // mixed into the AES key, and the chip's own lockout limits guesses to about
@ -29,7 +29,8 @@
// attacker on the machine. // attacker on the machine.
// //
// File: { v: 1, sealed: true, data: <b64 safeStorage blob>, fails, last } // File: { v: 1, sealed: true, data: <b64 safeStorage blob>, 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: <TPM key name>, wrapped: <b64> } // hw = { kind: "tpm", key: <TPM key name>, wrapped: <b64> }
"use strict"; "use strict";
@ -41,7 +42,9 @@ const tpmPin = require("./tpm-pin.cjs");
const MAX_FAILS = 5; const MAX_FAILS = 5;
const LOCKOUT_MS = 15 * 60 * 1000; const LOCKOUT_MS = 15 * 60 * 1000;
const ITERATIONS = 600_000; 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() }) { function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now = () => Date.now() }) {
const sealAvailable = () => { const sealAvailable = () => {
@ -101,13 +104,16 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
lockedMs: lockedMsOf(rec), lockedMs: lockedMsOf(rec),
sealed: !!(rec && rec.sealed), sealed: !!(rec && rec.sealed),
hardware: b ? (b.hw ? "tpm" : "none") : null, 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(), storable: sealAvailable(),
}; };
}, },
// Caller must have verified masterPassword against the vault first. // Caller must have verified masterPassword against the vault first.
async set(pin, masterPassword) { 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 (!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"); if (!sealAvailable()) throw new Error("this system has no protected keystore, so a PIN cannot be stored safely");
const old = blobOrNull(read()); const old = blobOrNull(read());
@ -122,7 +128,7 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
if (hw) key = tpm.mixKey(hw.secret, key); if (hw) key = tpm.mixKey(hw.secret, key);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(String(masterPassword), "utf8"), cipher.final(), cipher.getAuthTag()]); 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 }; if (hw) blob.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped };
write({ write({
v: 1, 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 }); if (!rec) throw Object.assign(new Error("no PIN is set"), { code: "no-pin", remaining: 0, lockedMs: 0 });
const locked = lockedMsOf(rec); const locked = lockedMsOf(rec);
if (locked > 0) throw Object.assign(new Error("too many wrong PINs"), { code: "locked", remaining: 0, lockedMs: locked }); 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. // Count the guess before trying it, so a crash mid-check still costs one.
rec.fails = (rec.fails || 0) + 1; rec.fails = (rec.fails || 0) + 1;
rec.last = now(); rec.last = now();
@ -205,4 +219,4 @@ function createVaultPin({ file, safeStorage, tpm = tpmPin, log = () => {}, now =
return self; return self;
} }
module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS }; module.exports = { createVaultPin, MAX_FAILS, LOCKOUT_MS, PIN_MIN, PIN_MAX };

287
main.js
View file

@ -491,6 +491,11 @@ const SETTINGS_DEFAULTS = {
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.) clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit: true, // drop navigation history (+ saved tabs unless restoreSession) clearHistoryOnQuit: true, // drop navigation history (+ saved tabs unless restoreSession)
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API 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) // Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual 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) 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", "password-setup", "password-status", "password-unlock", "password-update",
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order", "recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
"settings-open-panel", "settings-section", "tor-state", "settings-open-panel", "settings-section", "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 // Raw seeds and WIFs. No page uses these (add-ons go through the
// vaultImports shim in main), but an unguarded handler is reachable by any // vaultImports shim in main), but an unguarded handler is reachable by any
// renderer that gets code execution. // 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 // This wipes whichever the caller asked for. The `storages` list mirrors
// Chromium's clearStorageData taxonomy — we group them into a small user- // Chromium's clearStorageData taxonomy — we group them into a small user-
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple. // 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; const ses = session.defaultSession;
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } } 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 = []; const storages = [];
if (cookies) storages.push("cookies"); if (cookies) storages.push("cookies");
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache"); if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
@ -2740,8 +2771,9 @@ function initAddons() {
let pw; let pw;
try { pw = await vaultPin().open(String(pin || "")); } try { pw = await vaultPin().open(String(pin || "")); }
catch (err) { catch (err) {
return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0, 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 : undefined }; 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); } try { await unlockVaultWithMaster(pw); }
catch { catch {
@ -3528,25 +3560,38 @@ function setSidebar(show, panelId) {
emitSidebarState(); emitSidebarState();
} }
} }
function showPwFill(show, matches) { function showPwFill(show, matches, extra = {}) {
if (!pwFillPop) return; if (!pwFillPop) return;
if (show) { if (show) {
if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches))) return; if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches, extra))) return;
positionPwFill(); positionPwFill();
win.contentView.removeChildView(pwFillPop); win.contentView.removeChildView(pwFillPop);
win.contentView.addChildView(pwFillPop); win.contentView.addChildView(pwFillPop);
pwFillPop.setVisible(true); pwfVisible = true; 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; } } else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; }
} }
// Compute credential matches for a host. Exact hostname match in phase-1; // 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). // 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) { function pwMatchesForHost(host) {
if (!vaultState || !host) return []; if (!vaultState || !host) return [];
const h = String(host).toLowerCase(); const h = String(host).toLowerCase();
return (vaultState.entries || []) return (vaultState.entries || [])
.filter((e) => e.domain === h) .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 // 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 — // 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 // 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. // show/hide + display the count. Cheap; called on nav + vault unlock/lock.
function emitPwAvailability() { function emitPwAvailability() {
refreshSigninSites();
const t = activeTab(); const t = activeTab();
const host = t ? liveHost(t) : ""; const host = t ? liveHost(t) : "";
const count = pwMatchesForHost(host).length; const count = pwMatchesForHost(host).length;
@ -3809,6 +3855,7 @@ function setActive(id) {
if (popVisible) showPopover(false); // don't carry a stale popover across tabs if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false); if (epVisible) showEnginePicker(false);
if (lpVisible) showLangPicker(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 if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
// A user action that switches to a different tab (New Tab, Settings, // A user action that switches to a different tab (New Tab, Settings,
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave // address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
@ -6441,7 +6488,9 @@ function showApprovalModal(opts, addonId, tabId = null) {
const req = { const req = {
reqId: ++approvalSeq, reqId: ++approvalSeq,
addonId, 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?"), title: String(opts.title || "Approve?"),
body: opts.body == null ? "" : String(opts.body), body: opts.body == null ? "" : String(opts.body),
origin: opts.origin == null ? "" : String(opts.origin), origin: opts.origin == null ? "" : String(opts.origin),
@ -7307,21 +7356,23 @@ let unlockCurrent = null;
let unlockSeq = 0; let unlockSeq = 0;
// Resolves { ok: true } once the vault is unlocked, { ok: false, reason } // Resolves { ok: true } once the vault is unlocked, { ok: false, reason }
// when there is no vault or the user cancels. // when there is no vault or the user cancels.
function requestVaultUnlock({ reason, addonId } = {}) { // confirm: ask for the PIN or master password even when the vault is already
if (vaultState) return Promise.resolve({ ok: true, already: true }); // 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" }); 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 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(); }); return new Promise((resolve) => { unlockQueue.push({ req, resolve }); pumpUnlock(); });
} }
function pumpUnlock() { function pumpUnlock() {
if (unlockCurrent || !unlockQueue.length || !unlockPop) return; if (unlockCurrent || !unlockQueue.length || !unlockPop) return;
const next = unlockQueue.shift(); 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; unlockCurrent = next;
const st = vaultPin().status(); const st = vaultPin().status();
overlayReady(unlockPop).then(() => { 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 try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front
unlockPop.setVisible(true); unlockPop.setVisible(true);
unlockPop.webContents.focus(); unlockPop.webContents.focus();
@ -7349,12 +7400,21 @@ ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
catch (err) { catch (err) {
// Same words as Aegis's PIN pads: one PIN, one policy, one wording. // 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-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.` }; 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 }; return { ok: false, mode: "password", error: err.message };
} }
} }
try { 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 { } catch {
if (mode === "pin") { if (mode === "pin") {
// The PIN opened, but its master password no longer opens the vault // 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); } catch (e) { return vaultErr(e.message); }
}); });
ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); }); 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-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", () => ({ ipcMain.handle("password-status", () => ({
setup: fs.existsSync(vaultFile()), setup: fs.existsSync(vaultFile()),
@ -7457,6 +7528,11 @@ ipcMain.handle("password-get", async (_e, id) => {
if (!vaultState) return vaultErr("locked"); if (!vaultState) return vaultErr("locked");
try { try {
const v = await loadVaultLib(); 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); const password = await v.resolvePassword(vaultState, id);
return { ok: true, password }; return { ok: true, password };
} catch (e) { return vaultErr(e?.message || e); } } catch (e) { return vaultErr(e?.message || e); }
@ -7469,6 +7545,7 @@ ipcMain.handle("password-add", async (_e, spec) => {
const entry = v.newEntry(spec || {}); const entry = v.newEntry(spec || {});
vaultState.entries.push(entry); vaultState.entries.push(entry);
await v.saveVault(vaultFile(), vaultState); await v.saveVault(vaultFile(), vaultState);
emitPwAvailability();
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) }; return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); } } 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"); if (!e) return vaultErr("no such entry");
// Whitelist mutable fields; never let the renderer overwrite id/addedAt. // 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]; 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. // Switching between literal and generated: drop the other field.
if (patch && "literal" in patch) delete e.generated; if (patch && "literal" in patch) delete e.generated;
if (patch && "generated" in patch) delete e.literal; if (patch && "generated" in patch) delete e.literal;
await v.saveVault(vaultFile(), vaultState); await v.saveVault(vaultFile(), vaultState);
emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) }; return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); } } catch (e) { return vaultErr(e?.message || e); }
}); });
@ -7495,6 +7575,7 @@ ipcMain.handle("password-remove", async (_e, id) => {
const v = await loadVaultLib(); const v = await loadVaultLib();
vaultState.entries = vaultState.entries.filter((x) => x.id !== id); vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
await v.saveVault(vaultFile(), vaultState); await v.saveVault(vaultFile(), vaultState);
emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) }; return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); } } 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) => { ipcMain.handle("pw-fill-pick", async (e, id) => {
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" }; if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
showPwFill(false); 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" }; if (!vaultState) return { ok: false, err: "locked" };
try { try {
const v = await loadVaultLib(); const v = await loadVaultLib();
const entry = vaultState.entries.find((x) => x.id === id); const entry = vaultState.entries.find((x) => x.id === id);
if (!entry) return { ok: false, err: "no such entry" }; 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); const password = await v.resolvePassword(vaultState, id);
return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password }); return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password });
} catch (e) { return { ok: false, err: e?.message || String(e) }; } } 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 // The chrome sends arrow-up/down/enter through so the picker can move its
// selection cursor without stealing focus from the address input. // selection cursor without stealing focus from the address input.
ipcMain.handle("address-cursor", (_e, dir) => { ipcMain.handle("address-cursor", (_e, dir) => {
@ -8753,6 +9009,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
cookies: settings.clearCookiesOnQuit, cookies: settings.clearCookiesOnQuit,
cache: settings.clearCacheOnQuit, cache: settings.clearCacheOnQuit,
storage: settings.clearStorageOnQuit, storage: settings.clearStorageOnQuit,
keep: settings.keepSignInsOnQuit ? signinSites().keepOrigins() : [],
}); });
// Session file AND the address-bar history (history.json). // Session file AND the address-bar history (history.json).
if (settings.clearHistoryOnQuit) { if (settings.clearHistoryOnQuit) {

View file

@ -25,14 +25,18 @@
</div> </div>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;"); const esc = (s) => String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
function report() { requestAnimationFrame(() => { try { window.pwfill.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); } function report() { requestAnimationFrame(() => { try { window.pwfill.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
window.pwfill.onMatches((data) => { window.pwfill.onMatches((data) => {
const list = $("list"); const list = $("list");
const matches = data.matches || []; const matches = data.matches || [];
if (!matches.length) { list.innerHTML = `<div class="empty">No saved credentials for this site.</div>`; report(); return; } document.querySelector(".hdr").textContent = data.locked ? "Theseus Vault" : "Fill password for this site";
list.innerHTML = matches.map((m) => if (data.locked) {
`<div class="item" data-id="${esc(m.id)}"><span class="ic">🔑</span><span class="txt"><div class="u">${esc(m.username || "(no username)")}</div><div class="d">${esc(m.domain)}</div></span></div>` // The vault is locked, but it has a login for this site.
list.innerHTML = `<div class="item" data-id="__unlock"><span class="ic">🔒</span><span class="txt"><div class="u">Sign in with a saved login</div><div class="d">Unlock your vault to fill ${esc(data.host)}</div></span></div>`;
} else if (!matches.length) { list.innerHTML = `<div class="empty">No saved credentials for this site.</div>`; report(); return; }
else list.innerHTML = matches.map((m) =>
`<div class="item" data-id="${esc(m.id)}"><span class="ic">🔑</span><span class="txt"><div class="u">${esc(m.username || "(no username)")}</div><div class="d">${esc(m.domain)}${m.confirm ? " · asks for your PIN" : ""}</div></span></div>`
).join(""); ).join("");
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.pwfill.pick(el.dataset.id)); list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.pwfill.pick(el.dataset.id));
report(); report();

View file

@ -29,6 +29,9 @@ contextBridge.exposeInMainWorld("cfg", {
pinStatus: () => ipcRenderer.invoke("vault-pin-status"), pinStatus: () => ipcRenderer.invoke("vault-pin-status"),
pinSet: (pin, masterPassword) => ipcRenderer.invoke("vault-pin-set", { pin, masterPassword }), pinSet: (pin, masterPassword) => ipcRenderer.invoke("vault-pin-set", { pin, masterPassword }),
pinClear: () => ipcRenderer.invoke("vault-pin-clear"), pinClear: () => ipcRenderer.invoke("vault-pin-clear"),
pinCheckMaster: (masterPassword) => ipcRenderer.invoke("vault-check-master", masterPassword),
// Asks for the PIN or master password even while the vault is open.
pwConfirm: (reason) => ipcRenderer.invoke("vault-confirm", reason),
pinUnlock: () => ipcRenderer.invoke("vault-pin-unlock"), pinUnlock: () => ipcRenderer.invoke("vault-pin-unlock"),
// Main asks settings to jump to a specific sidebar section (e.g. from the // Main asks settings to jump to a specific sidebar section (e.g. from the
// engine picker's "Search settings…" click). Emits the section id string. // engine picker's "Search settings…" click). Emits the section id string.

View file

@ -252,6 +252,34 @@
.ctxmenu{ background:#ffffff; border-color:rgba(37,58,73,.15); color:#253A49; } .ctxmenu{ background:#ffffff; border-color:rgba(37,58,73,.15); color:#253A49; }
.ctxmenu .mi:hover{ background:rgba(37,58,73,.06); } .ctxmenu .mi:hover{ background:rgba(37,58,73,.06); }
} }
/* PIN setup dialog — the same pad as the unlock prompt and Aegis. */
.pinwiz{position:fixed;inset:0;background:rgba(0,0,0,.5);display:grid;place-items:center;z-index:50;padding:16px}
/* display:grid / display:flex would otherwise beat the hidden attribute */
.pinwiz[hidden], .row[hidden]{display:none}
.pinwiz-box{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px 16px;width:min(400px,100%);box-shadow:0 20px 60px #000c}
.pinwiz-box h3{margin:6px 0 4px;font-size:17px}
.pinwiz-text{color:var(--mut);font-size:13px;margin:0 0 14px}
.pinwiz-steps{display:flex;gap:6px}
.pinwiz-steps span{flex:1;height:3px;border-radius:2px;background:var(--line)}
.pinwiz-steps span.on{background:var(--acid)}
.pinwiz-box input[type=password]{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:inherit;font-size:14px}
.pinwiz-err{color:#f6768a;font-size:12.5px;min-height:18px;margin:8px 0 2px;text-align:center}
.pinwiz-act{display:flex;justify-content:space-between;gap:8px;margin-top:6px}
.pinwiz-len{display:flex;justify-content:center;gap:6px;margin:0 0 12px}
.pinwiz-len button{border:1px solid var(--line);background:transparent;color:var(--mut);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer}
.pinwiz-len button.on{border-color:var(--acid);color:var(--acid-text);background:rgb(from var(--acid) r g b / .12)}
.pinpad{display:flex;flex-direction:column;align-items:center;gap:14px}
.pinpad .pindots{display:flex;gap:12px}
.pinpad .pindot{width:12px;height:12px;border-radius:50%;border:1.5px solid var(--dim);transition:background .12s,border-color .12s}
.pinpad .pindot.on{background:var(--acid);border-color:var(--acid)}
.pinpad .pinkeys{display:grid;grid-template-columns:repeat(3,62px);gap:8px}
.pinpad .pinkeys button{height:46px;border-radius:10px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:500 18px system-ui,sans-serif;cursor:pointer}
.pinpad .pinkeys button:hover{border-color:var(--acid);color:var(--acid-text)}
.pinpad .pinkeys button.util{background:transparent;font-size:13px;color:var(--dim)}
@media (prefers-color-scheme: light){
.pinwiz-box input[type=password], .pinpad .pinkeys button{background:#f1f5f8;color:#253A49}
.pinpad .pinkeys button.util{background:transparent}
}
</style></head> </style></head>
<body> <body>
<div class="app"> <div class="app">
@ -601,13 +629,8 @@
<!-- State B: vault exists but locked --> <!-- State B: vault exists but locked -->
<div id="pwLocked" hidden> <div id="pwLocked" hidden>
<div class="row"> <div class="row">
<div class="txt"><div class="t">Unlock vault</div><div class="d">Enter your master password to view or add entries.</div></div> <div class="txt"><div class="t">Your vault is locked</div><div class="d" id="pwLockedDesc">Unlock it with your master password to view or add entries.</div></div>
<div class="ctl" style="align-items:stretch"><input id="pwUnlockPw" type="password" placeholder="Master password"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div> <div class="ctl"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
</div>
<div id="pwUnlockErr" class="pmuted" style="color:#f6768a;font-size:12.5px;margin-top:4px" hidden></div>
<div class="row" id="pwPinUnlockRow" hidden>
<div class="txt"><div class="t">Or use your PIN</div><div class="d">Five wrong PINs lock it for 15 minutes; the master password always works.</div></div>
<div class="ctl"><button id="pwPinUnlockBtn" class="btn" type="button">Unlock with PIN</button></div>
</div> </div>
</div> </div>
<!-- State C: vault unlocked --> <!-- State C: vault unlocked -->
@ -619,14 +642,19 @@
<h2 class="sub">Quick-unlock PIN</h2> <h2 class="sub">Quick-unlock PIN</h2>
<div class="row"> <div class="row">
<div class="txt"><div class="t">PIN</div> <div class="txt"><div class="t">PIN</div>
<div class="d" id="pinDesc">A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.</div></div> <div class="d" id="pinDesc">A PIN of 6 to 8 digits that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.</div></div>
<div class="ctl"><button id="pinSetBtn" class="btn" type="button">Set a PIN</button><button id="pinClearBtn" class="btn" type="button" hidden>Remove</button></div> <div class="ctl"><button id="pinSetBtn" class="btn" type="button">Set a PIN</button><button id="pinClearBtn" class="btn" type="button" hidden>Remove</button></div>
</div> </div>
<div id="pinForm" class="row" style="flex-direction:column;align-items:stretch;gap:8px" hidden> <!-- Step-by-step PIN setup: master password → choose PIN → repeat → done. -->
<div class="addeng"><input id="pinMaster" type="password" placeholder="Master password" autocomplete="current-password"></div> <div id="pinWiz" class="pinwiz" hidden>
<div class="addeng"><input id="pinNew1" type="password" inputmode="numeric" maxlength="6" placeholder="New 6-digit PIN" autocomplete="off"><input id="pinNew2" type="password" inputmode="numeric" maxlength="6" placeholder="Repeat PIN" autocomplete="off"></div> <div class="pinwiz-box" role="dialog" aria-modal="true" aria-labelledby="pinWizTitle">
<div id="pinErr" class="pmuted" style="color:#f6768a;font-size:12.5px" hidden></div> <div class="pinwiz-steps"><span data-step="1"></span><span data-step="2"></span><span data-step="3"></span></div>
<div style="display:flex;justify-content:flex-end;gap:6px"><button id="pinCancel" class="btn" type="button">Cancel</button><button id="pinSave" class="btn" type="button">Save PIN</button></div> <h3 id="pinWizTitle"></h3>
<p id="pinWizText" class="pinwiz-text"></p>
<div id="pinWizBody"></div>
<div id="pinWizErr" class="pinwiz-err"></div>
<div class="pinwiz-act"><button id="pinWizCancel" class="btn ghost" type="button">Cancel</button><button id="pinWizNext" class="btn" type="button">Next</button></div>
</div>
</div> </div>
<div id="pwList"></div> <div id="pwList"></div>
<h2 class="sub">Add an entry</h2> <h2 class="sub">Add an entry</h2>
@ -640,7 +668,20 @@
<div class="addeng"><button id="pwAddPreview" class="btn" type="button" style="flex:none">Preview</button><input id="pwAddPreviewOut" readonly placeholder="preview appears here" style="font-family:ui-monospace,monospace"></div> <div class="addeng"><button id="pwAddPreview" class="btn" type="button" style="flex:none">Preview</button><input id="pwAddPreviewOut" readonly placeholder="preview appears here" style="font-family:ui-monospace,monospace"></div>
<div style="display:flex;justify-content:flex-end"><button id="pwAddBtn" class="btn" type="button">Save entry</button></div> <div style="display:flex;justify-content:flex-end"><button id="pwAddBtn" class="btn" type="button">Save entry</button></div>
</div> </div>
<div class="note">On a site with a saved login, a key appears in the address bar while the vault is unlocked: click it to fill the login.</div> <div class="note">On a site with a saved login, Theseus offers it under the sign-in form, and a key appears in the address bar while the vault is unlocked.</div>
</div>
<h2 class="sub">Signing in</h2>
<div class="row">
<div class="txt"><div class="t">Offer to save passwords</div><div class="d">After you sign in or sign up on a site, Theseus asks whether to keep the login in your vault. You can also ask it to check your PIN or password before it fills that login.</div></div>
<label class="sw"><input type="checkbox" id="pwOfferSave"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Offer saved logins on sign-in forms</div><div class="d">When you click into a sign-in form, saved logins for that site appear under it. While the vault is locked, Theseus offers to unlock it.</div></div>
<label class="sw"><input type="checkbox" id="pwOfferFill"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" id="pwNeverRow" hidden>
<div class="txt"><div class="t">Never saved</div><div class="d" id="pwNeverList"></div></div>
<div class="ctl"><button id="pwNeverClear" class="btn ghost" type="button">Clear list</button></div>
</div> </div>
</section> </section>
<!-- NAMING --> <!-- NAMING -->
@ -822,6 +863,10 @@
<div class="txt"><div class="t">Clear cookies on quit</div><div class="d">Drops session + persistent cookies. You'll sign in again next launch.</div></div> <div class="txt"><div class="t">Clear cookies on quit</div><div class="d">Drops session + persistent cookies. You'll sign in again next launch.</div></div>
<label class="sw"><input type="checkbox" id="clearCookiesOnQuit"><span class="track"><span class="knob"></span></span></label> <label class="sw"><input type="checkbox" id="clearCookiesOnQuit"><span class="track"><span class="knob"></span></span></label>
</div> </div>
<div class="row" style="margin-left:24px">
<div class="txt"><div class="t">Keep sign-ins for sites in your vault</div><div class="d">Sites with a login saved in Settings › Passwords keep their cookies and site storage, so you stay signed in. Everything else is still cleared. If a site signs you out anyway, Theseus offers your saved login on its sign-in form.</div></div>
<label class="sw"><input type="checkbox" id="keepSignInsOnQuit"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row"> <div class="row">
<div class="txt"><div class="t">Clear HTTP cache on quit</div><div class="d">Drops cached images / scripts / stylesheets. Sites re-download; small disk win.</div></div> <div class="txt"><div class="t">Clear HTTP cache on quit</div><div class="d">Drops cached images / scripts / stylesheets. Sites re-download; small disk win.</div></div>
<label class="sw"><input type="checkbox" id="clearCacheOnQuit"><span class="track"><span class="knob"></span></span></label> <label class="sw"><input type="checkbox" id="clearCacheOnQuit"><span class="track"><span class="knob"></span></span></label>
@ -1133,7 +1178,8 @@
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
})(); })();
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "extensionsOnDemand", "walletAtLaunch", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc", const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "extensionsOnDemand", "walletAtLaunch", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit", "clearCookiesOnQuit", "keepSignInsOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"pwOfferSave", "pwOfferFill",
"quickLinksShow", "translateAutoOffer"]; "quickLinksShow", "translateAutoOffer"];
// Text inputs that round-trip through settings-set on change. Trimmed; a // Text inputs that round-trip through settings-set on change. Trimmed; a
// cleared field writes an empty string, which main re-defaults from // cleared field writes an empty string, which main re-defaults from
@ -2106,12 +2152,23 @@
pwLockedEl.hidden = which !== "locked"; pwLockedEl.hidden = which !== "locked";
pwUnlockedEl.hidden = which !== "unlocked"; pwUnlockedEl.hidden = which !== "unlocked";
} }
async function pwRenderNever() {
const s = await C.get();
const list = Array.isArray(s.pwNeverSave) ? s.pwNeverSave : [];
document.getElementById("pwNeverRow").hidden = !list.length;
document.getElementById("pwNeverList").textContent = list.length
? `Theseus won't offer to save passwords on: ${list.join(", ")}.` : "";
}
document.getElementById("pwNeverClear").onclick = async () => { await C.set("pwNeverSave", []); pwRenderNever(); };
async function pwRefresh() { async function pwRefresh() {
pwRenderNever().catch(() => {});
const st = await C.pwStatus(); const st = await C.pwStatus();
if (!st.setup) return pwShow("setup"); if (!st.setup) return pwShow("setup");
const pin = await C.pinStatus().catch(() => null); const pin = await C.pinStatus().catch(() => null);
if (!st.unlocked) { if (!st.unlocked) {
document.getElementById("pwPinUnlockRow").hidden = !(pin && pin.pinSet); document.getElementById("pwLockedDesc").textContent = pin && pin.pinSet
? "Unlock it with your PIN or master password to view or add entries."
: "Unlock it with your master password to view or add entries.";
return pwShow("locked"); return pwShow("locked");
} }
pwShow("unlocked"); pwShow("unlocked");
@ -2125,7 +2182,7 @@
document.getElementById("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN"; document.getElementById("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN";
document.getElementById("pinClearBtn").hidden = !set; document.getElementById("pinClearBtn").hidden = !set;
const desc = document.getElementById("pinDesc"); const desc = document.getElementById("pinDesc");
const base = "A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works."; const base = "A PIN of 6 to 8 digits that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.";
desc.textContent = set && pin.lockedMs > 0 ? base + ` Locked after wrong tries — it works again in ${Math.max(1, Math.ceil(pin.lockedMs / 60000))} min, or at once after a master-password unlock.` desc.textContent = set && pin.lockedMs > 0 ? base + ` Locked after wrong tries — it works again in ${Math.max(1, Math.ceil(pin.lockedMs / 60000))} min, or at once after a master-password unlock.`
: set && pin.hardware === "tpm" ? base + " It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk." : set && pin.hardware === "tpm" ? base + " It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
: set ? base + " This computer has no usable security chip, so the PIN only stops casual use: anything running as your Windows account, or a copy of this disk with your Windows password, can find it in minutes and with it your master password." : set ? base + " This computer has no usable security chip, so the PIN only stops casual use: anything running as your Windows account, or a copy of this disk with your Windows password, can find it in minutes and with it your master password."
@ -2133,33 +2190,125 @@
: base; : base;
document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false; document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false;
} }
const pinForm = document.getElementById("pinForm"); // Step-by-step PIN setup. Step 1 proves the master password (the PIN
const pinErr = document.getElementById("pinErr"); // wraps it), step 2 picks the PIN on a pad, step 3 repeats it, then
document.getElementById("pinSetBtn").onclick = () => { pinForm.hidden = false; pinErr.hidden = true; document.getElementById("pinMaster").focus(); }; // main seals it. The master password stays in this closure only until
document.getElementById("pinCancel").onclick = () => { // the dialog closes.
pinForm.hidden = true; const pinWiz = {
for (const id of ["pinMaster", "pinNew1", "pinNew2"]) document.getElementById(id).value = ""; el: document.getElementById("pinWiz"),
}; step: 0, master: "", first: "", entry: "", len: 6, busy: false,
document.getElementById("pinSave").onclick = async () => { open() {
pinErr.hidden = true; Object.assign(this, { step: 1, master: "", first: "", entry: "", len: 6, busy: false });
const master = document.getElementById("pinMaster").value; this.el.hidden = false;
const p1 = document.getElementById("pinNew1").value; this.render();
const p2 = document.getElementById("pinNew2").value; },
const fail = (m) => { pinErr.textContent = m; pinErr.hidden = false; }; close() {
if (!/^\d{6}$/.test(p1)) return fail("The PIN must be 6 digits."); Object.assign(this, { step: 0, master: "", first: "", entry: "" });
if (p1 !== p2) return fail("The two PINs don't match."); this.el.hidden = true;
if (!master) return fail("Enter your master password to bind the PIN to it."); },
const res = await C.pinSet(p1, master); error(msg) { document.getElementById("pinWizErr").textContent = msg || ""; },
if (!res.ok) return fail(res.err === "wrong master password" ? "Wrong master password." : res.err); press(k) {
document.getElementById("pinCancel").click(); if (this.busy || (this.step !== 2 && this.step !== 3)) return;
pwRefresh(); if (k === "back") this.entry = this.entry.slice(0, -1);
else if (k === "clear") this.entry = "";
else if (this.entry.length < this.len) this.entry += k;
this.error("");
this.paintDots();
if (this.entry.length === this.len) setTimeout(() => this.next(), 120);
},
paintDots() {
document.querySelectorAll("#pinWizBody .pindot").forEach((d, i) => d.classList.toggle("on", i < this.entry.length));
},
pad() {
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
return `<div class="pinpad"><div class="pindots">${Array.from({ length: this.len }, () => `<span class="pindot"></span>`).join("")}</div>` +
`<div class="pinkeys">${keys.map((k) => `<button type="button" data-k="${k}" class="${k === "back" || k === "clear" ? "util" : ""}" aria-label="${k === "back" ? "Delete" : k === "clear" ? "Clear" : k}">${k === "back" ? "⌫" : k === "clear" ? "Clear" : k}</button>`).join("")}</div></div>`;
},
render() {
const title = document.getElementById("pinWizTitle"), text = document.getElementById("pinWizText");
const body = document.getElementById("pinWizBody"), nextBtn = document.getElementById("pinWizNext");
document.querySelectorAll(".pinwiz-steps span").forEach((s) => s.classList.toggle("on", +s.dataset.step <= this.step));
this.error("");
if (this.step === 1) {
title.textContent = "Confirm your master password";
text.textContent = "The PIN unlocks the vault by opening your master password, so Theseus needs it once to set the PIN up.";
body.innerHTML = `<input id="pinWizPw" type="password" placeholder="Master password" autocomplete="current-password">`;
const inp = document.getElementById("pinWizPw");
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") this.next(); });
setTimeout(() => inp.focus(), 0);
nextBtn.hidden = false; nextBtn.textContent = "Next";
} else if (this.step === 2) {
title.textContent = "Choose a PIN";
text.textContent = "Pick how many digits, then type them. Longer is stronger.";
body.innerHTML = `<div class="pinwiz-len">${[6, 7, 8].map((n) => `<button type="button" data-len="${n}" class="${n === this.len ? "on" : ""}">${n} digits</button>`).join("")}</div>` + this.pad();
body.querySelectorAll("[data-len]").forEach((b) => b.onclick = () => { this.len = +b.dataset.len; this.entry = ""; this.render(); });
nextBtn.hidden = true;
} else if (this.step === 3) {
title.textContent = "Repeat your PIN";
text.textContent = `Type the same ${this.len} digits again.`;
body.innerHTML = this.pad();
nextBtn.hidden = true;
} else if (this.step === 4) {
title.textContent = "PIN set";
text.textContent = this.hardware === "tpm"
? "Your PIN now unlocks the vault here, in Aegis and in extensions. It is tied to this computer's security chip, which allows only a few wrong guesses an hour."
: "Your PIN now unlocks the vault here, in Aegis and in extensions. This computer has no usable security chip, so the PIN only stops casual use.";
body.innerHTML = "";
nextBtn.hidden = false; nextBtn.textContent = "Done";
}
body.querySelectorAll("[data-k]").forEach((b) => b.onclick = () => this.press(b.dataset.k));
document.getElementById("pinWizCancel").hidden = this.step === 4;
},
async next() {
if (this.busy) return;
if (this.step === 1) {
const pw = document.getElementById("pinWizPw").value;
if (!pw) return this.error("Enter your master password.");
this.busy = true;
const r = await C.pinCheckMaster(pw).catch((e) => ({ ok: false, err: e.message }));
this.busy = false;
if (!r.ok) return this.error("Wrong master password.");
this.master = pw; this.step = 2; this.render();
} else if (this.step === 2) {
if (this.entry.length !== this.len) return;
this.first = this.entry; this.entry = ""; this.step = 3; this.render();
} else if (this.step === 3) {
if (this.entry.length !== this.len) return;
if (this.entry !== this.first) {
this.first = ""; this.entry = ""; this.step = 2; this.render();
return this.error("The PINs didn't match. Choose your PIN again.");
}
this.busy = true;
document.getElementById("pinWizText").textContent = "Saving… the security chip can take a few seconds.";
const res = await C.pinSet(this.first, this.master).catch((e) => ({ ok: false, err: e.message }));
this.busy = false;
if (!res.ok) { this.entry = ""; this.render(); return this.error(res.err === "wrong master password" ? "Wrong master password." : res.err); }
this.hardware = res.hardware; this.master = ""; this.first = ""; this.entry = "";
this.step = 4; this.render();
pwRefresh();
} else if (this.step === 4) {
this.close();
}
},
}; };
document.getElementById("pinSetBtn").onclick = () => pinWiz.open();
document.getElementById("pinWizCancel").onclick = () => pinWiz.close();
document.getElementById("pinWizNext").onclick = () => pinWiz.next();
pinWiz.el.addEventListener("mousedown", (e) => { if (e.target === pinWiz.el && pinWiz.step !== 4) pinWiz.close(); });
document.addEventListener("keydown", (e) => {
if (pinWiz.el.hidden) return;
if (e.key === "Escape") return pinWiz.close();
if (/^[0-9]$/.test(e.key)) pinWiz.press(e.key);
else if (e.key === "Backspace" && (pinWiz.step === 2 || pinWiz.step === 3)) pinWiz.press("back");
});
document.getElementById("pinClearBtn").onclick = async () => { document.getElementById("pinClearBtn").onclick = async () => {
if (!confirm("Remove the PIN? The vault will need the master password again.")) return; if (!confirm("Remove the PIN? The vault will need the master password again.")) return;
await C.pinClear(); await C.pinClear();
pwRefresh(); pwRefresh();
}; };
document.getElementById("pwPinUnlockBtn").onclick = async () => { // Unlock opens Theseus's own centred prompt: the PIN pad when a PIN is
// set, the master password otherwise.
document.getElementById("pwUnlockBtn").onclick = async () => {
const r = await C.pinUnlock(); const r = await C.pinUnlock();
if (r && r.ok) pwRefresh(); if (r && r.ok) pwRefresh();
}; };
@ -2171,9 +2320,18 @@
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` + pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
`<span class="eic"><span class="es">🔑</span></span>` + `<span class="eic"><span class="es">🔑</span></span>` +
`<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` + `<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` +
`<button class="cx pwAsk" title="${e.confirm ? "Asks for your PIN or password before filling. Click to stop asking." : "Fills without asking. Click to require your PIN or password first."}" aria-pressed="${e.confirm ? "true" : "false"}" style="${e.confirm ? "" : "opacity:.35"}">🔒</button>` +
`<button class="cx pwShow" title="Show + copy">👁</button>` + `<button class="cx pwShow" title="Show + copy">👁</button>` +
`<button class="cx pwDel" title="Remove">✕</button>` + `<button class="cx pwDel" title="Remove">✕</button>` +
`</div>`).join(""); `</div>`).join("");
pwListEl.querySelectorAll(".pwAsk").forEach((b) => b.onclick = async (ev) => {
const id = ev.target.closest(".eng").dataset.id;
const on = b.getAttribute("aria-pressed") !== "true";
// Turning the check off needs the check itself.
if (!on) { const r = await C.pwConfirm(`Stop asking before filling this login?`); if (!r || !r.ok) return; }
await C.pwUpdate(id, { confirm: on });
pwRefresh();
});
pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => { pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => {
const id = ev.target.closest(".eng").dataset.id; const id = ev.target.closest(".eng").dataset.id;
const res = await C.pwGet(id); const res = await C.pwGet(id);
@ -2210,17 +2368,6 @@
document.getElementById("pwSetupMnemonic").value = ""; document.getElementById("pwSetupMnemonic").value = "";
pwRefresh(); pwRefresh();
}; };
// Unlock
document.getElementById("pwUnlockBtn").onclick = async () => {
const err = document.getElementById("pwUnlockErr");
err.hidden = true;
const pw = document.getElementById("pwUnlockPw").value;
const res = await C.pwUnlock(pw);
if (!res.ok) { err.textContent = res.err; err.hidden = false; return; }
document.getElementById("pwUnlockPw").value = "";
pwRefresh();
};
document.getElementById("pwUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("pwUnlockBtn").click(); });
// Lock // Lock
document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); }; document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); };
// Add-entry form: toggle literal input; wire preview + save // Add-entry form: toggle literal input; wire preview + save

View file

@ -15,11 +15,11 @@
html, body { margin: 0; height: 100%; background: transparent; } html, body { margin: 0; height: 100%; background: transparent; }
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); } body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); .promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: grid; place-items: start center; padding-top: 48px; } display: grid; place-items: center; padding: 16px; }
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; .promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px)); padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px));
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; } box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
@keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes pop { from { transform: scale(.97); opacity: 0; } to { transform: none; opacity: 1; } }
.who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; } .who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; }
.who .addon { color: var(--mut); } .who .addon { color: var(--mut); }
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; } .title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
@ -67,6 +67,7 @@
let req = null; let req = null;
let mode = "pin"; let mode = "pin";
let pin = ""; let pin = "";
let pinLength = 6; // 6-8; the PIN record says which, so the pad submits on the last digit
let busy = false; let busy = false;
let error = ""; let error = "";
@ -87,6 +88,7 @@
if (r && r.ok) { req = null; document.body.replaceChildren(); return; } if (r && r.ok) { req = null; document.body.replaceChildren(); return; }
error = (r && r.error) || "Could not unlock"; error = (r && r.error) || "Could not unlock";
if (r && r.mode) mode = r.mode; if (r && r.mode) mode = r.mode;
if (r && r.pinLength) pinLength = r.pinLength;
pin = ""; pin = "";
render(); render();
} }
@ -95,22 +97,23 @@
if (busy) return; if (busy) return;
if (d === "back") pin = pin.slice(0, -1); if (d === "back") pin = pin.slice(0, -1);
else if (d === "clear") pin = ""; else if (d === "clear") pin = "";
else if (pin.length < 6) pin += d; else if (pin.length < pinLength) pin += d;
error = ""; error = "";
render(); render();
if (pin.length === 6) submit(pin); if (pin.length === pinLength) submit(pin);
} }
function render() { function render() {
if (!req) return; if (!req) return;
const head = [ const head = [
el("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"), el("span", {}, "asks to unlock your vault")), el("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"),
el("span", {}, req.confirm ? "asks you to confirm it's you" : "asks to unlock your vault")),
el("h1", { class: "title" }, mode === "pin" ? "Enter your PIN" : "Enter your master password"), el("h1", { class: "title" }, mode === "pin" ? "Enter your PIN" : "Enter your master password"),
req.reason ? el("p", { class: "reason" }, req.reason) : null, req.reason ? el("p", { class: "reason" }, req.reason) : null,
]; ];
let bodyEls; let bodyEls;
if (mode === "pin") { if (mode === "pin") {
const dots = el("div", { class: "pindots" }, ...Array.from({ length: 6 }, (_, i) => el("span", { class: i < pin.length ? "pindot on" : "pindot" }))); const dots = el("div", { class: "pindots" }, ...Array.from({ length: pinLength }, (_, i) => el("span", { class: i < pin.length ? "pindot on" : "pindot" })));
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"]; const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
const pad = el("div", { class: "pinkeys" }, ...keys.map((k) => const pad = el("div", { class: "pinkeys" }, ...keys.map((k) =>
el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k }, el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k },
@ -139,6 +142,7 @@
window.unlock.onShow((r) => { window.unlock.onShow((r) => {
req = r; req = r;
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password"; mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
pinLength = r.pinLength || 6;
pin = ""; pin = "";
error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : ""; error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : "";
busy = false; busy = false;