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.
This commit is contained in:
Local Dev 2026-09-06 02:56:34 +02:00
parent 821cc8e808
commit 67939b1493
6 changed files with 158 additions and 8 deletions

View file

@ -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({

View file

@ -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;

View file

@ -174,6 +174,11 @@
<button class="btn danger" id="showXprv">Show account private key</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="lbl">Connected sites</div>
<div class="hint">Sites allowed to read your address without asking. Payments and message signing always ask.</div>
<div id="sites" class="kv"></div>
</div>
<div class="msg err" id="settingsMsg" hidden></div>
</section>
</div>

View file

@ -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 = `<div class="hint">None yet.</div>`; return; }
el.innerHTML = origins.map((o) => `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div class="mono">${esc(o)}</div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`).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;

View file

@ -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 ?? "") }),
});

16
main.js
View file

@ -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;