From 67939b1493f81882035508abff097f39177733cc Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 6 Sep 2026 02:56:34 +0200 Subject: [PATCH] feat(theseus/bchwallet): window.bitcoincash dapp bridge with per-origin permissions wallet-inject.js runs in the isolated world of https://*.x pages and exposes window.bitcoincash { isTheseus, version, network, getAddress, signAndSend, signMessage }. Every call is routed page -> addon-page-msg -> activate() handler -> approval overlay showing the requesting origin: - getAddress: approval with an "always allow" checkbox; grants persist in api.storage.permissions and are listed/revocable under Settings. - signAndSend / signMessage: approval on every call, never remembered. signMessage returns a BIP-137 recoverable signature (verified offline). - one pending approval per origin; page-facing errors never echo balance. Host fix: the inject IPC assigned event.returnValue twice, so pages always got an empty script list. --- addon-inject-preload.js | 3 +- bundled-addons/bchwallet/index.js | 101 ++++++++++++++++++++++ bundled-addons/bchwallet/panel.html | 5 ++ bundled-addons/bchwallet/panel.js | 12 +++ bundled-addons/bchwallet/wallet-inject.js | 29 ++++++- main.js | 16 ++-- 6 files changed, 158 insertions(+), 8 deletions(-) diff --git a/addon-inject-preload.js b/addon-inject-preload.js index f4e90a8..50711b9 100644 --- a/addon-inject-preload.js +++ b/addon-inject-preload.js @@ -14,7 +14,8 @@ const { contextBridge, ipcRenderer } = require("electron"); let injections = []; -try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; } catch {} +try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; } +catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); } for (const inj of injections) { const id = String(inj.id); const theseus = Object.freeze({ diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index cfb221b..e658650 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -176,11 +176,112 @@ function registerPanelMessages(api) { }); } +// ---- dapp bridge (window.bitcoincash) --------------------------------------- +// Permissions live in storage as { [origin]: { readAddress: true } }. Only +// readAddress can be remembered; signing and sending ask every time. +const pendingByOrigin = new Set(); +function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; } +function fromPage(m) { + if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message"); + return m.origin; +} +// One approval in flight per origin — a page can't stack modals. +async function withOriginLock(origin, fn) { + if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); + pendingByOrigin.add(origin); + try { return await fn(); } finally { pendingByOrigin.delete(origin); } +} +const MAGIC = "Bitcoin Signed Message:\n"; +function messageDigest(sha256, message) { + const enc = new TextEncoder(); + const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("message too long"); return Uint8Array.from([b.length, ...b]); }; + const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]); + return sha256(sha256(payload)); +} + +function registerPageMessages(api) { + api.onMessage("getAddress", async (_p, m) => { + const origin = fromPage(m); + const c = requireReady(); + const perms = permissions(api); + if (perms[origin] && perms[origin].readAddress) return c.wallet.current().address; + return withOriginLock(origin, async () => { + const pick = await api.approvalModal({ + title: "Share your Bitcoin Cash address?", + origin, + body: "The site will see your current receiving address and can look up its balance and history on the public chain.", + rows: [{ label: "Address", value: c.wallet.current().address, mono: true }], + actions: [{ id: "allow", label: "Share", primary: true }], + checkbox: { id: "always", label: "Always allow this site to see my address" }, + }); + if (!pick.startsWith("allow")) throw new Error("user rejected"); + if (pick === "allow+always") { perms[origin] = { ...(perms[origin] || {}), readAddress: true }; api.storage.set("permissions", perms); emitState(); } + return c.wallet.current().address; + }); + }); + api.onMessage("signAndSend", async (p, m) => { + const origin = fromPage(m); + const c = requireReady(); + return withOriginLock(origin, async () => { + let plan; + // Never echo the shortfall to a page — it would let a site probe the + // balance by bisecting amounts. + try { plan = planFrom(p); } + catch (e) { throw new Error(/insufficient funds|too small/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); } + const d = describePlan(plan); + if (d.recipients.length > 8) throw new Error("too many outputs"); + const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true })); + rows.push({ label: "Amount", value: fmtBch(d.recipients.reduce((a, r) => a + r.value, 0)) + " BCH", strong: true }); + rows.push({ label: "Fee", value: `${d.fee} sat (${d.feeRate} sat/B)` }); + rows.push({ label: "Total", value: fmtBch(d.total) + " BCH" }); + const pick = await api.approvalModal({ + title: "Send Bitcoin Cash?", + origin, + body: "This site is asking your wallet to pay. Check the address and amount.", + rows, + actions: [{ id: "send", label: "Send", primary: true }], + }); + if (pick !== "send") throw new Error("user rejected"); + const r = await c.wallet.signAndBroadcast(plan); + return { txid: r.txid }; + }); + }); + api.onMessage("signMessage", async (p, m) => { + const origin = fromPage(m); + const c = requireReady(); + const message = String(p && p.message != null ? p.message : ""); + if (message.length > 4096) throw new Error("message too long"); + return withOriginLock(origin, async () => { + const entry = c.wallet.current(); + const pick = await api.approvalModal({ + title: "Sign a message?", + origin, + body: "Signing proves you control the address below. It moves no coins.", + rows: [{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Address", value: entry.address, mono: true }], + actions: [{ id: "sign", label: "Sign", primary: true }], + }); + if (pick !== "sign") throw new Error("user rejected"); + const sig = c.keys.signRecoverable(entry, messageDigest(c.d.sha256, message)); + return { address: entry.address, signature: Buffer.from(sig).toString("base64") }; + }); + }); + // Panel-side management of remembered sites. + api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); + api.onMessage("revoke", (p, m) => { + fromPanel(m); + const perms = permissions(api); + delete perms[String(p && p.origin || "")]; + api.storage.set("permissions", perms); + return perms; + }); +} + module.exports = { activate(api) { api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "₿", page: "panel.html" }); const c = ctx = { api, d: null, keys: null, wallet: null, client: null, root: null, phase: "locked", error: null }; registerPanelMessages(api); + registerPageMessages(api); deps(api).then((d) => { if (ctx !== c) return; c.d = d; diff --git a/bundled-addons/bchwallet/panel.html b/bundled-addons/bchwallet/panel.html index 39028b7..537d2f8 100644 --- a/bundled-addons/bchwallet/panel.html +++ b/bundled-addons/bchwallet/panel.html @@ -174,6 +174,11 @@ +
+
Connected sites
+
Sites allowed to read your address without asking. Payments and message signing always ask.
+
+
diff --git a/bundled-addons/bchwallet/panel.js b/bundled-addons/bchwallet/panel.js index c00ca43..cb46424 100644 --- a/bundled-addons/bchwallet/panel.js +++ b/bundled-addons/bchwallet/panel.js @@ -193,6 +193,18 @@ function fillSettings() { } $("serverHint").textContent = (state.customServers ? "Custom list." : "Bundled defaults.") + (state.server ? " Connected to " + hostOf(state.server) + "." : " Not connected."); $("purpose").textContent = "silentmode/addons/bchwallet/mainnet/0"; + renderSites(); +} +async function renderSites() { + let perms = {}; + try { perms = await S.invoke("permissions"); } catch {} + const origins = Object.keys(perms).filter((o) => perms[o] && perms[o].readAddress); + const el = $("sites"); + if (!origins.length) { el.innerHTML = `
None yet.
`; return; } + el.innerHTML = origins.map((o) => `
${esc(o)}
`).join(""); + el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => { + try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {} + })); } $("applySettings").addEventListener("click", async () => { const msg = $("settingsMsg"); msg.hidden = true; diff --git a/bundled-addons/bchwallet/wallet-inject.js b/bundled-addons/bchwallet/wallet-inject.js index 346aea5..f5d73dd 100644 --- a/bundled-addons/bchwallet/wallet-inject.js +++ b/bundled-addons/bchwallet/wallet-inject.js @@ -1 +1,28 @@ -// Placeholder — filled in by the dapp-bridge step. +// Dapp bridge, run by Theseus in the isolated world of every tab whose URL +// matches addon.json "page-inject".origins. Exposes window.bitcoincash to the +// page. Every call crosses into the wallet's activate() context in main, +// which shows the approval overlay and enforces per-origin permissions — +// nothing here can sign or read anything on its own. +// +// `theseus` is provided by the host: { id, origin, contextBridge, invoke }. +const call = (msg, payload) => + theseus.invoke(msg, payload).catch((e) => { + // Strip Electron's IPC wrapper so the page sees the wallet's own message. + const text = String(e && e.message || e).replace(/^Error invoking remote method '[^']+': (Error: )?/, ""); + throw new Error(text); + }); + +theseus.contextBridge.exposeInMainWorld("bitcoincash", { + isTheseus: true, + version: "0.1.0", + network: "mainnet", + // Current receiving address (cashaddr). First call per origin asks the + // user; "always allow" makes later calls silent. + getAddress: () => call("getAddress"), + // txSpec: { to, amount } (sats) or { outputs: [{ to, amount }], feeRate }. + // Always approval-gated; resolves { txid }. + signAndSend: (txSpec) => call("signAndSend", txSpec && typeof txSpec === "object" ? txSpec : {}), + // BIP-137 signature over the message with the current address's key. + // Always approval-gated; resolves { address, signature (base64) }. + signMessage: (message) => call("signMessage", { message: String(message ?? "") }), +}); diff --git a/main.js b/main.js index 5b9835b..46e17bd 100644 --- a/main.js +++ b/main.js @@ -2316,16 +2316,20 @@ ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => { // Synchronous — the inject preload has to know what to run before the page's // own scripts start. Decided against the sender's committed URL; the href the // preload reports is only logged when it disagrees. -ipcMain.on("addon-inject-scripts", (e, href) => { - e.returnValue = []; +// Assigning event.returnValue sends the reply at once, so it is set exactly +// once at the end. +function injectionsForSender(e, href) { const tab = tabForSender(e.sender); - if (!tab || !addonHost) return; + if (!tab || !addonHost) return []; const url = e.sender.getURL(); - if (!url || url.startsWith("file:")) return; + if (!url || url.startsWith("file:")) return []; if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`); const origin = pageOriginOf(url); - e.returnValue = addonHost.injectionsFor(url).map((x) => ({ ...x, origin })); -}); + const list = addonHost.injectionsFor(url).map((x) => ({ ...x, origin })); + if (list.length) console.log(`[addons] inject ${list.map((x) => x.id).join(",")} into ${origin}`); + return list; +} +ipcMain.on("addon-inject-scripts", (e, href) => { e.returnValue = injectionsForSender(e, href); }); // Approval overlay. One request at a time; later callers queue behind the // visible one so two dapps can't race each other for the same click. let approvalPop = null;