Aegis: WizardConnect relay keys from the real root, overlay names the pairing origin

mountWallet zeroed the vault root after mounting, but the WizardConnect
adapter kept a reference to that same buffer and read it again for
every new pairing's relay key. Every pairing made after mount therefore
got a Nostr identity derived from 32 zero bytes and the pairing URI
alone, so anyone who saw the URI (the QR, a script on the dapp page)
could read the relay traffic, xpubs included, and speak as the wallet.
The adapter now gets, and keeps, its own copy.

The signing overlay and the PIN request named the dapp by its own
userPrompt, so a dapp paired once could present itself as any site.
The pairing origin the host verified is now recorded per URI and shown
instead; the dapp's text is a quoted row with invisible and bidi
characters removed. One sign request per connection may be on screen
at a time, and revoking a site in Aegis ends its pairings too.
This commit is contained in:
Local Dev 2026-10-04 03:45:34 +02:00
parent 561cb122ea
commit 3264c6d019
3 changed files with 105 additions and 14 deletions

View file

@ -855,7 +855,11 @@ async function mountWallet(entry) {
// dapp saw an unrelated, empty wallet; anything it built spent
// from addresses this wallet does not own, so signing failed with
// "no path for input". Silent, and only visible on testnet.
root32: root, accountPath: entry.accountPath || adapter.snapshot?.()?.accountPath || "m/44'/145'/0'",
// A copy: `root` is zeroed in the finally below, and the adapter
// reads its root again for every new pairing's relay key. Sharing
// the buffer gave every pairing made after mount a relay key derived
// from 32 zero bytes, i.e. from the pairing URI alone.
root32: new Uint8Array(root), accountPath: entry.accountPath || adapter.snapshot?.()?.accountPath || "m/44'/145'/0'",
}).catch((e) => c.api.log(`[${entry.id}] wc start:`, e?.message || e));
}
emitStateForWallet(entry.id);
@ -968,7 +972,16 @@ function deriveCashaddrFromWif(wif, prefix) {
function buildWcApproval(payload) {
const req = payload?.request || {};
const tx = req.transaction || {};
const dappName = String(tx.userPrompt || payload?.dappName || "unknown dapp").slice(0, 120);
// Who is asking is what Aegis recorded when the pairing was made: the page
// origin the host verified, or the panel when the user pasted the code.
// The dapp's userPrompt is its own free text, shown only as a quote — it
// used to be the overlay's origin and the PIN request's name, so a paired
// dapp could present itself as any site.
const paired = payload?.pairing && typeof payload.pairing.origin === "string" ? payload.pairing.origin : null;
const dappName = paired === "panel" ? "a dapp you paired in Aegis by pasting its code"
: paired ? paired
: "a dapp paired before Aegis recorded where pairings came from";
const says = plainLabel(tx.userPrompt || "", 160);
const walletName = payload?.label || payload?.walletId || "";
const network = ctx.runtimes.get(payload?.walletId)?.entry?.network;
const prefix = network === "chipnet" ? "bchtest" : "bitcoincash";
@ -989,6 +1002,7 @@ function buildWcApproval(payload) {
}
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?");
const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }];
if (says) rows.unshift({ label: "The dapp says", value: `“${says}”` });
// What the wallet is putting IN. The outputs alone never showed that a
// transaction spends the user's tokens — and with the token prefix now
// signed correctly (wc-sign.js) such a transaction is valid, so the
@ -1034,7 +1048,7 @@ function buildWcApproval(payload) {
} catch {}
rows.push({ label: "After signing", value: tx.broadcast ? "the dapp broadcasts it" : "signed hex is returned to the dapp" });
rows.push({ label: "Sighash", value: "ALL | FORKID | UTXOS" });
return { body: `${dappName} asks you to sign a Bitcoin Cash transaction.`, rows, dappName };
return { body: `A site paired over WizardConnect asks you to sign a Bitcoin Cash transaction. Paired from: ${dappName}.`, rows, dappName, origin: paired && paired !== "panel" ? paired : "WizardConnect" };
}
// ---- per-purpose default wallets -------------------------------------------
@ -1383,7 +1397,28 @@ async function requireDappTxPin(origin, what) {
// benign-looking opening cannot hide what the rest commits the user to.
function previewText(text, max = 1500) {
const s = String(text);
return s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s;
const cut = s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s;
return showInvisibles(cut);
}
// Characters that change how text LOOKS without being visible themselves:
// C0/C1 controls (newline and tab excepted), zero-width characters, and the
// bidi overrides/isolates that can make "0x…dead" render as "0x…daed" or
// reorder an overlay line. Built from code points so no editor or tool can
// silently turn the escapes into the characters themselves.
const INVISIBLE_RE = (() => {
const r = [[0x00, 0x08], [0x0b, 0x0c], [0x0e, 0x1f], [0x7f, 0x9f], [0x061c, 0x061c], [0x180e, 0x180e],
[0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x2066, 0x206f], [0xfeff, 0xfeff]];
const esc = (c) => String.fromCharCode(92) + "u" + c.toString(16).padStart(4, "0");
return new RegExp("[" + r.map(([a, b]) => (a === b ? esc(a) : esc(a) + "-" + esc(b))).join("") + "]", "g");
})();
// Signed text keeps every character, so the overlay names the invisible ones.
function showInvisibles(s) {
return String(s).replace(INVISIBLE_RE, (c) => `⟨U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}⟩`);
}
// A name or label that someone else chose (a dapp, a token registry): one
// line, nothing invisible, bounded.
function plainLabel(v, max = 80) {
return String(v ?? "").replace(INVISIBLE_RE, "").replace(/\s+/g, " ").trim().slice(0, max);
}
function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); }
function fromPage(m) {
@ -2325,7 +2360,7 @@ function registerPanelMessages(api) {
if (!ctx.wc) throw new Error("WizardConnect not ready");
const walletId = String(p && p.walletId || "");
const uri = String(p && p.uri || "");
await ctx.wc.connectUri(walletId, uri);
await ctx.wc.connectUri(walletId, uri, "panel");
return fullState();
});
api.onMessage("wcDisconnect", async (p, m) => {
@ -2443,7 +2478,7 @@ function registerPanelMessages(api) {
// from the options we offered, but the wallet could have gone away
// while the dialog was open.
const chosen = candidates.find((w) => w.id === pickedId) || preferred;
await ctx.wc.connectUri(chosen.id, uri);
await ctx.wc.connectUri(chosen.id, uri, origin);
return { paired: true, wallet: chosen.label };
});
});
@ -3119,6 +3154,8 @@ function revokeOrigin(api, origin) {
api.storage.set("permissions", perms);
sessionGrants.delete(origin);
syncInjectPolicy(api);
// A revoked site keeps no WizardConnect pairing either.
if (ctx?.wc?.disconnectOrigin) ctx.wc.disconnectOrigin(origin).catch(() => {});
}
// ---- who can see the wallet ------------------------------------------------
@ -4311,11 +4348,11 @@ module.exports = {
// keys fell back to a lone "OK" button whose id never matched, so
// every WizardConnect signing request was refused, and the HTML
// body was shown as literal markup.
const { body, rows, dappName, unreadable } = buildWcApproval(payload);
const { body, rows, dappName, unreadable, origin: wcOrigin } = buildWcApproval(payload);
if (unreadable) { api.log(`wc sign: refused an unreadable request from ${dappName}`); return { approved: false }; }
const pick = await api.approvalModal({
title: "Sign a Bitcoin Cash transaction (WizardConnect)?",
origin: dappName,
origin: wcOrigin,
body, rows,
actions: [{ id: "approve", label: "Sign", primary: true }],
});

View file

@ -31,6 +31,8 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
// HDKey and to derive per-URI relay identities via HKDF, so reconnecting
// yields the same Nostr identity (dapp recognises us on reload).
function makeAdapter({ root32, accountPath, walletId, label }) {
// Own copy: the caller may wipe its buffer once this returns.
root32 = new Uint8Array(root32);
const account = HDKey.fromMasterSeed(root32).derive(accountPath);
const branches = new Map();
const branchFor = (childIndex) => {
@ -70,7 +72,11 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
// Sign a transaction the dapp has already assembled. See signTx.js for
// the heavy lifting (SIGHASH_ALL|FORKID|UTXOS enforcement, libauth
// preimage + secp256k1 der/lowS signatures).
async signTransaction(request) {
// `pairing` is what Aegis itself recorded when this connection was
// made ({ origin } — the page origin the host verified, or "panel"),
// never what the dapp says about itself: the dapp's userPrompt and
// name are free text it controls.
async signTransaction(request, pairing = null) {
// Route through approval-modal first — the user always sees what
// they're signing before any private key touches the request.
if (!approvalRequest) throw new Error("no approval channel");
@ -78,6 +84,7 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
kind: "wc-sign",
walletId, label,
request,
pairing,
});
if (!decision?.approved) throw new Error("cancelled");
const { signTx } = require("./wc-sign.js");
@ -97,6 +104,8 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
// panel can show "Wallet A connected to 2 dapps, Wallet B to none" etc.
const managers = new Map(); // walletId -> WalletConnectionManager
const uris = new Map(); // walletId -> Set<uri> (persisted)
const origins = new Map(); // walletId -> Map<uri, { origin, at }> (persisted)
const busy = new Set(); // connection ids with a sign request on screen
const listeners = new Set(); // () => void — panel resubscribes on state change
function fireStateChange() { for (const fn of listeners) try { fn(); } catch {} }
@ -104,6 +113,13 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
function persist(walletId) {
const list = [...(uris.get(walletId) || new Set())];
api.storage.set(`wc/${walletId}/uris`, list);
const o = origins.get(walletId) || new Map();
for (const u of [...o.keys()]) if (!list.includes(u)) o.delete(u);
api.storage.set(`wc/${walletId}/origins`, Object.fromEntries(o));
}
function uriOf(mgr, connectionId) {
const all = typeof mgr.getConnections === "function" ? Object.values(mgr.getConnections() || {}) : [...(mgr.connections?.values?.() || [])];
return all.find((c) => c.id === connectionId)?.uri || null;
}
async function startForWallet({ walletId, label, root32, accountPath }) {
@ -119,17 +135,29 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
fireStateChange();
});
mgr.on("pendingSignRequest", async ({ connectionId, request }) => {
// One request per connection on screen at a time: a paired dapp can
// send over the relay whenever it likes, with no tab open, and must not
// be able to stack overlays.
if (busy.has(connectionId)) {
try { await mgr.sendSignError(connectionId, request?.sequence, "another request from this dapp is waiting for the user"); } catch {}
return;
}
busy.add(connectionId);
try {
const { signedTransaction } = await adapter.signTransaction(request);
const uri = uriOf(mgr, connectionId);
const pairing = (uri && origins.get(walletId)?.get(uri)) || null;
const { signedTransaction } = await adapter.signTransaction(request, pairing);
await mgr.sendSignResponse(connectionId, request.sequence, signedTransaction);
} catch (e) {
log(`wc[${walletId}] sign failed:`, e?.message || e);
try { await mgr.sendSignError(connectionId, request.sequence, cleanErrForDapp(e)); } catch {}
}
} finally { busy.delete(connectionId); }
});
// Restore persisted pairings.
uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || []));
const savedOrigins = api.storage.get(`wc/${walletId}/origins`, {}) || {};
origins.set(walletId, new Map(Object.entries(savedOrigins).filter(([, v]) => v && typeof v.origin === "string")));
for (const uri of uris.get(walletId)) {
try { mgr.connect(uri); } catch (e) { log(`wc[${walletId}] reconnect failed:`, e?.message); }
}
@ -141,9 +169,12 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
try { mgr.disconnectAll?.(); } catch {}
managers.delete(walletId);
uris.delete(walletId);
origins.delete(walletId);
}
async function connectUri(walletId, uri) {
// `origin`: the verified page origin that asked to pair, or "panel" when
// the user pasted the code into Aegis themselves.
async function connectUri(walletId, uri, origin = "panel") {
const mgr = managers.get(walletId);
if (!mgr) throw new Error("wc: wallet not ready");
const trimmed = String(uri || "").trim();
@ -152,6 +183,9 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
const set = uris.get(walletId) || new Set();
set.add(trimmed);
uris.set(walletId, set);
const o = origins.get(walletId) || new Map();
o.set(trimmed, { origin: String(origin || "panel"), at: Date.now() });
origins.set(walletId, o);
persist(walletId);
fireStateChange();
return id;
@ -168,6 +202,25 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
fireStateChange();
}
// Revoking a site in Aegis also ends the WizardConnect pairings it made.
async function disconnectOrigin(origin) {
let n = 0;
for (const [walletId, o] of origins) {
const mgr = managers.get(walletId);
for (const [uri, rec] of [...o]) {
if (rec.origin !== origin) continue;
const conn = mgr ? (typeof mgr.getConnections === "function" ? Object.values(mgr.getConnections() || {}) : []).find((c) => c.uri === uri) : null;
if (conn) { try { await mgr.disconnect(conn.id); } catch {} }
uris.get(walletId)?.delete(uri);
o.delete(uri);
persist(walletId);
n++;
}
}
if (n) fireStateChange();
return n;
}
function snapshot() {
const out = {};
for (const [walletId, mgr] of managers) {
@ -187,6 +240,7 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
label: c.label || null,
dappName: c.dappName || null,
dappIcon: c.dappIcon || null,
pairedFrom: origins.get(walletId)?.get(c.uri)?.origin || null,
// RelayStatus is an OBJECT: { status: "connected" | "reconnecting"
// | "disconnected" | "session_deleted" }. Reading `.kind` (which
// does not exist) fell through to String(object) and put the
@ -209,5 +263,5 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
return m.replace(/\n[\s\S]*$/, "").slice(0, 200);
}
return { startForWallet, stopForWallet, connectUri, disconnect, snapshot, onStateChange };
return { startForWallet, stopForWallet, connectUri, disconnect, disconnectOrigin, snapshot, onStateChange };
};

View file

@ -2474,7 +2474,7 @@ function renderConnectPane(bchWallets) {
const rowsHtml = rows.length
? rows.map((c) => `<div class="tx" style="grid-template-columns:auto 1fr auto;cursor:default;align-items:center;margin-top:6px">
<div>${c.dappIcon ? `<img src="${esc(c.dappIcon)}" style="width:18px;height:18px;border-radius:4px" onerror="this.hidden=true">` : ""}</div>
<div><div>${esc(c.dappName || c.label || "(pairing…)")} ${wcStatusTag(c.status)}</div><div class="hint">on <b>${esc(c.walletLabel)}</b> · <span class="mono">${esc((c.uri || "").slice(0, 40))}…</span></div></div>
<div><div>${esc(c.dappName || c.label || "(pairing…)")} ${wcStatusTag(c.status)}</div><div class="hint">${c.pairedFrom ? `paired from <b>${esc(c.pairedFrom === "panel" ? "Aegis (pasted code)" : c.pairedFrom)}</b> · ` : ""}on <b>${esc(c.walletLabel)}</b> · <span class="mono">${esc((c.uri || "").slice(0, 40))}…</span></div></div>
<button class="btn sm" data-wcpick="${esc(c.walletId)}|${esc(c.id)}">Disconnect</button>
</div>`).join("")
: `<div class="hint" style="padding:6px 8px">No dapps paired yet.</div>`;