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) {
Use master password instead · Settings
`; 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.