From c906e7b8eddc9663bbb9c9422c77057e55cb955a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:10:51 +0200 Subject: [PATCH 1/7] Aegis: refuse a Tron transaction whose fields appear twice The decoder read the first copy of a singular protobuf field; java-tron keeps the last. Any.value is opaque bytes, so a TransferContract carrying two recipients and two amounts hashes to the same txid either way: the overlay showed "1 TRX to X" while the chain would move 999 TRX to another address. It also defeated the plan-time and sign-time draft checks against a hostile node. A repeated singular field, or a known field with the wrong wire type, now refuses the transaction. --- bundled-addons/aegis/lib/tron-decode.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/bundled-addons/aegis/lib/tron-decode.js b/bundled-addons/aegis/lib/tron-decode.js index 8ecc60ae..396fc3b9 100644 --- a/bundled-addons/aegis/lib/tron-decode.js +++ b/bundled-addons/aegis/lib/tron-decode.js @@ -81,9 +81,20 @@ module.exports = function makeTronDecode({ sha256, base58check }) { } return out; } - const one = (fields, n) => fields.find((f) => f.field === n); - const bytesOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 2 ? f.value : null; }; - const numOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 0 ? f.value : null; }; + // A singular field that appears twice is legal protobuf: the parser keeps + // the LAST copy (and merges repeated sub-messages). java-tron does that, so + // reading the first copy would show the user one recipient and amount while + // the chain executes another — and Any.value is opaque bytes, so the signed + // hash is the same either way. Refuse instead of guessing, and refuse a + // known field sent with the wrong wire type for the same reason. + const one = (fields, n, wire) => { + const hits = fields.filter((f) => f.field === n); + if (hits.length > 1) throw new Error(`field ${n} appears ${hits.length} times; refusing an ambiguous transaction`); + if (hits[0] && hits[0].wire !== wire) throw new Error(`field ${n} has wire type ${hits[0].wire}, expected ${wire}`); + return hits[0]; + }; + const bytesOf = (fields, n) => { const f = one(fields, n, 2); return f ? f.value : null; }; + const numOf = (fields, n) => { const f = one(fields, n, 0); return f ? f.value : null; }; // 21-byte Tron address (0x41 || h20) → "T…" base58check. Anything else is // returned as hex so the overlay never hides a malformed field. From b85605416e23b75c45a9f441c40261ff76f72167 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:17:26 +0200 Subject: [PATCH 2/7] Aegis: the PIN is checked by the host, and guessing ends at five The panel fetched the PIN blob and decrypted it itself, then reported its own failures. The lockout therefore counted only what a well-behaved panel chose to report, a 15-minute timer handed out five more guesses forever, and anything able to run in the panel could take the blob and search the million PINs offline in minutes. The blob now never leaves index.js: pinSet builds it after checking the master password against the vault, pinUnwrap counts each guess before trying it, and five wrong guesses switch the PIN off until the master password is entered. The panel keeps its PIN pads and only sends digits. A blob from an older build (200k iterations) is re-made at 600k under a fresh salt on the next correct PIN. Only the topmost PIN pad listens to typed digits, so two stacked pads cannot both take one entry. --- bundled-addons/aegis/index.js | 121 ++++++++++++++++------- bundled-addons/aegis/panel.js | 175 +++++++++++++--------------------- 2 files changed, 154 insertions(+), 142 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 40815483..8fc06d98 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1230,6 +1230,45 @@ function openPinBlob(api) { return plain; } +// The PIN is checked here, never in the panel. The panel used to fetch the +// blob and decrypt it itself, then report its own failures — so the lockout +// counted only the guesses a well-behaved panel chose to report, and anything +// that could run in the panel could take the blob and search all million +// PINs offline. Now the blob stays in this process, every guess is counted +// before it is tried, and PIN_MAX_FAILS wrong guesses switch the PIN off +// until the master password is entered (no timer that hands out more tries). +// The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag +// appended to the ciphertext, as WebCrypto wrote it. +const PIN_ITERS = 600_000; +const PIN_MAX_FAILS = 5; +const PIN_RE = /^\d{6}$/; +const nodeCrypto = require("node:crypto"); +const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) => + nodeCrypto.pbkdf2(String(pin), Buffer.from(saltHex, "hex"), iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k)))); +async function pinWrap(pin, masterPassword) { + const salt = nodeCrypto.randomBytes(16).toString("hex"); + const iv = nodeCrypto.randomBytes(12); + const c = nodeCrypto.createCipheriv("aes-256-gcm", await pinKey(pin, salt, PIN_ITERS), iv); + const ct = Buffer.concat([c.update(String(masterPassword), "utf8"), c.final(), c.getAuthTag()]); + return { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS }; +} +async function pinUnwrapBlob(pin, blob) { + const ct = Buffer.from(String(blob.ct), "hex"); + const d = nodeCrypto.createDecipheriv("aes-256-gcm", await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS), Buffer.from(String(blob.iv), "hex")); + d.setAuthTag(ct.subarray(ct.length - 16)); + return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8"); +} +function pinFails(api) { + const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0; + return { fails: n, requireMaster: !!api.storage.get("aegis/pin/requireMaster", false) || n >= PIN_MAX_FAILS }; +} +// A master password the vault accepted. Clears the PIN strikes — the only +// thing that does, apart from a correct PIN while the PIN is still allowed. +function noteMasterVerified(api) { + api.storage.set("aegis/pin/failCount", 0); + api.storage.set("aegis/pin/requireMaster", false); +} + // "Ask for PIN on every transaction" used to be decided by the host and // enforced by nobody: `send` never checked it, and a dapp-initiated // transaction had no PIN step at all. A clearance is now a short-lived, @@ -1341,6 +1380,7 @@ function registerPanelMessages(api) { fromPanel(m); const pw = String(p && p.masterPassword || ""); await api.vault.lifecycle.unlock(pw); + noteMasterVerified(api); await mountAllWallets(); return fullState(); }); @@ -2122,6 +2162,7 @@ function registerPanelMessages(api) { if (/no vault|not set up/i.test(msg)) throw new Error(msg); throw new Error("wrong master password"); } + noteMasterVerified(api); const id = String((p && p.walletId) || selectedWalletId() || ""); const entry = walletEntries().find((w) => w.id === id); @@ -2546,52 +2587,61 @@ function registerPanelMessages(api) { // decryption inside its iframe — the master password never crosses the // process boundary except via vaultUnlock. These handlers only shuttle // the opaque blob + a small policy object in and out of api.storage. - api.onMessage("pinBlobGet", (_p, m) => { + // Set or replace the PIN. The master password is checked against the vault + // first, and the blob is built here, so the panel never holds one. + api.onMessage("pinSet", async (p, m) => { fromPanel(m); - return openPinBlob(api); - }); - api.onMessage("pinBlobSet", (p, m) => { - fromPanel(m); - const blob = p && p.blob; - if (!blob || typeof blob !== "object") throw new Error("blob required"); - if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") { - throw new Error("blob shape invalid"); - } - api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters })); + const pin = String((p && p.pin) || ""); + const pw = String((p && p.masterPassword) || ""); + if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits"); + if (!pw) throw new Error("master password required"); + try { await api.vault.lifecycle.unlock(pw); } + catch { throw new Error("wrong master password"); } + noteMasterVerified(api); + api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); return true; }); + // Try a PIN. Counts the guess before trying it, so a crash or a closed + // panel mid-check still costs an attempt. Answers + // { ok: true, masterPassword } | { ok: false, remaining, requireMaster } + api.onMessage("pinUnwrap", async (p, m) => { + fromPanel(m); + const pin = String((p && p.pin) || ""); + const blob = openPinBlob(api); + if (!blob) throw new Error("no PIN is set"); + if (pinFails(api).requireMaster) return { ok: false, remaining: 0, requireMaster: true }; + const before = pinFails(api).fails; + api.storage.set("aegis/pin/failCount", before + 1); + let pw = null; + if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } } + if (pw == null) { + const fails = before + 1; + if (fails >= PIN_MAX_FAILS) api.storage.set("aegis/pin/requireMaster", true); + return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - fails), requireMaster: fails >= PIN_MAX_FAILS }; + } + api.storage.set("aegis/pin/failCount", 0); + // A blob from an older build (200k iterations, or from before sealing) + // is re-made now, under a fresh salt, while the PIN is at hand. + if ((Number(blob.iters) || 0) < PIN_ITERS) { + try { api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); } catch (e) { api.log("PIN re-wrap:", e?.message || e); } + } + return { ok: true, masterPassword: pw }; + }); + api.onMessage("pinStatus", (_p, m) => { + fromPanel(m); + const f = pinFails(api); + return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, maxFails: PIN_MAX_FAILS, requireMaster: f.requireMaster }; + }); api.onMessage("pinBlobClear", (_p, m) => { fromPanel(m); api.storage.set("aegis/pin/v1", null); api.storage.set("aegis/pin/failCount", 0); + api.storage.set("aegis/pin/requireMaster", false); // Drop the gate record as well, so enrolling a new PIN later starts from // "not yet satisfied" rather than inheriting the old PIN's clearance. api.storage.set("aegis/pin/gate", null); return true; }); - // Track failed PIN attempts in the addon so a panel reload cannot bypass - // rate-limiting by dropping panel-side counters. - api.onMessage("pinFailInc", (_p, m) => { - fromPanel(m); - const cur = Number(api.storage.get("aegis/pin/failCount", 0)) || 0; - const next = cur + 1; - api.storage.set("aegis/pin/failCount", next); - api.storage.set("aegis/pin/failLast", Date.now()); - return { count: next, at: Date.now() }; - }); - api.onMessage("pinFailReset", (_p, m) => { - fromPanel(m); - api.storage.set("aegis/pin/failCount", 0); - api.storage.set("aegis/pin/failLast", 0); - return true; - }); - api.onMessage("pinFailStatus", (_p, m) => { - fromPanel(m); - return { - count: Number(api.storage.get("aegis/pin/failCount", 0)) || 0, - last: Number(api.storage.get("aegis/pin/failLast", 0)) || 0, - }; - }); // When to ask for the PIN. These are independent triggers, not a single // mode: wanting one at startup and one per transaction is a normal @@ -2658,6 +2708,7 @@ function registerPanelMessages(api) { if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN"); try { await api.vault.lifecycle.unlock(pw); } 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; return true; @@ -2678,6 +2729,7 @@ function registerPanelMessages(api) { const cfg = api.storage.get("aegis/security/v1", {}) || {}; return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinRequireMaster: pinFails(api).requireMaster, pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!cfg.requirePinForSending, @@ -2712,6 +2764,7 @@ function registerPanelMessages(api) { api.storage.set("aegis/security/v1", next); return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinRequireMaster: pinFails(api).requireMaster, pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!next.requirePinForSending, diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 4e041d9b..80bfe653 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -300,48 +300,20 @@ function fiatSkeleton() { return state?.prices?.enabled ? "≈ $—" : null; } -// ---- security: PIN encryption + verification (WebCrypto) ------------------- -// The PIN blob wraps the master password: PBKDF2-SHA256(pin, salt, iters) -// derives an AES-GCM key; the master password is encrypted with a fresh -// per-blob IV. The addon (main process) only handles the opaque blob; the -// panel never sends the raw PIN or the master password to it. The rate -// limiter is stored addon-side so reloading the panel cannot reset it. -const PIN_ITERS = 600000; // new blobs only; an existing blob carries its own count -const PIN_MAX_FAILS = 5; -const PIN_LOCKOUT_MS = 15 * 60 * 1000; -const b2h = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); -const h2b = (h) => { const b = new Uint8Array(h.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return b; }; -async function pinDeriveKey(pin, saltBytes, iters) { - const enc = new TextEncoder(); - const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); - return crypto.subtle.deriveKey( - { name: "PBKDF2", salt: saltBytes, iterations: iters, hash: "SHA-256" }, - material, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"], - ); -} -async function pinEncryptMaster(pin, masterPassword) { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const key = await pinDeriveKey(pin, salt, PIN_ITERS); - const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(masterPassword))); - return { salt: b2h(salt), iv: b2h(iv), ct: b2h(ct), iters: PIN_ITERS }; -} -async function pinDecryptMaster(pin, blob) { - const key = await pinDeriveKey(pin, h2b(blob.salt), blob.iters || PIN_ITERS); - const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: h2b(blob.iv) }, key, h2b(blob.ct)); - return new TextDecoder().decode(pt); -} -async function pinLockoutRemainingMs() { - try { - const s = await S.invoke("pinFailStatus"); - if (!s || !s.count || s.count < PIN_MAX_FAILS) return 0; - const since = Date.now() - (s.last || 0); - return since >= PIN_LOCKOUT_MS ? 0 : (PIN_LOCKOUT_MS - since); - } catch { return 0; } +// ---- security: PIN ---------------------------------------------------------- +// The pads below only collect six digits. The host (index.js pinUnwrap) +// holds the PIN blob, counts every guess before trying it, and after too +// many wrong ones switches the PIN off until the master password is entered. +// The panel never sees the blob, so it cannot be searched from here, and it +// cannot reset the counter. +// pinTry(pin) -> { ok: true, masterPassword } | { ok: false, remaining, requireMaster } +const pinTry = (pin) => S.invoke("pinUnwrap", { pin: String(pin) }); +async function pinNeedsMaster() { + try { return !!(await S.invoke("pinStatus")).requireMaster; } catch { return false; } } +const PIN_MASTER_COPY = "Too many wrong PINs. Enter your master password; the PIN works again after that."; +const wrongPinCopy = (remaining) => + `Wrong PIN. ${remaining} attempt${remaining === 1 ? "" : "s"} left, then Aegis asks for your master password.`; async function refreshSecurityState() { try { securityState = await S.invoke("securityGet"); @@ -3374,7 +3346,7 @@ function renderLockScreen(phase) { const forcePw = body.dataset.forcePw === "1"; 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) { + if (hasPin && !forcePw && !securityState?.pinRequireMaster) { // 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. @@ -3399,25 +3371,20 @@ function renderLockScreen(phase) { keys: body.querySelector("#lockPinKeys"), err: body.querySelector("#lockPinErr"), onComplete: async (pin) => { - const remain = await pinLockoutRemainingMs(); - if (remain > 0) { - $("lockPinErr").textContent = `Too many failed attempts. Try again in ${Math.ceil(remain / 60000)} min or use the master password.`; + let r; + try { r = await pinTry(pin); } + catch (e) { $("lockPinErr").textContent = cleanErr(e); return "reset"; } + if (!r.ok) { + $("lockPinErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining); + if (r.requireMaster) { body.dataset.forcePw = "1"; setTimeout(() => renderLockScreen("locked"), 1200); } return "reset"; } try { - const blob = await S.invoke("pinBlobGet"); - if (!blob) throw new Error("PIN not set"); - const pw = await pinDecryptMaster(pin, blob); - state = await S.invoke("vaultUnlock", { masterPassword: pw }); - await S.invoke("pinFailReset"); + state = await S.invoke("vaultUnlock", { masterPassword: r.masterPassword }); render(); return "ok"; } catch (e) { - const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); - const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0)); - $("lockPinErr").textContent = left > 0 - ? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.` - : `Locked for 15 min — use the master password instead.`; + $("lockPinErr").textContent = cleanErr(e); return "reset"; } }, @@ -3512,6 +3479,11 @@ function setupPinPad({ dots, keys, err, onComplete }) { // unlock, so six digits typed into the amount box counted as a PIN // attempt — and five such amounts locked the PIN for 15 minutes. if (dots.offsetParent === null) return; + // Two pads can be visible at once (a dapp's PIN request over a send's + // pad): only the topmost one listens, or six digits would complete both + // and a wrong PIN would cost two attempts. + const topModal = [...document.querySelectorAll(".pinmodal")].pop(); + if (topModal && !topModal.contains(dots)) return; const tgt = e.target; if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return; if (err) err.textContent = ""; @@ -5004,9 +4976,7 @@ async function handlePinSet(replacing) { }); if (!pin) return; try { - const blob = await pinEncryptMaster(pin, masterPw); - await S.invoke("pinBlobSet", { blob }); - await S.invoke("pinFailReset").catch(() => {}); + await S.invoke("pinSet", { pin, masterPassword: masterPw }); await refreshSecurityState(); renderGeneralSecurity(); } catch (e) { @@ -5253,11 +5223,11 @@ function paintPinDoor(reason) { } // How many wrong PINs before a sensitive reveal stops asking for the PIN and -// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose: -// someone fumbling their own PIN gets a way through that does not cost them a -// 15-minute lockout, and someone guessing is pushed onto the credential that -// is actually hard to guess. The global counter is NOT reset on the way -// across, so guesses still accumulate toward the lockout. +// asks for the master password instead. Lower than the host's limit (5) on +// purpose: someone fumbling their own PIN gets a way through before the PIN +// is switched off, and someone guessing is pushed onto the credential that +// is actually hard to guess. The host counter is NOT reset on the way +// across, so guesses still accumulate toward that limit. const REVEAL_PIN_MAX_FAILS = 3; // Prove entitlement to see a secret, and hand back the master password — @@ -5274,14 +5244,11 @@ async function authorizeForSecret(subtitle) { // the master password is the gate — never nothing. return promptMasterPassword({ title: "Confirm master password", subtitle }); } - const remain = await pinLockoutRemainingMs(); - if (remain > 0) { - // Locked out of the PIN, but the password is a separate credential and - // the lockout exists to stop PIN guessing, not to lock the owner out. - return promptMasterPassword({ - title: "Confirm master password", - subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(), - }); + if (await pinNeedsMaster()) { + // The PIN is switched off after too many wrong guesses, but the password + // is a separate credential: the strikes stop PIN guessing, they do not + // lock the owner out. + return promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() }); } const pin = await capturePinForSecret(subtitle); if (pin === null) return null; // cancelled @@ -5329,23 +5296,16 @@ function capturePinForSecret(subtitle) { setupPinPad({ dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"), onComplete: async (pin) => { - try { - const blob = await S.invoke("pinBlobGet"); - if (!blob) throw new Error("no PIN configured"); - const master = await pinDecryptMaster(pin, blob); - await S.invoke("pinFailReset").catch(() => {}); - done(master); - return "ok"; - } catch (e) { - tries++; - // Keep feeding the shared counter: these are real PIN guesses and - // they should still count toward the 15 min lockout. - await S.invoke("pinFailInc").catch(() => ({ count: 0 })); - if (tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; } - const left = REVEAL_PIN_MAX_FAILS - tries; - $("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`; - return "reset"; - } + let r; + try { r = await pinTry(pin); } + catch (e) { $("rsErr").textContent = cleanErr(e); return "reset"; } + if (r.ok) { done(r.masterPassword); return "ok"; } + // Every guess here also counts toward the host's limit. + tries++; + if (r.requireMaster || tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; } + const left = Math.min(REVEAL_PIN_MAX_FAILS - tries, r.remaining); + $("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`; + return "reset"; }, }); }); @@ -5366,10 +5326,11 @@ function verifyPinInteractively(subtitle) { } 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.`); - return false; + // The PIN is off after too many wrong guesses; the master password is the + // same proof (it is what the PIN unwraps), and the host checks it. + if (await pinNeedsMaster()) { + const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: `${PIN_MASTER_COPY} ${subtitle || ""}`.trim() }); + return pw || false; } return new Promise((resolve) => { const wrap = document.createElement("div"); @@ -5398,24 +5359,22 @@ async function verifyPinInteractivelyOnce(subtitle) { setupPinPad({ dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"), onComplete: async (pin) => { - try { - const blob = await S.invoke("pinBlobGet"); - if (!blob) throw new Error("no PIN configured"); - const master = await pinDecryptMaster(pin, blob); - await S.invoke("pinFailReset").catch(() => {}); - // Truthy for every existing caller; the gate hands it to the host - // as proof (see pinGate). - done(master || true); + let r; + try { r = await pinTry(pin); } + catch (e) { $("vpErr").textContent = cleanErr(e); return "reset"; } + // The unwrapped master password is truthy for every existing caller; + // the gate hands it to the host as proof (see pinGate). + if (r.ok) { done(r.masterPassword || true); return "ok"; } + $("vpErr").textContent = r.requireMaster ? PIN_MASTER_COPY : wrongPinCopy(r.remaining); + if (r.requireMaster) { + setTimeout(async () => { + try { wrap.remove(); } catch {} + const pw = await promptMasterPassword({ title: "Confirm master password", subtitle: PIN_MASTER_COPY }); + resolve(pw || false); + }, 1200); return "ok"; - } catch (e) { - const fs = await S.invoke("pinFailInc").catch(() => ({ count: 0 })); - const left = Math.max(0, PIN_MAX_FAILS - (fs?.count || 0)); - $("vpErr").textContent = left > 0 - ? `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left before a 15 min lockout.` - : `Locked for 15 min.`; - if (left === 0) { done(false); return "ok"; } - return "reset"; } + return "reset"; }, }); }); From e02d4698ea56277f74f7682ed091bcaf513d8489 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:19:42 +0200 Subject: [PATCH 3/7] Aegis: a permit is described from what the signature covers describePermit read td.message directly, and the EIP-712 encoder ignores keys a type does not declare. A dapp could put a decoy allowed:false or details:{amount:"1"} beside an unlimited permit and the overlay showed the decoy, not risky, under the plain Sign button, with the same digest. The overlay and the message preview are now built from the declared fields only, the permit variant is picked from the declared type, and dropped fields are counted on the overlay. Amounts of 2^96 units and up are flagged as effectively unlimited, marketplace orders and Safe transactions get the warning too, and the encoder refuses a non-hex address instead of signing it as zero bytes. --- bundled-addons/aegis/index.js | 89 ++++++++++++++++++++++++------ bundled-addons/aegis/lib/eip712.js | 7 ++- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 8fc06d98..e55fa4fe 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -3164,13 +3164,50 @@ const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_ac // PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the // overlay rows that say who may spend what until when, or null when the // typed data is not a spending approval. +// What the signature actually covers. The EIP-712 encoder reads only the +// fields each type declares; anything else in `message` is ignored by the +// digest but was read by the overlay, so a dapp could add a decoy +// `allowed: false` or `details: {amount: "1"}` beside an unlimited permit and +// have the overlay show the decoy. Everything shown is built from this view. +function signedView(td) { + const types = (td && typeof td.types === "object" && td.types) || {}; + let dropped = 0; + const walk = (type, v) => { + const arr = /\[\d*\]$/.exec(type); + if (arr) { + const inner = type.slice(0, arr.index); + return Array.isArray(v) ? v.map((x) => walk(inner, x)) : v; + } + const fields = types[type]; + if (!Array.isArray(fields)) return v; // atomic + const src = (v && typeof v === "object") ? v : {}; + for (const k of Object.keys(src)) if (!fields.some((f) => f && f.name === k)) dropped++; + const out = {}; + for (const f of fields) if (f && typeof f.name === "string") out[f.name] = walk(String(f.type), src[f.name]); + return out; + }; + return { message: walk(String(td?.primaryType || ""), td?.message), dropped }; +} + function describePermit(td) { const primary = String(td?.primaryType || ""); if (!/^Permit/.test(primary)) return null; - const msg = (td && typeof td.message === "object" && td.message) || {}; + const msg = signedView(td).message || {}; + const declared = (t) => (Array.isArray(td?.types?.[t]) ? td.types[t].map((f) => f && f.name) : []); + const pf = declared(primary); + const has = (...names) => names.every((n) => pf.includes(n)); const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max + // No token has a supply anywhere near this, so an amount this large is an + // unlimited approval in all but name (2^96 ≈ 7.9e28 base units: 79 billion + // tokens at 18 decimals). + const HUGE = 1n << 96n; const big = (v) => { try { return BigInt(v); } catch { return null; } }; - const amountText = (v) => { const b = big(v); return b === null ? String(v) : (b >= UNLIMITED ? "UNLIMITED (∞)" : b.toString() + " units"); }; + const amountText = (v) => { + const b = big(v); + if (b === null) return String(v); + if (b >= UNLIMITED) return "UNLIMITED (∞)"; + return b.toString() + " units" + (b >= HUGE ? " (effectively unlimited)" : ""); + }; const when = (v) => { const b = big(v); if (b === null) return String(v); @@ -3179,23 +3216,34 @@ function describePermit(td) { }; const rows = []; let risky = false; - const spender = msg.spender; + const spender = has("spender") ? msg.spender : null; rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true }); if (!spender) risky = true; const items = []; - if (primary === "Permit") { - // EIP-2612 has `value`; DAI has `allowed: true` (always unlimited). - if ("allowed" in msg) items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n }); - else items.push({ token: td.domain?.verifyingContract, amount: msg.value }); - if (msg.deadline != null || msg.expiry != null) rows.push({ label: "Valid until", value: when(msg.deadline ?? msg.expiry) }); - } else { - const list = Array.isArray(msg.details) ? msg.details : (msg.details ? [msg.details] : (Array.isArray(msg.permitted) ? msg.permitted : (msg.permitted ? [msg.permitted] : []))); + // The variant is chosen by the declared type, never by what the message + // happens to carry. + if (has("holder", "spender", "nonce", "expiry", "allowed")) { + // DAI-style: a bool, encoded by truthiness — so is this. + items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n }); + rows.push({ label: "Valid until", value: when(msg.expiry) }); + } else if (has("owner", "spender", "value", "deadline")) { + // EIP-2612. + items.push({ token: td.domain?.verifyingContract, amount: msg.value }); + rows.push({ label: "Valid until", value: when(msg.deadline) }); + } else if (has("details", "spender", "sigDeadline")) { + // Permit2 PermitSingle / PermitBatch. + const list = Array.isArray(msg.details) ? msg.details : [msg.details]; for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration }); - if (msg.sigDeadline != null || msg.deadline != null) rows.push({ label: "Signature valid until", value: when(msg.sigDeadline ?? msg.deadline) }); + rows.push({ label: "Signature valid until", value: when(msg.sigDeadline) }); + } else if (has("permitted", "spender", "deadline")) { + // Permit2 PermitTransferFrom / PermitBatchTransferFrom (and *Witness*). + const list = Array.isArray(msg.permitted) ? msg.permitted : [msg.permitted]; + for (const d of list) items.push({ token: d?.token, amount: d?.amount }); + rows.push({ label: "Signature valid until", value: when(msg.deadline) }); } items.slice(0, 10).forEach((it, i) => { const b = big(it.amount); - if (b !== null && b >= UNLIMITED) risky = true; + if (b === null || b >= HUGE) risky = true; rows.push({ label: items.length > 1 ? `Token #${i + 1}` : "Token", value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`, @@ -3203,7 +3251,7 @@ function describePermit(td) { }); }); if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; } - if (!items.length) { rows.push({ label: "Token", value: "(could not read the approval — treat as unlimited)" }); risky = true; } + if (!items.length) { rows.push({ label: "Token", value: "(not a permit shape Aegis can read — treat as unlimited)" }); risky = true; } return { rows, risky }; } @@ -3708,7 +3756,9 @@ function registerPageMessages(api) { } } const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)"; - const messagePreview = JSON.stringify(td.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || ""; + // Preview only what the signature covers (see signedView). + const view = signedView(td); + const messagePreview = JSON.stringify(view.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || ""; const rows = [{ label: "Domain", value: domainSummary }]; if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true }); rows.push({ label: "Primary type", value: String(td.primaryType || "") }); @@ -3718,9 +3768,14 @@ function registerPageMessages(api) { // and the deadline out of the message and say what they mean. const permit = describePermit(td); if (permit) for (const r of permit.rows) rows.push(r); + // Marketplace orders and multisig transactions are not permits but move + // NFTs, tokens or a whole Safe just as surely once signed. + const MOVES_ASSETS = /^(OrderComponents|BulkOrder|Order|MakerOrder|TakerOrder|SafeTx|SafeMessage|MetaTransaction|ForwardRequest)$/; + const movesAssets = !permit && MOVES_ASSETS.test(String(td.primaryType || "")); rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true }); + if (view.dropped) rows.push({ label: "Not signed", value: `${view.dropped} field${view.dropped === 1 ? "" : "s"} the site sent are not covered by this signature and are not shown` }); rows.push({ label: "Address", value: snapTd.address, mono: true }); - const risky = !!(permit && permit.risky); + const risky = !!(permit && permit.risky) || movesAssets; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?", @@ -3729,7 +3784,9 @@ function registerPageMessages(api) { ? (risky ? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site." : "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.") - : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", + : movesAssets + ? `WARNING: a signed ${String(td.primaryType)} can move your NFTs, tokens or Safe without another prompt. Only sign if you fully trust this site and the details below are what you expect.` + : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", rows, actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], }); diff --git a/bundled-addons/aegis/lib/eip712.js b/bundled-addons/aegis/lib/eip712.js index a9266795..0280e37e 100644 --- a/bundled-addons/aegis/lib/eip712.js +++ b/bundled-addons/aegis/lib/eip712.js @@ -87,8 +87,11 @@ module.exports = function makeEip712({ keccak_256 }) { return keccak_256(b); } if (type === "address") { - const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); - if (h.length !== 20) throw new Error("address must be 20 bytes"); + // Validate before decoding: the hex decoder turns non-hex characters + // into zero bytes, so a malformed address would sign as a different one. + const s = String(value ?? "").replace(/^0x/i, ""); + if (!/^[0-9a-fA-F]{40}$/.test(s)) throw new Error("address must be 20 bytes of hex"); + const h = hex2bytes(s); const out = new Uint8Array(32); out.set(h, 12); return out; From 2faa3d808a3a8e7b016e711849845c5ea2ec9217 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:21:11 +0200 Subject: [PATCH 4/7] Aegis: a WizardConnect request the overlay cannot read is not signed wc-sign accepted a flat request (tx and sourceOutputs at the top) that buildWcApproval does not read, so any paired dapp could get a signature after an overlay showing only the wallet, an input count and the sighash. The signer now takes only the nested WizardConnect shape, and the overlay refuses, without showing anything, a request whose outputs or spent inputs it cannot decode. Total out now sums every output, not the first 8. --- bundled-addons/aegis/index.js | 12 ++++++++++-- bundled-addons/aegis/lib/wc-sign.js | 13 +++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index e55fa4fe..dca00618 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -980,6 +980,13 @@ function buildWcApproval(payload) { inner = typeof dec === "string" ? null : dec; } catch { inner = null; } } + // Refuse what cannot be shown. The signer only takes this shape (see + // wc-sign.js), and an overlay without outputs or spent inputs is no + // approval at all. + if (!inner || !Array.isArray(inner.inputs) || !Array.isArray(inner.outputs) || !inner.outputs.length + || !Array.isArray(tx.sourceOutputs) || tx.sourceOutputs.length !== inner.inputs.length) { + return { unreadable: true, dappName }; + } const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?"); const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }]; // What the wallet is putting IN. The outputs alone never showed that a @@ -1011,6 +1018,7 @@ function buildWcApproval(payload) { try { const outs = inner?.outputs || []; let total = 0n; + for (const o of outs) { try { total += BigInt(o.valueSatoshis ?? 0); } catch {} } outs.slice(0, 8).forEach((o, i) => { const lb = o.lockingBytecode; const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex"); @@ -1018,7 +1026,6 @@ function buildWcApproval(payload) { if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46))); else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44))); const v = BigInt(o.valueSatoshis ?? 0); - total += v; const token = tokenText(o.token); rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true }); }); @@ -4150,7 +4157,8 @@ module.exports = { // keys fell back to a lone "OK" button whose id never matched, so // every WizardConnect signing request was refused, and the HTML // body was shown as literal markup. - const { body, rows, dappName } = buildWcApproval(payload); + const { body, rows, dappName, unreadable } = buildWcApproval(payload); + if (unreadable) { api.log(`wc sign: refused an unreadable request from ${dappName}`); return { approved: false }; } const pick = await api.approvalModal({ title: "Sign a Bitcoin Cash transaction (WizardConnect)?", origin: dappName, diff --git a/bundled-addons/aegis/lib/wc-sign.js b/bundled-addons/aegis/lib/wc-sign.js index 34d5ab4c..ffda3774 100644 --- a/bundled-addons/aegis/lib/wc-sign.js +++ b/bundled-addons/aegis/lib/wc-sign.js @@ -66,12 +66,13 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) { // the outputs) meant `tx.inputs` was undefined and signing threw on the // first real request. index.js already read the nested // request.transaction.userPrompt for the approval dialog, so only this - // module had it wrong. The flat shape is still accepted so a caller - // that hands us an already-unwrapped payload keeps working. - const inner = (request.transaction && (request.transaction.transaction !== undefined - || request.transaction.sourceOutputs !== undefined)) - ? request.transaction - : request; + // module had it wrong. Only the nested shape is accepted: the approval + // overlay reads that shape, and a flat request used to be signed after an + // overlay that could show neither its outputs nor what it spends. + const inner = request.transaction; + if (!inner || typeof inner !== "object" || inner.transaction === undefined || !Array.isArray(inner.sourceOutputs)) { + throw new Error("wc-sign: request is not a WizardConnect sign request (request.transaction.{transaction,sourceOutputs})"); + } const tx = ensureTransaction(inner.transaction, libauth); const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => { if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`); From 4511a933f49a394e897dabd871c0ccb17e0ca526 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:23:02 +0200 Subject: [PATCH 5/7] 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.", From 7447d57e963f0212b811167e9304b636a88001df Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:24:04 +0200 Subject: [PATCH 6/7] Aegis: a BCH payment re-reads permissions after waiting for the PIN signAndSend took a permissions snapshot, waited up to two minutes for the PIN, then wrote the snapshot back. Revoking the site meanwhile still let the allowance payment go out and restored the revoked allowance, and any other permission change made during the wait was lost. After the wait it now re-reads permissions, refuses an allowance payment whose allowance was revoked, replaced or no longer covers it, and changes only this origin's sendTx. --- bundled-addons/aegis/index.js | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index df8bebea..2eff0291 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -3351,15 +3351,31 @@ function registerPageMessages(api) { const perms = permissions(api); const budget = perms[origin] && perms[origin].sendTx; const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0; + // The PIN wait below can last two minutes, during which the user may + // revoke the site or other permissions may change. Everything after it + // re-reads permissions and changes only this origin's sendTx; writing + // back the snapshot taken here used to restore a revoked allowance and + // wipe any other change made meanwhile. + const patchSendTx = (fn) => { + const now = permissions(api); + const cur = { ...(now[origin] || {}) }; + const next = fn(cur.sendTx); + if (!next && !now[origin]) return; // revoked meanwhile: nothing to change + if (next) cur.sendTx = next; else delete cur.sendTx; + now[origin] = cur; + api.storage.set("permissions", now); + }; if (budget && d.total <= remaining) { // An allowance skips the overlay, not the PIN the user asked for on // every transaction. await requireDappTxPin(origin, "Bitcoin Cash payment"); + const fresh = (permissions(api)[origin] || {}).sendTx; + const left = fresh ? Math.max(0, (fresh.capSats | 0) - (fresh.usedSats | 0)) : 0; + if (!fresh || fresh.grantedAt !== budget.grantedAt || d.total > left) throw new Error("this site's allowance changed while waiting for the PIN; ask again"); const r = await rt.adapter.signAndBroadcast(plan); - budget.usedSats = (budget.usedSats | 0) + d.total; - api.storage.set("permissions", perms); + patchSendTx((t) => (t ? { ...t, usedSats: (t.usedSats | 0) + d.total } : t)); emitState(); - api.log(`silent send ${d.total} sat for ${origin}, ${remaining - d.total} sat of allowance left`); + api.log(`silent send ${d.total} sat for ${origin}, ${left - d.total} sat of allowance left`); return { txid: r.txid }; } const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true })); @@ -3384,13 +3400,8 @@ function registerPageMessages(api) { await requireDappTxPin(origin, "Bitcoin Cash payment"); const cap = flags.find((f) => f.startsWith("cap=")); const capSats = cap ? Number(cap.slice(4)) : 0; - if (BCH_ALLOWANCES.includes(capSats)) { - perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } }; - api.storage.set("permissions", perms); - } else if (budget) { - delete perms[origin].sendTx; - api.storage.set("permissions", perms); - } + if (BCH_ALLOWANCES.includes(capSats)) patchSendTx(() => ({ capSats, usedSats: 0, grantedAt: Date.now() })); + else if (budget) patchSendTx(() => null); emitState(); const r = await rt.adapter.signAndBroadcast(plan); return { txid: r.txid }; From 916a748889a9db68c9c4006623940eb78a669564 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 02:26:15 +0200 Subject: [PATCH 7/7] Aegis: sends name their wallet, amounts with spaces are refused, BCMR cleanup - send, sendToken and consolidate now require the wallet id the panel reviewed them for; a missing id used to skip the check, and the Solana token send sent none, so a selection change under the PIN pad sent from another wallet. - "1 0" was read as 10: a space inside an amount is now refused. - A BCMR registry list saved before https was enforced is filtered on read, and names lose the soft hyphen, Arabic letter mark, Mongolian vowel separator, line/paragraph separators and Unicode tag characters too. --- bundled-addons/aegis/index.js | 11 +++++++++-- bundled-addons/aegis/lib/bcmr.js | 14 ++++++++++---- bundled-addons/aegis/panel.js | 6 ++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 2eff0291..afca59a8 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1806,6 +1806,10 @@ function registerPanelMessages(api) { api.onMessage("sendToken", async (p, m) => { fromPanel(m); requirePanelTxPin(); + // Same rule as send: the token send names the wallet it was reviewed for. + if (!p || !p.walletId || String(p.walletId) !== selectedWalletId()) { + throw new Error("the selected wallet changed — review the send and try again"); + } const rt = requireSelected(); if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only"); const plan = await rt.adapter.planTokenTransfer(p || {}); @@ -1834,7 +1838,10 @@ function registerPanelMessages(api) { // The panel names the wallet its form was built for. If the selection // moved since (another surface, a late state push), refuse rather than // send this amount from a different wallet. - if (p && p.walletId && String(p.walletId) !== selectedWalletId()) { + // Required, not optional: a send that does not say which wallet it was + // reviewed for cannot be checked against the selection. + if (!p || !p.walletId) throw new Error("send names no wallet — review the send and try again"); + if (String(p.walletId) !== selectedWalletId()) { throw new Error("the selected wallet changed — review the send and try again"); } const rt = requireSelected(); @@ -1936,7 +1943,7 @@ function registerPanelMessages(api) { // The destination is the wallet the PREVIEW was drawn for, not whatever // is selected by the time the button is pressed: a preview painted for A // followed by a switch to B used to sweep everything into B. - if (p && p.destinationWalletId && String(p.destinationWalletId) !== destId) { + if (!p || !p.destinationWalletId || String(p.destinationWalletId) !== destId) { throw new Error("the selected wallet changed since this preview — reopen Consolidate"); } const destEntry = walletEntries().find((w) => w.id === destId); diff --git a/bundled-addons/aegis/lib/bcmr.js b/bundled-addons/aegis/lib/bcmr.js index 8c7b7574..889407fa 100644 --- a/bundled-addons/aegis/lib/bcmr.js +++ b/bundled-addons/aegis/lib/bcmr.js @@ -46,8 +46,11 @@ const DEFAULT_REGISTRIES = [ module.exports = function makeBcmr({ storage, log = () => {} }) { function registryList() { + // Filtered on read as well: a list saved before setRegistries enforced + // https still carries its http entries. const custom = storage.get("bcmr/registries", null); - if (Array.isArray(custom) && custom.length) return custom; + const clean = Array.isArray(custom) ? custom.filter((r) => r && typeof r.url === "string" && /^https:\/\//i.test(r.url)) : []; + if (clean.length) return clean; return DEFAULT_REGISTRIES.slice(); } function setRegistries(list) { @@ -164,10 +167,13 @@ module.exports = function makeBcmr({ storage, log = () => {} }) { // // A local name wins over the registry: the user typed it for this exact // category, which is better evidence than a third-party document. - // Control, bidi-override, zero-width and BOM characters. Built from code + // Control, bidi-override, zero-width and BOM characters, plus the soft + // hyphen, the Arabic letter mark, the Mongolian vowel separator, the + // line/paragraph separators and the Unicode tag block. Built from code // points so no invisible character has to live in this source file. - const rng = (a, b) => String.fromCharCode(a) + "-" + String.fromCharCode(b); - const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0x200b, 0x200f) + rng(0x202a, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + "]", "g"); + const rng = (a, b) => String.fromCodePoint(a) + "-" + String.fromCodePoint(b); + const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0xad, 0xad) + rng(0x61c, 0x61c) + rng(0x180e, 0x180e) + + rng(0x200b, 0x200f) + rng(0x2028, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + rng(0xe0000, 0xe007f) + "]", "gu"); function cleanText(v, max) { if (typeof v !== "string") return null; const s = v.replace(INVISIBLE, "").trim().slice(0, max); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 09f97eb0..ca7bb954 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -4135,8 +4135,10 @@ function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 : // guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5, // and Number() accepted "1e3", "0x10" and "-0.5". function parseAmountText(text) { - let raw = String(text == null ? "" : text).trim().replace(/\s/g, ""); + let raw = String(text == null ? "" : text).trim(); if (!raw) return { empty: true }; + // Space inside the number is refused, not deleted: "1 0" used to become 10. + if (/\s/.test(raw)) return { error: "Remove the space from the amount" }; if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(raw)) { raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567 } else if (/^\d{1,3},\d{3}$/.test(raw)) { @@ -4610,7 +4612,7 @@ $("sendBtn").addEventListener("click", async () => { try { const isToken = !!(req.mint && lastPlan._token); const r = isToken - ? await S.invoke("sendToken", { mint: req.mint, to: req.to, amount: req.amount }) + ? await S.invoke("sendToken", { walletId: req.walletId, mint: req.mint, to: req.to, amount: req.amount }) : await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo }); // A broadcast that returned no txid is still a broadcast: never report // it as a failure and leave a filled form inviting a second send.