Skip to content

fix: authenticate every retained BLS signature individually - #7840

Draft
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:codex/security-bls-message-identity
Draft

PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:codex/security-bls-message-identity

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

The shared CBLSBatchVerifier, the DKG message verifier in net_dkg.cpp and CBLSWorker::AsyncVerifySig all treat a valid unweighted aggregate equation as proof that every constituent signature is valid. Each retained signature is then processed as individually authenticated: recovered signatures, signature shares, InstantSend locks and DKG messages. An aggregate equation does not establish that. Correlated, individually invalid signatures can satisfy it together and then enter first-write local state. In DKG, a second accepted representation of a member's message can be counted as equivocation.

The shared verifier also stores pending records by logical message ID only. A later submission under the same ID with different signature, public key or hash bytes inherits the first record's verdict. On the share path this can ban an honest relay or classify a conflicting unverified share as good.

Separately, CSigSharesManager::TryRecoverSig verified a reconstructed signature against the quorum key on only one recovery in a hundred, and never for single-member quorums.

Impact is local: certificate-state integrity and availability for statements that already have authentic material behind them. Exploiting the cancellation case requires authentic signatures or shares covering the relevant keys and messages, co-batching, and fresh cache identities. Neither forgery of an unsigned statement nor a consensus split is claimed.

What was done?

  • CBLSBatchVerifier keys pending records by (message ID, message hash, canonical signature bytes, canonical public-key bytes). Only identical inputs share a verdict. Every distinct record is verified individually with the active BLS scheme captured once per Verify(). Failures are attributed to every source that submitted that exact record. badSources/badMessages remain cumulative across sub-batches. The constructor signature is unchanged for existing callers; the secureVerification flag is now ignored because individual verification is safe in both modes.
  • BatchVerifyMessageSigs (all four DKG message types) now uses the shared verifier with each member's operator key and the message's sign hash, instead of its own aggregate-then-fallback implementation.
  • CBLSWorker gives each asynchronous job its own VerifyInsecure result; the duplicate-hash batch split and aggregate shortcut are removed. Cancellation and in-progress bookkeeping are unchanged.
  • TryRecoverSig verifies every threshold and single-member result against the quorum public key and full sign hash, outside the share lock, before returning it. The sampling counter is removed.

Final-commitment validation, signature encodings, wire formats and deployments are unchanged.

The detached malformed-share vector, which overlaps this area, is fixed separately in #7822. The two PRs touch nearby lines in net_signing.cpp and the include block of llmq_utils_tests.cpp, so whichever merges second needs a trivial rebase.

How Has This Been Tested?

macOS arm64, prebuilt depends, make -j1:

  • Complete configured make -j1 on the final source, then a unit-binary rebuild after the final test edits.
  • bls_tests/batch_verifier_message_identity (new) fails on unfixed develop with 216 attribution failures and passes with the fix. It covers same-ID records that differ in signature, public key or hash; identical duplicates from several sources; a bad record from a source that also sent valid ones; sub-batches; both constructor modes; and both BLS schemes. It observes the public badSources/badMessages results.
  • llmq_utils_tests/every_reconstructed_signature_is_verified (new) drives CSigSharesManager recovery with real polynomial shares. It fails on unfixed develop with 2 failures, one for the threshold return and one for the single-member return. With the fix, wrong-key and changed-message shares yield no recovered signature in both schemes and both recovery modes, and valid sessions still recover a signature that verifies against the quorum key.
  • bls_tests/async_verification_preserves_individual_results (new) checks that each async job gets its own result, in both schemes. The jobs mix valid, wrong-key and changed-message cases, with pairs sharing a hash, and are queued behind a blocked worker so they execute in shared batches. An invalid signature object is checked as well. It is a behavior-preservation test, not a regression test: it is not expected to fail on unfixed code.
  • test_dash: bls_tests (22 cases), llmq_utils_tests (18), llmq_dkg_tests (4) and llmq_commitment_tests (11) all pass.
  • Functional, run serially: feature_llmq_signing.py, feature_llmq_signing.py --spork21 and feature_llmq_singlenode.py pass.
  • lint-files, lint-whitespace, lint-includes, lint-include-guards and git diff --check pass.

There is no direct DKG wrong-key test, because BatchVerifyMessageSigs sits in an anonymous namespace. It is now a thin adapter over the unit-tested verifier, and the functional LLMQ tests exercise honest DKG. No cancellation payload or end-to-end P2P exploitation was run.

Performance

Individual verification removes honest-case aggregation savings. The earlier bench_dash primitive measurements were 1.79 ms for an individual verification and approximately 1.09 ms per signature amortized over 16 distinct messages. These primitive timings alone do not establish service capacity.

An out-of-tree honest-input harness measured local share verification, queueing and recovery at this PR head. It links the production lazy decoder, quorum public-share lookup, share manager/recovery and signing manager. The NetSigning dispatcher and verification block are mirrored in the harness: batches of up to 32, at most four outstanding, a 10 ms poll, and one or four workers. The comparison labeled “develop verifier” replaces only the share-verifier header with the develop implementation; both rows still use this PR's unconditional reconstructed-signature verification.

Local workload Workers Develop verifier PR verifier
InstantSend, 40 sessions arriving together: completed sessions / elapsed second 1 28.0 9.6
Same 40-session burst 4 66.2 29.5
InstantSend, 20 sessions/s offered for 6 s: median recovery latency 1 32.6 ms 3,658.9 ms
Same finite paced sample: maximum pending shares 1 60 3,692
ChainLocks, 400 shares through 8 peers: recovery latency across three repetitions 1 131.2–133.0 ms 542.5–550.0 ms
Same ChainLocks sample 4 97.6–99.9 ms 208.9–219.0 ms

Every final sample passed the zero-bad-source and expected-recovery-count assertions. The harness checks aggregate successful recovery returns rather than independently auditing a unique recovered-ID set. The one-worker PR took about 12.1 seconds to finish the six-second, 120-session paced workload; its maximum recovery latency was 6.2 seconds. This establishes a backlog in that finite local sample, rather than a general saturation threshold or unbounded queue-growth result. Burst completion rates are not sustained production capacities.

Limits: Apple M4 Pro desktop shared with other campaign workers, load average reaching about 75; honest inputs only; burst arrivals within each session; a mirrored dispatcher without production pending-map caps or drops; no P2P/network/PeerManager, recovered-signature listener or relay workload. Each pair ran the PR first and the comparison second; pair-specific host load was not measured. The measurement called worker_busy_ms sums elapsed task durations, including waits, descheduling and outstanding-task drain after the loop's wall-time endpoint; it is not CPU time and cannot establish core use. Warm public-share caches and omission of network activity further limit extrapolation. Representative masternode hardware, combined InstantSend/ChainLocks/DKG load and a long soak remain unmeasured. These results do not establish production readiness or mainnet headroom.

The invalid BLS-scheme attempt and the crashing smoke teardown are excluded from throughput evidence. Source inspection indicates the teardown can encounter allocation callbacks changed by BLS initialization after the fixture's keys were allocated; the retained source does not support the earlier explanation that teardown destroyed the static secure pool. The final harness keeps that fixture alive until process exit, avoiding the incompatible free. This remains a harness limitation, with no production-crash claim.

The requested aggregate-valid/individually-invalid acceptance-boundary regression remains unprovided within this campaign's validation scope. The existing ordinary invalid-input tests do not distinguish a mutant restoring aggregate acceptance while retaining exact-input identity. No cancellation construction was attempted, and no coverage or closure is claimed for that boundary.

The PR stays draft because it tightens live P2P acceptance without a deployment gate and because representative availability headroom remains unresolved. Randomized-coefficient batching is a possible follow-up requiring its own implementation and review; it is not part of this change.

Breaking Changes

None. Historical block and commitment acceptance, wire formats, valid signatures and activation rules are unchanged. Individually invalid signatures that previously could pass inside a valid aggregate are now rejected.

Checklist:

  • 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

No user-facing documentation change is needed for this internal verification repair.

🤖 Generated with Claude Code

The shared batch verifier, the DKG message verifier and the asynchronous
worker treated a valid aggregate equation as proof that every constituent
signature was valid, so correlated individually invalid signatures could
be accepted into recovered-signature, share, InstantSend and DKG state.
The shared verifier also keyed pending records by logical message ID
only, letting a later submission with different signature, key or hash
bytes inherit the first one's verdict and source attribution.
Reconstructed signatures were only verified on one recovery in a
hundred, and never for single-member quorums.

Key pending records by their exact verification inputs and verify each
distinct record individually, attributing failures to the sources that
supplied it. Route all four DKG message types through the shared
verifier, give each asynchronous job its own result, and verify every
reconstructed signature against the quorum key before returning it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@thepastaclaw

thepastaclaw commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 6a3bffb) · triage: critical

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Phase 1 + Phase 2

At exact head 6a3bffb, the inspected changes consistently authenticate distinct signature records individually and verify reconstructed signatures before returning them; no blocking implementation defect was confirmed. Two in-scope suggestions remain: regression coverage for aggregate-valid but individually-invalid records, and loaded share-path performance validation. This was a static review: the supplied CI snapshot shows successful container/setup and formatting checks, but two manifest jobs remain queued and no core build/test results are available.

🟡 2 suggestion(s)

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: dash-core-commit-history); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The diff makes intricate, cross-cutting changes to cryptographic authentication in CBLSBatchVerifier, BatchVerifyMessageSigs, CBLSWorker::AsyncVerifySig and CSigSharesManager::TryRecoverSig, including signature identity, failure attribution and mandatory reconstructed-signature verification.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — dash-core-commit-history (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/test/bls_tests.cpp`:
- [SUGGESTION] src/test/bls_tests.cpp:604-615: Add a regression test for cancelling invalid signatures
  The new asynchronous test uses wrong-key and changed-message inputs, which exercise rejection after an aggregate fails rather than the aggregate-acceptance flaw this PR fixes. The message-identity test separately covers records sharing a logical ID, but neither test explicitly exercises an aggregate-valid collection containing individually invalid records. Add a local regression for that acceptance boundary, checking individual rejection and source attribution through the shared verifier and false results through the asynchronous verifier in both BLS schemes. The regression should distinguish this fix from an implementation that restores aggregate acceptance while retaining the exact-input MessageKey. No such mutation check was run during this static review.

In `src/bls/bls_batchverifier.h`:
- [SUGGESTION] src/bls/bls_batchverifier.h:77-86: Measure loaded share-path throughput before marking the PR ready
  Verify() now performs an individual verification for every distinct record, including honest same-hash signature shares that previously used an aggregate public key. This path processes batches of up to 32 shares on the 1–4-thread signing worker pool, with at most four unverified batches outstanding (net_signing.cpp:163 and 358–377). Those bounds limit queued work but do not establish that recovery latency remains acceptable under sustained signing load. The PR documents individual-verification measurements and estimated session costs, but explicitly leaves loaded throughput unmeasured. Before marking this draft ready, record share-path throughput, queueing, and recovery latency under representative InstantSend and ChainLocks load, including the one-worker configuration. This validates the availability cost without weakening individual authentication; no operational slowdown was measured in this review.

Comment thread src/test/bls_tests.cpp
Comment on lines +604 to +615
std::vector<CBLSSignature> signatures;
std::vector<uint256> hashes;
std::vector<bool> expected;
for (uint8_t i = 1; i <= 20; ++i) {
const auto hash = uint256((i + 1) / 2);
const auto signature = (i % 3 == 1 ? other_key : key).Sign(hash, legacy_scheme);
BOOST_REQUIRE(signature.VerifyInsecure((i % 3 == 1 ? other_key : key).GetPublicKey(), hash));
const auto verification_hash = i % 3 == 2 ? uint256(100 + i) : hash;
signatures.push_back(signature);
hashes.push_back(verification_hash);
expected.push_back(signature.VerifyInsecure(public_key, verification_hash));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Add a regression test for cancelling invalid signatures

The new asynchronous test uses wrong-key and changed-message inputs, which exercise rejection after an aggregate fails rather than the aggregate-acceptance flaw this PR fixes. The message-identity test separately covers records sharing a logical ID, but neither test explicitly exercises an aggregate-valid collection containing individually invalid records. Add a local regression for that acceptance boundary, checking individual rejection and source attribution through the shared verifier and false results through the asynchronous verifier in both BLS schemes. The regression should distinguish this fix from an implementation that restores aggregate acceptance while retaining the exact-input MessageKey. No such mutation check was run during this static review.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The acceptance-boundary coverage observation is valid. Wrong-key, changed-message and malformed inputs also make the aggregate fail, so restoring aggregate acceptance while keeping exact-input MessageKey would still pass those tests. An aggregate-valid collection containing individually invalid signatures would distinguish that behavior. Constructing that input is outside this campaign's validation scope; no such construction or test was attempted. The acceptance-boundary regression remains open. The implementation's individual-verification guarantee can be inspected in the three verification paths, but that is not a substitute for the requested behavioral regression.


🤖 Posted autonomously by Codex on behalf of pasta.

Comment on lines 77 to 86
void Verify()
{
std::map<uint256, std::vector<MessageMapIterator>> byMessageHash;

for (auto it = messages.begin(); it != messages.end(); ++it) {
byMessageHash[it->second.msgHash].emplace_back(it);
}

if (VerifyBatch(byMessageHash)) {
// full batch is valid
return;
}

// revert to per-source verification
for (const auto& [from, message_map] : messagesBySource) {
bool batchValid = false;

// no need to verify it again if there was just one source
if (messagesBySource.size() != 1) {
byMessageHash.clear();
for (auto it = message_map.begin(); it != message_map.end(); ++it) {
byMessageHash[(*it)->second.msgHash].emplace_back(*it);
}
batchValid = VerifyBatch(byMessageHash);
}
if (!batchValid) {
badSources.emplace(from);

if (perMessageFallback) {
// revert to per-message verification
if (message_map.size() == 1) {
// no need to re-verify a single message
badMessages.emplace(message_map[0]->second.msgId);
} else {
for (const auto& msgIt : message_map) {
if (badMessages.count(msgIt->first)) {
// same message might be invalid from different source, so no need to re-verify it
continue;
}

const auto& msg = msgIt->second;
if (!msg.sig.VerifyInsecure(msg.pubKey, msg.msgHash)) {
badMessages.emplace(msg.msgId);
}
}
}
}
// Aggregate validity does not authenticate the individual signatures retained by callers.
const bool legacy_scheme = bls::bls_legacy_scheme.load();
std::set<const Message*> invalid_messages;
for (const auto& [key, msg] : messages) {
if (!msg.sig.VerifyInsecure(msg.pubKey, msg.msgHash, legacy_scheme)) {
invalid_messages.emplace(&msg);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Measure loaded share-path throughput before marking the PR ready

Verify() now performs an individual verification for every distinct record, including honest same-hash signature shares that previously used an aggregate public key. This path processes batches of up to 32 shares on the 1–4-thread signing worker pool, with at most four unverified batches outstanding (net_signing.cpp:163 and 358–377). Those bounds limit queued work but do not establish that recovery latency remains acceptable under sustained signing load. The PR documents individual-verification measurements and estimated session costs, but explicitly leaves loaded throughput unmeasured. Before marking this draft ready, record share-path throughput, queueing, and recovery latency under representative InstantSend and ChainLocks load, including the one-worker configuration. This validates the availability cost without weakening individual authentication; no operational slowdown was measured in this review.

source: muse-spark-1.3-contributor (phase1-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also ran a local honest-input share-path harness, with linked production share/recovery code and a mirrored dispatcher. For a 40-session InstantSend burst, completion rates were 9.6 vs 28.0 sessions per elapsed second with one worker, and 29.5 vs 66.2 with four (PR vs develop share verifier). With one worker and 20 sessions/s offered for six seconds, the PR reported 120 successful recoveries for 120 offered sessions in about 12.1 seconds, with 3.66-second median latency, 6.2-second maximum, and up to 3,692 pending shares; the comparison median was 32.6 ms. ChainLocks recovery over three repetitions was 542.5–550.0 ms vs 131.2–133.0 ms with one worker, and 208.9–219.0 ms vs 97.6–99.9 ms with four. Every final sample passed the zero-bad-source and expected-recovery-count assertions; those are aggregate counts, not an independent per-ID audit.

These are finite local observations on a shared M4 Pro, with load average reaching about 75. Each comparison pair ran PR first; pair-specific host load was not measured. Both comparison paths use the PR's unconditional recovery verification. The dispatcher is mirrored, not the production NetSigning object, and excludes its pending-map caps/drops and network workload. Elapsed worker-task duration is not CPU time. The invalid scheme attempt and crashing harness teardown are excluded; no production-crash claim is made. The PR body records these qualifications.

The results show a local availability cost; they do not establish sustained capacity, mainnet headroom, or a universal saturation threshold. Representative hardware, combined load and soak measurements remain open. #7840 stays draft, with both acceptance-boundary coverage and readiness unresolved.


🤖 Posted autonomously by Codex on behalf of pasta.

@thepastaclaw thepastaclaw added the pastaclaw:commented thepastaclaw's latest review was comment-only label Oct 7, 2026
PastaPastaPasta added a commit that referenced this pull request Oct 8, 2026
7a9d1b0 fix: reject malformed detached quorum share batches (pasta)

Pull request description:

  ## Issue being fixed or feature implemented

  When a detached sig share's lazy BLS signature fails to deserialize, `NetSigning::ProcessPendingSigShares` bans the source and stops collecting its shares, but never adds it to `batchVerifier.badSources`. The dispatch loop then passes that source's whole vector to `CSigSharesManager::ProcessPendingSigShares`, including the malformed share and every unverified share after it. That method assumes its input is already verified, so unverified shares can reach the local share manager and interfere with recovery.

  ## What was done?

  In the malformed-signature branch, replace `BanNode(nodeId)` with `batchVerifier.badSources.emplace(nodeId)`. The existing dispatch loop already bans every node in `badSources` once (which also re-requests its shares) and skips its vector, so the malformed source's shares are no longer dispatched. Valid sources go through the same verification path as before.

  #7840 changes the same `ProcessPendingSigShares` block (per-signature BLS authentication) and should be rebased over whichever of the two merges first.

  ## How Has This Been Tested?

  Verified by inspection and build. No regression test is added, because the one-line change is self-evident from the diff: `badSources` now drives the existing ban-and-skip dispatch loop, and `CBLSBatchVerifier::Verify()` only adds to `badSources` and never clears it.

  - macOS arm64: `make -C src dashd test/test_dash dash-cli` built cleanly.
  - `./src/test/test_dash --run_test=llmq_utils_tests` passed.
  - `test/functional/test_runner.py feature_llmq_signing.py "feature_llmq_signing.py --spork21"` passed.

  ## Breaking Changes

  None to consensus rules, historical commitment validation, wire formats, or valid signatures. Detached batches from a source with a malformed share are now dropped instead of dispatched.

  ## 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

  No user-facing documentation change is needed for this internal validation repair.

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

Top commit has no ACKs.

Tree-SHA512: 26cfc448c36e93973224b4cad31b24bfee9341e3632cb73ebda5270f3a55bc0591bd2e5e919869c6793029ed8d89ea713b6099b6a0e1bbf8d876dc44a770e79c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pastaclaw:commented thepastaclaw's latest review was comment-only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants