diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 40815483..afca59a8 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -980,6 +980,13 @@ function buildWcApproval(payload) { inner = typeof dec === "string" ? null : dec; } catch { inner = null; } } + // Refuse what cannot be shown. The signer only takes this shape (see + // wc-sign.js), and an overlay without outputs or spent inputs is no + // approval at all. + if (!inner || !Array.isArray(inner.inputs) || !Array.isArray(inner.outputs) || !inner.outputs.length + || !Array.isArray(tx.sourceOutputs) || tx.sourceOutputs.length !== inner.inputs.length) { + return { unreadable: true, dappName }; + } const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?"); const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }]; // What the wallet is putting IN. The outputs alone never showed that a @@ -1011,6 +1018,7 @@ function buildWcApproval(payload) { try { const outs = inner?.outputs || []; let total = 0n; + for (const o of outs) { try { total += BigInt(o.valueSatoshis ?? 0); } catch {} } outs.slice(0, 8).forEach((o, i) => { const lb = o.lockingBytecode; const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex"); @@ -1018,7 +1026,6 @@ function buildWcApproval(payload) { if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46))); else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44))); const v = BigInt(o.valueSatoshis ?? 0); - total += v; const token = tokenText(o.token); rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true }); }); @@ -1230,39 +1237,88 @@ 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, // single-use fact recorded only after the host itself has verified the // master password the PIN unwraps (pinGateSatisfied). Panel sends consume // one; dapp transactions ask the open panel for one and wait. +// +// A clearance belongs to the thing the PIN was entered for. It used to be +// one global window: any PIN proof (opening the wallet, a settings toggle) +// let the next dapp transaction from any site through, a waiting dapp could +// take the clearance the user had just made for their own send, and with +// two sites waiting the second one's request was lost. Now: +// - each waiting dapp transaction has its own id, and only a proof that +// names that id releases it; +// - a proof given for "transaction" in the panel clears the panel's next +// send only; +// - any other proof clears nothing. const TX_CLEARANCE_MS = 90_000; const DAPP_PIN_WAIT_MS = 120_000; -let txClearanceUntil = 0; -let pendingPinRequest = null; // { origin, what, at } while a dapp tx waits for the PIN +let panelClearanceUntil = 0; +const pendingPinRequests = new Map(); // id -> { id, origin, what, at, cleared } function txPinOwed() { try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); } catch { return true; } // the safe direction for a lock is closed } -function takeTxClearance() { - if (Date.now() < txClearanceUntil) { txClearanceUntil = 0; return true; } - return false; -} function requirePanelTxPin() { - if (txPinOwed() && !takeTxClearance()) throw new Error("PIN required — confirm your PIN to continue"); + if (!txPinOwed()) return; + if (Date.now() < panelClearanceUntil) { panelClearanceUntil = 0; return; } + throw new Error("PIN required — confirm your PIN to continue"); } async function requireDappTxPin(origin, what) { - if (!txPinOwed() || takeTxClearance()) return; - pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() }; + 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); try { - try { ctx.api.emit("pinRequest", pendingPinRequest); } catch {} + try { ctx.api.emit("pinRequest", { id: req.id, origin: req.origin, what: req.what, at: req.at }); } catch {} const deadline = Date.now() + DAPP_PIN_WAIT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 250)); if (!ctx) break; - if (takeTxClearance()) return; + if (req.cleared) return; } - } finally { pendingPinRequest = null; } + } finally { pendingPinRequests.delete(req.id); } throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again."); } @@ -1341,6 +1397,7 @@ function registerPanelMessages(api) { fromPanel(m); const pw = String(p && p.masterPassword || ""); await api.vault.lifecycle.unlock(pw); + noteMasterVerified(api); await mountAllWallets(); return fullState(); }); @@ -1749,6 +1806,10 @@ function registerPanelMessages(api) { api.onMessage("sendToken", async (p, m) => { fromPanel(m); requirePanelTxPin(); + // Same rule as send: the token send names the wallet it was reviewed for. + if (!p || !p.walletId || String(p.walletId) !== selectedWalletId()) { + throw new Error("the selected wallet changed — review the send and try again"); + } const rt = requireSelected(); if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only"); const plan = await rt.adapter.planTokenTransfer(p || {}); @@ -1777,7 +1838,10 @@ function registerPanelMessages(api) { // The panel names the wallet its form was built for. If the selection // moved since (another surface, a late state push), refuse rather than // send this amount from a different wallet. - if (p && p.walletId && String(p.walletId) !== selectedWalletId()) { + // Required, not optional: a send that does not say which wallet it was + // reviewed for cannot be checked against the selection. + if (!p || !p.walletId) throw new Error("send names no wallet — review the send and try again"); + if (String(p.walletId) !== selectedWalletId()) { throw new Error("the selected wallet changed — review the send and try again"); } const rt = requireSelected(); @@ -1879,7 +1943,7 @@ function registerPanelMessages(api) { // The destination is the wallet the PREVIEW was drawn for, not whatever // is selected by the time the button is pressed: a preview painted for A // followed by a switch to B used to sweep everything into B. - if (p && p.destinationWalletId && String(p.destinationWalletId) !== destId) { + if (!p || !p.destinationWalletId || String(p.destinationWalletId) !== destId) { throw new Error("the selected wallet changed since this preview — reopen Consolidate"); } const destEntry = walletEntries().find((w) => w.id === destId); @@ -2122,6 +2186,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 +2611,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,15 +2732,26 @@ 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; + // What this proof clears (see requireDappTxPin). + const forRequest = String((p && p.requestId) || ""); + if (forRequest) { + const req = pendingPinRequests.get(forRequest); + if (!req) throw new Error("that request is no longer waiting"); + req.cleared = true; + } else if (String((p && p.event) || "") === "transaction") { + panelClearanceUntil = Date.now() + TX_CLEARANCE_MS; + } return true; }); // A panel opened while a dapp transaction is waiting for the PIN picks the // request up here; one already open gets the "pinRequest" event instead. api.onMessage("pinRequestPending", (_p, m) => { fromPanel(m); - return pendingPinRequest ? { ...pendingPinRequest } : null; + // The oldest waiting request that is not answered yet. + for (const r of pendingPinRequests.values()) if (!r.cleared) return { id: r.id, origin: r.origin, what: r.what, at: r.at }; + return null; }); ctx.pinNeeded = pinNeeded; // Seal a plain blob left by an older build now, not when the panel next @@ -2678,6 +2763,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 +2798,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, @@ -3111,13 +3198,50 @@ const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_ac // PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the // overlay rows that say who may spend what until when, or null when the // typed data is not a spending approval. +// What the signature actually covers. The EIP-712 encoder reads only the +// fields each type declares; anything else in `message` is ignored by the +// digest but was read by the overlay, so a dapp could add a decoy +// `allowed: false` or `details: {amount: "1"}` beside an unlimited permit and +// have the overlay show the decoy. Everything shown is built from this view. +function signedView(td) { + const types = (td && typeof td.types === "object" && td.types) || {}; + let dropped = 0; + const walk = (type, v) => { + const arr = /\[\d*\]$/.exec(type); + if (arr) { + const inner = type.slice(0, arr.index); + return Array.isArray(v) ? v.map((x) => walk(inner, x)) : v; + } + const fields = types[type]; + if (!Array.isArray(fields)) return v; // atomic + const src = (v && typeof v === "object") ? v : {}; + for (const k of Object.keys(src)) if (!fields.some((f) => f && f.name === k)) dropped++; + const out = {}; + for (const f of fields) if (f && typeof f.name === "string") out[f.name] = walk(String(f.type), src[f.name]); + return out; + }; + return { message: walk(String(td?.primaryType || ""), td?.message), dropped }; +} + function describePermit(td) { const primary = String(td?.primaryType || ""); if (!/^Permit/.test(primary)) return null; - const msg = (td && typeof td.message === "object" && td.message) || {}; + const msg = signedView(td).message || {}; + const declared = (t) => (Array.isArray(td?.types?.[t]) ? td.types[t].map((f) => f && f.name) : []); + const pf = declared(primary); + const has = (...names) => names.every((n) => pf.includes(n)); const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max + // No token has a supply anywhere near this, so an amount this large is an + // unlimited approval in all but name (2^96 ≈ 7.9e28 base units: 79 billion + // tokens at 18 decimals). + const HUGE = 1n << 96n; const big = (v) => { try { return BigInt(v); } catch { return null; } }; - const amountText = (v) => { const b = big(v); return b === null ? String(v) : (b >= UNLIMITED ? "UNLIMITED (∞)" : b.toString() + " units"); }; + const amountText = (v) => { + const b = big(v); + if (b === null) return String(v); + if (b >= UNLIMITED) return "UNLIMITED (∞)"; + return b.toString() + " units" + (b >= HUGE ? " (effectively unlimited)" : ""); + }; const when = (v) => { const b = big(v); if (b === null) return String(v); @@ -3126,23 +3250,34 @@ function describePermit(td) { }; const rows = []; let risky = false; - const spender = msg.spender; + const spender = has("spender") ? msg.spender : null; rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true }); if (!spender) risky = true; const items = []; - if (primary === "Permit") { - // EIP-2612 has `value`; DAI has `allowed: true` (always unlimited). - if ("allowed" in msg) items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n }); - else items.push({ token: td.domain?.verifyingContract, amount: msg.value }); - if (msg.deadline != null || msg.expiry != null) rows.push({ label: "Valid until", value: when(msg.deadline ?? msg.expiry) }); - } else { - const list = Array.isArray(msg.details) ? msg.details : (msg.details ? [msg.details] : (Array.isArray(msg.permitted) ? msg.permitted : (msg.permitted ? [msg.permitted] : []))); + // The variant is chosen by the declared type, never by what the message + // happens to carry. + if (has("holder", "spender", "nonce", "expiry", "allowed")) { + // DAI-style: a bool, encoded by truthiness — so is this. + items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n }); + rows.push({ label: "Valid until", value: when(msg.expiry) }); + } else if (has("owner", "spender", "value", "deadline")) { + // EIP-2612. + items.push({ token: td.domain?.verifyingContract, amount: msg.value }); + rows.push({ label: "Valid until", value: when(msg.deadline) }); + } else if (has("details", "spender", "sigDeadline")) { + // Permit2 PermitSingle / PermitBatch. + const list = Array.isArray(msg.details) ? msg.details : [msg.details]; for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration }); - if (msg.sigDeadline != null || msg.deadline != null) rows.push({ label: "Signature valid until", value: when(msg.sigDeadline ?? msg.deadline) }); + rows.push({ label: "Signature valid until", value: when(msg.sigDeadline) }); + } else if (has("permitted", "spender", "deadline")) { + // Permit2 PermitTransferFrom / PermitBatchTransferFrom (and *Witness*). + const list = Array.isArray(msg.permitted) ? msg.permitted : [msg.permitted]; + for (const d of list) items.push({ token: d?.token, amount: d?.amount }); + rows.push({ label: "Signature valid until", value: when(msg.deadline) }); } items.slice(0, 10).forEach((it, i) => { const b = big(it.amount); - if (b !== null && b >= UNLIMITED) risky = true; + if (b === null || b >= HUGE) risky = true; rows.push({ label: items.length > 1 ? `Token #${i + 1}` : "Token", value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`, @@ -3150,7 +3285,7 @@ function describePermit(td) { }); }); if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; } - if (!items.length) { rows.push({ label: "Token", value: "(could not read the approval — treat as unlimited)" }); risky = true; } + if (!items.length) { rows.push({ label: "Token", value: "(not a permit shape Aegis can read — treat as unlimited)" }); risky = true; } return { rows, risky }; } @@ -3223,15 +3358,31 @@ function registerPageMessages(api) { const perms = permissions(api); const budget = perms[origin] && perms[origin].sendTx; const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0; + // The PIN wait below can last two minutes, during which the user may + // revoke the site or other permissions may change. Everything after it + // re-reads permissions and changes only this origin's sendTx; writing + // back the snapshot taken here used to restore a revoked allowance and + // wipe any other change made meanwhile. + const patchSendTx = (fn) => { + const now = permissions(api); + const cur = { ...(now[origin] || {}) }; + const next = fn(cur.sendTx); + if (!next && !now[origin]) return; // revoked meanwhile: nothing to change + if (next) cur.sendTx = next; else delete cur.sendTx; + now[origin] = cur; + api.storage.set("permissions", now); + }; if (budget && d.total <= remaining) { // An allowance skips the overlay, not the PIN the user asked for on // every transaction. await requireDappTxPin(origin, "Bitcoin Cash payment"); + const fresh = (permissions(api)[origin] || {}).sendTx; + const left = fresh ? Math.max(0, (fresh.capSats | 0) - (fresh.usedSats | 0)) : 0; + if (!fresh || fresh.grantedAt !== budget.grantedAt || d.total > left) throw new Error("this site's allowance changed while waiting for the PIN; ask again"); const r = await rt.adapter.signAndBroadcast(plan); - budget.usedSats = (budget.usedSats | 0) + d.total; - api.storage.set("permissions", perms); + patchSendTx((t) => (t ? { ...t, usedSats: (t.usedSats | 0) + d.total } : t)); emitState(); - api.log(`silent send ${d.total} sat for ${origin}, ${remaining - d.total} sat of allowance left`); + api.log(`silent send ${d.total} sat for ${origin}, ${left - d.total} sat of allowance left`); return { txid: r.txid }; } const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true })); @@ -3256,13 +3407,8 @@ function registerPageMessages(api) { await requireDappTxPin(origin, "Bitcoin Cash payment"); const cap = flags.find((f) => f.startsWith("cap=")); const capSats = cap ? Number(cap.slice(4)) : 0; - if (BCH_ALLOWANCES.includes(capSats)) { - perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } }; - api.storage.set("permissions", perms); - } else if (budget) { - delete perms[origin].sendTx; - api.storage.set("permissions", perms); - } + if (BCH_ALLOWANCES.includes(capSats)) patchSendTx(() => ({ capSats, usedSats: 0, grantedAt: Date.now() })); + else if (budget) patchSendTx(() => null); emitState(); const r = await rt.adapter.signAndBroadcast(plan); return { txid: r.txid }; @@ -3655,7 +3801,9 @@ function registerPageMessages(api) { } } const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)"; - const messagePreview = JSON.stringify(td.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || ""; + // Preview only what the signature covers (see signedView). + const view = signedView(td); + const messagePreview = JSON.stringify(view.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || ""; const rows = [{ label: "Domain", value: domainSummary }]; if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true }); rows.push({ label: "Primary type", value: String(td.primaryType || "") }); @@ -3665,9 +3813,14 @@ function registerPageMessages(api) { // and the deadline out of the message and say what they mean. const permit = describePermit(td); if (permit) for (const r of permit.rows) rows.push(r); + // Marketplace orders and multisig transactions are not permits but move + // NFTs, tokens or a whole Safe just as surely once signed. + const MOVES_ASSETS = /^(OrderComponents|BulkOrder|Order|MakerOrder|TakerOrder|SafeTx|SafeMessage|MetaTransaction|ForwardRequest)$/; + const movesAssets = !permit && MOVES_ASSETS.test(String(td.primaryType || "")); rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true }); + if (view.dropped) rows.push({ label: "Not signed", value: `${view.dropped} field${view.dropped === 1 ? "" : "s"} the site sent are not covered by this signature and are not shown` }); rows.push({ label: "Address", value: snapTd.address, mono: true }); - const risky = !!(permit && permit.risky); + const risky = !!(permit && permit.risky) || movesAssets; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?", @@ -3676,7 +3829,9 @@ function registerPageMessages(api) { ? (risky ? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site." : "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.") - : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", + : movesAssets + ? `WARNING: a signed ${String(td.primaryType)} can move your NFTs, tokens or Safe without another prompt. Only sign if you fully trust this site and the details below are what you expect.` + : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", rows, actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], }); @@ -4040,7 +4195,8 @@ module.exports = { // keys fell back to a lone "OK" button whose id never matched, so // every WizardConnect signing request was refused, and the HTML // body was shown as literal markup. - const { body, rows, dappName } = buildWcApproval(payload); + const { body, rows, dappName, unreadable } = buildWcApproval(payload); + if (unreadable) { api.log(`wc sign: refused an unreadable request from ${dappName}`); return { approved: false }; } const pick = await api.approvalModal({ title: "Sign a Bitcoin Cash transaction (WizardConnect)?", origin: dappName, diff --git a/bundled-addons/aegis/lib/bcmr.js b/bundled-addons/aegis/lib/bcmr.js index 8c7b7574..889407fa 100644 --- a/bundled-addons/aegis/lib/bcmr.js +++ b/bundled-addons/aegis/lib/bcmr.js @@ -46,8 +46,11 @@ const DEFAULT_REGISTRIES = [ module.exports = function makeBcmr({ storage, log = () => {} }) { function registryList() { + // Filtered on read as well: a list saved before setRegistries enforced + // https still carries its http entries. const custom = storage.get("bcmr/registries", null); - if (Array.isArray(custom) && custom.length) return custom; + const clean = Array.isArray(custom) ? custom.filter((r) => r && typeof r.url === "string" && /^https:\/\//i.test(r.url)) : []; + if (clean.length) return clean; return DEFAULT_REGISTRIES.slice(); } function setRegistries(list) { @@ -164,10 +167,13 @@ module.exports = function makeBcmr({ storage, log = () => {} }) { // // A local name wins over the registry: the user typed it for this exact // category, which is better evidence than a third-party document. - // Control, bidi-override, zero-width and BOM characters. Built from code + // Control, bidi-override, zero-width and BOM characters, plus the soft + // hyphen, the Arabic letter mark, the Mongolian vowel separator, the + // line/paragraph separators and the Unicode tag block. Built from code // points so no invisible character has to live in this source file. - const rng = (a, b) => String.fromCharCode(a) + "-" + String.fromCharCode(b); - const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0x200b, 0x200f) + rng(0x202a, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + "]", "g"); + const rng = (a, b) => String.fromCodePoint(a) + "-" + String.fromCodePoint(b); + const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0xad, 0xad) + rng(0x61c, 0x61c) + rng(0x180e, 0x180e) + + rng(0x200b, 0x200f) + rng(0x2028, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + rng(0xe0000, 0xe007f) + "]", "gu"); function cleanText(v, max) { if (typeof v !== "string") return null; const s = v.replace(INVISIBLE, "").trim().slice(0, max); diff --git a/bundled-addons/aegis/lib/eip712.js b/bundled-addons/aegis/lib/eip712.js index a9266795..0280e37e 100644 --- a/bundled-addons/aegis/lib/eip712.js +++ b/bundled-addons/aegis/lib/eip712.js @@ -87,8 +87,11 @@ module.exports = function makeEip712({ keccak_256 }) { return keccak_256(b); } if (type === "address") { - const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); - if (h.length !== 20) throw new Error("address must be 20 bytes"); + // Validate before decoding: the hex decoder turns non-hex characters + // into zero bytes, so a malformed address would sign as a different one. + const s = String(value ?? "").replace(/^0x/i, ""); + if (!/^[0-9a-fA-F]{40}$/.test(s)) throw new Error("address must be 20 bytes of hex"); + const h = hex2bytes(s); const out = new Uint8Array(32); out.set(h, 12); return out; diff --git a/bundled-addons/aegis/lib/tron-decode.js b/bundled-addons/aegis/lib/tron-decode.js index 8ecc60ae..396fc3b9 100644 --- a/bundled-addons/aegis/lib/tron-decode.js +++ b/bundled-addons/aegis/lib/tron-decode.js @@ -81,9 +81,20 @@ module.exports = function makeTronDecode({ sha256, base58check }) { } return out; } - const one = (fields, n) => fields.find((f) => f.field === n); - const bytesOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 2 ? f.value : null; }; - const numOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 0 ? f.value : null; }; + // A singular field that appears twice is legal protobuf: the parser keeps + // the LAST copy (and merges repeated sub-messages). java-tron does that, so + // reading the first copy would show the user one recipient and amount while + // the chain executes another — and Any.value is opaque bytes, so the signed + // hash is the same either way. Refuse instead of guessing, and refuse a + // known field sent with the wrong wire type for the same reason. + const one = (fields, n, wire) => { + const hits = fields.filter((f) => f.field === n); + if (hits.length > 1) throw new Error(`field ${n} appears ${hits.length} times; refusing an ambiguous transaction`); + if (hits[0] && hits[0].wire !== wire) throw new Error(`field ${n} has wire type ${hits[0].wire}, expected ${wire}`); + return hits[0]; + }; + const bytesOf = (fields, n) => { const f = one(fields, n, 2); return f ? f.value : null; }; + const numOf = (fields, n) => { const f = one(fields, n, 0); return f ? f.value : null; }; // 21-byte Tron address (0x41 || h20) → "T…" base58check. Anything else is // returned as hex so the overlay never hides a malformed field. diff --git a/bundled-addons/aegis/lib/wc-sign.js b/bundled-addons/aegis/lib/wc-sign.js index 34d5ab4c..ffda3774 100644 --- a/bundled-addons/aegis/lib/wc-sign.js +++ b/bundled-addons/aegis/lib/wc-sign.js @@ -66,12 +66,13 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) { // the outputs) meant `tx.inputs` was undefined and signing threw on the // first real request. index.js already read the nested // request.transaction.userPrompt for the approval dialog, so only this - // module had it wrong. The flat shape is still accepted so a caller - // that hands us an already-unwrapped payload keeps working. - const inner = (request.transaction && (request.transaction.transaction !== undefined - || request.transaction.sourceOutputs !== undefined)) - ? request.transaction - : request; + // module had it wrong. Only the nested shape is accepted: the approval + // overlay reads that shape, and a flat request used to be signed after an + // overlay that could show neither its outputs nor what it spends. + const inner = request.transaction; + if (!inner || typeof inner !== "object" || inner.transaction === undefined || !Array.isArray(inner.sourceOutputs)) { + throw new Error("wc-sign: request is not a WizardConnect sign request (request.transaction.{transaction,sourceOutputs})"); + } const tx = ensureTransaction(inner.transaction, libauth); const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => { if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 4e041d9b..ca7bb954 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"); @@ -1224,6 +1196,9 @@ function openWalletManageModal(w) { + `

The imported key is kept rather than deleted: the sweep still has to confirm, and anyone holding the old address can still pay into it. Remove it yourself once its balance reads zero.`, }); if (!ok) return; + // The sweep is a spend: with "PIN on every transaction" the host refuses + // it without a proof given for a transaction. + if (!await pinGate("transaction", "Confirm the sweep with your PIN.")) return; try { const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel }); close(); @@ -3374,7 +3349,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 +3374,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 +3482,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 = ""; @@ -4160,8 +4135,10 @@ function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 : // guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5, // and Number() accepted "1e3", "0x10" and "-0.5". function parseAmountText(text) { - let raw = String(text == null ? "" : text).trim().replace(/\s/g, ""); + let raw = String(text == null ? "" : text).trim(); if (!raw) return { empty: true }; + // Space inside the number is refused, not deleted: "1 0" used to become 10. + if (/\s/.test(raw)) return { error: "Remove the space from the amount" }; if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(raw)) { raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567 } else if (/^\d{1,3},\d{3}$/.test(raw)) { @@ -4635,7 +4612,7 @@ $("sendBtn").addEventListener("click", async () => { try { const isToken = !!(req.mint && lastPlan._token); const r = isToken - ? await S.invoke("sendToken", { mint: req.mint, to: req.to, amount: req.amount }) + ? await S.invoke("sendToken", { walletId: req.walletId, mint: req.mint, to: req.to, amount: req.amount }) : await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo }); // A broadcast that returned no txid is still a broadcast: never report // it as a failure and leave a filled form inviting a second send. @@ -5004,9 +4981,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) { @@ -5169,19 +5144,29 @@ async function pinGate(event, subtitle) { // does not count, so a failure here is a failed gate. const proof = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN."); if (!proof) return false; - try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } + // `event` says what the proof is for: "transaction" clears the panel's + // next send, anything else only records the gate. + try { await S.invoke("pinGateSatisfied", { masterPassword: proof, event }); } catch { return false; } return true; } // A dapp transaction is waiting on the PIN ("ask on every transaction"). // The host cannot draw a PIN pad, so it asks the panel: prove the PIN here // and the pending transaction goes through. +// Requests are answered one at a time, each with its own PIN entry, and the +// proof names the request it was entered for — so a PIN typed for one site +// never releases another site's transaction. +let pinRequestBusy = false; async function answerPinRequest(req) { - if (!req || pinGateBlocked) return; - const where = String(req.origin || "A site").slice(0, 80); - const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`); - if (!proof) return; - try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* host keeps waiting, then refuses */ } + if (!req || !req.id || pinGateBlocked || pinRequestBusy) return; + pinRequestBusy = true; + try { + const where = String(req.origin || "A site").slice(0, 80); + const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`); + if (proof) { try { await S.invoke("pinGateSatisfied", { masterPassword: proof, requestId: req.id }); } catch { /* host keeps waiting, then refuses */ } } + } finally { pinRequestBusy = false; } + // Another site may be waiting too. + try { const next = await S.invoke("pinRequestPending"); if (next && next.id !== req.id) answerPinRequest(next); } catch {} } const PIN_GATE_COPY = { restart: "Theseus restarted — confirm your PIN to use this wallet.", @@ -5253,11 +5238,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 +5259,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 +5311,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 +5341,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 +5374,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"; }, }); });