diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index af38bfcf..135642b3 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1355,6 +1355,57 @@ async function tryTpmKey(api, pin) { try { return await tpmPin.create(pin); } catch (e) { tpmUnavailableThisBoot = true; api.log("PIN: no TPM key:", e?.message || e); return null; } } +// ---- one PIN: the Theseus vault PIN --------------------------------------- +// On a host with api.vault.pin (features.vaultPin) there is one PIN in +// Theseus: the vault PIN, checked in Theseus main against one strike +// counter, also used by Settings, the unlock prompt and Pithos. Aegis keeps +// its PIN pads and their wording and sends the digits there; the host opens +// the vault and answers only whether the PIN was right, so the master +// password never reaches Aegis. What the panel gets back in place of the +// password is a single-use proof (mintPinProof), which the handlers that +// used to verify a password accept instead. On older hosts (0.3.74-0.3.76) +// Aegis keeps its own PIN blob (aegis/pin/v1) exactly as before. +const hostPinApi = (api) => (api && api.features && api.features.vaultPin && api.vault && api.vault.pin) ? api.vault.pin : null; +let hostPinCache = { pinSet: false, hardware: null, storable: true, at: 0 }; +async function refreshHostPin(api) { + const hp = hostPinApi(api); + if (!hp) return null; + try { const st = await hp.status(); hostPinCache = { ...st, at: Date.now() }; } catch (e) { api.log("vault PIN status:", e?.message || e); } + return hostPinCache; +} +// Is there a PIN to ask for? Aegis's own (old hosts, or not yet migrated) +// or the vault PIN. +function hasPinNow(api) { + return !!api.storage.get(PIN_BLOB_KEY, null) || (!!hostPinApi(api) && !!hostPinCache.pinSet); +} +const PIN_PROOF_TTL_MS = 120_000; +const pinProofs = new Map(); // token -> expiry +function mintPinProof() { + const t = "pinproof:" + nodeCrypto.randomBytes(24).toString("hex"); + for (const [k, exp] of pinProofs) if (exp < Date.now()) pinProofs.delete(k); + pinProofs.set(t, Date.now() + PIN_PROOF_TTL_MS); + return t; +} +function takePinProof(t) { + const exp = pinProofs.get(t); + if (!exp) return false; + pinProofs.delete(t); + return exp > Date.now(); +} +// Where a handler used to check a master password: accept either the +// password (checked by the vault) or a fresh PIN proof minted above. +// Answers "pin" or "master"; throws when neither holds. +async function verifyPasswordOrProof(api, pw) { + const s = String(pw || ""); + if (s.startsWith("pinproof:")) { + if (!takePinProof(s)) throw new Error("PIN proof rejected"); + return "pin"; + } + await api.vault.lifecycle.unlock(s); + noteMasterVerified(api); + return "master"; +} + function pinFails(api) { const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0; const last = Number(api.storage.get("aegis/pin/failLast", 0)) || 0; @@ -1399,6 +1450,7 @@ function requirePanelTxPin() { throw new Error("PIN required — confirm your PIN to continue"); } async function requireDappTxPin(origin, what) { + await refreshHostPin(ctx.api); if (!txPinOwed()) return; const req = { id: nodeCrypto.randomBytes(8).toString("hex"), origin: String(origin || ""), what: String(what || "transaction"), at: Date.now(), cleared: false }; pendingPinRequests.set(req.id, req); @@ -1514,8 +1566,8 @@ function registerPanelMessages(api) { api.onMessage("vaultUnlock", async (p, m) => { fromPanel(m); const pw = String(p && p.masterPassword || ""); - await api.vault.lifecycle.unlock(pw); - noteMasterVerified(api); + // A PIN proof means Theseus has already opened the vault with the PIN. + await verifyPasswordOrProof(api, pw); await mountAllWallets(); return fullState(); }); @@ -1923,6 +1975,7 @@ function registerPanelMessages(api) { }); api.onMessage("sendToken", async (p, m) => { fromPanel(m); + await refreshHostPin(api); requirePanelTxPin(); // Same rule as send: the token send names the wallet it was reviewed for. if (!p || !p.walletId || String(p.walletId) !== selectedWalletId()) { @@ -1952,6 +2005,7 @@ function registerPanelMessages(api) { // Execute a send with approval overlay. api.onMessage("send", async (p, m) => { fromPanel(m); + await refreshHostPin(api); requirePanelTxPin(); // The panel names the wallet its form was built for. If the selection // moved since (another surface, a late state push), refuse rather than @@ -2055,6 +2109,7 @@ function registerPanelMessages(api) { api.onMessage("consolidateIntoSelected", async (p, m) => { fromPanel(m); + await refreshHostPin(api); requirePanelTxPin(); const destId = selectedWalletId(); if (!destId) throw new Error("no wallet selected"); @@ -2221,6 +2276,7 @@ function registerPanelMessages(api) { api.onMessage("promoteToHd", async (p, m) => { fromPanel(m); + await refreshHostPin(api); requirePanelTxPin(); const { entry, rt } = promotableImport(p && p.walletId); const dest = await createVaultWallet({ @@ -2295,8 +2351,9 @@ function registerPanelMessages(api) { fromPanel(m); const pw = String((p && p.masterPassword) || ""); if (!pw) throw new Error("master password required"); - try { await api.vault.lifecycle.unlock(pw); } + try { await verifyPasswordOrProof(api, pw); } catch (e) { + if (/PIN proof rejected/.test(e?.message || "")) throw new Error("PIN proof rejected — enter the PIN again"); // A missing or unset-up vault is a structural failure, not a bad // password — don't accuse the user of mistyping something that was // never going to work. @@ -2731,12 +2788,46 @@ function registerPanelMessages(api) { // the opaque blob + a small policy object in and out of api.storage. // Set or replace the PIN. The master password is checked against the vault // first, and the blob is built here, so the panel never holds one. + // Aegis's own PIN, gone: blob, its TPM key, its counters. + const dropOwnPin = () => { + const old = openPinBlob(api); + if (old?.hw?.key) tpmPin.remove(old.hw.key).catch(() => {}); + api.storage.set(PIN_BLOB_KEY, null); + api.storage.set("aegis/pin/failCount", 0); + api.storage.set("aegis/pin/failLast", 0); + }; + // A PIN entered correctly against Aegis's own blob while Theseus already + // has a different vault PIN: the user is asked once which one to keep. + let pendingPinMigration = null; // { pin, pw, until } + api.onMessage("pinMigrateResolve", async (p, m) => { + fromPanel(m); + const hp = hostPinApi(api); + const mig = pendingPinMigration; + pendingPinMigration = null; + if (!hp || !mig || mig.until < Date.now()) throw new Error("nothing to migrate"); + if (p && p.replace === true) await hp.set(mig.pin, mig.pw); + dropOwnPin(); + await refreshHostPin(api); + return { ok: true }; + }); + api.onMessage("pinSet", async (p, m) => { fromPanel(m); const pin = String((p && p.pin) || ""); const pw = String((p && p.masterPassword) || ""); if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits"); if (!pw) throw new Error("master password required"); + const hp = hostPinApi(api); + if (hp) { + // The one PIN: Theseus checks the master password and seals the PIN. + let r; + try { r = await hp.set(pin, pw); } + catch (e) { throw new Error(/wrong master password/.test(e?.message || "") ? "wrong master password" : (e?.message || String(e))); } + noteMasterVerified(api); + dropOwnPin(); + await refreshHostPin(api); + return { ok: true, hardware: r && r.hardware || null, unified: true }; + } try { await api.vault.lifecycle.unlock(pw); } catch { throw new Error("wrong master password"); } noteMasterVerified(api); @@ -2757,6 +2848,16 @@ function registerPanelMessages(api) { api.onMessage("pinUnwrap", async (p, m) => { fromPanel(m); const pin = String((p && p.pin) || ""); + const hp = hostPinApi(api); + if (hp && !api.storage.get(PIN_BLOB_KEY, null)) { + // The one PIN. Theseus counts the guess, opens the vault on success, + // and keeps the master password to itself. + const r = await hp.unlock(pin); + await refreshHostPin(api); + if (r && r.ok) return { ok: true, masterPassword: mintPinProof() }; + if (r && r.code === "no-pin") throw new Error("no PIN is set"); + return { ok: false, remaining: Number(r?.remaining) || 0, lockedMs: Number(r?.lockedMs) || 0, error: r?.error || undefined }; + } const blob = openPinBlob(api); if (!blob) throw new Error("no PIN is set"); const st = pinFails(api); @@ -2809,15 +2910,34 @@ function registerPanelMessages(api) { } } catch (e) { api.log("PIN re-wrap:", e?.message || e); } } + if (hp) { + // Migrate the old Aegis PIN to the one PIN on its first correct entry. + try { await api.vault.lifecycle.unlock(pw); } + catch { return { ok: false, remaining: 0, lockedMs: 0, error: "Your PIN is out of date. Enter the master password, then set a new PIN." }; } + const hs = await refreshHostPin(api); + if (!hs || !hs.pinSet) { + try { await hp.set(pin, pw); dropOwnPin(); await refreshHostPin(api); api.log("Aegis PIN moved to the Theseus vault PIN"); } + catch (e) { api.log("PIN migration:", e?.message || e); } + return { ok: true, masterPassword: mintPinProof(), migrated: true }; + } + pendingPinMigration = { pin, pw, until: Date.now() + PIN_PROOF_TTL_MS }; + return { ok: true, masterPassword: mintPinProof(), pinConflict: true }; + } return { ok: true, masterPassword: pw }; }); - api.onMessage("pinStatus", (_p, m) => { + api.onMessage("pinStatus", async (_p, m) => { fromPanel(m); + if (hostPinApi(api) && !api.storage.get(PIN_BLOB_KEY, null)) { + const hs = (await refreshHostPin(api)) || {}; + return { hasPin: !!hs.pinSet, fails: hs.fails || 0, last: hs.last || 0, maxFails: hs.maxFails || PIN_MAX_FAILS, lockedMs: hs.lockedMs || 0, unified: true, ...pinProtection() }; + } const f = pinFails(api); return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, last: f.last, maxFails: PIN_MAX_FAILS, lockedMs: f.lockedMs, ...pinProtection() }; }); - api.onMessage("pinBlobClear", (_p, m) => { + api.onMessage("pinBlobClear", async (_p, m) => { fromPanel(m); + const hp = hostPinApi(api); + if (hp) { try { await hp.clear(); } catch (e) { api.log("vault PIN clear:", e?.message || e); } await refreshHostPin(api); } const old = openPinBlob(api); if (old?.hw?.key) tpmPin.remove(old.hw.key).catch(() => {}); api.storage.set("aegis/pin/v1", null); @@ -2856,9 +2976,11 @@ function registerPanelMessages(api) { function pinProtection() { const blob = api.storage.get(PIN_BLOB_KEY, null) ? openPinBlob(api) : null; const exposed = api.storage.get(PIN_EXPOSED_KEY, null); + const host = hostPinApi(api) && !blob ? hostPinCache : null; return { - pinHardware: blob ? (blob.hw ? "tpm" : "none") : null, - pinStorable: osSealUsable(safeStorageOr(api)), + pinHardware: blob ? (blob.hw ? "tpm" : "none") : host ? (host.pinSet ? host.hardware || "none" : null) : null, + pinStorable: host ? host.storable !== false : osSealUsable(safeStorageOr(api)), + pinUnified: !!hostPinApi(api), pinLegacyExposure: exposed && !exposed.dismissed ? { at: exposed.at } : null, }; } @@ -2877,7 +2999,7 @@ function registerPanelMessages(api) { // panel reloads freely and must not be the thing that remembers whether a // gate was already satisfied. function pinNeeded(event) { - if (!api.storage.get("aegis/pin/v1", null)) return { needPin: false, reason: "no-pin" }; + if (!hasPinNow(api)) return { needPin: false, reason: "no-pin" }; const on = pinPolicy(); const g = pinGate(); if (on.interval) { @@ -2895,8 +3017,9 @@ function registerPanelMessages(api) { return { needPin: false, reason: "satisfied" }; } - api.onMessage("pinGateStatus", (p, m) => { + api.onMessage("pinGateStatus", async (p, m) => { fromPanel(m); + await refreshHostPin(api); const event = String(p && p.event || "panel-load"); return { ...pinNeeded(event), event, policy: pinPolicy() }; }); @@ -2910,9 +3033,8 @@ function registerPanelMessages(api) { const pw = String((p && p.masterPassword) || ""); if (!pw) throw new Error("PIN proof required"); if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN"); - try { await api.vault.lifecycle.unlock(pw); } + try { await verifyPasswordOrProof(api, pw); } catch { throw new Error("PIN proof rejected"); } - noteMasterVerified(api); api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); // What this proof clears (see requireDappTxPin). const forRequest = String((p && p.requestId) || ""); @@ -2938,11 +3060,12 @@ function registerPanelMessages(api) { // happens to open. try { openPinBlob(api); } catch {} - api.onMessage("securityGet", (_p, m) => { + api.onMessage("securityGet", async (_p, m) => { fromPanel(m); + await refreshHostPin(api); const cfg = api.storage.get("aegis/security/v1", {}) || {}; return { - hasPin: !!api.storage.get("aegis/pin/v1", null), + hasPin: hasPinNow(api), pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!cfg.requirePinForSending, @@ -2977,7 +3100,7 @@ function registerPanelMessages(api) { } api.storage.set("aegis/security/v1", next); return { - hasPin: !!api.storage.get("aegis/pin/v1", null), + hasPin: hasPinNow(api), pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!next.requirePinForSending, @@ -4614,6 +4737,8 @@ module.exports = { }; migrateLegacyStorage(api); registerPanelMessages(api); + // Learn whether the one PIN is set before the first gate is decided. + refreshHostPin(api).catch(() => {}); registerPageMessages(api); launchStartAsked = null; syncLaunchStart(api); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 63ddb3df..3bd3f3cb 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -313,6 +313,18 @@ const PIN_MAX_FAILS = 5; const pinTry = async (pin) => { const r = await S.invoke("pinUnwrap", { pin: String(pin) }); if (r && !r.ok && r.error) throw new Error(r.error); + // Theseus now has one PIN for everything. An Aegis PIN entered correctly + // while Theseus already has a different one: ask once which to keep. + if (r && r.ok && r.pinConflict) { + const replace = await aegisConfirm({ + title: "Use this PIN everywhere?", + confirmLabel: "Use this PIN", + cancelLabel: "Keep the Theseus PIN", + body: "Theseus and Aegis now share one PIN. Theseus already has a PIN of its own. Use the PIN you just entered for both (Settings, Pithos and Aegis), or keep the Theseus PIN — your old Aegis PIN then stops working.", + }); + try { await S.invoke("pinMigrateResolve", { replace: !!replace }); } catch {} + try { await refreshSecurityState(); } catch {} + } return r; }; async function pinLockoutRemainingMs() { @@ -4755,13 +4767,14 @@ async function renderGeneralSecurity() { if (chg) chg.hidden = !hasPin; if (rm) rm.hidden = !hasPin; // Say what the PIN really protects against on this machine. - if (hint) hint.textContent = hasPin + const shared = securityState.pinUnified ? " It is the Theseus PIN: the same PIN unlocks Settings › Passwords and Pithos." : ""; + if (hint) hint.textContent = (hasPin ? (securityState.pinHardware === "tpm" ? "On — a 6-digit PIN unlocks Aegis. 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." : "On — a 6-digit PIN unlocks Aegis. This computer has no usable security chip, so the PIN only stops casual use: anything that runs as your Windows account, or a copy of this disk with your Windows password, can find the PIN in minutes and with it your master password.") : (securityState.pinStorable === false ? "Off — this system has no protected keystore, so Aegis cannot store a PIN safely and asks for the master password every time." - : "Off — Aegis asks for the master password every time."); + : "Off — Aegis asks for the master password every time.")) + (hasPin ? shared : ""); if (set) set.disabled = !hasPin && securityState.pinStorable === false; // A PIN stored by Aegis before 0.31 sat on disk without OS protection. // Copies of the profile made back then still hold the master password @@ -4890,7 +4903,9 @@ $("gsPinRemove") && $("gsPinRemove").addEventListener("click", async () => { title: "Remove the quick-access PIN?", danger: true, confirmLabel: "Remove PIN", - body: "You'll have to type the master password on every unlock again.", + body: securityState.pinUnified + ? "This removes the Theseus PIN everywhere — Aegis, Settings › Passwords and Pithos. You'll have to type the master password on every unlock again." + : "You'll have to type the master password on every unlock again.", }); if (!ok) return; try {