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.",