Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions src/blind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 173321e:

Nit: moving this line was not necessary (and it causes an unnecessary generator computation in the Value::Null case).

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);
}
Expand Down
135 changes: 135 additions & 0 deletions tests/poc_issuance_inflation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! 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.
//!
//! - `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, 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 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::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
// 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 tx = Transaction {
version: 2,
lock_time: LockTime::ZERO,
input: vec![input],
output: vec![txout(Asset::Explicit(asset), Value::Explicit(100))],
};

// 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(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");
}
Loading