Skip to content

Commit 524173e

Browse files
Merge #7841: fix: authenticate cached InstantSend proof bytes
ecae783 fix: authenticate cached InstantSend proof bytes (pasta) Pull request description: ## Issue being fixed or feature implemented When an ISDLOCK arrives for a transaction whose recovered signature this node already has, the verifier skipped checking the lock's own signature and cycle. The cached recovered signature proves that the quorum signed this txid. It does not prove that the bytes in the received lock are that signature. The unchecked lock was then stored, relayed by its hash and returned by `getislocks`. A lock with the same txid that arrives later is rejected once one is stored. So a peer that sends an on-curve but invalid signature first, or a different known cycle hash, can keep the authentic proof from being stored and relayed. This does not authorize any new spend or cause a consensus split. It needs the recovered signature to be cached while no lock is stored, for example on a `-watchquorums` node. ## What was done? `BuildVerificationBatch` now resolves the quorum from the received lock's cycle before it consults the cache. It skips verification only when the cached recovered signature came from that same quorum and has the same signature bytes. Any other lock goes through normal batch verification. `missing_quorum_does_not_drop_batch` now uses a real mined non-rotating quorum, because a cache hit now needs a quorum that can be resolved. Rotating missing-quorum coverage stays in `received_genesis_cycle_has_no_quorum`. This is a Dash-specific fix. Bitcoin Core has nothing to backport. ## How Has This Been Tested? macOS arm64, existing depends build: - Full `make -j1` passed. - `test/functional/p2p_instantsend.py` passed. It uses non-rotating quorums. - `test/functional/rpc_verifyislock.py` passed. It uses rotating quorums, so masternodes that already hold the recovered signature receive the network locks. - `lint-includes.py`, `lint-circular-dependencies.py` and `git diff --check` passed. Linux x86_64 debug build, after the tests were simplified: - `./src/test/test_dash --run_test=evo_islock_tests` passed all 14 cases, on 3 runs in a row. New test `cached_recovered_sig_does_not_authenticate_other_lock`: - It adds mined commitments for two non-rotating quorums and caches a genuine recovered signature for a mempool transaction. It then passes a changed lock straight to `ProcessPendingInstantSendLocks`: once signed by another key, and once with the other cycle hash. It checks that the changed lock is not stored, and that the authentic lock processed afterwards is stored byte for byte. - With `net_instantsend.cpp` reverted to the base, both cases failed: the changed lock was stored and the authentic lock was not (4 failed checks, exit 201). An independent review approved the change. Beyond `rpc_verifyislock.py`, rotating-quorum cache hits are covered by reasoning, not by a dedicated test. No end-to-end P2P timing demonstration was done. ## Breaking Changes None. The wire format, quorum selection and signature validity rules are unchanged. ## Checklist: - [x] I have performed a self-review of my own code - [ ] I have made corresponding changes to the documentation - [ ] I have assigned this pull request to a milestone 🤖 Generated with [Claude Code](https://claude.com/claude-code) Top commit has no ACKs. Tree-SHA512: ce382fe64d1a7fd18a6199d4fbde73ca488857bdc08f855f927694cbab645ed20a6376df587b8ba7adea8a390ce103d07ff0b4e77f5f0a6d3ce40f1817994e05
2 parents 5c66004 + ecae783 commit 524173e

2 files changed

Lines changed: 122 additions & 45 deletions

File tree

‎src/instantsend/net_instantsend.cpp‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,6 @@ std::unique_ptr<NetInstantSend::BatchVerificationData> NetInstantSend::BuildVeri
127127

128128
auto id = islock->GetRequestId();
129129

130-
// no need to verify an ISLOCK if we already have verified the recovered sig that belongs to it
131-
if (m_sigman.HasRecoveredSig(llmq_params.type, id, islock->txid)) {
132-
data->alreadyVerified++;
133-
continue;
134-
}
135-
136130
auto cycleHeightOpt = GetBlockHeight(m_is_manager, m_chainman.ActiveChainstate(), islock->cycleHash);
137131
if (!cycleHeightOpt) {
138132
data->batchVerifier.badSources.emplace(nodeId);
@@ -156,6 +150,13 @@ std::unique_ptr<NetInstantSend::BatchVerificationData> NetInstantSend::BuildVeri
156150
data->batchVerifier.badMessages.emplace(hash);
157151
continue;
158152
}
153+
llmq::CRecoveredSig recovered_sig;
154+
if (m_sigman.GetRecoveredSig(llmq_params.type, id, islock->txid, recovered_sig) &&
155+
recovered_sig.getQuorumHash() == quorum->qc->quorumHash && recovered_sig.sig.Get() == sig) {
156+
data->alreadyVerified++;
157+
continue;
158+
}
159+
159160
uint256 signHash = llmq::SignHash{llmq_params.type, quorum->qc->quorumHash, id, islock->txid}.Get();
160161
data->batchVerifier.PushMessage(nodeId, hash, signHash, sig, quorum->qc->quorumPublicKey);
161162
data->verifyCount++;

‎src/test/evo_islock_tests.cpp‎

Lines changed: 115 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include <llmq/signhash.h>
1515
#include <llmq/signing.h>
1616
#include <primitives/transaction.h>
17+
#include <script/standard.h>
1718
#include <spork.h>
1819
#include <streams.h>
1920
#include <test/util/llmq_tests.h>
@@ -27,12 +28,67 @@
2728

2829
#include <boost/test/unit_test.hpp>
2930

30-
struct NetInstantSendTest : RegTestingSetup {
31+
struct NetInstantSendTest : TestChain100Setup {
32+
const Consensus::LLMQParams& params{llmq::testutils::GetLLMQParams(Consensus::LLMQType::LLMQ_TEST_INSTANTSEND)};
33+
llmq::CSigningManager& sigman{*m_node.llmq_ctx->sigman};
34+
NetInstantSend net{m_node.peerman.get(), *m_node.isman, nullptr, sigman, *m_node.llmq_ctx->qman,
35+
*m_node.chainlocks, *m_node.chainman, *m_node.mempool, *m_node.mn_sync};
36+
37+
NetInstantSendTest()
38+
{
39+
constexpr const char* REGTEST_SPORK_PRIVKEY{"cP4EKFyJsHT39LDqgdcB43Y3YXjNyjb5Fuas1GQSeAtjnZWmZEQK"};
40+
BOOST_REQUIRE(m_node.sporkman->SetSporkAddress(Params().SporkAddress()));
41+
BOOST_REQUIRE(m_node.sporkman->SetPrivKey(REGTEST_SPORK_PRIVKEY));
42+
BOOST_REQUIRE(m_node.sporkman->UpdateSpork(SPORK_2_INSTANTSEND_ENABLED, 0).has_value());
43+
}
44+
3145
static Uint256HashSet ProcessBatch(NetInstantSend& net, const Consensus::LLMQParams& params, int offset,
3246
const std::vector<instantsend::PendingISLockEntry>& locks)
3347
{
3448
return net.ProcessPendingInstantSendLocks(params, offset, /*ban=*/true, locks);
3549
}
50+
51+
// Returns the cycle hash of a quorum whose base block is at `height`. Mining a block afterwards drops it.
52+
uint256 AddMinedQuorum(int height, const CBLSSecretKey& key)
53+
{
54+
LOCK(cs_main);
55+
const CChain& chain = m_node.chainman->ActiveChain();
56+
auto qc = llmq::testutils::CreateValidCommitment(params, chain[height]->GetBlockHash());
57+
qc.quorumPublicKey = key.GetPublicKey();
58+
llmq::testutils::WriteMinedCommitment(*m_node.evodb, qc, chain[height + 10]->GetBlockHash(), height + 10, height);
59+
return qc.quorumHash;
60+
}
61+
62+
void Sign(instantsend::InstantSendLock& islock, const CBLSSecretKey& key) const
63+
{
64+
const auto sign_hash = llmq::SignHash{params.type, islock.cycleHash, islock.GetRequestId(), islock.txid}.Get();
65+
const bool legacy = bls::bls_legacy_scheme.load();
66+
islock.sig.Set(key.Sign(sign_hash, legacy), legacy);
67+
}
68+
69+
std::shared_ptr<instantsend::InstantSendLock> MakeLock(const uint256& cycle_hash, const CBLSSecretKey& key,
70+
const uint256& txid = GetRandHash(),
71+
const COutPoint& input = COutPoint{GetRandHash(), 0}) const
72+
{
73+
auto islock = std::make_shared<instantsend::InstantSendLock>();
74+
islock->txid = txid;
75+
islock->inputs.push_back(input);
76+
islock->cycleHash = cycle_hash;
77+
Sign(*islock, key);
78+
return islock;
79+
}
80+
81+
void CacheRecoveredSig(const instantsend::InstantSendLock& islock)
82+
{
83+
BOOST_REQUIRE(sigman.ProcessRecoveredSig(std::make_shared<llmq::CRecoveredSig>(params.type, islock.cycleHash,
84+
islock.GetRequestId(),
85+
islock.txid, islock.sig)));
86+
}
87+
88+
void Process(NodeId node, const std::shared_ptr<instantsend::InstantSendLock>& islock)
89+
{
90+
ProcessBatch(net, params, /*offset=*/0, {{{node, islock}, ::SerializeHash(*islock)}});
91+
}
3692
};
3793

3894
BOOST_AUTO_TEST_SUITE(evo_islock_tests)
@@ -93,49 +149,69 @@ BOOST_FIXTURE_TEST_CASE(received_genesis_cycle_has_no_quorum, TestChain100Setup)
93149

94150
BOOST_FIXTURE_TEST_CASE(missing_quorum_does_not_drop_batch, NetInstantSendTest)
95151
{
96-
constexpr const char* REGTEST_SPORK_PRIVKEY{"cP4EKFyJsHT39LDqgdcB43Y3YXjNyjb5Fuas1GQSeAtjnZWmZEQK"};
97-
BOOST_REQUIRE(m_node.sporkman->SetSporkAddress(Params().SporkAddress()));
98-
BOOST_REQUIRE(m_node.sporkman->SetPrivKey(REGTEST_SPORK_PRIVKEY));
99-
BOOST_REQUIRE(m_node.sporkman->UpdateSpork(SPORK_2_INSTANTSEND_ENABLED, 0).has_value());
100-
auto& sigman = *m_node.llmq_ctx->sigman;
101-
NetInstantSend net{m_node.peerman.get(), *m_node.isman, nullptr, sigman, *m_node.llmq_ctx->qman,
102-
*m_node.chainlocks, *m_node.chainman, *m_node.mempool, *m_node.mn_sync};
103-
const auto cycle_hash = WITH_LOCK(cs_main, return m_node.chainman->ActiveChain().Genesis()->GetBlockHash());
104152
CBLSSecretKey key;
105153
key.MakeNewKey();
154+
const auto cycle_hash = AddMinedQuorum(/*height=*/24, key);
155+
const auto missing_cycle = WITH_LOCK(cs_main, return m_node.chainman->ActiveChain().Genesis()->GetBlockHash());
156+
157+
std::vector<instantsend::PendingISLockEntry> locks;
158+
for (int i = 0; i < 3; ++i) {
159+
auto islock = MakeLock(i == 1 ? missing_cycle : cycle_hash, key);
160+
if (i != 1) {
161+
// Exercise the already-verified path on either side of an unavailable quorum.
162+
CacheRecoveredSig(*islock);
163+
}
164+
locks.push_back({{i == 2 ? 2 : 1, islock}, ::SerializeHash(*islock)});
165+
}
166+
167+
const auto failed = ProcessBatch(net, params, /*offset=*/0, locks);
168+
BOOST_CHECK_EQUAL(failed.size(), 1U);
169+
BOOST_CHECK(failed.contains(locks[1].islock_hash));
170+
BOOST_CHECK(m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[0].islock_hash}));
171+
BOOST_CHECK(!m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[1].islock_hash}));
172+
BOOST_CHECK(m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[2].islock_hash}));
173+
174+
const auto retried = ProcessBatch(net, params, params.dkgInterval, {locks[1]});
175+
BOOST_CHECK(retried.contains(locks[1].islock_hash));
176+
BOOST_CHECK(!m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[1].islock_hash}));
177+
BOOST_CHECK(!sigman.HasRecoveredSig(params.type, locks[1].islock->GetRequestId(), locks[1].islock->txid));
178+
BOOST_CHECK(sigman.FetchPendingReconstructed().empty());
179+
}
106180

107-
for (const auto type : {Consensus::LLMQType::LLMQ_TEST_DIP0024, Consensus::LLMQType::LLMQ_TEST_INSTANTSEND}) {
108-
const auto& params = llmq::testutils::GetLLMQParams(type);
109-
std::vector<instantsend::PendingISLockEntry> locks;
110-
for (int i = 0; i < 3; ++i) {
111-
auto islock = std::make_shared<instantsend::InstantSendLock>();
112-
islock->txid = GetRandHash();
113-
islock->inputs.emplace_back(GetRandHash(), 0);
114-
islock->cycleHash = cycle_hash;
115-
const auto sign_hash = llmq::SignHash{type, cycle_hash, islock->GetRequestId(), islock->txid}.Get();
116-
const bool legacy = bls::bls_legacy_scheme.load();
117-
islock->sig.Set(key.Sign(sign_hash, legacy), legacy);
118-
if (i != 1) {
119-
// Exercise the already-verified path on either side of an unavailable quorum.
120-
BOOST_REQUIRE(sigman.ProcessRecoveredSig(
121-
std::make_shared<llmq::CRecoveredSig>(type, cycle_hash, islock->GetRequestId(), islock->txid,
122-
islock->sig)));
123-
}
124-
locks.push_back({{i == 2 ? 2 : 1, islock}, ::SerializeHash(*islock)});
181+
BOOST_FIXTURE_TEST_CASE(cached_recovered_sig_does_not_authenticate_other_lock, NetInstantSendTest)
182+
{
183+
CBLSSecretKey key;
184+
key.MakeNewKey();
185+
CBLSSecretKey other_key;
186+
other_key.MakeNewKey();
187+
mineBlocks(1); // Matures a second coinbase to spend.
188+
const auto cycle_hash = AddMinedQuorum(/*height=*/24, key);
189+
const auto other_cycle_hash = AddMinedQuorum(/*height=*/48, other_key);
190+
191+
for (const bool change_cycle : {false, true}) {
192+
// A stored lock only blocks later locks for the same txid once the transaction is known.
193+
const int coinbase_index{change_cycle};
194+
const auto& coinbase = m_coinbase_txns[coinbase_index];
195+
const auto tx = CreateValidMempoolTransaction(coinbase, /*input_vout=*/0, /*input_height=*/coinbase_index + 1,
196+
coinbaseKey, GetScriptForRawPubKey(coinbaseKey.GetPubKey()),
197+
coinbase->vout[0].nValue - 1000);
198+
const auto authentic = MakeLock(cycle_hash, key, tx.GetHash(), tx.vin[0].prevout);
199+
CacheRecoveredSig(*authentic);
200+
201+
// Same txid, but either signed by another key or claiming another cycle.
202+
auto forged = std::make_shared<instantsend::InstantSendLock>(*authentic);
203+
if (change_cycle) {
204+
forged->cycleHash = other_cycle_hash;
205+
} else {
206+
Sign(*forged, other_key);
125207
}
208+
Process(/*node=*/1, forged);
209+
BOOST_CHECK(!m_node.isman->GetInstantSendLockByTxid(authentic->txid));
126210

127-
const auto failed = ProcessBatch(net, params, /*offset=*/0, locks);
128-
BOOST_CHECK_EQUAL(failed.size(), 1U);
129-
BOOST_CHECK(failed.contains(locks[1].islock_hash));
130-
BOOST_CHECK(m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[0].islock_hash}));
131-
BOOST_CHECK(!m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[1].islock_hash}));
132-
BOOST_CHECK(m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[2].islock_hash}));
133-
134-
const auto retried = ProcessBatch(net, params, params.dkgInterval, {locks[1]});
135-
BOOST_CHECK(retried.contains(locks[1].islock_hash));
136-
BOOST_CHECK(!m_node.isman->AlreadyHave(CInv{MSG_ISDLOCK, locks[1].islock_hash}));
137-
BOOST_CHECK(!sigman.HasRecoveredSig(type, locks[1].islock->GetRequestId(), locks[1].islock->txid));
138-
BOOST_CHECK(sigman.FetchPendingReconstructed().empty());
211+
Process(/*node=*/2, authentic);
212+
const auto stored = m_node.isman->GetInstantSendLockByTxid(authentic->txid);
213+
BOOST_REQUIRE(stored);
214+
BOOST_CHECK(::SerializeHash(*stored) == ::SerializeHash(*authentic));
139215
}
140216
}
141217

0 commit comments

Comments
 (0)