Skip to content
Open
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
10 changes: 9 additions & 1 deletion src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,22 @@ ConfidentialMPTClawback::preclaim(PreclaimContext const& ctx)
if (!sleIssuance->isFlag(lsfMPTCanClawback))
return tecNO_PERMISSION;

// Check if issuance allows confidential transfer
if (!sleIssuance->isFlag(lsfMPTCanConfidentialAmount))
return tecNO_PERMISSION; // LCOV_EXCL_LINE

// Check holder's MPToken
auto const sleHolderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, holder));
if (!sleHolderMPToken)
return tecOBJECT_NOT_FOUND;

// Check if holder has confidential balances to claw back
if (!sleHolderMPToken->isFieldPresent(sfIssuerEncryptedBalance))
return tecNO_PERMISSION;
return tecNO_PERMISSION; // LCOV_EXCL_LINE

// Check if Holder has ElGamal public Key
if (!sleHolderMPToken->isFieldPresent(sfHolderEncryptionKey))
return tecNO_PERMISSION; // LCOV_EXCL_LINE

// Sanity check: claw amount can not exceed confidential outstanding amount
auto const amount = ctx.tx[sfMPTAmount];
Expand Down
218 changes: 218 additions & 0 deletions src/test/app/ConfidentialTransfer_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2734,9 +2734,143 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
});
}

// send when spending balance is 0 (key registered, inbox merged, but nothing converted)
{
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 = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount,
});
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)});

// Register keys only (amt=0) for both parties, then merge — spending stays 0.
mptAlice.convert({.account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob)});
mptAlice.mergeInbox({.account = bob});
mptAlice.convert(
{.account = carol, .amt = 0, .holderPubKey = mptAlice.getPubKey(carol)});

BEAST_EXPECT(
mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 0);

// Trying to send any amount with 0 spending balance must fail:
// the range proof for < 0 is invalid.
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 1,
.err = tecBAD_PROOF,
});

BEAST_EXPECT(
mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 0);
}

// todo: test m exceeding range, require using scala and refactor
}

/* TODO: uncomment when MPT crypto supports proof generation with value 0
* Tests verifier behavior when the send amount is 0.
*
* The equality proof library and range proof library do not
* support generating proofs for amt=0 (they require a positive witness).
* To test the VERIFIER without crashing the helper, we bypass normal proof
* generation by supplying explicit ciphertexts, commitments, and a dummy
* (all-zero) proof. The preflight has no temBAD_AMOUNT guard for
* ConfidentialMPTSend, so all validation occurs in verifySendProofs.
*/
/*void
testSendZeroAmount(FeatureBitset features)
{
testcase("Send: zero amount — equality and range proof verifier behavior");
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 = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount,
});
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});

Buffer const bf = generateBlindingFactor();

// equality proof verification for amt=0.
// Encrypt 0 under each participant's key. The amount commitment is
// getTrivialCommitment() — a valid EC point that passes preflight's
// isValidCompressedECPoint check but is not the true PC for amt=0.
// The dummy ZKProof's equality component must be rejected by
// verifyMultiCiphertextEqualityProof.
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 0,
.proof = getTrivialSendProofHex(3),
.senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf),
.destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf),
.issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = tecBAD_PROOF,
});

// range proof verification for amt=0.
// Identical construction; focuses on the bulletproof range check
// embedded in ZKProof. The range proof for amount=0 with a dummy
// (all-zero) proof must also be rejected.
Buffer const bf2 = generateBlindingFactor();
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 0,
.proof = getTrivialSendProofHex(3),
.senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf2),
.destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf2),
.issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf2),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = tecBAD_PROOF,
});

// All rejected sends must leave balances unchanged.
BEAST_EXPECT(
mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100);
BEAST_EXPECT(
mptAlice.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0);
}*/

void
testDelete(FeatureBitset features)
{
Expand Down Expand Up @@ -6302,6 +6436,87 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter);
}

/* This test ensures that when sending confidential tokens, the encrypted
* amounts are securely locked to the correct accounts' official public keys.
*
* Attack scenario — Encrypting the issuer's copy with the wrong key:
* A sender correctly encrypts the hidden transfer amount for themselves
* and the receiver. However, they intentionally encrypt the issuer's
* copy of the data using the wrong public key (for example, using the
* receiver's key instead of the official issuer's key). */
void
testSendWrongIssuerPublicKey(FeatureBitset features)
{
testcase("Send: issuer ciphertext encrypted under wrong public key");
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 = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount});
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});

auto const bobSpendingBefore =
mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING);
BEAST_EXPECT(bobSpendingBefore == 100);

// issuer ciphertext encrypted under carol's holder key
// (should be under alice's registered issuer key).
{
Buffer const bf = generateBlindingFactor();
Buffer const wrongIssuerCt = mptAlice.encryptAmount(carol, 10, bf);

mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.issuerEncryptedAmt = wrongIssuerCt,
.err = tecBAD_PROOF,
});
}

// issuer ciphertext encrypted under bob's holder key
// (the sender's own key — still not the registered issuer key).
{
Buffer const bf = generateBlindingFactor();
Buffer const wrongIssuerCt = mptAlice.encryptAmount(bob, 10, bf);

mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.issuerEncryptedAmt = wrongIssuerCt,
.err = tecBAD_PROOF,
});
}

// all balances unchanged
BEAST_EXPECT(
mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) ==
bobSpendingBefore);
BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0);
}

// Exercises every Confidential Transfer transaction type (MPTokenIssuanceSet,
// Convert, MergeInbox, Send, ConvertBack) using tickets instead of regular account
// sequence numbers.
Expand Down Expand Up @@ -6531,6 +6746,7 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
testSendPreflight(features);
testSendPreclaim(features);
testSendRangeProof(features);
// testSendZeroAmount(features);
testSendDepositPreauth(features);
testSendCredentialValidation(features);
testSendWithAuditor(features);
Expand All @@ -6557,6 +6773,8 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
testHomomorphicCiphertextModification(features);
testConvertBackHomomorphicUnderflow(features);

testSendWrongIssuerPublicKey(features);

// Replay Tests
testMutatePrivacy(features);
testProofContextBinding(features);
Expand Down
8 changes: 4 additions & 4 deletions src/test/jtx/impl/mpt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1359,9 +1359,9 @@ MPTTester::send(MPTConfidentialSend const& arg)

// Skip proof generation if encrypted balance is missing (e.g.,
// feature disabled), or when the sender and destination are the
// same (malformed case causing pcm to be zero). This prevents a
// crash and allows certain error cases to be tested.
if (arg.account != arg.dest && prevEncryptedSenderSpending)
// same (malformed case causing pcm to be zero), or when spending
// balance is 0 (getBalanceLinkageProof throws for zero balance).
if (arg.account != arg.dest && prevEncryptedSenderSpending && *prevSenderSpending > 0)
{
proof = getConfidentialSendProof(
*arg.account,
Expand All @@ -1384,7 +1384,7 @@ MPTTester::send(MPTConfidentialSend const& arg)
jv[sfZKProof.jsonName] = strHex(*proof);
else
{
size_t const sizeEquality = secp256k1_mpt_prove_same_plaintext_multi_size(nRecipients);
size_t const sizeEquality = secp256k1_mpt_proof_equality_shared_r_size(nRecipients);
size_t const dummySize =
sizeEquality + 2 * ecPedersenProofLength + ecDoubleBulletproofLength;

Expand Down
Loading