From bc08dc8077271d420fa3e27efb558e6e3d66ad71 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 11:09:31 -0700 Subject: [PATCH 1/4] Describe the shipped A.5 catch-up and define proposal_context Co-Authored-By: Claude Opus 5.5 --- .../book-a5-catchup-proposal-context.md | 4 +++ book/src/api-reference.md | 8 ++++- book/src/protocol-flows.md | 2 +- book/src/session-lifecycle.md | 32 +++++++++++++------ 4 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 .changeset/book-a5-catchup-proposal-context.md diff --git a/.changeset/book-a5-catchup-proposal-context.md b/.changeset/book-a5-catchup-proposal-context.md new file mode 100644 index 0000000..b2db136 --- /dev/null +++ b/.changeset/book-a5-catchup-proposal-context.md @@ -0,0 +1,4 @@ +--- +--- + +Book-only: define `proposal_context` and describe the shipped A.5 catch-up; nothing ships. diff --git a/book/src/api-reference.md b/book/src/api-reference.md index 6c55b69..1d4e2bc 100644 --- a/book/src/api-reference.md +++ b/book/src/api-reference.md @@ -199,7 +199,13 @@ unchanged; its result carries the staged Upd both raw (`proposal_hash`), from one critical section, so a host binding a signature to the proposal (the anchor agent handoff) applies its own digest to the returned bytes with no staged-slot read a later prepare could have replaced; `encrypt`; -`process_incoming`; `proposal_context`; +`process_incoming`; `proposal_context() -> Option>` — the SHA-256 of the +receive group's classical group id, `None` until the receive group exists. The receive +group is the peer's send group, so this equals the `QueuedRemoteProposal.context` the +peer reports for proposals it receives from us. The Swift wrapper's `proposalContext` and +`PQQueuedRemoteProposal.context` return it in the tagged `PQDigest` form +(`[0x01] ‖ digest`), and a proposal's `digest` is the SHA-256 of the proposal message in +the same form; `queue_proposal` — approve the peer's Upd (single-occupancy running tally, latest-wins; validates then leaves the proposal cache untouched, so a rejected call is a no-op and a replacement never doubles up; dropped when the send epoch advances via an diff --git a/book/src/protocol-flows.md b/book/src/protocol-flows.md index 4c0326a..eeab099 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 both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if our send-PQ leaf still lags the canonical (classically committed) identity — the credential catch-up, announcing that identity — else an **A.4 ratchet**. 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 already staged does not upgrade that A.4 to an A.5 mid-flight — the catch-up defers to the following turn. +**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 both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if our send-PQ leaf still lags the canonical (classically committed) identity — the credential catch-up, announcing that identity — else an **A.4 ratchet**. 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 already staged does not upgrade that A.4 to an A.5 mid-flight — the catch-up defers to the following turn. A second one: the A.5 we open moves our leaf in the *peer's* send group (step 1 above), not our own send-PQ leaf, which moves only when we respond to an A.5 the peer opens. After a one-sided rotation the lag therefore persists, and every turn we hold opens another A.5. See [Session Lifecycle](./session-lifecycle.md) for the pending design decision. 1. Session establishment 1. Bob posts an APQ keyPackage diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 0810b32..1571055 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -190,15 +190,29 @@ slot frees. On the receiver, `queue_proposal` is a single-occupancy latest-wins (`queued_remote_successor()` reveals it), epoch-locked so it is dropped when the send epoch advances by an A.4 bind. -The winner's other leaves **lag and catch up**: the proposer's own send-group leaf -moves at its next approved commit (the peer observes `new_sender` on that staple, and -message attribution follows); the PQ leaves catch up at the next A.3/A.5 handoff (the -session self-drives this — when a rotation leaves the send-PQ leaf lagging, the next A.5 -it opens announces the session's *current*, already-canonical principal as the handoff, -and the handoff's new leaf carries that credential); the acceptor's recv-group leaf -converges from the invitation identity to the dedicated establishment principal via its -first committed Upd. Every catch-up is validated -against the AS history window. +The winner's other leaves **lag and catch up**. Each one moves only when a commit in its +own group moves it: + +- The proposer's own send-group leaf moves at its next approved commit. The peer + observes `new_sender` on that staple, and message attribution follows. +- Its leaf in the peer's send-PQ group (its own recv-PQ half) moves through an A.5 + re-key. While its own send-PQ leaf lags the canonical principal, the session + self-drives an A.5 whose Upd′ announces that principal, and the peer's Commit′ + installs it. +- Its own send-PQ leaf moves only when it *responds* to an A.5 the peer opened: the + responder's Commit′ carries its current credential. After a one-sided rotation none of + the peer's leaves lag, so the peer never opens one. The rotated party's send-PQ leaf + keeps its pre-rotation credential, and because that leaf still lags, the session opens + another A.5 on every PQ turn it holds. After the first, that A.5 is a same-id key + refresh. Whether the non-rotated peer should instead open a round that heals the + lagging send-PQ leaf is **pending a design decision**. +- The acceptor's recv-group leaf converges from the invitation identity to the dedicated + establishment principal when the peer commits one of its Upds. Those Upds propose the + identity the peer already treats as canonical (`proposing == sender`), so a host that + folds only offers where `proposing` differs from `sender` never commits one, and that + leaf keeps presenting the invitation identity. + +Every catch-up is validated against the AS history window. 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 fc8dd82c3fbc399023916a70f389fe53d0fa8fe6 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 11:17:35 -0700 Subject: [PATCH 2/4] Keep the book as the intended spec; note the A.5 catch-up as a shipped anomaly Co-Authored-By: Claude Opus 5.5 --- .../book-a5-catchup-proposal-context.md | 2 +- book/src/protocol-flows.md | 2 +- book/src/session-lifecycle.md | 43 +++++++++---------- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/.changeset/book-a5-catchup-proposal-context.md b/.changeset/book-a5-catchup-proposal-context.md index b2db136..8f5374c 100644 --- a/.changeset/book-a5-catchup-proposal-context.md +++ b/.changeset/book-a5-catchup-proposal-context.md @@ -1,4 +1,4 @@ --- --- -Book-only: define `proposal_context` and describe the shipped A.5 catch-up; nothing ships. +Book-only: define `proposal_context` and note the shipped A.5 catch-up anomaly; nothing ships. diff --git a/book/src/protocol-flows.md b/book/src/protocol-flows.md index eeab099..affe41b 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 both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if our send-PQ leaf still lags the canonical (classically committed) identity — the credential catch-up, announcing that identity — else an **A.4 ratchet**. 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 already staged does not upgrade that A.4 to an A.5 mid-flight — the catch-up defers to the following turn. A second one: the A.5 we open moves our leaf in the *peer's* send group (step 1 above), not our own send-PQ leaf, which moves only when we respond to an A.5 the peer opens. After a one-sided rotation the lag therefore persists, and every turn we hold opens another A.5. See [Session Lifecycle](./session-lifecycle.md) for the pending design decision. +**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 both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if our send-PQ leaf still lags the canonical (classically committed) identity — the credential catch-up, announcing that identity — else an **A.4 ratchet**. 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 already staged does not upgrade that A.4 to an A.5 mid-flight — the catch-up defers to the following turn. The deployed Rust engine deviates here after a one-sided rotation; see the shipped-anomaly note in [Session Lifecycle](./session-lifecycle.md). 1. Session establishment 1. Bob posts an APQ keyPackage diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 1571055..79312a8 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -190,29 +190,26 @@ slot frees. On the receiver, `queue_proposal` is a single-occupancy latest-wins (`queued_remote_successor()` reveals it), epoch-locked so it is dropped when the send epoch advances by an A.4 bind. -The winner's other leaves **lag and catch up**. Each one moves only when a commit in its -own group moves it: - -- The proposer's own send-group leaf moves at its next approved commit. The peer - observes `new_sender` on that staple, and message attribution follows. -- Its leaf in the peer's send-PQ group (its own recv-PQ half) moves through an A.5 - re-key. While its own send-PQ leaf lags the canonical principal, the session - self-drives an A.5 whose Upd′ announces that principal, and the peer's Commit′ - installs it. -- Its own send-PQ leaf moves only when it *responds* to an A.5 the peer opened: the - responder's Commit′ carries its current credential. After a one-sided rotation none of - the peer's leaves lag, so the peer never opens one. The rotated party's send-PQ leaf - keeps its pre-rotation credential, and because that leaf still lags, the session opens - another A.5 on every PQ turn it holds. After the first, that A.5 is a same-id key - refresh. Whether the non-rotated peer should instead open a round that heals the - lagging send-PQ leaf is **pending a design decision**. -- The acceptor's recv-group leaf converges from the invitation identity to the dedicated - establishment principal when the peer commits one of its Upds. Those Upds propose the - identity the peer already treats as canonical (`proposing == sender`), so a host that - folds only offers where `proposing` differs from `sender` never commits one, and that - leaf keeps presenting the invitation identity. - -Every catch-up is validated against the AS history window. +The winner's other leaves **lag and catch up**: the proposer's own send-group leaf +moves at its next approved commit (the peer observes `new_sender` on that staple, and +message attribution follows); the PQ leaves catch up at the next A.3/A.5 handoff (the +session self-drives this — when a rotation leaves the send-PQ leaf lagging, the next A.5 +it opens announces the session's *current*, already-canonical principal as the handoff, +and the handoff's new leaf carries that credential); the acceptor's recv-group leaf +converges from the invitation identity to the dedicated establishment principal via its +first committed Upd. Every catch-up is validated +against the AS history window. + +> **Shipped anomaly (deployed Rust engine).** The A.5 that a rotated party opens moves +> only its leaf in the *peer's* send-PQ group. Its own send-PQ leaf moves only when it +> responds to an A.5 the peer opens. After a one-sided rotation none of the peer's leaves +> lag, so the peer never opens one. The rotated party's send-PQ leaf therefore keeps its +> pre-rotation credential, and the session opens another A.5 on every PQ turn it holds; +> after the first, each is a same-id key refresh. How that leaf should reach its handoff +> is **pending a design decision**. 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. 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 c75927b9aafd2739a55e8e4bb6bc6d49cdccb632 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 11:24:49 -0700 Subject: [PATCH 3/4] Specify the reciprocal A.5 catch-up A rotated party's own A.5 moves its leaf in the peer's send group; the peer's next turn opens the reciprocal A.5, whose responder commit moves the rotated party's own send-PQ leaf. The trigger is a lagging leaf in the opener's receive group. Co-Authored-By: Claude Opus 5.5 --- .../book-a5-catchup-proposal-context.md | 2 +- book/src/group-rules.md | 3 +- book/src/protocol-flows.md | 8 +++- book/src/session-lifecycle.md | 39 ++++++++++++------- book/src/walkthrough.md | 6 +-- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.changeset/book-a5-catchup-proposal-context.md b/.changeset/book-a5-catchup-proposal-context.md index 8f5374c..f8c9d70 100644 --- a/.changeset/book-a5-catchup-proposal-context.md +++ b/.changeset/book-a5-catchup-proposal-context.md @@ -1,4 +1,4 @@ --- --- -Book-only: define `proposal_context` and note the shipped A.5 catch-up anomaly; nothing ships. +Book-only: specify the reciprocal A.5 credential catch-up, define `proposal_context`, and note the shipped anomaly; nothing ships. diff --git a/book/src/group-rules.md b/book/src/group-rules.md index ca8cd36..96adf10 100644 --- a/book/src/group-rules.md +++ b/book/src/group-rules.md @@ -142,7 +142,8 @@ sequence is driven by the classical ratchet itself: authorizations expire. 4. **Everything else lags and catches up.** The sender's own send-group leaf moves at its next approved commit (the peer observes `new_sender`); the PQ leaves catch up - at the next A.3/A.5 handoff; the acceptor's recv-group leaf converges from the + at the next A.3 handoff or over two A.5 rounds, one per PQ group — the sender's own + A.5, then the peer's reciprocal A.5; the acceptor's recv-group leaf converges from the invitation identity to the dedicated principal via its first committed Upd. The AS validates every catch-up against the sequence *history* (`CREDENTIAL_HISTORY_WINDOW = 8` canonical steps) — a lagging leaf may only diff --git a/book/src/protocol-flows.md b/book/src/protocol-flows.md index affe41b..afb64ee 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 both halves are live (post-A.3), the next `encrypt` opens the next round automatically: an **A.5 re-key** if our send-PQ leaf still lags the canonical (classically committed) identity — the credential catch-up, announcing that identity — else an **A.4 ratchet**. 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 already staged does not upgrade that A.4 to an A.5 mid-flight — the catch-up defers to the following turn. 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 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 its owner's canonical (classically committed) identity — our own leaf (the credential catch-up: our `Upd'` announces our identity), or the peer's (the reciprocal catch-up: the peer's responder `Commit'` carries theirs) — else an **A.4 ratchet**. 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 already staged does not upgrade that A.4 to an A.5 mid-flight — the catch-up defers to the following turn. The deployed Rust engine deviates here after a one-sided rotation; see the shipped-anomaly note in [Session Lifecycle](./session-lifecycle.md). 1. Session establishment 1. Bob posts an APQ keyPackage @@ -700,6 +700,12 @@ classical may in principle hold up the PQ ratchet. In practice it does not: the > signals receipt through the classical channel. One round re-keys ONE group — > the turn alternation brings the other group's round next, at the same bytes > per group as a two-in-one full commit, and no large frame is ever terminal. +> +> **A credential catch-up therefore takes two rounds.** After Alice's rotation, +> her own A.5 moves her leaf in [BSG-PQ] through her `Upd'`. Her leaf in +> [ASG-PQ] still lags, so Bob's next turn opens the reciprocal A.5 on [ASG-PQ], +> and Alice's responder `Commit'` carries her credential onto that leaf. Bob's +> `Upd'` in that round is a same-id refresh. ```mermaid sequenceDiagram diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 79312a8..29175db 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -49,8 +49,8 @@ initiator owes the bootstrap; completing an operation passes the turn to the pee **The host drives only the A.3 bootstrap and then ordinary sends — the SESSION self-drives A.4 and A.5.** There is no `begin(.ratchet/.rekey)` for the host to call: on each `encrypt`, when it is our turn and the side-band is idle, the session opens the next round automatically — an **A.5** -re-key when our send-PQ leaf still lags the canonical (classically committed) identity, else an -**A.4** ratchet. "A.4 begins immediately" is just the first send after the turn becomes ours; the +re-key when either leaf in the PQ half of our receive group (the group our A.5 re-keys) lags its +owner's canonical (classically committed) identity, else an **A.4** ratchet. "A.4 begins immediately" is just the first send after the turn becomes ours; the 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 @@ -78,8 +78,10 @@ frame rides that send's re-staple peek (`pq_pending_outbound`), so the host's ro - **PQ re-key** (`0x1B`/`0x1D`, then a stapled bind) — updatePath commits run on the two send groups' PQ halves **alone**, so the classical ratchet is never blocked behind a large ML-KEM updatePath. It is not a host call either: the session opens it in place of an A.4 when - our send-PQ leaf still lags the canonical principal (a Phase 8 classical rotation moved the - session client; the PQ leaf catches up here), announcing that principal as the handoff. The + either leaf in the PQ half of our receive group lags its owner's canonical principal. When + that leaf is ours (a Phase 8 classical rotation moved the session client), our `Upd'` + announces our principal as the handoff. When it is the peer's, the peer's responder `Commit'` + carries the peer's principal onto its own send-PQ leaf: the reciprocal catch-up. The initiator's send auto-stages `Upd'(self)` into the PQ half of the peer's send group (`0x1B`); the responder commits it with its own `Commit'` (`pq_rekey_respond`, `0x1D`) — whose updatePath rotates the committer's leaf and cross-injects a PSK exported from the PQ half of the *opposite* @@ -192,21 +194,28 @@ epoch advances by an A.4 bind. The winner's other leaves **lag and catch up**: the proposer's own send-group leaf moves at its next approved commit (the peer observes `new_sender` on that staple, and -message attribution follows); the PQ leaves catch up at the next A.3/A.5 handoff (the -session self-drives this — when a rotation leaves the send-PQ leaf lagging, the next A.5 -it opens announces the session's *current*, already-canonical principal as the handoff, -and the handoff's new leaf carries that credential); the acceptor's recv-group leaf +message attribution follows); the PQ leaves catch up at the next A.3 handoff or over +two A.5 rounds, one per PQ group (the session self-drives both). The rotated party's own +A.5 announces its *current*, already-canonical principal in the PQ half of the peer's +send group, and the handoff's new leaf carries that credential. The peer's next turn then +opens the reciprocal A.5, because the rotated party's leaf in the peer's receive group +still lags. The rotated party answers as responder, and its `Commit'` carries the +credential onto its own send-PQ leaf. The acceptor's recv-group leaf converges from the invitation identity to the dedicated establishment principal via its first committed Upd. Every catch-up is validated against the AS history window. -> **Shipped anomaly (deployed Rust engine).** The A.5 that a rotated party opens moves -> only its leaf in the *peer's* send-PQ group. Its own send-PQ leaf moves only when it -> responds to an A.5 the peer opens. After a one-sided rotation none of the peer's leaves -> lag, so the peer never opens one. The rotated party's send-PQ leaf therefore keeps its -> pre-rotation credential, and the session opens another A.5 on every PQ turn it holds; -> after the first, each is a same-id key refresh. How that leaf should reach its handoff -> is **pending a design decision**. Separately, a born-dedicated acceptor's catch-up Upds +> **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. 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. +> 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. diff --git a/book/src/walkthrough.md b/book/src/walkthrough.md index 0ea4696..d12a72f 100644 --- a/book/src/walkthrough.md +++ b/book/src/walkthrough.md @@ -30,9 +30,9 @@ The narrative, step by step: 7. **Continued messaging** — bidirectional traffic continues post-refresh. 8. **Rotation** — Alice `prepare_to_encrypt(Some(new_id))` (lazy: the successor's keys are minted and authorized on the fly — no separate stage call); Bob - observes `CommitResult.new_sender`. Her PQ leaves catch up automatically: the session - opens an A.5 re-key on her next send once the rotation leaves the send-PQ leaf lagging - (no host call — see Session Lifecycle). + observes `CommitResult.new_sender`. Her PQ leaves catch up automatically over two A.5 + re-keys: hers on her next PQ turn, then Bob's reciprocal one on his (no host call — see + Session Lifecycle). For the full flow detail — the PQ side-band rounds, routing, and rotation — see the [Session Lifecycle](./session-lifecycle.md) chapter, and the [Wire Format](./wire-format.md) From 1d1b846e2f0ce5714ee5e19b2b0f8bed60c7de6b Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 23 Sep 2026 11:40:16 -0700 Subject: [PATCH 4/4] Tighten the A.5 catch-up spec after review Define a lagging leaf, exclude a wedged side-band, cover the in-flight and responder races, correct the A.3 claim, and keep a credential a live PQ leaf still presents admissible past the history window. The shipped anomaly note gains its structural cause and the born-dedicated case. Co-Authored-By: Claude Opus 5.5 --- book/src/group-rules.md | 13 +++++++----- book/src/protocol-flows.md | 2 +- book/src/session-lifecycle.md | 38 ++++++++++++++++++++++++----------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/book/src/group-rules.md b/book/src/group-rules.md index 96adf10..a44546c 100644 --- a/book/src/group-rules.md +++ b/book/src/group-rules.md @@ -141,14 +141,17 @@ sequence is driven by the classical ratchet itself: (`remote_commit.new_recipient`, `my_principal_state` → `Sync`); losing candidates' authorizations expire. 4. **Everything else lags and catches up.** The sender's own send-group leaf moves at - its next approved commit (the peer observes `new_sender`); the PQ leaves catch up - at the next A.3 handoff or over two A.5 rounds, one per PQ group — the sender's own - A.5, then the peer's reciprocal A.5; the acceptor's recv-group leaf converges from the - invitation identity to the dedicated principal via its first committed Upd. + its next approved commit (the peer observes `new_sender`); a PQ leaf minted at A.3 + is born under its owner's then-canonical id, and every other PQ leaf catches up over + A.5 rounds, one per PQ group — the owner's own A.5, then the peer's reciprocal A.5; + the acceptor's recv-group leaf converges from the invitation identity to the + dedicated principal via its first committed Upd. The AS validates every catch-up against the sequence *history* (`CREDENTIAL_HISTORY_WINDOW = 8` canonical steps) — a lagging leaf may only fast-forward to an already-canonical credential; candidates are proposed and - canonicalized exclusively in the classical ratchet. + 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. 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 afb64ee..7ca1274 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 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 its owner's canonical (classically committed) identity — our own leaf (the credential catch-up: our `Upd'` announces our identity), or the peer's (the reciprocal catch-up: the peer's responder `Commit'` carries theirs) — else an **A.4 ratchet**. 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 already staged does not upgrade that A.4 to an A.5 mid-flight — the catch-up defers to the following turn. 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 the shipped-anomaly note in [Session Lifecycle](./session-lifecycle.md). 1. Session establishment 1. Bob posts an APQ keyPackage diff --git a/book/src/session-lifecycle.md b/book/src/session-lifecycle.md index 29175db..ac3b742 100644 --- a/book/src/session-lifecycle.md +++ b/book/src/session-lifecycle.md @@ -48,9 +48,11 @@ initiator owes the bootstrap; completing an operation passes the turn to the pee **The host drives only the A.3 bootstrap and then ordinary sends — the SESSION self-drives A.4 and A.5.** There is no `begin(.ratchet/.rekey)` for the host to call: on each `encrypt`, when it -is our turn and the side-band is idle, the session opens the next round automatically — an **A.5** -re-key when either leaf in the PQ half of our receive group (the group our A.5 re-keys) lags its -owner's canonical (classically committed) identity, else an **A.4** ratchet. "A.4 begins immediately" is just the first send after the turn becomes ours; the +is our turn and the side-band is idle (and never while it is wedged), the session opens the next +round automatically — an **A.5** re-key when 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. "A.4 begins immediately" is just the first send after the turn becomes ours; the 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 @@ -87,9 +89,11 @@ frame rides that send's re-staple peek (`pq_pending_outbound`), so the host's ro rotates the committer's leaf and cross-injects a PSK exported from the PQ half of the *opposite* send group. The round's third leg is not a side-band frame: the initiator acks with a pathless partial commit stapled onto its next classical commit (`pq_rekey_apply`), a FULL commit whose - `AppDataUpdate` reconciles the bumped `pq_epoch` **in-round**. (One credential catch-up can defer - a round when an A.4 is already in flight — a staged A.4 is not upgraded mid-flight; the A.5 fires - on the next turn.) + `AppDataUpdate` reconciles the bumped `pq_epoch` **in-round**. (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. Likewise, 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.) ## Routing @@ -194,8 +198,9 @@ epoch advances by an A.4 bind. The winner's other leaves **lag and catch up**: the proposer's own send-group leaf moves at its next approved commit (the peer observes `new_sender` on that staple, and -message attribution follows); the PQ leaves catch up at the next A.3 handoff or over -two A.5 rounds, one per PQ group (the session self-drives both). The rotated party's own +message attribution follows). A PQ leaf minted at A.3 is born under its owner's +then-canonical id; every other PQ leaf catches up over A.5 rounds, one per PQ group, which +the session self-drives. The rotated party's own A.5 announces its *current*, already-canonical principal in the PQ half of the peer's send group, and the handoff's new leaf carries that credential. The peer's next turn then opens the reciprocal A.5, because the rotated party's leaf in the peer's receive group @@ -203,19 +208,28 @@ still lags. The rotated party answers as responder, and its `Commit'` carries th credential onto its own send-PQ leaf. The acceptor's recv-group leaf converges from the invitation identity to the dedicated establishment principal via its first committed Upd. Every catch-up is validated -against the AS history window. +against the AS history window, and a credential that a live PQ leaf still presents stays +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. After a one-sided rotation, the rotated party's A.5 moves +> 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. -> Separately, a born-dedicated acceptor's catch-up Upds +> 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.