From 3264c6d019a66d5be53f1ee1fb59abe40faa72b5 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 03:45:34 +0200 Subject: [PATCH] Aegis: WizardConnect relay keys from the real root, overlay names the pairing origin mountWallet zeroed the vault root after mounting, but the WizardConnect adapter kept a reference to that same buffer and read it again for every new pairing's relay key. Every pairing made after mount therefore got a Nostr identity derived from 32 zero bytes and the pairing URI alone, so anyone who saw the URI (the QR, a script on the dapp page) could read the relay traffic, xpubs included, and speak as the wallet. The adapter now gets, and keeps, its own copy. The signing overlay and the PIN request named the dapp by its own userPrompt, so a dapp paired once could present itself as any site. The pairing origin the host verified is now recorded per URI and shown instead; the dapp's text is a quoted row with invisible and bidi characters removed. One sign request per connection may be on screen at a time, and revoking a site in Aegis ends its pairings too. --- bundled-addons/aegis/index.js | 53 +++++++++++++++++++++++----- bundled-addons/aegis/lib/wc.js | 64 +++++++++++++++++++++++++++++++--- bundled-addons/aegis/panel.js | 2 +- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 35e94ec7..47b79921 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -855,7 +855,11 @@ async function mountWallet(entry) { // dapp saw an unrelated, empty wallet; anything it built spent // from addresses this wallet does not own, so signing failed with // "no path for input". Silent, and only visible on testnet. - root32: root, accountPath: entry.accountPath || adapter.snapshot?.()?.accountPath || "m/44'/145'/0'", + // A copy: `root` is zeroed in the finally below, and the adapter + // reads its root again for every new pairing's relay key. Sharing + // the buffer gave every pairing made after mount a relay key derived + // from 32 zero bytes, i.e. from the pairing URI alone. + root32: new Uint8Array(root), accountPath: entry.accountPath || adapter.snapshot?.()?.accountPath || "m/44'/145'/0'", }).catch((e) => c.api.log(`[${entry.id}] wc start:`, e?.message || e)); } emitStateForWallet(entry.id); @@ -968,7 +972,16 @@ function deriveCashaddrFromWif(wif, prefix) { function buildWcApproval(payload) { const req = payload?.request || {}; const tx = req.transaction || {}; - const dappName = String(tx.userPrompt || payload?.dappName || "unknown dapp").slice(0, 120); + // Who is asking is what Aegis recorded when the pairing was made: the page + // origin the host verified, or the panel when the user pasted the code. + // The dapp's userPrompt is its own free text, shown only as a quote — it + // used to be the overlay's origin and the PIN request's name, so a paired + // dapp could present itself as any site. + const paired = payload?.pairing && typeof payload.pairing.origin === "string" ? payload.pairing.origin : null; + const dappName = paired === "panel" ? "a dapp you paired in Aegis by pasting its code" + : paired ? paired + : "a dapp paired before Aegis recorded where pairings came from"; + const says = plainLabel(tx.userPrompt || "", 160); const walletName = payload?.label || payload?.walletId || ""; const network = ctx.runtimes.get(payload?.walletId)?.entry?.network; const prefix = network === "chipnet" ? "bchtest" : "bitcoincash"; @@ -989,6 +1002,7 @@ function buildWcApproval(payload) { } 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) }]; + if (says) rows.unshift({ label: "The dapp says", value: `“${says}”` }); // What the wallet is putting IN. The outputs alone never showed that a // transaction spends the user's tokens — and with the token prefix now // signed correctly (wc-sign.js) such a transaction is valid, so the @@ -1034,7 +1048,7 @@ function buildWcApproval(payload) { } catch {} rows.push({ label: "After signing", value: tx.broadcast ? "the dapp broadcasts it" : "signed hex is returned to the dapp" }); rows.push({ label: "Sighash", value: "ALL | FORKID | UTXOS" }); - return { body: `${dappName} asks you to sign a Bitcoin Cash transaction.`, rows, dappName }; + return { body: `A site paired over WizardConnect asks you to sign a Bitcoin Cash transaction. Paired from: ${dappName}.`, rows, dappName, origin: paired && paired !== "panel" ? paired : "WizardConnect" }; } // ---- per-purpose default wallets ------------------------------------------- @@ -1383,7 +1397,28 @@ async function requireDappTxPin(origin, what) { // benign-looking opening cannot hide what the rest commits the user to. function previewText(text, max = 1500) { const s = String(text); - return s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s; + const cut = s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s; + return showInvisibles(cut); +} +// Characters that change how text LOOKS without being visible themselves: +// C0/C1 controls (newline and tab excepted), zero-width characters, and the +// bidi overrides/isolates that can make "0x…dead" render as "0x…daed" or +// reorder an overlay line. Built from code points so no editor or tool can +// silently turn the escapes into the characters themselves. +const INVISIBLE_RE = (() => { + const r = [[0x00, 0x08], [0x0b, 0x0c], [0x0e, 0x1f], [0x7f, 0x9f], [0x061c, 0x061c], [0x180e, 0x180e], + [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x2066, 0x206f], [0xfeff, 0xfeff]]; + const esc = (c) => String.fromCharCode(92) + "u" + c.toString(16).padStart(4, "0"); + return new RegExp("[" + r.map(([a, b]) => (a === b ? esc(a) : esc(a) + "-" + esc(b))).join("") + "]", "g"); +})(); +// Signed text keeps every character, so the overlay names the invisible ones. +function showInvisibles(s) { + return String(s).replace(INVISIBLE_RE, (c) => `⟨U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}⟩`); +} +// A name or label that someone else chose (a dapp, a token registry): one +// line, nothing invisible, bounded. +function plainLabel(v, max = 80) { + return String(v ?? "").replace(INVISIBLE_RE, "").replace(/\s+/g, " ").trim().slice(0, max); } function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); } function fromPage(m) { @@ -2325,7 +2360,7 @@ function registerPanelMessages(api) { if (!ctx.wc) throw new Error("WizardConnect not ready"); const walletId = String(p && p.walletId || ""); const uri = String(p && p.uri || ""); - await ctx.wc.connectUri(walletId, uri); + await ctx.wc.connectUri(walletId, uri, "panel"); return fullState(); }); api.onMessage("wcDisconnect", async (p, m) => { @@ -2443,7 +2478,7 @@ function registerPanelMessages(api) { // from the options we offered, but the wallet could have gone away // while the dialog was open. const chosen = candidates.find((w) => w.id === pickedId) || preferred; - await ctx.wc.connectUri(chosen.id, uri); + await ctx.wc.connectUri(chosen.id, uri, origin); return { paired: true, wallet: chosen.label }; }); }); @@ -3119,6 +3154,8 @@ function revokeOrigin(api, origin) { api.storage.set("permissions", perms); sessionGrants.delete(origin); syncInjectPolicy(api); + // A revoked site keeps no WizardConnect pairing either. + if (ctx?.wc?.disconnectOrigin) ctx.wc.disconnectOrigin(origin).catch(() => {}); } // ---- who can see the wallet ------------------------------------------------ @@ -4311,11 +4348,11 @@ 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, unreadable } = buildWcApproval(payload); + const { body, rows, dappName, unreadable, origin: wcOrigin } = 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, + origin: wcOrigin, body, rows, actions: [{ id: "approve", label: "Sign", primary: true }], }); diff --git a/bundled-addons/aegis/lib/wc.js b/bundled-addons/aegis/lib/wc.js index e8c81cc2..466cbace 100644 --- a/bundled-addons/aegis/lib/wc.js +++ b/bundled-addons/aegis/lib/wc.js @@ -31,6 +31,8 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect // HDKey and to derive per-URI relay identities via HKDF, so reconnecting // yields the same Nostr identity (dapp recognises us on reload). function makeAdapter({ root32, accountPath, walletId, label }) { + // Own copy: the caller may wipe its buffer once this returns. + root32 = new Uint8Array(root32); const account = HDKey.fromMasterSeed(root32).derive(accountPath); const branches = new Map(); const branchFor = (childIndex) => { @@ -70,7 +72,11 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect // Sign a transaction the dapp has already assembled. See signTx.js for // the heavy lifting (SIGHASH_ALL|FORKID|UTXOS enforcement, libauth // preimage + secp256k1 der/lowS signatures). - async signTransaction(request) { + // `pairing` is what Aegis itself recorded when this connection was + // made ({ origin } — the page origin the host verified, or "panel"), + // never what the dapp says about itself: the dapp's userPrompt and + // name are free text it controls. + async signTransaction(request, pairing = null) { // Route through approval-modal first — the user always sees what // they're signing before any private key touches the request. if (!approvalRequest) throw new Error("no approval channel"); @@ -78,6 +84,7 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect kind: "wc-sign", walletId, label, request, + pairing, }); if (!decision?.approved) throw new Error("cancelled"); const { signTx } = require("./wc-sign.js"); @@ -97,6 +104,8 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect // panel can show "Wallet A connected to 2 dapps, Wallet B to none" etc. const managers = new Map(); // walletId -> WalletConnectionManager const uris = new Map(); // walletId -> Set (persisted) + const origins = new Map(); // walletId -> Map (persisted) + const busy = new Set(); // connection ids with a sign request on screen const listeners = new Set(); // () => void — panel resubscribes on state change function fireStateChange() { for (const fn of listeners) try { fn(); } catch {} } @@ -104,6 +113,13 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect function persist(walletId) { const list = [...(uris.get(walletId) || new Set())]; api.storage.set(`wc/${walletId}/uris`, list); + const o = origins.get(walletId) || new Map(); + for (const u of [...o.keys()]) if (!list.includes(u)) o.delete(u); + api.storage.set(`wc/${walletId}/origins`, Object.fromEntries(o)); + } + function uriOf(mgr, connectionId) { + const all = typeof mgr.getConnections === "function" ? Object.values(mgr.getConnections() || {}) : [...(mgr.connections?.values?.() || [])]; + return all.find((c) => c.id === connectionId)?.uri || null; } async function startForWallet({ walletId, label, root32, accountPath }) { @@ -119,17 +135,29 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect fireStateChange(); }); mgr.on("pendingSignRequest", async ({ connectionId, request }) => { + // One request per connection on screen at a time: a paired dapp can + // send over the relay whenever it likes, with no tab open, and must not + // be able to stack overlays. + if (busy.has(connectionId)) { + try { await mgr.sendSignError(connectionId, request?.sequence, "another request from this dapp is waiting for the user"); } catch {} + return; + } + busy.add(connectionId); try { - const { signedTransaction } = await adapter.signTransaction(request); + const uri = uriOf(mgr, connectionId); + const pairing = (uri && origins.get(walletId)?.get(uri)) || null; + const { signedTransaction } = await adapter.signTransaction(request, pairing); await mgr.sendSignResponse(connectionId, request.sequence, signedTransaction); } catch (e) { log(`wc[${walletId}] sign failed:`, e?.message || e); try { await mgr.sendSignError(connectionId, request.sequence, cleanErrForDapp(e)); } catch {} - } + } finally { busy.delete(connectionId); } }); // Restore persisted pairings. uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || [])); + const savedOrigins = api.storage.get(`wc/${walletId}/origins`, {}) || {}; + origins.set(walletId, new Map(Object.entries(savedOrigins).filter(([, v]) => v && typeof v.origin === "string"))); for (const uri of uris.get(walletId)) { try { mgr.connect(uri); } catch (e) { log(`wc[${walletId}] reconnect failed:`, e?.message); } } @@ -141,9 +169,12 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect try { mgr.disconnectAll?.(); } catch {} managers.delete(walletId); uris.delete(walletId); + origins.delete(walletId); } - async function connectUri(walletId, uri) { + // `origin`: the verified page origin that asked to pair, or "panel" when + // the user pasted the code into Aegis themselves. + async function connectUri(walletId, uri, origin = "panel") { const mgr = managers.get(walletId); if (!mgr) throw new Error("wc: wallet not ready"); const trimmed = String(uri || "").trim(); @@ -152,6 +183,9 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect const set = uris.get(walletId) || new Set(); set.add(trimmed); uris.set(walletId, set); + const o = origins.get(walletId) || new Map(); + o.set(trimmed, { origin: String(origin || "panel"), at: Date.now() }); + origins.set(walletId, o); persist(walletId); fireStateChange(); return id; @@ -168,6 +202,25 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect fireStateChange(); } + // Revoking a site in Aegis also ends the WizardConnect pairings it made. + async function disconnectOrigin(origin) { + let n = 0; + for (const [walletId, o] of origins) { + const mgr = managers.get(walletId); + for (const [uri, rec] of [...o]) { + if (rec.origin !== origin) continue; + const conn = mgr ? (typeof mgr.getConnections === "function" ? Object.values(mgr.getConnections() || {}) : []).find((c) => c.uri === uri) : null; + if (conn) { try { await mgr.disconnect(conn.id); } catch {} } + uris.get(walletId)?.delete(uri); + o.delete(uri); + persist(walletId); + n++; + } + } + if (n) fireStateChange(); + return n; + } + function snapshot() { const out = {}; for (const [walletId, mgr] of managers) { @@ -187,6 +240,7 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect label: c.label || null, dappName: c.dappName || null, dappIcon: c.dappIcon || null, + pairedFrom: origins.get(walletId)?.get(c.uri)?.origin || null, // RelayStatus is an OBJECT: { status: "connected" | "reconnecting" // | "disconnected" | "session_deleted" }. Reading `.kind` (which // does not exist) fell through to String(object) and put the @@ -209,5 +263,5 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect return m.replace(/\n[\s\S]*$/, "").slice(0, 200); } - return { startForWallet, stopForWallet, connectUri, disconnect, snapshot, onStateChange }; + return { startForWallet, stopForWallet, connectUri, disconnect, disconnectOrigin, snapshot, onStateChange }; }; diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 6d1a9119..5ac60308 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -2474,7 +2474,7 @@ function renderConnectPane(bchWallets) { const rowsHtml = rows.length ? rows.map((c) => `
${c.dappIcon ? `` : ""}
-
${esc(c.dappName || c.label || "(pairing…)")} ${wcStatusTag(c.status)}
on ${esc(c.walletLabel)} · ${esc((c.uri || "").slice(0, 40))}…
+
${esc(c.dappName || c.label || "(pairing…)")} ${wcStatusTag(c.status)}
${c.pairedFrom ? `paired from ${esc(c.pairedFrom === "panel" ? "Aegis (pasted code)" : c.pairedFrom)} · ` : ""}on ${esc(c.walletLabel)} · ${esc((c.uri || "").slice(0, 40))}…
`).join("") : `
No dapps paired yet.
`;