Skip to content

fix: bind checkpoint state position to the verified terminal header#402

Closed
matthias-wright wants to merge 3 commits into
audit-may-2026from
m/checkpoint-header-chain
Closed

fix: bind checkpoint state position to the verified terminal header#402
matthias-wright wants to merge 3 commits into
audit-may-2026from
m/checkpoint-header-chain

Conversation

@matthias-wright

Copy link
Copy Markdown
Collaborator

Addresses #215.

Changes:

  • Add a position-binding step to verify_checkpoint_chain. Since the checkpoint is created at the penultimate block of an epoch and the terminal header is the last block, an honest checkpoint satisfies: latest_height + 1 == terminal.height(), head_digest == terminal.parent(), epoch == terminal.epoch(). Reject any checkpoint that doesn't.
  • Add CheckpointVerificationError::CheckpointStatePositionMismatch.
  • Parameterize checkpoint_verification_fixture by (state_latest_height, terminal_header_height) so it models the penultimate→last structure.
  • Add regression test test_checkpoint_verifier_binds_state_position_to_terminal_header: a consistent (5,6) checkpoint verifies; an inconsistent (5,9) one is rejected.

@matthias-wright matthias-wright force-pushed the m/checkpoint-header-chain branch from 9d32aa7 to 1d0d4e9 Compare June 16, 2026 03:31
@sebastian-osec

Copy link
Copy Markdown

I think this is a good partial fix for the positional part of #215. The new checks correctly bind the decoded checkpoint state to the verified terminal header for:

  • latest_height + 1 == terminal.height()
  • epoch == terminal.epoch()
  • head_digest == terminal.parent()

That matches the honest checkpoint flow, where the checkpoint is created from the penultimate block and committed by the terminal block.

However, I do not think this fully fixes #215 as written. The issue also calls out other decoded ConsensusState fields that are still independent checkpoint contents, especially forkchoice, epoch_genesis_hash, and the embedded DynamicEpocher.

Those fields are still controlled by whoever constructs the checkpoint artifact. A malicious checkpoint can satisfy the new height/epoch/head checks while still choosing a forged forkchoice or inconsistent epoch schedule, as long as those checkpoint bytes are committed by the terminal header. That matters because startup reads canonical_state.get_forkchoice() and commits that execution hash.

The added test covers the (latest_height, terminal_height) mismatch with (5, 9), but it does not test mutated forkchoice, epoch_genesis_hash, or an invalid DynamicEpocher horizon/schedule.

Small defensive note: saturating_add(1) would be better as checked_add(1) so an overflowed latest_height cannot accidentally satisfy the invariant.

@matthias-wright

Copy link
Copy Markdown
Collaborator Author

#422 (which builds on this PR) prevents loading a single-checkpoint file without verification (unless an unsafe flag is set), and also adds verification, such as checking that DynamicEpocher actually covers the decoded epoch/latest_height.
I think #422 fixes #215, so we should be able to close this PR once #422 is merged.

@sebastian-osec

Copy link
Copy Markdown

That makes sense to me. Since #422 builds on this branch and includes the checkpoint-position binding commit from #402, I think #402 can be closed as superseded once #422 is merged.

#422 also covers the follow-up concerns here by requiring verified checkpoint startup by default, binding last_block to the verified terminal finalization payload, using the verified terminal header instead of the separately loaded artifact, and checking that the embedded DynamicEpocher covers the decoded epoch/latest_height.

matthias-wright added a commit that referenced this pull request Jun 29, 2026
…acts to verified chain (#422)

Builds on #402.

Addresses #214.

Changes:
-Require verification by default. run_node_inner now panics when a checkpoint is supplied without a finalized-headers chain to verify against, instead of warning and continuing.
-Add --unsafe-skip-checkpoint-verification (RunFlags, requires --checkpoint-path) as the explicit opt-out for importing a checkpoint without verification artifacts; logs a loud UNSAFE warning when used.
-Bind last_block to the verified chain. On the verified path, require last_block.digest() == terminal.finalization().proposal.payload (the verified chain terminal), panicking on mismatch.
-Use the signature-verified terminal as the completion finalization. Replace the separately-loaded, unverified top-level finalized_header with the chain terminal FinalizedHeader (whose certificate was verified by -verify_checkpoint_chain) before handing it to the syncer.
-Cover checkpoint startup policy and harden artifact binding.
@matthias-wright

Copy link
Copy Markdown
Collaborator Author

Superseded by #422.

matthias-wright added a commit that referenced this pull request Jun 30, 2026
#423)

Builds on #422 (which builds on #402)

Addresses #216.

Changes:
-types/src/checkpoint.rs: add Step 5 to verify_checkpoint_chain_with_weak_subjectivity binding the decoded queues to the terminal header's committed deltas — require checkpoint_state.get_added_validators(next_epoch) to equal terminal.added_validators() and checkpoint_state.get_removed_validators() to equal terminal.removed_validators() (order-normalized), rejecting mismatches with a new CheckpointVerificationError::CheckpointTransitionQueueMismatch.
-Document the inherent residual: pending additions for epochs beyond next_epoch (possible when validator warm-up spans more than one epoch) are committed by finalized headers after the terminal and cannot be authenticated by a chain ending at this epoch; they are bound only once the next epoch's boundary header is verified.
-Test: add test_checkpoint_verifier_binds_transition_queues_to_terminal_header (honest empty-queue checkpoint verifies; a checkpoint carrying a pending removed_validators entry while the terminal header commits an empty set is rejected, with hash/membership/position checks still passing). Parameterize checkpoint_verification_fixture with the checkpoint's removed_validators queue and update its callers.
matthias-wright added a commit that referenced this pull request Jul 2, 2026
…425)

Builds on #423 (which builds on #422 and #402).

Addresses #217.

Changes:
-Step 3 now accumulates a node-key to BLS-key map and requires each checkpoint account's consensus_public_key to equal the BLS key accumulated for that node from the verified history. Mismatch returns the new CheckpointConsensusKeyMismatch error variant. The reverse membership check uses the same map.
-Add test_checkpoint_verifier_binds_consensus_keys_to_terminal_header covering both directions: the honest case (accounts carry the real, accumulated consensus keys and verify) and a swapped consensus key (rejected with CheckpointConsensusKeyMismatch). The shared checkpoint_verification_fixture gains a tamper_consensus_key parameter that swaps one active account's stored consensus key while leaving its node key, status, and the accumulated set intact.
-Bind checkpoint consensus keys to the verified terminal header.
matthias-wright added a commit that referenced this pull request Jul 2, 2026
Builds on #425 (which builds on #423, #422, and #402).

Addresses #310.

This issue was already solved by #170 and #286.

This PR just adds a regression test.

Changes:
-Add regression for finalized-header epoch-replay rejection
matthias-wright added a commit that referenced this pull request Jul 2, 2026
Builds on #426 (which builds on #425, #423, #422, and #402).

Addresses #311.

This only adds test coverage, the issue is not reachable.
The decoded ConsensusState, including every validator_accounts entry, is committed by checkpoint.data, which verify_checkpoint_chain binds to the terminal finalized header via checkpoint_hash == sha256(checkpoint.data). Appending a Joining account changes the digest, so the tampered checkpoint no longer matches the honest terminal header and is rejected at Step 2.

Changes:
-types/src/checkpoint.rs: test_checkpoint_verifier_rejects_extra_joining_account — reproduces the attack directly. It decodes the honest checkpoint, injects an extra Joining account (leaving the active signing set untouched), re-encodes, and asserts verify_checkpoint_chain rejects it with CheckpointHashMismatch.
-application/src/actor.rs: rejects_block_with_mismatched_checkpoint_hash — the consensus-layer half. It mirrors accepts_ordinary_child_inside_epoch, changing only the block's checkpoint_hash, and asserts handle_verify returns false, isolating the checkpoint_hash check as the sole cause.
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