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:
parent
821cc8e808
commit
67939b1493
6 changed files with 158 additions and 8 deletions
|
|
@ -14,7 +14,8 @@
|
||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
let injections = [];
|
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) {
|
for (const inj of injections) {
|
||||||
const id = String(inj.id);
|
const id = String(inj.id);
|
||||||
const theseus = Object.freeze({
|
const theseus = Object.freeze({
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
module.exports = {
|
||||||
activate(api) {
|
activate(api) {
|
||||||
api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "₿", page: "panel.html" });
|
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 };
|
const c = ctx = { api, d: null, keys: null, wallet: null, client: null, root: null, phase: "locked", error: null };
|
||||||
registerPanelMessages(api);
|
registerPanelMessages(api);
|
||||||
|
registerPageMessages(api);
|
||||||
deps(api).then((d) => {
|
deps(api).then((d) => {
|
||||||
if (ctx !== c) return;
|
if (ctx !== c) return;
|
||||||
c.d = d;
|
c.d = d;
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,11 @@
|
||||||
<button class="btn danger" id="showXprv">Show account private key</button>
|
<button class="btn danger" id="showXprv">Show account private key</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="msg err" id="settingsMsg" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,18 @@ function fillSettings() {
|
||||||
}
|
}
|
||||||
$("serverHint").textContent = (state.customServers ? "Custom list." : "Bundled defaults.") + (state.server ? " Connected to " + hostOf(state.server) + "." : " Not connected.");
|
$("serverHint").textContent = (state.customServers ? "Custom list." : "Bundled defaults.") + (state.server ? " Connected to " + hostOf(state.server) + "." : " Not connected.");
|
||||||
$("purpose").textContent = "silentmode/addons/bchwallet/mainnet/0";
|
$("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 () => {
|
$("applySettings").addEventListener("click", async () => {
|
||||||
const msg = $("settingsMsg"); msg.hidden = true;
|
const msg = $("settingsMsg"); msg.hidden = true;
|
||||||
|
|
|
||||||
|
|
@ -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
16
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
|
// 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
|
// own scripts start. Decided against the sender's committed URL; the href the
|
||||||
// preload reports is only logged when it disagrees.
|
// preload reports is only logged when it disagrees.
|
||||||
ipcMain.on("addon-inject-scripts", (e, href) => {
|
// Assigning event.returnValue sends the reply at once, so it is set exactly
|
||||||
e.returnValue = [];
|
// once at the end.
|
||||||
|
function injectionsForSender(e, href) {
|
||||||
const tab = tabForSender(e.sender);
|
const tab = tabForSender(e.sender);
|
||||||
if (!tab || !addonHost) return;
|
if (!tab || !addonHost) return [];
|
||||||
const url = e.sender.getURL();
|
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}`);
|
if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`);
|
||||||
const origin = pageOriginOf(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
|
// 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.
|
// visible one so two dapps can't race each other for the same click.
|
||||||
let approvalPop = null;
|
let approvalPop = null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue