fix(consensus): harden export signatures and weak RNG domain

Bound proposal-carried export signature blobs before hashing them during proposal precheck, and apply the same size cap at export signature harvest as defense in depth.

Cap the unverified export-signature cache by distinct tx hash while leaving verified signatures and existing entries ungated, so real exports can still reach quorum.

Domain-separate Hook RNG using the actual strong/weak execution role at draw time, and document the residual commit/reveal withholding bias across all entropy tiers.
This commit is contained in:
Nicholas Dudfield
2026-06-26 17:07:20 +07:00
parent d091ebfb7e
commit b4208459cf
7 changed files with 98 additions and 1 deletions

View File

@@ -12,6 +12,17 @@ namespace ripple {
/// EntropyCount says how many validators contributed; EntropyTier says which
/// gate the result passed. Values are strength-ordered so consumers can gate
/// with a numeric comparison (tier >= required).
///
/// RESIDUAL BIAS — applies to EVERY tier, including participant_aligned and
/// validator_quorum, not just the fallback. This is a commit/reveal scheme: a
/// validator can withhold its reveal until after observing peers' reveals,
/// choosing between two outcomes (its contribution in vs. out) — up to one bit
/// of influence per withholder, and colluding withholders near a threshold can
/// instead force a downgrade to a lower tier. These tiers bound and *label*
/// manipulation (it is observable and limited); they are NOT bias-resistant
/// against a colluding validator minority. Applications that need that
/// guarantee (e.g. high-value lotteries) must layer additional mechanisms on
/// top (VRF / threshold-BLS style constructions).
enum EntropyTier : std::uint8_t {
/// No usable entropy (reserved; a fresh ConsensusEntropy entry should
/// always carry one of the tiers below).

View File

@@ -1,6 +1,7 @@
#ifndef RIPPLE_PROTOCOL_EXPORT_LIMITS_H_INCLUDED
#define RIPPLE_PROTOCOL_EXPORT_LIMITS_H_INCLUDED
#include <cstddef>
#include <cstdint>
namespace ripple {
@@ -26,6 +27,14 @@ struct ExportLimits
// - inbound proposal signature processing (clamped to this)
// - validator signing work per round
static constexpr std::uint8_t maxPendingExports = 8;
// Maximum byte length of a single export-signature wire blob:
// txHash(32) + validator pubkey(33) + multisign signature(<= 72).
// A fully-canonical secp256k1 signature is at most 72 bytes (ed25519 is
// 64), so 137 is the true upper bound for a well-formed entry. The proposal
// ingress path hashes these blobs BEFORE the proposal signature is verified,
// so bounding the per-blob size caps pre-auth hashing/copy work (DoS).
static constexpr std::size_t maxExportSignatureBytes = 32 + 33 + 72;
};
} // namespace ripple

View File

@@ -220,6 +220,38 @@ public:
BEAST_EXPECT(
detail::checkProposalExtensions(okSet, true, true).result ==
ok);
// A single oversized blob is rejected before its bytes are hashed,
// even though the count is within maxPendingExports. This bounds
// the pre-auth SHA512 work on the proposal ingress path.
protocol::TMProposeSet oversized;
setPreviousLedger(oversized);
std::string const bigSig(
ExportLimits::maxExportSignatureBytes + 1, 'x');
std::vector<std::string> const bigSigs{bigSig};
ExtendedPosition oversizedPos{
makeHash("export-oversized-position")};
oversizedPos.exportSignaturesHash =
proposalExportSignaturesHash(bigSigs);
setPosition(oversized, oversizedPos);
oversized.add_exportsignatures(bigSig);
BEAST_EXPECT(
detail::checkProposalExtensions(oversized, true, true).result ==
oversizedExportSignature);
// A maximum-size blob is still accepted.
protocol::TMProposeSet maxSized;
setPreviousLedger(maxSized);
std::string const maxSig(
ExportLimits::maxExportSignatureBytes, 'x');
std::vector<std::string> const maxSigs{maxSig};
ExtendedPosition maxPos{makeHash("export-maxsize-position")};
maxPos.exportSignaturesHash = proposalExportSignaturesHash(maxSigs);
setPosition(maxSized, maxPos);
maxSized.add_exportsignatures(maxSig);
BEAST_EXPECT(
detail::checkProposalExtensions(maxSized, true, true).result ==
ok);
}
testcase("rejection diagnostics");
@@ -259,6 +291,10 @@ public:
tooManyExportSignatures,
"Proposal: too many export signatures",
"too many export sigs");
check(
oversizedExportSignature,
"Proposal: oversized export signature",
"oversized export sig");
check(
unsignedExportSignatures,
"Proposal: unsigned export signatures",

View File

@@ -18,7 +18,9 @@
//==============================================================================
#include <xrpld/app/consensus/ExportSignatureHarvester.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
@@ -136,6 +138,12 @@ harvestExportSignatures(
if (blob.size() < 65)
continue;
// Defense-in-depth: ProposalPrecheck already rejects oversized blobs at
// ingress, but bound the stored signature size here too so the merge/
// sidecar path can never cache an over-large buffer.
if (blob.size() > ExportLimits::maxExportSignatureBytes)
continue;
uint256 txHash;
std::memcpy(txHash.data(), blob.data(), 32);

View File

@@ -18,6 +18,7 @@ enum class ProposalPrecheckResult {
entropyDisabled,
exportDisabled,
tooManyExportSignatures,
oversizedExportSignature,
unsignedExportSignatures,
exportSignaturesHashMismatch,
missingExportSignatures
@@ -71,6 +72,9 @@ proposalPrecheckRejection(ProposalPrecheckResult result)
case ProposalPrecheckResult::tooManyExportSignatures:
return ProposalPrecheckRejection{
"Proposal: too many export signatures", "too many export sigs"};
case ProposalPrecheckResult::oversizedExportSignature:
return ProposalPrecheckRejection{
"Proposal: oversized export signature", "oversized export sig"};
case ProposalPrecheckResult::unsignedExportSignatures:
return ProposalPrecheckRejection{
"Proposal: unsigned export signatures", "unsigned export sigs"};
@@ -119,6 +123,17 @@ checkProposalExtensions(
return {
ProposalPrecheckResult::tooManyExportSignatures, parsedPosition};
// Reject oversized blobs BEFORE proposalExportSignaturesHash() hashes them.
// This runs before the proposal signature is verified, so an unbounded blob
// would otherwise let an unauthenticated peer force a large SHA512/copy.
for (auto const& blob : set.exportsignatures())
{
if (blob.size() > ExportLimits::maxExportSignatureBytes)
return {
ProposalPrecheckResult::oversizedExportSignature,
parsedPosition};
}
if (set.exportsignatures_size() > 0)
{
if (!parsedPosition->exportSignaturesHash)

View File

@@ -4090,6 +4090,11 @@ fairRng(
// we'll generate bytes in lots of 32
// Domain-separate by the execution role known AT DRAW TIME: hr.isStrong is
// set per pass (strong pre-apply vs weak/again-as-weak post-apply). The old
// executeAgainAsWeak flag is only set during the strong pass by hook_again
// and is false during the actual weak pass, so it mislabelled the weak draw
// "strong" and reused the strong-pass stream within one transaction.
uint256 rndData = sha512Half(
view.info().seq,
applyCtx.tx.getTransactionID(),
@@ -4097,7 +4102,7 @@ fairRng(
hr.hookHash,
hr.account,
hr.hookChainPosition,
hr.executeAgainAsWeak ? std::string("weak") : std::string("strong"),
hr.isStrong ? std::string("strong") : std::string("weak"),
sleEntropy->getFieldH256(sfDigest),
hr.rngCallCounter++);

View File

@@ -58,6 +58,13 @@ class ExportSigCollector
static constexpr std::uint32_t maxStaleLedgers = 256;
// Cap on distinct tracked export txns. Bounds the unverified cache: a
// malicious trusted validator can advertise proposal sigs with arbitrary
// txHashes for txns not in our open ledger (stored unverified, only TTL-
// evicted). Verified entries (real in-ledger exports) are never gated by
// this cap, so legitimate quorum collection is unaffected.
static constexpr std::size_t maxTrackedTxns = 4096;
void
touchSeq(SigEntry& entry, std::uint32_t seq)
{
@@ -106,6 +113,12 @@ public:
"ripple::ExportSigCollector::addUnverifiedSignature : "
"non-empty signature");
std::lock_guard lock(mutex_);
// Bound the unverified cache (see maxTrackedTxns). Only gate NEW
// txHashes; existing entries and the verified path are never blocked,
// so real exports still reach quorum.
if (sigs_.find(txnHash) == sigs_.end() &&
sigs_.size() >= maxTrackedTxns)
return;
auto& entry = sigs_[txnHash];
entry.validators.insert(validator);
// Don't overwrite a verified sig with an unverified one.