diff --git a/src/Makefile.test.include b/src/Makefile.test.include index faaee5aa1913..06f22cb4d05c 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -315,6 +315,7 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/decode_tx.cpp \ test/fuzz/descriptor_parse.cpp \ test/fuzz/deserialize.cpp \ + test/fuzz/dkg_message_framing.cpp \ test/fuzz/eval_script.cpp \ test/fuzz/fee_rate.cpp \ test/fuzz/fees.cpp \ diff --git a/src/llmq/dkgmessages.h b/src/llmq/dkgmessages.h index 177b68e5d4d4..fcc480469ca0 100644 --- a/src/llmq/dkgmessages.h +++ b/src/llmq/dkgmessages.h @@ -17,6 +17,13 @@ #include namespace llmq { +/** + * @warning The wire encodings below are walked a second time, by hand, at + * network intake: see CheckDKGMessageWireStructure() in llmq/net_dkg.h. That + * walk validates framing without materializing BLS objects, so any change to + * the (Un)serialize implementations in this file must be mirrored there. + * src/test/fuzz/dkg_message_framing.cpp guards the two against divergence. + */ class CDKGContribution { public: diff --git a/src/llmq/dkgsessionhandler.cpp b/src/llmq/dkgsessionhandler.cpp index c9258ff353f9..45f41bab08a0 100644 --- a/src/llmq/dkgsessionhandler.cpp +++ b/src/llmq/dkgsessionhandler.cpp @@ -12,7 +12,7 @@ namespace llmq { CDKGSessionHandler::CDKGSessionHandler(const Consensus::LLMQParams& _params) : params{_params}, - // we allow size*2 messages as we need to make sure we see bad behavior (double messages) + // we allow size*2 messages per sender as we need to make sure we see bad behavior (double messages) pendingContributions{(size_t)_params.size * 2}, pendingComplaints{(size_t)_params.size * 2}, pendingJustifications{(size_t)_params.size * 2}, @@ -25,22 +25,40 @@ CDKGSessionHandler::CDKGSessionHandler(const Consensus::LLMQParams& _params) : CDKGSessionHandler::~CDKGSessionHandler() = default; -void CDKGPendingMessages::PushPendingMessage(NodeId from, std::shared_ptr pm, const uint256& hash) +void CDKGPendingMessages::PushPendingMessage(NodeId from, const uint256& sender_protx, + std::shared_ptr pm, const uint256& hash) { LOCK(cs_messages); - if (messagesPerNode[from] >= maxMessagesPerNode) { - // TODO ban? - LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages, peer=%d\n", __func__, from); + // Check duplicates before charging the per-sender quota so a peer that + // resends the same hash cannot exhaust its budget with dupes. + if (seenMessages.count(hash) != 0) { + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); return; } - messagesPerNode[from]++; - if (!seenMessages.emplace(hash).second) { - LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); + const bool is_remote = from != -1; + if (is_remote) { + const auto sender_it = messagesPerSender.find(sender_protx); + if (sender_it != messagesPerSender.end() && sender_it->second >= maxMessagesPerSender) { + // TODO ban? + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages from %s, peer=%d\n", __func__, + sender_protx.ToString(), from); + return; + } + } + + if ((is_remote && pendingRemoteMessageCount >= maxPendingRemoteMessages) || + pendingMessages.size() >= maxPendingMessages) { + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- pending queue full, peer=%d\n", __func__, from); return; } + if (is_remote) { + messagesPerSender[sender_protx]++; + pendingRemoteMessageCount++; + } + seenMessages.emplace(hash); pendingMessages.emplace_back(std::make_pair(from, std::move(pm))); } @@ -50,6 +68,9 @@ std::list CDKGPendingMessages::PopPendingMes std::list ret; while (!pendingMessages.empty() && ret.size() < maxCount) { + if (pendingMessages.front().first != -1) { + pendingRemoteMessageCount--; + } ret.emplace_back(std::move(pendingMessages.front())); pendingMessages.pop_front(); } @@ -57,18 +78,19 @@ std::list CDKGPendingMessages::PopPendingMes return ret; } -bool CDKGPendingMessages::HasSeen(const uint256& hash) const +void CDKGPendingMessages::Clear() { LOCK(cs_messages); - return seenMessages.count(hash) != 0; + pendingMessages.clear(); + pendingRemoteMessageCount = 0; + messagesPerSender.clear(); + seenMessages.clear(); } -void CDKGPendingMessages::Clear() +bool CDKGPendingMessages::HasSeen(const uint256& hash) const { LOCK(cs_messages); - pendingMessages.clear(); - messagesPerNode.clear(); - seenMessages.clear(); + return seenMessages.count(hash) != 0; } void CDKGSessionHandler::ClearPendingMessages() diff --git a/src/llmq/dkgsessionhandler.h b/src/llmq/dkgsessionhandler.h index be55bfcbaa8a..1578f70f79e7 100644 --- a/src/llmq/dkgsessionhandler.h +++ b/src/llmq/dkgsessionhandler.h @@ -6,18 +6,16 @@ #define BITCOIN_LLMQ_DKGSESSIONHANDLER_H #include // for NodeId +#include #include +#include #include -#include #include -#include -#include #include class CDataStream; class CBlockIndex; -class uint256; namespace Consensus { struct LLMQParams; @@ -47,7 +45,7 @@ enum class QuorumPhase { * main handler thread, we push them into a CDKGPendingMessages object and later pop+deserialize them in the DKG phase * handler thread. * - * Each message type has it's own instance of this class. + * Each message type has its own instance of this class. */ class CDKGPendingMessages { @@ -55,53 +53,53 @@ class CDKGPendingMessages using BinaryMessage = std::pair>; private: - const size_t maxMessagesPerNode; + const size_t maxMessagesPerSender; + const size_t maxPendingRemoteMessages; + const size_t maxPendingMessages; mutable Mutex cs_messages; std::list pendingMessages GUARDED_BY(cs_messages); - std::map messagesPerNode GUARDED_BY(cs_messages); + size_t pendingRemoteMessageCount GUARDED_BY(cs_messages){0}; + // Keyed by the sender's MNAuth-verified proTxHash, not by NodeId: a peer that + // reconnects gets a fresh NodeId but keeps the same proTxHash, so its quota + // survives the reconnect instead of being reset. + // + // Entries deliberately live for the whole round rather than being released on + // pop: the quota is cumulative, so that a sender cannot regain retention slots + // simply by waiting for the worker to drain the queue. Size is therefore not + // bounded by the queue caps but by the number of distinct senders that get a + // message accepted in one round, which MNAuth pins to the registered + // masternode set (see CMNAuth::ProcessMessage: the proTxHash must resolve in + // the deterministic MN list and carry a valid operator-key signature). + Uint256HashMap messagesPerSender GUARDED_BY(cs_messages); Uint256HashSet seenMessages GUARDED_BY(cs_messages); public: - explicit CDKGPendingMessages(size_t _maxMessagesPerNode) : - maxMessagesPerNode(_maxMessagesPerNode) {}; + explicit CDKGPendingMessages(size_t _maxMessagesPerSender) : + maxMessagesPerSender(_maxMessagesPerSender), + // Belt-and-braces bound on live queue occupancy. The per-sender quota is + // the primary limit; this only caps the total across distinct senders. + maxPendingRemoteMessages(_maxMessagesPerSender * 2), + // Reserve one slot for the message produced by this node during the + // matching phase. + maxPendingMessages(maxPendingRemoteMessages + 1) + { + } /** * Enqueue a serialized DKG message under @p from with content hash @p hash. + * @p sender_protx is the sender's MNAuth-verified proTxHash and keys the + * per-sender quota; pass a null hash for messages this node produced itself + * (@p from == -1), which are exempt from that quota. * Caller is responsible for hashing the payload and (for real peers) * routing the erase-request to PeerManager. Drops the message silently on - * per-node capacity overflow or duplicate hash. + * per-sender or queue-wide capacity overflow, or duplicate hash. */ - void PushPendingMessage(NodeId from, std::shared_ptr pm, const uint256& hash) - EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); + void PushPendingMessage(NodeId from, const uint256& sender_protx, std::shared_ptr pm, + const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); std::list PopPendingMessages(size_t maxCount) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); bool HasSeen(const uint256& hash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); void Clear() EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); - - // Might return nullptr messages, which indicates that deserialization failed for some reason - template - std::vector>> PopAndDeserializeMessages(size_t maxCount) - EXCLUSIVE_LOCKS_REQUIRED(!cs_messages) - { - auto binaryMessages = PopPendingMessages(maxCount); - if (binaryMessages.empty()) { - return {}; - } - - std::vector>> ret; - ret.reserve(binaryMessages.size()); - for (const auto& bm : binaryMessages) { - auto msg = std::make_shared(); - try { - *bm.second >> *msg; - } catch (...) { - msg = nullptr; - } - ret.emplace_back(std::make_pair(bm.first, std::move(msg))); - } - - return ret; - } }; /** diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index e533aadf8777..48e3684fd54c 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -22,12 +22,16 @@ #include #include #include +#include #include +#include #include #include #include #include +#include + namespace llmq { namespace { @@ -72,48 +76,208 @@ size_t MaxDKGMessageSize(std::string_view msg_type, const Consensus::LLMQParams& return cap < HARD_CEILING ? cap : HARD_CEILING; } -// Cheap, param-only structural validation of a pushed DKG message, run at intake -// before retention. Deserializes a COPY of the payload (leaving the caller's bytes -// intact for the pending queue and its inventory hash) and checks only safe upper -// bounds derived from quorum params: no member-list lookup and no signature -// verification, which remain on the DKG worker thread. Deserializing the copy does -// decompress the BLS points carried in the payload, but that work is bounded by -// the size cap applied just before this check. Rejects malformed or clearly -// oversized payloads before retention. -bool CheckDKGMessageStructure(std::string_view msg_type, const CDataStream& vRecv, const Consensus::LLMQParams& params) +bool SkipBytes(CDataStream& ds, size_t size) +{ + try { + ds.ignore(size); + return true; + } catch (const std::exception&) { + return false; + } +} + +std::optional ReadCompactSizeNoThrow(CDataStream& ds) +{ + try { + return ReadCompactSize(ds); + } catch (const std::exception&) { + return std::nullopt; + } +} + +bool SkipCommonDKGFields(CDataStream& ds) +{ + constexpr size_t PREFIX = 1 + 32 + 32; // llmqType + quorumHash + proTxHash + return SkipBytes(ds, PREFIX); +} + +// BLS encodings have fixed wire sizes, so intake only needs to establish that +// the bytes are present. Decoding and canonical/scheme validation remain on the +// DKG worker, where each object is materialized exactly once. +template +bool SkipBLSObject(CDataStream& ds) +{ + return SkipBytes(ds, BLSObject::SerSize); +} + +// Mirrors DynamicBitSetFormatter::Unser (ReadCompactSize + ReadFixedBitSet), +// including ReadFixedBitSet's rejection of out-of-range padding bits, without +// materializing the std::vector. Returns the bit count on success. +std::optional ReadAndCheckDynBitset(CDataStream& ds, uint64_t max_size) +{ + const auto size = ReadCompactSizeNoThrow(ds); + if (!size.has_value() || size.value() > max_size) { + return std::nullopt; + } + + const size_t byte_size = (size.value() + 7) / 8; + if (byte_size == 0) { + return size; + } + if (!SkipBytes(ds, byte_size - 1)) { + return std::nullopt; + } + + uint8_t last{0}; + try { + ds.read(AsWritableBytes(Span{&last, 1})); + } catch (const std::exception&) { + return std::nullopt; + } + + if (byte_size * 8 != size.value()) { + const size_t rem = byte_size * 8 - size.value(); + const uint8_t mask = ~(uint8_t)(0xff >> rem); + if (last & mask) { + return std::nullopt; + } + } + + return size; +} + +bool CheckContributionWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) { const size_t size = params.size > 0 ? static_cast(params.size) : 0; const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; - try { - CDataStream s(vRecv); // copy; deserialization does not advance the caller's stream - if (msg_type == NetMsgType::QCONTRIB) { - CDKGContribution qc; - s >> qc; - return qc.vvec != nullptr && qc.vvec->size() == threshold && - qc.contributions != nullptr && - qc.contributions->blobs.size() >= min_size && - qc.contributions->blobs.size() <= size; - } else if (msg_type == NetMsgType::QCOMPLAINT) { - CDKGComplaint qc; - s >> qc; - return qc.badMembers.size() == qc.complainForMembers.size() && - qc.badMembers.size() <= size; - } else if (msg_type == NetMsgType::QJUSTIFICATION) { - CDKGJustification qj; - s >> qj; - return qj.contributions.size() <= size; - } else if (msg_type == NetMsgType::QPCOMMITMENT) { - CDKGPrematureCommitment qc; - s >> qc; - return qc.validMembers.size() <= size; + + if (!SkipCommonDKGFields(ds)) { + return false; + } + + const auto vvec_size = ReadCompactSizeNoThrow(ds); + if (!vvec_size.has_value() || vvec_size.value() != threshold) { + return false; + } + for (uint64_t i = 0; i < vvec_size.value(); ++i) { + if (!SkipBLSObject(ds)) { + return false; } + } + + if (!SkipBLSObject(ds) || !SkipBytes(ds, 32)) { return false; - } catch (const std::exception&) { + } + + const auto blob_count = ReadCompactSizeNoThrow(ds); + if (!blob_count.has_value() || blob_count.value() < min_size || blob_count.value() > size) { return false; } + for (uint64_t i = 0; i < blob_count.value(); ++i) { + const auto blob_size = ReadCompactSizeNoThrow(ds); + if (!blob_size.has_value() || !SkipBytes(ds, blob_size.value())) { + return false; + } + } + + return SkipBLSObject(ds) && ds.empty(); } +bool CheckComplaintWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + if (!SkipCommonDKGFields(ds)) { + return false; + } + const auto bad_members_size = ReadAndCheckDynBitset(ds, size); + if (!bad_members_size.has_value()) { + return false; + } + const auto complain_for_members_size = ReadAndCheckDynBitset(ds, size); + return complain_for_members_size.has_value() && bad_members_size.value() == complain_for_members_size.value() && + SkipBLSObject(ds) && ds.empty(); +} + +bool CheckJustificationWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + if (!SkipCommonDKGFields(ds)) { + return false; + } + + const auto contribution_count = ReadCompactSizeNoThrow(ds); + if (!contribution_count.has_value() || contribution_count.value() > size) { + return false; + } + for (uint64_t i = 0; i < contribution_count.value(); ++i) { + if (!SkipBytes(ds, 4) || !SkipBLSObject(ds)) { + return false; + } + } + + return SkipBLSObject(ds) && ds.empty(); +} + +bool CheckPrematureCommitmentWireStructure(CDataStream& ds, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + return SkipCommonDKGFields(ds) && ReadAndCheckDynBitset(ds, size).has_value() && + SkipBLSObject(ds) && SkipBytes(ds, 32) && SkipBLSObject(ds) && + SkipBLSObject(ds) && ds.empty(); +} + +} // namespace + +bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& payload, + const Consensus::LLMQParams& params) +{ + // Copy so the caller's read position (and the bytes backing its inventory + // hash) survive the walk. The copy is a memcpy bounded by the size cap + // applied just before this call -- negligible next to the BLS point + // decompression that deserializing the payload here would have cost. + CDataStream ds(payload); + if (msg_type == NetMsgType::QCONTRIB) { + return CheckContributionWireStructure(ds, params); + } else if (msg_type == NetMsgType::QCOMPLAINT) { + return CheckComplaintWireStructure(ds, params); + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + return CheckJustificationWireStructure(ds, params); + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + return CheckPrematureCommitmentWireStructure(ds, params); + } + return false; +} + +bool CheckDKGMessageStructure(const CDKGContribution& qc, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; + const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; + return qc.vvec != nullptr && qc.vvec->size() == threshold && qc.contributions != nullptr && + qc.contributions->blobs.size() >= min_size && qc.contributions->blobs.size() <= size; +} + +bool CheckDKGMessageStructure(const CDKGComplaint& qc, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + return qc.badMembers.size() == qc.complainForMembers.size() && qc.badMembers.size() <= size; +} + +bool CheckDKGMessageStructure(const CDKGJustification& qj, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + return qj.contributions.size() <= size; +} + +bool CheckDKGMessageStructure(const CDKGPrematureCommitment& qc, const Consensus::LLMQParams& params) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + return qc.validMembers.size() <= size; +} + +namespace { + // returns a set of NodeIds which sent invalid messages template std::unordered_set BatchVerifyMessageSigs(CDKGSession& session, @@ -258,19 +422,55 @@ void RelayInvToParticipants(const CDKGSession& session, const CConnman& connman, template void EnqueueOwn(CDKGPendingMessages& pending, const Message& msg) { + // Own messages skip the wire path but still populate the pending queue so + // the DKG worker sees them alongside peer messages. The inventory hash is + // computed over the serialized form so it matches what remote peers would + // compute for the same message. CDataStream ds(SER_NETWORK, PROTOCOL_VERSION); ds << msg; auto pm = std::make_shared(std::move(ds)); CHashWriter hw(SER_GETHASH, 0); hw.write(AsWritableBytes(Span{*pm})); - pending.PushPendingMessage(/*from=*/-1, std::move(pm), hw.GetHash()); + pending.PushPendingMessage(/*from=*/-1, /*sender_protx=*/uint256(), std::move(pm), hw.GetHash()); } +// Outcome of the single typed deserialization pass on the DKG worker. Split from +// a plain bool so the caller can log why a queued payload was dropped: framing +// passed at intake, so a failure here is either a BLS/canonical/scheme decode +// error or a param-bound structural violation. +enum class DKGMessageDecodeResult { + Ok, + DeserializeFailed, + StructureInvalid, +}; + template -bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, CDKGPendingMessages& pendingMessages, - PeerManagerInternal& peerman, size_t maxCount) +DKGMessageDecodeResult DeserializeAndCheckDKGMessage(CDataStream& ds, const Consensus::LLMQParams& params, + std::shared_ptr& msg) { - auto msgs = pendingMessages.PopAndDeserializeMessages(maxCount); + msg = std::make_shared(); + try { + ds >> *msg; + } catch (...) { + msg.reset(); + return DKGMessageDecodeResult::DeserializeFailed; + } + if (!ds.empty()) { + msg.reset(); + return DKGMessageDecodeResult::DeserializeFailed; + } + if (!CheckDKGMessageStructure(*msg, params)) { + msg.reset(); + return DKGMessageDecodeResult::StructureInvalid; + } + return DKGMessageDecodeResult::Ok; +} + +template +bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, const Consensus::LLMQParams& params, + CDKGPendingMessages& pendingMessages, PeerManagerInternal& peerman, size_t maxCount) +{ + auto msgs = pendingMessages.PopPendingMessages(maxCount); if (msgs.empty()) { return false; } @@ -280,13 +480,19 @@ bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, C for (const auto& p : msgs) { const NodeId& nodeId = p.first; - if (!p.second) { - LogPrint(BCLog::LLMQ_DKG, "%s -- failed to deserialize message, peer=%d\n", __func__, nodeId); + std::shared_ptr msg; + const auto decode_result = DeserializeAndCheckDKGMessage(*p.second, params, msg); + if (decode_result != DKGMessageDecodeResult::Ok) { + if (decode_result == DKGMessageDecodeResult::StructureInvalid) { + LogPrint(BCLog::LLMQ_DKG, "%s -- message failed structure check, peer=%d\n", __func__, nodeId); + } else { + LogPrint(BCLog::LLMQ_DKG, "%s -- failed to deserialize message, peer=%d\n", __func__, nodeId); + } peerman.PeerMisbehaving(nodeId, 100); continue; } bool ban = false; - if (!session.PreVerifyMessage(*p.second, ban)) { + if (!session.PreVerifyMessage(*msg, ban)) { if (ban) { LogPrint(BCLog::LLMQ_DKG, "%s -- banning node due to failed preverification, peer=%d\n", __func__, nodeId); peerman.PeerMisbehaving(nodeId, 100); @@ -294,7 +500,7 @@ bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, C LogPrint(BCLog::LLMQ_DKG, "%s -- skipping message due to failed preverification, peer=%d\n", __func__, nodeId); continue; } - preverifiedMessages.emplace_back(p); + preverifiedMessages.emplace_back(nodeId, std::move(msg)); } if (preverifiedMessages.empty()) { return true; @@ -321,6 +527,7 @@ bool ProcessPendingMessageBatch(const CConnman& connman, CDKGSession& session, C return true; } + } // namespace @@ -392,7 +599,10 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre // attacker-controlled payloads, so they must originate from an MNAuth-verified // masternode. qwatch is unauthenticated (any peer can set it via QWATCH) and is // only meaningful for pull/observation paths; it must not bypass this gate. - if (pfrom.GetVerifiedProRegTxHash().IsNull()) { + // Read once and reuse: this identity also keys the per-sender retention quota + // below, which must not be able to observe a different value than the gate did. + const uint256 sender_protx = pfrom.GetVerifiedProRegTxHash(); + if (sender_protx.IsNull()) { m_peer_manager->PeerMisbehaving(pfrom.GetId(), 10, "DKG message from non-verified peer"); return; } @@ -404,10 +614,17 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre Consensus::LLMQType llmqType; uint256 quorumHash; - vRecv >> llmqType; - vRecv >> quorumHash; - vRecv.Rewind(sizeof(uint256)); - vRecv.Rewind(sizeof(uint8_t)); + try { + vRecv >> llmqType; + vRecv >> quorumHash; + } catch (const std::exception&) { + m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "malformed DKG message"); + return; + } + if (!vRecv.Rewind(sizeof(uint256)) || !vRecv.Rewind(sizeof(uint8_t))) { + m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "malformed DKG message"); + return; + } const auto& llmq_params_opt = Params().GetLLMQ(llmqType); if (!llmq_params_opt.has_value()) { @@ -458,30 +675,31 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "oversized DKG message"); return; } - - // Cheap structural pre-validation before retention. Validates a copy so the - // original bytes (and their inventory hash) are preserved for the worker. - if (!CheckDKGMessageStructure(msg_type, vRecv, llmq_params)) { + if (!CheckDKGMessageWireStructure(msg_type, vRecv, llmq_params)) { m_peer_manager->PeerMisbehaving(pfrom.GetId(), 100, "malformed DKG message"); return; } + // Inventory hash is computed over the raw wire bytes, matching what a peer + // would compute for the same payload. The framing walk above validated a + // copy, so vRecv's read position is untouched. + CHashWriter hw(SER_GETHASH, 0); + hw.write(AsWritableBytes(Span{vRecv})); + const uint256 hash = hw.GetHash(); + int inv_type = 0; - if (msg_type == NetMsgType::QCONTRIB) + if (msg_type == NetMsgType::QCONTRIB) { inv_type = MSG_QUORUM_CONTRIB; - else if (msg_type == NetMsgType::QCOMPLAINT) + } else if (msg_type == NetMsgType::QCOMPLAINT) { inv_type = MSG_QUORUM_COMPLAINT; - else if (msg_type == NetMsgType::QJUSTIFICATION) + } else if (msg_type == NetMsgType::QJUSTIFICATION) { inv_type = MSG_QUORUM_JUSTIFICATION; - else if (msg_type == NetMsgType::QPCOMMITMENT) + } else if (msg_type == NetMsgType::QPCOMMITMENT) { inv_type = MSG_QUORUM_PREMATURE_COMMITMENT; + } Assume(inv_type != 0); // guarded by the early-return above auto pm = std::make_shared(std::move(vRecv)); - CHashWriter hw(SER_GETHASH, 0); - hw.write(AsWritableBytes(Span{*pm})); - const uint256 hash = hw.GetHash(); - const NodeId from = pfrom.GetId(); // DKG messages are only ever sent in reply to a GETDATA (see NetDKG::ProcessGetData), so one we @@ -518,7 +736,7 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre break; } Assume(pending != nullptr); - pending->PushPendingMessage(from, std::move(pm), hash); + pending->PushPendingMessage(from, sender_protx, std::move(pm), hash); }); if (!dispatched) { LogPrintf("NetDKG -- no session handlers for quorumIndex [%d]\n", quorumIndex); @@ -697,6 +915,9 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) handler.WaitForNextPhase(std::nullopt, QuorumPhase::Initialized); + // Leftovers missed their matching phase and cannot be accepted by this + // round. Discard their raw bytes without materializing BLS objects on the + // critical path to initializing the next quorum. handler.ClearPendingMessages(); uint256 curQuorumHash = handler.GetCurrentQuorumHash(); @@ -742,8 +963,8 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fContributeWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, handler.pendingContributions, - *m_peer_manager, 8); + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, + handler.pendingContributions, *m_peer_manager, 8); }; handler.HandlePhase(QuorumPhase::Contribute, QuorumPhase::Complain, curQuorumHash, 0.05, fContributeStart, fContributeWait); @@ -755,8 +976,8 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fComplainWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, handler.pendingComplaints, - *m_peer_manager, 8); + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, + handler.pendingComplaints, *m_peer_manager, 8); }; handler.HandlePhase(QuorumPhase::Complain, QuorumPhase::Justify, curQuorumHash, 0.05, fComplainStart, fComplainWait); @@ -767,8 +988,8 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fJustifyWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, handler.pendingJustifications, - *m_peer_manager, 8); + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, + handler.pendingJustifications, *m_peer_manager, 8); }; handler.HandlePhase(QuorumPhase::Justify, QuorumPhase::Commit, curQuorumHash, 0.05, fJustifyStart, fJustifyWait); @@ -779,7 +1000,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) } }; auto fCommitWait = [this, curSession, &handler, &active] { - return ProcessPendingMessageBatch(active.connman, *curSession, + return ProcessPendingMessageBatch(active.connman, *curSession, handler.params, handler.pendingPrematureCommitments, *m_peer_manager, 8); }; diff --git a/src/llmq/net_dkg.h b/src/llmq/net_dkg.h index 2b1d6988878a..f56ff26908ef 100644 --- a/src/llmq/net_dkg.h +++ b/src/llmq/net_dkg.h @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -25,7 +26,11 @@ class CMasternodeMetaMan; class CSporkManager; namespace llmq { class ActiveDKGSessionHandler; +class CDKGComplaint; +class CDKGContribution; class CDKGDebugManager; +class CDKGJustification; +class CDKGPrematureCommitment; class CDKGSessionManager; class CQuorumBlockProcessor; class CQuorumManager; @@ -34,6 +39,38 @@ class QuorumRole; } // namespace llmq namespace llmq { + +/** + * Framing-only validation of a raw DKG payload, run at intake before retention. + * Walks the wire encoding (CompactSize counts, dynamic bitsets, fixed-size BLS + * encodings) and checks truncation, trailing bytes, and the upper bounds derived + * from quorum params, without materializing any BLS object. Validates a copy, so + * @p payload is left untouched for the pending queue and its inventory hash. Typed deserialization + * -- and therefore BLS point decompression, canonical checks, and active-scheme + * handling -- happens exactly once, later, on the DKG worker thread. + * + * @warning This walk mirrors the (Un)serialize implementations in + * llmq/dkgmessages.h by hand. Any change to those must be reflected + * here; src/test/fuzz/dkg_message_framing.cpp asserts that this never + * rejects a payload the worker would accept. + * + * Exposed only so tests can reach it; production callers go through + * NetDKG::ProcessMessage. + */ +bool CheckDKGMessageWireStructure(std::string_view msg_type, const CDataStream& payload, + const Consensus::LLMQParams& params); + +/** + * Param-only structural validation of a typed DKG message: checks only safe + * upper bounds derived from quorum params. Run by the DKG worker immediately + * after deserializing queued bytes and before PreVerifyMessage, so malformed + * structures never reach deeper validation. + */ +bool CheckDKGMessageStructure(const CDKGContribution& qc, const Consensus::LLMQParams& params); +bool CheckDKGMessageStructure(const CDKGComplaint& qc, const Consensus::LLMQParams& params); +bool CheckDKGMessageStructure(const CDKGJustification& qj, const Consensus::LLMQParams& params); +bool CheckDKGMessageStructure(const CDKGPrematureCommitment& qc, const Consensus::LLMQParams& params); + /** * NetHandler responsible for DKG networking: * - QCONTRIB / QCOMPLAINT / QJUSTIFICATION / QPCOMMITMENT / QWATCH ProcessMessage diff --git a/src/test/fuzz/dkg_message_framing.cpp b/src/test/fuzz/dkg_message_framing.cpp new file mode 100644 index 000000000000..eb8f5af66c98 --- /dev/null +++ b/src/test/fuzz/dkg_message_framing.cpp @@ -0,0 +1,157 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace { +const std::array DKG_MSG_TYPES = { + NetMsgType::QCONTRIB, + NetMsgType::QCOMPLAINT, + NetMsgType::QJUSTIFICATION, + NetMsgType::QPCOMMITMENT, +}; + +//! Typed deserialization exactly as the DKG worker performs it: one pass, no +//! trailing bytes tolerated, followed by the param-bound structural check. +template +bool TypedDeserializeSucceeds(Span payload, const Consensus::LLMQParams& params) +{ + CDataStream ds{payload, SER_NETWORK, PROTOCOL_VERSION}; + Message msg; + try { + ds >> msg; + } catch (...) { + return false; + } + return ds.empty() && llmq::CheckDKGMessageStructure(msg, params); +} + +bool TypedDeserializeSucceeds(std::string_view msg_type, Span payload, + const Consensus::LLMQParams& params) +{ + if (msg_type == NetMsgType::QCONTRIB) { + return TypedDeserializeSucceeds(payload, params); + } else if (msg_type == NetMsgType::QCOMPLAINT) { + return TypedDeserializeSucceeds(payload, params); + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + return TypedDeserializeSucceeds(payload, params); + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + return TypedDeserializeSucceeds(payload, params); + } + return false; +} + +//! Build a message that is well formed for @p params, serialize it, and require +//! the framing walk to accept the bytes our own serializer just produced. This +//! covers the equivalence from the other end: it does not depend on the fuzzer +//! synthesizing a valid BLS encoding by chance, so a framing/serializer drift is +//! caught even from an empty corpus. +template +void CheckSerializedMessageIsAccepted(std::string_view msg_type, const Message& msg, + const Consensus::LLMQParams& params) +{ + CDataStream ds{SER_NETWORK, PROTOCOL_VERSION}; + ds << msg; + assert(llmq::CheckDKGMessageStructure(msg, params)); + assert(llmq::CheckDKGMessageWireStructure(msg_type, ds, params)); +} + +void CheckWellFormedMessageIsAccepted(std::string_view msg_type, const Consensus::LLMQParams& params, + FuzzedDataProvider& provider) +{ + const size_t size = params.size > 0 ? static_cast(params.size) : 0; + const size_t min_size = params.minSize > 0 ? static_cast(params.minSize) : 0; + const size_t threshold = params.threshold > 0 ? static_cast(params.threshold) : 0; + if (size == 0 || min_size > size) { + return; + } + + if (msg_type == NetMsgType::QCONTRIB) { + llmq::CDKGContribution qc; + qc.vvec = std::make_shared>(threshold); + qc.contributions = std::make_shared>(); + const size_t blobs = provider.ConsumeIntegralInRange(min_size, size); + qc.contributions->blobs.resize(blobs); + for (auto& blob : qc.contributions->blobs) { + blob.resize(provider.ConsumeIntegralInRange(0, 64)); + } + CheckSerializedMessageIsAccepted(msg_type, qc, params); + } else if (msg_type == NetMsgType::QCOMPLAINT) { + llmq::CDKGComplaint qc; + const size_t members = provider.ConsumeIntegralInRange(0, size); + qc.badMembers.assign(members, false); + qc.complainForMembers.assign(members, false); + for (size_t i = 0; i < members; ++i) { + qc.badMembers[i] = provider.ConsumeBool(); + qc.complainForMembers[i] = provider.ConsumeBool(); + } + CheckSerializedMessageIsAccepted(msg_type, qc, params); + } else if (msg_type == NetMsgType::QJUSTIFICATION) { + llmq::CDKGJustification qj; + qj.contributions.resize(provider.ConsumeIntegralInRange(0, size)); + CheckSerializedMessageIsAccepted(msg_type, qj, params); + } else if (msg_type == NetMsgType::QPCOMMITMENT) { + llmq::CDKGPrematureCommitment qc; + const size_t members = provider.ConsumeIntegralInRange(0, size); + qc.validMembers.assign(members, false); + for (size_t i = 0; i < members; ++i) { + qc.validMembers[i] = provider.ConsumeBool(); + } + CheckSerializedMessageIsAccepted(msg_type, qc, params); + } +} + +void initialize_dkg_message_framing() +{ + BLSInit(); +} +} // namespace + +/** + * DKG network intake validates framing without materializing BLS objects, then + * the DKG worker deserializes the retained bytes exactly once. Those are two + * hand-maintained parsers over one wire format, so they can drift apart. + * + * The safety-critical direction is that the framing walk must never reject a + * payload the worker would have accepted -- otherwise honest DKG messages are + * silently dropped at intake and quorum formation degrades. The converse is not + * asserted: framing deliberately accepts payloads whose BLS points fail to + * decode, so that the worker can score the sender. + */ +FUZZ_TARGET(dkg_message_framing, .init = initialize_dkg_message_framing) +{ + FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()}; + + const std::string_view msg_type = DKG_MSG_TYPES.at( + fuzzed_data_provider.ConsumeIntegralInRange(0, DKG_MSG_TYPES.size() - 1)); + const Consensus::LLMQParams& params = Consensus::available_llmqs.at( + fuzzed_data_provider.ConsumeIntegralInRange(0, Consensus::available_llmqs.size() - 1)); + // Framing is scheme-independent (BLS wire sizes are fixed), but typed + // deserialization is not; cover both so the equivalence is checked on each. + bls::bls_legacy_scheme.store(fuzzed_data_provider.ConsumeBool()); + + CheckWellFormedMessageIsAccepted(msg_type, params, fuzzed_data_provider); + + const std::vector payload = fuzzed_data_provider.ConsumeRemainingBytes(); + + const CDataStream payload_stream{payload, SER_NETWORK, PROTOCOL_VERSION}; + const bool framing_ok = llmq::CheckDKGMessageWireStructure(msg_type, payload_stream, params); + const bool typed_ok = TypedDeserializeSucceeds(msg_type, payload, params); + + assert(!typed_ok || framing_ok); +} diff --git a/src/test/llmq_dkg_tests.cpp b/src/test/llmq_dkg_tests.cpp index 9715a63f2719..12220cd65705 100644 --- a/src/test/llmq_dkg_tests.cpp +++ b/src/test/llmq_dkg_tests.cpp @@ -3,6 +3,9 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include +#include +#include #include #include @@ -23,4 +26,101 @@ BOOST_AUTO_TEST_CASE(llmq_dkgerror) BOOST_REQUIRE(GetSimulatedErrorRate(llmq::DKGError::type::_COUNT) == 0.0); } +namespace { +std::shared_ptr MakeDKGMessage() +{ + return std::make_shared(SER_NETWORK, PROTOCOL_VERSION); +} + +uint256 MakeTestHash(uint8_t value) +{ + uint256 hash; + hash.begin()[0] = value; + return hash; +} +} // namespace + +BOOST_AUTO_TEST_CASE(pending_messages_local_first_uses_full_remote_allowance) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + const uint256 protx_b = MakeTestHash(0xb1); + + // maxMessagesPerSender=2 -> remote cap 4, plus one reserved local slot. + CDKGPendingMessages pending{/*max_messages_per_sender=*/2}; + pending.PushPendingMessage(/*from=*/-1, /*sender_protx=*/uint256(), MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(4)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(5)); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(6).size(), 5U); +} + +BOOST_AUTO_TEST_CASE(pending_messages_quota_survives_reconnect) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + + CDKGPendingMessages pending{/*max_messages_per_sender=*/2}; + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(1))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(2))); + + // Reconnecting mints a fresh NodeId but keeps the proTxHash, so the quota is + // already spent and the queue-wide cap is never reached. + pending.PushPendingMessage(/*from=*/2, protx_a, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/3, protx_a, MakeDKGMessage(), MakeTestHash(4)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(3))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(4))); + + // Draining frees queue slots but does not refund the per-sender quota. + BOOST_CHECK_EQUAL(pending.PopPendingMessages(5).size(), 2U); + pending.PushPendingMessage(/*from=*/4, protx_a, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(5))); + + // A new round resets everything. + pending.Clear(); + pending.PushPendingMessage(/*from=*/4, protx_a, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(5))); +} + +BOOST_AUTO_TEST_CASE(pending_messages_bounded_across_senders) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + const uint256 protx_b = MakeTestHash(0xb1); + const uint256 protx_c = MakeTestHash(0xc1); + + CDKGPendingMessages pending{/*max_messages_per_sender=*/2}; + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + + // One sender's full quota does not consume the queue-wide remote allowance. + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(4)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(3))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(4))); + + // Distinct senders cannot push past the queue-wide remote-message cap. + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(5))); + + // Duplicates are rejected before charging the sender's quota. + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(1)); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(5).size(), 4U); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(6)); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(7)); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(8)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(6))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(7))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(8))); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 6d955f262f73..44c439ff1348 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -10,20 +10,36 @@ not MNAuth-verified are rejected before retention. - oversized DKG payloads are rejected (before deserialization / retention) even from a verified peer. - - structural pre-validation: malformed DKG payloads (valid quorum prefix, garbage - body) are rejected before retention even from a verified peer. + - structural pre-validation: truncated, trailing, or parametrically out-of-bounds + DKG payloads are rejected before retention even from a verified peer. + - BLS objects are not materialized at intake: a structurally plausible payload with + an invalid BLS encoding reaches the DKG worker and is rejected there. + - the per-sender retention quota is keyed by the MNAuth-verified proTxHash, so + reconnecting under a fresh NodeId does not refill it. + - late, framing-valid messages stay bounded across distinct senders and are + discarded without BLS materialization before the next round initializes. - a well-formed DKG message that the peer never announced and was never asked for is dropped before retention, even from a verified peer. -The node must not crash; the sending peer must be scored (Misbehaving). +The node must not crash; rejected malformed messages must be scored where the +matching worker still processes them. """ -from test_framework.messages import ser_compact_size, ser_uint256 +from test_framework.messages import ( + CInv, + hash256, + msg_inv, + ser_compact_size, + ser_uint256, + uint256_from_str, +) from test_framework.p2p import P2PInterface from test_framework.test_framework import DashTestFramework from test_framework.util import wait_until_helper LLMQ_TEST = 100 +# protocol.h GetInventoryType: MSG_QUORUM_CONTRIB +MSG_QUORUM_CONTRIB = 23 # A masternode protx/operator-pubkey pair accepted by the regtest-only `mnauth` # debug RPC, used to mark a P2P connection as MNAuth-verified without BLS signing. @@ -31,6 +47,17 @@ FAKE_PUBKEY = "8e7afdb849e5e2a085b035b62e21c0940c753f2d4501325743894c37162f287bccaffbedd60c36581dabbf127a22e43f" DKG_PUSH_TYPES = [b"qcontrib", b"qcomplaint", b"qjustify", b"qpcommit"] +VALID_BLS_PUBKEY = bytes.fromhex(FAKE_PUBKEY) +INVALID_NONZERO_BLS_PUBKEY = b"\xff" * 48 + +# LLMQ_TEST dkgInterval; phaseBlocks=2, so stage 0=Initialized, 2=Contribute, 4=Complain. +CYCLE_LENGTH = 24 + +# Mirrors CDKGPendingMessages in src/llmq/dkgsessionhandler.h: the handler is built +# with maxMessagesPerSender = params.size * 2, and the queue-wide remote cap is +# twice that again. Both are per message type. +MAX_MESSAGES_PER_SENDER_FACTOR = 2 +MAX_PENDING_REMOTE_FACTOR = 4 class msg_dkg_raw: @@ -48,10 +75,12 @@ def __repr__(self): return "msg_dkg_raw(type=%s, len=%d)" % (self.msgtype, len(self.payload)) -def get_p2p_id(node): +def get_p2p_id(node, uacomment=None): def get_id(): for p in node.getpeerinfo(): for p2p in node.p2ps: + if uacomment is not None and p2p.uacomment != uacomment: + continue if p["subver"] == p2p.strSubVer: return p["id"] return None @@ -68,6 +97,21 @@ def get_score(): wait_until_helper(lambda: get_score() == expected_score, timeout=10) +def send_requested_qcontrib(peer, payload): + """Announce, wait for GETDATA, then deliver a QCONTRIB payload. + + DKG objects only travel inv -> getdata -> object. NetDKG::ProcessMessage scores + unsolicited pushes before retention, so tests that need the message to reach the + pending queue must complete a real request first. Inventory hash matches + CHashWriter(SER_GETHASH, 0) over the raw wire bytes. + """ + inv_hash = uint256_from_str(hash256(payload)) + peer.send_message(msg_inv([CInv(MSG_QUORUM_CONTRIB, inv_hash)])) + peer.wait_for_getdata([inv_hash]) + peer.send_message(msg_dkg_raw(b"qcontrib", payload)) + peer.sync_with_ping() + + class DkgIntakeTest(DashTestFramework): def add_options(self, parser): self.add_wallet_options(parser) @@ -75,8 +119,14 @@ def add_options(self, parser): def set_test_params(self): # -whitelist keeps the adversarial peer connected even after it crosses the # discouragement threshold, so banscore stays observable for the score==100 cases. - # -debug=net surfaces the Misbehaving reason strings in debug.log. - extra_args = [["-whitelist=127.0.0.1", "-debug=net", "-deprecatedrpc=banscore"]] * 4 + # -debug=net surfaces the Misbehaving reason strings in debug.log, while + # -debug=llmq-dkg exposes worker and queue-boundary behavior. + extra_args = [[ + "-whitelist=127.0.0.1", + "-debug=net", + "-debug=llmq-dkg", + "-deprecatedrpc=banscore", + ]] * 4 self.set_dash_test_params(4, 3, extra_args=extra_args) def quorum_hash_prefix(self): @@ -85,14 +135,14 @@ def quorum_hash_prefix(self): # real in-progress quorum and reach the size/structural checks. return bytes([LLMQ_TEST]) + ser_uint256(int(self.quorum_hash, 16)) - def qcontrib_payload(self, blob_count): + def qcontrib_payload(self, blob_count, vvec_pubkey=VALID_BLS_PUBKEY, protx_hash=0): # CDKGContribution: llmqType, quorumHash, proTxHash, vvec, contributions, sig. # LLMQ_TEST uses threshold=2/minSize=2 by default, so blob_count=1 is # well-formed enough to deserialize but below the contribution lower bound. r = self.quorum_hash_prefix() - r += ser_uint256(0) # proTxHash - r += ser_compact_size(2) + b"\x00" * (2 * 48) # BLSVerificationVector - r += b"\x00" * 48 # CBLSIESMultiRecipientBlobs::ephemeralPubKey + r += ser_uint256(protx_hash) + r += ser_compact_size(2) + vvec_pubkey + VALID_BLS_PUBKEY # BLSVerificationVector + r += VALID_BLS_PUBKEY # CBLSIESMultiRecipientBlobs::ephemeralPubKey r += b"\x00" * 32 # CBLSIESMultiRecipientBlobs::ivSeed r += ser_compact_size(blob_count) for _ in range(blob_count): @@ -100,10 +150,10 @@ def qcontrib_payload(self, blob_count): r += b"\x00" * 96 # sig return r - def add_verified_peer(self, node): - peer = node.add_p2p_connection(P2PInterface()) - peer_id = get_p2p_id(node) - assert node.mnauth(peer_id, FAKE_PROTX, FAKE_PUBKEY) + def add_verified_peer(self, node, uacomment=None, protx=FAKE_PROTX): + peer = node.add_p2p_connection(P2PInterface(), uacomment=uacomment) + peer_id = get_p2p_id(node, uacomment) + assert node.mnauth(peer_id, protx, FAKE_PUBKEY) return peer, peer_id def run_test(self): @@ -120,6 +170,9 @@ def run_test(self): self.test_unverified_sender_rejected(mn_node) self.test_oversized_rejected(mn_node) self.test_malformed_rejected(mn_node) + self.test_trailing_bytes_rejected(mn_node) + self.test_malformed_bls_pubkey_rejected_by_worker(mn_node) + self.test_late_messages_bounded(mn_node) self.test_under_min_contribution_blobs_rejected(mn_node) self.test_unrequested_rejected(mn_node) @@ -163,6 +216,126 @@ def test_malformed_rejected(self, node): wait_for_banscore(node, peer_id, 100) node.disconnect_p2ps() + def test_trailing_bytes_rejected(self, node): + self.log.info("QCONTRIB with trailing bytes is rejected at intake (Misbehaving 100)") + peer, peer_id = self.add_verified_peer(node) + wait_for_banscore(node, peer_id, 0) + with node.assert_debug_log(["malformed DKG message"]): + peer.send_message(msg_dkg_raw( + b"qcontrib", + self.qcontrib_payload(blob_count=2) + b"\x00", + )) + peer.sync_with_ping() + wait_for_banscore(node, peer_id, 100) + node.disconnect_p2ps() + + def _start_fresh_dkg_cycle(self, nodes): + """Land on the base block of a fresh DKG cycle (phase 1 / Initialized).""" + skip_count = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) + # move_blocks (not plain generate) so mocktime keeps up with the DKG phase clock. + self.move_blocks(nodes, skip_count) + self.quorum_hash = self.nodes[0].getbestblockhash() + self.wait_for_quorum_phase(self.quorum_hash, 1, self.llmq_size, None, 0, self.mninfo) + + def test_malformed_bls_pubkey_rejected_by_worker(self, node): + self.log.info("QCONTRIB BLS decoding is deferred to the DKG worker (Misbehaving 100)") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + # Queue during Initialized; Contribute's matching drain deserializes and scores. + self._start_fresh_dkg_cycle(nodes) + + peer, peer_id = self.add_verified_peer(node) + wait_for_banscore(node, peer_id, 0) + with node.assert_debug_log( + ["failed to deserialize message"], + unexpected_msgs=["malformed DKG message", "unrequested DKG message"], + timeout=10, + ): + send_requested_qcontrib(peer, self.qcontrib_payload( + blob_count=2, + vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, + )) + wait_for_banscore(node, peer_id, 0) + self.move_blocks(nodes, 2) + wait_for_banscore(node, peer_id, 100) + node.disconnect_p2ps() + + def _send_late_qcontrib(self, node, peer, nonce): + """Send a framing-valid, BLS-invalid QCONTRIB that no on-time worker will drain.""" + send_requested_qcontrib(peer, self.qcontrib_payload( + blob_count=2, + vvec_pubkey=INVALID_NONZERO_BLS_PUBKEY, + # Unique bytes per message so retention is bounded by the quotas rather + # than by duplicate-hash suppression. + protx_hash=nonce, + )) + + def test_late_messages_bounded(self, node): + self.log.info("Late QCONTRIB retention is bounded per sender and queue-wide, then cleared without BLS decoding") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + self._start_fresh_dkg_cycle(nodes) + stage = self.nodes[0].getblockcount() % CYCLE_LENGTH + assert stage == 0, "expected DKG cycle base, got stage %d" % stage + # Park in Complain so nothing drains pendingContributions for the rest of the round. + complain_stage = 4 + self.move_blocks(nodes, complain_stage - stage) + assert self.nodes[0].getblockcount() % CYCLE_LENGTH == complain_stage + + # Part 1: the per-sender quota is keyed by the verified proTxHash, so a peer + # that reconnects under a fresh NodeId keeps spending the same budget. + sender_quota = MAX_MESSAGES_PER_SENDER_FACTOR * self.llmq_size + nonce = 0 + for i in range(sender_quota): + nonce += 1 + peer, peer_id = self.add_verified_peer(node, "dkg-reconnect-%d" % i) + self._send_late_qcontrib(node, peer, nonce) + wait_for_banscore(node, peer_id, 0) + peer.peer_disconnect() + peer.wait_for_disconnect() + + nonce += 1 + quota_peer, quota_peer_id = self.add_verified_peer(node, "dkg-reconnect-over") + with node.assert_debug_log(["too many messages from %s" % FAKE_PROTX]): + self._send_late_qcontrib(node, quota_peer, nonce) + wait_for_banscore(node, quota_peer_id, 0) + + # Part 2: distinct senders each get their own quota, but the queue-wide cap + # still bounds total retention. Keep the last accepted peer connected to + # verify that round-start clearing does not score stale BLS encodings. + queue_limit = MAX_PENDING_REMOTE_FACTOR * self.llmq_size + retained_peer_id = None + for i in range(queue_limit - sender_quota): + nonce += 1 + peer, peer_id = self.add_verified_peer(node, "dkg-sender-%d" % i, protx="%064x" % (0xd0 + i)) + self._send_late_qcontrib(node, peer, nonce) + wait_for_banscore(node, peer_id, 0) + retained_peer_id = peer_id + + nonce += 1 + overflow_peer, overflow_peer_id = self.add_verified_peer(node, "dkg-sender-over", protx="%064x" % 0xffff) + with node.assert_debug_log(["pending queue full"]): + self._send_late_qcontrib(node, overflow_peer, nonce) + wait_for_banscore(node, overflow_peer_id, 0) + + # Crossing the phase boundary must clear a bounded raw queue and finish + # initializing the next session without deserializing stale BLS points. + remaining = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) + with node.assert_debug_log( + [], + unexpected_msgs=[ + "malformed DKG message", + "failed to deserialize message", + "message failed structure check", + ], + timeout=60, + ): + self.move_blocks(nodes, remaining) + self.quorum_hash = self.nodes[0].getbestblockhash() + self.wait_for_quorum_phase(self.quorum_hash, 1, self.llmq_size, None, 0, self.mninfo) + assert retained_peer_id is not None + wait_for_banscore(node, retained_peer_id, 0) + wait_for_banscore(node, overflow_peer_id, 0) + node.disconnect_p2ps() + def test_under_min_contribution_blobs_rejected(self, node): self.log.info("QCONTRIB with fewer than minSize encrypted blobs is rejected (Misbehaving 100)") peer, peer_id = self.add_verified_peer(node)