Remove aggregated signature verification bypass for small proof_data#198
Conversation
verify_aggregated_signature() returned Ok(()) for any proof_data under 10 bytes, silently accepting unverified aggregated signatures. This was reachable from two production P2P paths: block attestation verification and gossip aggregated attestation verification. The bypass was likely added during development to avoid SSZ deserialization errors when empty proofs (from AggregatedSignatureProof::empty()) reached the verifier. However, the verify=false path that uses empty proofs never calls verify_signatures, so the guard was unnecessary — and it converted what should be an error into a silent success on the production verify=true path. A cross-client review confirmed no other lean consensus client (Zeam, Ream, Lantern, Grandine) has this bypass. All reject empty proof_data via SSZ deserialization failure, explicit guards, or invariant panics. All spec tests pass without the bypass (forkchoice, state transition, signatures).
🤖 Kimi Code ReviewSecurity Vulnerability: Signature BypassFile: The removal of the length check Impact: Without this check, empty or very short Recommendation: Restore the length check or verify its necessity. If the check was overly permissive, replace it with a more precise validation (e.g., checking for exact expected lengths based on XMSS parameters). The magic number Additional concern: The comment Automated review by Kimi (Moonshot AI) · custom prompt |
🤖 Codex Code Review
Automated review by OpenAI Codex · custom prompt |
🤖 Claude Code ReviewNow I have all the context I need to write the review. Review: Remove aggregated signature verification bypass for small proof_dataSummaryA minimal, surgical fix removing a critical security bypass in Security ImpactThe removed guard was a critical vulnerability on two production P2P paths:
Both paths feed directly into fork choice weight updates and the 3SF-mini justification/finalization thresholds. The attacker surface was unauthenticated (gossip), so no validator key was needed to exploit it. The fix is correct: removing the guard causes Code Review
if proof_data.len() < 10 {
return Ok(());
}Removal is correct. The
The function correctly derives
ethlambda_crypto::verify_aggregated_signature(
&aggregated.proof.proof_data,
...
)After this fix, an empty Test CoverageThe three spec test suites (forkchoice: 26/26, stf: 14/14, signature: 8/8) adequately cover the removal. There is no fast (non- #[test]
fn test_empty_proof_data_is_rejected() {
let empty = ByteListMiB::new(vec![]).unwrap();
let result = verify_aggregated_signature(&empty, vec![], &H256::ZERO, 0);
assert!(result.is_err(), "empty proof_data must not verify");
}This would act as a regression guard without requiring leanVM and wouldn't need VerdictApprove. The fix is minimal, correct, and closes a critical unauthenticated bypass. The follow-up items ( Automated review by Claude (Anthropic) · custom prompt |
Greptile SummaryThis PR removes a 4-line early-return bypass in Key points:
Confidence Score: 5/5
|
| Filename | Overview |
|---|---|
| crates/common/crypto/src/lib.rs | Removes a 4-line early-return bypass that silently accepted any proof_data shorter than 10 bytes without cryptographic verification; empty/small proofs now correctly fail at SSZ deserialization. No regression test guards against reintroduction. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["P2P message received"] --> B{"verify == true?"}
B -- No --> C["Skip verify_signatures\nEmpty proofs OK"]
B -- Yes --> D["verify_signatures"]
D --> E["verify_aggregated_signature\nproof_data, pubkeys, msg, slot"]
E --> F{"BEFORE: proof_data.len() less than 10?"}
F -- Yes --> G["return Ok - BYPASS\nNo crypto performed"]
F -- No --> H["from_ssz_bytes deserialization"]
E --> K["AFTER: from_ssz_bytes deserialization"]
K -- "Fails for empty/short data" --> L["return Err DeserializationFailed"]
K -- Succeeds --> M["xmss_verify_aggregated_signatures"]
H --> M
M --> N["return Ok"]
style G fill:#ff4444,color:#fff
style L fill:#44aa44,color:#fff
style N fill:#44aa44,color:#fff
Comments Outside Diff (1)
-
crates/common/crypto/src/lib.rs, line 159 (link)Missing regression test for the removed bypass
The test suite has no fast, non-ignored test verifying that empty or short
proof_datanow returnsErr(VerificationError::DeserializationFailed). All the meaningfulverify_aggregated_signaturetests are marked#[ignore = "too slow"], which means CI won't catch a reintroduction of this bypass.Adding a dedicated unit test requires no XMSS key generation and would run in milliseconds:
#[test] fn test_empty_proof_data_rejects_with_deserialization_error() { let empty_proof = ByteListMiB::new(vec![]).expect("empty list should be valid"); let result = verify_aggregated_signature(&empty_proof, vec![], &H256::ZERO, 0); assert!( matches!(result, Err(VerificationError::DeserializationFailed)), "empty proof_data must be rejected, got: {:?}", result ); }
A complementary test with 1–9 bytes of junk data would also harden coverage of the formerly-bypassed range.
Last reviewed commit: ba28031
…participants (#202) ## Motivation `StoreError::ParticipantsMismatch` was defined (`store.rs:899`) but never raised anywhere in the codebase. During block signature verification, `verify_signatures` extracts validator IDs from `attestation.aggregation_bits` and verifies the crypto proof against those public keys — but never checks that `aggregated_proof.participants` matches `aggregation_bits`. This is a problem because the block-building path uses `proof.participants` (not `aggregation_bits`) as the source of truth for which validators a stored proof covers. ### Why these two bitfields exist A `SignedBlockWithAttestation` contains both: - **`block.body.attestations[i].aggregation_bits`** — in the block body, declares "these validators attested" - **`signature.attestation_signatures[i].participants`** — in the signatures section, declares "this proof covers these validators" For an honest block, these are always identical. But since they come from untrusted network input (a peer's block), they can be crafted to differ. ### The data flow that makes this exploitable ``` RECEIVE PATH (on_block_core, verify_signatures) ──────────────────────────────────────────────── 1. verify_signatures extracts validator IDs from aggregation_bits 2. Collects public keys for those validators 3. Verifies crypto proof against those public keys → passes 4. Stores the WHOLE proof object (including proof.participants) in the cache BUILD PATH (select_aggregated_proofs, line 1118-1135) ───────────────────────────────────────────────────── 1. Reads stored proofs from cache 2. Uses proof.participants to determine coverage (line 1121) 3. Sets new block's aggregation_bits = proof.participants (line 1135) If participants ≠ aggregation_bits, the stored proof has wrong metadata, and the receiver will build blocks with incorrect aggregation_bits. ``` ### Attack scenario A malicious proposer (who is legitimately the proposer for a slot) crafts a block where: | Field | Value | |-------|-------| | `attestation.aggregation_bits` | `{1, 2, 3}` | | `proof.participants` | `{1, 2, 3, 4, 5}` | | `proof.proof_data` | valid proof for `{1, 2, 3}` | **What happens on the victim node:** 1. `verify_signatures` checks proof against `aggregation_bits` = `{1, 2, 3}` → **passes** ✅ 2. Proof is stored in the cache with `participants = {1, 2, 3, 4, 5}` → **poisoned cache** 3. Victim later becomes proposer, calls `select_aggregated_proofs` 4. `select_aggregated_proofs` reads `proof.participants` → thinks proof covers 5 validators 5. New block sets `aggregation_bits = {1, 2, 3, 4, 5}` (from `proof.participants`, line 1135) 6. Other nodes verify this block: proof checked against 5 public keys, but proof only covers 3 7. **Block is rejected** → victim produces invalid blocks **Impact:** The victim node builds invalid blocks that the rest of the network rejects. This damages the victim's reputation and causes missed proposal rewards. Requires the attacker to be a legitimate proposer for at least one slot. ### Other clients Both Zeam and Lantern enforce this check: - **Zeam** compares the two bitfields element-by-element in `verifySignatures()`, returns `InvalidBlockSignatures` on mismatch - **Lantern** does `memcmp` on the participant/aggregation bitfields in `signed_block_signatures_are_valid()` ## Description Add a `BitList` equality check in `verify_signatures` (before the crypto verification loop body) that compares `attestation.aggregation_bits` against `aggregated_proof.participants`. On mismatch, return the already-defined `StoreError::ParticipantsMismatch`. ```rust // Added at the top of the loop, before any expensive operations if attestation.aggregation_bits != aggregated_proof.participants { return Err(StoreError::ParticipantsMismatch); } ``` This runs before the public key collection and crypto verification, so invalid blocks are rejected cheaply without wasting CPU on signature verification. ## How to test All spec tests pass: ```bash cargo test -p ethlambda-blockchain --test forkchoice_spectests --release # 26/26 passed cargo test -p ethlambda-state-transition --test stf_spectests --release # 14/14 passed cargo test -p ethlambda-blockchain --test signature_spectests --release # 8/8 passed make lint # clean ``` ## Related - #198 — Removes the `proof_data.len() < 10` bypass in `verify_aggregated_signature` (separate but related fix)
Motivation
During a security review, we identified that
verify_aggregated_signature()incrates/common/crypto/src/lib.rssilently returnedOk(())for anyproof_dataunder 10 bytes — including empty proofs — without performing any cryptographic verification.This was reachable from two production P2P network paths:
store.rs:459) — any peer can send aSignedAggregatedAttestationwith emptyproof_data. No proposer key required.store.rs:1197) — a malicious proposer can include attestations with fabricatedaggregation_bitsand emptyproof_data.A malicious peer could forge attestation votes to manipulate fork choice (LMD-GHOST head selection) and influence justification/finalization thresholds.
Note: proposer individual XMSS signatures are NOT affected — they use
ValidatorSignature::is_valid(), a separate code path with no size-based bypass.Origin of the bypass
AggregatedSignatureProof::empty()creates proofs with emptyproof_datafor theverify=falsetest path. The< 10guard was likely added to prevent SSZ deserialization errors when these empty proofs reached the verifier during development. However, theverify=falsepath never callsverify_signatures, so the guard was unnecessary — and it converted what should be an error into a silent success on the productionverify=truepath.Description
Remove the early-return bypass in
verify_aggregated_signature():Without this guard, empty/small
proof_datawill naturally fail at the SSZ deserialization step (Devnet2XmssAggregateSignature::from_ssz_bytes) and returnVerificationError::DeserializationFailed.How to test
All spec tests pass without the bypass:
Related
StoreError::ParticipantsMismatchis defined atstore.rs:899but never raised. Could be addressed in a follow-up.