From 40e7fe2ae9ce30066b9e86c6c7eea26b2e4791ce Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 00:13:41 +0200 Subject: [PATCH] fix(aegis): PIN asked at the door, and the pad stops erasing itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions in 0.26.0. The PIN was still asked after the wallet had painted. pinGateOnLoad() ran after render() and was not awaited, so the interface appeared — balances and all — and the pad then opened on top of a panel the user could already read. It is now awaited before the first render, and `pinGateBlocked` keeps the chrome hidden for as long as the PIN is owed: render() returns early into a closed door, state pushes cannot paint around it, and Settings is covered too (unlike the vault lock, where Settings must stay reachable to create a PIN in the first place, here it would just be a way around the gate). Cancelling leaves the door shut with a retry rather than falling through to an open wallet. The pad threw digits away mid-entry. render() fires on every state push — balance polls fire it every couple of seconds — and renderLockScreen() rebuilt body.innerHTML unconditionally, replacing the pad and its buffer under the user's fingers. The reset was timed to the poll, not to the keypress count, which is why it looked like "after two digits". All three branches are now keyed on body.dataset.mode and rebuild only when the mode actually changes; the master-password and first-run forms had the same defect and were erasing half-typed input the same way. Also: every pad was wired through getElementById, so two pads alive at once (a load gate plus a transaction gate) were duplicate ids and the second setupPinPad bound the first pad's buttons. All four are now scoped to their own container, only one PIN prompt can be open at a time, and setupPinPad carries a note saying why it must never be handed a global lookup. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/panel.js | 125 ++++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 17 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 680d428a..02188848 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.26.0", + "version": "0.26.1", "category": "plugin", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 52c7b8da..819ec08b 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -3286,6 +3286,8 @@ function renderLockScreen(phase) { const body = $("lockBody"); if (phase === "nosetup") { title.textContent = "Set up Aegis"; + if (body.dataset.mode === "setup") return; + body.dataset.mode = "setup"; sub.textContent = "Pick a master password — every Aegis wallet is derived from it. The same master password on another machine recreates the same addresses."; body.innerHTML = `
@@ -3323,6 +3325,13 @@ function renderLockScreen(phase) { 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) { + // 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. + // That is the "resets after two digits" report: the reset is timed to the + // poll, not to the keypress count. Rebuild only when the mode changes. + if (body.dataset.mode === "pin") return; + body.dataset.mode = "pin"; body.innerHTML = `
${"".repeat(6)}
@@ -3336,9 +3345,9 @@ function renderLockScreen(phase) {
`; setupPinPad({ - dots: $("lockPinDots"), - keys: $("lockPinKeys"), - err: $("lockPinErr"), + dots: body.querySelector("#lockPinDots"), + keys: body.querySelector("#lockPinKeys"), + err: body.querySelector("#lockPinErr"), onComplete: async (pin) => { const remain = await pinLockoutRemainingMs(); if (remain > 0) { @@ -3367,7 +3376,10 @@ function renderLockScreen(phase) { if ($("lockGoSettingsFromPin")) $("lockGoSettingsFromPin").addEventListener("click", () => showTab("settings")); return; } - // Master-password entry. + // Master-password entry. Guarded for the same reason as the pad above: + // rebuilding on every state push erased a half-typed password. + if (body.dataset.mode === "pw") return; + body.dataset.mode = "pw"; body.innerHTML = `
@@ -3405,6 +3417,12 @@ function renderLockScreen(phase) { // typed and must return "ok" (leave state) or "reset" (clear back to // empty). Rendered by renderLockScreen for the unlock flow and by the // PIN modal helper for set / verify flows. +// IMPORTANT: pass `dots`/`keys`/`err` as elements scoped to the pad's own +// container, never via getElementById. Each pad's markup hard-codes its ids, +// so two pads alive at once (easy: a load-time gate plus a transaction gate) +// are duplicate ids — a global lookup then returns the FIRST one, and this +// function binds a second click handler to the wrong pad's buttons. The +// symptom is a pad that appends two digits per press and resets after three. function setupPinPad({ dots, keys, err, onComplete }) { let buf = ""; const paint = () => { @@ -3463,6 +3481,11 @@ function render() { // Settings is the only always-usable tab (fiat prices, connected sites // — nothing needs a live wallet). Every other tab is gated. const onSettings = tab === "settings"; + // An owed PIN hides everything, Settings included: unlike the vault lock + // (where Settings must stay reachable to configure a PIN in the first + // place), the PIN is already configured here and letting Settings through + // would be a way around the door. + if (pinGateBlocked) { paintPinDoor(); return; } $("tabs").hidden = !(ready || onSettings); const gate = $("gate"); gate.hidden = ready || onSettings || fullLock; @@ -4619,7 +4642,9 @@ function openPinModal({ title, subtitle, mode }) { wrap.addEventListener("click", (e) => { if (e.target === wrap) close(null); }); wrap.querySelector("#pmCancel").addEventListener("click", () => close(null)); setupPinPad({ - dots: $("pmDots"), keys: $("pmKeys"), err: $("pmErr"), + // Scoped to this overlay, not looked up by global id — see the note + // on setupPinPad about duplicate ids across simultaneous pads. + dots: wrap.querySelector("#pmDots"), keys: wrap.querySelector("#pmKeys"), err: wrap.querySelector("#pmErr"), onComplete: async (pin) => { if (mode === "confirm" && first.pin == null) { first.pin = pin; @@ -4949,7 +4974,15 @@ const PIN_GATE_COPY = { // Gate the panel itself on load. Runs once per panel script load, which is // also once per hide/show, so "on each wallet launch" is simply this check // with that trigger enabled. +// +// This must finish BEFORE the first render(). Asking afterwards was the +// original complaint in a new costume: the wallet painted, balances and all, +// and the pad then appeared on top of a panel the user could already read. +// `pinGateBlocked` keeps the chrome hidden for as long as the PIN is owed, +// so a cancelled or failed prompt leaves a closed door rather than an open +// wallet. let pinGateChecked = false; +let pinGateBlocked = false; async function pinGateOnLoad() { if (pinGateChecked) return; pinGateChecked = true; @@ -4959,7 +4992,44 @@ async function pinGateOnLoad() { // is already in the way, and stacking a PIN prompt on top of it is two // locks on one door. if (state && (state.overallPhase === "locked" || state.overallPhase === "nosetup")) return; - await pinGate("panel-load"); + let st; + try { st = await S.invoke("pinGateStatus", { event: "panel-load" }); } + catch { st = { needPin: true, reason: "unknown" }; } + if (!st.needPin) return; + + pinGateBlocked = true; + paintPinDoor(st.reason); + // 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". + for (;;) { + const ok = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN."); + if (ok) break; + const again = await aegisConfirm({ + title: "PIN required", + icon: "🔒", + confirmLabel: "Enter PIN", + cancelLabel: "Leave locked", + body: "Aegis stays locked until the PIN is entered. You can also unlock with your master password from Settings.", + }); + if (!again) return; // stays blocked; door remains shut + } + try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next load */ } + pinGateBlocked = false; + render(); +} + +// The closed door shown behind the pad, so there is never a moment where the +// wallet is readable but unauthenticated. +function paintPinDoor(reason) { + const g = $("gate"); + if (!g) return; + document.querySelector("header").hidden = true; + document.querySelector("nav").hidden = true; + const strip = $("walletStrip"); if (strip) strip.hidden = true; + const ls = $("lockScreen"); if (ls) ls.hidden = true; + g.hidden = false; + g.innerHTML = `
🔒
${esc(PIN_GATE_COPY[reason] || "Confirm with your PIN.")}
`; } // How many wrong PINs before a sensitive reveal stops asking for the PIN and @@ -5037,7 +5107,7 @@ function capturePinForSecret(subtitle) { wrap.querySelector("#rsUsePw").addEventListener("click", () => done("__fallback__")); let tries = 0; setupPinPad({ - dots: $("rsDots"), keys: $("rsKeys"), err: $("rsErr"), + dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"), onComplete: async (pin) => { try { const blob = await S.invoke("pinBlobGet"); @@ -5064,7 +5134,18 @@ function capturePinForSecret(subtitle) { // Ask the user to prove they know the PIN. Uses the same lockout counter // as the unlock flow so an attacker can't drain guesses via a spammed // Send button. Returns true on match, false on cancel / lockout / bad PIN. -async function verifyPinInteractively(subtitle) { +// Only one PIN prompt at a time. Two concurrent prompts are asking the same +// question, so the second joins the first instead of stacking a second pad +// over it — which is also how duplicate pad ids came to collide. +let pinPromptInFlight = null; +function verifyPinInteractively(subtitle) { + if (pinPromptInFlight) return pinPromptInFlight; + pinPromptInFlight = verifyPinInteractivelyOnce(subtitle) + .finally(() => { pinPromptInFlight = null; }); + return pinPromptInFlight; +} + +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.`); @@ -5095,7 +5176,7 @@ async function verifyPinInteractively(subtitle) { const done = (v) => { try { wrap.remove(); } catch {} resolve(v); }; wrap.querySelector("#vpCancel").addEventListener("click", () => done(false)); setupPinPad({ - dots: $("vpDots"), keys: $("vpKeys"), err: $("vpErr"), + dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"), onComplete: async (pin) => { try { const blob = await S.invoke("pinBlobGet"); @@ -5456,20 +5537,32 @@ $("refreshPrices").addEventListener("click", async () => { }); // ---- boot ------------------------------------------------------------------ -S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); }); +S.on("state", (s) => { + state = s; + render(); + // render() returns early while a PIN is owed; don't fill a hidden Settings + // tab behind the door either. + if (tab === "settings" && !pinGateBlocked) fillSettings(); +}); (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 // timer to arm once the vault is open. try { await refreshSecurityState(); } catch {} try { await refreshSessionState(); } catch {} - try { state = await S.invoke("state"); render(); } - catch (e) { $("gate").hidden = false; $("gate").innerHTML = `
⚠
${esc(cleanErr(e))}
`; } + try { state = await S.invoke("state"); } + catch (e) { + $("gate").hidden = false; + $("gate").innerHTML = `
⚠
${esc(cleanErr(e))}
`; + return; + } + // Settle the PIN BEFORE the first paint. Awaited on purpose: rendering the + // wallet first and prompting afterwards is exactly the behaviour being + // fixed, and a fire-and-forget call also let a second prompt open on top + // of this one. + await pinGateOnLoad(); + render(); bindIdleAutoLock(); - // Ask for the PIN at the door if the policy says so, rather than letting - // the wallet open and then interrupting the first thing the user clicks. - // This runs once per panel load, which is also once per hide/show. - pinGateOnLoad(); })(); // Persistent footer: aegis.x brand link + version marker + update check.