feat: add signature verification cache for export signatures

Add cryptographic verification of export signatures as they arrive:
- stashTxnData() caches serialized txn for verification
- verifyAndAddSignature() verifies against cached data, rejects invalid
- isSignatureVerified() / verifySignature() for Transactor fallback
- Cleanup methods updated to clear verification cache

Also removes leftover debug std::cerr from OpenView, STObject, and tests.
This commit is contained in:
Nicholas Dudfield
2026-01-27 18:03:55 +07:00
parent b28793b0fa
commit 034010716e
7 changed files with 278 additions and 93 deletions

View File

@@ -26,11 +26,13 @@
#include <ripple/protocol/PublicKey.h>
#include <ripple/protocol/STArray.h>
#include <ripple/protocol/STObject.h>
#include <ripple/protocol/Serializer.h>
#include <ripple/protocol/UintTypes.h>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <vector>
namespace ripple {
@@ -167,6 +169,59 @@ public:
void
cleanupStale(LedgerIndex currentSeq, LedgerIndex maxAge = 256);
// --- Signature verification cache ---
/** Stash the serialized transaction data for signature verification.
Called by signPendingExports() when signing, so we have the txn
data available to verify signatures from other validators.
@param txnHash The hash of the exported transaction
@param txnData Serialized STTx for building verification data
*/
void
stashTxnData(uint256 const& txnHash, Serializer txnData);
/** Verify and add a signature.
Verifies the signature against the cached txn data before adding.
If txn data isn't cached yet (race), adds without verification and
marks as unverified.
@param txnHash The hash of the exported transaction
@param validator The public key of the signing validator
@param signer The STObject containing the signature (sfSigner)
@param currentSeq Current ledger sequence
@return true if signature was added (regardless of verification status)
*/
bool
verifyAndAddSignature(
uint256 const& txnHash,
PublicKey const& validator,
STObject signer,
LedgerIndex currentSeq);
/** Check if a signature has been cryptographically verified.
@param txnHash The hash of the exported transaction
@param validator The public key of the validator
@return true if signature exists AND has been verified
*/
bool
isSignatureVerified(uint256 const& txnHash, PublicKey const& validator)
const;
/** Verify a previously-added signature that wasn't verified on add.
Called by Transactor as fallback when isSignatureVerified returns false.
@param txnHash The hash of the exported transaction
@param validator The public key of the validator
@return true if verification succeeded (or already verified)
*/
bool
verifySignature(uint256 const& txnHash, PublicKey const& validator);
private:
// Map<txnHash, Map<validatorPubKey, SignerObject>>
std::map<uint256, std::map<PublicKey, STObject>> signatures_;
@@ -174,6 +229,13 @@ private:
// Track when each export was first seen (for timeout)
std::map<uint256, LedgerIndex> firstSeenLedger_;
// Signature verification cache
// Serialized STTx for building multisig verification data
std::map<uint256, Serializer> exportedTxnData_;
// Which signatures have been cryptographically verified
std::map<uint256, std::set<PublicKey>> verified_;
mutable std::mutex mutex_;
beast::Journal j_;

View File

@@ -22,7 +22,10 @@
#include <ripple/app/misc/ValidatorList.h>
#include <ripple/ledger/ReadView.h>
#include <ripple/ledger/View.h>
#include <ripple/protocol/PublicKey.h>
#include <ripple/protocol/SField.h>
#include <ripple/protocol/STTx.h>
#include <ripple/protocol/Sign.h>
namespace ripple {
@@ -213,6 +216,8 @@ ExportSignatureCollector::clearForTxn(uint256 const& txnHash)
auto sigCount = signatures_.erase(txnHash);
auto seqCount = firstSeenLedger_.erase(txnHash);
exportedTxnData_.erase(txnHash);
verified_.erase(txnHash);
if (sigCount > 0 || seqCount > 0)
{
@@ -246,6 +251,8 @@ ExportSignatureCollector::cleanupStale(
signatures_.erase(txnHash);
firstSeenLedger_.erase(txnHash);
exportedTxnData_.erase(txnHash);
verified_.erase(txnHash);
}
if (!toRemove.empty())
@@ -255,4 +262,203 @@ ExportSignatureCollector::cleanupStale(
}
}
void
ExportSignatureCollector::stashTxnData(
uint256 const& txnHash,
Serializer txnData)
{
std::lock_guard lock(mutex_);
// Only stash if we don't already have it
if (exportedTxnData_.find(txnHash) == exportedTxnData_.end())
{
exportedTxnData_.emplace(txnHash, std::move(txnData));
JLOG(j_.trace()) << "Export: stashed txn data for " << txnHash;
}
}
bool
ExportSignatureCollector::verifyAndAddSignature(
uint256 const& txnHash,
PublicKey const& validator,
STObject signer,
LedgerIndex currentSeq)
{
std::lock_guard lock(mutex_);
// Track first-seen time for cleanup
if (firstSeenLedger_.find(txnHash) == firstSeenLedger_.end())
{
firstSeenLedger_[txnHash] = currentSeq;
JLOG(j_.debug()) << "Export: first signature for " << txnHash
<< " at ledger " << currentSeq;
}
// Check if we already have this signature
auto& signerMap = signatures_[txnHash];
if (signerMap.find(validator) != signerMap.end())
{
JLOG(j_.trace()) << "Export: already have signature from "
<< toBase58(TokenType::NodePublic, validator)
<< " for " << txnHash;
return true; // Already have it
}
// Try to verify if we have the txn data
bool verified = false;
auto txnIt = exportedTxnData_.find(txnHash);
if (txnIt != exportedTxnData_.end())
{
try
{
// Parse the stashed transaction
SerialIter sit(txnIt->second.slice());
auto stpTrans = std::make_shared<STTx const>(std::ref(sit));
// Get signer account from the signer object
auto signingAcc = signer.getAccountID(sfAccount);
auto sigPubKey = signer.getFieldVL(sfSigningPubKey);
auto signature = signer.getFieldVL(sfTxnSignature);
// Build the multisig data and verify
Serializer sigData = buildMultiSigningData(*stpTrans, signingAcc);
verified = ripple::verify(
PublicKey(makeSlice(sigPubKey)),
sigData.slice(),
makeSlice(signature),
true);
if (!verified)
{
JLOG(j_.warn())
<< "Export: signature verification FAILED for " << txnHash
<< " from " << toBase58(TokenType::NodePublic, validator);
return false; // Don't add invalid signature
}
JLOG(j_.trace())
<< "Export: signature verified for " << txnHash << " from "
<< toBase58(TokenType::NodePublic, validator);
}
catch (std::exception const& e)
{
JLOG(j_.warn()) << "Export: signature verification exception for "
<< txnHash << ": " << e.what();
return false; // Don't add if we can't verify
}
}
else
{
// No txn data yet - add unverified (will verify later or in Transactor)
JLOG(j_.trace()) << "Export: adding unverified signature for "
<< txnHash << " (no txn data yet)";
}
// Add the signature
signerMap.emplace(validator, std::move(signer));
if (verified)
{
verified_[txnHash].insert(validator);
}
JLOG(j_.trace()) << "Export: added signature from "
<< toBase58(TokenType::NodePublic, validator) << " for "
<< txnHash << " (total: " << signerMap.size()
<< ", verified=" << verified << ")";
return true;
}
bool
ExportSignatureCollector::isSignatureVerified(
uint256 const& txnHash,
PublicKey const& validator) const
{
std::lock_guard lock(mutex_);
auto it = verified_.find(txnHash);
if (it == verified_.end())
return false;
return it->second.find(validator) != it->second.end();
}
bool
ExportSignatureCollector::verifySignature(
uint256 const& txnHash,
PublicKey const& validator)
{
std::lock_guard lock(mutex_);
// Already verified?
auto verIt = verified_.find(txnHash);
if (verIt != verified_.end() &&
verIt->second.find(validator) != verIt->second.end())
{
return true;
}
// Get the signature
auto sigIt = signatures_.find(txnHash);
if (sigIt == signatures_.end())
return false;
auto signerIt = sigIt->second.find(validator);
if (signerIt == sigIt->second.end())
return false;
// Get the txn data
auto txnIt = exportedTxnData_.find(txnHash);
if (txnIt == exportedTxnData_.end())
{
JLOG(j_.warn()) << "Export: cannot verify signature - no txn data for "
<< txnHash;
return false;
}
try
{
// Parse the stashed transaction
SerialIter sit(txnIt->second.slice());
auto stpTrans = std::make_shared<STTx const>(std::ref(sit));
// Get signer info
auto const& signer = signerIt->second;
auto signingAcc = signer.getAccountID(sfAccount);
auto sigPubKey = signer.getFieldVL(sfSigningPubKey);
auto signature = signer.getFieldVL(sfTxnSignature);
// Build the multisig data and verify
Serializer sigData = buildMultiSigningData(*stpTrans, signingAcc);
bool verified = ripple::verify(
PublicKey(makeSlice(sigPubKey)),
sigData.slice(),
makeSlice(signature),
true);
if (verified)
{
verified_[txnHash].insert(validator);
JLOG(j_.trace())
<< "Export: late-verified signature for " << txnHash << " from "
<< toBase58(TokenType::NodePublic, validator);
}
else
{
JLOG(j_.warn())
<< "Export: late signature verification FAILED for " << txnHash
<< " from " << toBase58(TokenType::NodePublic, validator);
}
return verified;
}
catch (std::exception const& e)
{
JLOG(j_.warn()) << "Export: late verification exception for " << txnHash
<< ": " << e.what();
return false;
}
}
} // namespace ripple

View File

@@ -374,11 +374,16 @@ signPendingExports(
auto txnHash = stpTrans->getTransactionID();
// Get the collector and stash txn data for signature verification.
// This must happen before checking for cached signature so that
// peer signatures can be verified against this txn data.
auto& collector = app.getExportSignatureCollector();
collector.stashTxnData(txnHash, *s);
// Check if we already have our signature cached in the collector.
// This enables continuous broadcasting: we sign once, then keep
// re-broadcasting our cached signature every ledger until the
// export is finalized (ltEXPORTED_TXN deleted).
auto& collector = app.getExportSignatureCollector();
auto cachedSig = collector.getSignatureFrom(txnHash, pkSigning);
if (cachedSig)

View File

@@ -19,17 +19,6 @@
#include <ripple/basics/contract.h>
#include <ripple/ledger/OpenView.h>
#include <iostream>
#include <thread>
// Debug macro for export investigation
#define DBG_DEREF(msg) \
do \
{ \
std::cerr << "[OpenView::dereference t=" << std::this_thread::get_id() \
<< "] " << msg << std::endl; \
std::cerr.flush(); \
} while (0)
namespace ripple {
@@ -70,32 +59,16 @@ public:
value_type
dereference() const override
{
DBG_DEREF("ENTER key=" << iter_->first);
value_type result;
{
DBG_DEREF(
"creating SerialIter, txn size=" << iter_->second.txn->size());
SerialIter sit(iter_->second.txn->slice());
DBG_DEREF("creating STTx...");
try
{
result.first = std::make_shared<STTx const>(sit);
DBG_DEREF("STTx created, type=" << result.first->getTxnType());
}
catch (std::exception& e)
{
DBG_DEREF("STTx EXCEPTION: " << e.what());
throw;
}
result.first = std::make_shared<STTx const>(sit);
}
if (metadata_)
{
DBG_DEREF("creating metadata");
SerialIter sit(iter_->second.meta->slice());
result.second = std::make_shared<STObject const>(sit, sfMetadata);
DBG_DEREF("metadata created");
}
DBG_DEREF("EXIT");
return result;
}
};

View File

@@ -3219,7 +3219,9 @@ PeerImp::checkValidation(
<< " from validator "
<< toBase58(TokenType::NodePublic, validatorPK);
app_.getExportSignatureCollector().addSignature(
// Verify and add - will verify against cached txn data if
// available, otherwise adds unverified (verified later)
app_.getExportSignatureCollector().verifyAndAddSignature(
txnHash, validatorPK, std::move(signer), currentSeq);
}
catch (std::exception const& e)

View File

@@ -251,17 +251,7 @@ STObject::set(SerialIter& sit, int depth)
});
if (dup != sf.cend())
{
// Debug: dump all field names to identify the duplicate
std::cerr << "[STObject::set] DUPLICATE FIELD DETECTED!" << std::endl;
std::cerr << "[STObject::set] Duplicate field name: "
<< (*dup)->getFName().getName() << std::endl;
std::cerr << "[STObject::set] All fields in object:" << std::endl;
for (auto const& f : sf)
std::cerr << " - " << f->getFName().getName() << std::endl;
std::cerr.flush();
Throw<std::runtime_error>("Duplicate field detected");
}
return reachedEndOfObject;
}

View File

@@ -528,59 +528,6 @@ struct Export_test : public beast::unit_test::suite
{
auto const exportedDirKey = keylet::exportedDir();
bool dirEmpty = dirIsEmpty(*env.current(), exportedDirKey);
// Debug: if not empty, inspect what's in there
if (!dirEmpty && expectCleanup)
{
std::cerr << "=== DEBUG: exportedDir not empty ==="
<< std::endl;
std::shared_ptr<SLE const> sleDirNode{};
unsigned int uDirEntry{0};
uint256 dirEntry{beast::zero};
if (cdirFirst(
*env.current(),
exportedDirKey.key,
sleDirNode,
uDirEntry,
dirEntry))
{
do
{
auto sleItem =
env.current()->read(Keylet{ltCHILD, dirEntry});
if (sleItem)
{
std::cerr << " Entry: " << dirEntry << std::endl;
std::cerr << " LedgerSeq: "
<< sleItem->getFieldU32(sfLedgerSequence)
<< std::endl;
std::cerr
<< " TxnHash: "
<< sleItem->getFieldH256(sfTransactionHash)
<< std::endl;
if (sleItem->isFieldPresent(sfSigners))
{
auto const& signers =
sleItem->getFieldArray(sfSigners);
std::cerr
<< " Signers count: " << signers.size()
<< std::endl;
}
else
{
std::cerr << " Signers: NONE" << std::endl;
}
}
} while (cdirNext(
*env.current(),
exportedDirKey.key,
sleDirNode,
uDirEntry,
dirEntry));
}
std::cerr << "=== END DEBUG ===" << std::endl;
}
BEAST_EXPECT(dirEmpty == expectCleanup);
}