diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index ce5f6b30..be3c9bc4 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.31.0", + "version": "0.31.1", "category": "plugin", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index afca59a8..e5ed3d96 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1241,13 +1241,16 @@ function openPinBlob(api) { // 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). +// PINs offline. Now the blob stays in this process and every guess is +// counted before it is tried. PIN_MAX_FAILS wrong guesses lock the PIN for +// PIN_LOCKOUT_MS (the panel shows the same 15-minute lockout as before); +// every further wrong guess locks it again. A correct PIN or a master +// password the vault accepts clears the count. // 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_LOCKOUT_MS = 15 * 60 * 1000; const PIN_RE = /^\d{6}$/; const nodeCrypto = require("node:crypto"); const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) => @@ -1267,13 +1270,15 @@ async function pinUnwrapBlob(pin, blob) { } 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 }; + const last = Number(api.storage.get("aegis/pin/failLast", 0)) || 0; + const lockedMs = n >= PIN_MAX_FAILS ? Math.max(0, PIN_LOCKOUT_MS - (Date.now() - last)) : 0; + return { fails: n, last, lockedMs }; } -// 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. +// A master password the vault accepted. Clears the PIN strikes, as a +// correct PIN does. function noteMasterVerified(api) { api.storage.set("aegis/pin/failCount", 0); - api.storage.set("aegis/pin/requireMaster", false); + api.storage.set("aegis/pin/failLast", 0); } // "Ask for PIN on every transaction" used to be decided by the host and @@ -2627,23 +2632,24 @@ function registerPanelMessages(api) { }); // 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 } + // { ok: true, masterPassword } | { ok: false, remaining, lockedMs } 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); + const st = pinFails(api); + if (st.lockedMs > 0) return { ok: false, remaining: 0, lockedMs: st.lockedMs }; + api.storage.set("aegis/pin/failCount", st.fails + 1); + api.storage.set("aegis/pin/failLast", Date.now()); 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 }; + const now = pinFails(api); + return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - now.fails), lockedMs: now.lockedMs }; } api.storage.set("aegis/pin/failCount", 0); + api.storage.set("aegis/pin/failLast", 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) { @@ -2654,13 +2660,13 @@ function registerPanelMessages(api) { 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 }; + return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, last: f.last, maxFails: PIN_MAX_FAILS, lockedMs: f.lockedMs }; }); 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); + api.storage.set("aegis/pin/failLast", 0); // 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); @@ -2763,7 +2769,6 @@ 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, @@ -2798,7 +2803,6 @@ 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 ca7bb954..20be98dd 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -302,18 +302,15 @@ function fiatSkeleton() { // ---- 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 } +// holds the PIN blob, counts every guess before trying it, and enforces the +// 15-minute lockout after PIN_MAX_FAILS wrong ones. 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, lockedMs } +const PIN_MAX_FAILS = 5; const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) }); -async function pinNeedsMaster() { - try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; } +async function pinLockoutRemainingMs() { + try { return Number((await S.invoke("pinStatus")).lockedMs) || 0; } catch { return 0; } } -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"); @@ -3349,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 && !securityState?.pinRequireMaster) { + if (hasPin && !forcePw) { // 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. @@ -3374,15 +3371,20 @@ function renderLockScreen(phase) { keys: body.querySelector("#lockPinKeys"), err: body.querySelector("#lockPinErr"), onComplete: async (pin) => { - 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); } + 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.`; return "reset"; } try { + const r = await pinTry(pin); + if (!r.ok) { + const left = Math.max(0, r.remaining); + $("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.`; + return "reset"; + } state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword }); render(); return "ok"; @@ -5238,11 +5240,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 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. +// 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. const REVEAL_PIN_MAX_FAILS = 3; // Prove entitlement to see a secret, and hand back the master password — @@ -5259,11 +5261,14 @@ async function authorizeForSecret(subtitle) { // the master password is the gate — never nothing. return promptMasterPassword({ title: "Confirm master password", subtitle }); } - 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 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(), + }); } const pin = await capturePinForSecret(subtitle); if (pin === null) return null; // cancelled @@ -5315,10 +5320,10 @@ function capturePinForSecret(subtitle) { 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. + // Every guess here also counts toward the 15 min lockout. tries++; - if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; } - const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining); + if (r.lockedMs > 0 || 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"; }, @@ -5341,11 +5346,10 @@ function verifyPinInteractively(subtitle) { } async function verifyPinInteractivelyOnce(subtitle) { - // 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; + 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; } return new Promise((resolve) => { const wrap = document.createElement("div"); @@ -5380,15 +5384,11 @@ async function verifyPinInteractivelyOnce(subtitle) { // 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"; - } + const left = Math.max(0, r.remaining); + $("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"; }, });