fix(consensus): harden sidecar quorum inputs

This commit is contained in:
Nicholas Dudfield
2026-04-27 10:14:12 +07:00
parent 6e71f84867
commit 26bbef8efd
5 changed files with 46 additions and 41 deletions

View File

@@ -168,9 +168,9 @@ message TMProposeSet
optional uint32 hops = 12 [deprecated=true];
// Export signatures for pending exports seen in the proposal set.
// Each entry is: txnHash (32 bytes) + validator pubkey (33 bytes).
// Validators attach these so export quorum can be reached within
// the same consensus round.
// Each entry is: txnHash (32 bytes) + validator pubkey (33 bytes)
// + multisign signature (variable length). Validators attach these
// so export quorum can be reached within the same consensus round.
repeated bytes exportSignatures = 13;
}
@@ -224,9 +224,9 @@ message TMValidation
// Number of hops traveled
optional uint32 hops = 3 [deprecated = true];
// Export signatures for pending exports validated in this ledger.
// Each entry is: txnHash (32 bytes) + serialized sfSigner STObject.
// Used for ephemeral export signature collection via validation gossip.
// Legacy export signature gossip field retained for wire compatibility.
// Current proposal-based export signatures use
// TMProposeSet.exportSignatures.
repeated bytes exportSignatures = 4;
}
@@ -395,4 +395,3 @@ message TMHaveTransactions
{
repeated bytes hashes = 1;
}

View File

@@ -9,7 +9,7 @@ namespace ripple {
//
// These limits bound the DoS surface of the export signature system:
// - Each pending export requires every validator to sign it every round
// (sign-once, broadcast-many via TMValidation)
// (sign-once, attach once via TMProposeSet)
// - Inbound signature processing involves crypto verification per sig
// - The directory cap (maxPendingExports) is the root constraint;
// signing throughput and inbound processing are transitively bounded by it
@@ -21,8 +21,8 @@ struct ExportLimits
// Maximum pending exports in the exported directory at any time.
// This transitively caps:
// - signatures per TMValidation message (1 per pending export)
// - inbound signature processing in PeerImp (clamped to this)
// - signatures per TMProposeSet message (1 per pending export)
// - inbound proposal signature processing (clamped to this)
// - validator signing work per round
static constexpr std::uint8_t maxPendingExports = 8;
};

View File

@@ -100,8 +100,7 @@ struct TestVLPublisher
buildVLData(
std::vector<TestValidator> const& validators,
std::uint32_t sequence = 1,
std::uint32_t expiration =
767784645) const // ~2024, matches Import_test
std::uint32_t expiration = 767784645) const
{
// Build the JSON blob
std::string data = "{\"sequence\":" + std::to_string(sequence) +

View File

@@ -136,10 +136,19 @@ bool
ConsensusExtensions::hasQuorumOfCommits() const
{
auto threshold = quorumThreshold();
bool result = pendingCommits_.size() >= threshold;
JLOG(j_.trace()) << "RNG: hasQuorumOfCommits? " << pendingCommits_.size()
<< "/" << threshold << " -> " << (result ? "YES" : "no")
<< " (activeUNL=" << unlReportNodeIds_.size()
auto const proofedCommitCount = std::count_if(
pendingCommits_.begin(),
pendingCommits_.end(),
[this](auto const& entry) {
auto const& nid = entry.first;
return isUNLReportMember(nid) && nodeIdToKey_.count(nid) > 0 &&
commitProofs_.count(nid) > 0;
});
bool result = static_cast<std::size_t>(proofedCommitCount) >= threshold;
JLOG(j_.trace()) << "RNG: hasQuorumOfCommits? " << proofedCommitCount << "/"
<< threshold << " -> " << (result ? "YES" : "no")
<< " (pending=" << pendingCommits_.size()
<< ", activeUNL=" << unlReportNodeIds_.size()
<< ", likelyParticipants=" << likelyParticipants_.size()
<< ")";
return result;
@@ -341,6 +350,9 @@ ConsensusExtensions::buildCommitSet(LedgerIndex seq)
auto kit = nodeIdToKey_.find(nid);
if (kit == nodeIdToKey_.end())
continue;
auto proofIt = commitProofs_.find(nid);
if (proofIt == commitProofs_.end())
continue;
// Encode the NodeID into sfAccount so onAcquiredSidecarSet can
// recover it without recomputing (master vs signing key issue).
@@ -353,9 +365,7 @@ ConsensusExtensions::buildCommitSet(LedgerIndex seq)
sidecar.setAccountID(sfAccount, acctId);
sidecar.setFieldH256(sfDigest, commit);
sidecar.setFieldVL(sfSigningPubKey, kit->second.slice());
auto proofIt = commitProofs_.find(nid);
if (proofIt != commitProofs_.end())
sidecar.setFieldVL(sfBlob, serializeProof(proofIt->second));
sidecar.setFieldVL(sfBlob, serializeProof(proofIt->second));
auto const itemKey = sidecar.getHash(HashPrefix::sidecar);
Serializer s(2048);
@@ -550,17 +560,19 @@ ConsensusExtensions::clearRngState()
//@@end clear-rng-state
void
ConsensusExtensions::cacheUNLReport()
ConsensusExtensions::cacheUNLReport(
std::shared_ptr<Ledger const> const& prevLedger)
{
unlReportNodeIds_.clear();
bool const includeSelf = mode_ == ConsensusMode::proposing &&
app_.getValidatorKeys().keys &&
app_.getValidatorKeys().nodeID != beast::zero;
// Try UNL Report from the validated ledger
if (auto const prevLedger = app_.getLedgerMaster().getValidatedLedger())
// Try UNL Report from the consensus parent ledger. Falling back to
// LedgerMaster preserves older tests that call cacheUNLReport directly,
// but live rounds should pass the exact parent ledger for the round.
auto const sourceLedger =
prevLedger ? prevLedger : app_.getLedgerMaster().getValidatedLedger();
if (sourceLedger)
{
if (auto const sle = prevLedger->read(keylet::UNLReport()))
if (auto const sle = sourceLedger->read(keylet::UNLReport()))
{
if (sle->isFieldPresent(sfActiveValidators))
{
@@ -584,14 +596,11 @@ ConsensusExtensions::cacheUNLReport()
{
unlReportNodeIds_.insert(calcNodeID(masterKey));
}
}
// Only include ourselves when actively proposing. Observers/non-validators
// do not emit commitments and must not be expected in commit quorum.
if (includeSelf)
unlReportNodeIds_.insert(app_.getValidatorKeys().nodeID);
else
unlReportNodeIds_.erase(app_.getValidatorKeys().nodeID);
auto const& valKeys = app_.getValidatorKeys();
if (valKeys.keys && valKeys.nodeID != beast::zero)
unlReportNodeIds_.insert(valKeys.nodeID);
}
JLOG(j_.trace()) << "RNG: cacheUNLReport size=" << unlReportNodeIds_.size();
}
@@ -1048,6 +1057,7 @@ ConsensusExtensions::fetchSidecarsIfNeeded(ExtendedPosition const& peerPos)
{
fetchRngSetIfNeeded(peerPos.commitSetHash, SidecarKind::commit);
fetchRngSetIfNeeded(peerPos.entropySetHash, SidecarKind::reveal);
fetchRngSetIfNeeded(peerPos.exportSigSetHash, SidecarKind::exportSig);
}
void
@@ -1093,8 +1103,8 @@ ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq)
// same entropy — preventing reveal-subset divergence at
// timeout boundaries.
//
// Each leaf is an STTx with sfSigningPubKey (validator key)
// and sfDigest (the reveal).
// Each leaf is an STObject(sfGeneric) sidecar with sfSigningPubKey
// (validator key) and sfDigest (the reveal).
std::vector<std::pair<PublicKey, uint256>> sorted;
entropySetMap_->visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
@@ -1453,7 +1463,7 @@ ConsensusExtensions::onRoundStart(
hash_set<NodeID> lastProposers)
{
clearRngState();
cacheUNLReport();
cacheUNLReport(prevLedger.ledger_);
setExpectedProposers(std::move(lastProposers));
resetSubState();
}
@@ -1705,7 +1715,7 @@ ConsensusExtensions::decoratePosition(
}
setMode(ConsensusMode::proposing);
cacheUNLReport();
cacheUNLReport(prevLedger);
generateEntropySecret();
auto const& valKeys = app_.getValidatorKeys();

View File

@@ -27,9 +27,6 @@ using TickContext = ConsensusTick<ExtendedPosition, RCLCxPeerPos, RCLTxSet>;
/// Owns all RNG/Export state that was previously scattered across
/// RCLCxAdaptor and Consensus.h. Lifecycle hooks are grouped by
/// caller/threading context.
///
/// See .ai-docs/refactoring-xahaud-consensus-extensions-v8.md.j2
/// for design rationale.
class ConsensusExtensions
{
Application& app_;
@@ -190,7 +187,7 @@ public:
fetchSidecarsIfNeeded(ExtendedPosition const& peerPos);
void
cacheUNLReport();
cacheUNLReport(std::shared_ptr<Ledger const> const& prevLedger = {});
bool
isUNLReportMember(NodeID const& nodeId) const;