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.
This commit is contained in:
Local Dev 2026-10-04 02:23:02 +02:00
parent 2faa3d808a
commit 4511a933f4
2 changed files with 53 additions and 20 deletions

View file

@ -1282,33 +1282,43 @@ function noteMasterVerified(api) {
// single-use fact recorded only after the host itself has verified the // single-use fact recorded only after the host itself has verified the
// master password the PIN unwraps (pinGateSatisfied). Panel sends consume // master password the PIN unwraps (pinGateSatisfied). Panel sends consume
// one; dapp transactions ask the open panel for one and wait. // 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 TX_CLEARANCE_MS = 90_000;
const DAPP_PIN_WAIT_MS = 120_000; const DAPP_PIN_WAIT_MS = 120_000;
let txClearanceUntil = 0; let panelClearanceUntil = 0;
let pendingPinRequest = null; // { origin, what, at } while a dapp tx waits for the PIN const pendingPinRequests = new Map(); // id -> { id, origin, what, at, cleared }
function txPinOwed() { function txPinOwed() {
try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); } try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); }
catch { return true; } // the safe direction for a lock is closed 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() { 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) { async function requireDappTxPin(origin, what) {
if (!txPinOwed() || takeTxClearance()) return; if (!txPinOwed()) return;
pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() }; 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 {
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; const deadline = Date.now() + DAPP_PIN_WAIT_MS;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250)); await new Promise((r) => setTimeout(r, 250));
if (!ctx) break; 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."); 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"); } catch { throw new Error("PIN proof rejected"); }
noteMasterVerified(api); noteMasterVerified(api);
api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); 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; return true;
}); });
// A panel opened while a dapp transaction is waiting for the PIN picks the // 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. // request up here; one already open gets the "pinRequest" event instead.
api.onMessage("pinRequestPending", (_p, m) => { api.onMessage("pinRequestPending", (_p, m) => {
fromPanel(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; ctx.pinNeeded = pinNeeded;
// Seal a plain blob left by an older build now, not when the panel next // Seal a plain blob left by an older build now, not when the panel next

View file

@ -1196,6 +1196,9 @@ function openWalletManageModal(w) {
+ `<br><br>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.`, + `<br><br>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; 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 { try {
const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel }); const r = await S.invoke("promoteToHd", { walletId: w.id, label: pv.suggestedLabel });
close(); close();
@ -5139,19 +5142,29 @@ async function pinGate(event, subtitle) {
// does not count, so a failure here is a failed gate. // 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."); const proof = await verifyPinInteractively(subtitle || PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
if (!proof) return false; 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; } catch { return false; }
return true; return true;
} }
// A dapp transaction is waiting on the PIN ("ask on every transaction"). // 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 // The host cannot draw a PIN pad, so it asks the panel: prove the PIN here
// and the pending transaction goes through. // 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) { async function answerPinRequest(req) {
if (!req || pinGateBlocked) return; if (!req || !req.id || pinGateBlocked || pinRequestBusy) return;
pinRequestBusy = true;
try {
const where = String(req.origin || "A site").slice(0, 80); 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.`); const proof = await verifyPinInteractively(`${where} is waiting — confirm the ${String(req.what || "transaction").slice(0, 60)} with your PIN.`);
if (!proof) return; if (proof) { try { await S.invoke("pinGateSatisfied", { masterPassword: proof, requestId: req.id }); } catch { /* host keeps waiting, then refuses */ } }
try { await S.invoke("pinGateSatisfied", { masterPassword: proof }); } 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 = { const PIN_GATE_COPY = {
restart: "Theseus restarted — confirm your PIN to use this wallet.", restart: "Theseus restarted — confirm your PIN to use this wallet.",