Skip to content

fix(bpa,bpv7): ingress refuses an encrypted BIB over a per-hop block - #760

Open
ricktaylor wants to merge 9 commits into
mainfrom
fix/per-hop-bpsec
Open

ricktaylor wants to merge 9 commits into
mainfrom
fix/per-hop-bpsec

Conversation

@ricktaylor

Copy link
Copy Markdown
Owner

fix(bpa,bpv7): ingress refuses an encrypted BIB over a per-hop block

Stacked PR. Base: docs/design-refresh (#752). One commit, in bpv7's checks and the bpa ingress header pass.

A Previous Node or Bundle Age block whose integrity operation sits inside an encrypted BIB is a combination no valid bundle carries:

  • only the forwarder that inserted a per-hop block can be the security source of an operation on it;
  • RFC 9172 §3.9 forbids that node both signing and encrypting the block;
  • an earlier node's signature has to go when the block changes, which it does at every hop.

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_bib returns the new bpsec::Error::EncryptedBibCoversPerHopBlock(n), naming the lowest such block, when the combination is provable:

  • Keyed: the keyed pass decrypted the BIB and stamped the block's coverage with it.
  • Structural, no key: the bundle has an encrypted BIB, and every BCB-covered block other than a BIB is a Previous Node or Bundle Age block. An encrypted BIB must share a target with its BCB (RFC 9172 §3.7, §3.8), so it must cover one of them.

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_headers calls the check straight after checks::verify, on the coverage the keyed pass left. status_report_reason_for maps 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).
  • The reason-mapping unit test in bpa/src/bundle/parse.rs covers the new arm.

Not in this PR

  • Per-hop combinations that can't be proven at the door still park at egress.
  • A Previous Node or Bundle Age block this node must accept, but holds no key for, still parks. Whether that needs a holding pattern that wakes up when keys change is an open design question.

🤖 Generated with Claude Code

@ricktaylor
ricktaylor force-pushed the fix/per-hop-bpsec branch 19 times, most recently from 48f1428 to 06bc6f8 Compare October 13, 2026 13:23
@ricktaylor
ricktaylor force-pushed the fix/per-hop-bpsec branch 7 times, most recently from 8989328 to c6e790d Compare October 15, 2026 18:14
ricktaylor and others added 4 commits October 19, 2026 06:53
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>
ricktaylor and others added 5 commits October 19, 2026 06:53
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>

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant