Repository navigation
fix(bpa,bpv7): ingress refuses an encrypted BIB over a per-hop block - #760
Open
ricktaylor wants to merge 9 commits into
Open
ricktaylor wants to merge 9 commits into
ricktaylor wants to merge 9 commits into
Conversation
ricktaylor
force-pushed
the
fix/per-hop-bpsec
branch
19 times, most recently
from
October 13, 2026 13:23
48f1428 to
06bc6f8
Compare
ricktaylor
force-pushed
the
fix/per-hop-bpsec
branch
7 times, most recently
from
October 15, 2026 18:14
8989328 to
c6e790d
Compare
The forward path re-derived the peer identity it already had by
destructuring the channel's target status behind a hot-path
unreachable!, through an accessor with exactly one caller. Peer now
carries its id, forward composes ForwardPending { peer, queue, next_hop }
directly (the out-of-range clamp clamps the index), and
Sender::queue_status is deleted; send_to's same_queue debug_assert
still covers the queue-identity contract. A future mis-keyed queue is
no longer a forwarding panic (review F-13; the fuller QueueKey redesign
rides the queue tranche).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Origination is not a hop: the per-hop rewrite leaves the Hop Count alone on a bundle this node originated, so it leaves with the count it was built with, and the next node makes the first increment. Only a bundle that arrived from a peer has its count increased here. A pipeline test originates a bundle carrying a Hop Count through the raw door and asserts the forwarded count is the built one. The Rewriter and filter design docs, the component test plan row and the CHANGELOG say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ExtensionEditor becomes a scoped handle on an owner's Editor (ExtensionEditor::new(&mut editor)). Its edits go into the owner's editor, which the owner rebuilds once the handle is gone, so several handles can edit one editor in turn, between the owner's own edits. The gates judge a target's coverage by the editor's current record, which an owner edit before the handle may have changed. finish() goes: the owner rebuilds. The handle edits through its borrow (Editor::edit_with), so no owner privilege is reachable from it. Editor::staged_view() snapshots the bundle as the staged edits leave it. StagedView lends the current primary and block headers with their current coverage, reads the current block bodies through its Reader impl, and gives a DecryptingReader over them, which decrypts a covered block under the BCB operation sets as staged: a staged one included, such as a BIB that a removal re-encrypted. The copy is header-sized. RewriteContext takes a second lifetime for the borrowed handle. The engine still builds an editor per Rewriter and rebuilds it when the handle edited. Tests: the ExtensionEditor tests port to the handle; the gates read the owner's current coverage (a removed BIB's kept target); refusals keep the owner's edits; the staged view reads the edits staged when it was taken and not later ones, decrypts a kept covered block, and decrypts a staged re-encrypted BIB. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The per-hop writes and the scheduled §5.1.1 removals at the output doors edit over the record's block index, the parse ingress verified, instead of re-parsing the stored bytes without keys. Every stored bundle was validated at a door or built by the BPA, so the index is the parse, and its coverage stamps are the ones ingress derived: keyed where this node holds the keys. A key-holding forwarder therefore updates a Previous Node, Hop Count or Bundle Age block that a BIB it decrypted at ingress provably does not cover. It strips the block from its BCB and writes it in plaintext, as the acceptor of the operations on a block it must update, where the keyless re-parse used to read the block as possibly covered and refuse the write. A block an encrypted BIB may or does cover is still refused. The update_extension_blocks comment now says when the editor refuses. The key-holding relay test flips: the decrypted BIB covers only the payload, so the relay increments the encrypted Hop Count and writes it in plaintext, and the payload's protection leaves untouched. The test that pinned the per-hop re-parse's decode failure goes with the re-parse. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
Each output attempt edits through one editor, over the record's block index and the loaded bytes, and rebuilds the bundle once. At Egress the scheduled §5.1.1 removals, the Rewriters and the per-hop writes all edit it; at Delivery the removals and the Rewriters do, and the door rebuilds only if either edited, then runs the Verifiers over the result. The keys are derived once per attempt, over the stored bundle: no output stage adds a BCB. The Rewriters read their predecessors' edits from a snapshot of the editor's staged edits (StagedView), taken again only after a link that edits, so non-editing links share a snapshot and its decrypt memo. A link never sees its own edits. RewriteContext::bundle() lends the snapshot's bundle; a staged block's extents index nothing, and its body reads through the reader. Nothing is materialised per link, so the per-link rebuild-and-re-parse check goes: every edit is checked when it is staged, and a failed rebuild, now a BPA or bpv7 bug, is fatal at the door, which cannot name a link. The output chains decode only the BCB operation sets the index locates. ChainOutcome::Continue drops its bytes: only the input chains use it, and they do not edit. Tests: a Rewriter reads its predecessor's insert through the snapshot and not its own; egress edits rebuild with a consistent block map; output keys skip the provider without a BCB and consult it once with one; a BCB body that does not decode is fatal at Egress and at the Deliver Verifiers. The key re-derivation test retires: no stage adds a BCB. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The forward door loaded the stored bundle as one buffer, edited it on the attempt's editor, and flattened the rebuild into a single Final for the CLA. Store::load_stream is the streaming read seam, the twin of save_stream, at its target signature. Its interim body yields load_data's buffer as one Final; the storage tranche swaps in the backends' streamed load with no caller change. forward_bundle opens the stream, with load_data_or_drop's DepletedStorage drop, and holds it only until the bytes held reach the payload's data, read from the record's block index: every header the output stages edit or read precedes it. The new dispatcher::output module carries both halves: - pull_headers holds the first segment as-is when it covers the header region, and concatenates only a region split across segments. A stream that ends short of the headers is storage corruption, and fatal. - ChunkReceiver hands the rebuild to Cla::forward as segments: the outer array's head, then each chunk (an edited block's new bytes, a kept block's resident bytes zero-copy). After a kept payload come the rest of the resident bytes and the rest of the stored stream, which hold the outer break. total_len is the rebuilt index's encoded_len(), which debug builds check the yielded bytes against. Nothing on the egress path flattens the bundle. Unit tests cut the load inside the header region, at the payload's data and inside the payload, and check the segments against the flattened rebuild of the whole bundle. They also cover: - kept chunks sharing the stored allocation; - a payload past the resident end reading NotResident through the editor's staged view; - an empty payload; - a rebuilt last chunk; - both fatal short loads. The streaming CLA test now receives the rebuild as segments, with this node's Previous Node and the payload intact. Docs: - The filter docs say an Egress Rewriter reads the payload only while it is resident. - The streaming design records the landed egress, the interim whole-buffer load and what remains of Phase B. - The block-count TODO entry and the CHANGELOG are updated to match. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
A forward that the CLA answered StreamCancelled parked in Waiting with every other synchronous error, so a transfer cancelled by an interruption waited for a routing or link event that might never come. A cancelled transfer is a failed acceptance: the bundle was not forwarded, and is valid to retry. offer_to_cla now answers it with OfferOutcome::Redispatch, as a deferred Failed outcome is answered. Every other synchronous error still parks the bundle alone in Waiting, since a failure that may be deterministic is never retried inline. resolve_offer's Redispatch arm owns the whole re-entry: - It re-claims the bundle with a conditional swap from the in-hand status, so a concurrent resolver (the reaper, a peer sweep, a duplicate outcome) still wins. - It counts bpa.bundle.forwarding.failed once per won claim. - It reloads the record before dispatching, because the cancelled attempt's in-hand copy carries its in-memory rewrites. transfer_outcome's Failed arm drops its own swap and counter for it. A CLA that cancels every attempt loops until the bundle expires; the offer_to_cla comment, the cla::Error::StreamCancelled and Cla::forward docs, design.md and the CHANGELOG say so. Tests: - A CLA that cancels its first attempt is offered the bundle again with no routing event, using rib.poll_waiting_idle() as the barrier, and the retry sends the bundle intact, parsed from the bytes the CLA pulled. - A cancelled transfer's re-claim loses to a terminal resolution, the twin of the failed-transfer case. - The park-intent tests' CLAs fail with an internal error, so they go on testing the Waiting park: failed_forward_does_not_retry_inline, forward_failure_park_recheck_redispatches, forward_failure_never_resurrects_resolved_bundle and failed_streamed_forward_is_requeued_and_retried. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
Delivering to an Application or to the administrative endpoint, this node is the payload's security acceptor. A payload that fails to decrypt is dropped with FailedSecurityOperation (RFC 9172 §5.1.1), or UnknownSecurityOperation when its BCB names a security context the node does not recognise (§7.1); both were BlockUnintelligible. Without a key the bundle still parks: until the next registration at an Application, in Waiting at the administrative endpoint. Both paths take the payload through one synchronous Dispatcher::payload_bytes, so the parse products can no longer be held across an await. The stored bytes were validated at ingress and the whole bundle is in hand, so a re-parse failure or an absent payload is a BPA bug or storage corruption, and panics. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
An integrity operation on a Previous Node or Bundle Age block inside an encrypted BIB is a combination no valid bundle carries: only the forwarder that inserted a per-hop block can source an operation on it, RFC 9172 §3.9 forbids it both signing and encrypting the block, and an earlier node's signature had to go when the block changed. Ingress accepted such a bundle, and the forwarder, unable to update the block, parked it at every egress attempt until it expired. bpv7's new checks::reject_per_hop_integrity_in_encrypted_bib refuses the bundle where the combination is provable: a keyed pass that decrypted the BIB stamps the block's coverage with it, or, without the key, every BCB-covered block other than a BIB is a Previous Node or Bundle Age block, so an encrypted BIB must cover one (RFC 9172 §3.7, §3.8). The Hop Count is exempt, its increment being a SHOULD. The ingress header pass calls it after the keyed verification and maps the new bpsec::Error::EncryptedBibCoversPerHopBlock to "Conflicting security operation" (RFC 9172 §7.1), so the bundle is deleted with that report; anything short of proof passes as before. The reason mapping's rustdoc no longer claims the structural parser catches every conflict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
ricktaylor
force-pushed
the
fix/per-hop-bpsec
branch
from
October 19, 2026 05:53
c6e790d to
63f098c
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(bpa,bpv7): ingress refuses an encrypted BIB over a per-hop block
A Previous Node or Bundle Age block whose integrity operation sits inside an encrypted BIB is a combination no valid bundle carries:
Ingress accepted such a bundle. The forwarder then could not update the block, so it parked the bundle at every egress attempt until the bundle expired. This PR refuses the bundle at the door instead.
The check
hardy_bpv7::checks::reject_per_hop_integrity_in_encrypted_bibreturns the newbpsec::Error::EncryptedBibCoversPerHopBlock(n), naming the lowest such block, when the combination is provable:Anything short of proof passes as before. The Hop Count is exempt, because its increment is a SHOULD (RFC 9171 §4.4.3), so a Hop Count that can't be updated travels unchanged. Block types are compared by encoding, so an alias of either type counts.
At ingress
verify_headerscalls the check straight afterchecks::verify, on the coverage the keyed pass left.status_report_reason_formaps the new error to reason code 16, "Conflicting security operation" (RFC 9172 §7.1), so the bundle is deleted with that report. Until now that code was unreachable. The function's rustdoc no longer claims that the structural parser catches every conflict.Docs
bpa/docs/refactor_plan.md: the per-hop section records what is built, and that anything short of proof is the remaining work. The "Reason codes for per-hop failures" entry is removed, because this PR provides the path to code 16. Both CHANGELOGs have an entry; bpv7's is BREAKING, for the new error variant.Tests
bpv7/tests/checks.rs:the_structure_proves_an_encrypted_bib_over_a_per_hop_block(Previous Node and Bundle Age, no key);a_keyed_pass_proves_an_encrypted_bib_over_a_per_hop_block(the payload is also encrypted, so the structure alone proves nothing);a_hop_count_under_an_encrypted_bib_passes.bpa/tests/forward.rs, through a relay with status reports on, asserting the reason code, the reported bundle id and the deletion, and that nothing was forwarded:keyless_relay_refuses_an_encrypted_bib_over_a_per_hop_block(Bundle Age);key_holding_relay_refuses_an_encrypted_bib_over_a_per_hop_block(Previous Node with an encrypted payload, so only the keyed pass proves it).bpa/src/bundle/parse.rscovers the new arm.Not in this PR
🤖 Generated with Claude Code