diff --git a/include/xrpl/protocol/EntropyTier.h b/include/xrpl/protocol/EntropyTier.h index 392a38481..2ba45074d 100644 --- a/include/xrpl/protocol/EntropyTier.h +++ b/include/xrpl/protocol/EntropyTier.h @@ -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). diff --git a/include/xrpl/protocol/ExportLimits.h b/include/xrpl/protocol/ExportLimits.h index 54d354d78..bde3157cf 100644 --- a/include/xrpl/protocol/ExportLimits.h +++ b/include/xrpl/protocol/ExportLimits.h @@ -1,6 +1,7 @@ #ifndef RIPPLE_PROTOCOL_EXPORT_LIMITS_H_INCLUDED #define RIPPLE_PROTOCOL_EXPORT_LIMITS_H_INCLUDED +#include #include 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 diff --git a/src/test/consensus/ProposalPrecheck_test.cpp b/src/test/consensus/ProposalPrecheck_test.cpp index a40d311fa..ec28aedf8 100644 --- a/src/test/consensus/ProposalPrecheck_test.cpp +++ b/src/test/consensus/ProposalPrecheck_test.cpp @@ -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 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 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", diff --git a/src/xrpld/app/consensus/ExportSignatureHarvester.cpp b/src/xrpld/app/consensus/ExportSignatureHarvester.cpp index af323bd39..c6ff9ae2f 100644 --- a/src/xrpld/app/consensus/ExportSignatureHarvester.cpp +++ b/src/xrpld/app/consensus/ExportSignatureHarvester.cpp @@ -18,7 +18,9 @@ //============================================================================== #include + #include +#include #include #include #include @@ -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); diff --git a/src/xrpld/app/consensus/ProposalPrecheck.h b/src/xrpld/app/consensus/ProposalPrecheck.h index 1bef79ff5..dbe294584 100644 --- a/src/xrpld/app/consensus/ProposalPrecheck.h +++ b/src/xrpld/app/consensus/ProposalPrecheck.h @@ -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) diff --git a/src/xrpld/app/hook/detail/applyHook.cpp b/src/xrpld/app/hook/detail/applyHook.cpp index 4953c99e3..f68f35250 100644 --- a/src/xrpld/app/hook/detail/applyHook.cpp +++ b/src/xrpld/app/hook/detail/applyHook.cpp @@ -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++); diff --git a/src/xrpld/app/misc/ExportSigCollector.h b/src/xrpld/app/misc/ExportSigCollector.h index 08e6648cb..f5db603a8 100644 --- a/src/xrpld/app/misc/ExportSigCollector.h +++ b/src/xrpld/app/misc/ExportSigCollector.h @@ -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.