From 4511a933f49a394e897dabd871c0ccb17e0ca526 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:23:02 +0200 Subject: [PATCH] Aegis: a PIN clears only the transaction it was entered for One global 90 s clearance was opened by every PIN proof. Opening the wallet or ticking a setting let the next dapp transaction from any site through without a PIN; a dapp waiting in its poll could take the clearance the user had just made for their own send; and with two sites waiting the second one's request was dropped. Each waiting dapp transaction now has an id, and only a proof naming that id releases it; a proof given for "transaction" in the panel clears the panel's next send only; any other proof clears nothing. The panel answers waiting sites one at a time. Promote to HD now asks for the PIN like any other spend instead of failing when PIN-per-transaction is on. --- bundled-addons/aegis/index.js | 48 +++++++++++++++++++++++++---------- bundled-addons/aegis/panel.js | 25 +++++++++++++----- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index dca00618..df8bebea 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1282,33 +1282,43 @@ function noteMasterVerified(api) { // 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."); } @@ -2717,14 +2727,24 @@ function registerPanelMessages(api) { 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 diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 80bfe653..09f97eb0 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -1196,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(); @@ -5139,19 +5142,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.",