fix(types): validate the decoded epoch schedule in DynamicEpocher::read_cfg - #461
erkancamli wants to merge 3 commits into
Conversation
…ad_cfg bounds() and containing() both document that they rely on the segments being sorted by start_epoch and by start_height, which only holds for a schedule update_length built. read_cfg checked only that the segment count and each length were non-zero, so an imported checkpoint or a persisted state could carry a schedule where the two queries disagree about which epoch a height belongs to.
|
No workflow runs have been approved on this one yet, so there are no checks to look at. Could someone enable them? Locally: Same question applies to #460 and #458 if it is easier to enable them together. |
Mutation check: replacing the first-segment and strictly-increasing
guards with `if false` left `cargo test -p summit-types --lib
dynamic_epocher` fully green. The single test buffer only trips the
height-continuity branch, so two thirds of the new validation could be
deleted without CI noticing.
Also tightens the existing assertion. It was `if let Ok(..) { panic!() }`,
which would have passed on an EndOfBuffer too; it now pins
Error::Invalid with the specific message, matching the style of the
neighbouring tests in this module.
|
Two of the three guards I added were untested, and I only found that by mutating them rather than by reading the test. Replacing each guard's condition with
The single test buffer Pushed
I also tightened the original assertion. It was Current: One thing worth stating plainly that the description left implicit: the reason this is worth doing at all is that I also checked that nothing committed breaks: there are no |
Problem
Both query paths on
DynamicEpocherdocument the invariant they depend on.bounds:and
containingrepeats it verbatim.boundsresolves a query by scanning the segments in reverse onstart_epoch;containingscans in reverse onstart_height. They agree only on a schedule shaped the wayupdate_lengthbuilds one: segment 0 anchored at (epoch 0, height 0), and each later segment starting at exactlyprev.start_height + (start_epoch - prev.start_epoch) * prev.length.update_lengthmaintains that, because it anchors a new segment atbounds(target_epoch).0.read_cfgdoes not check it. It validates only that the segment count is non-zero and that eachlengthis non-zero:read_cfgis the import path for untrusted bytes:ConsensusState::read_cfgcalls it, andConsensusState::try_from(&Checkpoint)decodes exactly those bytes, so it covers checkpoint bootstrap as well as every persisted-state load. The decode is already written with that threat model in mind, as the comment just above the loop shows:so the ordering invariant looks like an oversight rather than a deliberate omission.
What goes wrong
Feed it two segments:
(epoch 0, height 0, length 100)and(epoch 5, height 50, length 100).update_lengthcould never produce the second one, since it would anchor epoch 5 at height 500, not 50. The decode accepts it, and the two documented-as-consistent queries then disagree about the same height:That matters because the two sides are used for different jobs.
Checkpoint's Step 5 exists to catch an epocher that disagrees with the rest of the state, and its comment names the threat directly:but Step 5 only consults
epoch_bounds, theboundsside. Runtime epoch-boundary classification goes through the other side:is_first_block_of_epoch/is_last_block_of_epoch/is_penultimate_block_of_epochintypes/src/utils.rsall callcontaining, and those drive checkpoint creation and committee transitions in the finalizer, plusheader_view_binds_to_roundin the syncer. So a schedule can satisfy Step 5 while the importing node classifies epoch boundaries differently from the rest of the network.The sibling that already does this
WithdrawalQueue::read_flatrejects an out-of-order decoded deque, with a comment that states the same reasoning:Same shape, same exposure. This PR gives the epocher decode the same treatment.
Fix
Reject in
read_cfganythingupdate_lengthcould not have produced: the first segment must start at (epoch 0, height 0); later segments must have a strictly increasingstart_epochand astart_heightthat continues the preceding segment's schedule exactly. The arithmetic is checked, so a crafted schedule cannot overflow its way past the comparison.No legitimate schedule changes: every segment
update_lengthappends satisfies this by construction.Tests
read_cfg_rejects_a_schedule_the_builder_could_not_producebuilds the crafted schedule above and asserts the decode is refused. If the decode succeeds, it prints both query results so the failure explains itself rather than just saying "expected Err".It fails on
mainwith the message quoted above, and passes with the fix.cargo test -p summit-types --lib→ 478 passed, 0 failed, 1 ignoredcargo test -p summit --lib tests::checkpointing→ 19 passed, 0 failed (these exercise real checkpoint join and replay flows, which encode and decode live epochers, so they confirm legitimate schedules still decode)cargo fmt --checkcleanA second, smaller thing I did not include
SszTree::verify_proof(types/src/ssz_tree.rs) omits the terminalidx == 1check that its siblingverify_proof_gindexperforms, and does not boundleaf_indexagainst1 << depth, so a proof generated for leafialso verifies fori + 2^depth. I confirmed that by running it. I left it out of this PR becauseverify_proofhas no production caller: every real proof path goes throughverify_proof_gindex, which is correct. Happy to open a separate hardening PR if you want it closed anyway.