From 3174d5fa449deb987ede5c6aaaa5759dbddf1b96 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Thu, 29 Oct 2026 14:46:48 +0300 Subject: [PATCH 01/10] fix(instantsend): propagate !cs_timingsTxSeen to the callers of RemoveNonLockedTx The backport of #7858 made RemoveNonLockedTx and HandleFullyConfirmedBlock take cs_timingsTxSeen and annotated them with !cs_timingsTxSeen, but on v23.1.x they are also called from other CInstantSendManager methods that were left unannotated. Clang's thread-safety analysis rejects those calls, so a clang build with --enable-werror (the default in CI) fails in instantsend.cpp. develop is not affected: its remaining same-class callers already carry the annotation. Add !cs_timingsTxSeen to the direct callers (ProcessInstantSendLock, RemoveConflictedTx, NotifyChainLock, UpdatedBlockTip) and to the callers of RemoveConflictedTx (RemoveMempoolConflictsForLock, ResolveBlockConflicts). Annotations only; no behavior change. Verified with a full clang build using --enable-werror. Co-Authored-By: Claude Opus 5.5 --- src/instantsend/instantsend.h | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/instantsend/instantsend.h b/src/instantsend/instantsend.h index 6f15ca8d6571..b278c76c80b4 100644 --- a/src/instantsend/instantsend.h +++ b/src/instantsend/instantsend.h @@ -141,13 +141,13 @@ class CInstantSendManager final : public instantsend::InstantSendSignerParent void RemoveNonLockedTx(const uint256& txid, bool retryChildren) EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry, !cs_timingsTxSeen); void RemoveConflictedTx(const CTransaction& tx) - EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry); + EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry, !cs_timingsTxSeen); void TruncateRecoveredSigsForInputs(const instantsend::InstantSendLock& islock); void RemoveMempoolConflictsForLock(const uint256& hash, const instantsend::InstantSendLock& islock) - EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry); + EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry, !cs_timingsTxSeen); void ResolveBlockConflicts(const uint256& islockHash, const instantsend::InstantSendLock& islock) - EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingLocks, !cs_pendingRetry, !cs_height_cache); + EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingLocks, !cs_pendingRetry, !cs_timingsTxSeen, !cs_height_cache); void HandleFullyConfirmedBlock(const CBlockIndex* pindex) EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry, !cs_timingsTxSeen); @@ -167,7 +167,7 @@ class CInstantSendManager final : public instantsend::InstantSendSignerParent CSigningManager& Sigman() { return sigman; } [[nodiscard]] std::variant ProcessInstantSendLock( NodeId from, const uint256& hash, const instantsend::InstantSendLockPtr& islock) - EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingLocks, !cs_pendingRetry, !cs_height_cache); + EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingLocks, !cs_pendingRetry, !cs_timingsTxSeen, !cs_height_cache); void TransactionAddedToMempool(const CTransactionRef& tx) EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingLocks, !cs_pendingRetry, !cs_timingsTxSeen); @@ -183,9 +183,9 @@ class CInstantSendManager final : public instantsend::InstantSendSignerParent instantsend::InstantSendLockPtr GetInstantSendLockByTxid(const uint256& txid) const; void NotifyChainLock(const CBlockIndex* pindexChainLock) - EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry); + EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry, !cs_timingsTxSeen); void UpdatedBlockTip(const CBlockIndex* pindexNew) - EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry, !cs_height_cache); + EXCLUSIVE_LOCKS_REQUIRED(!cs_nonLocked, !cs_pendingRetry, !cs_timingsTxSeen, !cs_height_cache); void RemoveConflictingLock(const uint256& islockHash, const instantsend::InstantSendLock& islock) EXCLUSIVE_LOCKS_REQUIRED(!cs_height_cache); From c8627a6e130da5222ad933d9db8a46dde5d55115 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Thu, 29 Oct 2026 15:32:59 +0300 Subject: [PATCH 02/10] fix(llmq): drop DKG members whose verification vectors share coefficients A quorum member can copy another member's whole contribution, its verification vector and encrypted shares alike, and re-sign it with its own operator key. BLS-IES decryption is bound to the recipient, not the dealer, so every recipient decrypts a share that is consistent with the copied vvec: the copy passes the individual contribution check and nobody complains. SendCommitment then fails to build the quorum verification vector, because VerifyVerificationVectors rejects coefficients shared between members (and AggregateSecretKeys rejects the identical shares), so no honest member sends a premature commitment and a single member kills the DKG round. The copy and the original cannot be told apart, so VerifyAndCommit now marks every remaining member whose vvec shares a coefficient with another one as bad, after members with open complaints are excluded. Members that agree on the remaining members and hold the same contributions drop the same ones, so the quorum vvec can be built and they still agree on the commitment. A new CBLSWorker::FindVerificationVectorsWithSharedCoefficients() helper finds them with the same hash comparison VerifyVerificationVectors uses. PoSe trade-off: both members are invalid in the final commitment, so each gets the usual CalcPenalty(66) PoSe penalty for failed DKG participation. An attacker can therefore make a chosen honest co-member take that penalty (a ban if repeated in two DKGs close together), paying the same penalty itself and only when both are selected into the same quorum. Before this change the same attack prevented the commitment altogether. Possible follow-up: the cross-member duplicate checks look like input hygiene from the original DKG worker (3ee27c1681) rather than a security requirement. Joint-Feldman DKG stays secure while one honest dealer's polynomial is secret, and a copied contribution gives the copier no control over the quorum key, so accepting duplicates would neutralize the attack without punishing anyone. That needs a cryptographic review and relaxing the shared duplicate checks in quorum vvec building and secret key aggregation, and nodes still running older code would keep failing on a duplicated set during a rollout, so it is not done here. Binding each contribution to its dealer would also exclude only the copier, but it changes the DKG protocol. Test: bls_verify_contribution_shares_duplicate_coefficient_tests now covers the full copy (every share verifies, the quorum vvec can't be built, the helper flags exactly that pair, and the vvec builds without them). The VerifyAndCommit marking itself is covered end to end by the feature_llmq_simplepose scenario added in the following commit, which fails without it. feature_llmq_dkgerrors, feature_llmq_rotation, feature_llmq_signing and feature_llmq_simplepose pass. Co-Authored-By: Claude Opus 5.5 --- src/active/dkgsession.cpp | 26 +++++++++++++++++++++++++- src/bls/bls_worker.cpp | 20 ++++++++++++++++++++ src/bls/bls_worker.h | 4 ++++ src/test/bls_tests.cpp | 17 +++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/active/dkgsession.cpp b/src/active/dkgsession.cpp index 7cc27c2d5f6a..f5cd21fe8f9b 100644 --- a/src/active/dkgsession.cpp +++ b/src/active/dkgsession.cpp @@ -429,7 +429,25 @@ void ActiveDKGSession::VerifyAndCommit(CDKGPendingMessages& pendingMessages, Pee } } - if (!badMembers.empty() || !openComplaintMembers.empty()) { + // A member can copy another member's whole contribution: every share verifies, but the duplicated + // coefficients make the quorum verification vector impossible to build, so no commitment could be made. + // The copy can't be told from the original, so drop both; members that agree on the remaining members + // and hold the same contributions drop the same ones and still agree on the commitment. + std::vector remainingMembers; + std::vector remainingVvecs; + for (const auto& m : members) { + if (!m->bad) { + remainingMembers.emplace_back(m->idx); + remainingVvecs.emplace_back(receivedVvecs[m->idx]); + } + } + std::vector sharedCoefficientMembers; + for (const auto i : CBLSWorker::FindVerificationVectorsWithSharedCoefficients(remainingVvecs)) { + MarkBadMember(remainingMembers[i]); + sharedCoefficientMembers.emplace_back(remainingMembers[i]); + } + + if (!badMembers.empty() || !openComplaintMembers.empty() || !sharedCoefficientMembers.empty()) { logger.Batch("verification result:"); } if (!badMembers.empty()) { @@ -444,6 +462,12 @@ void ActiveDKGSession::VerifyAndCommit(CDKGPendingMessages& pendingMessages, Pee logger.Batch(" %s", members[idx]->dmn->proTxHash.ToString()); } } + if (!sharedCoefficientMembers.empty()) { + logger.Batch(" members sharing verification vector coefficients and now marked as bad:"); + for (const auto& idx : sharedCoefficientMembers) { + logger.Batch(" %s", members[idx]->dmn->proTxHash.ToString()); + } + } logger.Flush(); diff --git a/src/bls/bls_worker.cpp b/src/bls/bls_worker.cpp index 9f06c13763fb..15e4c6bdae47 100644 --- a/src/bls/bls_worker.cpp +++ b/src/bls/bls_worker.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -805,6 +806,25 @@ bool CBLSWorker::VerifyVerificationVectors(Span vvecs) return true; } +std::set CBLSWorker::FindVerificationVectorsWithSharedCoefficients(Span vvecs) +{ + std::map owners; + std::set ret; + for (size_t i = 0; i < vvecs.size(); i++) { + if (vvecs[i] == nullptr) { + continue; + } + for (const auto& coefficient : *vvecs[i]) { + const auto [it, inserted] = owners.emplace(coefficient.GetHash(), i); + if (!inserted && it->second != i) { + ret.emplace(it->second); + ret.emplace(i); + } + } + } + return ret; +} + void CBLSWorker::AsyncSign(const CBLSSecretKey& secKey, const uint256& msgHash, const CBLSWorker::SignDoneCallback& doneCallback) { workerPool.push([secKey, msgHash, doneCallback](int threadId) { diff --git a/src/bls/bls_worker.h b/src/bls/bls_worker.h index cc1b0d75d5f2..c2ece38e0f50 100644 --- a/src/bls/bls_worker.h +++ b/src/bls/bls_worker.h @@ -11,6 +11,7 @@ #include #include +#include #include // Low level BLS/DKG stuff. All very compute intensive and optimized for parallelization @@ -119,6 +120,9 @@ class CBLSWorker // Simple verification of vectors. Checks x.IsValid() for every entry and checks for duplicate entries static bool VerifyVerificationVector(Span vvec); static bool VerifyVerificationVectors(Span vvecs); + //! Indexes of the vvecs sharing a coefficient with another vvec, which VerifyVerificationVectors() rejects. + //! Null vvecs are skipped. + static std::set FindVerificationVectorsWithSharedCoefficients(Span vvecs); // Internally batched signature signing and verification void AsyncSign(const CBLSSecretKey& secKey, const uint256& msgHash, const SignDoneCallback& doneCallback); diff --git a/src/test/bls_tests.cpp b/src/test/bls_tests.cpp index d448fa7e7a4c..8e9a9ef9c875 100644 --- a/src/test/bls_tests.cpp +++ b/src/test/bls_tests.cpp @@ -687,6 +687,23 @@ void FuncVerifyContributionSharesDuplicateCoefficient(const bool legacy_scheme) BOOST_CHECK(!res[0]); // the member that planted the duplicate is caught alone BOOST_CHECK(res[1]); // the honest member whose coefficient was copied is unaffected BOOST_CHECK(res[2]); // the other honest member is unaffected + BOOST_CHECK(CBLSWorker::FindVerificationVectorsWithSharedCoefficients(vvecs) == (std::set{0, 1})); + + // Dealer 0 copies dealer 1's whole contribution, vvec and share alike: every share verifies, + // but the quorum verification vector can't be built until both copies are dropped. + vvecs[0] = std::make_shared>(*vvecs[1]); + recvShares[0] = skShares[1][R]; + res = worker.VerifyContributionShares(recvId, vvecs, recvShares); + BOOST_REQUIRE_EQUAL(res.size(), 3U); + BOOST_CHECK(res[0] && res[1] && res[2]); + BOOST_CHECK(worker.BuildQuorumVerificationVector(vvecs) == nullptr); + BOOST_CHECK(CBLSWorker::FindVerificationVectorsWithSharedCoefficients(vvecs) == (std::set{0, 1})); + std::vector remaining{vvecs[2]}; + BOOST_CHECK(worker.BuildQuorumVerificationVector(remaining) != nullptr); + + // Distinct vvecs share nothing, and a missing vvec is skipped. + std::vector distinct{vvecs[1], nullptr, vvecs[2]}; + BOOST_CHECK(CBLSWorker::FindVerificationVectorsWithSharedCoefficients(distinct).empty()); worker.Stop(); } From 1ec6aaa10b3def84e414c99df30b4ebe49e971bc Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Thu, 29 Oct 2026 15:47:51 +0300 Subject: [PATCH 03/10] test: cover DKG members dealing identical contributions Add a regtest-only "contribution-clone" DKG sim error: a member simulating it derives its secret polynomial from the quorum hash alone, so every member simulating it deals the same contribution, as if it had copied another member's whole contribution. feature_llmq_simplepose (5-member llmq_test, minSize 3) now starts with a scenario where two masternodes deal cloned contributions. The quorum is mined with exactly the other three as valid members, and both cloners are PoSe-punished but not banned. The scenario then lets the penalty decay with DKG disabled, so the existing scenarios start from a clean state. Without the VerifyAndCommit marking from the previous commit, no member can build the quorum verification vector, no premature commitment is sent and the scenario fails waiting for the commit phase. Co-Authored-By: Claude Opus 5.5 --- src/active/dkgsession.cpp | 20 +++++++++++++++- src/llmq/dkgsession.h | 2 ++ test/functional/feature_llmq_simplepose.py | 27 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/active/dkgsession.cpp b/src/active/dkgsession.cpp index f5cd21fe8f9b..c2b74b2e9cb7 100644 --- a/src/active/dkgsession.cpp +++ b/src/active/dkgsession.cpp @@ -15,6 +15,8 @@ #include #include +#include +#include #include #include @@ -47,7 +49,23 @@ void ActiveDKGSession::Contribute(CDKGPendingMessages& pendingMessages, PeerMana cxxtimer::Timer t1(true); logger.Batch("generating contributions"); - if (!blsWorker.GenerateContributions(params.threshold, memberIds, vvecContribution, m_sk_contributions)) { + if (ShouldSimulateError(DKGError::type::CONTRIBUTION_CLONE)) { + // Derive the contribution from the quorum alone, so every member simulating this deals the same + // contribution, as if it had copied another member's + logger.Batch("dealing a cloned contribution"); + std::vector svec(params.threshold); + vvecContribution = std::make_shared>(); + for (const auto i : irange::range(svec.size())) { + uint256 seed{::SerializeHash(std::make_pair(m_quorum_base_block_index->GetBlockHash(), uint32_t(i)))}; + *seed.begin() = 0; // keep it below the group order + svec[i] = CBLSSecretKey{Span{seed}}; + vvecContribution->emplace_back(svec[i].GetPublicKey()); + } + m_sk_contributions.resize(memberIds.size()); + for (const auto i : irange::range(memberIds.size())) { + m_sk_contributions[i].SecretKeyShare(svec, memberIds[i]); + } + } else if (!blsWorker.GenerateContributions(params.threshold, memberIds, vvecContribution, m_sk_contributions)) { // this should never happen actually logger.Batch("GenerateContributions failed"); return; diff --git a/src/llmq/dkgsession.h b/src/llmq/dkgsession.h index 7a7e85660378..210a4ee2e7fd 100644 --- a/src/llmq/dkgsession.h +++ b/src/llmq/dkgsession.h @@ -73,6 +73,7 @@ class DKGError { JUSTIFY_OMIT, JUSTIFY_LIE, CONTRIBUTION_INVALID, + CONTRIBUTION_CLONE, _COUNT }; static constexpr DKGError::type from_string(std::string_view in) { @@ -84,6 +85,7 @@ class DKGError { if (in == "justify-lie") return JUSTIFY_LIE; if (in == "justify-omit") return JUSTIFY_OMIT; if (in == "contribution-invalid") return CONTRIBUTION_INVALID; + if (in == "contribution-clone") return CONTRIBUTION_CLONE; return _COUNT; } }; diff --git a/test/functional/feature_llmq_simplepose.py b/test/functional/feature_llmq_simplepose.py index 274e71556cab..20d1cae2a131 100755 --- a/test/functional/feature_llmq_simplepose.py +++ b/test/functional/feature_llmq_simplepose.py @@ -45,6 +45,8 @@ def run_test(self): self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) self.wait_for_sporks_same() + self.test_cloned_contributions() + # Lets isolate MNs one by one and verify that punishment/banning happens self.test_banning(self.isolate_mn, 2) @@ -72,6 +74,31 @@ def run_test(self): # With PoSe off there should be no punishing for outdated nodes self.test_no_banning(self.force_old_mn_proto, 3) + def test_cloned_contributions(self): + self.log.info("Two members dealing identical contributions are both excluded and the quorum still forms") + cloners = self.mninfo[:2] + others = self.mninfo[2:] + for mn in cloners: + mn.get_node(self).quorum('dkgsimerror', 'contribution-clone', '100') + qh = self.mine_quorum(mninfos_valid=others) + for mn in cloners: + mn.get_node(self).quorum('dkgsimerror', 'contribution-clone', '0') + + valid_members = {m['proTxHash'] for m in self.nodes[0].quorum('info', 100, qh)['members'] if m['valid']} + assert_equal(valid_members, {mn.proTxHash for mn in others}) + for mn in cloners: + assert check_punished(self.nodes[0], mn) + assert not check_banned(self.nodes[0], mn) + + self.log.info("Let the PoSe penalty of the cloners decay without running any DKG") + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 4070908800) + self.wait_for_sporks_same() + self.generate(self.nodes[0], 66) + for mn in self.mninfo: + assert not check_punished(self.nodes[0], mn) + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + self.wait_for_sporks_same() + def isolate_mn(self, mn: MasternodeInfo): mn.get_node(self).setnetworkactive(False) self.wait_until(lambda: mn.get_node(self).getconnectioncount() == 0) From 12bbd216fe628d10eed15d8ae5681b7cb1879144 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Thu, 29 Oct 2026 20:52:48 +0300 Subject: [PATCH 04/10] fix(llmq): weight batched DKG contribution verification During the DKG every member verifies the contributions it received with CBLSWorker::VerifyContributionShares. Its aggregated mode, which the DKG uses, summed the verification vectors and the secret-key shares of each batch of CONTRIBUTION_VERIFY_BATCH_SIZE dealers without weights and marked the whole batch valid when the sums matched. Two colluding dealers in the same batch could therefore hand a recipient wrong shares whose errors cancel (for example by swapping them): the recipient doesn't complain and stores wrong verified shares, and if one of the dealers is later excluded its quorum secret key share is wrong. Each batch is now verified by one worker task that multiplies every dealer's vvec coefficients and the public key of its share by the same fresh secret random 64-bit weight before aggregating, and checks that the public key share recovered from the aggregated vvec matches the aggregated public keys. A batch that fails falls back to verifying its contributions one by one, as before; a batch whose aggregation failed outright used to be marked invalid as a whole instead. The old unweighted aggregation flow for this path is removed, and MakeBatchVerificationWeight takes the weight size. 64-bit weights: a batch with a wrong contribution passes with probability about 2^-64. Colluding dealers lose nothing on a failed attempt (they can answer the complaint with a valid justification), so an attacker gets many free attempts, but the weights never leave the verifying node, so it can't test them offline, and 2^-64 per attempt stays negligible. 32-bit weights would have been about 1.9x faster but leave a small chance of success over the lifetime of the network. The weighting is only sound for points in the prime-order subgroup. Legacy-scheme deserialization skips that check, so CDKGContribution now decodes its whole vvec under a single scheme and records it in a non-serialized vvecLegacyScheme field (the wire format is unchanged). The session keeps it per member, and VerifyPendingContributions only uses the weighted batches when none of the pending vvecs was decoded under the legacy scheme, verifying them one by one otherwise. A point decoded under the basic scheme has always passed the subgroup check, whatever the chain did since, so this also covers a reorg across the v19 activation. Release build (-O2), one member verifying every dealer's contribution, all valid, 4 BLS worker threads: quorum unweighted (old) individual weighted (new) llmq_100_67 49 ms 292 ms 189 ms llmq_400_60 0.58 s 4.15 s 2.45 s llmq_400_85 0.82 s 5.92 s 3.46 s Also fix bench/bls_dkg.cpp, which passed its aggregated flag in the parallel position, so its "aggregated" and "simple" variants compared parallel against serial aggregated verification. Tests: bls_verify_contribution_shares_honest_tests now covers two dealers in one batch swapping shares (both blamed) and a missing share (only it blamed), with and without parallel verification, through the default arguments the DKG uses; the swap cases fail with all weights set to 1. llmq_dkg_tests/contribution_records_vvec_scheme round-trips a contribution under each scheme and checks the recorded scheme and the decoded point. Co-Authored-By: Claude Opus 5.5 --- src/active/dkgsession.cpp | 10 +++- src/bench/bls_dkg.cpp | 3 +- src/bls/bls_batchverifier.h | 9 +-- src/bls/bls_worker.cpp | 111 ++++++++++++++++-------------------- src/bls/bls_worker.h | 12 ++-- src/llmq/dkgmessages.h | 8 ++- src/llmq/dkgsession.cpp | 2 + src/llmq/dkgsession.h | 2 + src/test/bls_tests.cpp | 24 ++++++++ src/test/llmq_dkg_tests.cpp | 35 ++++++++++++ 10 files changed, 141 insertions(+), 75 deletions(-) diff --git a/src/active/dkgsession.cpp b/src/active/dkgsession.cpp index 7cc27c2d5f6a..1a1d848a152b 100644 --- a/src/active/dkgsession.cpp +++ b/src/active/dkgsession.cpp @@ -119,9 +119,9 @@ void ActiveDKGSession::SendContributions(CDKGPendingMessages& pendingMessages, P } // Verifies all pending secret key contributions in one batch -// This is done by aggregating the verification vectors belonging to the secret key contributions +// This is done by aggregating randomly weighted verification vectors belonging to the secret key contributions // The resulting aggregated vvec is then used to recover a public key share -// The public key share must match the public key belonging to the aggregated secret key contributions +// The public key share must match the equally weighted public keys of the secret key contributions // See CBLSWorker::VerifyContributionShares for more details. void ActiveDKGSession::VerifyPendingContributions() { @@ -138,6 +138,7 @@ void ActiveDKGSession::VerifyPendingContributions() std::vector memberIndexes; std::vector vvecs; std::vector skContributions; + bool anyLegacyVvec{false}; for (const auto& idx : pendingContributionVerifications) { const auto& m = members[idx]; @@ -146,6 +147,7 @@ void ActiveDKGSession::VerifyPendingContributions() } memberIndexes.emplace_back(idx); vvecs.emplace_back(receivedVvecs[idx]); + anyLegacyVvec |= receivedVvecsLegacyScheme[idx]; skContributions.emplace_back(receivedSkContributions[idx]); // Write here to definitely store one contribution for each member no matter if // our share is valid or not, could be that others are still correct @@ -163,7 +165,9 @@ void ActiveDKGSession::VerifyPendingContributions() return; } - auto result = blsWorker.VerifyContributionShares(myId, vvecs, skContributions); + // Weighted aggregation needs points that passed subgroup checks, which legacy-scheme deserialization skips + auto result = blsWorker.VerifyContributionShares(myId, vvecs, skContributions, /*parallel=*/true, + /*aggregated=*/!anyLegacyVvec); if (result.size() != memberIndexes.size()) { logger.Batch("VerifyContributionShares returned result of size %d but size %d was expected, something is wrong", result.size(), memberIndexes.size()); return; diff --git a/src/bench/bls_dkg.cpp b/src/bench/bls_dkg.cpp index fa243fa439fb..362a045a2e4b 100644 --- a/src/bench/bls_dkg.cpp +++ b/src/bench/bls_dkg.cpp @@ -44,7 +44,8 @@ class DKG void VerifyContributionShares(size_t whoAmI, const std::set& invalidIndexes, bool aggregated) { - auto result = blsWorker.VerifyContributionShares(members[whoAmI].id, receivedVvecs, receivedSkShares, aggregated); + auto result = blsWorker.VerifyContributionShares(members[whoAmI].id, receivedVvecs, receivedSkShares, + /*parallel=*/true, aggregated); for (const size_t i : irange::range(receivedVvecs.size())) { if (invalidIndexes.count(i)) { assert(!result[i]); diff --git a/src/bls/bls_batchverifier.h b/src/bls/bls_batchverifier.h index 93eb5e5df502..647d38e675e3 100644 --- a/src/bls/bls_batchverifier.h +++ b/src/bls/bls_batchverifier.h @@ -13,14 +13,15 @@ #include #include -//! A secret random nonzero 128-bit scalar, well below the group order. Weighting each signature and its public key by -//! one makes errors in several signatures unable to cancel out in an aggregate. -inline CBLSSecretKey MakeBatchVerificationWeight(FastRandomContext& rng) +//! A secret random nonzero scalar of numBytes bytes (at most 16, so well below the group order). Weighting each +//! signature and its public key by one makes errors in several signatures unable to cancel out in an aggregate. +inline CBLSSecretKey MakeBatchVerificationWeight(FastRandomContext& rng, size_t numBytes = 16) { + assert(numBytes > 0 && numBytes <= 16); std::array bytes{}; do { const uint256 rand{rng.rand256()}; - std::copy(rand.begin(), rand.begin() + 16, bytes.end() - 16); + std::copy(rand.begin(), rand.begin() + numBytes, bytes.end() - numBytes); } while (std::all_of(bytes.begin(), bytes.end(), [](uint8_t b) { return b == 0; })); return CBLSSecretKey{bytes}; } diff --git a/src/bls/bls_worker.cpp b/src/bls/bls_worker.cpp index 9f06c13763fb..65d87670d687 100644 --- a/src/bls/bls_worker.cpp +++ b/src/bls/bls_worker.cpp @@ -3,7 +3,9 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include +#include #include #include @@ -393,13 +395,6 @@ struct ContributionVerifier : public std::enable_shared_from_this > aggDone; - // we can't directly update a vector in parallel // as vector is not thread safe (uses bitsets internally) // so we must use vector temporarily and concatenate/convert @@ -452,7 +447,6 @@ struct ContributionVerifier : public std::enable_shared_from_this>(0); batchState.start = i * batchSize; batchState.count = std::min(batchSize, vvecs.size() - batchState.start); batchState.verifyResults.assign(batchState.count, 0); @@ -484,32 +478,60 @@ struct ContributionVerifier : public std::enable_shared_from_thisshared_from_this()); - auto vvecAgg = std::make_shared>(vvecs.subspan(batchState.start, batchState.count), parallel, workerPool, [this, self, batchIdx] (const BLSVerificationVectorPtr& vvec) {HandleAggVvecDone(batchIdx, vvec);}); - auto skShareAgg = std::make_shared>(Span{skShares}.subspan(batchState.start, batchState.count), parallel, workerPool, [this, self, batchIdx] (const CBLSSecretKey& skShare) {HandleAggSkShareDone(batchIdx, skShare);}); - - vvecAgg->Start(); - skShareAgg->Start(); + auto f = [this, self, batchIdx](int threadId) { + auto& batchState = batchStates[batchIdx]; + if (VerifyWeightedBatch(batchState)) { + // whole batch is valid + batchState.verifyResults.assign(batchState.count, 1); + HandleVerifyDone(batchState.count); + } else { + // at least one entry in the batch is invalid, revert to per-contribution verification (but parallelized) + AsyncVerifyBatchOneByOne(batchIdx); + } + }; + PushOrDoWork(std::move(f)); } - void HandleAggVvecDone(size_t batchIdx, const BLSVerificationVectorPtr& vvec) + // Each contribution's vvec and the public key of its share are multiplied by the same secret random weight before + // they are aggregated, so that errors in the contributions of several dealers can't cancel out. 64-bit weights + // let a batch with an invalid contribution pass with a probability of at most 2^-64. + bool VerifyWeightedBatch(const BatchState& batchState) const { - auto& batchState = batchStates[batchIdx]; - batchState.vvec = vvec; - if (++(*batchState.aggDone) == 2) { - HandleAggDone(batchIdx); + FastRandomContext rng; + std::vector aggVvec; + std::vector pubKeys; + pubKeys.reserve(batchState.count); + for (size_t i = batchState.start; i < batchState.start + batchState.count; i++) { + const auto& vvec = vvecs[i]; + if (vvec == nullptr || vvec->empty() || !skShares[i].IsValid() || + (!aggVvec.empty() && vvec->size() != aggVvec.size())) { + return false; + } + const CBLSSecretKey weight{MakeBatchVerificationWeight(rng, /*numBytes=*/8)}; + CBLSPublicKey pubKey{skShares[i].GetPublicKey()}; + pubKey.MulInsecure(weight); + pubKeys.emplace_back(pubKey); + const bool first{aggVvec.empty()}; + for (size_t j = 0; j < vvec->size(); j++) { + CBLSPublicKey coefficient{(*vvec)[j]}; + if (!coefficient.IsValid()) { + return false; + } + coefficient.MulInsecure(weight); + if (first) { + aggVvec.emplace_back(coefficient); + } else { + aggVvec[j].AggregateInsecure(coefficient); + } + } } - } - void HandleAggSkShareDone(size_t batchIdx, const CBLSSecretKey& skShare) - { - auto& batchState = batchStates[batchIdx]; - batchState.skShare = skShare; - if (++(*batchState.aggDone) == 2) { - HandleAggDone(batchIdx); + + CBLSPublicKey expected; + if (!expected.PublicKeyShare(aggVvec, forId)) { + return false; } + return expected == CBLSPublicKey::AggregateInsecure(pubKeys); } void HandleVerifyDone(size_t count) @@ -520,39 +542,6 @@ struct ContributionVerifier : public std::enable_shared_from_thisempty() || !batchState.skShare.IsValid()) { - // something went wrong while aggregating and there is nothing we can do now except mark the whole batch as failed - // this can only happen if inputs were invalid in some way - batchState.verifyResults.assign(batchState.count, 0); - HandleVerifyDone(batchState.count); - return; - } - - AsyncAggregatedVerifyBatch(batchIdx); - } - - void AsyncAggregatedVerifyBatch(size_t batchIdx) - { - auto self(this->shared_from_this()); - auto f = [this, self, batchIdx](int threadId) { - auto& batchState = batchStates[batchIdx]; - bool result = Verify(batchState.vvec, batchState.skShare); - if (result) { - // whole batch is valid - batchState.verifyResults.assign(batchState.count, 1); - HandleVerifyDone(batchState.count); - } else { - // at least one entry in the batch is invalid, revert to per-contribution verification (but parallelized) - AsyncVerifyBatchOneByOne(batchIdx); - } - }; - PushOrDoWork(std::move(f)); - } - void AsyncVerifyBatchOneByOne(size_t batchIdx) { size_t count = batchStates[batchIdx].count; diff --git a/src/bls/bls_worker.h b/src/bls/bls_worker.h index cc1b0d75d5f2..be464a6dd070 100644 --- a/src/bls/bls_worker.h +++ b/src/bls/bls_worker.h @@ -102,11 +102,13 @@ class CBLSWorker static constexpr size_t CONTRIBUTION_VERIFY_BATCH_SIZE{8}; // The following functions verify multiple verification vectors and contributions for the same id - // This is parallelized by performing batched verification. The verification vectors and the contributions of - // a batch are aggregated (in parallel, see AsyncBuildQuorumVerificationVector and AsyncBuildSecretKeyShare). The - // result per batch is a single aggregated verification vector and a single aggregated contribution, which are then - // verified with VerifyContributionShare. If verification of the aggregated inputs is successful, the whole batch - // is marked as valid. If the batch verification fails, the individual entries are verified in a non-aggregated manner + // This is parallelized by performing batched verification. Each verification vector of a batch and the public key of + // its contribution are multiplied by the same secret random 64-bit weight, so that errors in the contributions of + // several dealers can't cancel out, and aggregated. The public key share recovered from the aggregated verification + // vector must match the aggregated public key. If it does, the whole batch is marked as valid. If the batch + // verification fails, the individual entries are verified in a non-aggregated manner + // The weighting is only sound for points that passed subgroup checks, so callers that may hold objects deserialized + // under the legacy scheme must pass aggregated=false void AsyncVerifyContributionShares(const CBLSId& forId, Span vvecs, Span skShares, bool parallel, bool aggregated, std::function&)> doneCallback); std::future > AsyncVerifyContributionShares(const CBLSId& forId, Span vvecs, Span skShares, diff --git a/src/llmq/dkgmessages.h b/src/llmq/dkgmessages.h index b3421c88ba75..07034160ddd2 100644 --- a/src/llmq/dkgmessages.h +++ b/src/llmq/dkgmessages.h @@ -26,6 +26,8 @@ class CDKGContribution BLSVerificationVectorPtr vvec; std::shared_ptr> contributions; CBLSSignature sig; + //! Not serialized: whether vvec was deserialized under the legacy BLS scheme, which skips the subgroup check + bool vvecLegacyScheme{false}; public: template @@ -52,7 +54,11 @@ class CDKGContribution s >> llmqType; s >> quorumHash; s >> proTxHash; - s >> tmp1; + // Decode the whole vvec under one scheme, so that vvecLegacyScheme describes every point in it + vvecLegacyScheme = bls::bls_legacy_scheme.load(); + for (uint64_t i = 0, size = ReadCompactSize(s); i < size; i++) { + s >> CBLSPublicKeyVersionWrapper(tmp1.emplace_back(), vvecLegacyScheme); + } s >> tmp2; s >> sig; diff --git a/src/llmq/dkgsession.cpp b/src/llmq/dkgsession.cpp index 4c8c21d2b20a..0e4341c79e9b 100644 --- a/src/llmq/dkgsession.cpp +++ b/src/llmq/dkgsession.cpp @@ -90,6 +90,7 @@ bool CDKGSession::Init(const uint256& _myProTxHash, int _quorumIndex) members.resize(mns.size()); memberIds.resize(members.size()); receivedVvecs.resize(members.size()); + receivedVvecsLegacyScheme.resize(members.size()); receivedSkContributions.resize(members.size()); vecEncryptedContributions.resize(members.size()); @@ -244,6 +245,7 @@ std::optional CDKGSession::ReceiveMessage(const CDKGContribution& qc) } receivedVvecs[member->idx] = qc.vvec; + receivedVvecsLegacyScheme[member->idx] = qc.vvecLegacyScheme; int receivedCount = ranges::count_if(members, [](const auto& m){return !m->contributions.empty();}); diff --git a/src/llmq/dkgsession.h b/src/llmq/dkgsession.h index 7a7e85660378..63b50d391c89 100644 --- a/src/llmq/dkgsession.h +++ b/src/llmq/dkgsession.h @@ -137,6 +137,8 @@ class CDKGSession std::vector memberIds; std::vector receivedVvecs; + // whether each received vvec was deserialized under the legacy BLS scheme, see CDKGContribution::vvecLegacyScheme + std::vector receivedVvecsLegacyScheme; // these are not necessarily verified yet. Only trust in what was written to the DB std::vector receivedSkContributions; /// Contains the received unverified/encrypted DKG contributions diff --git a/src/test/bls_tests.cpp b/src/test/bls_tests.cpp index c32a20453c17..cfca20ec6bfe 100644 --- a/src/test/bls_tests.cpp +++ b/src/test/bls_tests.cpp @@ -666,6 +666,30 @@ void FuncVerifyContributionSharesHonest(const bool legacy_scheme) } } + // 3. With the defaults the DKG uses, only the dealers of wrong shares are blamed: + // - two colluding dealers in the same batch swap their shares: each one is wrong, but their sum is not, so the + // errors cancel in an unweighted aggregate; + // - a missing (invalid) share fails its batch and is blamed alone. + auto swapped = skContributions; + std::swap(swapped[1], swapped[2]); + auto missing = skContributions; + missing[5] = CBLSSecretKey{}; + std::vector, std::set>> cases{ + {swapped, {1, 2}}, + {missing, {5}}, + }; + for (auto& [shares, bad] : cases) { + for (const bool parallel : {true, false}) { + const auto result = worker.VerifyContributionShares(myId, vvecs, shares, parallel); + BOOST_REQUIRE_EQUAL(result.size(), QUORUM_SIZE); + for (const size_t i : irange::range(QUORUM_SIZE)) { + const bool expected{bad.count(i) == 0}; + BOOST_CHECK_MESSAGE(result[i] == expected, "index " << i << " expected " << expected << " got " + << result[i] << " (parallel=" << parallel << ")"); + } + } + } + worker.Stop(); } diff --git a/src/test/llmq_dkg_tests.cpp b/src/test/llmq_dkg_tests.cpp index 4a844c26190a..fcc981c20dc9 100644 --- a/src/test/llmq_dkg_tests.cpp +++ b/src/test/llmq_dkg_tests.cpp @@ -2,7 +2,9 @@ // 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 @@ -139,4 +141,37 @@ BOOST_AUTO_TEST_CASE(pending_messages_quota_is_per_protx) BOOST_CHECK_EQUAL(pending.PopPendingMessages(6).size(), 5U); } +BOOST_AUTO_TEST_CASE(contribution_records_vvec_scheme) +{ + // A contribution remembers which BLS scheme its vvec was deserialized under, since only the basic scheme checks + // subgroup membership, which weighted contribution verification relies on + using namespace llmq; + + const bool legacy_scheme_before{bls::bls_legacy_scheme.load()}; + + uint256 seed{uint256S("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")}; + *seed.begin() = 0; // keep it below the group order + const CBLSSecretKey sk{Span{seed}}; + BOOST_REQUIRE(sk.IsValid()); + CDKGContribution qc; + qc.llmqType = Consensus::LLMQType::LLMQ_TEST; + qc.vvec = std::make_shared>(std::vector{sk.GetPublicKey()}); + qc.contributions = std::make_shared>(); + + for (const bool legacy : {true, false}) { + bls::bls_legacy_scheme.store(legacy); + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << qc; + CDKGContribution received; + stream >> received; + BOOST_CHECK_EQUAL(received.vvecLegacyScheme, legacy); + BOOST_REQUIRE_EQUAL(received.vvec->size(), 1U); + BOOST_CHECK((*received.vvec)[0].IsValid()); + BOOST_CHECK((*received.vvec)[0] == (*qc.vvec)[0]); + BOOST_CHECK(stream.empty()); + } + + bls::bls_legacy_scheme.store(legacy_scheme_before); +} + BOOST_AUTO_TEST_SUITE_END() From 593f2521075dd9b7298c09f6171b81d5072d84be Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Thu, 29 Oct 2026 14:44:08 +0300 Subject: [PATCH 05/10] fix(bls): weight batch-verified signatures so complementary errors cannot cancel CBLSBatchVerifier and the DKG message batch (BatchVerifyMessageSigs) added signatures and public keys without weights and accepted the whole batch when the aggregate verified. Two signatures that are each wrong but whose errors cancel (S1 + T and S2 - T, for distinct messages or for the same message and key) therefore passed as one valid aggregate: Verify() returned before any per-source check, nobody landed in badSources, and the forged entries were stored. For LLMQ sig shares that lets a peer that has seen an honest share occupy the member's slot with a wrong one, so the honest copies are dropped as already known and recovery fails; recovered sigs, ISLOCKs and DKG messages go through the same aggregation. Multiply each signature and its public key by a secret random nonzero 128-bit weight before aggregating, which makes a cancelling combination negligibly unlikely. The per-source and per-message fallbacks still verify the original signatures. MulInsecure() is added to CBLSPublicKey and CBLSSignature for the scalar multiplication; the weights are drawn in the header-only verifier, so the consensus library gains no RNG dependency. BatchVerifyMessageSigs now also skips an invalid operator key instead of handing it to the multiplication. The new batch_verifier_tests cases (cancelling errors across two messages of one source, and across two sources signing the same message with the same key, in every verifier mode) and the new sig-share row fail without this change and pass with it. BatchVerifyMessageSigs is file-local and needs a full CDKGSession, so it is covered by feature_llmq_dkgerrors and feature_llmq_signing rather than a unit test. Co-Authored-By: Claude Opus 5.5 --- src/active/dkgsessionhandler.cpp | 19 ++++++++++++++--- src/bls/bls.cpp | 14 +++++++++++++ src/bls/bls.h | 3 +++ src/bls/bls_batchverifier.h | 35 ++++++++++++++++++++++++++----- src/test/bls_tests.cpp | 36 ++++++++++++++++++++++++++++++++ src/test/llmq_utils_tests.cpp | 9 ++++++++ 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/src/active/dkgsessionhandler.cpp b/src/active/dkgsessionhandler.cpp index f83f6826ccba..c3e9a9262aea 100644 --- a/src/active/dkgsessionhandler.cpp +++ b/src/active/dkgsessionhandler.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -259,6 +260,7 @@ std::set BatchVerifyMessageSigs(CDKGSession& session, const std::vector< std::set ret; bool revertToSingleVerification = false; + FastRandomContext rng; CBLSSignature aggSig; std::vector pubKeys; @@ -286,11 +288,22 @@ std::set BatchVerifyMessageSigs(CDKGSession& session, const std::vector< ret.emplace(nodeId); continue; } + CBLSPublicKey pubKey{member->dmn->pdmnState->pubKeyOperator.Get()}; + if (!pubKey.IsValid()) { + // should not happen as operator keys are validated on registration + ret.emplace(nodeId); + continue; + } + + const CBLSSecretKey weight{MakeBatchVerificationWeight(rng)}; + CBLSSignature weightedSig{msg->sig}; + weightedSig.MulInsecure(weight); + pubKey.MulInsecure(weight); if (first) { - aggSig = msg->sig; + aggSig = weightedSig; } else { - aggSig.AggregateInsecure(msg->sig); + aggSig.AggregateInsecure(weightedSig); } first = false; @@ -306,7 +319,7 @@ std::set BatchVerifyMessageSigs(CDKGSession& session, const std::vector< break; } - pubKeys.emplace_back(member->dmn->pdmnState->pubKeyOperator.Get()); + pubKeys.emplace_back(pubKey); messageHashes.emplace_back(msgHash); } if (!revertToSingleVerification) { diff --git a/src/bls/bls.cpp b/src/bls/bls.cpp index 49981ee9190f..eef647e794b3 100644 --- a/src/bls/bls.cpp +++ b/src/bls/bls.cpp @@ -203,6 +203,13 @@ bool CBLSPublicKey::PublicKeyShare(Span mpk, const CBLSId& _id) return true; } +void CBLSPublicKey::MulInsecure(const CBLSSecretKey& factor) +{ + assert(IsValid() && factor.IsValid()); + impl = factor.impl * impl; + cachedHash.SetNull(); +} + bool CBLSPublicKey::DHKeyExchange(const CBLSSecretKey& sk, const CBLSPublicKey& pk) { fValid = false; @@ -291,6 +298,13 @@ void CBLSSignature::SubInsecure(const CBLSSignature& o) cachedHash.SetNull(); } +void CBLSSignature::MulInsecure(const CBLSSecretKey& factor) +{ + assert(IsValid() && factor.IsValid()); + impl = factor.impl * impl; + cachedHash.SetNull(); +} + bool CBLSSignature::VerifyInsecure(const CBLSPublicKey& pubKey, const uint256& hash, const bool specificLegacyScheme) const { if (!IsValid() || !pubKey.IsValid()) { diff --git a/src/bls/bls.h b/src/bls/bls.h index 19dd5ae7611c..2e0eb6571a9d 100644 --- a/src/bls/bls.h +++ b/src/bls/bls.h @@ -314,6 +314,8 @@ class CBLSPublicKey : public CBLSWrapper pks); + void MulInsecure(const CBLSSecretKey& factor); + bool PublicKeyShare(Span mpk, const CBLSId& id); bool DHKeyExchange(const CBLSSecretKey& sk, const CBLSPublicKey& pk); @@ -360,6 +362,7 @@ class CBLSSignature : public CBLSWrapper sigs, Span pks, const uint256& hash); void SubInsecure(const CBLSSignature& o); + void MulInsecure(const CBLSSecretKey& factor); [[nodiscard]] bool VerifyInsecure(const CBLSPublicKey& pubKey, const uint256& hash, const bool specificLegacyScheme) const; [[nodiscard]] bool VerifyInsecure(const CBLSPublicKey& pubKey, const uint256& hash) const; [[nodiscard]] bool VerifyInsecureAggregated(Span pubKeys, Span hashes) const; diff --git a/src/bls/bls_batchverifier.h b/src/bls/bls_batchverifier.h index 528b527f0090..93eb5e5df502 100644 --- a/src/bls/bls_batchverifier.h +++ b/src/bls/bls_batchverifier.h @@ -6,10 +6,25 @@ #define DASH_CRYPTO_BLS_BATCHVERIFIER_H #include +#include +#include +#include #include #include +//! A secret random nonzero 128-bit scalar, well below the group order. Weighting each signature and its public key by +//! one makes errors in several signatures unable to cancel out in an aggregate. +inline CBLSSecretKey MakeBatchVerificationWeight(FastRandomContext& rng) +{ + std::array bytes{}; + do { + const uint256 rand{rng.rand256()}; + std::copy(rand.begin(), rand.begin() + 16, bytes.end() - 16); + } while (std::all_of(bytes.begin(), bytes.end(), [](uint8_t b) { return b == 0; })); + return CBLSSecretKey{bytes}; +} + template class CBLSBatchVerifier { @@ -19,6 +34,9 @@ class CBLSBatchVerifier uint256 msgHash; CBLSSignature sig; CBLSPublicKey pubKey; + //! sig and pubKey multiplied by the same MakeBatchVerificationWeight() + CBLSSignature weightedSig; + CBLSPublicKey weightedPubKey; }; using MessageMap = std::map; @@ -32,6 +50,8 @@ class CBLSBatchVerifier MessageMap messages; MessagesBySourceMap messagesBySource; + FastRandomContext rng; + public: std::set badSources; std::set badMessages; @@ -48,7 +68,12 @@ class CBLSBatchVerifier { assert(sig.IsValid() && pubKey.IsValid()); - auto it = messages.emplace(msgId, Message{msgId, msgHash, sig, pubKey}).first; + auto [it, inserted] = messages.emplace(msgId, Message{msgId, msgHash, sig, pubKey, sig, pubKey}); + if (inserted) { + const CBLSSecretKey weight{MakeBatchVerificationWeight(rng)}; + it->second.weightedSig.MulInsecure(weight); + it->second.weightedPubKey.MulInsecure(weight); + } messagesBySource[sourceId].emplace_back(it); if (subBatchSize != 0 && messages.size() >= subBatchSize) { @@ -155,8 +180,8 @@ class CBLSBatchVerifier continue; } - sigsToAggregate.push_back(msg.sig); - pubKeysToAggregate.push_back(msg.pubKey); + sigsToAggregate.push_back(msg.weightedSig); + pubKeysToAggregate.push_back(msg.weightedPubKey); } CBLSPublicKey aggPubKey = CBLSPublicKey::AggregateInsecure(pubKeysToAggregate); @@ -210,8 +235,8 @@ class CBLSBatchVerifier if (dups.emplace(msg.msgId).second) { msgHashes.emplace_back(msgHash); - pubKeys.emplace_back(msg.pubKey); - sigsToAggregate.push_back(msg.sig); + pubKeys.emplace_back(msg.weightedPubKey); + sigsToAggregate.push_back(msg.weightedSig); } messageIts.pop_back(); diff --git a/src/test/bls_tests.cpp b/src/test/bls_tests.cpp index d448fa7e7a4c..c32a20453c17 100644 --- a/src/test/bls_tests.cpp +++ b/src/test/bls_tests.cpp @@ -286,6 +286,30 @@ static void AddMessage(std::vector& vec, uint32_t sourceId, uint32_t ms vec.emplace_back(m); } +// Adds two wrong signatures whose errors cancel out when both are aggregated unweighted +static void AddCancellingMessages(std::vector& vec, uint32_t sourceId1, uint32_t msgId1, uint8_t msgHash1, + uint32_t sourceId2, uint32_t msgId2, uint8_t msgHash2, bool sameKey) +{ + const bool legacy_scheme = bls::bls_legacy_scheme.load(); + AddMessage(vec, sourceId1, msgId1, msgHash1, true); + AddMessage(vec, sourceId2, msgId2, msgHash2, true); + auto& m1 = vec[vec.size() - 2]; + auto& m2 = vec[vec.size() - 1]; + if (sameKey) { + m2.sk = m1.sk; + m2.pk = m1.pk; + m2.sig = m2.sk.Sign(m2.msgHash, legacy_scheme); + } + + CBLSSecretKey sk_error; + sk_error.MakeNewKey(); + const CBLSSignature error{sk_error.Sign(uint256::ONE, legacy_scheme)}; + m1.sig.AggregateInsecure(error); + m2.sig.SubInsecure(error); + m1.valid = false; + m2.valid = false; +} + static void Verify(std::vector& vec, bool secureVerification, bool perMessageFallback) { CBLSBatchVerifier batchVerifier(secureVerification, perMessageFallback); @@ -363,6 +387,18 @@ void FuncBatchVerifier(const bool legacy_scheme) // last message invalid from one source AddMessage(msgs, 1, 7, 1, false); Verify(msgs); + + msgs.clear(); + // errors cancelling out between distinct messages from one source + AddMessage(msgs, 1, 1, 1, true); + AddCancellingMessages(msgs, 2, 2, 2, 2, 3, 3, /*sameKey=*/false); + Verify(msgs); + + // errors cancelling out between distinct sources signing the same message with the same key + msgs.clear(); + AddMessage(msgs, 1, 1, 1, true); + AddCancellingMessages(msgs, 2, 2, 2, 3, 3, 2, /*sameKey=*/true); + Verify(msgs); } void FuncThresholdSignature(const bool legacy_scheme) diff --git a/src/test/llmq_utils_tests.cpp b/src/test/llmq_utils_tests.cpp index 1eb066fc5bd1..cb8d059233fe 100644 --- a/src/test/llmq_utils_tests.cpp +++ b/src/test/llmq_utils_tests.cpp @@ -189,9 +189,16 @@ BOOST_AUTO_TEST_CASE(sig_share_batch_verifier_judges_each_sender_by_its_own_shar sk_forger.MakeNewKey(); const CBLSSignature forged_sig{sk_forger.Sign(sig_share.GetSignHash(), legacy_scheme)}; + // Two wrong shares whose errors cancel out: (valid + forged) + (valid - forged) == 2 * valid + CBLSSignature plus_error_sig{valid_sig}; + plus_error_sig.AggregateInsecure(forged_sig); + CBLSSignature minus_error_sig{valid_sig}; + minus_error_sig.SubInsecure(forged_sig); + constexpr NodeId honest{1}; constexpr NodeId attacker{2}; constexpr NodeId other_honest{3}; + constexpr NodeId other_attacker{4}; struct Push { NodeId from; @@ -204,6 +211,8 @@ BOOST_AUTO_TEST_CASE(sig_share_batch_verifier_judges_each_sender_by_its_own_shar {{{attacker, forged_sig}, {honest, valid_sig}}, {attacker}}, // The same valid share relayed by two peers is not misbehavior. {{{honest, valid_sig}, {other_honest, valid_sig}}, {}}, + // Complementary errors from two peers must not pass as one valid aggregate. + {{{attacker, plus_error_sig}, {other_attacker, minus_error_sig}}, {attacker, other_attacker}}, }; for (const auto& [pushes, expected_bad_sources] : cases) { From 5d9fe74d2ac3e734a438875b576423bf57f58888 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 14:45:26 -0500 Subject: [PATCH 06/10] fix(governance): don't vote NO-FUNDING on triggers before their maturity window Before the superblock maturity window opens, CreateSuperblockCandidate() returns nothing, yet VoteGovernanceTriggers() still voted NO-FUNDING on every active future trigger. Once the window opened, CreateGovernanceTrigger() picked up the existing trigger with the matching DataHash, and the YES path treated any earlier FUNDING vote of ours as final, so the early NO was never replaced by a YES. A single masternode could exploit this by relaying, just before the window, a trigger matching the payments every node is about to compute: all masternodes voted NO on it, no other trigger was created, and the superblock paid none of the approved proposals. Skip NO-FUNDING votes on triggers whose maturity window has not started yet, since there is no local candidate to compare them against. They still get a NO once the window opens if they don't match. Co-Authored-By: Claude Opus 5.5 --- src/governance/signing.cpp | 10 ++++-- src/governance/signing.h | 2 +- test/functional/feature_governance.py | 50 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/governance/signing.cpp b/src/governance/signing.cpp index abc0777d6301..90395627a8c4 100644 --- a/src/governance/signing.cpp +++ b/src/governance/signing.cpp @@ -166,7 +166,7 @@ std::optional GovernanceSigner::CreateGovernanceTrigger return std::make_optional(gov_sb); } -void GovernanceSigner::VoteGovernanceTriggers(const std::optional& trigger_opt) +void GovernanceSigner::VoteGovernanceTriggers(const std::optional& trigger_opt, int nHeight) { // only active masternodes can vote on triggers if (m_mn_activeman.GetProTxHash().IsNull()) return; @@ -228,6 +228,12 @@ void GovernanceSigner::VoteGovernanceTriggers(const std::optionalGetBlockHeight() - Params().GetConsensus().nSuperblockMaturityWindow) { + // We can't build a candidate for this trigger's superblock yet, so we have nothing to compare it to + LogPrint(BCLog::GOBJECT, "%s -- Not voting NO-FUNDING for trigger:%s, its maturity window has not started\n", + __func__, trigger_hash.ToString()); + continue; + } if (trigger_hash == votedFundingYesTriggerHash) { // Skip actual trigger LogPrint(BCLog::GOBJECT, "%s -- Not voting NO-FUNDING for trigger:%s, we voted yes for it already\n", @@ -288,7 +294,7 @@ void GovernanceSigner::UpdatedBlockTip(const CBlockIndex* pindex) { const auto sb_opt = CreateSuperblockCandidate(pindex->nHeight); const auto trigger_opt = CreateGovernanceTrigger(sb_opt); - VoteGovernanceTriggers(trigger_opt); + VoteGovernanceTriggers(trigger_opt, pindex->nHeight); CSuperblock_sptr pSuperblock; if (m_govman.GetBestSuperblock(m_dmnman.GetListAtChainTip(), pSuperblock, pindex->nHeight)) { ResetVotedFundingTrigger(); diff --git a/src/governance/signing.h b/src/governance/signing.h index f03aec29096d..cf298be24a41 100644 --- a/src/governance/signing.h +++ b/src/governance/signing.h @@ -75,7 +75,7 @@ class GovernanceSigner std::optional CreateGovernanceTrigger(const std::optional& sb_opt); std::optional CreateSuperblockCandidate(int nHeight) const; void ResetVotedFundingTrigger(); - void VoteGovernanceTriggers(const std::optional& trigger_opt); + void VoteGovernanceTriggers(const std::optional& trigger_opt, int nHeight); }; #endif // BITCOIN_GOVERNANCE_SIGNING_H diff --git a/test/functional/feature_governance.py b/test/functional/feature_governance.py index 925ac0b1ccc6..c45c5f192642 100755 --- a/test/functional/feature_governance.py +++ b/test/functional/feature_governance.py @@ -391,6 +391,56 @@ def sync_gov(node): self.check_superblockbudget(True) self.check_superblock() + self.log.info("A matching trigger relayed before the maturity window must still be funded") + sb_block_height = 240 + window_start = sb_block_height - sb_maturity_window + # Blocks above came faster than 2.5 minutes apart, so get clear of the per-masternode trigger rate limit + self.bump_mocktime(3 * sb_cycle * 150, update_schedulers=False) + self.generate(self.nodes[0], window_start - 3 - self.nodes[0].getblockcount(), sync_fun=self.sync_blocks()) + + # The payee at window_start creates the trigger, so let it reach the window alone, then rewind it + payee_address = self.nodes[0].masternode("winners")[str(window_start + 1)] + early_idx = next(mn.nodeIdx for mn in self.mninfo + if self.nodes[0].protx("info", mn.proTxHash)["state"]["payoutAddress"] in payee_address) + early_node = self.nodes[early_idx] + self.isolate_node(early_idx) + fork_point = early_node.getblockcount() + 1 + for _ in range(3): + self.bump_mocktime(1) + self.generate(early_node, 1, sync_fun=self.no_op) + self.wait_until(lambda: have_trigger_for_height([early_node], sb_block_height), timeout=5) + early_trigger_hash = next(h for h, t in early_node.gobject("list", "valid", "triggers").items() + if json.loads(t["DataString"])["event_block_height"] == sb_block_height) + early_node.invalidateblock(early_node.getblockhash(fork_point)) + self.reconnect_isolated_node(early_idx, 0) + self.sync_blocks() + + def early_trigger_relayed(): + # Each bump runs the once-a-second governance relay flush + self.bump_mocktime(1) + return all(early_trigger_hash in node.gobject("list", "valid", "triggers") for node in self.nodes) + self.wait_until(early_trigger_relayed, timeout=10) + + self.log.info("Masternodes must not vote NO on it before the window opens") + for _ in range(2): + self.bump_mocktime(1) + self.generate(self.nodes[0], 1, sync_fun=self.sync_blocks()) + assert_equal(self.nodes[0].getblockcount(), window_start - 1) + for node in self.nodes: + # A masternode's own vote is visible on its own node without waiting for relay + node.syncwithvalidationinterfacequeue() + assert_equal(node.gobject("list", "valid", "triggers")[early_trigger_hash]["NoCount"], 0) + + self.log.info("Every masternode votes YES on it once the window opens") + self.bump_mocktime(1) + self.generate(self.nodes[0], 1, sync_fun=self.sync_blocks()) + self.wait_until(lambda: self.nodes[0].gobject("list", "valid", "triggers")[early_trigger_hash]["YesCount"] == self.mn_count, timeout=5) + n = sb_block_height - self.nodes[0].getblockcount() + self.bump_mocktime(n) + self.generate(self.nodes[0], n, sync_fun=self.sync_blocks()) + assert_equal(self.nodes[0].getblockcount(), sb_block_height) + self.check_superblock() + if __name__ == '__main__': DashGovernanceTest().main() From bd948aa8521105c250b4f83e949c97314aea9b36 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 14:53:54 -0500 Subject: [PATCH 07/10] fix(governance): don't vote on triggers while not synced CreateSuperblockCandidate() returns nothing until the node is synced, but VoteGovernanceTriggers() still voted NO-FUNDING on every active future trigger, including ones loaded from governance.dat. A masternode restarting inside the maturity window, before it had voted YES, would therefore vote NO on the legitimate trigger and, once synced, treat that NO as its final vote for the identical trigger it computes. Skip trigger voting until we are synced, as there is no candidate to compare triggers to before then. Co-Authored-By: Claude Opus 5.5 --- src/governance/signing.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/governance/signing.cpp b/src/governance/signing.cpp index 90395627a8c4..40524ac94689 100644 --- a/src/governance/signing.cpp +++ b/src/governance/signing.cpp @@ -170,6 +170,8 @@ void GovernanceSigner::VoteGovernanceTriggers(const std::optional Date: Thu, 29 Oct 2026 19:59:35 +0000 Subject: [PATCH 08/10] fix(bls): stop weighted contribution batch verification on an invalid partial aggregate CBLSPublicKey::AggregateInsecure() marks the result invalid if the underlying aggregation throws, and the next dealer's AggregateInsecure() then asserts on it. Return false instead, so the batch is verified one by one. Co-Authored-By: Claude Opus 5.5 --- src/bls/bls_worker.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bls/bls_worker.cpp b/src/bls/bls_worker.cpp index 65d87670d687..bf4e01bd4be2 100644 --- a/src/bls/bls_worker.cpp +++ b/src/bls/bls_worker.cpp @@ -524,6 +524,10 @@ struct ContributionVerifier : public std::enable_shared_from_this Date: Thu, 29 Oct 2026 19:45:06 +0000 Subject: [PATCH 09/10] fix(llmq): accept equal DKG secret key shares when aggregating a member's share A member builds its quorum secret key share by summing the shares the valid dealers sent it, through CBLSWorker::AggregateSecretKeys. That rejected the input outright if two shares were equal, so the member logged "failed to build own secret share" and sent no premature commitment (and the QDATA recovery path punished the honest peer that served the shares). Two colluding dealers, for example two masternodes of one operator in the same quorum, can deal a chosen member equal shares: pick polynomials that agree at its id. Both contributions verify, nobody complains, and the vvecs share no coefficient, so neither the batch vvec checks nor the shared coefficient marking in VerifyAndCommit see anything. With degree t-1 polynomials they can do this for up to t-1 members at once, which is enough to keep any LLMQ type from reaching minSize signers, and nobody is blamed. Duplicate shares are harmless here: the sum is the correct share either way, and it is checked against the quorum vvec when the premature commitment is verified. Aggregate secret key shares with a validity check only. Public key and signature aggregation keep their duplicate check. Test: bls_aggregate_colliding_contribution_shares_tests builds two dealers whose polynomials agree at the receiver's id, checks that every contribution verifies and the quorum vvec builds, and that the receiver's aggregated share matches the public key share of the quorum vvec. It fails without this change. Co-Authored-By: Claude Opus 5.5 --- src/bls/bls_worker.cpp | 12 ++++++--- src/test/bls_tests.cpp | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/bls/bls_worker.cpp b/src/bls/bls_worker.cpp index 15e4c6bdae47..270bcd48afb9 100644 --- a/src/bls/bls_worker.cpp +++ b/src/bls/bls_worker.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -621,13 +622,15 @@ BLSVerificationVectorPtr CBLSWorker::BuildQuorumVerificationVector(Span void AsyncAggregateHelper(ctpl::thread_pool& workerPool, Span vec, bool parallel, - std::function doneCallback) + std::function doneCallback, bool rejectDuplicates = true) { if (vec.empty()) { doneCallback(T()); return; } - if (!VerifyVectorHelper(vec)) { + const bool inputValid{rejectDuplicates ? VerifyVectorHelper(vec) + : std::all_of(vec.begin(), vec.end(), [](const T& item) { return item.IsValid(); })}; + if (!inputValid) { doneCallback(T()); return; } @@ -639,7 +642,10 @@ void AsyncAggregateHelper(ctpl::thread_pool& workerPool, Span vec, bool paral void CBLSWorker::AsyncAggregateSecretKeys(Span secKeys, bool parallel, std::function doneCallback) { - AsyncAggregateHelper(workerPool, secKeys, parallel, std::move(doneCallback)); + // These are the DKG shares dealt to one member. Two colluding dealers can deal it equal shares while their + // contributions stay valid and their vvecs share no coefficient, so rejecting duplicates here would let them keep + // chosen members from building their quorum secret key share. The sum is correct either way. + AsyncAggregateHelper(workerPool, secKeys, parallel, std::move(doneCallback), /*rejectDuplicates=*/false); } std::future CBLSWorker::AsyncAggregateSecretKeys(Span secKeys, bool parallel) diff --git a/src/test/bls_tests.cpp b/src/test/bls_tests.cpp index 8e9a9ef9c875..0347dbacb1cd 100644 --- a/src/test/bls_tests.cpp +++ b/src/test/bls_tests.cpp @@ -714,6 +714,61 @@ BOOST_AUTO_TEST_CASE(bls_verify_contribution_shares_duplicate_coefficient_tests) FuncVerifyContributionSharesDuplicateCoefficient(false); } +void FuncAggregateCollidingContributionShares(const bool legacy_scheme) +{ + bls::bls_legacy_scheme.store(legacy_scheme); + + CBLSWorker worker; + worker.Start(); + + // The receiver's id is -1 mod the group order, so a degree-1 polynomial b0 + b1 * x takes the value b0 - b1 there + uint256 recvIdHash; + const auto minusOne{ParseHex("73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000")}; + std::copy(minusOne.begin(), minusOne.end(), recvIdHash.begin()); + const CBLSId recvId{recvIdHash}; + + // Dealer 0 is honest. Dealers 1 and 2 collude: their polynomials differ, but agree at the receiver's id + const auto makeKey = [] { CBLSSecretKey sk; sk.MakeNewKey(); return sk; }; + std::vector> polys{{makeKey(), makeKey()}, {makeKey(), makeKey()}, {}}; + CBLSSecretKey share1; + BOOST_REQUIRE(share1.SecretKeyShare(polys[1], recvId)); + const CBLSSecretKey b1{makeKey()}; + CBLSSecretKey b0{share1}; + b0.AggregateInsecure(b1); + polys[2] = {b0, b1}; + + std::vector vvecs; + std::vector recvShares; + for (auto& poly : polys) { + auto vvec = std::make_shared>(); + for (const auto& coefficient : poly) { + vvec->emplace_back(coefficient.GetPublicKey()); + } + vvecs.emplace_back(vvec); + BOOST_REQUIRE(recvShares.emplace_back().SecretKeyShare(poly, recvId)); + } + BOOST_REQUIRE(recvShares[1] == recvShares[2]); + + // Every contribution verifies and nothing in the vvecs collides + const std::vector ok{worker.VerifyContributionShares(recvId, vvecs, recvShares)}; + BOOST_CHECK(ok == std::vector(3, true)); + const BLSVerificationVectorPtr quorumVvec{worker.BuildQuorumVerificationVector(vvecs)}; + BOOST_REQUIRE(quorumVvec != nullptr); + + // so the receiver must still get the quorum secret key share matching the quorum vvec + const CBLSSecretKey skShare{worker.AggregateSecretKeys(recvShares)}; + BOOST_REQUIRE(skShare.IsValid()); + BOOST_CHECK(skShare.GetPublicKey() == CBLSWorker::BuildPubKeyShare(quorumVvec, recvId)); + + worker.Stop(); +} + +BOOST_AUTO_TEST_CASE(bls_aggregate_colliding_contribution_shares_tests) +{ + FuncAggregateCollidingContributionShares(true); + FuncAggregateCollidingContributionShares(false); +} + // A dummy BLS object that satisfies the minimal interface expected by CBLSLazyWrapper. class DummyBLS { From 01dc7e058c913258bb36dafe88dfc8fd3fdedda5 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 30 Oct 2026 17:20:37 +0000 Subject: [PATCH 10/10] chore: prepare v23.1.10 release Bumps _CLIENT_VERSION_BUILD to 10 (_CLIENT_VERSION_IS_RELEASE stays true, no RC), adds the flatpak metainfo release entry, writes the v23.1.10 release notes and archives the v23.1.9 notes under doc/release-notes/dash/. Man pages carry the version bump only; no CLI options changed this release. Co-Authored-By: Claude Opus 5.5 --- configure.ac | 2 +- .../flatpak/org.dash.dash-core.metainfo.xml | 1 + doc/man/dash-cli.1 | 6 +- doc/man/dash-qt.1 | 6 +- doc/man/dash-tx.1 | 6 +- doc/man/dash-util.1 | 6 +- doc/man/dash-wallet.1 | 6 +- doc/man/dashd.1 | 6 +- doc/release-notes.md | 137 ++++++--------- .../dash/release-notes-23.1.9.md | 157 ++++++++++++++++++ 10 files changed, 226 insertions(+), 107 deletions(-) create mode 100644 doc/release-notes/dash/release-notes-23.1.9.md diff --git a/configure.ac b/configure.ac index aa1c4996db1b..0a44fe16e01f 100644 --- a/configure.ac +++ b/configure.ac @@ -2,7 +2,7 @@ AC_PREREQ([2.69]) dnl Don't forget to push a corresponding tag when updating any of _CLIENT_VERSION_* numbers define(_CLIENT_VERSION_MAJOR, 23) define(_CLIENT_VERSION_MINOR, 1) -define(_CLIENT_VERSION_BUILD, 9) +define(_CLIENT_VERSION_BUILD, 10) define(_CLIENT_VERSION_IS_RELEASE, true) define(_COPYRIGHT_YEAR, 2026) define(_COPYRIGHT_HOLDERS,[The %s developers]) diff --git a/contrib/flatpak/org.dash.dash-core.metainfo.xml b/contrib/flatpak/org.dash.dash-core.metainfo.xml index ba72128f9769..47b354704a49 100644 --- a/contrib/flatpak/org.dash.dash-core.metainfo.xml +++ b/contrib/flatpak/org.dash.dash-core.metainfo.xml @@ -21,6 +21,7 @@ + diff --git a/doc/man/dash-cli.1 b/doc/man/dash-cli.1 index 9fda610b7d09..cb5bdf21b492 100644 --- a/doc/man/dash-cli.1 +++ b/doc/man/dash-cli.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-CLI "1" "October 2026" "dash-cli v23.1.9" "User Commands" +.TH DASH-CLI "1" "October 2026" "dash-cli v23.1.10" "User Commands" .SH NAME -dash-cli \- manual page for dash-cli v23.1.9 +dash-cli \- manual page for dash-cli v23.1.10 .SH SYNOPSIS .B dash-cli [\fI\,options\/\fR] \fI\, \/\fR[\fI\,params\/\fR] \fI\,Send command to Dash Core\/\fR @@ -15,7 +15,7 @@ dash-cli \- manual page for dash-cli v23.1.9 .B dash-cli [\fI\,options\/\fR] \fI\,help Get help for a command\/\fR .SH DESCRIPTION -Dash Core RPC client version v23.1.9 +Dash Core RPC client version v23.1.10 .SH OPTIONS .HP \-? diff --git a/doc/man/dash-qt.1 b/doc/man/dash-qt.1 index 305803d723e2..27270f91a6a6 100644 --- a/doc/man/dash-qt.1 +++ b/doc/man/dash-qt.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-QT "1" "October 2026" "dash-qt v23.1.9" "User Commands" +.TH DASH-QT "1" "October 2026" "dash-qt v23.1.10" "User Commands" .SH NAME -dash-qt \- manual page for dash-qt v23.1.9 +dash-qt \- manual page for dash-qt v23.1.10 .SH SYNOPSIS .B dash-qt [\fI\,command-line options\/\fR] [\fI\,URI\/\fR] .SH DESCRIPTION -Dash Core version v23.1.9 +Dash Core version v23.1.10 .PP Optional URI is a Dash address in BIP21 URI format. .SH OPTIONS diff --git a/doc/man/dash-tx.1 b/doc/man/dash-tx.1 index 906c258e33ca..67846c628edf 100644 --- a/doc/man/dash-tx.1 +++ b/doc/man/dash-tx.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-TX "1" "October 2026" "dash-tx v23.1.9" "User Commands" +.TH DASH-TX "1" "October 2026" "dash-tx v23.1.10" "User Commands" .SH NAME -dash-tx \- manual page for dash-tx v23.1.9 +dash-tx \- manual page for dash-tx v23.1.10 .SH SYNOPSIS .B dash-tx [\fI\,options\/\fR] \fI\, \/\fR[\fI\,commands\/\fR] \fI\,Update hex-encoded dash transaction\/\fR @@ -9,7 +9,7 @@ dash-tx \- manual page for dash-tx v23.1.9 .B dash-tx [\fI\,options\/\fR] \fI\,-create \/\fR[\fI\,commands\/\fR] \fI\,Create hex-encoded dash transaction\/\fR .SH DESCRIPTION -Dash Core dash\-tx utility version v23.1.9 +Dash Core dash\-tx utility version v23.1.10 .SH OPTIONS .HP \-? diff --git a/doc/man/dash-util.1 b/doc/man/dash-util.1 index 55dc5922e6e6..6489698409d9 100644 --- a/doc/man/dash-util.1 +++ b/doc/man/dash-util.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-UTIL "1" "October 2026" "dash-util v23.1.9" "User Commands" +.TH DASH-UTIL "1" "October 2026" "dash-util v23.1.10" "User Commands" .SH NAME -dash-util \- manual page for dash-util v23.1.9 +dash-util \- manual page for dash-util v23.1.10 .SH SYNOPSIS .B dash-util [\fI\,options\/\fR] [\fI\,commands\/\fR] \fI\,Do stuff\/\fR .SH DESCRIPTION -Dash Core dash\-util utility version v23.1.9 +Dash Core dash\-util utility version v23.1.10 .SH OPTIONS .HP \-? diff --git a/doc/man/dash-wallet.1 b/doc/man/dash-wallet.1 index 1ddce2058fde..2dc0921b55b9 100644 --- a/doc/man/dash-wallet.1 +++ b/doc/man/dash-wallet.1 @@ -1,9 +1,9 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASH-WALLET "1" "October 2026" "dash-wallet v23.1.9" "User Commands" +.TH DASH-WALLET "1" "October 2026" "dash-wallet v23.1.10" "User Commands" .SH NAME -dash-wallet \- manual page for dash-wallet v23.1.9 +dash-wallet \- manual page for dash-wallet v23.1.10 .SH DESCRIPTION -Dash Core dash\-wallet version v23.1.9 +Dash Core dash\-wallet version v23.1.10 .PP dash\-wallet is an offline tool for creating and interacting with Dash Core wallet files. By default dash\-wallet will act on wallets in the default mainnet wallet directory in the datadir. diff --git a/doc/man/dashd.1 b/doc/man/dashd.1 index cfddccb0d144..aa191f301f4d 100644 --- a/doc/man/dashd.1 +++ b/doc/man/dashd.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH DASHD "1" "October 2026" "dashd v23.1.9" "User Commands" +.TH DASHD "1" "October 2026" "dashd v23.1.10" "User Commands" .SH NAME -dashd \- manual page for dashd v23.1.9 +dashd \- manual page for dashd v23.1.10 .SH SYNOPSIS .B dashd [\fI\,options\/\fR] \fI\,Start Dash Core\/\fR .SH DESCRIPTION -Dash Core version v23.1.9 +Dash Core version v23.1.10 .SH OPTIONS .HP \-? diff --git a/doc/release-notes.md b/doc/release-notes.md index 2f1c11f9b3bf..4b89c8da4d63 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -1,9 +1,8 @@ -# Dash Core version v23.1.9 +# Dash Core version v23.1.10 -This is a new patch version release. It fixes a consensus-affecting -transaction-encoding bug, several remotely reachable crashes and -denial-of-service issues in the masternode, DKG, signing and governance paths, -and backports a number of hardening and robustness fixes from later releases. +This is a new patch version release. It fixes a governance issue that could +stop a superblock from paying any proposals, and hardens DKG and BLS signature +verification against misbehaving quorum members and peers. Upgrading is **strongly recommended** for all nodes, and required for masternodes. @@ -30,91 +29,54 @@ require a reindex. # Release Notes -## Consensus - -- Transactions whose version field is negative are now rejected once DIP3 is - active. The version and type share one 32-bit field; a negative version was - sign-extended when a transaction was re-serialized, so a block containing such - a transaction could be accepted by a node that received the original bytes and - rejected by every node that received the re-serialized form, leading to a - persistent chain split. The new rule rejects these transactions uniformly. No - valid or historical transaction is affected and transaction ids are unchanged. - -## Crash fixes - -These issues do not affect consensus rules or put funds at risk, but each one -can take a node offline. - -- Fixed an abort during the DKG when a quorum member sent a contribution whose - decrypted secret-key share was invalid: the share reached an aggregation - assertion and terminated the process on every honest member of the quorum. - Invalid shares are now rejected before aggregation. -- Fixed block-template failures caused by provider transactions: a - `ProUpServTx` with an invalid type, a `ProRegTx` reusing collateral already - referenced by an in-mempool update, and a pending owner key colliding with a - Platform node id are now rejected at mempool acceptance. -- Bounded the masternode-list caches by size so a peer cannot drive unbounded - memory growth between blocks. -- Bounded `DomainPort` deserialization so a small provider transaction can no - longer make the node allocate and zero very large buffers while holding - `cs_main`. -- Decoding an unknown-version provider transaction through the verbose decode - RPCs no longer dereferences a missing network-info object. -- `masternode payments` no longer aborts at the genesis block, and the - masternode-list RPCs no longer cache a list for a block outside the active - chain. -- Evonodes no longer abort at credit-pool snapshot boundaries when the pool is - first constructed outside a block connection (for example by - `getassetunlockstatuses`). -- Flat-file (block and undo) positions are now stored and seeked as 64-bit - values. Block-undo data can be several times the size of its block, so a busy - chain could push a data file past the point a 32-bit offset can address; the - write and seek paths no longer narrow the position to 32 bits, which avoided a - silent wrap (and, on Windows, a failure past 2 GiB). - -## InstantSend - -- Provider (masternode) transactions are no longer given InstantSend locks. - Locking them served no purpose and interacted badly with lock eviction on - mempool removal. Software that waited for an InstantSend lock on a provider - transaction should wait for a ChainLock or for confirmations instead. +## Governance + +- Masternodes no longer vote NO-FUNDING on a superblock trigger before that + superblock's maturity window opens (about 3 days before the superblock on + mainnet), when they have no candidate of their own to compare it against. + These early votes could keep a trigger with the correct payments from + reaching the funding threshold, so the superblock paid no proposals. + Triggers that don't match a masternode's candidate still receive a NO vote + once the window opens. +- Masternodes no longer vote on triggers until they are fully synced. A + masternode restarting inside the maturity window could otherwise vote NO on + the correct trigger before it was able to build its own candidate. + +Masternodes running older versions still cast the early NO votes, so this +protection becomes effective as masternodes upgrade. Masternode operators +should upgrade before the next superblock. ## Security and denial-of-service hardening These issues do not affect consensus and do not put funds at risk. -- DKG contribution verification: a single verification-vector coefficient - shared between two members no longer fails the whole verification batch. - Previously that marked unrelated honest members' contributions invalid and - caused them to be complained about; contributions are now verified - individually, so only the offending member is affected. -- Signature-share batch verification is now scoped per sender, so one peer can - no longer get an honest peer penalised or have a forged share stored in place - of an honest one. Malformed detached quorum share batches are rejected, and - signing intake is gated against the active-quorum set before any quorum is - retrieved. -- Governance: an orphan vote now requires a valid masternode signature before - it is cached, the number of orphan parent objects re-requested per tick is - bounded, governance vote signature checks are memoised to bound the cost of a - sync request, a vote is no longer recorded before it is validated, and a - failed BLS vote-signature check is logged under the governance category - instead of unconditionally, so a peer cannot fill `debug.log` with it. -- Socket exception monitoring is now consistent between the `poll()` and - `select()` backends, and a peer whose send buffer stalls is now subject to the - send timeout, so it can no longer hold a connection slot indefinitely. -- Verified inbound connections are bounded per operator identity; `CHECKDATASIG` - uses the signature cache; repeated authenticated Platform bans are skipped; - `DYNBITSET` allocation is bounded against the remaining stream size; a bogus - InstantSend lock no longer drops a batch of unrelated locks; and - `-allowprivatenet` no longer influences masternode address validation. - -## Wallet - -- Invalid-mnemonic errors no longer echo the recovery phrase, an encrypted - wallet is relocked if an HD upgrade fails after it was unlocked, and a wallet - unlock deadline can no longer be extended without re-authenticating. - -# v23.1.9 Change log +- BLS batch verification (signature shares, recovered signatures, InstantSend + locks and DKG messages) now weights each signature with a secret random + factor before aggregating. Previously two invalid signatures whose errors + cancelled out could pass as a valid batch. A peer could use this to disrupt + InstantSend or ChainLock signing, get honest peers penalised, or get honest + DKG members marked bad. +- Batched verification of DKG contributions is weighted the same way, so + colluding members can no longer give another member invalid secret-key + shares that pass verification, which could leave that member unable to sign + for the quorum. This did not expose the quorum key. +- A DKG member copying another member's entire contribution could prevent the + whole quorum from forming. Members whose verification vectors share a + coefficient are now excluded from the commitment instead of the round + failing. This excludes both the copying member and the member it copied, + and both receive the usual PoSe penalty for failed DKG participation. +- Colluding DKG members could stop chosen honest members from building their + secret-key share, which could prevent a quorum from forming without any + member being blamed. The same condition made a node penalise an honest peer + serving quorum data during share recovery. Equal secret-key shares from + different members are now accepted; the resulting key share is still + verified against the quorum's verification vector. + +Like the governance fix, the DKG fixes protect a quorum once enough of its +members run this version. Weighted batch verification does more work per +message, so masternodes may see higher CPU use during DKG and signing. + +# v23.1.10 Change log See detailed [set of changes][set-of-changes]. @@ -122,8 +84,6 @@ See detailed [set of changes][set-of-changes]. Thanks to everyone who directly contributed to this release: -- Konstantin Akimov -- PastaClaw - PastaPastaPasta - UdjinM6 @@ -134,6 +94,7 @@ debug the release candidates. These releases are considered obsolete. Old release notes can be found here: +- [v23.1.9](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.9.md) released Oct/29/2026 - [v23.1.8](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.8.md) released Jul/29/2026 - [v23.1.7](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.7.md) released Jun/30/2026 - [v23.1.5](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.5.md) released Jun/19/2026 @@ -154,4 +115,4 @@ These releases are considered obsolete. Old release notes can be found here: - [v21.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.0.0.md) released Jul/25/2024 - [v20.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-20.1.1.md) released April/3/2024 -[set-of-changes]: https://github.com/dashpay/dash/compare/v23.1.8...dashpay:v23.1.9 +[set-of-changes]: https://github.com/dashpay/dash/compare/v23.1.9...dashpay:v23.1.10 diff --git a/doc/release-notes/dash/release-notes-23.1.9.md b/doc/release-notes/dash/release-notes-23.1.9.md new file mode 100644 index 000000000000..2f1c11f9b3bf --- /dev/null +++ b/doc/release-notes/dash/release-notes-23.1.9.md @@ -0,0 +1,157 @@ +# Dash Core version v23.1.9 + +This is a new patch version release. It fixes a consensus-affecting +transaction-encoding bug, several remotely reachable crashes and +denial-of-service issues in the masternode, DKG, signing and governance paths, +and backports a number of hardening and robustness fixes from later releases. + +Upgrading is **strongly recommended** for all nodes, and required for +masternodes. + +Please report bugs using the issue tracker at GitHub: + + + +# Upgrading and downgrading + +## How to Upgrade + +If you are running an older version, shut it down. Wait until it has completely +shut down (which might take a few minutes for older versions), then run the +installer (on Windows) or just copy over /Applications/Dash-Qt (on Mac) or +dashd/dash-qt (on Linux). + +## Downgrade warning + +### Downgrade to a version < v23.0.0 + +Downgrading to a version older than v23.0.0 is not supported, and will +require a reindex. + +# Release Notes + +## Consensus + +- Transactions whose version field is negative are now rejected once DIP3 is + active. The version and type share one 32-bit field; a negative version was + sign-extended when a transaction was re-serialized, so a block containing such + a transaction could be accepted by a node that received the original bytes and + rejected by every node that received the re-serialized form, leading to a + persistent chain split. The new rule rejects these transactions uniformly. No + valid or historical transaction is affected and transaction ids are unchanged. + +## Crash fixes + +These issues do not affect consensus rules or put funds at risk, but each one +can take a node offline. + +- Fixed an abort during the DKG when a quorum member sent a contribution whose + decrypted secret-key share was invalid: the share reached an aggregation + assertion and terminated the process on every honest member of the quorum. + Invalid shares are now rejected before aggregation. +- Fixed block-template failures caused by provider transactions: a + `ProUpServTx` with an invalid type, a `ProRegTx` reusing collateral already + referenced by an in-mempool update, and a pending owner key colliding with a + Platform node id are now rejected at mempool acceptance. +- Bounded the masternode-list caches by size so a peer cannot drive unbounded + memory growth between blocks. +- Bounded `DomainPort` deserialization so a small provider transaction can no + longer make the node allocate and zero very large buffers while holding + `cs_main`. +- Decoding an unknown-version provider transaction through the verbose decode + RPCs no longer dereferences a missing network-info object. +- `masternode payments` no longer aborts at the genesis block, and the + masternode-list RPCs no longer cache a list for a block outside the active + chain. +- Evonodes no longer abort at credit-pool snapshot boundaries when the pool is + first constructed outside a block connection (for example by + `getassetunlockstatuses`). +- Flat-file (block and undo) positions are now stored and seeked as 64-bit + values. Block-undo data can be several times the size of its block, so a busy + chain could push a data file past the point a 32-bit offset can address; the + write and seek paths no longer narrow the position to 32 bits, which avoided a + silent wrap (and, on Windows, a failure past 2 GiB). + +## InstantSend + +- Provider (masternode) transactions are no longer given InstantSend locks. + Locking them served no purpose and interacted badly with lock eviction on + mempool removal. Software that waited for an InstantSend lock on a provider + transaction should wait for a ChainLock or for confirmations instead. + +## Security and denial-of-service hardening + +These issues do not affect consensus and do not put funds at risk. + +- DKG contribution verification: a single verification-vector coefficient + shared between two members no longer fails the whole verification batch. + Previously that marked unrelated honest members' contributions invalid and + caused them to be complained about; contributions are now verified + individually, so only the offending member is affected. +- Signature-share batch verification is now scoped per sender, so one peer can + no longer get an honest peer penalised or have a forged share stored in place + of an honest one. Malformed detached quorum share batches are rejected, and + signing intake is gated against the active-quorum set before any quorum is + retrieved. +- Governance: an orphan vote now requires a valid masternode signature before + it is cached, the number of orphan parent objects re-requested per tick is + bounded, governance vote signature checks are memoised to bound the cost of a + sync request, a vote is no longer recorded before it is validated, and a + failed BLS vote-signature check is logged under the governance category + instead of unconditionally, so a peer cannot fill `debug.log` with it. +- Socket exception monitoring is now consistent between the `poll()` and + `select()` backends, and a peer whose send buffer stalls is now subject to the + send timeout, so it can no longer hold a connection slot indefinitely. +- Verified inbound connections are bounded per operator identity; `CHECKDATASIG` + uses the signature cache; repeated authenticated Platform bans are skipped; + `DYNBITSET` allocation is bounded against the remaining stream size; a bogus + InstantSend lock no longer drops a batch of unrelated locks; and + `-allowprivatenet` no longer influences masternode address validation. + +## Wallet + +- Invalid-mnemonic errors no longer echo the recovery phrase, an encrypted + wallet is relocked if an HD upgrade fails after it was unlocked, and a wallet + unlock deadline can no longer be extended without re-authenticating. + +# v23.1.9 Change log + +See detailed [set of changes][set-of-changes]. + +# Credits + +Thanks to everyone who directly contributed to this release: + +- Konstantin Akimov +- PastaClaw +- PastaPastaPasta +- UdjinM6 + +As well as everyone that submitted issues, reviewed pull requests and helped +debug the release candidates. + +# Older releases + +These releases are considered obsolete. Old release notes can be found here: + +- [v23.1.8](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.8.md) released Jul/29/2026 +- [v23.1.7](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.7.md) released Jun/30/2026 +- [v23.1.5](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.5.md) released Jun/19/2026 +- [v23.1.4](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.4.md) released Jun/18/2026 +- [v23.1.3](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.3.md) released May/28/2026 +- [v23.1.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.2.md) released Mar/12/2026 +- [v23.1.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.1.0.md) released Feb/15/2026 +- [v23.0.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.0.2.md) released Dec/4/2025 +- [v23.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-23.0.0.md) released Nov/10/2025 +- [v22.1.3](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.3.md) released Jul/15/2025 +- [v22.1.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.2.md) released Apr/15/2025 +- [v22.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.1.md) released Feb/17/2025 +- [v22.1.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.1.0.md) released Feb/10/2025 +- [v22.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-22.0.0.md) released Dec/12/2024 +- [v21.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.1.1.md) released Oct/22/2024 +- [v21.1.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.1.0.md) released Aug/8/2024 +- [v21.0.2](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.0.2.md) released Aug/1/2024 +- [v21.0.0](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-21.0.0.md) released Jul/25/2024 +- [v20.1.1](https://github.com/dashpay/dash/blob/master/doc/release-notes/dash/release-notes-20.1.1.md) released April/3/2024 + +[set-of-changes]: https://github.com/dashpay/dash/compare/v23.1.8...dashpay:v23.1.9