Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/book-a5-catchup-proposal-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Book-only: specify the reciprocal A.5 credential catch-up, define `proposal_context`, and note the shipped anomaly; nothing ships.
8 changes: 7 additions & 1 deletion book/src/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>>` — 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
Expand Down
12 changes: 8 additions & 4 deletions book/src/group-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 7 additions & 1 deletion book/src/protocol-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 47 additions & 13 deletions book/src/session-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions book/src/walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading