Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Session Lifecycle

Establishment

  1. TwoMlsPqSession::initiate(client, their_kp, app_binding) — Alice builds her send group (Group_A): the ML-KEM-768 half first, the APQ-PSK exported from it, then the classical half bound by that PSK — carrying the optional app_binding (the app-state binding, welded into the GroupContext here and immutable for the session’s lifetime; see Group Rules rule 8). The app-layer welcome that identifies Alice is attached with set_initial_app_payload. pending_outbound() returns the first frame as one opaque §A.1 envelope — [app_payload ∥ APQWelcome_A] HPKE-sealed to Bob’s KP′ — so that welcome is hidden on the invitation channel (see Header Encryption).
  2. TwoMlsPqInvitation::open_initial(envelope) -> OpenedInitial then receive(welcome, their_classical_kp, bootstrap_kp_commitment, spawn_token, new_client_id, expected_remote, expected_app_binding) — Bob opens the envelope (the invitation holds the KP′ material), validates the app-layer welcome, and joins Group_A as his receive group (PQ half first, re-deriving the APQ-PSK, then the classical half), and builds his own send group (Group_B) classical half only, bound by a cross-party PSK exported from Group_A’s classical half — Group_B’s PQ half is deferred to the bootstrap (below), so Bob can send immediately. new_client_id selects an optional dedicated per-session principal at establishment: Group_B (and later its A.3 PQ half) is created under a freshly-minted principal with that id, so Alice sees the dedicated principal as the creator leaf of the very welcome she joins from — no first-frame rotation is needed. Alice adopts the id on the joining frame, surfacing it as remote_commit.new_sender; authenticity rides the cross-party PSK — only the invitation holder can create a Group_B that Alice’s join accepts. APQWelcome_B (with an empty PQ slot) rides as the staple on every frame Bob sends until his first commit (see Wire Format); a standalone copy is also in pending_outbound() for hosts that deliver it separately.
  3. Alice joins Group_B as her receive group from whichever copy arrives first — the staple on Bob’s first message frame, or a standalone APQWelcome_B via process_incoming. Re-deliveries are idempotent no-ops (the session records the digest of the welcome it joined from; a different welcome on a live session is UnexpectedWelcome). Now is_established() is true on both sides.

The PQ side-band

Three flows run beside the message path on their own tagged frames (see Wire Format). This section is the caller’s view; Protocol Flows §A.3–A.5 is the protocol they implement, and why the epochs must line up as they do. A single turn alternates between the parties: the session initiator owes the bootstrap; completing an operation passes the turn to the peer (my_pq_turn()), and only one operation may be in flight at a time.

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 (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 .finishBootstrap plus sending messages. A deployed host deviates here; see 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 initiate (bootstrap_kp_commitment() put its hash inside the signed establishment payload, and Bob’s receive pinned it) — Bob verifies the hash (BootstrapKpMismatch otherwise), creates Group_B.pq around it and returns its Welcome (0x15); Alice joins and binds. Both send groups are then complete APQ groups (is_fully_established()). The round’s closing bind is not a side-band frame — it rides the next message frame’s staple. The bind is structurally the PQ ratchet’s (below), differing only in where its injected secret comes from — an exporter off the newly joined group rather than a KEM exchange — and it doubles as the round’s receipt: that secret is derivable only from inside Group_B.pq, so a bind that applies at all proves Alice joined.
  • PQ ratchet (0x17/0x19, then a stapled bind) — injects fresh ML-KEM entropy into a send group’s PQ half via a pathless PSK commit and re-binds the exported APQ-PSK into the classical half in the same round. Opened automatically by the turn-holder’s next send (no host call): it auto-stages the initiator’s ML-KEM encapsulation key (0x17); the responder answers with its ciphertext plus the AEAD-sealed injected secret (0x19), and the closing bind rides the next message frame’s staple. Both legs are MLS application messages in their own sender’s classical send group — the entropy they carry is PQ, the transport that authenticates them is not (see Wire Format).
  • 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 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'’s new leaf carries 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. (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

The session tells the transport where to listen and post; both derive from exportSecret(label="rendezvous", context="TwoMLS", len=32) on a group’s classical half, so the two ends compute identical addresses:

  • should_listen_on() — the send group’s ids plus one rendezvous address per retained classical epoch. Listening works from birth; exporters are only derivable at their epoch, so each address is captured live, and the window follows mls-rs’s own epoch retention (traffic posted at a recently prior epoch’s address still lands).
  • send_rendezvous() — where to post: the receive group’s exporter at its current epoch. The receive group is the peer’s send group, so this value appears verbatim in the peer’s listen set. None until the receive group exists (the initiator’s first frame travels via the invitation channel instead).

Sending

Sending is two-phase so CommProtocol can bind a per-round proposal hash:

  • prepare_to_encrypt(proposing) — stages key material and returns a PrepareEncryptResult { proposal_message, proposal_hash, committed_remote_client_id, did_commit }. proposal_message is the staged Upd(self) proposal, raw — every round stages one, rotation rounds included — and proposal_hash its 32-byte SHA-256; the receiver independently derives the same value as QueuedRemoteProposal.digest. A host that must sign over the proposal (the anchor agent handoff binds the rotation Upd’s digest) digests proposal_message itself: bytes and hash come from the same critical section, so no later prepare can interpose a different Upd. Through the Swift package, derive that digest with PQDigest.over(_:) — not a hand-rolled hash — so it stays equal to proposalHash when the suite’s digest changes.
    • proposing: None → routine round. Our own send group commits in two cases, both gated on the peer having applied our previous commit: when a queued, app-approved remote proposal is pending (it folds the proposal — did_commit: true, and the cross-party PSK refreshes if the peer’s send group has advanced since the last binding), or when an owed PQ bind must be discharged (a proposal-less, updatePath-only commit — did_commit: true with nothing folded, so PQ liveness never waits on app approval policy). A round with neither pending commits nothing.
    • proposing: Some(new_client_id) → this round’s Upd proposes a rotation to that ClientId, admitting the candidate on the fly (see Principal key rotation below).
  • encrypt(app_message) — binds the pending proposal_hash into the message’s authenticated data and returns EncryptResult { cipher_text, sender, recipient, epochs }, where epochs is the send group’s epoch pair — pq_epoch (0 while that half is deferred) and classical_epoch (the message epoch). The frame is always the [staple][proposal][app] triple: the staple (our latest send-group commit, or our APQWelcome until the first commit) rides every frame, so a peer that missed a frame is healed by the next one.

Receiving

process_incoming(ciphertext) returns Option<DecryptResult>:

  • application_message — a decrypted app message.
  • proposal — the peer’s stapled Upd(sender) proposal, offered for app approval (then queue_proposal(digest)).
  • remote_commit — a CommitResult, surfaced on the delivery that applied the staple or performed the welcome join (peer rotated, or established under a dedicated principal → new_sender); repeats of an already-applied staple are silent skips. A standalone welcome that adopts a dedicated peer principal returns a DecryptResult with only remote_commit set — the handoff is observable whichever copy of the welcome arrives first. new_sender is an event hint; their_principal_state() is the truth (the signal is lost if the same frame’s app message fails).
  • None — a welcome that changed nothing to announce (a re-delivery already joined from, or a first join under the peer’s expected identity), or a message for an unknown epoch (re-establish the session — not recovered in-library).

A stapled commit ahead of the receive group’s next epoch fails with EpochDesync before the app ciphertext is touched: the peer advanced more than one commit past us and the bridging commit no longer rides any frame — re-establish territory, distinguishable from a transient DecryptionFailed (e.g. a message frame that overtook its A.4 BIND, which succeeds on retry once the BIND lands).

Remote proposals & the folding commit

When process_incoming surfaces a proposal, CommProtocol orders it against its own sequence number and, if accepted, calls queue_proposal(digest). The next prepare_to_encrypt(None) then commits it (did_commit: true), advancing the send epoch and refreshing the PSK binding.

Principal key rotation

Rotation is proposal-driven (see Group Rules — the Authentication Service) and lazy — there is no separate stage call: prepare_to_encrypt(Some(new_id)) makes this round’s stapled Upd(self) carry the successor’s credential, minting the successor (the app supplies only the opaque ClientId; signing keys are session-owned) and authorizing it on the fly if new_id is not already a candidate. Admitting a candidate marks the session Pending. Different rounds may propose different candidates — the app orders them. The peer surfaces each candidate as QueuedRemoteProposal.proposing, approves one with queue_proposal (the running tally), and its next commit canonicalizes it: committed_remote_client_id and their_principal_state report the new identity on the committing side, and the commit staple’s return canonicalizes the proposer (remote_commit.new_recipient, my_principal_state → Sync { new }, the session swaps to the winning principal). Because rotation rides the proposal slot, it can be offered on the very first frame — it can never displace the welcome staple. A candidate proposed on the wire is never dropped (the peer may commit any of them); staging beyond the in-flight window parks the request in a single deferred slot and proposes it on the next routine round once a slot frees. On the receiver, queue_proposal is a single-occupancy latest-wins tally (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). 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 carries its current, already-canonical principal onto its leaf in the PQ half of the peer’s send group. 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, and a credential that a live PQ leaf still presents stays admissible past window eviction until that leaf catches up (see Group Rules, rule 4).

The deployed engine deviates from this catch-up; see 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 under it (Establishment, above).

Invitations & replayed initial frames

A published key package is backed by a self-contained TwoMlsPqInvitation (the credential plus the key package’s private material, including its signing keys) rather than a live client; one invitation services many welcomes, deduplicating repeats per remote (DuplicateWelcome). receive(welcome, their_classical_kp, bootstrap_kp_commitment, spawn_token) takes an opaque, caller-chosen, replay-stable token for the initial frame and records token → the spawned session's receive group in a forward table:

  • forward_group_id(spawn_token) — Some means this exact initial frame was already accepted; route the payload to the owning session instead of surfacing a fresh welcome.

  • TwoMlsPqSession::forwarded(spawn_token) — the session acknowledges the re-delivery (Ok(None) always: the call only validates the routing — a pre-establishment frame staples the sender’s current app message §A.1-style, and the host delivers that staple by parsing the envelope (decode_initial_plaintext) and feeding it to process_incoming); a mismatched token is a mis-route.

  • processed_welcome_group_id(welcome) — the content-keyed counterpart of the forward table: resolves a re-delivered welcome (by the digest of its exact bytes) to the spawned session’s receive group, with no host token convention needed. receive consults the same ledger up front and rejects a re-delivery as DuplicateWelcome before claiming or reserving anything.

The invitation pushes these to its ArchiveSink after every receive — the consumed set, the forward table, the processed-welcome ledger, and the bootstrap-commitment routing 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).

Session profiles

The behavior this book specifies is the correct profile. A second profile, deployed-compatible, adds two behaviors that keep a session with the deployed engine healthy:

  • Announced id. An A.5 Upd' that changes its leaf’s credential id also carries the new id in its authenticated data. A key-only Upd' carries none. Deployed hosts read the announced id to trigger reconciliation.
  • Deferred reciprocal. The reciprocal A.5 waits until the peer’s own A.5 has landed, meaning the peer’s leaf in our send-PQ group presents its current canonical id. Until then our turn opens an A.4, so the peer can still run its own round. This is the accommodation for anomaly 5 below. The correct profile opens the reciprocal as soon as the peer’s leaf lags.

In both profiles a receiver accepts an Upd' whose authenticated data is empty or equals the new leaf’s credential id, and rejects any other value.

Deployed-compatible is the default and is frozen: it changes only to fix a defect or to follow the deployed engine. The profile is chosen once per session, from the two key packages (Group Rules, rule 9), and never changes, not even when a peer upgrades. A client advertises a profile only once it implements that profile completely. The deployed engine advertises none, so every session with it runs deployed-compatible, and so does every session created before profiles existed. A session migrated from the deployed engine is deployed-compatible too; its groups carry no record. Two clients that both advertise the correct profile start in it, with no flag day. A later wire-visible change to the correct behavior ships as a new profile with its own extension type, never as a change to a shipped one.

Shipped anomalies

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.

  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: the deployed-compatible profile’s deferred reciprocal (Session profiles), which every session with the deployed engine runs. Against a conforming peer it 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. 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 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, 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 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 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'.