diff --git a/conan.lock b/conan.lock index a6eae8f27b0..f5a18bc8a55 100644 --- a/conan.lock +++ b/conan.lock @@ -12,7 +12,7 @@ "protobuf/6.33.5#d96d52ba5baaaa532f47bda866ad87a5%1774467363.12", "openssl/3.6.2#4789bbf131b77d0515d15e094c8f697f%1778071755.506", "nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1775040983.408", - "mpt-crypto/0.3.0-rc4#67142060d3e9b33cf49b980b1bfba40a%1781115459.717", + "mpt-crypto/0.3.0-rc5#536cb5bdf3cc7ce1b29373fa94d7eab9%1781638154.791", "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1765850143.914", "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1765842973.492", "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1765842973.03", @@ -42,12 +42,18 @@ ], "python_requires": [], "overrides": { + "openssl/[>=1.1 <4]": [ + "openssl/3.6.2" + ], "protobuf/[>=5.27.0 <7]": [ "protobuf/6.33.5" ], "lz4/1.9.4": [ "lz4/1.10.0" ], + "openssl/[>=3.5 <4]": [ + "openssl/3.6.2#4789bbf131b77d0515d15e094c8f697f" + ], "boost/[>=1.83.0 <1.91.0]": [ "boost/1.91.0" ], diff --git a/conanfile.py b/conanfile.py index 7c99670a159..9ef841fe76c 100644 --- a/conanfile.py +++ b/conanfile.py @@ -30,9 +30,8 @@ class Xrpl(ConanFile): "ed25519/2015.03", "grpc/1.78.1", "libarchive/3.8.7", - "mpt-crypto/0.3.0-rc4", + "mpt-crypto/0.3.0-rc5", "nudb/2.0.9", - "openssl/3.6.2", "secp256k1/0.7.1", "soci/4.0.3", "zlib/1.3.2", @@ -134,6 +133,7 @@ def requirements(self): self.requires("boost/1.91.0", force=True, transitive_headers=True) self.requires("date/3.0.4", transitive_headers=True) self.requires("lz4/1.10.0", force=True) + self.requires("openssl/3.6.2", force=True) self.requires("protobuf/6.33.5", force=True) self.requires("sqlite3/3.53.0", force=True) if self.options.jemalloc: diff --git a/cspell.config.yaml b/cspell.config.yaml index 27c40327cb0..7bf5a199652 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -241,8 +241,10 @@ words: - queuable - Raphson - replayer + - rerandomize - rerandomization - rerandomized + - rerandomizes - rerere - retriable - RIPD diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index fd16f08d8c4..c1d48550df1 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -200,6 +200,21 @@ homomorphicAdd(Slice const& a, Slice const& b); std::optional homomorphicSubtract(Slice const& a, Slice const& b); +/** + * @brief Re-randomizes an ElGamal ciphertext without changing its plaintext. + * + * Adds Enc(0; randomness) under the supplied public key to the ciphertext. + * This is used when a public, deterministic scalar must perturb ciphertext + * randomness while preserving ledger reproducibility. + * + * @param ciphertext The ciphertext to re-randomize (66 bytes). + * @param pubKeySlice The ElGamal public key matching the ciphertext recipient. + * @param randomness The scalar used as zero-encryption randomness (32 bytes). + * @return The re-randomized ciphertext, or std::nullopt on failure. + */ +std::optional +rerandomizeCiphertext(Slice const& ciphertext, Slice const& pubKeySlice, Slice const& randomness); + /** * @brief Encrypts an amount using ElGamal encryption. * diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index fa27508b7e0..5786820f741 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -220,6 +220,16 @@ homomorphicSubtract(Slice const& a, Slice const& b) return serializeEcPair(diff); } +std::optional +rerandomizeCiphertext(Slice const& ciphertext, Slice const& pubKeySlice, Slice const& randomness) +{ + auto zero = encryptAmount(0, pubKeySlice, randomness); + if (!zero) + return std::nullopt; + + return homomorphicAdd(ciphertext, *zero); +} + Buffer generateBlindingFactor() { diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp index b89a64e2910..e7e52b945ed 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -270,10 +271,11 @@ ConfidentialMPTSend::doApply() auto sleSenderMPToken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); auto sleDestinationMPToken = view().peek(keylet::mptoken(mptIssuanceID, destination)); + auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID)); auto const sleDestAcct = view().read(keylet::account(destination)); - if (!sleSenderMPToken || !sleDestinationMPToken || !sleDestAcct) + if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance || !sleDestAcct) return tecINTERNAL; // LCOV_EXCL_LINE // Deposit preauth authorization was already verified in preclaim. @@ -285,6 +287,8 @@ ConfidentialMPTSend::doApply() auto const senderEc = ctx_.tx[sfSenderEncryptedAmount]; auto const destEc = ctx_.tx[sfDestinationEncryptedAmount]; auto const issuerEc = ctx_.tx[sfIssuerEncryptedAmount]; + auto const proof = ctx_.tx[sfZKProof]; + Slice const sendChallenge{proof.data(), kEcBlindingFactorLength}; auto const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount]; @@ -321,8 +325,13 @@ ConfidentialMPTSend::doApply() // Add to destination's inbox balance { + auto rerandomizedDestEc = rerandomizeCiphertext( + destEc, (*sleDestinationMPToken)[sfHolderEncryptionKey], sendChallenge); + if (!rerandomizedDestEc) + return tecINTERNAL; // LCOV_EXCL_LINE + auto const curInbox = (*sleDestinationMPToken)[sfConfidentialBalanceInbox]; - auto newInbox = homomorphicAdd(curInbox, destEc); + auto newInbox = homomorphicAdd(curInbox, *rerandomizedDestEc); if (!newInbox) return tecINTERNAL; // LCOV_EXCL_LINE @@ -331,8 +340,13 @@ ConfidentialMPTSend::doApply() // Add to issuer's balance { + auto rerandomizedIssuerEc = + rerandomizeCiphertext(issuerEc, (*sleIssuance)[sfIssuerEncryptionKey], sendChallenge); + if (!rerandomizedIssuerEc) + return tecINTERNAL; // LCOV_EXCL_LINE + auto const curIssuerEnc = (*sleDestinationMPToken)[sfIssuerEncryptedBalance]; - auto newIssuerEnc = homomorphicAdd(curIssuerEnc, issuerEc); + auto newIssuerEnc = homomorphicAdd(curIssuerEnc, *rerandomizedIssuerEc); if (!newIssuerEnc) return tecINTERNAL; // LCOV_EXCL_LINE diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index de7ce0e391a..3b475bd0c20 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -28,10 +30,18 @@ #include #include #include +#include #include #include +#include +#include + +#include +#include + #include +#include #include #include #include @@ -40,13 +50,14 @@ #include #include #include +#include #include #include namespace xrpl { // NOLINTBEGIN(misc-const-correctness, bugprone-unchecked-optional-access) -class ConfidentialTransferTest : public ConfidentialTransferTestBase +class ConfidentialTransfer_test : public ConfidentialTransferTestBase { void testConvert(FeatureBitset features) @@ -7840,6 +7851,111 @@ class ConfidentialTransferTest : public ConfidentialTransferTestBase } } + void + testSendRerandomizesRecipientInboxAgainstMergeCancellation(FeatureBitset features) + { + testcase("Send: recipient inbox rerandomization prevents merge cancellation"); + + using namespace test::jtx; + Env env{*this, features}; + Account const alice("alice"), bob("bob"), 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, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Carol converts 50 and merge inbox + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + mptAlice.mergeInbox({.account = carol}); + + // Bob converts 20 and has not merged yet. His spending + // balance is the canonical zero Enc(0; r0), and his inbox uses the + // publicly revealed convert blinding factor. + Buffer const convertBlindingFactor = generateBlindingFactor(); + mptAlice.convert({ + .account = bob, + .amt = 20, + .holderPubKey = mptAlice.getPubKey(bob), + .blindingFactor = convertBlindingFactor, + }); + + // Derive the deterministic canonical-zero randomness r0 used for + // Bob's first spending balance. + auto getCanonicalZeroBlindingFactor = [](AccountID const& account, MPTID const& mptID) { + Buffer scalar(kEcBlindingFactorLength); + std::array hashInput{}; + std::memcpy(hashInput.data(), "EncZero", 7); + std::memcpy(hashInput.data() + 7, account.data(), account.size()); + std::memcpy(hashInput.data() + 27, mptID.data(), mptID.size()); + + for (;;) + { + unsigned int mdLen = kEcBlindingFactorLength; + if (EVP_Digest( + hashInput.data(), + hashInput.size(), + scalar.data(), + &mdLen, + EVP_sha256(), + nullptr) != 1) + { + Throw("Failed to derive canonical zero blinding factor"); + } + + if (secp256k1_ec_seckey_verify(mpt_secp256k1_context(), scalar.data())) + return scalar; + + std::memcpy(hashInput.data(), scalar.data(), scalar.size()); + } + }; + + // Pick randomness that would cancel Bob's MergeInbox C1 to infinity + // without receiver-side re-randomization. + auto negateScalarSum = [](Buffer const& lhs, Buffer const& rhs) { + Buffer sum(kEcBlindingFactorLength); + Buffer negated(kEcBlindingFactorLength); + secp256k1_mpt_scalar_add(sum.data(), lhs.data(), rhs.data()); + secp256k1_mpt_scalar_negate(negated.data(), sum.data()); + return negated; + }; + + Buffer const canonicalZeroBlindingFactor = + getCanonicalZeroBlindingFactor(bob.id(), mptAlice.issuanceID()); + Buffer const maliciousSendBlindingFactor = + negateScalarSum(canonicalZeroBlindingFactor, convertBlindingFactor); + + // This leaves Bob's inbox with randomness -r0, making + // MergeInbox hit the point at infinity. + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 5, + .blindingFactor = maliciousSendBlindingFactor, + }); + + mptAlice.mergeInbox({.account = bob}); + + auto const bobSpending = + mptAlice.getDecryptedBalance(bob, MPTTester::HolderEncryptedSpending); + BEAST_EXPECT(bobSpending && *bobSpending == 25); + } + void testWithFeats(FeatureBitset features) { @@ -7931,6 +8047,7 @@ class ConfidentialTransferTest : public ConfidentialTransferTestBase testSendCiphertextCombination(features); testSendCiphertextRerandomization(features); testSendZeroRandomnessCiphertext(features); + testSendRerandomizesRecipientInboxAgainstMergeCancellation(features); } public: @@ -7945,10 +8062,6 @@ class ConfidentialTransferTest : public ConfidentialTransferTestBase }; // NOLINTEND(misc-const-correctness, bugprone-unchecked-optional-access) -// TEMPORARILY DISABLED: the ConfidentialTransfer suite is unusably slow because -// the test harness verifies balances via ElGamal decryption, which brute-forces -// a discrete log over a fixed iteration range in mpt-crypto. Re-enable (uncomment) -// once mpt-crypto ships the faster baby-step-giant-step decryption. -// BEAST_DEFINE_TESTSUITE(ConfidentialTransfer, app, xrpl); +BEAST_DEFINE_TESTSUITE(ConfidentialTransfer, app, xrpl); } // namespace xrpl