Repository navigation
fix(sync): authenticate heights and exclude rejected block suppliers - #1036
evan-forbes wants to merge 21 commits into
Conversation
And two more auto-invalidated findings. Analyzed 16 files, diff |
The audit of #1036 reported two ways a supplier escapes the exclusion this pull request introduces. A body whose transaction list is empty or does not start with a coinbase transaction has no coinbase height. The block identifier comes from the header, so such a response still satisfies the requested hash, but the download failed with `InvalidHeight` and discarded the attached feedback, leaving the supplier eligible for the same hash on every retry. It is now rejected. The advertiser stays unattributed, because it did not choose the body. The Zakura response decoder checked only that each delivered hash was requested, not that it was delivered once. A peer could answer a single-hash request twice; the consumer saw a response that did not match its request and reported a generic download failure, which retried without excluding anyone. The decoder now accepts one answer per requested hash, and a decode failure rejects the supplier that produced it.
The arm claimed it is only reached once the requeue budget is exhausted. The retry handler returns success on exhaustion so the round keeps its other downloads, so the arm is unreachable from there. The round's stall deadline is what recovers an exhausted hash.
Main's #940 landed an overlapping narrower check: `tip_child_mismatch` authenticates a claimed height only when the block's parent is the current tip. This branch's `parent_height_mismatch` authenticates against any committed parent through the new `AnyChainHeight` query, so its `ParentHeightMismatch` replaces `TipChildHeightMismatch` and keeps main's parameterised retry test covering the transaction-commitment and merkle-root kinds #940 added.
…tment branch Main's #940 added `forged_expiry_with_authentic_height_is_a_payload_mismatch`, which caught a real regression in this branch: requesting the parent's context before the body's Merkle root meant an unavailable parent reported `MissingParentContext` and masked a malleated body. That classifies a permanently invalid body retryable and leaves its supplier unscored against a header the node keeps re-requesting. Every Merkle-root outcome is decided from the header's commitment and the delivered list, so the check now runs before the pending-parent error is reported. `is_poisoned_body` from #940 keeps its shape but widens to every body-commitment kind, because the early commitment check proves an authorizing-data mismatch that an alternate body for the same header can repair. Two test mocks answer the requests the other side of the merge introduced.
Replace the per-block parent-height lookup with a stateless check. The header commits to every transaction ID through the Merkle root, a V1-V4 transaction ID commits to the coinbase input height, and a V5+ coinbase expiry height must equal the block height (ZIP 203). So a body whose Merkle root matches, and whose V5+ expiry equals its input height, carries the height its header commits to. The parent lookup cost one state read per downloaded block and could not check blocks whose parent had not arrived yet, which is most blocks during checkpoint sync. That left the lookahead drop open (V12 #297531). The syncer runs the free expiry check on every block and hashes the transactions only when the claimed height would drop or pause the block; the verifiers already check the Merkle root of every accepted block. Gossip checks every body, because it carries about one block per target spacing and a rejection there can be re-requested. Drop the AnyChainHeight state request, so zakura-state is unchanged.
…nt branch #1036 now authenticates coinbase heights without chain state. Main added NSM and ZIP 234 verifier tests whose state mocks did not answer this branch's parent context request; they now get a bound stand-in context. The checkpoint handoff router test uses a V4 coinbase, so its height-gap child still reaches state instead of failing at the parent context check.
A body at or below the lookahead reset height clears the pause flag, but heights inside the window skipped the Merkle check. A forged in-window height could therefore release backpressure the syncer still needed. Run the check for that case too; it costs one txid pass per pause cycle, because the first honest block below the reset height clears the flag. Fixes V12 #297532.
| if !self.poisoned_block_retry_counts.contains_key(&hash) | ||
| && self.poisoned_block_retry_counts.len() >= MAX_POISONED_BLOCK_RETRY_HASHES | ||
| { | ||
| return Ok(()); |
There was a problem hiding this comment.
AI comment
I reproduced this with both a full retry table and an exhausted per-block budget. With mocked services and a 200 ms stall timeout, unrelated tip extensions kept the round alive for five deadlines without recovering the required block. Restarting on either limit made the tests pass. Can we keep a bounded recovery path for the missing hash, or restart when recovery can't continue?
| let Some(block_height) = block.coinbase_height() else { | ||
| if let Some(feedback) = &supplier_feedback { feedback.reject(); } | ||
| return Err(BlockDownloadVerifyError::InvalidHeight { hash, advertiser_addr: None }); |
There was a problem hiding this comment.
AI comment
If a supplier returns the requested header without a coinbase, we exclude it here but drop the required hash through InvalidHeight. Gossip also loses both exclusion and recovery on this case. Can we route missing-height bodies through the bounded poisoned-body recovery path in both downloaders?
| if self.counts.len() >= MAX_INBOUND_CONCURRENCY && !self.counts.contains_key(&hash) { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
AI comment
In a downloader test with mocked services, the next block recovered after 199 unrelated failed downloads but got no retry after 200, despite an empty queue and an unused per-block budget. The junk arrived sequentially from one source and never reached verification. One extra table slot restored recovery. Could we keep unverified junk from using up recovery capacity for the next block?
Motivation
A V5+ body can change its coinbase height without changing its header hash, because ZIP 244 leaves the coinbase input out of the transaction ID. Legacy sync and gossip read that height before full validation. A peer can set it to
1or to a far-ahead value, and the behind-tip or lookahead policy then drops the requested hash: unscored and not requeued. A V4 body works too, because the downloader drops the block before anything checks its Merkle root.V12 finding #297531 reported this on main. Zebra fixed the behind-tip half in v6.3.0 (GHSA-g95h-hw6g-pvgv) with a parent-height check. The lookahead half is still open upstream.
Solution
The earlier revision checked the claimed height against the committed parent. That needed one state read per downloaded block, and it could not check blocks whose parent had not arrived yet: during checkpoint sync, that is most of them. This revision drops the parent lookup and the
AnyChainHeightstate request, sozakura-stateno longer changes.Testing
auth_download_height: canonical V4 and V5 heights are bound. A V5 input-height rewrite fails the free expiry check. A V5 input-and-expiry rewrite and a V4 input-height rewrite fail the Merkle check.unknown_parent_rejects_forged_heights: a forged low or high height on a block whose parent is unknown is rejected and attributed to its supplier.forged_reset_height_keeps_the_lookahead_pause: while the pause is active, a forged in-window height is rejected and leaves the pause set. Without the fix, the body reaches the verifier.gossiped_non_tip_child_rejects_poisoned_coinbase_heightcovers the same cases on the gossip path.inbound_poisoned_coinbase_height_scores_and_requeuesstill passes for an in-window V1 rewrite, because gossip checks the Merkle root of every body.zakuralibrary 454 passed,zakura-networklibrary 1514 passed (3 ignored). Strict Clippy forzakura,zakura-network, andzakura-statewith all targets,cargo fmt, and./scripts/changelog.py checkpass.Changelog
Updates the changelog fragment and the parameter-ledger entries.
Follow-up
#1038 and #1039 are stacked on this branch and still build on the parent lookup. They need a merge from this branch before review.
Provenance
Ported from the private security review, then reworked for the stateless check.