From 55fad29b0d86688722dfcd22b4061309d11e5d4f Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 12:15:11 -0700 Subject: [PATCH 1/6] Specify signing a joined group with its KeyPackage key The deployed A.3 join signs with the current key, which a later responder commit can orphan; a conforming peer defers the reciprocal A.5 until the peer's own A.5 has landed. Co-Authored-By: Claude Opus 5.5 --- .changeset/book-join-key-rule.md | 4 +++ book/src/group-rules.md | 6 +++- book/src/protocol-flows.md | 2 +- book/src/session-lifecycle.md | 53 +++++++++++++++++++------------- 4 files changed, 42 insertions(+), 23 deletions(-) create mode 100644 .changeset/book-join-key-rule.md diff --git a/.changeset/book-join-key-rule.md b/.changeset/book-join-key-rule.md new file mode 100644 index 0000000..d206e9a --- /dev/null +++ b/.changeset/book-join-key-rule.md @@ -0,0 +1,4 @@ +--- +--- + +Book-only: specify signing a joined group with its KeyPackage key, and note the deployed join anomaly; nothing ships. diff --git a/book/src/group-rules.md b/book/src/group-rules.md index a44546c..6648700 100644 --- a/book/src/group-rules.md +++ b/book/src/group-rules.md @@ -151,7 +151,11 @@ sequence is driven by the classical ratchet itself: fast-forward to an already-canonical credential; candidates are proposed and canonicalized exclusively in the classical ratchet. A credential that a live PQ leaf still presents stays admissible past window eviction until that leaf catches up; the - A.3 founding pins are one instance of this rule. + A.3 founding pins are one instance of this rule. Until a leaf moves, its owner signs + in that group with the key the leaf presents. A group joined from a KeyPackage (the + A.3 KP′) is signed with that KeyPackage's key, even if the owner has rotated since it + was minted. Moving one group's leaf never retires a key that another group's leaf + still presents. Enforcement is the mls-rs `IdentityProvider` (`apq/src/authentication.rs`): `valid_successor` implements same-id / authorized-step / catch-up; `validate_member` diff --git a/book/src/protocol-flows.md b/book/src/protocol-flows.md index 7ca1274..5724ad6 100644 --- a/book/src/protocol-flows.md +++ b/book/src/protocol-flows.md @@ -139,7 +139,7 @@ Independently, we have an exchange of large PQ key messages, carried as dedicate 2. Alice and Bob take turns initiating PQ operations. Alice is first, and makes a variation of PQ re-keying to bootstrap Bob’s group: 1. (In place of a proposal) Alice sends a PQ keyPackage to Bob 2. (In place of a commit) Bob constructs the PQ half of his send group from it and replies with a Welcome (for that group) - 3. Alice joins via the Welcome and closes the round with a bind, exactly as the PQ ratchet's — the only difference is where S comes from (a group exporter off the joined group's birth epoch rather than a KEM exchange). The bind rides her next classical commit as the staple; Bob takes the turn on applying it + 3. Alice joins via the Welcome — signing in that group with KP′'s key, which her leaf there presents, until her own A.5 moves it — and closes the round with a bind, exactly as the PQ ratchet's — the only difference is where S comes from (a group exporter off the joined group's birth epoch rather than a KEM exchange). The bind rides her next classical commit as the staple; Bob takes the turn on applying it (Bob’s dedicated principal is selected at session establishment, not here. Alice started with a principal she generated to talk to Bob’s invitation principal; Bob accepts under a principal dedicated to Alice — his send group is created directly under it, and Alice adopts it when she joins his group. The PQ bootstrap and re-key only carry already-canonical credentials onto the PQ leaves.) diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index ac3b742..9d5f495 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -212,27 +212,38 @@ against the AS history window, and a credential that a live PQ leaf still presen admissible past window eviction until that leaf catches up (see [Group Rules](./group-rules.md), rule 4). -> **Shipped anomaly (deployed Rust engine).** The deployed engine opens an A.5 when its own -> *send*-PQ leaf lags, not when a leaf in its receive group lags, and it never opens the -> reciprocal A.5 for the peer. Its trigger reads its send-PQ leaf, but its own round can -> only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided -> rotation, the rotated party's A.5 moves -> only its leaf in the peer's send group. The peer never opens the reciprocal round, so -> the rotated party's own send-PQ leaf keeps its pre-rotation credential. Because that -> leaf still lags, the rotated party opens another A.5 on every PQ turn it holds; after -> the first, each is a same-id key refresh. A conforming peer heals it: the deployed -> engine's responder `Commit'` does carry its current credential, so the reciprocal A.5 -> completes the catch-up. Against a deployed peer, a conforming rotated party's own -> send-PQ leaf stays behind, because that peer never opens the reciprocal round, and that -> party must keep signing that group under the credential its leaf presents. For the same -> reason, a deployed born-dedicated acceptor never catches up its leaf in the initiator's -> send-PQ group: its own send-PQ leaf was minted at A.3 under the dedicated id, so it -> never lags, and its trigger never fires. The deployed AS also pins only the A.3 founding -> ids, so a leaf left behind for more than the history window can no longer catch up at -> all. Separately, a born-dedicated acceptor's catch-up Upds -> propose the identity the peer already treats as canonical (`proposing == sender`). A -> host that folds only offers where `proposing` differs from `sender` never commits one, -> so that recv-group leaf keeps presenting the invitation identity. +> **Shipped anomaly (deployed Rust engine).** +> +> - **Wrong trigger.** It opens an A.5 when its own *send*-PQ leaf lags, not when a leaf +> in its receive group lags, and it never opens the reciprocal A.5. Its own round can +> only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided +> rotation, the rotated party therefore opens another A.5 on every PQ turn it holds +> (after the first, each is a same-id key refresh), and its own send-PQ leaf keeps its +> pre-rotation credential. +> - **A conforming peer heals it.** The deployed responder `Commit'` does carry its +> current credential, so a conforming peer's reciprocal A.5 completes the catch-up. In +> the other direction, a conforming rotated party's own send-PQ leaf stays behind +> against a deployed peer, and that party must keep signing the group under the +> credential its leaf presents. +> - **Born-dedicated acceptor.** A deployed born-dedicated acceptor never catches up its +> leaf in the initiator's send-PQ group: its own send-PQ leaf was minted at A.3 under +> the dedicated id, so its trigger never fires. Separately, its catch-up Upds propose +> the identity the peer already treats as canonical (`proposing == sender`). A host +> that folds only offers where `proposing` differs from `sender` never commits one, so +> its recv-group leaf keeps presenting the invitation identity. +> - **History window.** The deployed AS pins only the A.3 founding ids, so a leaf left +> behind for longer than the history window can no longer catch up. +> - **Unchecked join.** Its A.3 bind joins the peer's send-PQ group signing with its +> *current* PQ key, not the KP′ key its leaf there presents. After a rotation before +> the bind, its A.5 `Upd'` in that group is mis-signed and always rejected, and the +> presented key survives only as its own send-PQ group's signer. When it later answers +> a peer's A.5, including a reciprocal one, its responder `Commit'` replaces that +> signer, and the leaf is orphaned for good: no copy of its key remains. Moving the +> party onto a conforming engine before then heals it, because that engine drops the +> mis-signed `Upd'` and re-proposes under the carried key. So a conforming peer defers +> a reciprocal A.5 until the peer's own A.5 has succeeded, meaning the peer's leaf in +> our send-PQ group is current. Against a conforming peer this costs at most one extra +> round. For the common "dedicated agent per session" pattern, don't rotate at establishment at all: pass the agent's id to `receive(…, new_client_id:)` and the session is born From e892144df8b47ee5d3cc7d56e711047ef06adb87 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 12:25:30 -0700 Subject: [PATCH 2/6] List each shipped anomaly with how it resolves Co-Authored-By: Claude Opus 5.5 --- book/src/session-lifecycle.md | 74 ++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 9d5f495..0496aa7 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -212,38 +212,50 @@ against the AS history window, and a credential that a live PQ leaf still presen admissible past window eviction until that leaf catches up (see [Group Rules](./group-rules.md), rule 4). -> **Shipped anomaly (deployed Rust engine).** +> **Shipped anomaly (deployed Rust engine).** Five deviations. Each notes how it resolves: +> healed by a conforming peer following the spec, healed only once the deployed party runs +> a conforming engine, or needing an accommodation beyond the spec. > -> - **Wrong trigger.** It opens an A.5 when its own *send*-PQ leaf lags, not when a leaf -> in its receive group lags, and it never opens the reciprocal A.5. Its own round can -> only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided -> rotation, the rotated party therefore opens another A.5 on every PQ turn it holds -> (after the first, each is a same-id key refresh), and its own send-PQ leaf keeps its -> pre-rotation credential. -> - **A conforming peer heals it.** The deployed responder `Commit'` does carry its -> current credential, so a conforming peer's reciprocal A.5 completes the catch-up. In -> the other direction, a conforming rotated party's own send-PQ leaf stays behind -> against a deployed peer, and that party must keep signing the group under the -> credential its leaf presents. -> - **Born-dedicated acceptor.** A deployed born-dedicated acceptor never catches up its -> leaf in the initiator's send-PQ group: its own send-PQ leaf was minted at A.3 under -> the dedicated id, so its trigger never fires. Separately, its catch-up Upds propose -> the identity the peer already treats as canonical (`proposing == sender`). A host -> that folds only offers where `proposing` differs from `sender` never commits one, so -> its recv-group leaf keeps presenting the invitation identity. -> - **History window.** The deployed AS pins only the A.3 founding ids, so a leaf left -> behind for longer than the history window can no longer catch up. -> - **Unchecked join.** Its A.3 bind joins the peer's send-PQ group signing with its -> *current* PQ key, not the KP′ key its leaf there presents. After a rotation before -> the bind, its A.5 `Upd'` in that group is mis-signed and always rejected, and the -> presented key survives only as its own send-PQ group's signer. When it later answers -> a peer's A.5, including a reciprocal one, its responder `Commit'` replaces that -> signer, and the leaf is orphaned for good: no copy of its key remains. Moving the -> party onto a conforming engine before then heals it, because that engine drops the -> mis-signed `Upd'` and re-proposes under the carried key. So a conforming peer defers -> a reciprocal A.5 until the peer's own A.5 has succeeded, meaning the peer's leaf in -> our send-PQ group is current. Against a conforming peer this costs at most one extra -> round. +> 1. **Wrong trigger.** It opens an A.5 when its own *send*-PQ leaf lags, not when a leaf +> in its receive group lags, and it never opens the reciprocal A.5. Its own round can +> only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided +> rotation, the rotated party therefore opens another A.5 on every PQ turn it holds +> (after the first, each is a same-id key refresh), and its own send-PQ leaf keeps its +> pre-rotation credential. +> - *Resolution: healed by spec behavior.* The deployed responder `Commit'` does carry +> its current credential, so a conforming peer's reciprocal A.5 completes the +> catch-up and the loop stops. In the other direction, a conforming rotated party's +> own send-PQ leaf stays behind against a deployed peer. That needs no accommodation: +> rule 4 already has it keep signing that group with the key its leaf presents. +> 2. **Born-dedicated acceptor's PQ leaf.** A deployed born-dedicated acceptor never +> catches up its leaf in the initiator's send-PQ group: its own send-PQ leaf was minted +> at A.3 under the dedicated id, so its trigger never fires. +> - *Resolution: healed once the acceptor runs a conforming engine,* whose own A.5 +> fires. The peer cannot heal it, since only the acceptor's own `Upd'` moves that +> leaf. +> 3. **Born-dedicated acceptor's catch-up Upds.** They propose the identity the peer +> already treats as canonical (`proposing == sender`). A host that folds only offers +> where `proposing` differs from `sender` never commits one, so the acceptor's +> recv-group leaf keeps presenting the invitation identity. +> - *Resolution: healed by the peer's host.* A conforming engine marks such an offer +> as a catch-up, which the host can approve without authorizing a new credential. +> 4. **History window.** The deployed AS pins only the A.3 founding ids. +> - *Resolution: healed by spec behavior wherever a conforming engine validates the +> move,* because it keeps any id a live PQ leaf presents admissible (rule 4). A +> deployed validator still refuses a leaf left behind for longer than the history +> window, and nothing heals that. +> 5. **Unchecked join.** Its A.3 bind joins the peer's send-PQ group signing with its +> *current* PQ key, not the KP′ key its leaf there presents (contrary to rule 4). After +> a rotation before the bind, its A.5 `Upd'` in that group is mis-signed and always +> rejected, and the presented key survives only as its own send-PQ group's signer. +> When it later answers a peer's A.5, its responder `Commit'` replaces that signer, +> and the leaf is orphaned for good: no copy of its key remains. +> - *Resolution: needs an accommodation.* A conforming peer defers a reciprocal A.5 +> until the peer's own A.5 has succeeded, meaning the peer's leaf in our send-PQ +> group is current. Against a conforming peer this costs at most one extra round. +> A conforming engine that takes the party over drops its mis-signed parked `Upd'` +> and re-proposes under the carried key, which heals it. Once the leaf is orphaned, +> nothing heals it. For the common "dedicated agent per session" pattern, don't rotate at establishment at all: pass the agent's id to `receive(…, new_client_id:)` and the session is born From 001c9619ebd6f86750932735cab15f504a900876 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 12:33:30 -0700 Subject: [PATCH 3/6] Collect shipped anomalies in one section and record the A.3 stall Co-Authored-By: Claude Opus 5.5 --- book/src/protocol-flows.md | 2 +- book/src/session-lifecycle.md | 108 ++++++++++++++++++++-------------- 2 files changed, 64 insertions(+), 46 deletions(-) diff --git a/book/src/protocol-flows.md b/book/src/protocol-flows.md index 5724ad6..2696350 100644 --- a/book/src/protocol-flows.md +++ b/book/src/protocol-flows.md @@ -53,7 +53,7 @@ In its place we have two PQ operations: One round re-keys ONE group; the turn alternation brings the other group’s round next. The large updatePath commit happens in isolation on the PQ group, otherwise we block the classical ratchet on transmitting it — only the small pathless ack rides the classical staple. -**Who opens a round — the session, not the host.** The host never selects or opens A.4/A.5; the session self-drives them. Whenever it is our turn, the PQ side-band is idle and not wedged, and both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if either leaf in the PQ half of our receive group (the group our A.5 re-keys) lags, else an **A.4 ratchet**. A leaf *lags* when it presents a credential id other than its owner's *current* canonical (classically committed) id; a same-id key refresh is not a lag. If the lagging leaf is ours, the round is the credential catch-up: our `Upd'` announces our identity. If it is the peer's, the round is the reciprocal catch-up: the peer's responder `Commit'` carries theirs. Opening is send-driven and best-effort (a transient staging failure just retries on the next send), and the frame it stages rides that same send's re-staple. So the abstract "initiator (Alice) sends…" above is, concretely, *Alice's next ordinary message once the turn is hers*. One subtlety: a rotation that lands while an A.4 is staged, or while an A.5 `Upd'` is in flight, does not re-mint that round, and a responder whose own rotation staple has not yet applied answers with a `Commit'` that moves nothing. Either way the leaf still lags after the round and the next turn's trigger opens the catch-up — a race costs one extra round, never a stall. The deployed Rust engine deviates here after a one-sided rotation; see the shipped-anomaly note in [Session Lifecycle](./session-lifecycle.md). +**Who opens a round — the session, not the host.** The host never selects or opens A.4/A.5; the session self-drives them. Whenever it is our turn, the PQ side-band is idle and not wedged, and both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if either leaf in the PQ half of our receive group (the group our A.5 re-keys) lags, else an **A.4 ratchet**. A leaf *lags* when it presents a credential id other than its owner's *current* canonical (classically committed) id; a same-id key refresh is not a lag. If the lagging leaf is ours, the round is the credential catch-up: our `Upd'` announces our identity. If it is the peer's, the round is the reciprocal catch-up: the peer's responder `Commit'` carries theirs. Opening is send-driven and best-effort (a transient staging failure just retries on the next send), and the frame it stages rides that same send's re-staple. So the abstract "initiator (Alice) sends…" above is, concretely, *Alice's next ordinary message once the turn is hers*. One subtlety: a rotation that lands while an A.4 is staged, or while an A.5 `Upd'` is in flight, does not re-mint that round, and a responder whose own rotation staple has not yet applied answers with a `Commit'` that moves nothing. Either way the leaf still lags after the round and the next turn's trigger opens the catch-up — a race costs one extra round, never a stall. The deployed Rust engine deviates here after a one-sided rotation; see [Shipped anomalies](./session-lifecycle.md#shipped-anomalies). 1. Session establishment 1. Bob posts an APQ keyPackage diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 0496aa7..bd624ef 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -56,7 +56,8 @@ is not a lag. "A.4 begins immediately" is just the first send after the turn bec ratchet then ping-pongs, turn-gated so the two sides never both open at once. Staging is best-effort (a transient KEM/proposal failure simply retries on the next send) and the staged frame rides that send's re-staple peek (`pq_pending_outbound`), so the host's role is -`.finishBootstrap` plus sending messages. +`.finishBootstrap` plus sending messages. A deployed host deviates here; see +[Shipped anomalies](#shipped-anomalies). - **Bootstrap** (`0x13`/`0x15`, then a stapled bind) — stands up Group_B's deferred PQ half off the critical path: Alice sends her PQ key package (`0x13`) — the one PRE-COMMITTED at @@ -212,50 +213,8 @@ against the AS history window, and a credential that a live PQ leaf still presen admissible past window eviction until that leaf catches up (see [Group Rules](./group-rules.md), rule 4). -> **Shipped anomaly (deployed Rust engine).** Five deviations. Each notes how it resolves: -> healed by a conforming peer following the spec, healed only once the deployed party runs -> a conforming engine, or needing an accommodation beyond the spec. -> -> 1. **Wrong trigger.** It opens an A.5 when its own *send*-PQ leaf lags, not when a leaf -> in its receive group lags, and it never opens the reciprocal A.5. Its own round can -> only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided -> rotation, the rotated party therefore opens another A.5 on every PQ turn it holds -> (after the first, each is a same-id key refresh), and its own send-PQ leaf keeps its -> pre-rotation credential. -> - *Resolution: healed by spec behavior.* The deployed responder `Commit'` does carry -> its current credential, so a conforming peer's reciprocal A.5 completes the -> catch-up and the loop stops. In the other direction, a conforming rotated party's -> own send-PQ leaf stays behind against a deployed peer. That needs no accommodation: -> rule 4 already has it keep signing that group with the key its leaf presents. -> 2. **Born-dedicated acceptor's PQ leaf.** A deployed born-dedicated acceptor never -> catches up its leaf in the initiator's send-PQ group: its own send-PQ leaf was minted -> at A.3 under the dedicated id, so its trigger never fires. -> - *Resolution: healed once the acceptor runs a conforming engine,* whose own A.5 -> fires. The peer cannot heal it, since only the acceptor's own `Upd'` moves that -> leaf. -> 3. **Born-dedicated acceptor's catch-up Upds.** They propose the identity the peer -> already treats as canonical (`proposing == sender`). A host that folds only offers -> where `proposing` differs from `sender` never commits one, so the acceptor's -> recv-group leaf keeps presenting the invitation identity. -> - *Resolution: healed by the peer's host.* A conforming engine marks such an offer -> as a catch-up, which the host can approve without authorizing a new credential. -> 4. **History window.** The deployed AS pins only the A.3 founding ids. -> - *Resolution: healed by spec behavior wherever a conforming engine validates the -> move,* because it keeps any id a live PQ leaf presents admissible (rule 4). A -> deployed validator still refuses a leaf left behind for longer than the history -> window, and nothing heals that. -> 5. **Unchecked join.** Its A.3 bind joins the peer's send-PQ group signing with its -> *current* PQ key, not the KP′ key its leaf there presents (contrary to rule 4). After -> a rotation before the bind, its A.5 `Upd'` in that group is mis-signed and always -> rejected, and the presented key survives only as its own send-PQ group's signer. -> When it later answers a peer's A.5, its responder `Commit'` replaces that signer, -> and the leaf is orphaned for good: no copy of its key remains. -> - *Resolution: needs an accommodation.* A conforming peer defers a reciprocal A.5 -> until the peer's own A.5 has succeeded, meaning the peer's leaf in our send-PQ -> group is current. Against a conforming peer this costs at most one extra round. -> A conforming engine that takes the party over drops its mis-signed parked `Upd'` -> and re-proposes under the carried key, which heals it. Once the leaf is orphaned, -> nothing heals it. +The deployed engine deviates from this catch-up; see +[Shipped anomalies](#shipped-anomalies). For the common "dedicated agent per session" pattern, don't rotate at establishment at all: pass the agent's id to `receive(…, new_client_id:)` and the session is born @@ -293,3 +252,62 @@ table (contract 23) — so all four survive a restore. The token is opaque to this crate — the caller picks the convention (Germ's adapter digests the envelope's STABLE PREFIX — the app payload, else the bare welcome — so every pre-establishment re-staple from the same initiator resolves to the same token). + +## Shipped anomalies + +This book specifies intended behavior. The deployed Rust engine, and the host it first +shipped in, deviate from it in six ways. Each item notes how it resolves: healed by a +conforming peer following the spec, healed only once the deployed party runs a conforming +engine, needing an accommodation beyond the spec, or pending a design decision. + +1. **Wrong trigger.** The deployed engine opens an A.5 when its own *send*-PQ leaf lags, not when a leaf + in its receive group lags, and it never opens the reciprocal A.5. Its own round can + only move its receive-PQ leaf, so the trigger never clears itself. After a one-sided + rotation, the rotated party therefore opens another A.5 on every PQ turn it holds + (after the first, each is a same-id key refresh), and its own send-PQ leaf keeps its + pre-rotation credential. + - *Resolution: healed by spec behavior.* The deployed responder `Commit'` does carry + its current credential, so a conforming peer's reciprocal A.5 completes the + catch-up and the loop stops. In the other direction, a conforming rotated party's + own send-PQ leaf stays behind against a deployed peer. That needs no accommodation: + rule 4 already has it keep signing that group with the key its leaf presents. +2. **Born-dedicated acceptor's PQ leaf.** A deployed born-dedicated acceptor never + catches up its leaf in the initiator's send-PQ group: its own send-PQ leaf was minted + at A.3 under the dedicated id, so its trigger never fires. + - *Resolution: healed once the acceptor runs a conforming engine,* whose own A.5 + fires. The peer cannot heal it, since only the acceptor's own `Upd'` moves that + leaf. +3. **Born-dedicated acceptor's catch-up Upds.** They propose the identity the peer + already treats as canonical (`proposing == sender`). A host that folds only offers + where `proposing` differs from `sender` never commits one, so the acceptor's + recv-group leaf keeps presenting the invitation identity. + - *Resolution: healed by the peer's host.* A conforming engine marks such an offer + as a catch-up, which the host can approve without authorizing a new credential. +4. **History window.** The deployed AS pins only the A.3 founding ids. + - *Resolution: healed by spec behavior wherever a conforming engine validates the + move,* because it keeps any id a live PQ leaf presents admissible (rule 4). A + deployed validator still refuses a leaf left behind for longer than the history + window, and nothing heals that. +5. **Unchecked join.** The deployed engine's A.3 bind joins the peer's send-PQ group signing with its + *current* PQ key, not the KP′ key its leaf there presents (contrary to rule 4). After + a rotation before the bind, its A.5 `Upd'` in that group is mis-signed and always + rejected, and the presented key survives only as its own send-PQ group's signer. + When it later answers a peer's A.5, its responder `Commit'` replaces that signer, + and the leaf is orphaned for good: no copy of its key remains. + - *Resolution: needs an accommodation.* A conforming peer defers a reciprocal A.5 + until the peer's own A.5 has succeeded, meaning the peer's leaf in our send-PQ + group is current. Against a conforming peer this costs at most one extra round. + A conforming engine that takes the party over drops its mis-signed parked `Upd'` + and re-proposes under the carried key, which heals it. Once the leaf is orphaned, + nothing heals it. +6. **A.3 never completes under the deployed host.** That host ships the initiator's KP′ + inside an ordinary message, which the acceptor answers, standing up its send-PQ half + and parking `Welcome'`. But the host never sends side-band frames, so `Welcome'` never + leaves: the initiator waits for it indefinitely, and neither side becomes fully + established. No A.4 or A.5 ever runs, so the session keeps only the PQ protection its + establishment seeded. + - *Resolution: pending a design decision.* Either a host must carry side-band frames, + as [The PQ side-band](#the-pq-side-band) specifies, and a host that does completes the round from the parked + `Welcome'`, including after the session moves to a conforming engine. Or the + protocol carries A.3's `Welcome'` in-band on ordinary frames, as it already carries + the pre-delivered KP′ and the bind. From 4437329f64e5f88a64dc873d1caf744c52d2a1c3 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 12:38:45 -0700 Subject: [PATCH 4/6] Resolve the A.3 stall anomaly: an upgraded acceptor completes it Co-Authored-By: Claude Opus 5.5 --- book/src/session-lifecycle.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index bd624ef..3a24d5c 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -258,7 +258,7 @@ re-staple from the same initiator resolves to the same token). This book specifies intended behavior. The deployed Rust engine, and the host it first shipped in, deviate from it in six ways. Each item notes how it resolves: healed by a conforming peer following the spec, healed only once the deployed party runs a conforming -engine, needing an accommodation beyond the spec, or pending a design decision. +engine, or needing an accommodation beyond the spec. 1. **Wrong trigger.** The deployed engine opens an A.5 when its own *send*-PQ leaf lags, not when a leaf in its receive group lags, and it never opens the reciprocal A.5. Its own round can @@ -300,14 +300,17 @@ engine, needing an accommodation beyond the spec, or pending a design decision. A conforming engine that takes the party over drops its mis-signed parked `Upd'` and re-proposes under the carried key, which heals it. Once the leaf is orphaned, nothing heals it. -6. **A.3 never completes under the deployed host.** That host ships the initiator's KP′ +6. **The deployed card host never sends side-band frames.** It ships the initiator's KP′ inside an ordinary message, which the acceptor answers, standing up its send-PQ half - and parking `Welcome'`. But the host never sends side-band frames, so `Welcome'` never - leaves: the initiator waits for it indefinitely, and neither side becomes fully - established. No A.4 or A.5 ever runs, so the session keeps only the PQ protection its - establishment seeded. - - *Resolution: pending a design decision.* Either a host must carry side-band frames, - as [The PQ side-band](#the-pq-side-band) specifies, and a host that does completes the round from the parked - `Welcome'`, including after the session moves to a conforming engine. Or the - protocol carries A.3's `Welcome'` in-band on ordinary frames, as it already carries - the pre-delivered KP′ and the bind. + and parking `Welcome'`. But the host never sends that frame, so the initiator waits + for it indefinitely, and neither side becomes fully established. No A.4 or A.5 ever + runs, so the session keeps only the PQ protection its establishment seeded. The same + host does *receive* side-band frames and hands them to the session. + - *Resolution: healed once the acceptor's host carries side-band frames, as + [The PQ side-band](#the-pq-side-band) specifies.* An upgraded acceptor sends its + parked `Welcome'`. The deployed initiator binds, and its bind rides an ordinary + frame, so A.3 completes without the initiator upgrading. Every later A.4 or A.5 + needs the deployed party to send a side-band leg, so those rounds stall, without + error, until it upgrades. A conforming host should bound its re-sends of a parked + leg to such a peer. A deployed acceptor cannot be healed from the initiator's side, + because only it holds the `Welcome'`. From 1ebf8924114244b5fc7e9ce919711e36a8e92d9d Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 12:40:12 -0700 Subject: [PATCH 5/6] Narrow the side-band anomaly to the card host Co-Authored-By: Claude Opus 5.5 --- book/src/session-lifecycle.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 3a24d5c..09ff592 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -255,7 +255,7 @@ re-staple from the same initiator resolves to the same token). ## Shipped anomalies -This book specifies intended behavior. The deployed Rust engine, and the host it first +This book specifies intended behavior. The deployed Rust engine, and the card host it shipped in, deviate from it in six ways. Each item notes how it resolves: healed by a conforming peer following the spec, healed only once the deployed party runs a conforming engine, or needing an accommodation beyond the spec. @@ -305,9 +305,12 @@ engine, or needing an accommodation beyond the spec. and parking `Welcome'`. But the host never sends that frame, so the initiator waits for it indefinitely, and neither side becomes fully established. No A.4 or A.5 ever runs, so the session keeps only the PQ protection its establishment seeded. The same - host does *receive* side-band frames and hands them to the session. + host does *receive* side-band frames and hands them to the session, and the deployed + anchor host co-sends each parked frame with the next message, so anchor sessions + complete A.3. - *Resolution: healed once the acceptor's host carries side-band frames, as - [The PQ side-band](#the-pq-side-band) specifies.* An upgraded acceptor sends its + [The PQ side-band](#the-pq-side-band) specifies and the anchor host already does.* + No protocol change is needed. An upgraded acceptor sends its parked `Welcome'`. The deployed initiator binds, and its bind rides an ordinary frame, so A.3 completes without the initiator upgrading. Every later A.4 or A.5 needs the deployed party to send a side-band leg, so those rounds stall, without From 46903a2f0c052c533366c70f4c3ef592e67912b2 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 12:41:44 -0700 Subject: [PATCH 6/6] Leave re-send cadence to host policy in the side-band anomaly Co-Authored-By: Claude Opus 5.5 --- book/src/session-lifecycle.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 09ff592..3fdbfc5 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -310,10 +310,10 @@ engine, or needing an accommodation beyond the spec. complete A.3. - *Resolution: healed once the acceptor's host carries side-band frames, as [The PQ side-band](#the-pq-side-band) specifies and the anchor host already does.* - No protocol change is needed. An upgraded acceptor sends its - parked `Welcome'`. The deployed initiator binds, and its bind rides an ordinary - frame, so A.3 completes without the initiator upgrading. Every later A.4 or A.5 - needs the deployed party to send a side-band leg, so those rounds stall, without - error, until it upgrades. A conforming host should bound its re-sends of a parked - leg to such a peer. A deployed acceptor cannot be healed from the initiator's side, - because only it holds the `Welcome'`. + No protocol change is needed. An upgraded acceptor sends its parked `Welcome'`. The + deployed initiator binds, and its bind rides an ordinary frame, so A.3 completes + without the initiator upgrading. Every later A.4 or A.5 needs the deployed party to + send a side-band leg, so those rounds stay open, without error, until it upgrades. + How often a host re-sends its parked leg is host policy, but a host must never drop + it. A deployed acceptor cannot be healed from the initiator's side, because only it + holds the `Welcome'`.