test: Add confidential mpt bulletproof tests (#7816)

This commit is contained in:
Olek
2026-07-30 08:18:33 -04:00
committed by GitHub
parent 532506541f
commit 3c0659d26b
3 changed files with 456 additions and 0 deletions

View File

@@ -192,6 +192,7 @@ unsigned-integer-overflow:rpc/handlers/orderbook/GetAggregatePrice.cpp
# Test-only intentional overflow/underflow in fixture and unit-test arithmetic.
unsigned-integer-overflow:tests/libxrpl/basics/RangeSet.cpp
unsigned-integer-overflow:test/app/Batch_test.cpp
unsigned-integer-overflow:test/app/ConfidentialTransfer_test.cpp
unsigned-integer-overflow:test/app/Invariants_test.cpp
unsigned-integer-overflow:test/app/Loan_test.cpp
unsigned-integer-overflow:test/app/NFToken_test.cpp

View File

@@ -5469,6 +5469,429 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
}
}
void
testSendOverdraftBulletproof(FeatureBitset features)
{
uint64_t const balance = 100;
testSendOverdraftBulletproofImpl(features, balance, balance); // SUCCEED
testSendOverdraftBulletproofImpl(features, balance, balance + 1); // FAIL
}
void
testSendOverdraftBulletproofImpl(FeatureBitset features, unsigned balance, unsigned amt)
{
testcase("Send: overdraft prevention via bulletproof");
using namespace test::jtx;
// Attack scenario: Alice has 100 tokens, tries to send 101 to Bob.
// The client-side check in mpt-crypto:mpt_utility.cpp:743 prevents honest
// clients from creating this proof. We bypass it by manually
// constructing a forged proof to demonstrate that the ledger's
// range proof verification catches the overdraft.
Env env{*this, features};
Account const alice("alice"), bob("bob"), issuer("issuer");
uint64_t const aliceBalance = balance;
uint64_t const aliceAmount = amt;
uint64_t const aliceRemaining = aliceBalance - aliceAmount;
// Setup: Alice has 100 tokens converted to confidential
ConfidentialEnv confEnv{
env,
issuer,
{{.account = alice, .payAmount = 1000, .convertAmount = aliceBalance},
{.account = bob, .payAmount = 1000, .convertAmount = 30}}};
auto& mptIssuer = confEnv.mpt;
std::pair<int, TER> errors = aliceAmount > aliceBalance
? std::make_pair(-1, TER(tecBAD_PROOF))
: std::make_pair(0, TER(tesSUCCESS));
unsigned const numParticipants = 3;
// Verify Alice's actual balance before attack
{
auto const balance = requireOptional(
mptIssuer.getDecryptedBalance(alice, MPTTester::holderEncryptedSpending),
"Missing Alice's balance");
BEAST_EXPECT(balance == aliceBalance);
}
// We cannot use ConfidentialSendSetup directly because it would
// call mpt_get_confidential_send_proof which has a client-side
// check (amount > balance) at line 743 in mpt_utility.cpp.
// Instead, we manually construct the transaction components.
Buffer const randomElgamal = generateBlindingFactor();
Buffer const randomBalance = generateBlindingFactor();
// Create encrypted amounts (using the OVERDRAFT amount)
Buffer const aliceEncAmt = mptIssuer.encryptAmount(alice, aliceAmount, randomElgamal);
Buffer const bobEncAmt = mptIssuer.encryptAmount(bob, aliceAmount, randomElgamal);
Buffer const issuerEncAmt = mptIssuer.encryptAmount(issuer, aliceAmount, randomElgamal);
// Create commitments
// IMPORTANT: Amount commitment uses same randomness as ElGamal encryption!
Buffer const amtCommit = mptIssuer.getPedersenCommitment(aliceAmount, randomElgamal);
Buffer const balanceCommit = mptIssuer.getPedersenCommitment(aliceBalance, randomBalance);
// Get Alice's current encrypted spending balance
Buffer const aliceEncBalance = requireOptional(
mptIssuer.getEncryptedBalance(alice, MPTTester::holderEncryptedSpending),
"Missing Alice's encrypted spending balance");
uint32_t const version = mptIssuer.getMPTokenVersion(alice);
auto const ctxHash = getSendContextHash(
alice.id(), mptIssuer.issuanceID(), env.seq(alice), bob.id(), version);
// Now we need to manually generate the sigma proof part.
// The sigma proof verifies ciphertext consistency and commitments,
// but doesn't check the range. We'll construct it with the overdraft
// amount to bypass the client-side check.
// Generate the sigma proof manually using the lower-level secp256k1 API
auto* ctx = mpt_secp256k1_context();
Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE);
// Parse all public keys and ciphertexts
secp256k1_pubkey c1, c2Alice, c2Bob, c2Issuer;
// Parse sender's ciphertext C1 (first 33(kCompressedEcPointLength) bytes)
auto x = secp256k1_ec_pubkey_parse(ctx, &c1, aliceEncAmt.data(), kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1, "Failed to parse C1"))
return;
// Parse C2 components for all recipients
x = secp256k1_ec_pubkey_parse(
ctx, &c2Alice, aliceEncAmt.data() + kCompressedEcPointLength, kCompressedEcPointLength);
auto y = secp256k1_ec_pubkey_parse(
ctx, &c2Bob, bobEncAmt.data() + kCompressedEcPointLength, kCompressedEcPointLength);
auto z = secp256k1_ec_pubkey_parse(
ctx,
&c2Issuer,
issuerEncAmt.data() + kCompressedEcPointLength,
kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1 && y == 1 && z == 1, "Failed to parse C2 components"))
return;
secp256k1_pubkey c2Vec[] = {c2Alice, c2Bob, c2Issuer};
// Parse public keys
secp256k1_pubkey pkAlice, pkBob, pkIssuer;
auto alicePubKey = requireOptional(mptIssuer.getPubKey(alice), "Missing alice pubkey");
auto bobPubKey = requireOptional(mptIssuer.getPubKey(bob), "Missing bob pubkey");
auto issuerPubKey = requireOptional(mptIssuer.getPubKey(issuer), "Missing issuer pubkey");
x = secp256k1_ec_pubkey_parse(ctx, &pkAlice, alicePubKey.data(), kCompressedEcPointLength);
y = secp256k1_ec_pubkey_parse(ctx, &pkBob, bobPubKey.data(), kCompressedEcPointLength);
z = secp256k1_ec_pubkey_parse(
ctx, &pkIssuer, issuerPubKey.data(), kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1 && y == 1 && z == 1, "Failed to parse public keys"))
return;
secp256k1_pubkey pkVec[] = {pkAlice, pkBob, pkIssuer};
// Parse commitments
secp256k1_pubkey pcAmount, pcBalance, b1, b2;
x = secp256k1_ec_pubkey_parse(ctx, &pcAmount, amtCommit.data(), kCompressedEcPointLength);
y = secp256k1_ec_pubkey_parse(
ctx, &pcBalance, balanceCommit.data(), kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse commitments"))
return;
// Parse balance ciphertext
x = secp256k1_ec_pubkey_parse(ctx, &b1, aliceEncBalance.data(), kCompressedEcPointLength);
y = secp256k1_ec_pubkey_parse(
ctx, &b2, aliceEncBalance.data() + kCompressedEcPointLength, kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse balance ciphertext"))
return;
// Get Alice's private key
auto alicePrivKey = requireOptional(mptIssuer.getPrivKey(alice), "Missing alice privkey");
// Generate the compact sigma proof (part of mpt_get_confidential_send_proof)
// This will succeed because sigma proof doesn't check amount vs balance
x = secp256k1_compact_standard_prove(
ctx,
sigmaProof.data(),
aliceAmount,
aliceBalance,
randomElgamal.data(),
alicePrivKey.data(),
randomBalance.data(),
numParticipants,
&c1,
c2Vec,
pkVec,
&pcAmount,
&pkAlice,
&pcBalance,
&b1,
&b2,
ctxHash.data());
if (!BEAST_EXPECTS(x == 1, "Failed to generate sigma proof"))
return;
// Direct verification
x = secp256k1_compact_standard_verify(
ctx,
sigmaProof.data(),
numParticipants,
&c1,
c2Vec,
pkVec,
&pcAmount,
&pkAlice,
&pcBalance,
&b1,
&b2,
ctxHash.data());
if (!BEAST_EXPECTS(x == 1, "Sigma verification failed"))
return;
// Compute the remaining blinding factor: r_remaining = r_balance - r_amount
// This is required because the ledger homomorphically computes:
// C_remaining = C_balance - C_amount = Commit(remaining, r_balance - r_amount)
Buffer randomRemaining(kEcBlindingFactorLength);
Buffer negRandomElgamal(kEcBlindingFactorLength);
secp256k1_mpt_scalar_negate(negRandomElgamal.data(), randomElgamal.data());
secp256k1_mpt_scalar_add(
randomRemaining.data(), randomBalance.data(), negRandomElgamal.data());
// Now forge the bulletproof claiming
auto const forgedBulletproof = getForgedBulletproof(
{aliceAmount, aliceRemaining}, {randomElgamal, randomRemaining}, ctxHash);
// Combine sigma proof + forged bulletproof
Buffer combinedProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE + kEcDoubleBulletproofLength);
std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE);
std::memcpy(
combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE,
forgedBulletproof.data(),
kEcDoubleBulletproofLength);
// Direct verification
x = mpt_verify_send_range_proof(
combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE,
amtCommit.data(),
balanceCommit.data(),
ctxHash.data());
if (!BEAST_EXPECTS(x == errors.first, "Forged proof passed validation"))
return;
// Attempt the transaction with forged proof
// Expected to FAIL with tecBAD_PROOF
mptIssuer.send({
.account = alice,
.dest = bob,
.amt = aliceAmount,
.proof = strHex(combinedProof),
.senderEncryptedAmt = aliceEncAmt,
.destEncryptedAmt = bobEncAmt,
.issuerEncryptedAmt = issuerEncAmt,
.amountCommitment = amtCommit,
.balanceCommitment = balanceCommit,
.err = errors.second,
});
// Verify Alice's balance unchanged (attack prevented!)
{
auto const balance = requireOptional(
mptIssuer.getDecryptedBalance(alice, MPTTester::holderEncryptedSpending),
"Missing post-attack balance");
if (aliceAmount > aliceBalance)
{
BEAST_EXPECT(balance == aliceBalance);
}
else
{
BEAST_EXPECT(balance < aliceBalance);
}
}
}
void
testConvertBackOverdraftBulletproof(FeatureBitset features)
{
uint64_t const balance = 100;
testConvertBackOverdraftBulletproofImpl(features, balance, balance); // SUCCEED
testConvertBackOverdraftBulletproofImpl(features, balance, balance + 1); // FAIL
}
void
testConvertBackOverdraftBulletproofImpl(FeatureBitset features, uint64_t balance, uint64_t amt)
{
testcase("Convert back: overdraft prevention via bulletproof");
using namespace test::jtx;
// Attack scenario: Bob has 100 confidential tokens, tries to convert back 101.
// The client-side check in mpt_get_convert_back_proof would prevent honest
// clients from creating this proof. We bypass it by manually constructing
// a forged proof to demonstrate that the ledger's bulletproof verification
// catches the overdraft.
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
uint64_t const bobBalance = balance;
uint64_t const convertAmount = amt;
uint64_t const bobRemaining = bobBalance - convertAmount;
// Setup: Bob and Carol both have confidential balance
// Carol ensures outstanding amount >= convertAmount (bypass preclaim check)
// This allows us to test the bulletproof specifically
ConfidentialEnv confEnv{
env,
alice,
{
{.account = bob, .payAmount = 1000, .convertAmount = bobBalance},
{.account = carol,
.payAmount = 1000,
.convertAmount = std::max(convertAmount, bobBalance + 1)},
}};
auto& mptAlice = confEnv.mpt;
std::pair<int, TER> errors = convertAmount > bobBalance
? std::make_pair(-1, TER(tecBAD_PROOF))
: std::make_pair(0, TER(tesSUCCESS));
// Verify Bob's actual balance before attack
{
auto const balance = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing Bob's balance");
BEAST_EXPECT(balance == bobBalance);
}
// We cannot use the standard getConvertBackProof because it calls
// mpt_get_convert_back_proof which has client-side validation.
// Instead, we manually construct the sigma proof and forge the bulletproof.
Buffer const blindingFactor = generateBlindingFactor();
Buffer const pcBlindingFactor = generateBlindingFactor();
// Create encrypted amounts for the conversion
Buffer const bobEncAmt = mptAlice.encryptAmount(bob, convertAmount, blindingFactor);
Buffer const issuerEncAmt = mptAlice.encryptAmount(alice, convertAmount, blindingFactor);
// Create Pedersen commitment to the current balance
Buffer const balanceCommit = mptAlice.getPedersenCommitment(bobBalance, pcBlindingFactor);
// Get Bob's current encrypted spending balance
Buffer const bobEncBalance = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing Bob's encrypted spending balance");
uint32_t const version = mptAlice.getMPTokenVersion(bob);
auto const ctxHash =
getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version);
// Now manually generate the compact sigma proof for ConvertBack
auto* ctx = mpt_secp256k1_context();
Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE);
// Parse the holder's public key
secp256k1_pubkey pkBob;
auto bobPubKey = requireOptional(mptAlice.getPubKey(bob), "Missing bob pubkey");
auto x = secp256k1_ec_pubkey_parse(ctx, &pkBob, bobPubKey.data(), kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1, "Failed to parse Bob's public key"))
return;
// Parse balance commitment
secp256k1_pubkey pcBalance;
x = secp256k1_ec_pubkey_parse(
ctx, &pcBalance, balanceCommit.data(), kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1, "Failed to parse balance commitment"))
return;
// Parse balance ciphertext (B1, B2)
secp256k1_pubkey b1, b2;
x = secp256k1_ec_pubkey_parse(ctx, &b1, bobEncBalance.data(), kCompressedEcPointLength);
auto y = secp256k1_ec_pubkey_parse(
ctx, &b2, bobEncBalance.data() + kCompressedEcPointLength, kCompressedEcPointLength);
if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse balance ciphertext"))
return;
// Get Bob's private key
auto bobPrivKey = requireOptional(mptAlice.getPrivKey(bob), "Missing bob privkey");
// Generate the compact sigma proof for ConvertBack
// This verifies balance ownership and commitment linkage
x = secp256k1_compact_convertback_prove(
ctx,
sigmaProof.data(),
bobBalance,
bobPrivKey.data(),
pcBlindingFactor.data(),
&pkBob,
&b1,
&b2,
&pcBalance,
ctxHash.data());
if (!BEAST_EXPECTS(x == 1, "Failed to generate convertback sigma proof"))
return;
// Verify the sigma proof passes (it doesn't check range)
x = secp256k1_compact_convertback_verify(
ctx, sigmaProof.data(), &pkBob, &b1, &b2, &pcBalance, ctxHash.data());
if (!BEAST_EXPECTS(x == 1, "Sigma verification failed"))
return;
// Now forge the single bulletproof claiming the remaining balance is valid
// For ConvertBack, we need to prove: (balance - convertAmount) >= 0
// We create a commitment to the remainder and generate a bulletproof for it
// The bulletproof needs the blinding factor for the remainder commitment
// The ledger computes: C_remainder = C_balance - convertAmount*G
// So the blinding factor is just pcBlindingFactor (no randomness in convertAmount*G)
auto const forgedBulletproof =
getForgedSingleBulletproof(bobRemaining, pcBlindingFactor, ctxHash);
// Combine sigma proof + forged bulletproof
Buffer combinedProof(kEcConvertBackProofLength);
std::memcpy(
combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE);
std::memcpy(
combinedProof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE,
forgedBulletproof.data(),
kEcSingleBulletproofLength);
// Direct verification of the full proof
x = mpt_verify_convert_back_proof(
combinedProof.data(),
bobPubKey.data(),
bobEncBalance.data(),
balanceCommit.data(),
convertAmount,
ctxHash.data());
if (!BEAST_EXPECTS(x == errors.first, "Forged proof verification mismatch"))
return;
// Attempt the transaction with forged proof
// Expected to FAIL with tecBAD_PROOF when convertAmount > bobBalance
mptAlice.convertBack({
.account = bob,
.amt = convertAmount,
.proof = combinedProof,
.holderEncryptedAmt = bobEncAmt,
.issuerEncryptedAmt = issuerEncAmt,
.blindingFactor = blindingFactor,
.pedersenCommitment = balanceCommit,
.err = errors.second,
});
// Verify Bob's balance unchanged (attack prevented!)
{
auto const postBalance = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing post-attack balance");
if (convertAmount > bobBalance)
{
BEAST_EXPECT(postBalance == bobBalance);
}
else
{
BEAST_EXPECT(postBalance < bobBalance);
}
}
}
void
testConvertBackBulletproof(FeatureBitset features)
{
@@ -8143,6 +8566,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
testConvertBackWithAuditor(features);
testConvertBackPedersenProof(features);
testConvertBackBulletproof(features);
testConvertBackOverdraftBulletproof(features);
// Homomorphic operation tests
testSendHomomorphicOverflow(features);
@@ -8177,6 +8601,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
testSendInvalidProofContextBinding(features);
testSendForgedEqualityProof(features);
testSendForgedRangeProof(features);
testSendOverdraftBulletproof(features);
testSendNegativeValueMalleability(features);
testSendFiatShamirBinding(features);
testSendProofComponentReuse(features);

View File

@@ -94,6 +94,36 @@ protected:
return proof;
}
// Generate a forged single bulletproof for a single value and blinding factor.
// Used to test ConvertBack overdraft prevention via bulletproof verification.
static Buffer
getForgedSingleBulletproof(
uint64_t value,
Buffer const& blindingFactor,
uint256 const& contextHash)
{
auto* const ctx = mpt_secp256k1_context();
secp256k1_pubkey h;
secp256k1_mpt_get_h_generator(ctx, &h);
Buffer proof(kEcSingleBulletproofLength);
size_t proofLen = kEcSingleBulletproofLength;
if (secp256k1_bulletproof_prove_agg(
ctx,
proof.data(),
&proofLen,
&value,
blindingFactor.data(),
1, // m = 1 (single bulletproof)
&h,
contextHash.data()) == 0)
Throw<std::runtime_error>("Failed to generate forged single bulletproof");
return proof;
}
// Get a bad ciphertext with valid structure but cryptographic invalid for
// testing purposes. For preflight test purposes.
static Buffer const&