Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions application/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
}
58 changes: 57 additions & 1 deletion types/src/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2151,4 +2151,60 @@ 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);

// 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:?}"
);
}
}
Loading