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:
parent
561cb122ea
commit
3264c6d019
3 changed files with 105 additions and 14 deletions
|
|
@ -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 }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue