From 9bb2cb5d76d4cde9016d4f8f64585f0399ffff47 Mon Sep 17 00:00:00 2001 From: Alexander Miller Date: Fri, 30 Oct 2026 10:32:59 -0400 Subject: [PATCH 1/2] script: cache verified P2MR data-signature checks Route OP_CHECKDATASIGPQC and OP_CHECKDATASIGADDPQC verification through a new BaseSignatureChecker::VerifyPQCDataSignature hook. The default runs the primitive, so every checker that does not override it (signing, dummy, miniscript, test and fuzz checkers) behaves as before, and DeferringSignatureChecker forwards it to the checker it wraps. CachingTransactionSignatureChecker caches verified data-signature successes in the shared signature cache under a separately salted 'D' domain, keyed by the QbitDataSigPQC digest, public key and signature, with the same store/consume rules and shared budget as #162. Failures are never cached. The interpreter still charges validation weight and runs every size check before calling the checker, so validity, weight and error codes are unchanged. A valid data signature does not commit to the spending transaction, so a leaf such as OP_CHECKDATASIGPQC OP_NOT could be replayed across mutated transactions, each costing a full SLH-DSA verification during mempool admission. Such replays now cost one verification. Tests: pqc_sigcache_tests replaces data_signatures_bypass_the_cache with data_signatures_use_their_own_cache_domain, adds data_signature_replays_verify_once, and extends concurrent_checks_of_one_signature to data signatures. txvalidationcache_tests adds pqc_data_signature_reuse_across_mutated_spends. Refs #259 Co-Authored-By: Claude Opus 5.5 --- src/script/interpreter.cpp | 13 +- src/script/interpreter.h | 11 + src/script/sigcache.cpp | 34 +++- src/script/sigcache.h | 13 +- src/test/pqc_sigcache_tests.cpp | 293 +++++++++++++++++++++++---- src/test/txvalidationcache_tests.cpp | 105 ++++++++++ 6 files changed, 424 insertions(+), 45 deletions(-) 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