diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index b630c825..8d1becf3 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -975,6 +975,32 @@ function buildWcApproval(payload) { } 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 + // transaction spends the user's tokens — and with the token prefix now + // signed correctly (wc-sign.js) such a transaction is valid, so the + // tokens leaving must be on the overlay. + const hexOf = (v) => (typeof v === "string" ? v.toLowerCase() : Buffer.from(v || []).toString("hex")); + const tokenText = (t) => { + if (!t) return ""; + const cat = hexOf(t.category); + let amt = "0"; + try { amt = BigInt(t.amount ?? 0).toString(); } catch {} + const nft = t.nft ? ` + NFT (${String(t.nft.capability || "none")})` : ""; + return ` + token ${cat.slice(0, 8)}…${cat.slice(-4)}: ${amt} units${nft}`; + }; + try { + const srcs = Array.isArray(tx.sourceOutputs) ? tx.sourceOutputs : []; + if (srcs.length) { + let inTotal = 0n; + const tokenLines = []; + srcs.forEach((o, i) => { + try { inTotal += BigInt(o.valueSatoshis ?? 0); } catch {} + if (o.token) tokenLines.push(`input #${i + 1}${tokenText(o.token)}`); + }); + rows.push({ label: "Spending", value: `${fmtBch(Number(inTotal))} BCH from ${srcs.length} input${srcs.length === 1 ? "" : "s"}` }); + if (tokenLines.length) rows.push({ label: "Tokens spent", value: tokenLines.slice(0, 8).join("\n") + (tokenLines.length > 8 ? `\n… and ${tokenLines.length - 8} more` : ""), mono: true, strong: true }); + } + } catch {} try { const outs = inner?.outputs || []; let total = 0n; @@ -986,7 +1012,7 @@ function buildWcApproval(payload) { 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 = o.token ? " + CashTokens" : ""; + 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 }); }); if (outs.length > 8) rows.push({ label: "Outputs", value: `… and ${outs.length - 8} more` }); @@ -1160,6 +1186,86 @@ function requireWallet(id) { return rt; } function requireSelected() { return requireWallet(selectedWalletId()); } + +// ---- PIN: sealed blob + host-verified transaction clearance --------------- +// The PIN blob is the vault master password wrapped under a 6-digit PIN. In +// plain add-on storage that made the master password exactly as strong as a +// million PBKDF2 guesses to anyone holding a copy of the profile (a backup, +// another machine, a disk image) — the panel's lockout counter never sees an +// offline guess. It is therefore sealed with the OS keystore (DPAPI / +// Keychain / libsecret) before it touches disk, the same as Theseus's own +// vault PIN: a copied file is useless without this OS account. A plain blob +// from an older build is sealed the first time it is read. +const PIN_BLOB_KEY = "aegis/pin/v1"; +function sealPinBlob(api, blob) { + const ss = safeStorageOr(api); + try { + if (ss && ss.isEncryptionAvailable()) { + return { v: 2, sealed: ss.encryptString(JSON.stringify(blob)).toString("base64") }; + } + } catch { /* fall through: unsealed is still better than no PIN at all */ } + return blob; +} +function openPinBlob(api) { + const b = api.storage.get(PIN_BLOB_KEY, null); + if (!b || typeof b !== "object") return null; + if (b.sealed) { + const ss = safeStorageOr(api); + if (!ss) return null; + try { + const plain = JSON.parse(ss.decryptString(Buffer.from(String(b.sealed), "base64"))); + return (plain && typeof plain === "object") ? plain : null; + } catch { return null; } // sealed under another OS account: the PIN is simply gone + } + const plain = { salt: b.salt, iv: b.iv, ct: b.ct, iters: b.iters }; + const sealed = sealPinBlob(api, plain); + if (sealed.sealed) api.storage.set(PIN_BLOB_KEY, sealed); + return plain; +} + +// "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. +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 +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"); +} +async function requireDappTxPin(origin, what) { + if (!txPinOwed() || takeTxClearance()) return; + pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() }; + try { + try { ctx.api.emit("pinRequest", pendingPinRequest); } 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; + } + } finally { pendingPinRequest = null; } + throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again."); +} + +// Signed text shown on an approval overlay. Long messages are cut for the +// overlay's sake, but never silently: the cut says how much is missing, so a +// benign-looking opening cannot hide what the rest commits the user to. +function previewText(text, max = 1500) { + const s = String(text); + return s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s; +} function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); } function fromPage(m) { if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message"); @@ -1635,6 +1741,7 @@ function registerPanelMessages(api) { }); api.onMessage("sendToken", async (p, m) => { fromPanel(m); + requirePanelTxPin(); const rt = requireSelected(); if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only"); const plan = await rt.adapter.planTokenTransfer(p || {}); @@ -1659,6 +1766,13 @@ function registerPanelMessages(api) { // Execute a send with approval overlay. api.onMessage("send", async (p, m) => { fromPanel(m); + 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 + // send this amount from a different wallet. + if (p && p.walletId && String(p.walletId) !== selectedWalletId()) { + throw new Error("the selected wallet changed — review the send and try again"); + } const rt = requireSelected(); const plan = await Promise.resolve(rt.adapter.plan(p || {})); const d = describePlan(plan, rt.entry.chain, rt.entry.network); @@ -1752,26 +1866,39 @@ function registerPanelMessages(api) { api.onMessage("consolidateIntoSelected", async (p, m) => { fromPanel(m); + requirePanelTxPin(); const destId = selectedWalletId(); if (!destId) throw new Error("no wallet selected"); + // 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) { + throw new Error("the selected wallet changed since this preview — reopen Consolidate"); + } const destEntry = walletEntries().find((w) => w.id === destId); if (!destEntry) throw new Error("selected wallet not found"); const destRt = ctx.runtimes.get(destId); if (!destRt?.adapter) throw new Error("destination wallet not ready"); const destAddr = destRt.adapter.snapshot().address; if (!destAddr) throw new Error("destination wallet has no receive address"); - // sourceIds are the wallets the user CHECKED in the preview. Defaults - // to every eligible sibling if the panel omits the field (safety net, - // shouldn't happen in normal flow). - const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length - ? new Set(p.sourceIds.map(String)) - : null; + // sourceIds are the wallets the user CHECKED. They are required: an + // omitted list used to mean "every sibling wallet". + if (!Array.isArray(p?.sourceIds) || !p.sourceIds.length) throw new Error("choose at least one wallet to consolidate"); + const requested = new Set(p.sourceIds.map(String)); const sources = walletEntries().filter((w) => w.chain === destEntry.chain && w.network === destEntry.network && w.id !== destId && - (!requested || requested.has(w.id)), + requested.has(w.id), ); + if (!sources.length) throw new Error("none of the chosen wallets can be consolidated into this one"); + + // Plan every sweep first, then put the WHOLE batch on the host overlay. + // This emptied wallets on the panel's say-so alone; every other spend + // in Aegis is confirmed on a surface the panel cannot draw. + const meta = chainMeta(destEntry.chain, destEntry.network); + const dec = meta?.decimals ?? 8; + const planned = []; const results = []; for (const src of sources) { const rt = ctx.runtimes.get(src.id); @@ -1781,6 +1908,38 @@ function registerPanelMessages(api) { } try { const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); + planned.push({ src, rt, plan }); + } catch (e) { + results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) }); + } + } + if (planned.length) { + let totalNet = 0n, totalFee = 0n; + const rows = planned.slice(0, 12).map(({ src, plan }) => { + const net = BigInt(String(plan.recipients?.[0]?.value ?? 0)); + const fee = BigInt(String(plan.fee ?? 0)); + totalNet += net; totalFee += fee; + return { label: "Empty", value: `${src.label} — ${fmtValue(net.toString(), dec)} ${meta?.ticker || ""}`, mono: true }; + }); + for (const { plan } of planned.slice(12)) { + totalNet += BigInt(String(plan.recipients?.[0]?.value ?? 0)); + totalFee += BigInt(String(plan.fee ?? 0)); + } + if (planned.length > 12) rows.push({ label: "…", value: `and ${planned.length - 12} more wallets` }); + rows.push({ label: "Into", value: `${destEntry.label} — ${destAddr}`, mono: true }); + rows.push({ label: "Arrives", value: `${fmtValue(totalNet.toString(), dec)} ${meta?.ticker || ""}`, strong: true }); + rows.push({ label: "Fees", value: `${fmtValue(totalFee.toString(), dec)} ${meta?.ticker || ""} across ${planned.length} transaction${planned.length === 1 ? "" : "s"}` }); + const pick = await api.approvalModal({ + title: `Consolidate ${planned.length} wallet${planned.length === 1 ? "" : "s"}?`, + origin: "Aegis wallet panel", + body: "Each wallet listed is emptied into the destination in its own transaction. This cannot be undone.", + rows, + actions: [{ id: "send", label: "Consolidate", primary: true }], + }); + if (pick !== "send") throw new Error("cancelled"); + } + for (const { src, rt, plan } of planned) { + try { const r = await rt.adapter.signAndBroadcast(plan); results.push({ walletId: src.id, label: src.label, ok: true, @@ -1873,6 +2032,7 @@ function registerPanelMessages(api) { api.onMessage("promoteToHd", async (p, m) => { fromPanel(m); + requirePanelTxPin(); const { entry, rt } = promotableImport(p && p.walletId); const dest = await createVaultWallet({ chain: entry.chain, network: entry.network, @@ -2343,8 +2503,7 @@ function registerPanelMessages(api) { // the opaque blob + a small policy object in and out of api.storage. api.onMessage("pinBlobGet", (_p, m) => { fromPanel(m); - const b = api.storage.get("aegis/pin/v1", null); - return (b && typeof b === "object") ? b : null; + return openPinBlob(api); }); api.onMessage("pinBlobSet", (p, m) => { fromPanel(m); @@ -2353,7 +2512,7 @@ function registerPanelMessages(api) { 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("aegis/pin/v1", { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters }); + api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters })); return true; }); api.onMessage("pinBlobClear", (_p, m) => { @@ -2442,13 +2601,32 @@ function registerPanelMessages(api) { const event = String(p && p.event || "panel-load"); return { ...pinNeeded(event), event, policy: pinPolicy() }; }); - // Called only after the panel has actually decrypted the PIN blob, which - // is proof of the PIN and not merely a claim about it. - api.onMessage("pinGateSatisfied", (_p, m) => { + // The panel decrypts the PIN blob and hands over what was inside it. That + // is the vault master password, and the host checks it against the vault + // itself — so a gate is cleared by proof the host verified, not by the + // panel saying so. The same proof opens a single-use transaction + // clearance (see requirePanelTxPin / requireDappTxPin). + api.onMessage("pinGateSatisfied", async (p, m) => { fromPanel(m); + 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); } + catch { throw new Error("PIN proof rejected"); } api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); + txClearanceUntil = 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; + }); + ctx.pinNeeded = pinNeeded; + // Seal a plain blob left by an older build now, not when the panel next + // happens to open. + try { openPinBlob(api); } catch {} api.onMessage("securityGet", (_p, m) => { fromPanel(m); @@ -2725,7 +2903,7 @@ function previewBytes(bytes, max = 400) { let text = null; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {} if (text != null && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) { - return text.length > max ? text.slice(0, max) + "…" : text; + return previewText(text, Math.max(max, 1500)); } return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`; } @@ -2852,6 +3030,56 @@ function solInstructionRows(parsed) { return rows; } +const ETH_RPC_PRECONNECT = new Set(["eth_chainId", "net_version", "eth_blockNumber"]); +const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_accounts|eth_requestAccounts|eth_coinbase|eth_signTypedData|eth_newFilter|eth_newBlockFilter|eth_newPendingTransactionFilter|eth_uninstallFilter|eth_getFilterChanges|eth_getFilterLogs|eth_subscribe|eth_unsubscribe|eth_mining|eth_submit|eth_getWork)/; + +// EIP-2612 Permit, DAI-style permit, and Uniswap Permit2 (PermitSingle / +// 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. +function describePermit(td) { + const primary = String(td?.primaryType || ""); + if (!/^Permit/.test(primary)) return null; + const msg = (td && typeof td.message === "object" && td.message) || {}; + const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max + 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 when = (v) => { + const b = big(v); + if (b === null) return String(v); + if (b >= 4102444800n) return "never expires"; // 2100-01-01 and beyond + try { return new Date(Number(b) * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; } catch { return b.toString(); } + }; + const rows = []; + let risky = false; + const spender = msg.spender; + 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] : []))); + 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) }); + } + items.slice(0, 10).forEach((it, i) => { + const b = big(it.amount); + if (b !== null && b >= UNLIMITED) 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)}` : ""}`, + mono: true, + }); + }); + 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; } + return { rows, risky }; +} + async function withOriginLock(origin, fn) { if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); pendingByOrigin.add(origin); @@ -2922,6 +3150,9 @@ function registerPageMessages(api) { const budget = perms[origin] && perms[origin].sendTx; const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0; 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 r = await rt.adapter.signAndBroadcast(plan); budget.usedSats = (budget.usedSats | 0) + d.total; api.storage.set("permissions", perms); @@ -2948,6 +3179,7 @@ function registerPageMessages(api) { }); const [action, ...flags] = pick.split("+"); if (action !== "send") throw new Error("user rejected"); + 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)) { @@ -2974,7 +3206,7 @@ function registerPageMessages(api) { origin, body: "Signing proves you control the address below. It moves no coins.", rows: [ - { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, + { label: "Message", value: previewText(message), mono: true }, { label: "Address", value: rt.adapter.current().address, mono: true }, ], actions: [{ id: "sign", label: "Sign", primary: true }], @@ -3135,6 +3367,7 @@ function registerPageMessages(api) { actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], }); if (pick !== "sign") throw new Error("user rejected"); + await requireDappTxPin(origin, "Tron transaction"); const sig = rt.adapter.signRawData(tx.raw_data_hex); rememberSignedTron(decoded.txid); return { ...tx, txID: decoded.txid, signature: [sig] }; @@ -3167,7 +3400,7 @@ function registerPageMessages(api) { origin, body: "Signing proves you control this address. It moves no coins.", rows: [ - { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, + { label: "Message", value: previewText(message), mono: true }, { label: "Address", value: snap.address, mono: true }, ], actions: [{ id: "sign", label: "Sign", primary: true }], @@ -3313,6 +3546,7 @@ function registerPageMessages(api) { actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }], }); if (pick !== "send") throw new Error("user rejected"); + await requireDappTxPin(origin, "Ethereum transaction"); const r = await rt.adapter.signAndBroadcast(plan); return { txid: r.txid }; }); @@ -3335,23 +3569,46 @@ function registerPageMessages(api) { // truncated JSON preview of the message so the user has a fighting // chance to spot phishing. const dom = td.domain || {}; + const snapTd = rt.adapter.snapshot(); + // A signature for another chain is the standard way to get an approval + // the user believes is for a testnet or a sidechain. MetaMask refuses a + // domain whose chainId is not the active chain; so does Aegis. + if (dom.chainId != null && dom.chainId !== "") { + let domChain = null; + try { domChain = BigInt(dom.chainId); } catch {} + if (domChain === null || domChain !== BigInt(snapTd.chainId)) { + throw ethError(`typed data is for chain ${dom.chainId} but this site is connected on chain ${snapTd.chainId}`, 4901); + } + } 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, null, 2); - const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview; + const messagePreview = JSON.stringify(td.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 || "") }); + // Off-chain approvals. A Permit / Permit2 signature moves no gas and + // shows no transaction, yet lets the spender take the tokens later — it + // is how most wallet drains happen now. Pull the spender, the amounts + // 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); + rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true }); + rows.push({ label: "Address", value: snapTd.address, mono: true }); + const risky = !!(permit && permit.risky); return withOriginLock(origin, async () => { const pick = await api.approvalModal({ - title: "Sign typed data (EIP-712)?", + title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?", origin, - body: "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: [ - { label: "Domain", value: domainSummary }, - { label: "Primary type", value: String(td.primaryType || "") }, - { label: "Message", value: preview, mono: true }, - { label: "Address", value: rt.adapter.snapshot().address, mono: true }, - ], - actions: [{ id: "sign", label: "Sign", primary: true }], + body: permit + ? (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.", + rows, + actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], }); if (pick !== "sign") throw new Error("user rejected"); + // A permit moves tokens as surely as a transaction does. + if (permit) await requireDappTxPin(origin, "token spending permit"); return rt.adapter.signTypedDataDigest(digest); }); }); @@ -3472,6 +3729,13 @@ function registerPageMessages(api) { const method = String(p && p.method || ""); const params = (p && p.params) || []; if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through"); + // The user's RPC endpoint is theirs (often a keyed, metered one). A site + // that has not connected gets the three calls every dapp makes before + // asking to connect and nothing else; signing, account and filter-state + // methods are never relayed, and broadcasting needs a connection. + const connected = ethConnectedFor(origin); + if (!connected && !ETH_RPC_PRECONNECT.has(method)) throw ethError("not connected — call eth_requestAccounts first", 4100); + if (ETH_RPC_NEVER.test(method)) throw ethError(`Aegis does not relay ${method}`, 4200); return rt.adapter._client.call(method, params); }); @@ -3560,6 +3824,7 @@ function registerPageMessages(api) { actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); + await requireDappTxPin(origin, "Solana transaction"); return rt.adapter.signBytes(messageBytes); }); }); @@ -3599,6 +3864,7 @@ function registerPageMessages(api) { actions: [{ id: "send", label: "Sign & send", primary: true }], }); if (pick !== "send") throw new Error("user rejected"); + await requireDappTxPin(origin, "Solana transaction"); // Sign the exact message bytes and patch our slot. signMessage() ran // the bytes through String(), so every signature was over "1,2,3,…" // and the network rejected it. Partial signatures already in the wire @@ -3698,7 +3964,10 @@ module.exports = { body, rows, actions: [{ id: "approve", label: "Sign", primary: true }], }); - return { approved: pick === "approve" }; + if (pick !== "approve") return { approved: false }; + try { await requireDappTxPin(dappName, "Bitcoin Cash transaction (WizardConnect)"); } + catch (e) { api.log("wc sign:", e?.message || e); return { approved: false }; } + return { approved: true }; }, }); c.wc.onStateChange(() => emitState()); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 1fb92afd..f738bec5 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -306,7 +306,7 @@ function fiatSkeleton() { // 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 = 200000; +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(""); @@ -377,8 +377,13 @@ function bindIdleAutoLock() { if (!s || s.phase !== "ready") return; try { state = await S.invoke("vaultLock"); - stripView = { mode: "coins", groupKey: null }; - render(); + // Start the panel over rather than re-render it. Whatever was open + // goes with the page: a revealed key, an import form holding a seed + // phrase, a half-filled send, the password remembered for PIN + // enrolment. Painting a lock screen underneath left all of that on + // top of a "locked" wallet. + try { delete window.__aegisLastPw; } catch {} + location.reload(); } catch (e) { /* silent — user activity will retry */ } }, mins * 60 * 1000); }; @@ -1009,7 +1014,12 @@ function aegisConfirm(opts) { }; const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); finish(false); } - else if (e.key === "Enter") { e.stopPropagation(); finish(true); } + else if (e.key === "Enter") { + // Enter means "the focused button". It used to mean yes even with + // focus on Cancel, so Tab → Enter removed the wallet. + e.preventDefault(); e.stopPropagation(); + finish(document.activeElement?.dataset?.ac !== "no"); + } }; document.addEventListener("keydown", onKey, true); overlay.addEventListener("click", (e) => { if (e.target === overlay) finish(false); }); @@ -1017,8 +1027,10 @@ function aegisConfirm(opts) { e.stopPropagation(); finish(b.dataset.ac === "yes"); })); + // A destructive question opens on Cancel, like the host's own overlay. const yes = overlay.querySelector('[data-ac="yes"]'); - if (yes) yes.focus(); + const no = overlay.querySelector('[data-ac="no"]'); + if (o.danger && no) no.focus(); else if (yes) yes.focus(); }); } @@ -2208,7 +2220,7 @@ function openConsolidateModal() { } setBusy(true, "Sending…"); try { - const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked }); + const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId }); renderResult(res); } catch (e) { msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; @@ -2327,7 +2339,7 @@ async function renderConsolidateInline(hostEl) { const go = hostEl.querySelector("#inconGo"); go.disabled = true; go.textContent = "Sending…"; try { - const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked }); + const res = await S.invoke("consolidateIntoSelected", { sourceIds: checked, destinationWalletId: preview?.destinationWalletId }); const okCount = res.results.filter((r) => r.ok).length; const badCount = res.results.length - okCount; const explorer = sel()?.explorerTx || ""; @@ -3440,6 +3452,11 @@ function renderLockScreen(phase) { // as the panel navigates or reloads. window.__aegisLastPw = pw; setTimeout(() => { try { delete window.__aegisLastPw; } catch {} }, 60_000); + // Empty the field and let the form be rebuilt next time. It stayed + // filled behind the unlocked wallet, so after an idle lock or Sign out + // the password was sitting in the box for anyone to press Unlock. + try { $("gateUnlockPw").value = ""; } catch {} + body.dataset.mode = ""; body.dataset.forcePw = ""; render(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } @@ -3481,13 +3498,22 @@ function setupPinPad({ dots, keys, err, onComplete }) { try { res = await onComplete(buf); } finally { keys.querySelectorAll("button").forEach((x) => x.disabled = false); - if (res !== "ok") { buf = ""; paint(); } + // Always forget the digits. A pad that kept its six after "ok" was + // dead on the next lock and held the PIN in memory until then. + buf = ""; paint(); } } })); // Keyboard fallback — some users prefer typing 6 digits fast. const keyHandler = async (e) => { if (!dots.isConnected) { document.removeEventListener("keydown", keyHandler); return; } + // Only while this pad is actually on screen, and never for keys typed + // into a field. The lock-screen pad stays in the DOM (hidden) after + // 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; + const tgt = e.target; + if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return; if (err) err.textContent = ""; if (/^[0-9]$/.test(e.key)) { if (buf.length >= 6) return; @@ -3498,7 +3524,7 @@ function setupPinPad({ dots, keys, err, onComplete }) { try { res = await onComplete(buf); } finally { keys.querySelectorAll("button").forEach((x) => x.disabled = false); - if (res !== "ok") { buf = ""; paint(); } + buf = ""; paint(); } } } else if (e.key === "Backspace") { buf = buf.slice(0, -1); paint(); } @@ -3509,6 +3535,7 @@ function setupPinPad({ dots, keys, err, onComplete }) { function render() { if (!state) return; + noteSelectedWallet(); const s = sel(); const ready = s && s.phase === "ready"; const phase = s?.phase; @@ -4120,27 +4147,74 @@ function setUnit(u) { const s = amountUnits(); unit = u; applyUnitPicker(); - if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s); + // Exact conversion in the decimals of what is being sent. fmtBig() caps at + // 8 places and always used the chain's decimals, so 1 wei became "0" and a + // 6-decimal token amount shrank 1000× on a round trip. + if (s) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(s), amountDecimals()) : String(s); updateSendFiatPreview(); } -function amountUnits() { - const raw = $("sendAmt").value.trim().replace(/,/g, ""); - if (!raw) return 0; - // For SPL tokens the amount is a raw u64 string in the token's own - // smallest unit — same BigInt-safe path SC uses. - const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); - const bigDecimals = sendAsset != null || d > 15; - if (unit === "small") { - if (bigDecimals) return raw.replace(/\D+/g, "") || "0"; - return Math.round(Number(raw)); +function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 : decimals(); } + +// — pure functions, exercised by the sandbox harness. +// What the user typed → { whole, frac } digit strings. A wallet must never +// 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, ""); + if (!raw) return { empty: true }; + 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)) { + return { error: `"${raw}" could mean ${raw.replace(",", "")} or ${raw.replace(",", ".")} — write it without the comma, or with a dot` }; + } else if (/^\d*,\d+$/.test(raw)) { + raw = raw.replace(",", "."); // decimal comma: 0,5 } + if (!/^(\d+\.?\d*|\.\d+)$/.test(raw)) return { error: "Enter a plain number, like 0.5" }; const [w, f = ""] = raw.split("."); - const frac = (f + "0".repeat(d)).slice(0, d); - if (bigDecimals) { - const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0"); - return total.toString(); + return { whole: (w || "0").replace(/^0+(?=\d)/, ""), frac: f }; +} +// → smallest units as a decimal string, or { error }. `unit` is "big" (coins) +// or "small" (sats / wei / raw token units). +function amountToUnits(text, decimalsN, unit) { + const p = parseAmountText(text); + if (p.empty) return { units: "0", empty: true }; + if (p.error) return { error: p.error }; + const d = Math.max(0, Math.floor(Number(decimalsN) || 0)); + if (unit === "small") { + if (/[1-9]/.test(p.frac)) return { error: "The smallest unit cannot have decimals" }; + return { units: BigInt(p.whole).toString() }; } - return Number(w || 0) * Math.pow(10, d) + Number(frac || 0); + if (/[1-9]/.test(p.frac.slice(d))) return { error: `At most ${d} decimal place${d === 1 ? "" : "s"} here` }; + const frac = (p.frac + "0".repeat(d)).slice(0, d); + return { units: (BigInt(p.whole) * (10n ** BigInt(d)) + BigInt(frac || "0")).toString() }; +} +// Exact inverse for filling the field: units → "1.5" with no grouping. +function unitsToDecimalText(units, decimalsN) { + const d = Math.max(0, Math.floor(Number(decimalsN) || 0)); + const s = String(units).replace(/^0+(?=\d)/, ""); + if (!/^\d+$/.test(s)) return ""; + if (d === 0) return s; + const pad = s.padStart(d + 1, "0"); + const frac = pad.slice(pad.length - d).replace(/0+$/, ""); + return pad.slice(0, pad.length - d) + (frac ? "." + frac : ""); +} +// + +// Why the amount in the field is not usable, or null. Set by amountUnits(). +let amountError = null; +function amountUnits() { + amountError = null; + const d = amountDecimals(); + const r = amountToUnits($("sendAmt").value, d, unit); + if (r.error) { amountError = r.error; return 0; } + if (r.empty) return 0; + // SPL tokens and 18/24-decimal coins travel as decimal strings; the 8–9 + // decimal chains keep their Number contract with the host, bounded so a + // value past 2^53 is refused instead of silently rounded. + const bigDecimals = sendAsset != null || d > 15; + if (bigDecimals) return r.units; + if (BigInt(r.units) > BigInt(Number.MAX_SAFE_INTEGER)) { amountError = "That amount is too large"; return 0; } + return Number(r.units); } // Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere). function balanceSum(b) { @@ -4425,50 +4499,119 @@ function updateSendFiatPreview() { const el = $("sendAmtFiat"); if (!el) return; const s = sel(); if (!s || sendAsset) { el.hidden = true; return; } const units = amountUnits(); - if (!units || !state?.prices?.enabled) { el.hidden = true; return; } + if (!units || amountError || !state?.prices?.enabled) { el.hidden = true; return; } const usd = usdOf(chain(), units, decimals()); const txt = fmtFiat(usd); el.textContent = txt ? "≈ " + txt : ""; el.hidden = !txt; } -function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); } +function schedulePlan() { + // The summary on screen is stale from the first keystroke: Send stays off + // until a plan for exactly what is in the form has come back. + lastPlan = null; lastPlanReq = null; + const b = $("sendBtn"); if (b) b.disabled = true; + clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); +} +// The request a plan was made for. Send submits THIS, never a fresh read of +// the form, so what is signed is what the summary showed. +let lastPlanReq = null; +let planSeq = 0; +function currentSendReq() { + const amount = amountUnits(); + const base = { walletId: state?.selectedWalletId || null, to: $("sendTo").value.trim() }; + if (sendAsset) return { ...base, mint: sendAsset.mint, amount }; + return { + ...base, amount, + feeRate: chain() === "bch" ? Number($("feeRate").value) : undefined, + sendMax, + memo: chain() === "bch" ? String($("sendMemo")?.value || "").trim() : "", + }; +} +// Selecting another wallet invalidates everything the Send form worked out. +// The fields used to survive the switch: the button stayed live on the old +// wallet's plan, Max swept the new wallet under the old summary, and a +// number typed in sats was re-read as wei. +let sendFormWalletId = null; +function noteSelectedWallet() { + const id = state?.selectedWalletId || null; + if (id === sendFormWalletId) return; + const prev = (state?.wallets || []).find((w) => w.id === sendFormWalletId); + const cur = (state?.wallets || []).find((w) => w.id === id); + const first = sendFormWalletId === null; + sendFormWalletId = id; + if (first) return; + lastPlan = null; lastPlanReq = null; planSeq++; + clearTimeout(planTimer); + sendAsset = null; + if (sendMax) { + sendMax = false; + try { $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; $("sendAmt").value = ""; } catch {} + } + // Another coin means the address and the number mean something else. + if (!prev || !cur || prev.chain !== cur.chain || prev.network !== cur.network) { + try { $("sendTo").value = ""; $("sendAmt").value = ""; if ($("sendMemo")) $("sendMemo").value = ""; } catch {} + unit = "big"; + try { applyUnitPicker(); } catch {} + } + try { + $("sendBtn").disabled = true; + $("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—"; + $("sendMsg").hidden = true; + } catch {} + if ($("sendTo")?.value && $("sendAmt")?.value) schedulePlan(); +} async function updatePlan() { + const seq = ++planSeq; const to = $("sendTo").value.trim(); const msg = $("sendMsg"); msg.hidden = true; - lastPlan = null; $("sendBtn").disabled = true; + lastPlan = null; lastPlanReq = null; $("sendBtn").disabled = true; $("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—"; $("sendToHint").textContent = ""; - if (!to || (!sendMax && !amountUnits())) return; + const req = currentSendReq(); + if (amountError && !sendMax) { msg.className = "msg err"; msg.textContent = amountError; msg.hidden = false; return; } + if (!to || (!sendMax && !req.amount)) return; try { if (sendAsset) { // SPL token flow — amount is raw units of the token's decimals. - const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() }); - lastPlan = { _token: true, ...p }; + const p = await S.invoke("planTokenSend", { mint: req.mint, to, amount: req.amount }); + if (seq !== planSeq) return; // a newer plan is on its way + lastPlan = { _token: true, ...p }; lastPlanReq = req; $("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol; $("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL"; $("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol; $("sendBtn").disabled = false; return; } - const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; - const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : ""; - const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax, memo }); - lastPlan = p; + const p = await S.invoke("planSend", { to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo }); + if (seq !== planSeq) return; // a newer plan is on its way + lastPlan = p; lastPlanReq = req; $("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : ""; $("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker(); $("sumFee").textContent = chain() === "bch" ? fmtSmall(p.fee) + " " + smallUnitLabel() : fmtBig(p.fee) + " " + ticker(); $("sumTotal").textContent = fmtBig(p.total) + " " + ticker(); - if (sendMax) $("sendAmt").value = unit === "big" ? fmtBig(p.recipients[0].value) : String(p.recipients[0].value); + if (sendMax) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(p.recipients[0].value), amountDecimals()) : String(p.recipients[0].value); $("sendBtn").disabled = false; } catch (e) { + if (seq !== planSeq) return; msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; } } $("sendBtn").addEventListener("click", async () => { - if (!lastPlan) return; + if (!lastPlan || !lastPlanReq) return; const msg = $("sendMsg"); msg.hidden = true; + // The form must still say what the summary was computed for. Max rewrites + // the amount field itself, so it is compared without the amount. + const now = currentSendReq(); + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); + const strip = (r) => (r.sendMax ? { ...r, amount: null } : r); + if (!same(strip(now), strip(lastPlanReq))) { + await updatePlan(); + if (lastPlan) { msg.className = "msg err"; msg.textContent = "The form changed — check the updated summary, then press Send again."; msg.hidden = false; } + return; + } + const req = lastPlanReq; // PIN gate. Whether a transaction needs one is the policy's call, not a // single flag's — and if the user only asked for a PIN at startup, this // check passes without a prompt. @@ -4477,16 +4620,22 @@ $("sendBtn").addEventListener("click", async () => { } $("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…"; try { - const isToken = sendAsset && lastPlan._token; - const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined; - const memo = chain() === "bch" ? String($("sendMemo")?.value || "").trim() : ""; + const isToken = !!(req.mint && lastPlan._token); const r = isToken - ? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() }) - : await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax, memo }); + ? await S.invoke("sendToken", { 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. + const txid = r && typeof r.txid === "string" ? r.txid : ""; msg.className = "msg ok"; - msg.innerHTML = `Sent. ${esc(r.txid.slice(0, 16))}…`; - msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid))); + if (txid) { + msg.innerHTML = `Sent. ${esc(txid.slice(0, 16))}…`; + msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, txid))); + } else { + msg.textContent = "Sent. The network did not return a transaction id — check History before sending again."; + } msg.hidden = false; + lastPlanReq = null; $("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; if ($("sendMemo")) $("sendMemo").value = ""; $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; @@ -4741,7 +4890,10 @@ for (const [id, key] of PIN_ON_FIELDS) { securityState = await S.invoke("securitySet", { pinOn: { [key]: want } }); // Ticking a trigger should not fire it retroactively: the user is // sitting in Settings having just proved whatever got them here. - if (want) { try { await S.invoke("pinGateSatisfied"); } catch {} } + // The host only records a gate against proof it verified, so this + // works when the password is still at hand (just unlocked); otherwise + // the new trigger simply asks once at its next occasion. + if (want && window.__aegisLastPw) { try { await S.invoke("pinGateSatisfied", { masterPassword: window.__aegisLastPw }); } catch {} } renderGeneralSecurity(); } catch (e) { box.checked = !want; @@ -4998,9 +5150,25 @@ async function pinGate(event, subtitle) { try { st = await S.invoke("pinGateStatus", { event }); } catch { st = { needPin: true, reason: "unknown" }; } if (!st.needPin) return true; - const ok = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN."); - if (ok) { try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next time */ } } - return ok; + // verifyPinInteractively resolves to what the PIN unwrapped — the vault + // master password — and the host checks that itself before it records the + // gate or lets a transaction through. A PIN the host could not verify + // 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 }); } + 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. +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 */ } } const PIN_GATE_COPY = { restart: "Theseus restarted — confirm your PIN to use this wallet.", @@ -5040,9 +5208,10 @@ async function pinGateOnLoad() { // Keep asking until it is satisfied. Cancelling does not open the wallet; // it leaves the door shut with a button to try again, which is the only // honest outcome for "a PIN is required here". + let proof = null; for (;;) { - const ok = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN."); - if (ok) break; + proof = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN."); + if (proof) break; const again = await aegisConfirm({ title: "PIN required", icon: "🔒", @@ -5052,7 +5221,7 @@ async function pinGateOnLoad() { }); if (!again) return; // stays blocked; door remains shut } - try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next load */ } + try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } catch { /* re-asks next load */ } pinGateBlocked = false; render(); } @@ -5219,9 +5388,11 @@ async function verifyPinInteractivelyOnce(subtitle) { try { const blob = await S.invoke("pinBlobGet"); if (!blob) throw new Error("no PIN configured"); - await pinDecryptMaster(pin, blob); + const master = await pinDecryptMaster(pin, blob); await S.invoke("pinFailReset").catch(() => {}); - done(true); + // Truthy for every existing caller; the gate hands it to the host + // as proof (see pinGate). + done(master || true); return "ok"; } catch (e) { const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); @@ -5586,6 +5757,7 @@ S.on("state", (s) => { // tab behind the door either. if (tab === "settings" && !pinGateBlocked) fillSettings(); }); +S.on("pinRequest", (req) => { answerPinRequest(req); }); (async () => { // Load security + session state first so the very first render() knows // whether to paint the PIN pad on the lock screen and what idle-lock @@ -5605,6 +5777,8 @@ S.on("state", (s) => { await pinGateOnLoad(); render(); bindIdleAutoLock(); + // Opened because a dapp transaction is waiting for the PIN? Answer it. + try { answerPinRequest(await S.invoke("pinRequestPending")); } catch {} })(); // Persistent footer: aegis.x brand link + version marker + update check.