diff --git a/.changeset/book-a5-catchup-proposal-context.md b/.changeset/book-a5-catchup-proposal-context.md new file mode 100644 index 0000000..f8c9d70 --- /dev/null +++ b/.changeset/book-a5-catchup-proposal-context.md @@ -0,0 +1,4 @@ +--- +--- + +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/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/group-rules.md b/book/src/group-rules.md index ca8cd36..a44546c 100644 --- a/book/src/group-rules.md +++ b/book/src/group-rules.md @@ -141,13 +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/A.5 handoff; 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 4c0326a..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 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 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 @@ -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 0810b32..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 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 +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 @@ -78,16 +80,20 @@ 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* 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 @@ -192,13 +198,41 @@ 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). 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 +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. +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. 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. 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 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)