Skip to content

Verify proposer attestation identity matches block proposer#220

Merged
pablodeymo merged 1 commit intomainfrom
fix/proposer-attestation-identity-check
Mar 13, 2026
Merged

Verify proposer attestation identity matches block proposer#220
pablodeymo merged 1 commit intomainfrom
fix/proposer-attestation-identity-check

Conversation

@pablodeymo
Copy link
Collaborator

Motivation

A spec-to-code compliance audit identified a missing validation check in verify_signatures(). The leanSpec reference implementation requires:

# Critical safety check: the attestation must be from the actual proposer.
# Without this, an attacker could substitute another validator's attestation.
assert proposer_attestation.validator_id == block.proposer_index, (
    "Proposer attestation must be from the block proposer"
)

Our implementation was missing this check.

The problem

In verify_signatures(), the proposer's signature is verified using the public key looked up via block.proposer_index (L1218-1224). This correctly proves the proposer signed the attestation, but it does not verify that proposer_attestation.validator_id matches block.proposer_index.

Later, in on_block_core() (L614), the attestation is stored using the validator_id from the attestation itself:

let proposer_vid = proposer_attestation.validator_id;

This value is then used as the key when inserting the attestation into the fork choice store (L634 or L640-644).

Attack scenario

Without this check, a malicious proposer for slot N could:

  1. Set block.proposer_index = 5 (their real index, required for slot assignment)
  2. Set proposer_attestation.validator_id = 7 (a different validator)
  3. Sign the attestation with their own key (validator 5)

What passes: Signature verification succeeds because it uses block.proposer_index (5) to look up the key, and validator 5 did sign the attestation.

What goes wrong: The attestation is stored under validator 7's identity in the fork choice latest-attestation map. This:

  • Overwrites validator 7's legitimate fork choice vote
  • Points validator 7's vote wherever the proposer wants
  • Gives the proposer influence over 2 fork choice votes instead of 1 (their own at interval 1, plus the hijacked one)

In a small validator set (e.g., 4-node devnet), this represents a 25% advantage per malicious proposal. In larger sets the impact shrinks proportionally.

Fix

One check added to verify_signatures(), directly matching the spec assertion:

if proposer_attestation.validator_id != block.proposer_index {
    return Err(StoreError::ProposerAttestationMismatch {
        attestation_id: proposer_attestation.validator_id,
        proposer_index: block.proposer_index,
    });
}

Test plan

  • make fmt passes
  • make lint passes (clippy with -D warnings)
  • make test passes (all 112 tests, including forkchoice and signature spec tests)
  • Devnet test: verify blocks are still produced and finalized normally

The spec (block.py:186) requires that proposer_attestation.validator_id
equals block.proposer_index before verifying the proposer signature.
Without this check, a malicious proposer could set a different
validator_id in their attestation, which passes signature verification
(keyed on proposer_index) but gets stored under the fake validator_id,
hijacking that validator's fork choice vote.
@github-actions
Copy link

🤖 Kimi Code Review

Review Summary

The PR adds a validation check for proposer attestation validator ID against block proposer index. This is a critical consensus-layer security fix that prevents malicious blocks from including attestations from non-proposer validators.

Issues Found

  1. Inconsistent error naming (line 914-918): The error variant name ProposerAttestationMismatch is misleading. The check is validating that the attestation's validator ID matches the block's proposer index, not detecting a mismatch between proposer and attestation. Consider renaming to ProposerAttestationValidatorMismatch or InvalidProposerAttestationValidator.

  2. Missing validation for attestation slot (line 1222): While checking validator ID is good, there's no validation that the proposer attestation's slot matches the block's slot. This could allow attestations from different slots to be included. Add:

    if proposer_attestation.slot != block.slot {
        return Err(StoreError::ProposerAttestationSlotMismatch { ... });
    }
  3. Error variant ordering (line 914-918): The new error variant is placed in the middle of the enum. For consistency with the codebase pattern, it should be added at the end of the enum to maintain logical grouping.

Positive Aspects

  • The validation logic is correctly placed after signature decoding but before signature verification
  • The error message is descriptive and includes both values for debugging
  • The check directly prevents a potential attack vector where malicious validators could include attestations from other validators

Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link

🤖 Codex Code Review

Findings (ordered by severity):

  1. Low: Missing targeted test for the new identity-binding check.
    The new guard at store.rs:1222 is consensus-critical (prevents proposer/attestation identity mismatch), but I don’t see a corresponding regression test in this PR.
    Suggested test: construct a block where message.proposer_attestation.validator_id != message.block.proposer_index and assert StoreError::ProposerAttestationMismatch.

  2. Nit: Error-field naming is slightly misleading for maintainability.
    In store.rs:917, attestation_id actually represents a validator index/ID. Renaming to something like attestation_validator_id would improve readability and reduce confusion during incident triage.

Overall: the core change looks correct and beneficial. The new check closes a meaningful validation gap by ensuring proposer attestation identity matches the block proposer before proposer signature verification.


Automated review by OpenAI Codex · custom prompt

@github-actions
Copy link

🤖 Claude Code Review

Now I have enough context for a thorough review.


PR Review: Verify proposer attestation validator_id matches block proposer_index

Summary

This PR closes a spec compliance gap in verify_signatures() where proposer_attestation.validator_id was never checked against block.proposer_index. The bug would allow a malicious proposer to hijack another validator's fork choice vote.


Correctness

The fix is correct and necessary.

The attack vector is real and well-described. Before this change:

  • Signature verification at line 1233–1249 uses block.proposer_index to look up the public key — so it verifies that the proposer signed something.
  • But on_block_core() at line 614 then uses proposer_attestation.validator_id as the storage key, meaning the attestation gets credited to whatever validator ID the proposer chose to write in that field.

The new check at lines 1222–1227 closes this gap by ensuring both fields are equal before verifying the signature. Placement is correct: the identity check happens before any expensive crypto operations, which is also a good performance practice.


Placement: Should the check be earlier?

The new check runs after all body attestation verifications (the for loop ending at line 1218). This is technically fine — the body attestation loop only examines block.body.attestations, not the proposer attestation — but consider moving the check to immediately after let proposer_attestation = ... and before the body-attestation loop. This would make it clearer that the proposer identity check is a pre-condition for everything that follows and would fail fast on the cheapest possible check before doing crypto.

That said, the current placement is not a bug — it's a minor readability/ordering nit.


Missing test

The only test in the module is verify_signatures_rejects_participants_mismatch. There is no test for the new ProposerAttestationMismatch path. This should have a unit test:

#[test]
fn verify_signatures_rejects_proposer_attestation_identity_mismatch() {
    // ... build a signed_block where
    // block.proposer_index = 0, proposer_attestation.validator_id = 1
    let result = verify_signatures(&state, &signed_block);
    assert!(
        matches!(result, Err(StoreError::ProposerAttestationMismatch { .. })),
        "Expected ProposerAttestationMismatch, got: {result:?}"
    );
}

Without this, a future refactor that accidentally removes the guard would not be caught by tests.


Error variant design

The new ProposerAttestationMismatch variant is well-structured: named fields (attestation_id, proposer_index) make the error message self-explanatory. No issues here.

One minor nit: the field name attestation_id is slightly ambiguous — it refers to validator_id from the attestation, but the word "id" could refer to the attestation itself. A name like attestation_validator_id would be marginally clearer, but this is a trivial concern.


Spec alignment

The Python spec at block.py:186 asserts:

assert proposer_attestation.validator_id == block.proposer_index

The Rust translation is exact. The PR description and commit message both correctly cite the spec location.


Verdict

Approve with one actionable request: add a unit test for the new ProposerAttestationMismatch error path. The fix itself is correct, well-scoped, and directly matches the spec. The remaining points above are nits that can be addressed in follow-up if desired.


Automated review by Claude (Anthropic) · custom prompt

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR closes a spec-compliance gap in verify_signatures() by asserting that proposer_attestation.validator_id equals block.proposer_index before the cryptographic signature check is performed. Without this guard a malicious proposer could set a different validator's identity on the attestation, pass signature verification (which uses proposer_index to fetch the public key), and then have the attestation stored under a hijacked identity in the fork-choice latest-attestation map.

Key changes:

  • New StoreError::ProposerAttestationMismatch { attestation_id, proposer_index } error variant with a human-readable message (lines 913–919).
  • Identity equality check inserted in verify_signatures() immediately before signature decoding and verification (lines 1222–1227) — matching the leanSpec reference assertion directly.
  • The check correctly runs only on the verified (verify = true) code path in on_block_core(), leaving the test-only on_block_without_verification path unaffected.

Minor observations:

  • The new validation path has no dedicated unit test inside the existing #[cfg(test)] module; a test analogous to verify_signatures_rejects_participants_mismatch should be added to guard against regressions.
  • The struct field attestation_id in ProposerAttestationMismatch differs from the name used in the error message text (validator_id), which can make it harder to trace a rendered error back to the source field.

Confidence Score: 4/5

  • This PR is safe to merge — the fix is correct, minimal, and directly matches the leanSpec reference assertion.
  • The identity check is logically sound, placed at the right point in the verification pipeline, and addresses a real attack vector. Score is 4 rather than 5 because there is no new unit test specifically exercising the ProposerAttestationMismatch path, leaving a regression risk for future refactors. A minor naming inconsistency in the error struct (attestation_id vs validator_id) also slightly reduces clarity.
  • No files require special attention — crates/blockchain/src/store.rs is the only changed file and the change is self-contained.

Important Files Changed

Filename Overview
crates/blockchain/src/store.rs Adds a new ProposerAttestationMismatch error variant and inserts an identity-equality guard in verify_signatures() to ensure proposer_attestation.validator_id == block.proposer_index before proceeding with cryptographic verification. Fix is correct and well-placed; minor gaps around the absence of a dedicated unit test and a slightly ambiguous error-field name.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[on_block] --> B[on_block_core\nverify=true]
    B --> C[verify_signatures]
    C --> D{attestations.len ==\nsignatures.len?}
    D -- No --> E[Err: AttestationSignatureMismatch]
    D -- Yes --> F[For each attestation:\nverify aggregated sig]
    F -- Invalid --> G[Err: AggregateVerificationFailed]
    F -- All valid --> H{proposer_attestation.validator_id\n== block.proposer_index?}
    H -- No --> I["Err: ProposerAttestationMismatch ✨ NEW"]
    H -- Yes --> J[Decode proposer signature]
    J --> K[Look up proposer pubkey\nvia block.proposer_index]
    K --> L{proposer_signature.is_valid?}
    L -- No --> M[Err: ProposerSignatureVerificationFailed]
    L -- Yes --> N[Ok — proceed to\nstate_transition & store]

    style I fill:#f90,color:#000
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 1222-1227

Comment:
**No unit test for the new mismatch check**

The new validation path (`ProposerAttestationMismatch`) is security-critical but has no dedicated unit test. The existing module already has `verify_signatures_rejects_participants_mismatch` as a pattern to follow. Without a test here, a future refactor could accidentally remove or bypass this guard and no regression would catch it.

Consider adding a unit test analogous to the existing one, e.g.:

```rust
#[test]
fn verify_signatures_rejects_proposer_attestation_mismatch() {
    let state = State::from_genesis(1000, vec![]);

    let attestation_data = AttestationData {
        slot: 0,
        head: Checkpoint::default(),
        target: Checkpoint::default(),
        source: Checkpoint::default(),
    };

    // block.proposer_index = 0, but proposer_attestation.validator_id = 1 — mismatch
    let signed_block = SignedBlockWithAttestation {
        message: BlockWithAttestation {
            block: Block {
                slot: 0,
                proposer_index: 0,  // actual proposer
                parent_root: H256::ZERO,
                state_root: H256::ZERO,
                body: BlockBody { attestations: AggregatedAttestations::new(vec![]).unwrap() },
            },
            proposer_attestation: Attestation {
                validator_id: 1, // different validator — triggers the new guard
                data: attestation_data,
            },
        },
        signature: BlockSignatures {
            attestation_signatures: ssz_types::VariableList::new(vec![]).unwrap(),
            proposer_signature: ssz_types::FixedVector::from_elem(0),
        },
    };

    let result = verify_signatures(&state, &signed_block);
    assert!(
        matches!(
            result,
            Err(StoreError::ProposerAttestationMismatch {
                attestation_id: 1,
                proposer_index: 0,
            })
        ),
        "Expected ProposerAttestationMismatch, got: {result:?}"
    );
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 913-919

Comment:
**Field name `attestation_id` is ambiguous**

The error message renders the field as `validator_id` (the description says "validator_id {attestation_id}"), but the struct field is named `attestation_id`. This slight mismatch between the struct field name and the error message text can make it harder to correlate a runtime error string back to the source struct when debugging.

A more self-documenting name would make the relationship explicit:

```suggestion
    #[error(
        "Proposer attestation validator_id {attestation_validator_id} does not match block proposer_index {proposer_index}"
    )]
    ProposerAttestationMismatch {
        attestation_validator_id: u64,
        proposer_index: u64,
    },
```

(The construction site at line 1224 would need to update the field name accordingly.)

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: b788bd2

Comment on lines +1222 to +1227
if proposer_attestation.validator_id != block.proposer_index {
return Err(StoreError::ProposerAttestationMismatch {
attestation_id: proposer_attestation.validator_id,
proposer_index: block.proposer_index,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

No unit test for the new mismatch check

The new validation path (ProposerAttestationMismatch) is security-critical but has no dedicated unit test. The existing module already has verify_signatures_rejects_participants_mismatch as a pattern to follow. Without a test here, a future refactor could accidentally remove or bypass this guard and no regression would catch it.

Consider adding a unit test analogous to the existing one, e.g.:

#[test]
fn verify_signatures_rejects_proposer_attestation_mismatch() {
    let state = State::from_genesis(1000, vec![]);

    let attestation_data = AttestationData {
        slot: 0,
        head: Checkpoint::default(),
        target: Checkpoint::default(),
        source: Checkpoint::default(),
    };

    // block.proposer_index = 0, but proposer_attestation.validator_id = 1 — mismatch
    let signed_block = SignedBlockWithAttestation {
        message: BlockWithAttestation {
            block: Block {
                slot: 0,
                proposer_index: 0,  // actual proposer
                parent_root: H256::ZERO,
                state_root: H256::ZERO,
                body: BlockBody { attestations: AggregatedAttestations::new(vec![]).unwrap() },
            },
            proposer_attestation: Attestation {
                validator_id: 1, // different validator — triggers the new guard
                data: attestation_data,
            },
        },
        signature: BlockSignatures {
            attestation_signatures: ssz_types::VariableList::new(vec![]).unwrap(),
            proposer_signature: ssz_types::FixedVector::from_elem(0),
        },
    };

    let result = verify_signatures(&state, &signed_block);
    assert!(
        matches!(
            result,
            Err(StoreError::ProposerAttestationMismatch {
                attestation_id: 1,
                proposer_index: 0,
            })
        ),
        "Expected ProposerAttestationMismatch, got: {result:?}"
    );
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 1222-1227

Comment:
**No unit test for the new mismatch check**

The new validation path (`ProposerAttestationMismatch`) is security-critical but has no dedicated unit test. The existing module already has `verify_signatures_rejects_participants_mismatch` as a pattern to follow. Without a test here, a future refactor could accidentally remove or bypass this guard and no regression would catch it.

Consider adding a unit test analogous to the existing one, e.g.:

```rust
#[test]
fn verify_signatures_rejects_proposer_attestation_mismatch() {
    let state = State::from_genesis(1000, vec![]);

    let attestation_data = AttestationData {
        slot: 0,
        head: Checkpoint::default(),
        target: Checkpoint::default(),
        source: Checkpoint::default(),
    };

    // block.proposer_index = 0, but proposer_attestation.validator_id = 1 — mismatch
    let signed_block = SignedBlockWithAttestation {
        message: BlockWithAttestation {
            block: Block {
                slot: 0,
                proposer_index: 0,  // actual proposer
                parent_root: H256::ZERO,
                state_root: H256::ZERO,
                body: BlockBody { attestations: AggregatedAttestations::new(vec![]).unwrap() },
            },
            proposer_attestation: Attestation {
                validator_id: 1, // different validator — triggers the new guard
                data: attestation_data,
            },
        },
        signature: BlockSignatures {
            attestation_signatures: ssz_types::VariableList::new(vec![]).unwrap(),
            proposer_signature: ssz_types::FixedVector::from_elem(0),
        },
    };

    let result = verify_signatures(&state, &signed_block);
    assert!(
        matches!(
            result,
            Err(StoreError::ProposerAttestationMismatch {
                attestation_id: 1,
                proposer_index: 0,
            })
        ),
        "Expected ProposerAttestationMismatch, got: {result:?}"
    );
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +913 to +919
#[error(
"Proposer attestation validator_id {attestation_id} does not match block proposer_index {proposer_index}"
)]
ProposerAttestationMismatch {
attestation_id: u64,
proposer_index: u64,
},
Copy link
Contributor

Choose a reason for hiding this comment

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

Field name attestation_id is ambiguous

The error message renders the field as validator_id (the description says "validator_id {attestation_id}"), but the struct field is named attestation_id. This slight mismatch between the struct field name and the error message text can make it harder to correlate a runtime error string back to the source struct when debugging.

A more self-documenting name would make the relationship explicit:

Suggested change
#[error(
"Proposer attestation validator_id {attestation_id} does not match block proposer_index {proposer_index}"
)]
ProposerAttestationMismatch {
attestation_id: u64,
proposer_index: u64,
},
#[error(
"Proposer attestation validator_id {attestation_validator_id} does not match block proposer_index {proposer_index}"
)]
ProposerAttestationMismatch {
attestation_validator_id: u64,
proposer_index: u64,
},

(The construction site at line 1224 would need to update the field name accordingly.)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 913-919

Comment:
**Field name `attestation_id` is ambiguous**

The error message renders the field as `validator_id` (the description says "validator_id {attestation_id}"), but the struct field is named `attestation_id`. This slight mismatch between the struct field name and the error message text can make it harder to correlate a runtime error string back to the source struct when debugging.

A more self-documenting name would make the relationship explicit:

```suggestion
    #[error(
        "Proposer attestation validator_id {attestation_validator_id} does not match block proposer_index {proposer_index}"
    )]
    ProposerAttestationMismatch {
        attestation_validator_id: u64,
        proposer_index: u64,
    },
```

(The construction site at line 1224 would need to update the field name accordingly.)

How can I resolve this? If you propose a fix, please make it concise.

@pablodeymo pablodeymo merged commit 21bc157 into main Mar 13, 2026
7 checks passed
@pablodeymo pablodeymo deleted the fix/proposer-attestation-identity-check branch March 13, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants