diff --git a/bundled-addons/aegis/lib/chain-btc.js b/bundled-addons/aegis/lib/chain-btc.js index 38bf3f42..cb31e4f5 100644 --- a/bundled-addons/aegis/lib/chain-btc.js +++ b/bundled-addons/aegis/lib/chain-btc.js @@ -10,6 +10,7 @@ // supports the resulting scripts because bitcoinjs-lib does. An explicit // address-family picker like DGB's is a follow-up. +const { verifyFunding, assertFee } = require("./utxo-verify.js"); const NETWORKS = { mainnet: { id: "mainnet", label: "Mainnet", @@ -453,17 +454,10 @@ module.exports = function makeBtcAdapter({ } async signAndBroadcast(plan) { - // BIP44 inputs need the whole previous transaction (nonWitnessUtxo) - // so PSBT can compute a legacy sighash; fetch each one in parallel - // before assembling the PSBT. - const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44"); - const prevHex = new Map(); - if (needsPrev.length) { - const results = await Promise.all(needsPrev.map((u) => - this._client.call("blockchain.transaction.get", [u.txid, false]) - )); - needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i]))); - } + // Every non-taproot input's previous transaction is fetched and + // checked against the value the plan used (lib/utxo-verify.js); BIP44 + // inputs also need it whole (nonWitnessUtxo) for the legacy sighash. + const prevHex = await verifyFunding({ chosen: plan._chosen, client: this._client, Transaction: bitcoinjs.Transaction }); const psbt = new Psbt({ network: this._bjsNet }); for (const u of plan._chosen) { // Signal replace-by-fee, so a payment sent at too low a rate can be @@ -502,6 +496,7 @@ module.exports = function makeBtcAdapter({ } } psbt.finalizeAllInputs(); + assertFee(psbt, plan); const tx = psbt.extractTransaction(); const hex = tx.toHex(); const txid = await this._client.call("blockchain.transaction.broadcast", [hex]); diff --git a/bundled-addons/aegis/lib/chain-dgb.js b/bundled-addons/aegis/lib/chain-dgb.js index 5a3d1621..92a9c7de 100644 --- a/bundled-addons/aegis/lib/chain-dgb.js +++ b/bundled-addons/aegis/lib/chain-dgb.js @@ -16,6 +16,7 @@ // object, so a seed exported here can be restored on iancoleman.io/bip39 // or the SilentCode Digibyte web-wallet with matching addresses. +const { verifyFunding, assertFee } = require("./utxo-verify.js"); const NETWORK = "mainnet"; const DEFAULT_PURPOSE = 84; const EXPLORER_TX = "https://digiexplorer.info/tx/"; @@ -442,12 +443,10 @@ module.exports = function makeDgbAdapter({ }); // P2PKH (BIP44) inputs need the whole previous transaction for the // legacy sighash; fetch each one before assembling the PSBT. - const prevHex = new Map(); - const needsPrev = chosen.filter((u) => u.entry.family === "bip44"); - if (needsPrev.length) { - const results = await Promise.all(needsPrev.map((u) => this._client.call("blockchain.transaction.get", [u.txid, false]))); - needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i]))); - } + // Every non-taproot input is checked against its previous transaction + // first, so a server cannot shrink an input and turn change into fee + // (lib/utxo-verify.js). + const prevHex = await verifyFunding({ chosen, client: this._client, Transaction: bitcoinjs.Transaction }); const psbtInputs = chosen.map((u) => { const fam = u.entry.family; const inp = { txid: u.txid, vout: u.vout }; @@ -476,7 +475,11 @@ module.exports = function makeDgbAdapter({ psbt.signInput(i, this._keys.signerFor(entry)); } } - const { hex, txid } = finalizeAndExtract(psbt); + psbt.finalizeAllInputs(); + assertFee(psbt, plan); + const extracted = psbt.extractTransaction(); + const hex = extracted.toHex(); + const txid = extracted.getId(); const broadcast = await this._client.call("blockchain.transaction.broadcast", [hex]); if (typeof broadcast !== "string" || broadcast.length !== 64) { throw new Error("broadcast rejected: " + JSON.stringify(broadcast)); diff --git a/bundled-addons/aegis/lib/utxo-verify.js b/bundled-addons/aegis/lib/utxo-verify.js new file mode 100644 index 00000000..cf33a99f --- /dev/null +++ b/bundled-addons/aegis/lib/utxo-verify.js @@ -0,0 +1,55 @@ +// Check the Electrum server's word on what each input is worth before +// signing a BTC/DGB transaction. +// +// Coin selection, change and the fee on the approval overlay are computed +// from listunspent's `value`. A legacy (P2PKH) signature does not commit to +// the value of the coin it spends, so a server that under-reported a UTXO +// made Aegis sign a transaction whose real fee was the difference — up to +// bitcoinjs's 5000 sat/vB ceiling — while the overlay showed the small, +// planned fee. A segwit v0 signature commits to its own input's value only, +// which still leaves the two-request variant (lie about a different input +// each time, combine the signatures). Taproot commits to every input's +// amount and script, so a lie there just makes the signature invalid. +// +// For every non-taproot input the previous transaction is fetched, its txid +// recomputed (so the server cannot hand over a different one), and the +// output's value and script compared with what was planned. Any mismatch +// refuses to sign. +"use strict"; + +async function verifyFunding({ chosen, client, Transaction }) { + const need = chosen.filter((u) => u.entry.family !== "bip86"); + const uniq = [...new Set(need.map((u) => u.txid))]; + const got = await Promise.all(uniq.map((txid) => client.call("blockchain.transaction.get", [txid, false]))); + const prevHex = new Map(); + uniq.forEach((txid, i) => prevHex.set(txid, String(got[i] || ""))); + for (const u of need) { + const hex = prevHex.get(u.txid); + let tx; + try { tx = Transaction.fromHex(hex); } + catch { throw new Error(`the server sent an unreadable transaction for input ${u.txid}:${u.vout}; not signing`); } + if (tx.getId() !== u.txid) throw new Error(`the server sent a different transaction for input ${u.txid}:${u.vout}; not signing`); + const out = tx.outs[u.vout]; + if (!out) throw new Error(`input ${u.txid}:${u.vout} does not exist; not signing`); + if (BigInt(out.value) !== BigInt(u.value)) { + throw new Error(`the server misreported input ${u.txid}:${u.vout} (said ${u.value}, the transaction says ${out.value}); not signing`); + } + const script = u.entry.script ? Buffer.from(u.entry.script) : (u.entry.scriptHex ? Buffer.from(u.entry.scriptHex, "hex") : null); + if (script && !Buffer.from(out.script).equals(script)) { + throw new Error(`input ${u.txid}:${u.vout} is not paid to this wallet's address; not signing`); + } + } + return prevHex; +} + +// The fee the signed transaction actually pays must be the one the user +// approved. +function assertFee(psbt, plan) { + let actual; + try { actual = psbt.getFee(); } catch { return; } // a taproot-only PSBT without full prevouts + if (BigInt(actual) !== BigInt(plan.fee)) { + throw new Error(`the transaction would pay a fee of ${actual}, not the ${plan.fee} you approved; not signing`); + } +} + +module.exports = { verifyFunding, assertFee };