diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 639327ebfa..4325e502f6 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -5620,6 +5620,277 @@ class ConfidentialTransfer_test : public beast::unit_test::suite .err = tecBAD_PROOF}); // Fails because TransactionContextID differs } + /* This test simulates an attack where the holder ciphertext is modified + * via homomorphic addition (adding Encrypted_amt(1)) while leaving the issuer + * ciphertext unchanged. It confirms that the validator detects the + * mismatch between the re-computed ciphertexts and the submitted ones, + * resulting in tecBAD_PROOF. */ + void + testHomomorphicCiphertextModification(FeatureBitset features) + { + testcase("Homomorphic ciphertext modification"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy}); + + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // Bob converts 50 to confidential balance + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({.account = bob}); + + // Prepare valid parameters for a ConvertBack of 10 + uint64_t const amt = 10; + Buffer const bf = generateBlindingFactor(); + + auto const holderCipherText = mptAlice.encryptAmount(bob, amt, bf); + auto const issuerCipherText = mptAlice.encryptAmount(alice, amt, bf); + + // Generate a "Delta" ciphertext (Encrypting 1) + // We use Bob's key because we are tampering with Bob's (Holder's) field + Buffer const deltaBf = generateBlindingFactor(); + auto const deltaCipherText = mptAlice.encryptAmount(bob, 1, deltaBf); + + // Homomorphically add Delta to HolderCipherText: Tampered = Enc(10) + Enc(1) = Enc(11) + auto tamperedOpt = homomorphicAdd(holderCipherText, deltaCipherText); + BEAST_EXPECT(tamperedOpt.has_value()); + Buffer tamperedHolderCipherText = std::move(*tamperedOpt); + + // Generate a valid proof for the ORIGINAL amount (10) + auto const spendingBal = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + auto const spendingBalEnc = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + Buffer const pcBf = generateBlindingFactor(); + auto const pedersenCommitment = mptAlice.getPedersenCommitment(*spendingBal, pcBf); + + auto const currentVersion = mptAlice.getMPTokenVersion(bob); + // Uses the new signature: Account, IssuanceID, Sequence, Version + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBal, + .encryptedAmt = *spendingBalEnc, + .blindingFactor = pcBf, + }); + + // Submit transaction with Divergent Ciphertexts + // Holder Ciphertext encrypts 11. Issuer Ciphertext encrypts 10. + // The consistency check (re-encryption of `amt` with `bf`) will match Issuer but FAIL for + // Holder. + mptAlice.convertBack( + {.account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = tamperedHolderCipherText, // Tampered (11) + .issuerEncryptedAmt = issuerCipherText, // Original (10) + .blindingFactor = bf, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF}); + } + + /* This test verifies that rippled correctly rejects attempts to + * overflow the maximum allowable token amount via homomorphic manipulation. + * It simulates an attack where an individual takes a valid ciphertext encrypting + * the maximum amount (maxMPTokenAmount) and homomorphically adds an encryption of + * 1 to it, producing a ciphertext for MAX+1. The test confirms that the Bulletproof + * range proof or inner-product constraints detect this overflow and invalidate the + * transaction, preserving the supply invariant. */ + void + testSendHomomorphicOverflow(FeatureBitset features) + { + testcase("Send: homomorphic overflow attack via Enc(MAX) + Enc(1)"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanLock | tfMPTCanPrivacy | tfMPTCanTransfer}); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({.account = carol}); + + // Bob sends 10 to carol. The send amount (10) and Bob's remaining balance + // (90) are both within [0, maxMPTokenAmount]. Range proof passes. + mptAlice.send({.account = bob, .dest = carol, .amt = 10}); + + // Bob's spending balance is 90 after the baseline send. + auto const bobSpendingBefore = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == 90); + + // Construct Enc(maxMPTokenAmount) with Bob's public key. + Buffer const bf1 = generateBlindingFactor(); + Buffer const encMax = mptAlice.encryptAmount(bob, maxMPTokenAmount, bf1); + + // Construct Enc(1) with a separate blinding factor. + Buffer const bf2 = generateBlindingFactor(); + Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2); + + // Homomorphically add to produce CB_S_holder' = Enc(MAX) + Enc(1) + auto overflowedOpt = homomorphicAdd(encMax, encOne); + BEAST_EXPECT(overflowedOpt.has_value()); + Buffer overflowedCt = std::move(*overflowedOpt); + + // Submit the send transaction with the tampered ciphertext. + // Setting amt = maxMPTokenAmount + 1 drives proof generation for the + // overflowed value. The bulletproof range check [0, maxMPTokenAmount] + // rejects MAX+1; the validator must return tecBAD_PROOF. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = maxMPTokenAmount + 1, + .senderEncryptedAmt = overflowedCt, + .err = tecBAD_PROOF, + }); + + auto const bobSpendingAfter = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter); + } + + /* This test ensures that the system prevents underflow attacks where a user + * attempts to create a negative balance through homomorphic subtraction. It + * simulates a scenario where an attacker takes a ciphertext encrypting zero + * and subtracts an encryption of 1, resulting in a value of -1. + * The test asserts that the range proof verification fails because the resulting + * value falls outside the valid non-negative range [0, maxMPTokenAmount], + * causing the validator to reject the transaction with tecBAD_PROOF. */ + void + testConvertBackHomomorphicUnderflow(FeatureBitset features) + { + testcase("ConvertBack: homomorphic underflow attack via Enc(0) - Enc(1)"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy}); + + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 10); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + // Converting back 1 from 10 leaves remaining balance = 9 (non-negative). + // Range proof [0, maxMPTokenAmount] passes. + mptAlice.convertBack({.account = bob, .amt = 1}); + + // Bob's spending balance is now 9; public balance is 1. + auto const bobSpendingBefore = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == 9); + auto const bobPublicBefore = mptAlice.getBalance(bob); + BEAST_EXPECT(bobPublicBefore == 1); + + // Construct Enc(0) — the zero encrypted balance using Bob's key. + Buffer const bf1 = generateBlindingFactor(); + Buffer const encZero = mptAlice.encryptAmount(bob, 0, bf1); + + // Construct Enc(1) with a separate blinding factor. + Buffer const bf2 = generateBlindingFactor(); + Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2); + + // Homomorphically subtract to produce CB_S_holder' = Enc(0) − Enc(1) + // = Enc(−1), which lies below [0, maxMPTokenAmount]. + auto underflowedOpt = homomorphicSubtract(encZero, encOne); + BEAST_EXPECT(underflowedOpt.has_value()); + Buffer underflowedCt = std::move(*underflowedOpt); + + // The underflowed value as uint64_t: 0 - 1 wraps to 0xFFFFFFFFFFFFFFFF. + // Generate a real proof using this wrapped value. The validator must still reject it + // because 0xFFFFFFFFFFFFFFFE (remaining balance) is outside [0, maxMPTokenAmount]. + constexpr std::uint64_t underflowedAmt = + static_cast(0) - static_cast(1); + + Buffer const pcBf = generateBlindingFactor(); + Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(underflowedAmt, pcBf); + + auto const currentVersion = mptAlice.getMPTokenVersion(bob); + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + 1, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = underflowedAmt, + .encryptedAmt = underflowedCt, + .blindingFactor = pcBf, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 1, + .proof = proof, + .holderEncryptedAmt = underflowedCt, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + + // Supply invariant: both public and confidential balances must be unchanged + // after the rejected attack. + BEAST_EXPECT(mptAlice.getBalance(bob) == bobPublicBefore); + auto const bobSpendingAfter = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter); + } + void testWithFeats(FeatureBitset features) { @@ -5662,6 +5933,12 @@ class ConfidentialTransfer_test : public beast::unit_test::suite testConvertBackPedersenProof(features); testConvertBackBulletproof(features); + // Homomorphic operation tests + testSendHomomorphicOverflow(features); + testHomomorphicCiphertextModification(features); + testConvertBackHomomorphicUnderflow(features); + + // Replay Tests testMutatePrivacy(features); testProofContextBinding(features); testProofCiphertextBinding(features);