Close the PQ bind's consume-then-fail window - #117
Merged
Conversation
A bind's PQ commit carries the -02 AppDataUpdate attestation, and apq::rules rejects any Update co-riding it — so one cached proposal in our send-PQ makes commit_pq_and_owe_bind fail with the round's one-shot input already spent, and mutate_and_persist writes that tear to the blob. mls-rs caches a by-ref proposal inside process_incoming_message, before the A.4 leg door inspects the message kind, so the peer can park one without forging anything. Guard the three bind entry points before they consume, and drop what a refusal admitted at the two doors that admit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A.3's and A.5's S is re-exported from our own send-PQ, and that export is fallible. Taking the round state first left a failed export with the slot already empty: the peer re-staples the same bind on every frame, every retry answered the retriable SessionNotReady, and nothing latched it — the failure never reaches apply_bind, so pq_receive_broken() reported a healthy session that could not receive. Classify first, export second, consume third. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A.3's join, A.4's decapsulation and A.5's applied Commit' each land before the bind's own commit, and mutate_and_persist writes that partial state even when the commit then fails. The session was left answering SessionNotReady forever — or, at A.3, DuplicateSideBand, reporting a round that never closed as done while is_fully_established() returned true and the peer waited for a staple nobody would build. Wrap each trigger's tail in past_no_return, which renames any escape to the fatal BindTriggerFailed and latches pq_wedged. The latch rides the archive, unlike bind_apply_broken: that one may heal on restore only because the receive path persists on success only, which is exactly what these closures do not do. The header-key capture stays outside the region — it is the repeatable exporter, re-derived by should_listen_on, so latching a round that succeeded would over-fire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regenerated binding (the new case appends at ordinal 31, so every prior ordinal is stable), the SessionError code and its .reestablish disposition, the bridge mapping, isSideBandWedged, and binding contract 29 -> 30. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 0bbcb6e The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Merged
This was referenced Jul 27, 2026
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.
Stacked on #115 — review that first; this branch targets its head.
A bind's PQ half carries the -02
AppDataUpdateattestation, andapq::rulesrejects any Update co-riding it. So one by-ref proposal cached in our send-PQ makes every later bind commit fail — after the round's one-shot input is spent (A.4's ephemeral opened, A.3's KP consumed by the join, A.5'sCommit'applied).That was reachable by the counterparty with no forgery: mls-rs caches a by-ref proposal inside
process_incoming_message, before the A.4 leg door inspects the message kind, so a proposal routed through a door that answers a benign retriable error still lands in the cache and stays there. The next bind then tore itself apart — ephemeral gone, parked EK no longer re-mintable, every retry answering the apparently-retriableSessionNotReadyforever,is_fully_established()still true. And because the bind's persist captures partial mutations by design, the tear reached the archive, so restoring reproduced it.Guard before consuming. The three bind entry points now check for that residue in their guard phase, where refusing is free, and the two admitting doors drop what they refuse.
Latch what no guard can catch. Past the point of no return each trigger's tail runs inside a region that renames any escape to the new fatal
BindTriggerFailed. That latch rides the archive, unlikeBindApplyFailed— the apply latch may heal on restore only because inbound processing persists on success alone, which is exactly what these closures do not do. Queryable viapq_side_band_wedged(); classical messaging is unaffected throughout.Two smaller corrections ride along: the A.3/A.5 responder derives its bind secret before consuming the round, and the post-commit header-key capture is best-effort with a re-derivation backstop in
should_listen_on(it is the repeatable exporter — latching a round that succeeded would over-fire).Binding contract 29 → 30. Archive stays v3, gaining one tail field.
Tests
test_a_peer_proposal_smuggled_into_the_send_pq_leaves_no_cached_residueandtest_bind_refuses_a_poisoned_send_pq_before_the_ephemeral_is_spentboth fail without the fix and pass with it — verified by stashingpq_ops.rsand re-running. Plus the wedge's fatal-and-survives-restore case, A.3's answer-honestly-not-DuplicateSideBandcase, and a negative control over clean A.3/A.4/A.5 rounds.test_failed_bind_apply_breaks_receive_not_send_and_heals_on_restoreis byte-for-byte unchanged — it guards the heal-on-restore property the new region must not regress.374 Rust tests pass;
cargo fmtand both clippy gates clean. Swift compiles (library and tests); the Swift test runtime could not be exercised locally — the test bundle fails to dlopen the framework by rpath under this Xcode 27 beta, and SIP stripsDYLD_*— so CI'sswiftjob is the real check there.taplois not installed locally, but no.tomlchanged.🤖 Generated with Claude Code