From c79a513836f8a8c791f39a5c37274daf0bf3f328 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 04:05:45 +0200 Subject: [PATCH] Aegis: a connected site keeps the wallet the user approved Dapp calls resolved their wallet afresh every time: Ethereum took the first ready wallet on the site's chain, Solana and Tron the sidebar selection. A site connected to wallet B was served wallet A, choosing another wallet in the sidebar re-pointed every connected Solana/Tron site and told it the new address, and a connected site could list every EVM wallet's address by looping wallet_switchEthereumChain and reading the account after each switch. Grants now record the wallet id and address the user approved, and calls use exactly that wallet (older grants are bound on first use; a missing wallet is an error, not a substitute). A switch to a chain whose wallet has a different address is asked for. A site can no longer "add" a built-in chain id with its own RPC and get a second mainnet wallet; chainMeta has no chainId for built-in networks, so that check never matched. --- bundled-addons/aegis/index.js | 126 ++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 27 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index cd38ef95..ee54ed37 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -3152,6 +3152,28 @@ function patchGrant(api, origin, scope, patch) { setGrant(api, origin, scope, { ...rest, ...patch }, persisted); return true; } +// A connected site talks to the wallet the user approved for it — recorded +// as walletId + address in the grant — and to no other. The bridges used to +// resolve "the first ready wallet on this chain" or the sidebar selection on +// every call, so a site the user connected to wallet B was silently served +// wallet A, and selecting another wallet in the sidebar re-pointed every +// connected Solana/Tron site to it (and told it the new address). +// Grants made before this carry no walletId; they are bound to whatever +// they resolve to the first time they are used. +function boundRuntime(api, origin, scope, chain) { + const g = origin ? grantFor(api, origin, scope) : null; + if (!g || !g.walletId) return null; + const rt = ctx.runtimes.get(g.walletId); + if (!rt || rt.entry.chain !== chain || rt.phase !== "ready") { + throw new Error("the wallet this site was connected to is not available (removed or locked) — reconnect the site"); + } + return rt; +} +function bindGrant(api, origin, scope, rt) { + const g = origin ? grantFor(api, origin, scope) : null; + if (g && !g.walletId) patchGrant(api, origin, scope, { walletId: rt.entry.id, address: rt.adapter.snapshot().address }); + return rt; +} // An unconnected site that asked for a chain (addChain / switch before // connect) gets it remembered for its eventual eth_requestAccounts — no // address is disclosed by this. @@ -3600,11 +3622,14 @@ function legacyBchRuntime() { // The Tron bridge routes to the currently-selected wallet if it is Tron; // otherwise it looks for the first ready Tron wallet on the selected network // hint; else rejects with "no tron wallet". -function activeTronRuntime() { +function activeTronRuntime(origin) { + const api = ctx.api; + const bound = boundRuntime(api, origin, "trx", "trx"); + if (bound) return bound; const selId = selectedWalletId(); const selRt = selId && ctx.runtimes.get(selId); - if (selRt && selRt.entry.chain === "trx" && selRt.phase === "ready") return selRt; - for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "trx" && rt.phase === "ready") return rt; + if (selRt && selRt.entry.chain === "trx" && selRt.phase === "ready") return bindGrant(api, origin, "trx", selRt); + for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "trx" && rt.phase === "ready") return bindGrant(api, origin, "trx", rt); throw new Error("no Tron wallet available — add one in the Aegis sidebar"); } @@ -3790,7 +3815,7 @@ function registerPageMessages(api) { // ---- Tron bridge (tronWeb / tronLink) ----------------------------------- api.onMessage("trx.requestAccounts", async (_p, m) => { const origin = fromPage(m); - const rt = activeTronRuntime(); + const rt = activeTronRuntime(origin); const snap = rt.adapter.snapshot(); if (grantFor(api, origin, "trx")) return { code: 200, address: snap.address, network: snap.network }; return withOriginLock(origin, async () => { @@ -3807,14 +3832,14 @@ function registerPageMessages(api) { checkbox: { id: "always", label: "Always allow this site to see this address" }, }); if (!pick.startsWith("allow")) throw new Error("user rejected"); - setGrant(api, origin, "trx", { readAddress: true, network: snap.network }, pick === "allow+always"); + setGrant(api, origin, "trx", { readAddress: true, network: snap.network, walletId: rt.entry.id, address: snap.address }, pick === "allow+always"); return { code: 200, address: snap.address, network: snap.network }; }); }); api.onMessage("trx.getAccount", (_p, m) => { const origin = fromPage(m); if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first"); - const rt = activeTronRuntime(); + const rt = activeTronRuntime(origin); const snap = rt.adapter.snapshot(); return { address: snap.address, network: snap.network }; }); @@ -3824,7 +3849,7 @@ function registerPageMessages(api) { // which can say "1 TRX to X" over bytes that do something else entirely. api.onMessage("trx.signTransaction", async (p, m) => { const origin = fromPage(m); - const rt = activeTronRuntime(); + const rt = activeTronRuntime(origin); if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first"); const tx = p && p.transaction; if (!tx || typeof tx !== "object" || !tx.raw_data_hex) throw new Error("bad transaction"); @@ -3892,7 +3917,7 @@ function registerPageMessages(api) { }); api.onMessage("trx.sendRawTransaction", async (p, m) => { const origin = fromPage(m); - const rt = activeTronRuntime(); + const rt = activeTronRuntime(origin); if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first"); const signedTx = p && p.transaction; if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx"); @@ -3906,7 +3931,7 @@ function registerPageMessages(api) { }); api.onMessage("trx.signMessageV2", async (p, m) => { const origin = fromPage(m); - const rt = activeTronRuntime(); + const rt = activeTronRuntime(origin); if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first"); const message = String(p && p.message != null ? p.message : ""); if (message.length > 4096) throw new Error("message too long"); @@ -3941,15 +3966,52 @@ function registerPageMessages(api) { // never redirects a connected dapp. Unconnected origins get the chain // they asked for before connecting, else the selected ETH wallet. function activeEthRuntime(origin) { + const bound = boundRuntime(api, origin, "eth", "eth"); + if (bound) return bound; const g = origin ? grantFor(api, origin, "eth") : null; const wanted = g?.chainId ?? (origin ? sessionGrants.get(origin)?.eth?.chainId : null); - if (wanted != null) { const rt = ethRuntimeForChain(wanted); if (rt) return rt; } + if (wanted != null) { const rt = ethRuntimeForChain(wanted); if (rt) return bindGrant(api, origin, "eth", rt); } const selId = selectedWalletId(); const selRt = selId && ctx.runtimes.get(selId); - if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return selRt; - for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return rt; + if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return bindGrant(api, origin, "eth", selRt); + for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return bindGrant(api, origin, "eth", rt); throw new Error("no Ethereum wallet available — add one in the Aegis sidebar"); } + // Move a CONNECTED site to the wallet on another chain. The same address + // moves silently; a different one is a new disclosure and is asked for — + // otherwise a connected site could enumerate every EVM wallet's address by + // looping wallet_switchEthereumChain over chain ids and reading eth.state. + async function moveEthGrant(origin, chainId, consented = false) { + const g = grantFor(api, origin, "eth"); + if (!g) return false; + const cur = (() => { try { return activeEthRuntime(origin); } catch { return null; } })(); + const curAddr = String(g.address || cur?.adapter.snapshot().address || "").toLowerCase(); + let target = null; + for (const rt of ctx.runtimes.values()) { + if (rt.entry.chain !== "eth" || rt.phase !== "ready" || Number(rt.adapter.snapshot().chainId) !== Number(chainId)) continue; + if (rt.adapter.snapshot().address.toLowerCase() === curAddr) { target = rt; break; } + if (!target) target = rt; + } + if (!target) return false; + const snap = target.adapter.snapshot(); + if (snap.address.toLowerCase() !== curAddr && !consented) { + const ask = (opts) => withOriginLock(origin, () => api.approvalModal(opts)); + const pick = await ask(({ + title: "Let this site see another Ethereum address?", + origin, + body: "The site asked to switch networks. On that network Aegis uses a different wallet, so switching shows the site this address too.", + rows: [ + { label: "Address", value: snap.address, mono: true }, + { label: "Network", value: chainMeta("eth", target.entry.network)?.label || snap.network }, + { label: "Wallet", value: target.entry.label }, + ], + actions: [{ id: "allow", label: "Switch", primary: true }], + })); + if (!String(pick || "").startsWith("allow")) throw ethError("user rejected the network switch", 4001); + } + patchGrant(api, origin, "eth", { chainId: Number(chainId), walletId: target.entry.id, address: snap.address }); + return true; + } function ethConnectedFor(origin) { return !!grantFor(api, origin, "eth"); } function ethError(message, code) { const e = new Error(message); e.code = code; return e; } api.onMessage("eth.requestAccounts", async (_p, m) => { @@ -3973,7 +4035,7 @@ function registerPageMessages(api) { checkbox: { id: "always", label: "Always allow this site to see this address" }, }); if (!pick.startsWith("allow")) throw new Error("user rejected"); - setGrant(api, origin, "eth", { readAddress: true, chainId: snap.chainId }, pick === "allow+always"); + setGrant(api, origin, "eth", { readAddress: true, chainId: snap.chainId, walletId: rt.entry.id, address: snap.address }, pick === "allow+always"); return { address: snap.address, chainIdHex, networkVersion }; }); }); @@ -4194,7 +4256,7 @@ function registerPageMessages(api) { // to flip the global selected wallet, so any site — connected or not — // could move every other connected dapp onto another chain. Unconnected // sites just have the preference remembered for their connect prompt. - if (!patchGrant(api, origin, "eth", { chainId: wantId })) rememberPendingChain(origin, wantId); + if (!(await moveEthGrant(origin, wantId))) rememberPendingChain(origin, wantId); return null; }); // EIP-3085: dapp asks Aegis to add a new EVM chain. On approval, we @@ -4221,23 +4283,31 @@ function registerPageMessages(api) { const ticker = String(nc.symbol || "ETH").slice(0, 6).toUpperCase(); // Reject if a wallet on this chain already exists — no-op success per // EIP-3085 conventions. + // Built-in networks carry no chainId in chainMeta, so "add chain 1 with + // my RPC" used to create a second mainnet wallet whose fees, nonce and + // balance came from the site's RPC. Their ids are refused outright. + const builtinIds = Object.values(ctx.d?.ethAdapter?.NETWORKS || {}).map((n) => Number(n.chainId)); + if (builtinIds.includes(Number(chainId)) && !walletEntries().some((w) => w.chain === "eth" && Number(ctx.runtimes.get(w.id)?.adapter?.snapshot?.()?.chainId) === Number(chainId))) { + throw ethError(`chain ${chainId} is built into Aegis; add it in the Aegis panel, not from a site`, 4001); + } const existing = walletEntries().find((w) => w.chain === "eth" && (w.network === CUSTOM_ETH_PREFIX + chainId - || (chainMeta("eth", w.network)?.chainId === chainId))); + || (chainMeta("eth", w.network)?.chainId === chainId) + || Number(ctx.runtimes.get(w.id)?.adapter?.snapshot?.()?.chainId) === Number(chainId))); if (existing) { // Already known: behaves like a switch for THIS origin only. Never a // grant — this used to persist a connection without any prompt, so // any site could read the address by "adding" a chain Aegis already // had. An unconnected site gets the chain remembered for its eventual // eth_requestAccounts and learns nothing else. - if (!patchGrant(api, origin, "eth", { chainId })) rememberPendingChain(origin, chainId); + if (!(await moveEthGrant(origin, chainId))) rememberPendingChain(origin, chainId); return null; } return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Add an Ethereum chain?", origin, - body: "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs.", + body: "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs." + (grantFor(api, origin, "eth") ? " Aegis makes a new wallet for it, and a connected site will see that wallet's address." : ""), rows: [ { label: "Chain name", value: chainName }, { label: "Chain ID", value: `${chainId} (${chainIdHex})` }, @@ -4273,7 +4343,7 @@ function registerPageMessages(api) { await mountWallet(entry); // Adding a chain is not connecting. A connected site moves to the new // chain; an unconnected one has it remembered for its connect prompt. - if (!patchGrant(api, origin, "eth", { chainId })) rememberPendingChain(origin, chainId); + if (!(await moveEthGrant(origin, chainId, true))) rememberPendingChain(origin, chainId); return null; }); }); @@ -4310,11 +4380,13 @@ function registerPageMessages(api) { }); // ---- Solana (wallet-adapter) ------------------------------------------ - function activeSolRuntime() { + function activeSolRuntime(origin) { + const bound = boundRuntime(api, origin, "sol", "sol"); + if (bound) return bound; const selId = selectedWalletId(); const selRt = selId && ctx.runtimes.get(selId); - if (selRt && selRt.entry.chain === "sol" && selRt.phase === "ready") return selRt; - for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return rt; + if (selRt && selRt.entry.chain === "sol" && selRt.phase === "ready") return bindGrant(api, origin, "sol", selRt); + for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return bindGrant(api, origin, "sol", rt); throw new Error("no Solana wallet available — add one in the Aegis sidebar"); } function solConnectedFor(origin) { return !!grantFor(api, origin, "sol"); } @@ -4324,12 +4396,12 @@ function registerPageMessages(api) { api.onMessage("sol.state", (_p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) return { address: null }; - try { const snap = activeSolRuntime().adapter.snapshot(); return { address: snap.address, network: snap.network }; } + try { const snap = activeSolRuntime(origin).adapter.snapshot(); return { address: snap.address, network: snap.network }; } catch { return { address: null }; } }); api.onMessage("sol.connect", async (_p, m) => { const origin = fromPage(m); - const rt = activeSolRuntime(); + const rt = activeSolRuntime(origin); const snap = rt.adapter.snapshot(); if (solConnectedFor(origin)) return { address: snap.address, network: snap.network }; return withOriginLock(origin, async () => { @@ -4346,14 +4418,14 @@ function registerPageMessages(api) { checkbox: { id: "always", label: "Always allow this site to see this address" }, }); if (!pick.startsWith("allow")) throw new Error("user rejected"); - setGrant(api, origin, "sol", { readAddress: true, network: snap.network }, pick === "allow+always"); + setGrant(api, origin, "sol", { readAddress: true, network: snap.network, walletId: rt.entry.id, address: snap.address }, pick === "allow+always"); return { address: snap.address, network: snap.network }; }); }); api.onMessage("sol.signMessage", async (p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); - const rt = activeSolRuntime(); + const rt = activeSolRuntime(origin); const b64 = String(p && p.messageB64 || ""); const bytes = new Uint8Array(Buffer.from(b64, "base64")); if (bytes.length > 16384) throw new Error("message too long"); @@ -4384,7 +4456,7 @@ function registerPageMessages(api) { api.onMessage("sol.signTransaction", async (p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); - const rt = activeSolRuntime(); + const rt = activeSolRuntime(origin); const messageBytes = new Uint8Array(Buffer.from(String(p && p.messageB64 || ""), "base64")); const parsed = parseSolMessage(messageBytes, rt.adapter._pub); if (!parsed.ok) throw new Error("cannot parse transaction message: " + parsed.error); @@ -4418,7 +4490,7 @@ function registerPageMessages(api) { api.onMessage("sol.signAndSend", async (p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); - const rt = activeSolRuntime(); + const rt = activeSolRuntime(origin); const wireB64 = String(p && p.wireB64 || ""); if (!wireB64) throw new Error("wireB64 required (full serialized transaction)"); const wire = new Uint8Array(Buffer.from(wireB64, "base64"));