Skip to content

fix(consensus): authenticate authorizing data before proof verification - #1038

Open
evan-forbes wants to merge 11 commits into
security/validated-parent-heightfrom
security/early-body-commitment
Open

evan-forbes wants to merge 11 commits into
security/validated-parent-heightfrom
security/early-body-commitment

Conversation

@evan-forbes

@evan-forbes evan-forbes commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

A V5+ body can retain its header hash and transaction IDs while changing authorizing data. Dispatching proofs before checking the header commitment wastes verification work. Requiring parent context also needs recovery when an honest child arrives before its parent commits.

Solution

Based on #1036 for shared height admission and supplier feedback. Review only the commits above that base.

  • Query immutable context for the actual committed parent. Use retained non-finalized branches or the exact finalized tip. Keep unavailable context retryable and unscored.
  • Check child height and the authorizing-data commitment before transaction dispatch. Reuse contextual commitment validation and retain the commit-time check.
  • Classify proven mismatches as replaceable payload errors. Exclude their suppliers before bounded retries without invalidating the header hash or restarting unrelated downloads.
  • Retain only hashes for legacy sync's bounded missing-context retries.
  • Retain gossiped bodies for bounded missing-context retries. Release the source verification gate between attempts. Limit retention to half the configured gossip slots so missing-parent bodies cannot consume the whole queue. Retry once per second within the existing eight-minute verification window.

V4-only bodies keep their existing path. Prepared-candidate caching retains its exact-body checks.

Testing

The implementation passes 100 sync tests, 16 inbound downloader tests, and 33 semantic block tests. New gossip regressions cover reuse of the same body, source-gate release, successful retry, expiry, and retention-capacity exhaustion without peer scoring.

The semantic regression sends a poisoned body and then its honest counterpart through the same verifier. It checks zero transaction dispatch for the poisoned body and transaction dispatch for the honest body. A complete honest-body state commit remains an integration gate.

Strict clippy passed for the state, consensus, network, and node libraries and tests. Formatting and changelog checks passed.

Changelog

Adds a changelog fragment and parameter-ledger entries.

Release gates

Keep this PR in draft. Validate full alternate-body recovery, competing branches, parent finalization, and checkpoint/native handoff with actual state commits. Measure throughput, retained-body memory, repeated context checks, and sync re-download bandwidth under out-of-order traffic.

Provenance

Ported unchanged from the private security review. It no longer needs to stay private.

CI coverage

Workflows filter branches: [main, "feat/**", "release/**"], so a PR based on security/early-body-commitment runs only the docs and security jobs. Retarget this PR to main once #1036 merges to get the full suite. Verified locally on Rust 1.98.0 against this branch: strict Clippy over the workspace, cargo fmt --check, changelog validation, and 2,300 library tests across zakura, zakura-network, and zakura-state.

…' into security/early-body-commitment

# Conflicts:
#	docs/changelog/params.md
@evan-forbes
evan-forbes marked this pull request as ready for review September 17, 2026 22:27
@v12-auditor

v12-auditor Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found 10 issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-281506 🟡 Medium
Retained children block their own parents

A legacy peer can advertise a valid child before its parent, causing the child to enter the missing-context retention loop while consuming that IP address's sole admission slot. The loop drops the per-source mutex between verifier attempts, but the retained task remains counted in source_counts until it completes. When the same peer later advertises the parent, admission observes source_count == source_limit == 1 and returns FullQueue before the parent can acquire the released mutex. Inbound discards that admission result and acknowledges the advertisement, so the retained child continues to fail until another source, the syncer, or a later post-timeout advertisement supplies the parent. Authenticated Zakura advertisements do not provide a fallback through this downloader because that path is explicitly ignored.

F-281507 🟡 Medium
Parent retention reserves no parent capacity

The new semaphore caps the number of already-retained missing-parent children, but it does not reserve any global admission slots for downloads that could supply their parents. Retained children continue to count toward pending, while unrelated downloads may occupy every remaining global slot. Once the global queue is full, an honest parent advertisement is rejected as FullQueue without regard to whether it unlocks retained work. Releasing source mutexes cannot help because the parent is rejected before task creation. A distributed sender can therefore fill both halves of the queue and prevent the retained children from making progress for the full context window.

F-281508 🟡 Medium
Expired gossip retries start another verification

The gossip retention loop evaluates context_deadline only after a verifier request has completed. If a missing-context response arrives shortly before the deadline, the task sleeps until the deadline and then returns to the top of the loop, where it starts another verifier request unconditionally. Each verifier request is independently wrapped with the full BLOCK_VERIFY_TIMEOUT, so this post-deadline attempt can run for another eight minutes under verifier saturation. The retained body, global queue slot, source count, and semaphore permit remain held during that extra attempt. The stated eight-minute retention window is therefore not a hard bound.

F-281509 🔵 Low
Minimum concurrency disables parent retention

Downloads::new accepts the supported minimum full-verification concurrency of one and constructs the missing-parent semaphore using integer division by two. At that valid setting, the semaphore has zero permits. Every first MissingParentContext result consequently fails try_acquire_owned and immediately abandons retention. The newly added out-of-order gossip recovery is therefore completely disabled for operators using the minimum supported concurrency. Valid children arriving before their parents must be rediscovered later through another advertisement or sync.

F-281510 🟠 High
Expired context entries discard required hashes

Missing-parent timestamps are removed only when the corresponding block eventually succeeds. Once a timestamp reaches BLOCK_VERIFY_TIMEOUT, the handler returns success without scheduling another retry and without deleting the expired entry. After 4,096 such entries accumulate, every new missing-context hash also returns success before it is recorded or scheduled. The round-exhaustion predicate ignores parent_context_wait_started, so these unscheduled required hashes can disappear from the current round. The next sync attempt clears the map, granting the same adversarially ordered hashes fresh eight-minute windows and allowing the cycle to repeat.

F-281511 🟠 High
Context retries bypass sync lookahead

Parent-context retries are stored in registry_miss_retry, but they are explicitly excluded from the head-of-line predicate that normally drains speculative work. When the earliest retry deadline fires, the timer arm gathers every due hash and invokes download_and_verify for all of them without checking the configured lookahead, past_lookahead, or the current in-flight task count. Up to 4,096 missing-context hashes can be re-armed repeatedly during their retry windows while normal reserve dispatch continues. Network concurrency limits only the network service stage; queued tasks can remain pending in verification after network permits are released. The timer also records the dispatch wave itself as progress, delaying the stall detector.

F-281512 🟠 High
Child-only tips trap parent discovery

A one-hash FindBlocks response is accepted as a required download but does not create a prospective tip that can extend discovery. If that block is a valid child whose parent is absent, the verifier returns MissingParentContext(parent), but the retry handler discards the parent hash and repeatedly schedules the child hash instead. While the child retry deadline exists, the exhausted-tip refresh path is disabled because it requires registry_miss_retry to be empty. The node therefore re-downloads the same child for up to eight minutes without requesting the missing dependency. When the round completes, the next sync attempt clears the timestamp and grants the same child another full retry window.

F-281513 🟠 High
Terminal retries defeat the stall watchdog

Terminal retry states are acknowledged with Ok(()) without scheduling the affected hash or causing the round to fail. This occurs when the missing-context table is full or its per-hash timeout has expired, and when the poisoned-body table or retry budget is exhausted. The downloader has already removed the hash from its only in-flight dedupe map before the handler runs, so a later peer response can dispatch the same unresolved hash again. Every such handled error is unconditionally credited as progress, as is every extension response. Alternating admissible hash-list extensions can therefore keep rediscovering unresolved hashes and continually reset the round watchdog without any block commitment.

F-281514 🟡 Medium
Immutable headers trigger impossible body retries

The new downloader recovery code assumes every BodyVerificationClass::PayloadMismatch can be repaired by fetching a different body for the same header hash. The state classifier, however, places every InvalidBlockCommitment in that class, including failures derived solely from immutable header commitment bytes, such as an invalid Sapling root encoding or invalid Heartwood activation reserved value. Inbound and sync consequently reject and max-score suppliers, then spend bounded poisoned-body retries on a hash that no alternate body can repair. Sync preserves the selected hash after retry exhaustion rather than immediately obtaining different tips. The classification therefore conflates body malleability with immutable invalid-header evidence.

F-281515 🔵 Low
Sync backoff exceeds its retry deadline

The missing-parent branch checks whether elapsed time is still below BLOCK_VERIFY_TIMEOUT and then schedules now + delay, where the backoff grows to thirty seconds. A failure received immediately before the eight-minute boundary therefore creates a retry deadline after the boundary. When that timer fires, the syncer starts a fresh network download and verifier attempt without constraining them to the original deadline. Dispatching the late attempt is also credited as progress. The documented missing-context duration is consequently not a hard bound.

Analyzed 10 files, diff 3ed1502...934d547.

The audit of #1038 reported five ways the missing-parent retry path outlives or
escapes its own bounds.

An expired wait can never schedule another retry, but it stayed in the table.
Once 4,096 of them accumulated, every later missing-context hash returned early
before it was recorded or scheduled, and the round-exhaustion predicate ignores
those waits, so the required hash disappeared from the round. Expired waits are
now pruned on each miss.

A failure arriving just before the eight-minute boundary scheduled its retry
after it, and the retry dispatch does not constrain the new attempt to the
original window. The backoff is now clamped to the retention deadline.

A retry wave dispatched every due hash at once, ignoring the configured
lookahead. It is now bounded by the remaining lookahead, and the hashes it
defers are re-armed rather than left with a past deadline.

The exhausted-tip refresh was gated on no retries being pending at all. A
registry miss means no peer has the block, so the round must let it drain, but a
missing parent is found by extending tips, so a child-only tip trapped
discovery for the whole window. The gate now distinguishes the two.

The gossip retention loop checked its deadline only after a verifier request
returned, so an attempt begun at the deadline held the body, the queue slot and
the permit for another full verification timeout. It now re-checks first. The
retention semaphore also took the minimum supported full-verification
concurrency of one to zero permits, which disabled retention entirely.
evan-forbes added a commit that referenced this pull request Sep 18, 2026
…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.
evan-forbes added a commit that referenced this pull request Sep 18, 2026
…attribution branch

`is_context_independent_body_error` is gone. #1038 now propagates every
merkle-root attribution outcome through the pending-parent path, because all of
them are decided from the header's commitment and the delivered transaction
list, so a predicate that selected a subset can only lose evidence. Its test is
retargeted at that broader rule and covers a plain malleated body too.

`merkle_root_validity` keeps both sides' comments, and the misbehavior score list
keeps both sides' variants.
…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.
evan-forbes added a commit that referenced this pull request Oct 3, 2026
…tion branch

Main's newer floor-preference tests call floor_has_preferred_unsaturated_server
without this branch's body-retry filter; they now pass one that admits
every peer. Manifests take main's published versions.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant