diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 40815483..8fc06d98 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1230,6 +1230,45 @@ function openPinBlob(api) { return plain; } +// The PIN is checked here, never in the panel. The panel used to fetch the +// blob and decrypt it itself, then report its own failures — so the lockout +// counted only the guesses a well-behaved panel chose to report, and anything +// that could run in the panel could take the blob and search all million +// PINs offline. Now the blob stays in this process, every guess is counted +// before it is tried, and PIN_MAX_FAILS wrong guesses switch the PIN off +// until the master password is entered (no timer that hands out more tries). +// The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag +// appended to the ciphertext, as WebCrypto wrote it. +const PIN_ITERS = 600_000; +const PIN_MAX_FAILS = 5; +const PIN_RE = /^\d{6}$/; +const nodeCrypto = require("node:crypto"); +const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) => + nodeCrypto.pbkdf2(String(pin), Buffer.from(saltHex, "hex"), iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k)))); +async function pinWrap(pin, masterPassword) { + const salt = nodeCrypto.randomBytes(16).toString("hex"); + const iv = nodeCrypto.randomBytes(12); + const c = nodeCrypto.createCipheriv("aes-256-gcm", await pinKey(pin, salt, PIN_ITERS), iv); + const ct = Buffer.concat([c.update(String(masterPassword), "utf8"), c.final(), c.getAuthTag()]); + return { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS }; +} +async function pinUnwrapBlob(pin, blob) { + const ct = Buffer.from(String(blob.ct), "hex"); + const d = nodeCrypto.createDecipheriv("aes-256-gcm", await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS), Buffer.from(String(blob.iv), "hex")); + d.setAuthTag(ct.subarray(ct.length - 16)); + return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8"); +} +function pinFails(api) { + const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0; + return { fails: n, requireMaster: !!api.storage.get("aegis/pin/requireMaster", false) || n >= PIN_MAX_FAILS }; +} +// A master password the vault accepted. Clears the PIN strikes — the only +// thing that does, apart from a correct PIN while the PIN is still allowed. +function noteMasterVerified(api) { + api.storage.set("aegis/pin/failCount", 0); + api.storage.set("aegis/pin/requireMaster", false); +} + // "Ask for PIN on every transaction" used to be decided by the host and // enforced by nobody: `send` never checked it, and a dapp-initiated // transaction had no PIN step at all. A clearance is now a short-lived, @@ -1341,6 +1380,7 @@ function registerPanelMessages(api) { fromPanel(m); const pw = String(p && p.masterPassword || ""); await api.vault.lifecycle.unlock(pw); + noteMasterVerified(api); await mountAllWallets(); return fullState(); }); @@ -2122,6 +2162,7 @@ function registerPanelMessages(api) { if (/no vault|not set up/i.test(msg)) throw new Error(msg); throw new Error("wrong master password"); } + noteMasterVerified(api); const id = String((p && p.walletId) || selectedWalletId() || ""); const entry = walletEntries().find((w) => w.id === id); @@ -2546,52 +2587,61 @@ function registerPanelMessages(api) { // decryption inside its iframe — the master password never crosses the // process boundary except via vaultUnlock. These handlers only shuttle // the opaque blob + a small policy object in and out of api.storage. - api.onMessage("pinBlobGet", (_p, m) => { + // 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. + api.onMessage("pinSet", async (p, m) => { fromPanel(m); - return openPinBlob(api); - }); - api.onMessage("pinBlobSet", (p, m) => { - fromPanel(m); - const blob = p && p.blob; - if (!blob || typeof blob !== "object") throw new Error("blob required"); - if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") { - throw new Error("blob shape invalid"); - } - api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters })); + 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"); + try { await api.vault.lifecycle.unlock(pw); } + catch { throw new Error("wrong master password"); } + noteMasterVerified(api); + api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); return true; }); + // Try a PIN. Counts the guess before trying it, so a crash or a closed + // panel mid-check still costs an attempt. Answers + // { ok: true, masterPassword } | { ok: false, remaining, requireMaster } + api.onMessage("pinUnwrap", async (p, m) => { + fromPanel(m); + const pin = String((p && p.pin) || ""); + const blob = openPinBlob(api); + if (!blob) throw new Error("no PIN is set"); + if (pinFails(api).requireMaster) return { ok: false, remaining: 0, requireMaster: true }; + const before = pinFails(api).fails; + api.storage.set("aegis/pin/failCount", before + 1); + let pw = null; + if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } } + if (pw == null) { + const fails = before + 1; + if (fails >= PIN_MAX_FAILS) api.storage.set("aegis/pin/requireMaster", true); + return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - fails), requireMaster: fails >= PIN_MAX_FAILS }; + } + api.storage.set("aegis/pin/failCount", 0); + // A blob from an older build (200k iterations, or from before sealing) + // is re-made now, under a fresh salt, while the PIN is at hand. + if ((Number(blob.iters) || 0) < PIN_ITERS) { + try { api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); } catch (e) { api.log("PIN re-wrap:", e?.message || e); } + } + return { ok: true, masterPassword: pw }; + }); + api.onMessage("pinStatus", (_p, m) => { + fromPanel(m); + const f = pinFails(api); + return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, maxFails: PIN_MAX_FAILS, requireMaster: f.requireMaster }; + }); api.onMessage("pinBlobClear", (_p, m) => { fromPanel(m); api.storage.set("aegis/pin/v1", null); api.storage.set("aegis/pin/failCount", 0); + api.storage.set("aegis/pin/requireMaster", false); // Drop the gate record as well, so enrolling a new PIN later starts from // "not yet satisfied" rather than inheriting the old PIN's clearance. api.storage.set("aegis/pin/gate", null); return true; }); - // Track failed PIN attempts in the addon so a panel reload cannot bypass - // rate-limiting by dropping panel-side counters. - api.onMessage("pinFailInc", (_p, m) => { - fromPanel(m); - const cur = Number(api.storage.get("aegis/pin/failCount", 0)) || 0; - const next = cur + 1; - api.storage.set("aegis/pin/failCount", next); - api.storage.set("aegis/pin/failLast", Date.now()); - return { count: next, at: Date.now() }; - }); - api.onMessage("pinFailReset", (_p, m) => { - fromPanel(m); - api.storage.set("aegis/pin/failCount", 0); - api.storage.set("aegis/pin/failLast", 0); - return true; - }); - api.onMessage("pinFailStatus", (_p, m) => { - fromPanel(m); - return { - count: Number(api.storage.get("aegis/pin/failCount", 0)) || 0, - last: Number(api.storage.get("aegis/pin/failLast", 0)) || 0, - }; - }); // When to ask for the PIN. These are independent triggers, not a single // mode: wanting one at startup and one per transaction is a normal @@ -2658,6 +2708,7 @@ function registerPanelMessages(api) { 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); } catch { throw new Error("PIN proof rejected"); } + noteMasterVerified(api); api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); txClearanceUntil = Date.now() + TX_CLEARANCE_MS; return true; @@ -2678,6 +2729,7 @@ function registerPanelMessages(api) { const cfg = api.storage.get("aegis/security/v1", {}) || {}; return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinRequireMaster: pinFails(api).requireMaster, pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!cfg.requirePinForSending, @@ -2712,6 +2764,7 @@ function registerPanelMessages(api) { api.storage.set("aegis/security/v1", next); return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinRequireMaster: pinFails(api).requireMaster, pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!next.requirePinForSending, diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 4e041d9b..80bfe653 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -300,48 +300,20 @@ function fiatSkeleton() { return state?.prices?.enabled ? "≈ $—" : null; } -// ---- security: PIN encryption + verification (WebCrypto) ------------------- -// The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters) -// derives an AES-GCM key; the master password is encrypted with a fresh -// per-blob IV. The addon (main process) only handles the opaque blob; the -// panel never sends the raw PIN or the master password to it. The rate -// limiter is stored addon-side so reloading the panel cannot reset it. -const PIN_ITERS = 600000; // new blobs only; an existing blob carries its own count -const PIN_MAX_FAILS = 5; -const PIN_LOCKOUT_MS = 15 * 60 * 1000; -const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); -const h2b = (h) => { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return b; }; -async function pinDeriveKey(pin, saltBytes, iters) { - const enc = new TextEncoder(); - const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); - return crypto.subtle.deriveKey( - { name: "PBKDF2", salt: saltBytes, iterations: iters, hash: "SHA-256" }, - material, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"], - ); -} -async function pinEncryptMaster(pin, masterPassword) { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const key = await pinDeriveKey(pin, salt, PIN_ITERS); - const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(masterPassword))); - return { salt: b2h(salt), iv: b2h(iv), ct: b2h(ct), iters: PIN_ITERS }; -} -async function pinDecryptMaster(pin, blob) { - const key = await pinDeriveKey(pin, h2b(blob.salt), blob.iters || PIN_ITERS); - const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: h2b(blob.iv) }, key, h2b(blob.ct)); - return new TextDecoder().decode(pt); -} -async function pinLockoutRemainingMs() { - try { - const s = await S.invoke("pinFailStatus"); - if (!s || !s.count || s.count < PIN_MAX_FAILS) return 0; - const since = Date.now() - (s.last || 0); - return since >= PIN_LOCKOUT_MS ? 0 : (PIN_LOCKOUT_MS - since); - } catch { return 0; } +// ---- security: PIN ---------------------------------------------------------- +// The pads below only collect six digits. The host (index.js pinUnwrap) +// holds the PIN blob, counts every guess before trying it, and after too +// many wrong ones switches the PIN off until the master password is entered. +// The panel never sees the blob, so it cannot be searched from here, and it +// cannot reset the counter. +// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, requireMaster } +const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) }); +async function pinNeedsMaster() { + try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; } } +const PIN_MASTER_COPY = "Too many wrong PINs. Enter your master password; the PIN works again after that."; +const wrongPinCopy = (remaining) => + `Wrong PIN. ${remaining} attempt${remaining === 1 ? "" : "s"} left, then Aegis asks for your master password.`; async function refreshSecurityState() { try { securityState = await S.invoke("securityGet"); @@ -3374,7 +3346,7 @@ function renderLockScreen(phase) { const forcePw = body.dataset.forcePw === "1"; title.textContent = "Unlock Aegis"; sub.textContent = "Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet."; - if (hasPin && !forcePw) { + if (hasPin && !forcePw && !securityState?.pinRequireMaster) { // render() runs on every state push — balance polls fire it every couple // of seconds — and this used to rebuild body.innerHTML each time, wiping // the pad DOM and its digit buffer out from under someone mid-entry. @@ -3399,25 +3371,20 @@ function renderLockScreen(phase) { keys: body.querySelector("#lockPinKeys"), err: body.querySelector("#lockPinErr"), onComplete: async (pin) => { - const remain = await pinLockoutRemainingMs(); - if (remain > 0) { - $("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`; + let r; + try { r = await pinTry(pin); } + catch (e) { $("lockPinErr").textContent = cleanErr(e); return "reset"; } + if (!r.ok) { + $("lockPinErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining); + if (r.requireMaster) { body.dataset.forcePw = "1"; setTimeout(() => renderLockScreen("locked"), 1200); } return "reset"; } try { - const blob = await S.invoke("pinBlobGet"); - if (!blob) throw new Error("PIN not set"); - const pw = await pinDecryptMaster(pin, blob); - state = await S.invoke("vaultUnlock", { masterPassword: pw }); - await S.invoke("pinFailReset"); + state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword }); render(); return "ok"; } catch (e) { - const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); - const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0)); - $("lockPinErr").textContent = left > 0 - ? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.` - : `Locked for 15 min — use the master password instead.`; + $("lockPinErr").textContent = cleanErr(e); return "reset"; } }, @@ -3512,6 +3479,11 @@ function setupPinPad({ dots, keys, err, onComplete }) { // unlock, so six digits typed into the amount box counted as a PIN // attempt — and five such amounts locked the PIN for 15 minutes. if (dots.offsetParent === null) return; + // Two pads can be visible at once (a dapp's PIN request over a send's + // pad): only the topmost one listens, or six digits would complete both + // and a wrong PIN would cost two attempts. + const topModal = [...document.querySelectorAll(".pinmodal")].pop(); + if (topModal && !topModal.contains(dots)) return; const tgt = e.target; if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return; if (err) err.textContent = ""; @@ -5004,9 +4976,7 @@ async function handlePinSet(replacing) { }); if (!pin) return; try { - const blob = await pinEncryptMaster(pin, masterPw); - await S.invoke("pinBlobSet", { blob }); - await S.invoke("pinFailReset").catch(() => {}); + await S.invoke("pinSet", { pin, masterPassword: masterPw }); await refreshSecurityState(); renderGeneralSecurity(); } catch (e) { @@ -5253,11 +5223,11 @@ function paintPinDoor(reason) { } // How many wrong PINs before a sensitive reveal stops asking for the PIN and -// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose: -// someone fumbling their own PIN gets a way through that does not cost them a -// 15-minute lockout, and someone guessing is pushed onto the credential that -// is actually hard to guess. The global counter is NOT reset on the way -// across, so guesses still accumulate toward the lockout. +// asks for the master password instead. Lower than the host's limit (5) on +// purpose: someone fumbling their own PIN gets a way through before the PIN +// is switched off, and someone guessing is pushed onto the credential that +// is actually hard to guess. The host counter is NOT reset on the way +// across, so guesses still accumulate toward that limit. const REVEAL_PIN_MAX_FAILS = 3; // Prove entitlement to see a secret, and hand back the master password — @@ -5274,14 +5244,11 @@ async function authorizeForSecret(subtitle) { // the master password is the gate — never nothing. return promptMasterPassword({ title: "Confirm master password", subtitle }); } - const remain = await pinLockoutRemainingMs(); - if (remain > 0) { - // Locked out of the PIN, but the password is a separate credential and - // the lockout exists to stop PIN guessing, not to lock the owner out. - return promptMasterPassword({ - title: "Confirm master password", - subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(), - }); + if (await pinNeedsMaster()) { + // The PIN is switched off after too many wrong guesses, but the password + // is a separate credential: the strikes stop PIN guessing, they do not + // lock the owner out. + return promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() }); } const pin = await capturePinForSecret(subtitle); if (pin === null) return null; // cancelled @@ -5329,23 +5296,16 @@ function capturePinForSecret(subtitle) { setupPinPad({ dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"), onComplete: async (pin) => { - try { - const blob = await S.invoke("pinBlobGet"); - if (!blob) throw new Error("no PIN configured"); - const master = await pinDecryptMaster(pin, blob); - await S.invoke("pinFailReset").catch(() => {}); - done(master); - return "ok"; - } catch (e) { - tries++; - // Keep feeding the shared counter: these are real PIN guesses and - // they should still count toward the 15 min lockout. - await S.invoke("pinFailInc").catch(() => ({ count: 0 })); - if (tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; } - const left = REVEAL_PIN_MAX_FAILS - tries; - $("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`; - return "reset"; - } + let r; + try { r = await pinTry(pin); } + catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; } + if (r.ok) { done(r.masterPassword); return "ok"; } + // Every guess here also counts toward the host's limit. + tries++; + if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; } + const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining); + $("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`; + return "reset"; }, }); }); @@ -5366,10 +5326,11 @@ function verifyPinInteractively(subtitle) { } async function verifyPinInteractivelyOnce(subtitle) { - const remain = await pinLockoutRemainingMs(); - if (remain > 0) { - aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`); - return false; + // The PIN is off after too many wrong guesses; the master password is the + // same proof (it is what the PIN unwraps), and the host checks it. + if (await pinNeedsMaster()) { + const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() }); + return pw || false; } return new Promise((resolve) => { const wrap = document.createElement("div"); @@ -5398,24 +5359,22 @@ async function verifyPinInteractivelyOnce(subtitle) { setupPinPad({ dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"), onComplete: async (pin) => { - try { - const blob = await S.invoke("pinBlobGet"); - if (!blob) throw new Error("no PIN configured"); - const master = await pinDecryptMaster(pin, blob); - await S.invoke("pinFailReset").catch(() => {}); - // Truthy for every existing caller; the gate hands it to the host - // as proof (see pinGate). - done(master || true); + let r; + try { r = await pinTry(pin); } + catch (e) { $("vpErr").textContent = cleanErr(e); return "reset"; } + // The unwrapped master password is truthy for every existing caller; + // the gate hands it to the host as proof (see pinGate). + if (r.ok) { done(r.masterPassword || true); return "ok"; } + $("vpErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining); + if (r.requireMaster) { + setTimeout(async () => { + try { wrap.remove(); } catch {} + const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: PIN_MASTER_COPY }); + resolve(pw || false); + }, 1200); return "ok"; - } catch (e) { - const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); - const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0)); - $("vpErr").textContent = left > 0 - ? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.` - : `Locked for 15 min.`; - if (left === 0) { done(false); return "ok"; } - return "reset"; } + return "reset"; }, }); });