From f398161a3399b71a0b1a2365d55b02e4be65de2a Mon Sep 17 00:00:00 2001 From: Matthias Wright Date: Thu, 18 Jun 2026 20:12:53 +0800 Subject: [PATCH 1/2] test: cover the checkpoint_hash binding --- application/src/actor.rs | 65 ++++++++++++++++++++++++++++++++++++++++ types/src/checkpoint.rs | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/application/src/actor.rs b/application/src/actor.rs index 4a23c581..6e6b7a33 100644 --- a/application/src/actor.rs +++ b/application/src/actor.rs @@ -2243,4 +2243,69 @@ mod tests { "honest block with prev_randao = 0 must be accepted" ); } + + /// handle_verify must reject a block whose `checkpoint_hash` disagrees with + /// the verifying node's locally-derived `aux_data.checkpoint_hash`. An honest + /// validator only votes for a block whose checkpoint_hash matches the + /// checkpoint it computed from its own canonical state, so a finalized + /// terminal header's checkpoint_hash provably commits to the canonical + /// state an honest supermajority agreed on. This is the consensus-layer + /// binding that makes checkpoint-state injection (extra accounts, funds, + /// params) unreachable on the verified import path: such bytes change the + /// checkpoint digest and would never be signed. + /// + /// This mirrors `accepts_ordinary_child_inside_epoch` exactly, changing only + /// the block's checkpoint_hash, so the checkpoint_hash check is the sole + /// cause of the flip from accept to reject. + #[test] + fn rejects_block_with_mismatched_checkpoint_hash() { + let parent_height = 3; // mid-epoch 0 + let parent = make_block( + [0u8; 32].into(), + parent_height, + 0, + parent_height, + parent_height * 12, + ); + + // Identical to the accepted ordinary child, except it carries an + // attacker-chosen checkpoint_hash instead of the expected `None`. + let height = parent_height + 1; + let timestamp = height * 12; + let payload = empty_payload(height, parent.eth_block_hash(), timestamp); + let block = Block::compute_digest( + parent.digest(), + height, + timestamp, + payload, + Vec::new(), + 0, + height, + Some([7u8; 32].into()), // <-- disagrees with aux_data.checkpoint_hash (None) + [0u8; 32].into(), + Vec::new(), + Vec::new(), + [0u8; 32], + ); + + // The verifying node derived no checkpoint at this height (None = [0; 32]). + let aux_data = make_aux_data(0); + + let round = Round::new(Epoch::new(aux_data.epoch), View::new(block.view())); + let parent_view = parent.view(); + assert!( + !handle_verify( + round, + &block, + parent, + parent_view, + &epocher(), + &aux_data, + u64::MAX / 4, + u32::MAX + ), + "block whose checkpoint_hash disagrees with the locally-derived \ + aux_data.checkpoint_hash must be rejected" + ); + } } diff --git a/types/src/checkpoint.rs b/types/src/checkpoint.rs index f1d57203..72344c1d 100644 --- a/types/src/checkpoint.rs +++ b/types/src/checkpoint.rs @@ -2151,4 +2151,61 @@ mod tests { from the signed certificate payload, got {result:?}" ); } + + // A malicious checkpoint carries an extra attacker-controlled + // validator account with status Joining, while the active signing set still + // matches the finalized-header chain. The verifier's reverse membership check + // only rejects extra *Active* accounts (extra Joining accounts are legitimate + // for pending joiners), so the membership check alone would let it through. + // It is instead caught by the Step 2 checkpoint-hash binding: injecting the + // account changes the checkpoint digest, which no longer matches the + // checkpoint_hash the honest terminal header committed to. An attacker cannot + // append a Joining account to a checkpoint and still pair it with the genuine + // finalized-header chain. + #[test] + fn test_checkpoint_verifier_rejects_extra_joining_account() { + use crate::account::{ValidatorAccount, ValidatorStatus}; + + let (genesis, checkpoint, _tampered, honest) = + checkpoint_verification_fixture(0, 1, Vec::new(), false); + + // Sanity: the honest checkpoint verifies against the honest finalized header. + super::verify_checkpoint_chain(&genesis, std::slice::from_ref(&honest), &checkpoint) + .expect("fixture checkpoint should verify before tampering"); + + // Inject an extra Joining account into the decoded state, leaving the + // active signing set untouched. + let mut state = + ConsensusState::try_from(&checkpoint).expect("honest checkpoint state should decode"); + let rogue_node = ed25519::PrivateKey::from_seed(99).public_key(); + let rogue_node_bytes: [u8; 32] = rogue_node + .as_ref() + .try_into() + .expect("ed25519 public key is 32 bytes"); + let rogue_account = ValidatorAccount { + consensus_public_key: bls12381::PrivateKey::from_seed(999).public_key(), + withdrawal_credentials: Address::from([99u8; 20]), + balance: 32_000_000_000, + status: ValidatorStatus::Joining, + has_pending_deposit: false, + has_pending_withdrawal: false, + joining_epoch: 5, + last_deposit_index: 0, + }; + state.set_account(rogue_node_bytes, rogue_account); + + // Re-encoding the tampered state changes the checkpoint digest, so it no + // longer matches the honest terminal header's committed checkpoint_hash. + let tampered = Checkpoint::new(&state); + let result = + super::verify_checkpoint_chain(&genesis, std::slice::from_ref(&honest), &tampered); + assert!( + matches!( + result, + Err(super::CheckpointVerificationError::CheckpointHashMismatch) + ), + "verifier must reject a checkpoint carrying an extra Joining account not \ + committed by the terminal finalized header, got {result:?}" + ); + } } From 830a26d63e1c742a872e67a33a326d0e4ec5134f Mon Sep 17 00:00:00 2001 From: Matthias Wright Date: Thu, 2 Jul 2026 13:45:58 +0800 Subject: [PATCH 2/2] fix: tests --- types/src/checkpoint.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/types/src/checkpoint.rs b/types/src/checkpoint.rs index 72344c1d..d73ac99f 100644 --- a/types/src/checkpoint.rs +++ b/types/src/checkpoint.rs @@ -2095,7 +2095,7 @@ mod tests { use crate::header::{FinalizedHeader, FinalizedHeaderError, Header}; let (genesis, checkpoint, _tampered_checkpoint, honest) = - checkpoint_verification_fixture(0, 1, Vec::new(), false); + checkpoint_verification_fixture(0, 1); // Sanity: the honest finalized header verifies against the honest checkpoint. super::verify_checkpoint_chain(&genesis, std::slice::from_ref(&honest), &checkpoint) @@ -2166,8 +2166,7 @@ mod tests { fn test_checkpoint_verifier_rejects_extra_joining_account() { use crate::account::{ValidatorAccount, ValidatorStatus}; - let (genesis, checkpoint, _tampered, honest) = - checkpoint_verification_fixture(0, 1, Vec::new(), false); + let (genesis, checkpoint, _tampered, honest) = checkpoint_verification_fixture(0, 1); // Sanity: the honest checkpoint verifies against the honest finalized header. super::verify_checkpoint_chain(&genesis, std::slice::from_ref(&honest), &checkpoint)