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.
`;