mirror of
https://github.com/Xahau/xahaud.git
synced 2026-08-23 00:10:54 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user