Aegis: a WizardConnect request the overlay cannot read is not signed

wc-sign accepted a flat request (tx and sourceOutputs at the top) that
buildWcApproval does not read, so any paired dapp could get a signature
after an overlay showing only the wallet, an input count and the sighash.
The signer now takes only the nested WizardConnect shape, and the overlay
refuses, without showing anything, a request whose outputs or spent
inputs it cannot decode. Total out now sums every output, not the first 8.
This commit is contained in:
Local Dev 2026-10-04 02:21:11 +02:00
parent e02d4698ea
commit 2faa3d808a
2 changed files with 17 additions and 8 deletions

View file

@ -980,6 +980,13 @@ function buildWcApproval(payload) {
inner = typeof dec === "string" ? null : dec; inner = typeof dec === "string" ? null : dec;
} catch { inner = null; } } catch { inner = null; }
} }
// Refuse what cannot be shown. The signer only takes this shape (see
// wc-sign.js), and an overlay without outputs or spent inputs is no
// approval at all.
if (!inner || !Array.isArray(inner.inputs) || !Array.isArray(inner.outputs) || !inner.outputs.length
|| !Array.isArray(tx.sourceOutputs) || tx.sourceOutputs.length !== inner.inputs.length) {
return { unreadable: true, dappName };
}
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?"); 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) }]; const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }];
// What the wallet is putting IN. The outputs alone never showed that a // What the wallet is putting IN. The outputs alone never showed that a
@ -1011,6 +1018,7 @@ function buildWcApproval(payload) {
try { try {
const outs = inner?.outputs || []; const outs = inner?.outputs || [];
let total = 0n; let total = 0n;
for (const o of outs) { try { total += BigInt(o.valueSatoshis ?? 0); } catch {} }
outs.slice(0, 8).forEach((o, i) => { outs.slice(0, 8).forEach((o, i) => {
const lb = o.lockingBytecode; const lb = o.lockingBytecode;
const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex"); const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex");
@ -1018,7 +1026,6 @@ function buildWcApproval(payload) {
if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46))); if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46)));
else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44))); else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44)));
const v = BigInt(o.valueSatoshis ?? 0); const v = BigInt(o.valueSatoshis ?? 0);
total += v;
const token = tokenText(o.token); const token = tokenText(o.token);
rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true }); rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true });
}); });
@ -4150,7 +4157,8 @@ module.exports = {
// keys fell back to a lone "OK" button whose id never matched, so // keys fell back to a lone "OK" button whose id never matched, so
// every WizardConnect signing request was refused, and the HTML // every WizardConnect signing request was refused, and the HTML
// body was shown as literal markup. // body was shown as literal markup.
const { body, rows, dappName } = buildWcApproval(payload); const { body, rows, dappName, unreadable } = buildWcApproval(payload);
if (unreadable) { api.log(`wc sign: refused an unreadable request from ${dappName}`); return { approved: false }; }
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: "Sign a Bitcoin Cash transaction (WizardConnect)?", title: "Sign a Bitcoin Cash transaction (WizardConnect)?",
origin: dappName, origin: dappName,

View file

@ -66,12 +66,13 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) {
// the outputs) meant `tx.inputs` was undefined and signing threw on the // the outputs) meant `tx.inputs` was undefined and signing threw on the
// first real request. index.js already read the nested // first real request. index.js already read the nested
// request.transaction.userPrompt for the approval dialog, so only this // request.transaction.userPrompt for the approval dialog, so only this
// module had it wrong. The flat shape is still accepted so a caller // module had it wrong. Only the nested shape is accepted: the approval
// that hands us an already-unwrapped payload keeps working. // overlay reads that shape, and a flat request used to be signed after an
const inner = (request.transaction && (request.transaction.transaction !== undefined // overlay that could show neither its outputs nor what it spends.
|| request.transaction.sourceOutputs !== undefined)) const inner = request.transaction;
? request.transaction if (!inner || typeof inner !== "object" || inner.transaction === undefined || !Array.isArray(inner.sourceOutputs)) {
: request; throw new Error("wc-sign: request is not a WizardConnect sign request (request.transaction.{transaction,sourceOutputs})");
}
const tx = ensureTransaction(inner.transaction, libauth); const tx = ensureTransaction(inner.transaction, libauth);
const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => { const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => {
if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`); if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`);