diff --git a/doc/release-notes-265.md b/doc/release-notes-265.md new file mode 100644 index 0000000000..b578612a8b --- /dev/null +++ b/doc/release-notes-265.md @@ -0,0 +1,22 @@ +P2MR data-signature checks use the signature cache +================================================== + +`OP_CHECKDATASIGPQC` and `OP_CHECKDATASIGADDPQC` checks now use the +signature cache, like P2MR transaction signatures since #162. Previously every +data-signature check ran a full SLH-DSA verification. A data signature does not +commit to the spending transaction, so a peer could resubmit one valid +signature in an unlimited number of mutated transactions, each costing a full +verification during mempool admission. Such replays now cost one verification. + +Only verified successes are cached, in a separately salted domain, so an entry +for a transaction signature can never satisfy a data-signature check or the +reverse. Validity, validation weight and script errors are unchanged; this is +not a consensus change. + +Known limitation: failures are not cached. Anyone holding one valid signature +can derive unlimited distinct invalid PQC signatures that each still cost a +full verification, on both the data-signature and transaction-signature paths. +Because this release does not penalize peers that relay consensus-invalid +transactions, a peer can cost a node about 0.5 µs of message-handling and +`cs_main` time per witness byte. Discouraging peers whose P2MR PQC signatures +fail, or budgeting script-validation cost per peer, are possible follow-ups. diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index c1b00b109d..e76d64c78f 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -434,7 +434,7 @@ static bool EvalChecksigP2MR(const valtype& sig, const valtype& pubkey, ScriptEx return true; } -static bool EvalCheckDataSigPQC(const valtype& sig, const valtype& msg_hash, const valtype& pubkey, ScriptExecutionData& execdata, unsigned int flags, ScriptError* serror, bool& success) +static bool EvalCheckDataSigPQC(const valtype& sig, const valtype& msg_hash, const valtype& pubkey, ScriptExecutionData& execdata, unsigned int flags, const BaseSignatureChecker& checker, ScriptError* serror, bool& success) { success = !sig.empty(); if (success) { @@ -464,7 +464,7 @@ static bool EvalCheckDataSigPQC(const valtype& sig, const valtype& msg_hash, con const CPQCPubKey pqc_pubkey{pubkey}; const uint256 datasig_hash = ComputeQbitDataSigPQCHash(msg_hash); - if (!pqc_pubkey.Verify(datasig_hash, sig)) { + if (!checker.VerifyPQCDataSignature(sig, pqc_pubkey, datasig_hash)) { return set_error(serror, SCRIPT_ERR_P2MR_SIG); } return true; @@ -1238,7 +1238,7 @@ bool EvalScript(std::vector >& stack, const CScript& const valtype& pubkey = stacktop(-1); bool success{false}; - if (!EvalCheckDataSigPQC(sig, msg_hash, pubkey, execdata, flags, serror, success)) return false; + if (!EvalCheckDataSigPQC(sig, msg_hash, pubkey, execdata, flags, checker, serror, success)) return false; popstack(stack); popstack(stack); popstack(stack); @@ -1259,7 +1259,7 @@ bool EvalScript(std::vector >& stack, const CScript& const valtype& pubkey = stacktop(-1); bool success{false}; - if (!EvalCheckDataSigPQC(sig, msg_hash, pubkey, execdata, flags, serror, success)) return false; + if (!EvalCheckDataSigPQC(sig, msg_hash, pubkey, execdata, flags, checker, serror, success)) return false; popstack(stack); popstack(stack); popstack(stack); @@ -1997,6 +1997,11 @@ bool GenericTransactionSignatureChecker::CheckSchnorrSignature(std::span sig, const CPQCPubKey& pubkey, const uint256& datasig_hash) const +{ + return pubkey.Verify(datasig_hash, sig); +} + template bool GenericTransactionSignatureChecker::VerifyPQCSignature(std::span sig, const CPQCPubKey& pubkey, const uint256& sighash) const { diff --git a/src/script/interpreter.h b/src/script/interpreter.h index 33e23e0f99..f16b2fa21c 100644 --- a/src/script/interpreter.h +++ b/src/script/interpreter.h @@ -307,6 +307,12 @@ class BaseSignatureChecker return false; } + /** Verify an OP_CHECKDATASIGPQC/OP_CHECKDATASIGADDPQC signature over its + * QbitDataSigPQC digest. The interpreter performs every weight, size and + * digest step first; this only decides whether the signature is valid. + * The default runs the primitive verification. */ + virtual bool VerifyPQCDataSignature(std::span sig, const CPQCPubKey& pubkey, const uint256& datasig_hash) const; + virtual bool CheckTemplateVerifyHash(std::span expected, ScriptError* serror = nullptr) const { return false; @@ -392,6 +398,11 @@ class DeferringSignatureChecker : public BaseSignatureChecker return m_checker.CheckPQCSignature(sig, pubkey, sigversion, execdata, serror); } + bool VerifyPQCDataSignature(std::span sig, const CPQCPubKey& pubkey, const uint256& datasig_hash) const override + { + return m_checker.VerifyPQCDataSignature(sig, pubkey, datasig_hash); + } + bool CheckTemplateVerifyHash(std::span expected, ScriptError* serror = nullptr) const override { return m_checker.CheckTemplateVerifyHash(expected, serror); diff --git a/src/script/sigcache.cpp b/src/script/sigcache.cpp index ecc830cd43..4f696fa61b 100644 --- a/src/script/sigcache.cpp +++ b/src/script/sigcache.cpp @@ -24,16 +24,20 @@ SignatureCache::SignatureCache(const size_t max_size_bytes) // We want the nonce to be 64 bytes long to force the hasher to process // this chunk, which makes later hash computations more efficient. We // just write our 32-byte entropy, and then pad with 'E' for ECDSA, - // 'S' for Schnorr and 'P' for PQC (followed by 0 bytes). + // 'S' for Schnorr, 'P' for PQC transaction signatures and 'D' for PQC + // data signatures (followed by 0 bytes). static constexpr unsigned char PADDING_ECDSA[32] = {'E'}; static constexpr unsigned char PADDING_SCHNORR[32] = {'S'}; static constexpr unsigned char PADDING_PQC[32] = {'P'}; + static constexpr unsigned char PADDING_PQC_DATA[32] = {'D'}; m_salted_hasher_ecdsa.Write(nonce.begin(), 32); m_salted_hasher_ecdsa.Write(PADDING_ECDSA, 32); m_salted_hasher_schnorr.Write(nonce.begin(), 32); m_salted_hasher_schnorr.Write(PADDING_SCHNORR, 32); m_salted_hasher_pqc.Write(nonce.begin(), 32); m_salted_hasher_pqc.Write(PADDING_PQC, 32); + m_salted_hasher_pqc_data.Write(nonce.begin(), 32); + m_salted_hasher_pqc_data.Write(PADDING_PQC_DATA, 32); const auto [num_elems, approx_size_bytes] = setValid.setup_bytes(max_size_bytes); LogInfo("Using %zu MiB out of %zu MiB requested for signature cache, able to store %zu elements", @@ -58,6 +62,12 @@ void SignatureCache::ComputeEntryPQC(uint256& entry, const uint256& hash, std::s hasher.Write(hash.begin(), 32).Write(pubkey.data(), pubkey.size()).Write(sig.data(), sig.size()).Finalize(entry.begin()); } +void SignatureCache::ComputeEntryPQCData(uint256& entry, const uint256& datasig_hash, std::span sig, const CPQCPubKey& pubkey) const +{ + CSHA256 hasher = m_salted_hasher_pqc_data; + hasher.Write(datasig_hash.begin(), 32).Write(pubkey.data(), pubkey.size()).Write(sig.data(), sig.size()).Finalize(entry.begin()); +} + bool SignatureCache::Get(const uint256& entry, const bool erase) { std::shared_lock lock(cs_sigcache); @@ -108,3 +118,25 @@ bool CachingTransactionSignatureChecker::VerifyPQCSignature(std::spanVerified(valid, insert); return valid; } + +bool CachingTransactionSignatureChecker::VerifyPQCDataSignature(std::span sig, const CPQCPubKey& pubkey, const uint256& datasig_hash) const +{ + // The interpreter only passes valid keys and exact-size signatures. Any + // other input fails without a backend call, and an invalid key adds no + // bytes to the entry, so keep such inputs out of the cache entirely. + if (!pubkey.IsValid() || sig.size() != PQC_SIG_SIZE) { + return TransactionSignatureChecker::VerifyPQCDataSignature(sig, pubkey, datasig_hash); + } + uint256 entry; + m_signature_cache.ComputeEntryPQCData(entry, datasig_hash, sig, pubkey); + PQCSignatureCacheObserver* const observer{m_signature_cache.GetPQCObserver()}; + const bool hit{m_signature_cache.Get(entry, !store)}; + if (observer) observer->Lookup(hit, !store); + if (hit) return true; + const bool valid{TransactionSignatureChecker::VerifyPQCDataSignature(sig, pubkey, datasig_hash)}; + // Only a verified success is published to the cache. + const bool insert{valid && store}; + if (insert) m_signature_cache.Set(entry); + if (observer) observer->Verified(valid, insert); + return valid; +} diff --git a/src/script/sigcache.h b/src/script/sigcache.h index 004f9a7d45..5a32dc2934 100644 --- a/src/script/sigcache.h +++ b/src/script/sigcache.h @@ -33,14 +33,14 @@ static constexpr size_t DEFAULT_SCRIPT_EXECUTION_CACHE_BYTES{DEFAULT_VALIDATION_ static_assert(DEFAULT_VALIDATION_CACHE_BYTES == DEFAULT_SIGNATURE_CACHE_BYTES + DEFAULT_SCRIPT_EXECUTION_CACHE_BYTES); /** - * Test-only observer of PQC transaction-signature cache activity. Production - * code never installs one; it must not influence validation results. + * Test-only observer of PQC transaction- and data-signature cache activity. + * Production code never installs one; it must not influence validation results. */ class PQCSignatureCacheObserver { public: virtual ~PQCSignatureCacheObserver() = default; - //! Called once per PQC transaction-signature cache lookup. + //! Called once per PQC transaction- or data-signature cache lookup. virtual void Lookup(bool hit, bool erase) = 0; //! Called once per primitive PQC verification performed after a cache miss. virtual void Verified(bool valid, bool inserted) = 0; @@ -54,10 +54,12 @@ class PQCSignatureCacheObserver class SignatureCache { private: - //! Entries are SHA256(nonce || 'E', 'S' or 'P' || 31 zero bytes || signature hash || public key || signature): + //! Entries are SHA256(nonce || 'E', 'S', 'P' or 'D' || 31 zero bytes || signature hash || public key || signature), + //! where 'P' marks PQC transaction signatures and 'D' PQC data signatures (OP_CHECKDATASIG(ADD)PQC): CSHA256 m_salted_hasher_ecdsa; CSHA256 m_salted_hasher_schnorr; CSHA256 m_salted_hasher_pqc; + CSHA256 m_salted_hasher_pqc_data; typedef CuckooCache::cache map_type; map_type setValid; std::shared_mutex cs_sigcache; @@ -75,6 +77,8 @@ class SignatureCache void ComputeEntryPQC(uint256& entry, const uint256& hash, std::span sig, const CPQCPubKey& pubkey) const; + void ComputeEntryPQCData(uint256& entry, const uint256& datasig_hash, std::span sig, const CPQCPubKey& pubkey) const; + bool Get(const uint256& entry, const bool erase); void Set(const uint256& entry); @@ -96,6 +100,7 @@ class CachingTransactionSignatureChecker : public TransactionSignatureChecker bool VerifyECDSASignature(const std::vector& vchSig, const CPubKey& vchPubKey, const uint256& sighash) const override; bool VerifySchnorrSignature(std::span sig, const XOnlyPubKey& pubkey, const uint256& sighash) const override; bool VerifyPQCSignature(std::span sig, const CPQCPubKey& pubkey, const uint256& sighash) const override; + bool VerifyPQCDataSignature(std::span sig, const CPQCPubKey& pubkey, const uint256& datasig_hash) const override; }; #endif // QBIT_SCRIPT_SIGCACHE_H diff --git a/src/test/pqc_sigcache_tests.cpp b/src/test/pqc_sigcache_tests.cpp index ff82bd1f28..e90436aafa 100644 --- a/src/test/pqc_sigcache_tests.cpp +++ b/src/test/pqc_sigcache_tests.cpp @@ -144,6 +144,8 @@ class UncachedChecker final : public TransactionSignatureChecker using TransactionSignatureChecker::VerifyPQCSignature; }; +//! A PQC verification input. For data signatures, sighash holds the +//! QbitDataSigPQC digest of the message hash. struct PQCTuple { uint256 sighash; CPQCPubKey pubkey; @@ -157,6 +159,34 @@ uint256 EntryPQC(const SignatureCache& cache, const PQCTuple& tuple) return entry; } +uint256 EntryPQCData(const SignatureCache& cache, const PQCTuple& tuple) +{ + uint256 entry; + cache.ComputeEntryPQCData(entry, tuple.sighash, tuple.sig, tuple.pubkey); + return entry; +} + +//! A fresh key's OP_CHECKDATASIGPQC signature over a message hash. +struct DataSignature { + uint256 msg_hash; + PQCTuple tuple; +}; + +DataSignature MakeDataSignature(const std::string& message) +{ + CPQCKey key; + key.MakeNewKey(); + BOOST_REQUIRE(key.IsValid()); + const uint256 msg_hash{(HashWriter{} << message).GetSHA256()}; + const uint256 datasig_hash{ComputeQbitDataSigPQCHash(msg_hash)}; + valtype sig; + uint32_t counter{0}; + BOOST_REQUIRE(key.Sign(datasig_hash, sig, counter)); + PQCTuple tuple{datasig_hash, key.GetPubKey(), std::move(sig)}; + BOOST_REQUIRE(tuple.pubkey.Verify(tuple.sighash, tuple.sig)); + return {msg_hash, std::move(tuple)}; +} + valtype FlipFirstByte(valtype bytes) { BOOST_REQUIRE(!bytes.empty()); @@ -738,12 +768,15 @@ BOOST_AUTO_TEST_CASE(concurrent_checks_of_one_signature) { // Script-check threads can verify one signature at the same time, and // block connection (consume) can overlap mempool admission (store) on the - // same cache. The assertions below hold for every interleaving; none - // depends on timing. Runtime is bounded by 2 * THREADS * ROUNDS primitive + // same cache. Transaction and data signatures share that cache in separate + // domains. The assertions below hold for every interleaving; none depends + // on timing. Runtime is bounded by 4 * THREADS * ROUNDS primitive // verifications. const std::vector vectors{LoadWitnessVectors()}; const PQCTuple valid{AcceptedTuple(FindVector(vectors, "single_key_default_sighash"))}; const PQCTuple invalid{valid.sighash, valid.pubkey, FlipFirstByte(valid.sig)}; + const PQCTuple data_valid{MakeDataSignature("pqc sigcache concurrent data signature").tuple}; + const PQCTuple data_invalid{data_valid.sighash, data_valid.pubkey, FlipFirstByte(data_valid.sig)}; constexpr size_t THREADS{8}; constexpr size_t ROUNDS{3}; constexpr size_t CHECKS{THREADS * ROUNDS}; @@ -752,6 +785,8 @@ BOOST_AUTO_TEST_CASE(concurrent_checks_of_one_signature) ObservedCache observed{1 << 20}; std::atomic valid_accepted{0}; std::atomic invalid_accepted{0}; + std::atomic data_valid_accepted{0}; + std::atomic data_invalid_accepted{0}; uint64_t backend_calls{0}; { ScopedPQCVerificationCounter verifications; @@ -762,6 +797,8 @@ BOOST_AUTO_TEST_CASE(concurrent_checks_of_one_signature) for (size_t r{0}; r < ROUNDS; ++r) { valid_accepted += checker.VerifyPQCSignature(valid.sig, valid.pubkey, valid.sighash); invalid_accepted += checker.VerifyPQCSignature(invalid.sig, invalid.pubkey, invalid.sighash); + data_valid_accepted += checker.VerifyPQCDataSignature(data_valid.sig, data_valid.pubkey, data_valid.sighash); + data_invalid_accepted += checker.VerifyPQCDataSignature(data_invalid.sig, data_invalid.pubkey, data_invalid.sighash); } }); } @@ -779,38 +816,39 @@ BOOST_AUTO_TEST_CASE(concurrent_checks_of_one_signature) BOOST_CHECK_EQUAL(valid_accepted.load(), CHECKS); BOOST_CHECK_EQUAL(invalid_accepted.load(), 0U); - // No false hit: every check of the invalid tuple missed and failed in the backend. - BOOST_CHECK_EQUAL(verified_fail, CHECKS); - // Every check of the valid tuple either hit or was verified, at least once. - BOOST_CHECK_EQUAL(hits + verified_ok, CHECKS); - BOOST_CHECK_GE(verified_ok, 1U); + BOOST_CHECK_EQUAL(data_valid_accepted.load(), CHECKS); + BOOST_CHECK_EQUAL(data_invalid_accepted.load(), 0U); + // No false hit: every check of an invalid tuple missed and failed in the backend. + BOOST_CHECK_EQUAL(verified_fail, 2 * CHECKS); + // Every check of a valid tuple either hit or was verified, and each + // valid tuple was verified at least once. + BOOST_CHECK_EQUAL(hits + verified_ok, 2 * CHECKS); + BOOST_CHECK_GE(verified_ok, 2U); // Each lookup is a hit or a miss, and each miss reaches the backend exactly once. BOOST_CHECK_EQUAL(events.size(), hits + misses + verified_ok + verified_fail); - BOOST_CHECK_EQUAL(hits + misses, 2 * CHECKS); + BOOST_CHECK_EQUAL(hits + misses, 4 * CHECKS); BOOST_CHECK_EQUAL(misses, verified_ok + verified_fail); BOOST_CHECK_EQUAL(backend_calls, misses); - // Racing stores leave the valid tuple cached and never the invalid one. + // Racing stores leave each valid tuple cached in its own domain only, and + // never an invalid one. BOOST_CHECK(observed.cache.Get(EntryPQC(observed.cache, valid), /*erase=*/false)); BOOST_CHECK(!observed.cache.Get(EntryPQC(observed.cache, invalid), /*erase=*/false)); + BOOST_CHECK(observed.cache.Get(EntryPQCData(observed.cache, data_valid), /*erase=*/false)); + BOOST_CHECK(!observed.cache.Get(EntryPQCData(observed.cache, data_invalid), /*erase=*/false)); + BOOST_CHECK(!observed.cache.Get(EntryPQCData(observed.cache, valid), /*erase=*/false)); + BOOST_CHECK(!observed.cache.Get(EntryPQC(observed.cache, data_valid), /*erase=*/false)); } -BOOST_AUTO_TEST_CASE(data_signatures_bypass_the_cache) +BOOST_AUTO_TEST_CASE(data_signatures_use_their_own_cache_domain) { - // The cache is scoped to transaction signatures. OP_CHECKDATASIGPQC calls - // the primitive directly: under a caching checker it emits no cache - // events, stores nothing and cannot be satisfied by a cache entry. - CPQCKey key; - key.MakeNewKey(); - BOOST_REQUIRE(key.IsValid()); - const CPQCPubKey pubkey{key.GetPubKey()}; - const uint256 msg_hash{(HashWriter{} << std::string{"pqc sigcache data signature"}).GetSHA256()}; - const uint256 datasig_hash{ComputeQbitDataSigPQCHash(msg_hash)}; - valtype sig; - uint32_t counter{0}; - BOOST_REQUIRE(key.Sign(datasig_hash, sig, counter)); - const valtype invalid_sig{FlipFirstByte(sig)}; + // OP_CHECKDATASIGPQC caches verified successes with the transaction + // signatures' store/consume rules, in a separately salted domain: an + // entry in either domain never satisfies a check in the other. + const DataSignature data_sig{MakeDataSignature("pqc sigcache data signature")}; + const PQCTuple& valid{data_sig.tuple}; + const PQCTuple invalid{valid.sighash, valid.pubkey, FlipFirstByte(valid.sig)}; - const CScript leaf_script{CScript{} << valtype(msg_hash.begin(), msg_hash.end()) << valtype(pubkey.begin(), pubkey.end()) << OP_CHECKDATASIGPQC}; + const CScript leaf_script{CScript{} << valtype(data_sig.msg_hash.begin(), data_sig.msg_hash.end()) << valtype(valid.pubkey.begin(), valid.pubkey.end()) << OP_CHECKDATASIGPQC}; const valtype leaf_bytes(leaf_script.begin(), leaf_script.end()); const valtype control_block{static_cast(P2MR_LEAF_VERSION_V1 | 1)}; @@ -833,27 +871,210 @@ BOOST_AUTO_TEST_CASE(data_signatures_bypass_the_cache) ObservedCache observed{1 << 20}; const auto uncached_checker{ctx.Uncached()}; const auto caching_checker{ctx.Caching(observed.cache, /*store=*/true)}; + const auto check = [&](const valtype& witness_sig, const EvalOutcome& expected, uint64_t expected_verifications, + const std::vector& expected_events, const std::string& label) { + ScopedPQCVerificationCounter verifications; + BOOST_CHECK_MESSAGE(eval(witness_sig, caching_checker) == expected, label); + BOOST_CHECK_MESSAGE(verifications.GetCount() == expected_verifications, label << " backend calls " << verifications.GetCount()); + observed.CheckEvents(expected_events, label); + }; - const EvalOutcome valid_uncached{eval(sig, uncached_checker)}; + // A verified success is stored in the data domain only, and reused. + const EvalOutcome valid_uncached{eval(valid.sig, uncached_checker)}; BOOST_CHECK(valid_uncached.outcome == (ScriptOutcome{true, SCRIPT_ERR_OK})); + check(valid.sig, valid_uncached, 1, {Miss(false), Ok(true)}, "cold valid data signature"); + check(valid.sig, valid_uncached, 0, {Hit(false)}, "warm valid data signature"); + BOOST_CHECK(observed.cache.Get(EntryPQCData(observed.cache, valid), /*erase=*/false)); + BOOST_CHECK(!observed.cache.Get(EntryPQC(observed.cache, valid), /*erase=*/false)); + + // A failure is never stored and is verified again on every check. + const EvalOutcome invalid_uncached{eval(invalid.sig, uncached_checker)}; + BOOST_CHECK(invalid_uncached.outcome == (ScriptOutcome{false, SCRIPT_ERR_P2MR_SIG})); for (int i{0}; i < 2; ++i) { + check(invalid.sig, invalid_uncached, 1, {Miss(false), Fail()}, strprintf("invalid data signature %d", i)); + } + BOOST_CHECK(!observed.cache.Get(EntryPQCData(observed.cache, invalid), /*erase=*/false)); + + // Bind every byte of each component, including changes that preserve a + // long prefix of the cached valid tuple. + std::vector> variants; + for (const bool last : {false, true}) { + PQCTuple hash_variant{valid}; + hash_variant.sighash.begin()[last ? valid.sighash.size() - 1 : valid.sighash.size() / 2] ^= 1; + variants.emplace_back(last ? "last digest byte" : "middle digest byte", hash_variant); + valtype pubkey_bytes(valid.pubkey.begin(), valid.pubkey.end()); + pubkey_bytes[last ? pubkey_bytes.size() - 1 : pubkey_bytes.size() / 2] ^= 1; + variants.emplace_back(last ? "last pubkey byte" : "middle pubkey byte", PQCTuple{valid.sighash, CPQCPubKey{pubkey_bytes}, valid.sig}); + PQCTuple sig_variant{valid}; + sig_variant.sig[last ? valid.sig.size() - 1 : valid.sig.size() / 2] ^= 1; + variants.emplace_back(last ? "last signature byte" : "middle signature byte", sig_variant); + } + for (const auto& [label, variant] : variants) { + BOOST_CHECK_MESSAGE(EntryPQCData(observed.cache, variant) != EntryPQCData(observed.cache, valid), label); ScopedPQCVerificationCounter verifications; - BOOST_CHECK(eval(sig, caching_checker) == valid_uncached); - BOOST_CHECK_EQUAL(verifications.GetCount(), 1U); - observed.CheckEvents({}, "valid data signature"); + BOOST_CHECK_MESSAGE(!caching_checker.VerifyPQCDataSignature(variant.sig, variant.pubkey, variant.sighash), label); + BOOST_CHECK_MESSAGE(verifications.GetCount() == 1U, label << " backend calls " << verifications.GetCount()); + observed.CheckEvents({Miss(false), Fail()}, label); } - BOOST_CHECK(!observed.cache.Get(EntryPQC(observed.cache, {datasig_hash, pubkey, sig}), /*erase=*/false)); - // Prime the transaction-signature entry for the failing tuple. A data - // signature check that consulted the cache would now succeed. - const EvalOutcome invalid_uncached{eval(invalid_sig, uncached_checker)}; - BOOST_CHECK(invalid_uncached.outcome == (ScriptOutcome{false, SCRIPT_ERR_P2MR_SIG})); - observed.cache.Set(EntryPQC(observed.cache, {datasig_hash, pubkey, invalid_sig})); + // The data domain differs from every other domain hashing identical bytes + // (see key_components_and_domain for the ECDSA construction). + uint256 schnorr_entry; + observed.cache.ComputeEntrySchnorr(schnorr_entry, valid.sighash, valid.sig, + XOnlyPubKey{std::span{valid.pubkey.data(), valid.pubkey.size()}}); + valtype ecdsa_sig(valid.pubkey.begin(), valid.pubkey.end()); + ecdsa_sig.insert(ecdsa_sig.end(), valid.sig.begin(), valid.sig.end()); + uint256 ecdsa_entry; + observed.cache.ComputeEntryECDSA(ecdsa_entry, valid.sighash, ecdsa_sig, CPubKey{}); + const uint256 data_entry{EntryPQCData(observed.cache, valid)}; + BOOST_CHECK(data_entry != EntryPQC(observed.cache, valid)); + BOOST_CHECK(data_entry != schnorr_entry); + BOOST_CHECK(data_entry != ecdsa_entry); + + // Prime a transaction-signature entry for the failing tuple. A data + // signature check that consulted that domain would now succeed. The + // transaction-signature check does hit it, so the entry is live. + observed.cache.Set(EntryPQC(observed.cache, invalid)); + check(invalid.sig, invalid_uncached, 1, {Miss(false), Fail()}, "invalid data signature with a primed transaction-signature entry"); + BOOST_CHECK(caching_checker.VerifyPQCSignature(invalid.sig, invalid.pubkey, invalid.sighash)); + observed.CheckEvents({Hit(false)}, "primed transaction-signature entry"); + + // Conversely, a data-signature entry never satisfies a transaction + // signature check, while the data-signature check does hit it. + PQCTuple planted{valid}; + planted.sig.back() ^= 1; + observed.cache.Set(EntryPQCData(observed.cache, planted)); { ScopedPQCVerificationCounter verifications; - BOOST_CHECK(eval(invalid_sig, caching_checker) == invalid_uncached); + BOOST_CHECK(!caching_checker.VerifyPQCSignature(planted.sig, planted.pubkey, planted.sighash)); BOOST_CHECK_EQUAL(verifications.GetCount(), 1U); - observed.CheckEvents({}, "invalid data signature with a primed entry"); + observed.CheckEvents({Miss(false), Fail()}, "transaction signature with a primed data-signature entry"); + } + check(planted.sig, valid_uncached, 0, {Hit(false)}, "primed data-signature entry"); + + // Inputs the interpreter never passes skip the cache without a backend + // call. An invalid key contributes no bytes, so the key-prefixed + // signature below hashes to the cached valid entry. + valtype shifted_sig(valid.pubkey.begin(), valid.pubkey.end()); + shifted_sig.insert(shifted_sig.end(), valid.sig.begin(), valid.sig.end()); + BOOST_REQUIRE(EntryPQCData(observed.cache, {valid.sighash, CPQCPubKey{}, shifted_sig}) == data_entry); + const valtype short_sig(valid.sig.begin(), valid.sig.end() - 1); + { + ScopedPQCVerificationCounter verifications; + BOOST_CHECK(!caching_checker.VerifyPQCDataSignature(shifted_sig, CPQCPubKey{}, valid.sighash)); + BOOST_CHECK(!caching_checker.VerifyPQCDataSignature(short_sig, valid.pubkey, valid.sighash)); + BOOST_CHECK_EQUAL(verifications.GetCount(), 0U); + observed.CheckEvents({}, "malformed inputs bypass the cache"); + } +} + +BOOST_AUTO_TEST_CASE(data_signature_replays_verify_once) +{ + // A valid data signature in a leaf that fails anyway: the signature check + // succeeds after a full verification, and OP_NOT then fails the input. + // The data-signature digest does not depend on the transaction, so every + // mutated spend replays the same tuple. In store mode (mempool admission) + // only the first replay reaches the backend, while the uncached checker + // pays for every one. Results, errors and validation weight are unchanged. + const DataSignature data_sig{MakeDataSignature("pqc sigcache failing leaf")}; + const PQCTuple& tuple{data_sig.tuple}; + const valtype msg(data_sig.msg_hash.begin(), data_sig.msg_hash.end()); + const valtype pubkey(tuple.pubkey.begin(), tuple.pubkey.end()); + const std::vector> leaves{ + {"CHECKDATASIGPQC", CScript{} << msg << pubkey << OP_CHECKDATASIGPQC << OP_NOT}, + {"CHECKDATASIGADDPQC", CScript{} << msg << OP_0 << pubkey << OP_CHECKDATASIGADDPQC << OP_NOT}, + }; + constexpr uint32_t REPLAYS{8}; + const ScriptOutcome eval_false{false, SCRIPT_ERR_EVAL_FALSE}; + + for (const auto& [name, leaf_script] : leaves) { + const valtype leaf_bytes(leaf_script.begin(), leaf_script.end()); + const valtype control_block{static_cast(P2MR_LEAF_VERSION_V1 | 1)}; + const uint256 merkle_root{ComputeP2MRMerkleRoot(control_block, ComputeP2MRLeafHash(P2MR_LEAF_VERSION_V1, leaf_bytes))}; + const CTransaction credit{BuildCreditingTransaction(CScript{} << OP_2 << valtype(merkle_root.begin(), merkle_root.end()), /*nValue=*/1000)}; + CScriptWitness witness; + witness.stack = {tuple.sig, leaf_bytes, control_block}; + const CMutableTransaction base_spend{BuildSpendingTransaction(CScript{}, witness, credit)}; + // Each replay is a distinct transaction carrying the same witness. + const auto replay = [&](uint32_t i) { + CMutableTransaction tx{base_spend}; + tx.nLockTime = i; + return InputUnderTest{tx, {credit.vout[0]}, /*index=*/0}; + }; + + std::vector uncached; + { + ScopedPQCVerificationCounter verifications; + for (uint32_t i{0}; i < REPLAYS; ++i) uncached.push_back(replay(i).VerifyUncached(P2MR_SCRIPT_VERIFY_FLAGS)); + BOOST_CHECK_MESSAGE(verifications.GetCount() == REPLAYS, name << " uncached backend calls " << verifications.GetCount()); + } + + ObservedCache observed{1 << 20}; + { + ScopedPQCVerificationCounter verifications; + for (uint32_t i{0}; i < REPLAYS; ++i) { + const std::string label{strprintf("%s store replay %u", name, i)}; + InputUnderTest spend{replay(i)}; + const ScriptOutcome outcome{spend.VerifyCaching(observed.cache, /*store=*/true, P2MR_SCRIPT_VERIFY_FLAGS)}; + BOOST_CHECK_MESSAGE(uncached[i] == eval_false, label << " uncached " << OutcomeToString(uncached[i])); + BOOST_CHECK_MESSAGE(outcome == uncached[i], label << " cached " << OutcomeToString(outcome)); + observed.CheckEvents(i == 0 ? std::vector{Miss(false), Ok(true)} : std::vector{Hit(false)}, label); + } + BOOST_CHECK_MESSAGE(verifications.GetCount() == 1U, name << " store backend calls " << verifications.GetCount()); + } + BOOST_CHECK(observed.cache.Get(EntryPQCData(observed.cache, tuple), /*erase=*/false)); + + // Block validation consumes a stored entry without verifying, and + // verifies on a cold cache without storing. + { + ScopedPQCVerificationCounter verifications; + InputUnderTest spend{replay(REPLAYS)}; + BOOST_CHECK(spend.VerifyCaching(observed.cache, /*store=*/false, P2MR_SCRIPT_VERIFY_FLAGS) == eval_false); + observed.CheckEvents({Hit(true)}, name + " consume hit"); + BOOST_CHECK_EQUAL(verifications.GetCount(), 0U); + } + { + ObservedCache cold{1 << 20}; + ScopedPQCVerificationCounter verifications; + InputUnderTest spend{replay(0)}; + BOOST_CHECK(spend.VerifyCaching(cold.cache, /*store=*/false, P2MR_SCRIPT_VERIFY_FLAGS) == eval_false); + cold.CheckEvents({Miss(true), Ok(false)}, name + " consume miss"); + BOOST_CHECK_EQUAL(verifications.GetCount(), 1U); + BOOST_CHECK(!cold.cache.Get(EntryPQCData(cold.cache, tuple), /*erase=*/false)); + } + + // The remaining validation weight and the final stack match the + // uncached checker, cold and warm, under both weight rules. + struct EvalOutcome { + ScriptOutcome outcome; + int64_t weight_left; + std::vector stack; + bool operator==(const EvalOutcome&) const = default; + }; + for (const unsigned int flags : {P2MR_SCRIPT_VERIFY_FLAGS, P2MR_LEGACY_WEIGHT_FLAGS}) { + InputUnderTest spend{replay(0)}; + const auto eval = [&](const BaseSignatureChecker& checker) { + std::vector stack{tuple.sig}; + ScriptExecutionData execdata; + execdata.m_validation_weight_left = ::GetSerializeSize(witness.stack) + VALIDATION_WEIGHT_OFFSET; + execdata.m_validation_weight_left_init = true; + ScriptError err{SCRIPT_ERR_UNKNOWN_ERROR}; + const bool result{EvalScript(stack, leaf_script, flags, checker, SigVersion::P2MR, execdata, &err)}; + return EvalOutcome{{result, err}, execdata.m_validation_weight_left, std::move(stack)}; + }; + SignatureCache cache{1 << 20}; + const CachingTransactionSignatureChecker caching{&spend.tx, 0, spend.spent_output.nValue, /*storeIn=*/true, cache, spend.txdata}; + const EvalOutcome uncached_eval{eval(TransactionSignatureChecker{&spend.tx, 0, spend.spent_output.nValue, spend.txdata, MissingDataBehavior::ASSERT_FAIL})}; + const EvalOutcome cold{eval(caching)}; + const EvalOutcome warm{eval(caching)}; + const int64_t cost{(flags & SCRIPT_VERIFY_P2MR_LEGACY_VALIDATION_WEIGHT) ? P2MR_VALIDATION_WEIGHT_PER_SIGOP_LEGACY : P2MR_VALIDATION_WEIGHT_PER_SIGOP_V2}; + BOOST_TEST_MESSAGE(strprintf("%s flags=%x weight_left uncached=%d cold=%d warm=%d", name, flags, uncached_eval.weight_left, cold.weight_left, warm.weight_left)); + BOOST_CHECK(uncached_eval.outcome == (ScriptOutcome{true, SCRIPT_ERR_OK})); + BOOST_CHECK(uncached_eval.stack == std::vector{valtype{}}); + BOOST_CHECK_EQUAL(uncached_eval.weight_left, static_cast(::GetSerializeSize(witness.stack)) + VALIDATION_WEIGHT_OFFSET - cost); + BOOST_CHECK(cold == uncached_eval); + BOOST_CHECK(warm == uncached_eval); + } } } diff --git a/src/test/txvalidationcache_tests.cpp b/src/test/txvalidationcache_tests.cpp index 0e5679af3b..cd2c2c8cba 100644 --- a/src/test/txvalidationcache_tests.cpp +++ b/src/test/txvalidationcache_tests.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include