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"));