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

Introduction

TwoMLSPQ is a Rust implementation of a PQ triple ratchet with header encryption. Its interface is exported through UniFFI. We provide a swift build script, and a swift package that wraps the FFI API in Swift Concurrency-friendly types.

TwoMLSPQ is a Rust + UniFFI library implementing 1:1 post-quantum end-to-end encryption on top of two send groups, one per party. Each send group is implemented as an APQ group — a classical MLS group and a PQ MLS group bound by a PSK chain, following draft-ietf-mls-combiner-02 and instantiated with ML-KEM-768 (FIPS 203).

Where it sits

App
    ├─ CommProtocol (Swift)          identities · anchors · handoff signatures
    └─ TwoMLSPQ (Swift package)      Swift API · tagged digest bytes
            └─ TwoMLSPQ (rust crate) sessions · two send groups (APQ groups)
                └─ mls-rs            MLS group state · key schedules
                    └─ crypto        X25519/ChaCha (classical) · ML-KEM-768 (PQ)

The Swift package has no external Swift dependencies. CommProtocol is a sibling under the app, not a layer beneath: the two meet in app code, which carries values between them.

Digests cross the Swift API as self-describing tagged Data[kind][digest] — that this package derives (PQDigest.over(_:)) and compares. The kind tag matters: the digest algorithm is a facet of the crate’s cipher suite, so the tag namespace versions with the crate, and a new suite ships from this repo without waiting on a release of anything else. Callers owe these bytes nothing but carriage — hand them back verbatim. Anything that must byte-match a digest this library emitted (an establishment welcome digest, a value signed alongside proposalHash) must be derived with PQDigest.over(_:) rather than hashed by hand, or it silently diverges the first time the suite’s digest changes.

It does not otherwise depend on CommProtocol’s identities and exposes an MLS client interface of basic credentials.

The app hands TwoMLSPQ the opaque ClientId of one of its agents — identity bytes. TwoMLSPQ builds a TwoMlsPqPrincipal for that ClientId, minting a fresh MLS leaf signing key internally. The ClientId is carried as the MLS Basic Credential — identity trust comes from the app layer, not an external Authentication Service — and the signing key that authenticates the leaf lives inside this library and never crosses the boundary. Everything above the ClientId is outside this library’s boundary.

How a session is built

Each session holds two send groups, one per party; locally they play two roles:

  • send group — owned by this party; they commit and encrypt here.
  • receive group — the remote party’s send group; this party joins and decrypts here.

Each send group is an APQ group: a pair of MLS groups — a classical half (0x0003) and an ML-KEM-768 half (0xFDEA) — cryptographically bound by a PSK chain. The hybrid (classical + post-quantum) guarantee comes from this two-group binding, not from a hybrid cipher suite.

The rest of this book covers the cipher suites, the session lifecycle, the wire format, header encryption (the outer seal that hides frame metadata), the PSK binding contract, and the public API, finishing with an end-to-end walkthrough and how to run the benchmarks.

Concepts

The object model: one mls-rs client vs. three TwoMLSPQ objects

mls-rs centralizes everything behind one long-lived Client per credential: it generates key packages, creates and joins groups, and owns the storage providers (key packages, group state, PSKs) that every operation reads and writes. Persisting an mls-rs application means persisting that one client’s providers, and all operations for a credential funnel through the one object.

TwoMLSPQ deliberately breaks this up into three app-facing objects, each owning exactly the state its job needs:

  • TwoMlsPqPrincipal — the principal: a credential-scoped signing identity. Its job is minting key packages and invitations and holding their private material only until it is captured into an invitation (generate_invitation purges the principal’s own copies). It is not a hub for group operations.
  • TwoMlsPqInvitation — a self-contained receiving capability: one published combiner key package’s private material, the signing identity, and the consumed-remote replay guard. It turns welcomes into sessions with no live client and survives restarts through its own archive. TwoMLS manages the key package’s lifetime itself rather than via mls-rs’s on-the-wire last-resort extension: a last-resort invitation retains its key package to accept many welcomes, while a single-use one consumes it (dropping the private material from the archive) after the first accepted session.
  • TwoMlsPqSession — one established pairwise channel: the two send groups (each an APQ group — see below) and the per-round state.

Naming

TwoMLSPQ keeps its own vocabulary rather than borrowing mls-rs’s Client (the invitation and session each contain mls-rs clients, so “client” would be ambiguous) or CommProtocol’s Agent (this crate is CommProtocol-agnostic):

mls-rsTwoMLSPQrole
ClientTwoMlsPqPrincipalcredential-scoped signer; mints invitations & sessions
KeyPackageTwoMlsPqInvitationone published key package’s private material
groupTwoMlsPqSessionone established pairwise channel

A principal is 1:1 with the MLS Basic Credential. CommProtocol calls the same entity an agent (delegated from its Identity/Anchor); that Agent ↔ Principal correspondence is documented at the AbstractTwoMLS boundary, so this layer never says “agent.”

Internally the invitation and the session still drive mls-rs clients — the invitation rebuilds a stateless one from its captured material on each receive, and a session holds the client backing its groups (plus the successor client staged by a principal rotation) — but those are hidden plumbing, never handed to the app.

The consequence is that persistence is per-object, not per-client, and it is push-based: rather than the app pulling a snapshot when it chooses, each stateful object PUSHES its new state to an app-provided sink after every state-advancing mutation. The app attaches one ArchiveSink per object with install_sink, and the object calls persist(seq, kind, bytes) from inside the mutation. There is no mls-rs-style “restore the client and find your groups again” path — you restore an invitation (TwoMlsPqInvitation.restore(archive)) or a session (TwoMlsPqSession.restore(core, checkpoint)).

Push, not pull, closes a soundness gap. A pull archive() is a move, not a copy — using the live object after snapshotting it, then restoring, rewinds the sender ratchet and re-derives AEAD keys/nonces against a real transcript (security review finding H1). Pushing after every mutation keeps the persisted state always current without the app ever holding a stale-yet-live snapshot. The pull getter survives only as an in-crate test/fuzz helper, off the FFI.

A session’s state splits by encode cost: the ML-KEM ratchet trees dominate the encode (≈1.2 KB per node) and change only on a PQ operation, while classical mutations are cheap and frequent. So a session pushes one of two blob kinds:

  • Core — everything except the two ML-KEM trees (identity, both classical group snapshots, the PSK ledger, the listen map, a staged rotation, parked frames, meta). Written on every classical mutation.
  • Checkpoint — the complete state, including the PQ trees. Written on every PQ-touching mutation and once at install_sink.

Each blob is a single atomic push, so the sink needs no cross-object transaction, and because the PQ trees never change between checkpoints a Core is always consistent with the latest Checkpoint. Restore reconciles the two (PQ halves from the checkpoint, the rest by whichever blob has the higher state_seq) and fails closed if their PQ-epoch manifests disagree. An invitation carries no ML-KEM trees, so it is monolithic — it only ever pushes a Checkpoint.

Concretely, a session encodes by enumerating its groups: each of its (up to four) MLS groups is exported per group through the group object and the storage handle captured when that group was created or joined (apq’s CombinerGroup::export_state / export_classical for a Core / load_combiner_group), never by snapshotting a client’s whole store. This keeps encoding correct across principal rotation — rotation swaps the session’s internal client, and a group’s state keeps flowing through the handle it was born with.

The same ownership rule covers PSKs: the session keeps a small ledger of its send group’s recent cross-party TwoMLS-PSKs and live-injects them into the stores a group actually resolves from, immediately before building or processing the commit that references them; retired and one-shot entries are deleted afterwards, so the mls-rs secret stores are ephemeral plumbing that holds nothing the session doesn’t. The ledger resolves frames that crossed one of our commits (which reference an epoch mls-rs can no longer export), and — being session-owned state — it rides in every pushed blob (Core and Checkpoint alike; it is not a PQ tree), so a restored session (whose rebuilt client’s PSK stores start empty; the key-package stores are preloaded from the blob) still resolves them.

Two asymmetric send groups

A classical MLS group is symmetric: every member can commit. TwoMLSPQ instead gives each party a group they alone send on:

  • Group_A — Alice’s send group. Alice commits and encrypts; Bob joins and decrypts.
  • Group_B — Bob’s send group. Bob commits and encrypts; Alice joins and decrypts.

This makes the two directions independent and lets each side advance its own key material without coordinating a shared committer.

The APQ group

Each send group above is really an APQ group: a classical half (0x0003) and an ML-KEM-768 half (0xFDEA). The two halves are bound by injecting a PSK derived from the PQ half into the classical half at creation; a second, cross-party PSK ties each party’s send group to the group it receives on. An attacker must break both halves to break the session — so the classical half keeps protecting traffic even if ML-KEM were to fail, and vice-versa. This is the hybrid property, achieved at the group level (the construction follows draft-ietf-mls-combiner-02; in code an APQ group is the apq crate’s CombinerGroup). See PSK Binding.

Basic credentials and the TwoMLS AS

The MLS leaf identity is the principal’s opaque ClientId, wrapped in a Basic Credential. Basic credentials carry no external trust, so CommProtocol still makes every trust decision — it validates identities out of band, decides which remote proposals to accept, and binds a per-round proposal hash into the plaintext. What the library now adds is an Authentication Service that enforces those decisions inside MLS: each leaf’s credential may evolve only along the app-defined sequence (candidates ride the ratchet’s Upd proposals; approving one via queue_proposal IS the authorization; the commit canonicalizes it — see The Rules of a TwoMLS Group). TwoMLSPQ still stages and reports; the app still decides; the AS makes a bypassed decision cryptographically unrepresentable.

The CommProtocol boundary

TwoMLSPQ receives a principal’s opaque ClientId (the identity bytes of what CommProtocol calls an agent) — not its signing keys, which it mints internally — and returns key packages, ciphertexts, and structured results (DecryptResult, PrepareEncryptResult, …). Everything above — DIDs, anchor signatures, key discovery from a PDS, sequencing/ordering of proposals, transport — belongs to CommProtocol. Keeping that line sharp is why the API is deliberately small and stateless about identity.

Opaque is not the same as free, though: a ClientId this library never reads is still persisted in ratchet trees and byte-compared, which makes its encoding an identifier format with an append-only obligation on whoever produces it. See Carried Encodings for what the boundary costs in each direction.

Carried Encodings

TwoMLSPQ holds bytes it does not interpret: the principal’s ClientId, the app-state binding welded into a key package, the app payload riding an establishment envelope. This chapter is about what that costs. Carrying a value opaquely is not the same as being decoupled from it — some carried bytes get persisted in MLS group state or byte-compared across a version boundary, and those two facts impose obligations on whoever produces the encoding, in a repo that has no build-time edge to this one.

The principle the API is built on: foreign facts are opaque, domestic facts are typed. This library types what it owns and takes everything else as Data. The obligations below are the residue — they are small, but they are invisible, because both sides of each one spell the value Data.

Digests: owned, not carried

Digests used to be the counterexample. The Swift surface spoke CommProtocol.TypedDigest, which meant the kind tag — the byte naming the hash algorithm — lived in another package’s enum. That inverted ownership: the digest algorithm is a facet of this crate’s cipher suite (TwoMlsSuite::CURRENT.digest), so a new suite’s digest could not be named without first releasing the package that owned the tag namespace.

The bytes were always the real contract. The 33-byte tagged form is what a cross-party agent handoff signs over, and the verifier never parses it out of a message — it rebuilds its signature body from a locally derived reference digest and checks the signature against that. Sharing the Swift type bought call-site convenience and nothing else.

So digests are now derived, tagged, and compared here (PQDigest), and carried by everyone else as Data. Suite agility follows: a new digest is a new tag in this repo, and no other package needs a release to permit it. Consumers owe these bytes nothing but carriage — hand them back verbatim, and derive anything that must byte-match one with PQDigest.over(_:) rather than a hand-rolled hash.

ClientId is an identifier format

The app hands in a ClientId and this library never looks inside it. But it does not merely pass it along: the bytes are baked into the MLS Basic Credential, persisted in ratchet trees, byte-compared for identity mapping and the credential-sequence whitelist, and checked against a welcome’s creator leaf at a born-dedicated establishment. The app then parses the same bytes back out of a DecryptResult into its own key type.

Once bytes you produce are persisted by someone else as a key, you have an identifier format, and identifier formats evolve append-only. In the Germ stack a ClientId is an agent public key’s wireFormat — itself a tagged, self-describing encoding — so this works without anyone coordinating: adding a key algorithm mints a new tag, those become new ClientIds through ordinary credential rotation, and every previously persisted id still parses under its old tag.

What is not safe is changing the layout of an existing tag in place. That does not break a wire format — it breaks stored group state, for every live session, with no migration path short of re-pairing. This library cannot detect such a change; it will faithfully compare the new bytes against the old ones and report an identity mismatch.

The app binding is a derived equality

The AppBinding extension (the app_binding threaded through initiate / receive, see API Reference) is different in kind, and the difference matters. It is equality-compared, not parsed, and it is derived independently on both ends from shared inputs — in the Germ stack, the two anchor DIDs in lexical order. Self-description does not protect a value like this: there is nothing to read a tag off, only two byte strings that must come out identical on two devices that may be running different builds.

So the obligation is a stable canonical derivation — the sort order, the DID normal form, the separator — and it is stricter than the ClientId rule, because even an append-only change to the producer breaks it if the derived bytes move. The consolation is blast radius: nothing is persisted, and the value is re-derived fresh at each establishment, so a mismatch is a version-skew window that closes when both ends update, not a permanent wound in stored state. The crate rejects the welcome before consuming the invitation (AppBindingMismatch), so a skewed pair fails safe and retries cleanly.

What carries no obligation

Most opaque payloads impose nothing. App-layer identity envelopes, signed handoffs, and the establishment app payload ride through as bytes both parties see identically — this library digests some of them, but it never needs to agree with the producer about their internal encoding, only to hand the same bytes to both ends. Change those formats freely: an encoding change moves both sides at once.

The distinction is durable state and independent derivation. Ask of any carried value: does someone persist it, and does anyone else recompute it? If neither, it is pure carriage.

Summary

Carried valueHow it is usedProducer’s obligationIf violated
Digests (PQDigest)derived and compared herenone — this package owns the tags
ClientIdpersisted in group state, compared, parsed backappend-only (never re-lay out an existing tag)live sessions’ stored state; re-pairing
AppBindingequality-compared, derived on both endsfrozen canonical derivationestablishment skew window; fails safe
App payloads, envelopespassed throughnone

None of these appear in a dependency graph. Both sides of every row spell the value Data — which is the point, and the reason they are written down here.

The Rules of a TwoMLS Group

Every MLS group in this protocol — the classical and PQ halves of both send groups — is a 1:1 pair driving a deliberately tiny slice of MLS. This chapter is the normative list of what a TwoMLS group may ever do, and the map of where each rule is enforced. The legacy classical implementation enforced most of these ad hoc; here they are one reusable layer: an MlsRules filter every client is built with (apq/src/rules.rs), plus session-level checks at the protocol’s own ingest points.

The constraint set

  1. Exactly two members, fixed at creation. The creation commit is the one permitted Add: the creator, alone in the group, adds the single peer and nothing else. That is how every half is born — the classical halves at establishment, the acceptor’s PQ half at the A.3 bootstrap (create_group_with_member). Once the roster is two: no Add, Remove, ReInit, ExternalInit, or GroupContextExtensions, ever; no external commits; no external senders. The only custom proposal ever admitted is the draft-02 AppDataUpdate (type 0x0008) that attests new epochs in a FULL commit (rule 7); every other custom proposal is rejected.
  2. At most one Update per commit, never the committer’s own. The only Update a commit legitimately folds is the other party’s leaf update (the stapled Upd(sender) of the classical ratchet, or the A.5 Upd'). An MLS Update always covers its sender’s leaf, so requiring a member sender other than the committer pins it to the peer.
  3. Application and external PSKs only, never resumption. The APQ-PSK and cross-party TwoMLS-PSK are imported as application(3) PSKs (draft-02 §6.2); the A.4 injected secret rides an external(1) PSK. Resumption PSKs are never used (this protocol never resumes groups). See PSK Binding.
  4. Basic credentials only, evolving along the app-defined sequence. Each leaf’s credential is the member’s opaque ClientId, and it may change only per the TwoMLS Authentication Service (below).
  5. Commit epoch must equal the receiver’s current epoch. Ahead is EpochDesync (re-establish territory — unrecoverable in-library); behind is an idempotent skip (the staple re-rides every frame).
  6. Establishment binds identities. A combiner key package’s two halves must name one ClientId; the welcome’s creator leaf must equal the key package’s identity at receive/accept; an A.3 bootstrap key package must match the hash commitment the initiator pinned at initiate and the acceptor recorded at receive (BootstrapKpMismatch) — which binds the exact KP′, stronger than a names-the-peer equality, while its leaf identity is still AS-validated at group creation. The caller can additionally pin the whole exchange to an identity it already expects with receive(expected_remote:) — checked before any invitation state is claimed, so a mismatch consumes nothing. (The one deliberate adoption: the initiator’s welcome join takes the creator leaf as the peer’s principal — the acceptor may establish under a dedicated per-session principal, authenticated by the cross-party PSK.)
  7. APQInfo is written once; epochs are attested per FULL. Each half carries an APQInfo GroupContext extension (type 0xF0A1) naming both group ids, the mode, both cipher suites, and the creation-time epochs — written at creation, riding the Welcome, and never rewritten (rewriting it would need a GroupContextExtensions proposal, which rule 1 forbids — and which would force an updatePath onto the otherwise-pathless A.4 bind). Epoch freshness rides the AppDataUpdate in each FULL commit instead: both halves carry it, the two copies must agree, and each must attest its group’s actual new epoch. A.5 re-keys are PQ-only and their side-band Commit' carries no AppDataUpdate (an attestation smuggled into one is rejected); the bumped pq_epoch reconciles in-round, in the FULL commit the initiator staples as the round’s ack. The deferred A.3 PQ group id is pre-allocated in the classical half’s APQInfo with its epoch set to EPOCH_UNBOUND until the bootstrap lands.
  8. The AppBinding is written once and verified at every join. A session is definitionally bound to its two agents, but agents are mutable (the rotation lifecycle); the optional AppBinding GroupContext extension (type 0xF0A2, the APQInfo mechanism) binds the session to the app’s immutable relationship identity instead. Written at group creation into both classical halves (the PQ halves inherit coverage through the APQInfo half-binding), riding the Welcome, and never rewritten — rule 1’s GroupContextExtensions ban is what makes it immutable. Verification is an exact, symmetric match: receive(expected_app_binding:) requires the welcome to carry exactly the stated binding (a stripped or unequal binding is a wrong-relationship welcome or downgrade attempt; a binding the caller did not state is never silently accepted), the acceptor mirrors the verified binding onto its return group, and the initiator requires the return welcome to carry its own binding back unchanged — all AppBindingMismatch, and on the invitation path raised before any invitation state is claimed. PQ halves must carry no binding (they inherit coverage; a smuggled PQ-half copy is rejected at every PQ join), and an empty binding is reserved as invalid — rejected at creation and as an expectation — so an accidentally empty digest cannot mint a bound-to-nothing session (None is the deliberate unbound state). The payload should be a digest (the first adopter binds H(domain-tag ‖ role-ordered did:did)); the crate never interprets the bytes. Leaves advertise the extension type, so a binding-carrying group can only ever contain capability-bearing leaves.

Enforcement map

Defense in depth is deliberate: the rules filter, the join gates, and the session checks each cover ingress the others cannot see.

RuleTwoMlsRules (filter_proposals)Session / apq checks
Two membersroster gate on every commit, both directionsensure_two_party at every welcome join (no commit runs there) and re-asserted after every applied commit
Creation-commit shaperoster == 1 ⇒ exactly one Addgroups only ever built via create_group_with_member
No Add/Remove/ReInit/GCE post-creationrejected on build and on receive (a peer commit carrying one is vetoed before it applies)post-commit ensure_two_party backstop
Custom proposals: only AppDataUpdate (0x0008)≤ 1 custom proposal, correct type, committer-sent, strict-decoded, same-half epoch == context.epoch + 1; any other custom type rejectedpresence/cross-half agreement and actual-epoch match verified in the session (apply_bind), before app decrypt
≤ 1 Update, peer’s own leaf onlysender ≠ committer on every folded Updaterequire_peer_update at ingest rejects anything that isn’t the peer’s own-leaf Update before it enters a cache — the stapled proposal (queue_proposal) and the A.5 opener (pq_rekey_respond); the A.5 ack (pq_rekey_apply) ingests a Commit', not a proposal, so its folded Update is vetoed instead by the mls-rs rules filter (sender ≠ committer)
Application/external PSKs, never resumptionresumption rejected; external or application acceptedPSK ids are minted internally (export_psk); application PSKs carry the APQ/cross-party bindings, the A.4 injected secret stays external
No external commits/sendersCommitSource::NewMember rejectedexternal senders never configured
Epoch disciplinestaple-epoch compare in process_incoming (EpochDesync / skip)
Identity binding at establishmentexpected_remote pre-claim check; creator-leaf ≡ key-package check at join; A.3 bootstrap KP hash-commitment check (BootstrapKpMismatch)
App-state binding at establishmentGCE ban keeps it immutable post-creationverify_app_binding against expected_app_binding at receive/accept (post-join, pre-claim) and against the session’s own binding at the initiator’s return-welcome join; verify_pq_half_unbound at every PQ-half join (the binding lives on the classical halves only); empty bindings rejected at creation and as expectations (all AppBindingMismatch); leaf capability advertisement keeps uncapable leaves out of bound groups

Two properties worth naming:

  • A peer’s rule-violating commit is rejected before it is applied — the receive side of filter_proposals runs during commit processing, so the local group state never advances into the violating epoch; the session surfaces Mls and the group remains usable.
  • A poisoned proposal cache fails loudly at build. queue_proposal is the only path into the send group’s cache and pre-validates, so if a forbidden proposal ever reaches a commit build, the build errors rather than silently filtering it out — an invariant violation should be visible, and the round recovers because the peer re-staples.

The Authentication Service

Each party’s leaf credential evolves along an app-defined sequence, and the sequence is driven by the classical ratchet itself:

  1. Candidates ride the Upd proposals. prepare_to_encrypt(Some(id)) makes this frame’s Upd(self) carry a successor credential (its new leaf bears the candidate’s credential), minting the successor principal and authorizing it on the fly if id is not already a candidate — there is no separate stage call, so a rotation can ride the very first frame. Different frames may propose different candidates. A candidate that has been proposed on the wire is never evicted — the peer may commit any of them, and only the proposer holds the winner’s signing key. Staging beyond the in-flight window parks the request in a single deferred slot (a newer stage replaces it) and it is proposed automatically on the next routine round once a canonicalization frees a slot. The frame’s proposal section is self-describing — the receiver surfaces the candidate as QueuedRemoteProposal.proposing before the proposal touches any group, and queue_proposal verifies the declared identity against the Update’s actual leaf.
  2. The app’s approval is the authorization. queue_proposal is the running tally: approving a proposal authorizes its credential as the peer’s next; a later approval replaces the tally (single-occupancy, latest-wins — the app owns ordering). queued_remote_successor() returns the currently-queued credential so the app can decide whether to replace it or keep it. Approval validates the proposal and then leaves the group’s proposal cache untouched (it re-applies the one approved proposal only at commit), so a rejected approval is a no-op and a replacement never accumulates a second Update. The tally is epoch-locked: it is dropped when our send epoch advances by an A.4 bind, and the app re-approves from the peer’s fresh offer (the receiver may freely drop — the proposer re-sends every round).
  3. The commit defines the canonical next credential. When the receiver’s commit folds the chosen Upd, that credential becomes the sender’s canonical identity (committed_remote_client_id, their_principal_state). The staple back canonicalizes the sender’s own session onto the winning candidate (remote_commit.new_recipient, my_principal_stateSync); 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. 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.

Enforcement is the mls-rs IdentityProvider (apq/src/authentication.rs): valid_successor implements same-id / authorized-step / catch-up; validate_member whitelists known-or-authorized identities (with a one-shot adoption window strictly around a welcome join whose creator cannot be known in advance — the dedicated establishment principal, authenticated by the cross-party PSK); external senders are always rejected. Every client a session drives — invitation-derived, dedicated, staged candidates, restored — resolves to one session-canonical state through a rebindable view, and the sequences ride the session archive.

A refusal surfaces as CredentialRejected and is retryable where it arises from a staple: the staple re-rides every frame, so approve-and-reprocess recovers the round. new_sender / new_recipient are event hints; zxvbbvsv=their_principal_state() / my_principal_state() are the truth.

Cipher Suites & Feature Flags

The declared suite

A session’s cryptography is one up-front declaration, not a set of independent knobs: the internal TwoMlsSuite enum names the whole configuration (Curve25519ChaChaMlKem768 today, TwoMlsSuite::CURRENT), and every downstream crypto decision is a facet read from it:

FacetDrawn fromDrives
pair()both halvesgroup construction (crypto_config), the wire suite id (invitation/session archive headers, APQInfo), the APQ mode
hpke()the PQ halfthe §A.1 establishment envelope and the parallel A.3 bootstrap-KP envelope (sealed to the PQ EK in the published KP′)
header_aead()the classical half’s AEADthe outer seal on every rendezvous-channel frame and the A.4 injected-secret seal. ChaCha20-Poly1305’s 256-bit key has the strongest post-quantum margin — notably better than the PQ suite’s AES-128-GCM — which is why the PQ side-band too is sealed with the classical AEAD
digest()the classical half’s hashevery digest the crate emits: session ids, welcome digests, proposal/app AAD, the A.3 bootstrap-KP commitment (SHA-256, dispatched infallibly)

The header AEAD is a facet of the one declaration, not an independent configuration point: changing any facet means declaring a new TwoMlsSuite variant, and both parties must run the same declaration to interoperate.

Lifecycle. The suite is born at build time and goes public at the key-package posting: each half of a published APQKeyPackage names its cipher suite in the KeyPackage’s cleartext framing, so the pair is readable off the posted KP. From there, the suite of every inbound §A.1 ciphertext is defined by which posted KP (→ which invitation) it was sealed to — the receiver holds a limited, known key set and never guesses — and the §A.1 HPKE seal additionally binds the declared suite via untransmitted AAD (envelope_framing_aad(), contract 22; see wire format). After establishment the pair rides APQInfo in every Welcome (joiners re-verify), heads the invitation and session archives (decoders reject a pair that is not the declared suite), and the header AEAD + digest facets govern every steady-state frame.

This is agility readiness, not negotiation: exactly one variant exists and every decoder validates == CURRENT. Widening the accepted set (a suite → provider registry, per-invitation suites) is a separate, deferred protocol change; declaring the suite once, encoding it once, and binding it everywhere is that change’s groundwork.

Suites

RoleValueSuite
Classical0x0003MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519 (RFC 9420 §17.1)
Post-quantum0xFDEAMLS_128_ML_KEM_768_AES128GCM_SHA256_Ed25519 (FIPS 203, private range)

MlsCipherSuite::is_combiner_pq() returns true only for 0xFDEA — it is the routing signal: true means “handle in TwoMLSPQ as the PQ half”, false means “hand to the classical library”. is_combiner_classical() returns true only for 0x0003; use it to recognise the classical half of an APQ group so it is paired with the ML-KEM-768 half rather than routed to the classical library on its own.

TwoMLSPQ uses pure ML-KEM-768 for the PQ half — there is no hybrid (XWing-style) cipher suite. The hybrid property comes from the APQ group’s two-half binding (the classical group bound to the ML-KEM-768 group via PSK).

Suites and the APQ mode

MLS cipher suites are monolithic: one id fixes the KEM, AEAD, hash, and signature scheme together (RFC 9420 §17.1). A suite id alone therefore tells you its signature scheme — both 0x0003 and 0xFDEA end in …_Ed25519, i.e. classical Ed25519 signatures.

A session is locked to a concrete pair, ApqCipherSuite { classical, pq } — the source of truth. The APQ mode is derived from that pair, not the other way around:

  • ConfidentialityOnly — the shipped combination (0x0003, 0xFDEA): ML-KEM-768 confidentiality with classical Ed25519 authentication. Authentication is not post-quantum.
  • A future confidentiality + authentication mode would use a PQ signature scheme (ML-DSA / SLH-DSA). No such suite has an IANA assignment, so — exactly as ML-KEM-768 uses the private-range 0xFDEA — it would be pinned to a hardcoded private-range value, added to the suite classifier, and given a new mode variant.

A small classifier (in apq) reads each half’s KEM/signature nature off the §17.1 table. ApqCipherSuite::new enforces the slot invariant — the classical half must be a classical KEM and the PQ half a post-quantum (ML-KEM) KEM, both recognised — rejecting anything else with CipherSuiteMismatch. Given a valid pair, mode() is total: it reads the PQ half’s signature scheme, yielding ConfidentialityOnly for a classical (Ed25519) signature and ConfidentialityAndAuthenticity for a post-quantum one.

The suite pair is a fixed, stored property of a session: captured at construction, persisted in the session and invitation archives, and validated up front. A peer key package or welcome whose suites don’t match fails early with CipherSuiteMismatch (or PqNotAvailable when the peer offers no PQ half at all) rather than as a late, opaque decrypt error, and a restored archive whose suite pair differs from the build’s pinned suite is rejected as ArchiveInvalid.

The same (mode, classical_suite, pq_suite) triple is recorded in each half’s APQInfo GroupContext extension (type 0xF0A1) and carried in the Welcome, so a joiner re-verifies the pair — and rejects an invalid or duplicate suite pairing, as draft-ietf-mls-combiner-02 requires — against that record, not only against its own pinned suite. See group rules (rule 7) and wire format.

Feature flags

FlagMeaning
cryptokitApple CryptoKit backs both halves (classical suites + native ML-KEM-768, FIPS 203). macOS 26 / iOS 26+ only. The shipped configuration.
awslcaws-lc backs both halves. Portable (Linux CI runs the full suite with it) and wire-compatible with cryptokit.
benchmark_utilGates the benches/* targets and their fixtures.

Exactly one provider feature must be selected — there is no default, and a build with neither fails with an explicit compile_error!. When both are enabled (--all-features on an Apple machine), cryptokit wins. The PQ half is always real ML-KEM-768: the crypto provider is an implementation detail pinned in two-mls-pq/src/providers.rs, and the apq crate compiles no provider at all — the concrete providers are injected as generic parameters (apq::CryptoConfig).

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, 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 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.

  • 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 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 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.)

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_stateSync { 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); 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.

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 signing identity plus the key package’s private material) 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).

Wire Format

Session-path blobs are tagged frames — no bare MLS messages ride the wire. The §A.1 invitation-channel envelope is the exception: it is a raw HPKE blob with no outer tag ([u32-LE kem_output_len][kem_output][ciphertext]), because the transport channel already routes it to the HPKE opener and an outer discriminator would only fingerprint which frames carry PQ material. Discrimination moves INSIDE, to the authenticated leading tag of the HPKE plaintext (see “The §A.1 envelope” below).

TagValueMeaning
APQ_TAG0x01APQ Welcome (invitation channel; also the message frame’s staple-slot welcome form)
MESSAGE_FRAME_TAG0x03The message frame: [staple][proposal][app] — the only message-path frame
APQ_PRIVATE_MESSAGE_TAG0x05Draft-02 §7 APQPrivateMessage — the staple slot’s bind form. Declared in apq
ESTABLISHMENT_VECTOR_TAG0x07Inner leading tag of the §A.1 establishment vector (an HPKE-plaintext byte, not an outer wire tag). Declared in key_packages
PRE_ESTABLISHMENT_APP_TAG0x09§A.1 app staple, envelope-interior only: [0x09][ASG-cl PrivateMessage] (sealed in the initiator’s send group)
ESTABLISHMENT_HANDOFF_TAG0x0BBorn-dedicated establishment staple: [0x0B][u32-LE len][signed handoff blob][u32-LE len][APQWelcome_A] — wraps the unmodified welcome next to the acceptor’s opaque host-signed delegation. A message-path staple form (rides the 0x03 staple slot and standalone delivery), but banded here because it exists only during establishment
PQ_BOOTSTRAP_KP_TAG0x13Bootstrap: PQ key package for the deferred send-group half
PQ_BOOTSTRAP_WELCOME_TAG0x15Bootstrap: the new PQ group’s Welcome (PQ-groups-only; no classical commit)
PQ_EK_TAG0x17PQ ratchet: the ML-KEM encapsulation key, carried as an MLS application message in the sender’s send-classical group — [0x17][MLSMessage], the message’s authenticated content being [0x17][ek]. MLS then authenticates the leg by leaf signature and current-epoch proof (see below)
PQ_CT_TAG0x19PQ ratchet: the ML-KEM ciphertext plus the AEAD-sealed injected secret ([u32-LE enc_len][enc][sealed], not a bare KEM ciphertext — the seal makes ML-KEM implicit rejection an explicit failure), likewise an MLS application message in its own sender’s send-classical group: [0x19][MLSMessage], content [0x19][enc ∥ sealed]
PQ_REKEY_UPD_TAG0x1BPQ re-key: initiator’s Upd' proposal
PQ_REKEY_COMMIT_TAG0x1DPQ re-key: the responder’s Commit'

There is no bind tag: a round’s closing bind is the message-frame staple (the APQPrivateMessage above), not a side-band frame, so every side-band frame is answered by its round’s next leg.

Why the A.4 legs are MLS messages, not bare bytes. Wrapping the EK and CT as MLS application messages (rather than shipping the raw key bytes) is what gives each leg the same two-factor authentication MLS gives every member message: a leaf signature and proof of the current epoch’s secrets (the receive ratchet won’t decrypt without them). Following the MLS convention of signature, this closes a post-compromise gap the bare frame had: a stolen signing key alone can no longer forge a leg the peer will act on, because the attacker also lacks the current epoch secrets once a round they missed has healed the group. The seal over the injected secret is unchanged and still does its own job (the explicit receipt); the MLS framing is purely the authentication layer. See Protocol Flows §A.4 and pq_ops::process_a4_leg.

Why the CLASSICAL group carries them. Both factors are fresher there. The classical leaf signature and epoch secrets heal on every round and pick up a principal rotation the moment it is canonicalized, while a send-PQ leaf lags until an A.5 catch-up — so the same two-factor argument simply binds to more recent key material. Nothing is traded away in strength: MLS cipher suites are monolithic and both halves sign Ed25519 (the PQ suite is confidentiality-only — see Cipher Suites), so the signature factor is classical either way, and the classical epoch secrets are hybrid (ML-KEM-seeded via the APQ PSK). Under an ML-KEM break the classical carrier keeps both factors where the PQ carrier kept only the signature. The round’s binding to the PQ group is untouched: ct_seal_psk is still a PQ-group exporter, keyed into the seal over S.

Each leg rides its own sender’s send-classical group — the EK the initiator’s, the CT the responder’s — not one shared group as the PQ form used. The responder must not mint its CT in the mirror the EK arrived in: that mirror routinely holds its own uncommitted Upd, and mls-rs refuses to encrypt while a by-ref proposal is cached (CommitRequired) — a failure that would land after the EK decrypt consumed its generation and strand the round.

The cost, and what pays it. A classical-carried leg is encrypted at the epoch it was minted at, and ordinary traffic advances that epoch — past the peer’s retention window within a few commits. So a leg is re-minted at the current epoch whenever the carrying group has moved (pq_ops::rewrap_side_band, driven from the send path), instead of being pinned for re-send the way the PQ form could be (pq_epoch cannot move mid-round). What survives a stall is therefore the round, not a leg’s bytes: a blob captured before a burst of commits stops opening, exactly as a message frame from that moment does. A leg that arrives ahead of the commit for its epoch is the ordinary retriable case, healed by the staple’s re-send.

The epoch floor that re-minting requires. One logical leg now exists as several valid wraps, and MLS keeps the keys of generations it skipped so they can still arrive out of order — so a superseded wrap redelivered late (a push relay handing over a second copy, not an attack) would still decrypt, where a replay of the wrap actually consumed would not. Answering one after its round closed would park a responder against an ephemeral the initiator has already discarded, and since only a bind clears that state and only a clear state opens a round, both sides would stop ratcheting for good. Both receive paths therefore reject, before decrypting, a classical-carried leg whose epoch is strictly below the receiver’s current classical epoch. The bound is exact rather than heuristic: reaching epoch E proves the sender committed E, and that commit’s own send re-minted the live leg to E, so anything below E is superseded or replayed by construction. Legs ahead of the receiver stay retriable, as before, and the legacy PQ-carried CT below is exempt — it has no re-minting and the epoch it sits at cannot move mid-round.

Receiving the older form. A 0x19 minted in the PQ group still binds: a peer that opened its round before this change can only re-send the CT bytes it parked (the payload is MLS-encrypted to us, so it cannot rebuild them), and refusing it would strand an otherwise completable round. pq_ratchet_bind therefore routes on the leg’s group_id. The respond path is single-form — a PQ-carried 0x17 is dropped, non-fatally and without consuming anything — so the two forms never interleave within a round.

An old-form EK is migrated, not answered. The asymmetry above would strand the other half of the compatibility problem — a round restored from an older archive holds a PQ-form EK no upgraded peer will answer — so the first send after such a restore converts it, re-minting the classical form from the ephemeral the round still holds. That trades a window against a not-yet-upgraded peer for one against an upgraded peer, which is the right way round: the first is permanent once both sides upgrade, the second heals the moment the peer does. Only the EK is convertible; a retained CT is encrypted to its peer and stays in the form it was minted in, which is why the bind keeps the dual-form arm.

Length prefix & padding (inside the seal). These tagged frames are the AEAD plaintext; the symmetric header seal wraps each as [u32-LE frame_len][frame][optional zero padding] (see Header Encryption). Two consequences at the wire level: every frame carries a fixed +4-byte length prefix, and a side-band frame may be zero-padded up to the size of the message it is co-stapled with when the host declares a frame-sizing intent (set_pad_target(Some(n)), capped at a push-payload budget n). The intent’s purpose today is unlinkability — equal sealed lengths make the two co-stapled payloads size-indistinguishable to an on-path observer — and, as the motivating forward-looking use of the same knob, chunking into fixed push-payload units. Absent the intent, frames are the natural size and carry only the prefix.

The table is the prose half of the registry; frames::tests::BANDS is the executable half, and the two must agree. The space spans three declaration sites, because each tag lives with the thing it tags: APQ_TAG and APQ_PRIVATE_MESSAGE_TAG in the apq crate, ESTABLISHMENT_VECTOR_TAG in key_packages (an inner HPKE-plaintext tag, not a session frame), and the rest in session::frames. Ownership is local; allocation is global — which is exactly how 0x15 could be claimed twice, by a reader of frames.rs for whom the establishment-vector tag is invisible.

The §A.1 envelope

The envelope blob has no outer tag[u32-LE kem_output_len][kem_output][ciphertext], a raw HPKE seal. The two §A.1 frame kinds share this identical outer shape and are told apart only AFTER HPKE-open, by the authenticated leading tag of the plaintext:

  • ESTABLISHMENT_VECTOR_TAG (0x07) → the establishment reply — four u32-LE length-prefixed sections [app_payload][welcome][return_key_package][stapled_message].
  • PQ_BOOTSTRAP_KP_TAG (0x13) → the parallel-delivered A.3 bootstrap KP frame, carried verbatim ([0x13][KP′]) — the same side-band frame steady-state A.3 uses, only its outer framing differs (HPKE envelope here vs. header-sealed side-band later).

This is the same discipline the whole tag space follows: the transport path limits which keys the receiver decrypts with, then the inner authenticated tag guides parsing — like the 0x03 message frame’s staple slot self-discriminating on its first byte (0x01 welcome vs. 0x00 commit). An outer tag would be observable and would fingerprint which frames carry PQ material; there is nothing it could disambiguate that the channel does not already. The initiator re-seals under a fresh HPKE ephemeral on every send (reply and bootstrap KP alike), so a stable inner KP′ never produces a linkable blob.

The seal binds the declared suite via untransmitted AAD (contract 22). Both sides derive [framing version (1)][classical u16 BE][pq u16 BE] — the declared suite’s wire bytes (envelope_framing_aad()) — and pass it as the HPKE aad; it never travels (RFC 9180 aad is a seal/open input, not part of the ciphertext, so only byte-equality matters). A peer whose declared pair or framing version differs fails the AEAD tag as DecryptionFailed — deliberately opaque, the header seal’s “indistinguishable by construction” contract — which downgrade-binds the WHOLE pair, classical half included (the HPKE operation alone only ever touches the PQ half), at zero wire and zero plaintext bytes. The suite needs no signaling here: each half of a posted APQKeyPackage names its cipher suite in the KeyPackage’s cleartext framing, and the suite of an inbound envelope is defined by which posted KP (→ which invitation) it was sealed to. Hosts on the split hpke_open + decode_initial_plaintext path must supply envelope_framing_aad(); open_initial derives it internally. See Cipher Suites for the declared suite and its facets.

The space is banded. Each band owns a contiguous range of odd bytes, is packed from its start, and keeps its remaining room at the end:

BandRangeUsedContents
Message path0x010x053 / 3APQWelcome, message frame, APQPrivateMessage staple form. Full, and closed by design — the message path has exactly these shapes
A.1 establishment0x070x113 / 6establishment-vector inner tag, pre-establishment staple, born-dedicated establishment handoff. The hybrid nested envelope would land in the room
PQ side-band0x130x316 / 16exactly what pq_frame_kind classifies, in lifecycle order: bootstrap, ratchet, re-key

Banding is what makes “the side-band is 0x130x31” a claim that survives growth: a new flow appends at the end of its band, into reserved room, so the range shorthands across the code and this book stay true instead of being silently falsified by an out-of-band append. A range in prose should at least not be a lie. Only a band that FILLS forces the bands below it to move — the message path is the one full band, its three shapes leaving no room.

The room is free in both directions that could have cost something. On the wire, header encryption seals every blob, so a tag is never observed and a sparse space fingerprints nothing. In the tests, tag_space_holds asserts density within a band and membership against that band’s bounds — so room at a band’s end is legal, while appending past the end still fails. The reserve costs no enforcement, which is why the sizes can be generous. They are reserves, not predictions: only the message path’s fullness is a design claim.

A band’s reserved bytes are unallocated and must not classify, so the side-band’s invariant is set equality against the registry — side_band_band_matches_the_classifier checks pq_frame_kind against it over all 256 bytes. A range test would be unsound here: a reserved byte is in 0x130x31, so “in range ⟺ classified” would wave through a reserve that quietly started routing.

The side-band’s lifecycle order now matches the spec’s section numbers: the classifier is ordered by lifecycle (A.3 bootstrap, then A.4 ratchet, then A.5 re-key), and the section numbers follow that same order — tag 0x13/0x17/0x1B line up with §A.3/§A.4/§A.5.

Each multi-section frame uses a u32-LE length prefix per embedded field. Hosts classify PQ side-band frames via the exported pq_frame_kind (never by matching raw tag bytes); everything that is not a side-band frame or a standalone welcome routes to process_incoming.

The message frame: always staple the commit

The message path has exactly one shape, [0x03][staple][proposal][app], with no optional sections:

  • staple — the sender’s latest send-group classical commit, re-stapled on every frame until superseded; the send group’s APQWelcome until the first commit exists; the born-dedicated establishment handoff (0x0B) wrapping that welcome until a dedicated acceptor’s first commit; or — when the commit discharged an owed bind — the draft-02 §7 APQPrivateMessage carrying the classical commit and its PQ partner (the classical half is unusable without the apq_psk only the PQ half supplies, so the two travel as one). Any single received frame therefore brings the peer up to the sender’s current epoch: losing the frame that first carried a commit no longer strands the direction — the next frame heals it, and a lost bind heals by the same re-send. (Multi-commit gaps still exceed one staple; that is re-establish territory.)
  • proposal — the routine Upd(sender) addressed to the peer’s send group, staged on every round, including principal-rotation rounds.
  • app — the application message; its authenticated data is sha256(proposal) on every round.

The staple slot self-discriminates by its first byte: an APQWelcome starts 0x01, an MLSMessage starts 0x00 (its two-byte ProtocolVersion), an APQPrivateMessage starts 0x05 — the wrapper tag exists precisely because the draft struct’s own first byte is its inner MLSPrivateMessage’s 0x00, which the slot could not tell from a bare commit — and a born-dedicated establishment handoff starts 0x0B (the receiver extracts its inner welcome and processes that, dedup keyed on the inner welcome’s digest). The receiver processes staples idempotently — a welcome for an already-joined group and a commit older than the receive group’s epoch are cheap skips; a commit ahead of the receive group surfaces EpochDesync before the app ciphertext is touched.

Rotation is not a frame kind. A principal rotation is a commit whose updated leaf credential carries the new ClientId; the receiver detects it by diffing the committer’s leaf credential across the apply (the AS validated the succession during processing), not by any frame tag or authenticated-data byte. It rides the same message frame, stages the same routine proposal, and folds any queued peer proposal it finds cached.

Why re-stapling stays cheap

Commits can be large, but the protocol keeps the classical staple small by design: classical stapled commits carry no PQ keys — PQ work rides partial PQ commits on the side-band (no updatePath), and big ML-KEM updatePaths are isolated to A.5 on the PQ groups alone. So the steady-state staple is a classical two-member commit, a few hundred bytes.

The unavoidably large staples are the APQ welcomes, and only until the first commit:

  • the acceptor staples only a classical-half welcome (~1 KB) — its PQ group is deferred to the A.3 bootstrap for exactly this reason;
  • the initiator re-staples its full two-half APQ welcome (ML-KEM-sized, several KB) on every frame until its first commit — a window that is app-gated (it closes when the first peer proposal is approved and committed) and whose repeats the peer skips idempotently.

Message-frame anatomy

For a fixed 43-byte payload, a steady-state rotation frame (CURVE25519_CHACHA, awslc) is 1341 B, opened from its header seal and split on the u32-LE section prefixes (benches/sizes.rs prints this split):

SectionBytesContents
staple (commit)651the classical rotation commit MLSMessage (a PublicMessage): its UpdatePath (the rotated leaf + one path node), one by-value proposal — the 73 B APQ PSK — plus the commit’s signature, confirmation, and membership tags
proposal (Upd)395[u32 proposingLen][ClientId][Upd(sender)] — a full leaf by value, ~357 B of it the Upd MLSMessage
app254the application PrivateMessage (43 B payload + ~211 B MLS framing)
framing41frame tag (1) + three u32 section prefixes (12) + header-seal nonce and tag (28)

Two things that look like waste are not:

  • The commit folds by reference, never by value. An approved peer Upd is cached (process_incoming) and committed by its ~32-byte ProposalRef; the only proposal the commit carries by value is the small APQ PSK. benches/sizes.rs opens the staple and asserts the by-value proposal bytes stay under 200, so a regression that started inlining the fold — turning that 73 B into a leaf-sized ~360 B — fails the bench immediately. (By hand: MlsMessage::proposals_by_value() on an opened staple returns the PSK and nothing leaf-sized.)
  • The leaf-sized cost is the two groups, not a double-carry. A rotation touches both send groups: the sender’s own via the commit’s UpdatePath leaf, and the peer’s via the by-value Upd(sender) in the proposal section (which the peer folds by reference on its next commit). Each leaf crosses the wire once; the newSender invariant is what makes it every message. Shrinking it is a protocol-level choice (rotate less often, or stop mirroring every self-update into the peer’s group), not a by-reference fix — the commit is already by reference.

Why the tags are odd

All tags are odd — the rule that lets a tagged frame and an MLS message be told apart from their first byte alone. An MLSMessage begins with its two-byte ProtocolVersion (MLS 1.0 = 0x0001, big-endian), so its first byte is always 0x00, and 0x00 is even by construction. The invariant now does double duty: it is also what makes the message frame’s staple slot self-discriminating (welcome 0x01 vs. commit 0x00) without a separate discriminator byte. The entire even space stays unused and in reserve. Extending the protocol is not “take the next unused odd value” — that is what would break contiguity; it is “append at the end of the right band, into the room the band already reserves.” Only a band that fills forces the bands below it to move.

Draft-02 conformance inside the frames

The Germ tags above are the transport envelope; inside them the MLS payloads carry the draft-ietf-mls-combiner-02 structures directly. The apq crate conforms to the draft, and the Germ frames enclose the draft-02 wire shapes rather than replacing them.

  • APQInfo — a GroupContext extension (type 0xF0A1) present in both halves of each APQ group and carried automatically in every Welcome’s GroupInfo. It names both group ids, the mode, both cipher suites, and the creation-time epochs; it is written once at creation and never rewritten, and joiners verify it against the groups they actually joined (see group rules, rule 7).
  • AppDataUpdate — a custom proposal (type 0x0008) that rides both commits of every FULL commit, attesting the new epochs of both halves. Receivers verify the two copies agree and match the actual post-commit epochs before any app data is decrypted.
  • Combiner key package — the CombinerKeyPackage payload adopts the draft’s §7 APQKeyPackage { t_key_package, pq_key_package } TLS encoding inside Germ’s version framing. A key package that does not carry this encoding is rejected outright.

Every occupied leaf must advertise the APQInfo extension (0xF0A1) and the AppDataUpdate proposal (0x0008) types; a leaf that cannot support them is rejected rather than silently degraded.

Invariants

The tag values are part of the on-wire protocol; pre-release, a renumber is allowed. When adding a message type, append it at the end of its band — into the room, so nothing else moves — add a row to frames::tests::BANDS and to the table above, add matching encode_*/decode_* helpers following the u32-LE length-prefix pattern, and extend pq_frame_kind if it is a side-band frame — hosts dispatch on the classifier, so a frame kind that never reaches it is invisible to them. Bump BINDING_CONTRACT_VERSION if any value moves: a renumber is a wire cut.

If a band ever fills, the bands below it move. That is cheap here because nothing outside frames.rs names a tag by value — hosts classify via pq_frame_kind, and the crate references the constants — but it is a wire cut, which is what the room exists to postpone. tag_space_holds enforces the bands (disjoint, odd-bounded, packed from the start, inside their bounds) and names the colliding constants when it fires.

Header Encryption

Status: implemented. Two key families — the classical half (HeaderKey) sealing message-path frames and the A.4 legs, the PQ half (HeaderKeyPQ) sealing the A.3/A.5 frames, each keyed by its own ratchet’s epoch — plus the initiator’s first frame, which the library HPKE-envelopes ([app_payload ∥ APQWelcome_A] sealed to the peer’s KP′) so the app-layer welcome is covered too; the peer opens it with TwoMlsPqInvitation::open_initial. Every outbound blob is opaque. The only remaining refinement is the hybrid nested envelope (open question #2) — the first-frame envelope is PQ-only today.

MLS PrivateMessage encrypts the message content, but its framing is plaintext: group_id, epoch, content_type, and the entire authenticated_data field travel in the clear (RFC 9420 §6.3). TwoMLSPQ’s own frame layer adds a plaintext tag byte on top. This chapter specifies the outer encryption layer — header encryption — that makes every outbound blob a single opaque ciphertext, following the scheme the classical stack (multiMLS-Swift TwoMLS) already ships.

Sequencing: the wire-format rework (always-staple the send-group commit; one message-frame shape; retagging) landed first — tag values below refer to the reworked Wire Format. Header encryption is applied on top of those frames; its rules are per-stream — which group’s key schedule the frame’s contents belong to — rather than per-tag, with the A.4 legs following their inner message into the classical stream.

As shipped, in one paragraph. TwoMlsPqSession::encrypt, pending_outbound (once a recv group exists), and pq_take_pending_outbound/pq_pending_outbound (the session self-drives the side-band, so there is no host begin return) all emit [12-byte random nonce][ChaCha20-Poly1305 ct+tag], the AEAD covering a length-prefixed, optionally zero-padded plaintext — [u32-LE frame_len][frame][padding] (see Frame length prefix & padding below). Message-path frames and the A.4 legs key on exportSecret("germ.network.twomlspq.headerKey.v1", group_id, 32) on the recv group’s classical half at its current classical epoch; the A.3/A.5 frames key on exportSecret("germ.network.twomlspq.headerKey.pq.v1", group_id, 32) on the recv group’s PQ half at its current pq_epoch (the pre-A.3 BOOTSTRAP_KP, whose recv-PQ group doesn’t exist yet, falls back to the classical key). The receiver’s open_incoming(blob) -> Option<OpenedFrame { kind, frame }> trial-decrypts over both per-epoch windows of its own send group’s header keys (the classical window captured beside the listen addresses; the PQ window captured at each pq_epoch advance; both archived), returns the plaintext frame plus a routing kind (Message or PqSideBand { PqFrameKind }), and the host routes frame to the existing plaintext entry points — which also transparently open a sealed blob passed straight through, so a host may skip open_incoming for the message path. The initiator’s initial welcome (invitation channel, no symmetric key yet) is the one frame not sealed here.

What leaks today

FieldWhereWhat an observer learns
frame tag (0x010x1D)first byte of every tagged frameframe kind: establishment vs. rotation vs. PQ side-band activity (bootstrap, ratchet, re-key)
group_idevery MLSMessagea stable per-direction session identifier — links every message of a direction across epochs, undoing the per-epoch rendezvous rotation for anyone who stores ciphertexts
epochevery MLSMessagecommit cadence, message ordering, session age
content_typeevery PrivateMessageapplication vs. proposal vs. commit
authenticated_dataevery PrivateMessagethe 32-byte per-round proposal hash; on rotation frames and A.5 Upd' proposals, the announced ClientId
Welcome plaintextAPQWelcome (both halves)cipher suites, KeyPackageRefs of the joiner — linkable to published key packages
MLS version / wire formatevery MLSMessageprotocol fingerprint

The rendezvous scheme already unlinks routing across epochs; header encryption extends that to the ciphertexts themselves, so a stored frame is one uniform blob with no protocol fingerprint, no session identifier, and no visible side-band activity.

The classical precedent, verified

What multiMLS-Swift TwoMLS actually does (verified against SendGroup.headerEncrypt, ReceiveGroup.headerDecrypt, prepareCommit, processNewEpoch, expectWelcome, and the invitation flow):

  • Steady state — the header key is an exporter of the opposite group, at that group’s current epoch. Frames I send on my send group are sealed with ChaChaPoly under exportSecret(label = "germ.network.pairwiseKeyExport", context = group_id, len = 32) evaluated on my receive group (= the peer’s send group) at the newest epoch I have applied there. It is not derived from the previous epoch of the sending group.
  • Rotation points. When I commit my own send group (epoch n → n+1) I export its new header key and file it in my receive window, keyed as “the key the peer will seal under once they process this commit”. When I apply the peer’s commit to my receive group (epoch m → m+1) I re-derive my send header key from the new epoch. The two groups alternate commits, so the header key protecting each direction always comes from the freshest secret both parties provably share.
  • Why the opposite group: the frame that carries a commit for epoch n+1 is itself encrypted at epoch n+1 — a receiver cannot derive anything from n+1‘s secrets before decrypting that very frame. The sender’s receive group is the freshest state the receiver is guaranteed to already have. (The previous epoch n of the same group would also satisfy the availability constraint, but it is strictly staler, and the cross-group derivation additionally ties the two directions’ metadata protection together, complementing the cross-party PSK.)
  • Receive side: trial decryption over a bounded window. The receiver keeps up to 3 epochs of expected header keys (EpochHistory, maxPrevousEpochs) plus the establishment-phase keys, and tries each against an incoming blob. There is no key id or epoch hint on the wire; the blob is fully oblivious. Frames that cross a commit in flight decrypt via the older window entries.
  • Establishment, first round (no shared group yet): every initiator frame is HPKE-sealed (Curve25519_SHA256_ChachaPoly) to the init key of the recipient’s KeyPackage (getHpkeInitKey — the Welcome-encryption key, not the leaf-node encryption key), with info = recipient ClientId. The sealed plaintext is the composed first frame: the app-layer welcome (AppWelcome.Combined) together with the MLS welcome — the app payload is a parameter of group creation, so the library envelopes the whole thing.
  • Establishment, second round: the joiner’s return frame (carrying Group_B’s welcome) is sealed under the symmetric exporter of Group_A at the epoch the joiner joined; the initiator pre-computed that key at creation and holds it in reconnectArchive until the return frame arrives (a classical multiMLS-Swift symbol — its name predates the split between establishment keys and the classical rejoin stash it also holds).

Relation to the classical stapling construction

Classical TwoMLS staples the commit into the AD of a proposal and that proposal into the AD of the app message, then header-encrypts the outermost message. Assessment:

  • What it bought: no bespoke frame format (everything on the wire is one MLSMessage), atomic delivery, and mix-and-match resistance — the AD chain binds app message ↔ proposal ↔ commit, and the receiver checks each link (the proposal’s AD must equal the commit bytes before the commit is applied; the app message’s AD must hash to the proposal digest). Because AD is covered by PrivateMessage authentication, each link is authenticated once its carrier is processed, and MLS independently authenticates the commit itself during processing — nothing unauthenticated is ever acted on; “unchecked” refers only to the parse that extracts the nested bytes.
  • What it costs: authenticated_data is plaintext in PrivateMessage, so the stapled messages are wire-visible metadata — stapling only works because the header layer hides it; parsing is a try-cascade over unauthenticated nested structure (uncheckedAuthData) with genuinely odd control flow; and the commit must still be applied before the app message riding with it can be decrypted, so a frame that fails late leaves the group advanced (the classical rejoin machinery — multiMLS-Swift’s in-band recovery, no PQ counterpart — exists largely to recover from this).
  • TwoMLSPQ replaced stapling with explicit length-prefixed tagged frames. The sender still writes the 32-byte proposal hash into the app message’s AD, but — unlike the Swift stack — nothing on the receive side of this crate reads it back: the message-frame handler applies the commit and surfaces the stapled proposal’s digest without comparing either against the app message’s AD, and the AD is not exposed across the FFI. Component-binding today rests on the digest CommProtocol binds inside the encrypted app payload, not on the AD. Header encryption incidentally restores frame-level splice resistance against network adversaries — the outer AEAD covers all sections of a frame as one unit — but peer-level mix-and-match hardening (checking the AD on receive, as classical does) remains a separate, worthwhile fix, orthogonal to this design.
  • Verdict: keep the frame format; do not import stapling. Tagged frames keep the atomicity, parse cleanly, and their one real downside — a recognizable plaintext container — is exactly what header encryption removes.

Design

Sealed frame

Every blob that leaves the library is one of:

SealedFrame   = [12-byte random nonce][AEAD ct+tag]   ; steady state (symmetric)
EnvelopeFrame = [kem_output][AEAD ct+tag]             ; establishment only (HPKE)

; the symmetric AEAD's plaintext (never on the wire in the clear):
Plaintext     = [u32-LE frame_len][frame][zero padding]   ; padding: frame_len..pad_to
  • The AEAD is a single choice for the whole header layer (the declared suite’s header-AEAD facet, providers::header_aead_suite(), ChaCha20-Poly1305 today), not inherited from the group whose exporter produced the key. Both families — message-path (classical exporter) and PQ side-band (PQ exporter) — seal with this one AEAD; the two-family split only chooses which group half derives the key. The key length (header_key_len = the AEAD’s aead_key_size) and the nonce length (aead_nonce_size) are both read from the chosen suite, so swapping the header AEAD is a one-line change with nothing downstream assuming a specific cipher or size. Empty AAD; the plaintext is a 4-byte little-endian length prefix, then the entire existing frame (tag byte included), then optional zero padding (see below).
    • Why not the group’s AEAD: the PQ suite’s AEAD is AES-128-GCM (128-bit key); sealing the side-band with the classical ChaCha20-Poly1305 (256-bit key) gives it the stronger primitive and better post-quantum headroom — matching the group’s own AEAD there would be a downgrade. The header AEAD is a build-level constant (both parties must agree; there is no negotiation), so a per-deployment change is a recompile, and runtime negotiation would be a separate protocol addition.
  • The HPKE envelope is the shipped §A.1 primitive: hpke_seal under the 0xFDEA suite to the PQ init key in the recipient’s published KP′, info = recipient ClientId. The two forms carry no discriminator; they never share a channel (envelopes travel only on the invitation channel, symmetric frames only on rendezvous addresses), so the receiver always knows which opener to use.
  • No version byte, tag, key id, or epoch hint outside the encryption. A sealed frame is indistinguishable from random to anyone without the session’s keys.

Frame length prefix & padding

The symmetric AEAD’s plaintext is [u32-LE frame_len][frame][zero padding]. The 4-byte little-endian prefix is the real frame length; try_open reads it and returns exactly that many bytes, so any trailing zero padding is stripped before a (trailing-byte-strict) decoder ever sees it. The prefix is unconditional — every symmetric-sealed frame carries it, message-path and side-band alike — so the wire grows a fixed 4 bytes per frame.

The padding is the optional half. A host declares a frame-sizing intent with set_pad_target(Some(n)): when set, seal_side_band grows each side-band frame up to min(n, last_message_frame_len) — the co-stapled message’s size, capped at a push-payload budget n — but only ever grows (a frame already larger, e.g. an A.3 Welcome’ or A.5 Commit’, keeps its natural size). Message frames and return welcomes (seal) carry the prefix but are never padded. With the intent set, an A.4/A.5 side-band frame and the message it is co-stapled with seal to the same length, so an on-path observer — who sees the two as separately-delivered, randomly-ordered payloads — cannot tell them apart by size. Absent the intent (None, the default), frames go out at their natural size. The motivating forward-looking use of the same knob is to chunk frames into fixed push-payload units.

Key schedule

Two key families, and the rule is each header key tracks the clock of the group its frame’s contents actually live in. The classical family keys from the classical halves and rotates with the classical epoch: it protects the message path and the two A.4 legs, whose inner MLS messages are themselves classical-group application messages. The PQ family keys from the PQ halves and rotates with pq_epoch: it protects the A.3 and A.5 frames, which carry PQ-group handshake material.

The split matters because the classical and PQ ratchets run on independent, asynchronous cadences — the classical ratchet is continuous (every message), the PQ side-band is a slower turn-based exchange, and the two synchronize only at the A.4 bind (partial PQ commit + the classical commit importing the exported PSK). An A.3/A.5 frame keyed by the classical epoch would have its outer-seal availability governed by classical message volume: a frame in flight could be overtaken by classical epoch advances unrelated to it and, past the classical retention window, become unopenable — and those frames cannot be re-minted. Keying them by pq_epoch decouples them; their small window covers any lag regardless of classical traffic. The A.4 legs accept that coupling precisely because they can be re-minted (see the rejected simplification below).

HeaderKey(G, e)   = exportSecret(label = "germ.network.twomlspq.headerKey.v1",
                                 context = group_id(G.classical),
                                 len = 32)  on G's classical half at epoch e

HeaderKeyPQ(G, e) = exportSecret(label = "germ.network.twomlspq.headerKey.pq.v1",
                                 context = group_id(G.pq),
                                 len = 32)  on G's PQ half at pq_epoch e
  • New labels, distinct from each other (insurance against any group-id coincidence), from the classical stack’s (germ.network.pairwiseKeyExport), the rendezvous exporter, and the PSK exporter — none of the derivations may collide.
  • Message-path keys are hybrid. Group_A’s classical key schedule absorbs the ML-KEM-derived APQ-PSK at creation (and again at every A.4 bind). Group_B is created classical-only pre-A.3, but its key schedule absorbs the cross-party TwoMLS-PSK exported from Group_A’s classical half — whose epoch secrets are already ML-KEM-seeded — so Group_B’s hybridness (and hence its header keys’) is transitive through that PSK until its own PQ half lands at the A.3 bootstrap. Either way, a quantum adversary who breaks X25519 alone cannot reconstruct the epoch secrets the exporters draw from.
  • The remaining PQ-family keys are PQ-only — a deliberate, consistent failure domain. No classical entropy ever enters the PQ groups (the A.1/A.4/A.5 PSKs are all ML-KEM-derived or PQ↔PQ), so HeaderKeyPQ lacks the classical half’s hybrid cover. An adversary who breaks ML-KEM already breaks the PQ groups those frames service; the marginal loss is side-band metadata (PQ group ids, epochs, activity). The protocol-level remedy — a reverse (classical→PQ) PSK injection at A.4/A.5 commits, hybridizing the PQ groups’ own key schedules — is noted as an open question, out of scope for the header layer. Note the scope shrank: the A.4 legs now key from the classical (hybrid) family, so what remains PQ-only is A.3 and A.5.
  • Rotation. Message-path keys — and the A.4 legs’ — refresh whenever the classical epoch advances (A.2 ratchet, rotation, A.4 bind). The A.3/A.5 keys refresh whenever pq_epoch advances — so an A.5 re-key immediately rotates the keys protecting subsequent A.3/A.5 metadata, rather than waiting for the next bind; its effect reaches the classical-family keys at the next A.4 bind, as elsewhere.
  • A direction that never commits keeps one header key indefinitely; with 12-byte random nonces the birthday margin (~2⁴⁸ frames per key) is far beyond any realistic per-epoch volume, so no mid-epoch rotation is needed.

Rejected simplification — one classical family for all side-band streams. Sealing every side-band frame under the classical family (the classical recv-group key is always available post-establishment, so it needs no second window or pre-A.3 fallback) is rejected because it couples the frame’s outer-seal availability to the async classical cadence: a frame in flight can be overtaken by classical epoch advances driven by unrelated message traffic and, once they exceed the classical retention window, become unopenable — a delivery-robustness dependency that shouldn’t exist. Keeping A.3/A.5 on the PQ family removes it there (those frames key by pq_epoch, which only PQ commits advance) and additionally gives them immediate PCS at A.5.

Why the A.4 legs are the exception. The argument above is about frames whose bytes are pinned — minted once and re-sent unchanged, so an epoch that moves underneath them is unrecoverable. That is no longer true of the legs: their inner MLS message rides the classical groups and is re-minted at the current epoch whenever that group moves (rewrap_side_band), so their availability window is the message path’s own — “routable ⟺ openable” — rather than an unbounded stall. They pay the coupling and get the hybrid header key the classical family had going for it. A.3/A.5 cannot be re-minted — a Welcome' and a Commit' are handshake messages, not payloads a sender can re-encrypt at will — so they keep the PQ family for exactly the original reason.

Send rule

  • Message-path frames (0x01 standalone welcomes and 0x03 message frames — encrypt’s output, welcome-or-commit staple included): seal under HeaderKey(recv_group, current classical epoch).
  • A.4 legs (0x17, 0x19): seal under the classical HeaderKey(recv_group, current classical epoch), with side-band padding still applied. Their inner MLS message rides the classical groups (see Wire Format), so keying the outer seal by pq_epoch would leave the two clocks unrelated — the seal has to track the same epoch the leg’s own re-wrap does. This also brings these two frames’ metadata under the hybrid classical key schedule, which is the trade-off in the rejected simplification below, taken deliberately here and only here. The family is chosen by the leg’s carrier, not its tag, so the one leg that is still PQ-carried — a CT retained across the upgrade from a build that carried the legs in the PQ groups — keeps the PQ family, exactly as the build that minted it sealed it. That keeps the rule above literally true of every frame.
  • Every other PQ side-band frame (0x13, 0x15, 0x1B, 0x1D): seal under HeaderKeyPQ(recv_group, current pq_epoch) — the opposite PQ group at its pq_epoch. These still carry PQ-group handshake material, and their availability must not couple to classical traffic. This covers them however they reach the host, all surfaced by pq_pending_outbound/pq_take_pending_outbound: the responder replies (pq_bootstrap_respond’s 0x15, pq_rekey_respond’s 0x1D) and the initiator frames the session auto-stages on a send — the A.5 Upd' (0x1B), plus the host-driven A.3 pq_bootstrap_begin KP (0x13). Leaving any of them plaintext would fingerprint every PQ exchange by its first frame. The round’s closing BIND is not a side-band frame: it rides the next message frame’s staple and is sealed with the rest of that frame under the classical HeaderKey (below), so it never lands in this slot.
    • Pre-A.3 fallback: the one side-band frame whose recv-PQ group doesn’t exist yet is the initiator’s BOOTSTRAP_KP — its recv-PQ (Group_B.pq) is the very group the bootstrap creates. seal_side_band falls back to the classical HeaderKey for exactly that frame (a one-time establishment frame whose cadence is irrelevant, and the classical recv group always exists); the receiver opens it from its classical window via the dual-window try_open. Every steady-state side-band frame has its recv-PQ live and uses HeaderKeyPQ.
  • Both keys are recomputed live (exporters work at the current epoch, which is exactly where the recv group sits); no send-side storage.
  • Pre-establishment (initiator between initiate and the return welcome): no frame is sealed here because there is no recv group and thus no symmetric key. The operations that could otherwise emit a frame are blocked: prepare_to_encrypt needs the recv group to stage its proposal, rotation is additionally gated on peer_confirmed (both from the wire-format rework), and the session-driven side-band cannot open a round without both PQ halves live (pq_halves_live), so no A.4/A.5 stages pre-establishment. The one thing the initiator does emit — its initial welcome — travels the invitation channel (below).
  • The acceptor always has a recv group from accept() onward, so every acceptor frame — including the first, whose staple slot carries APQWelcome_B — is symmetric, sealed under HeaderKey(Group_A, join epoch). The initiator opens it from its window: see below.
  • Seal timing: frames are sealed on exit — at the boundary where bytes leave the library — so the acceptor’s welcome rides raw in the message frame’s staple slot and the whole frame is sealed once (no double sealing). pending_outbound seals only when a recv group exists, so the initiator’s plaintext initial welcome passes through and the acceptor’s return welcome is sealed.

Receive rule

There are two receive windows, one per family (the peer seals under their recv group, which is my send group):

  • recv_header_keysHeaderKey(send_group, e) for each retained classical epoch of my send group. Captured live-at-epoch beside the rendezvous address in record_listen_rendezvous (exporters can’t be derived retroactively), same call sites (group creation, the A.2/rotation commits in prepare_to_encrypt, the A.4 bind, the should_listen_on/persist-encode backstops), same retention (the send-group storage probe). So for the message path, a frame that can still be routed can still be opened: this window is exactly the rendezvous listen window.
  • recv_header_keys_pqHeaderKeyPQ(send_group.pq, e) for recent pq_epochs of my send-PQ group. Captured by record_pq_header_key wherever the send-PQ group is created or its pq_epoch advances (initiate / pq_bootstrap_respond create it; pq_bootstrap_bind, pq_ratchet_bind, pq_rekey_respond, pq_rekey_apply advance it). This window has no rendezvous coupling — the PQ side-band keeps no routing addresses of its own (routing stays classical). Retention is a plain keep-newest PQ_HEADER_WINDOW = 4; A.3/A.5 are turn-based with one op in flight, so pq_epoch moves slowly and a few keys cover any lag regardless of classical traffic — which is the whole point (see rejected simplification).

open_incoming(blob) / the receivers’ open_or_raw trial-AEAD-open against both windows (classical first, then PQ), newest epoch first in each. The two windows partition the space between them, so a blob opens under exactly one key and there is no ambiguity — but the family no longer corroborates the frame’s kind: the classical window now opens message frames, the A.4 legs, and the pre-A.3 BOOTSTRAP_KP alike, and only the A.3/A.5 frames are PQ-keyed. Routing is the inner tag’s job (opened_frame_kind); the family is merely which key worked. Each trial is one ChaCha20-Poly1305 open — DoS cost is bounded and linear in the combined (small) window. On success it classifies the opened frame’s leading tag into OpenedFrameKind (Message for 0x01/0x03, PqSideBand { PqFrameKind } for 0x13–0x1D) and returns OpenedFrame { kind, frame }; the host routes frame by kind. On exhaustion it returns Ok(None) — “unknown, drop it”, which trial decryption makes literal: an out-of-window frame and garbage are indistinguishable, by construction. An opened-but-unrecognized tag is DecryptionFailed.

Convenience: process_incoming and the pq_* receivers transparently remove the seal if present (open_or_raw), so a host may pass the sealed blob straight through for the message path and skip the explicit open_incoming (it still needs open_incoming to route side-band frames). An already-opened frame passes through — it fails AEAD auth under every window key. This is a receiver convenience only; the metadata-hiding property is a sender guarantee (every outbound frame is sealed), so accepting an opened frame downgrades nothing an observer sees.

Observability caveat: desyncs that mls-rs would once have surfaced loudly can read as a silent None here; a host tracking liveness should treat a run of Nones on a live session as a signal to re-establish the session (recovery is out-of-session — none exists at this layer).

Frames that cross a commit in flight are covered by the window: if the peer sealed under my send group’s epoch n while my n → n+1 commit was in transit to them, the n entry still opens it (the same reasoning as the send_psk_ledger, and the reason the window must be ≥ 2 even in the happy path).

Establishment walkthrough

Alice initiates; Bob accepts (send groups per the Session Lifecycle; this matches Protocol Flows §A.1 orientation and the crate’s constructor names — Alice builds Group_A ≡ ASG).

  1. Alice initiate(client, their_kp) + set_initial_app_payload — builds Group_A; captures HeaderKey(Group_A, e₀) into her receive window (piggybacked on the existing record_listen_rendezvous call). It composes [app_payload ∥ APQWelcome_A] and HPKE-seals it to Bob’s KP′ inside the library, so pending_outbound() returns one opaque envelope — the first frame’s metadata, including the app-layer welcome that identifies the initiator, is hidden without the host having to compose the envelope itself. (The current_staple — the message-frame staple form the peer idempotently skips — keeps the plaintext APQWelcome_A; only pending_outbound is the envelope.)
  2. Bob’s host opens it with TwoMlsPqInvitation::open_initial(blob) -> OpenedInitial (the establishment variant carries { app_payload, welcome, … }; the invitation holds the KP′ private material; the call is decrypt-only and does not consume a single-use invitation). It validates the app-layer welcome and computes the spawn token over the decrypted frame — the token must be replay-stable across re-sends, and a re-sent envelope has a fresh HPKE ephemeral (different outer bytes, identical plaintext), which is exactly why open_initial returns the plaintext and the host keys the token on it. Then receive(welcome, their_classical_kp, bootstrap_kp_commitment, spawn_token) joins.
  3. Bob receive/accept — joins Group_A, builds Group_B classical; captures HeaderKey(Group_B, e₀) into his window. His send key is HeaderKey(Group_A, join epoch) — derivable immediately.
  4. Bob’s first frame — a message frame with APQWelcome_B in its staple slot, sealed under HeaderKey(Group_A, e₀). Alice’s window (from step 1) opens it; she joins Group_B; her send key becomes HeaderKey(Group_B, current). Both directions are now symmetric, and every subsequent frame — A.2 rounds, rotation, A.3 bootstrap (whose PQ Welcome rides a sealed side-band frame, no envelope of its own), A.4, A.5 — follows the steady-state rules.

Replays and re-sends: forward_group_id(spawn_token) remains a pure table lookup, and the content-keyed processed_welcome_group_id resolves a re-delivered welcome directly. A spent single-use invitation has lost the KP′ private material and can no longer open_initial a replayed envelope; hosts that need replay acknowledgment after consumption use last-resort invitations.

Direct accept() keeps its plaintext-welcome signature (a test/embedded entry point for callers that already hold a plaintext welcome); the normal path is initiate(…) (+ set_initial_app_payload) → TwoMlsPqInvitation::open_initialreceive.

Host routing and the API

Header encryption hides the leading tag byte, so the host cannot route a raw blob to the pq_* entry points by its first byte; the wire boundary sits one step in:

  • open_incoming(blob) -> Option<OpenedFrame { kind, frame }> — the session method: one trial-decrypt pass over the receive window, returning the plaintext frame plus its kind (OpenedFrameKind::Message for 0x01/0x03, PqSideBand { PqFrameKind } for 0x13–0x1D), or None if no window key opens it. The host routes frame by kind to process_incoming / pq_ratchet_* / pq_rekey_* / pq_bootstrap_*; those entry points keep their plaintext-frame signatures (and additionally auto-open a sealed blob, per the receive rule). forwarded(spawn_token) is untouched — it takes the token, not bytes.
  • Outbound is sealed inside the library at every exit: EncryptResult .cipher_text, pending_outbound() (the acceptor’s symmetric-sealed return welcome, the initiator’s HPKE envelope), pq_pending_outbound()/pq_take_pending_outbound() (the auto-staged A.4/A.5 frames and the responder replies), and the host-driven pq_bootstrap_begin return. The exported hpke_seal_to_key_package / hpke_open pair stays for other stacks; the main path uses initiate(…) / set_initial_app_payload / open_initial.
  • Archive: both receive windows (recv_header_keys, recv_header_keys_pq) ride in the session archive as parallel (epoch, key) lists, entries validated to 32 bytes on restore.

What this layer does and does not provide

Provides: metadata confidentiality (everything in the table above), unlinkability of stored ciphertexts across epochs and across the two directions, uniform-looking blobs, hybrid confidentiality for the metadata layer, whole-frame splice resistance against network adversaries, and — because the outer keys are symmetric and shared — the same deniability shape as the inner protocol.

Does not provide: timing obfuscation, or general traffic-analysis resistance beyond the opt-in side-band sizing. (Length is now partly addressed: with set_pad_target, a side-band frame pads up to its co-stapled message so the two are size-indistinguishable — see Frame length prefix & padding. It does not hide that a side-band round occurred, and the message stream itself is unpadded.) Also not provided: third-party-verifiable authenticity (either key-holder can forge the outer layer — by design; the inner MLS authentication is the arbiter); sender anonymity against the rendezvous server within an epoch (routing already reveals the channel); and protection of the very first envelope against a break of ML-KEM alone — see open questions.

Non-committing AEAD note: trial decryption with ChaCha20-Poly1305 across the window is safe here because every candidate key is honestly derived and secret; the partitioning-oracle failure mode requires attacker-chosen keys, which this scheme never has.

Implementation

  1. providers.rs: header_aead_suite() (the declared suite’s header-AEAD facet) beside pq_envelope_suite() — the CipherSuiteProvider whose aead_seal/aead_open/random_bytes/aead_key_size/aead_nonce_size back the seal.
  2. session.rs: header_key(group) and header_key_pq(pq_group) (length = header_key_len() = the header AEAD’s key size) beside rendezvous_secret; SessionInner::seal / seal_side_band (three cases: classical+padding for the A.4 legs, PQ for A.3/A.5, unpadded classical fallback for the pre-A.3 BOOTSTRAP_KP) / try_open (both windows) / open_or_raw; record_listen_rendezvous captures the classical header key into recv_header_keys, and record_pq_header_key captures the PQ header key into recv_header_keys_pq at each pq_epoch advance (initiate, pq_bootstrap_respond, pq_bootstrap_bind, pq_ratchet_bind, pq_rekey_respond, pq_rekey_apply); seal at every outbound exit (encrypt / pending_outbound classical; pq_bootstrap_begin and pq_pending_outbound/pq_take_pending_outbound via seal_side_band, whose seal_with carries the length prefix + optional pad_target padding); the session-driven maybe_stage_next_round auto-opens A.4/A.5 from encrypt; open_incoming with OpenedFrameKind; process_incoming and the pq_* receivers open_or_raw their input.
  3. First frame: set_initial_app_payload attaches the app-layer welcome, and the library HPKE-envelopes [app_payload ∥ APQWelcome_A] to the peer’s KP′ (key_packages::seal_initial_envelope), returning it via pending_outbound; TwoMlsPqInvitation::open_initial(blob) -> OpenedInitial opens it (decrypt-only; does not consume the invitation). current_staple keeps the plaintext welcome.
  4. Archive: recv_header_keys and recv_header_keys_pq as (epoch, key) entries, 32-byte validated on restore.
  5. Tests: sealed frames carry no plaintext framing; cross-commit crossing; restored session opens an in-flight frame (message and side-band); garbage → None; sealed side-band opens and classifies + full A.3/A.4/A.5 through sealed frames; an A.4 round survives classical churn that evicts the message window by re-wrapping (and its pre-churn bytes do not — the classical family’s accepted cost); the pre-A.3 BOOTSTRAP_KP opens via the classical fallback; the initial envelope round-trips through open_initial (app_payload + welcome), a re-send changes only the outer bytes, a spent invitation can’t open, and the initiator’s staple stays the plaintext welcome.

Open questions

  1. Hybrid envelope for the very first frame? The §A.1 envelope is PQ-only — the inverse of the classical stack’s X25519-only envelope. A nested seal (classical HPKE inside the PQ envelope, both init keys are already in the published pair) would make first-frame metadata survive a break of either KEM, for ~one X25519 op and ~100 bytes. The payload’s own secrecy is already hybrid at the MLS layer; this is purely about Welcome metadata (KeyPackageRefs, suites, and the app payload now inside the envelope). Recommended, but it changes the published-KP consumption contract, so it should ride the same release as the envelope’s first real adoption.
  2. Hybridizing the PQ groups (the remaining PQ-family trade-off): HeaderKeyPQ is ML-KEM-only, so it falls to an ML-KEM-only break where the classical HeaderKey would not — accepted, because an ML-KEM break already exposes those frames’ content, leaving only their metadata. A reverse (classical→PQ) PSK injection at the A.4/A.5 PQ commits would give the PQ groups’ key schedules — and hence HeaderKeyPQ — classical cover, closing the one non-hybrid derivation. It is protocol-level (changes commit contents on both sides), so it belongs to a revision of the APQ binding, not the header layer. Scope note: moving the A.4 legs onto the classical carrier already brought their metadata under the hybrid family, so what this question still covers is A.3 and A.5.
  3. Receive-side AD checking (from the stapling assessment): should the message-frame handler verify the app message’s AD against the stapled proposal’s digest (and, on a rotation commit, against the commit), restoring the classical stack’s peer-level mix-and-match checks? Orthogonal to header encryption but adjacent — deciding it in the same review avoids re-opening the frame contract twice.
  4. Padding. Out of scope here, but the uniform blob makes a future fixed-bucket padding scheme purely additive.

Protocol Flows

The authoritative TwoMLSPQ / APQ protocol design: session establishment, the classical ratchet, and the three PQ side-band operations, as message sequence diagrams. Appendix A gives the granular MLS operations behind each.

This design rides with its implementation on purpose. A protocol design that does not is drifting from it: the code cannot be reviewed against a spec in another repo, and a spec cannot fail a test. Here, a flow and the code that implements it change in one commit.

Where this chapter and the rest of the book overlap, the altitude differs rather than the content: Session Lifecycle is the API caller’s view of the same three side-band flows (which function to call, in what order), while this is the protocol they implement (which secret moves where, and why the epochs must line up). The Wire Format is what they travel in.

Sketch

APQ gives us a means for 2 parties to establish and evolve shared PQ cryptographic state, and to choose intervals to bind that PQ state to classical MLS group(s) that is the primary mechanism for a messaging session.

Unfortunately the full APQ commit as defined in the draft makes the full PQ commit, with a new leafNode, and updatePath, and a PQ cipher text, a prerequisite to decrypting appMessages in the corresponding classical group. This makes it impossible to implement the kinds of amortizing strategies that are currently deployed, if decrypting new messages is now contingent on first receiving a large PQ commit.

For parity with the current state of the art in hybrid 1:1 ratcheting sessions, we use APQ groups as our state machine, but separate the roles of

  1. efficiently attaining PQ PCS in the classical group
  2. updating our PQ group state

that the APQ full commit currently accomplishes.

In its place we have two PQ operations:

  1. A PQ ratchet

    1. Takes the following steps:
      1. The initiator (Alice) sends a PQ EK, as a dedicated side-band frame
      2. The respondent (Bob) picks a fresh random secret (S) and seals it to the EK — under a key bound to the KEM shared secret and a repeatable export of Alice’s PQ group at its current epoch — returning [enc][sealed S] as a dedicated side-band frame. (Sealing a random S rather than using the KEM output directly is what lets Alice open it: ML-KEM decapsulation returns garbage, not an error, for a ciphertext answering a different ephemeral, so only the AEAD tag over S can reject a stale or misdirected ciphertext before it is injected. S is then hybrid-secure — it holds if either ML-KEM or the epoch secret does.)
      3. Alice opens S (an explicit receipt; a stale ciphertext fails here with her ephemeral and PQ leaf intact), imports it as a PSK into her PQ group as a partial commit, and binds it to her classical group, advancing both halves’ epochs
        1. The corresponding classical commit imports a PSK from the PQ group, as the draft’s FULL commit would
        2. Since the PQ commit doesn’t have an update path, it is only encrypted with the previous group secret and not any PQ ciphertexts, so we can staple it to outgoing messages alongside the classical commit
        3. Alice can then discard the corresponding DK and S.
      4. On receipt of the stapled PQ and classical commits (one APQPrivateMessage staple advancing both groups’ epochs), Bob can apply it and then discard S
    2. An attacker cannot compute the new PQ or classical group state without having had access to either the DK, or the shared secret S, in the window between when they were generated and discarded
    3. On receipt of the stapled PQ partial commit, Bob knows that it is his turn to initiate a PQ operation.
  2. Re-key the PQ group

    We still need to regularly re-key the PQ group, on cadence, and to hand the PQ leaves to a credential the classical ratchet has already canonicalized (credential changes are proposed and approved in the classical ratchet — see the TwoMLS AS note below; the PQ re-key only catches the PQ leaves up):

    1. The initiator (Alice) sends a proposal to update her leafNode in the PQ half of the respondent’s (Bob’s) send group — the proposal replaces the proposer’s leaf
    2. On receipt, Bob makes a full commit in his send group — the one large updatePath commit of the round, which also replaces the committer’s leaf (this is where Bob’s own leaf catches up to a rotated credential)
      1. Like in TwoMLS, this full commit injects a PSK from the opposite send group.
    3. On receipt of Bob’s PQ commit, Alice applies it and acks: a pathless partial commit on her own send group importing a secret exported from the just-rekeyed group, bound to her classical group exactly as the PQ ratchet’s bind is — it rides her next classical commit as the staple. Deriving the secret requires having applied Bob’s commit, so an ack that applies at all is the receipt.
    4. On receipt of the stapled ack, Bob is now the initiator

    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.

  1. Session establishment
    1. Bob posts an APQ keyPackage
    2. Alice forms an APQ group (as her send group), sends the APQ Welcome
    3. Bob derives his send group from the classical half of Alice’s send group, with a PSK, inheriting the PQ initiation
      1. Bob’s send group starts with only its classical half — he doesn’t yet create the PQ half — so that he can immediately send app messages from his send group, with stapled classical keys.

Now we have two independent state machines.

  1. Classical Ratchet
    1. The classical ratchet proceeds exactly as in TwoMLS, exchanging rounds of AppMessage + Proposal + Commit, all stapled together
    2. A sender commits to its send group only when it is licensed to (see Evidence-gating below), and only when it has something to commit: a peer proposal its app has approved (queue_proposal), or an owed PQ bind to discharge. Until then each frame re-staples the latest commit, and app messages keep flowing in the current epoch
    3. Such a commit also re-injects a cross-party PSK exported from the sender’s receive group (the TwoMLS binding), but only when that group has advanced since the sender last bound it — i.e. when the peer has produced new entropy to entangle with (a peer commit or a PQ ratchet). Re-binding an unadvanced peer epoch would add nothing, so a commit with no new peer entropy carries no cross-party PSK (it still refreshes the sender’s own leaf via the updatePath, and folds the peer’s Update if it carries one). This is what keeps the two send groups entangled with each other’s current state, rather than re-stating a binding already in force.
    4. Credential rotation rides this same ratchet (the TwoMLS AS): staged candidate credentials travel in the stapled Update proposals, the peer’s approval of a proposal is the authorization, and the peer’s commit canonicalizes the winner — the PQ leaves only catch up later (A.3/A.5)

Evidence-gating: at most one commit outstanding, per direction

A sender may only commit once the peer has demonstrably applied its previous commit. Each send group is a single-writer channel — only its owner ever commits in it — so this is a per-direction rule, and it is the invariant two other properties silently rest on:

  • Any single frame heals the peer. Every frame re-staples the sender’s latest commit, which bridges a peer that is at most one commit behind. A sender that could commit twice while the peer was away would produce a staple nothing bridges — an unrecoverable EpochDesync in ordinary lossy messaging, rather than the re-establish-only edge it is.
  • A bind’s staple provably survives until applied. A bind’s PQ half exists on the wire only as the current staple (§A.3–A.5); a superseded staple never re-sends, and by then owed_bind is consumed and the PQ exporter leaf is spent. If a sender could commit past an unapplied bind, the peer’s recv-PQ mirror would permanently lose an epoch the sender’s send-PQ has advanced past — and no classical reconnect repairs a PQ group.

The evidence is the peer’s stapled proposal, and it is in-protocol. The peer builds its Upd(self) in its recv group, which is our send group, so the proposal is bound to our send group’s epoch: an offer bound to our current epoch could only have been produced by a party that had applied our commits through it. Our own commit invalidates any offer still in flight (it was built for the prior epoch), and the peer re-proposes at the new epoch on its next frame — so the license is re-earned exactly once per round trip. (The peer’s commit proves the same fact, but any frame carrying their commit also carries their proposal at our epoch — the frame is [staple][proposal][app], all sections mandatory — so proposal-evidence strictly contains commit-evidence, and it also arrives on the frames where they don’t commit.)

The license is not approval, but it is authenticated. It accrues at receive, independent of queue_proposal: an offer the app never approves, is slow to approve, or whose credential the AS would refuse still licenses the discharge — approval is the app’s to withhold (it is the AS authorization step, and folding stays gated on it), but the license is not the app’s to stall, so a bug or delay in approving a remote proposal can never block a bind’s classical commit. What the license does require is that the offer validate against our send group (the same validate_offered_update a fold runs, applied here unconditionally): the epoch field of raw proposal bytes is unsigned, so trusting it would let a malicious peer splice a higher epoch and forge the license into discharging a bind the peer has not applied. A valid offer proves exactly our current send epoch, and the watermark is stamped to that.

This was implicit for as long as folding an approved proposal was the only way to commit: the fold IS the evidence, since validate_offered_update runs the offer through mls-rs against the live send group and a stale-epoch offer is refused there. Committing without a fold (to discharge an owed bind) needs the same license, so the watermark is now tracked explicitly rather than inferred from the fold.

Where each ratchet advances. TwoMLS is a state machine advanced by sending and processing messages: the PQ ratchet advances when a PQ step is processed, but the classical ratchet advances only at prepare_to_encrypt — the host’s own next send. So the library never commits classically behind the app: a PQ trigger leaves an owed bind, and the discharge (fold or licensed proposal-less commit) rides whatever round the host starts. There is deliberately no third reason to commit — no commit on cadence merely because the license is present — since every commit of ours invalidates the peer’s in-flight offer, and committing every licensed round would churn offers inside the window the peer’s app has to approve them. A host that wants leaf-refresh PCS faster than its PQ cadence should run the PQ ratchet faster; the bind carries both PCS sources.

Why the proposal and not the PSK. The peer’s commit that cross-injects a PSK exported from our send group at epoch E is also proof it applied E — it cannot export from a mirror it has not advanced. It is not used as the license because it rides commits only, and both directions would then gate on each other: two sides that commit concurrently (neither having seen the other’s) each hold an unapplied commit and neither can produce the evidence that would release the other. The proposal rides every frame, commit or not, so the license cannot deadlock. (The injection remains a useful check, and the header-key application receipt — deleted with the retirement machinery — was the weaker version of the same idea: it proved transport-window position where the proposal proves MLS state incorporation.)

Independently, we have an exchange of large PQ key messages, carried as dedicated side-band frames alongside the classical ratchet. The state flip-flops when each direction has finished receiving a message from the other.

  1. PQ operations

    1. At the end of session establishment, Alice and Bob now exchange (large) PQ messages independently of the classical ratchet
    2. Alice and Bob take turns initiating PQ operations. Alice is first, and makes a variation of PQ re-keying to bootstrap Bob’s group:
      1. (In place of a proposal) Alice sends a PQ keyPackage to Bob
      2. (In place of a commit) Bob constructs the PQ half of his send group from it and replies with a Welcome (for that group)
      3. Alice joins via the Welcome and closes the round with a bind, exactly as the PQ ratchet’s — the only difference is where S comes from (a group exporter off the joined group’s birth epoch rather than a KEM exchange). The bind rides her next classical commit as the staple; Bob takes the turn on applying it

    (Bob’s dedicated principal is selected at session establishment, not here. Alice started with a principal she generated to talk to Bob’s invitation principal; Bob accepts under a principal dedicated to Alice — his send group is created directly under it, and Alice adopts it when she joins his group. The PQ bootstrap and re-key only carry already-canonical credentials onto the PQ leaves.)

Session establishment

sequenceDiagram
    autonumber
    participant Alice
    participant KeyDir as Key Directory
    participant Bob

    Note over Alice,KeyDir: Alice registration
    Alice->>KeyDir: Posts an APQ keyPackage + delegate key

    Note over Bob,KeyDir: Bob registration
    Bob->>KeyDir: Posts an APQ keyPackage + delegate key

    Note over Alice,KeyDir: Alice connects to Bob
    Alice->>KeyDir: Queries for Bob's delegate key + keyPackages
    KeyDir->>Alice: Returns Bob's delegate key + keyPackages (incl. the APQ keyPackage)

    Alice->>Alice: Creates an APQ group from Bob's APQ keyPackage = Alice's send group
    Alice->>Bob: One HPKE envelope [ app payload ∥ APQ Welcome (Alice's send group) ],<br/>sealed to the PQ EK in Bob's APQ keyPackage. The signed app payload carries the welcome,<br/>Alice's CLASSICAL return keyPackage (all Bob needs — his send group starts classical-only),<br/>and a hash of Alice's PQ keyPackage (which travels later, in the PQ bootstrap side-band)

    Alice->>Bob: App messages in Alice's send group — each a fresh HPKE envelope to Bob's keyPackage (as the initial frame),<br/>re-stapling the APQ Welcome — any single one lets Bob join and read it. Once Alice joins Bob's send group she<br/>header-seals instead, still re-stapling the Welcome until her first commit supersedes it

    Note over Bob: Process Welcome
    Bob->>Bob: Processes the Welcome to produce a classical group
    Bob->>Bob: Forms his send group with a key exported from the classical half of Alice's send group (as a PSK),<br/>created directly under an Alice-dedicated principal (handing off from his invitation principal)
    Note over Alice,Bob: Bob's send group has no PQ half yet, so he can immediately send app messages.<br/>Alice adopts Bob's dedicated principal when she joins his send group.

At this point Alice’s send group is a full APQ group. Bob’s send group has only its classical half, but this session establishment is protected with the PQ key Bob posted, and the PQ leaf node key Alice sent within her APQ welcome. Because we exported keys from the PQ half of Alice’s send group to its classical half, and then to Bob’s classical half, Bob’s send group is protected by the PQ key exchange. Bob can then immediately send messages to Alice.

At this point we assume a slow, failable bidirectional channel for Alice and Bob to exchange large payloads, carried as dedicated PQ side-band frames alongside the app messages on the send groups’ classical halves. Every steady-state frame — message-path and side-band alike — leaves the library header-encrypted (sealed under a key exported from the receiving group), so frame metadata is not visible on the wire. In the pre-establishment window, until a party has a receiving group to export from, its app messages ride HPKE envelopes sealed to the peer’s keyPackage (as the initial frame), not header-encrypted frames.

Classical Ratchet

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: Alice round
    Alice->>Bob: App messages (Alice's send group) + stapled update Proposal for Alice in Bob's send group + the latest Commit to Alice's send group (re-stapled until superseded)
    Bob->>Bob: On receipt, applies the Commit to Alice's send group (if new), decrypts the app message, and surfaces the Proposal for app approval (queue_proposal)

    Note over Alice,Bob: Bob round
    Bob->>Bob: When his app has approved a Proposal, commits it to Bob's send group — re-injecting a cross-party PSK from Alice's send group only if it has advanced since he last bound it — then encrypts in the new epoch. With nothing approved he sends without committing.
    Bob->>Alice: Bob's AppMessage + stapled Commit + stapled Proposal for Alice's send group

Finish PQ Setup

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: Bootstrap the PQ half of Bob's send group<br/>(Alice initiates — a variation of PQ re-keying)
    Alice-)Bob: (in place of a Proposal) PQ keyPackage for Alice, as a side-band frame.<br/>The keyPackage must name the established peer.
    Bob-)Bob: Constructs the PQ half of his send group from Alice's keyPackage,<br/>under his current — already dedicated — principal (no credential change here)
    Bob-)Alice: (in place of a Commit) Welcome (for that PQ half), as a side-band frame
    Alice-)Alice: Joins the PQ half of Bob's send group via the Welcome
    Alice-)Bob: Closes the round with a bind, exactly as the PQ ratchet's — S comes from a group exporter<br/>off the joined group's birth epoch rather than a KEM exchange. The bind rides her next<br/>classical COMMIT as the APQPrivateMessage staple (granular detail in A.3)
    Bob-)Bob: Derives the same S, applies both halves from the staple → Bob is now the initiator
    Note over Alice,Bob: The turn flips when Bob APPLIES the stapled bind — the initiator relinquishes at its<br/>terminal send, the responder takes the turn on applying it. Bob's dedicated principal<br/>was selected at session establishment (see above), not in this step.

PQ Ratchet

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: PQ ratchet (Alice is the initiator this round)
    Alice-)Bob: PQ EK (encapsulation key), as a side-band frame
    Bob-)Bob: Picks a fresh random secret S and SEALS it to the EK — under a key bound to the KEM shared<br/>secret and a repeatable export of Alice's PQ group at its current epoch → [enc][sealed S]
    Bob-)Alice: [enc][sealed S], as a side-band frame
    Alice-)Alice: Opens S — the AEAD tag is the receipt (a stale or misdirected ciphertext fails here, her ephemeral<br/>and PQ leaf intact) — imports S as a PSK → partial commit in her PQ group (pq epoch advances)
    Alice-)Alice: Discards the DK and S — folded in, so nothing waits holding it
    Alice-)Alice: (at her next classical COMMIT) classical commit imports a PSK from the PQ group<br/>(no update path, so no PQ ciphertext — staple-able)
    Alice-)Bob: Ordinary message frame, stapling an APQPrivateMessage: the PQ partial commit + the classical commit
    Bob-)Bob: Applies both halves from the staple, then discards S
    Note over Alice,Bob: Bob now knows it is his turn to initiate the next PQ operation

Re-key the PQ Group

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: PQ re-key (run on cadence, or to carry a credential the classical ratchet
    Note over Alice,Bob: has already canonicalized onto the PQ leaves).
    Note over Alice,Bob: The round's ONE large updatePath commit runs in isolation on the PQ group,
    Note over Alice,Bob: so the classical ratchet is not blocked
    Alice-)Bob: Proposal to update Alice's leafNode in the PQ half of Bob's send group, as a side-band frame
    Bob-)Bob: Full Commit in the PQ half of Bob's send group — the round's one updatePath commit, which<br/>also replaces Bob's own leaf (injects a PSK from the opposite send group's PQ half only<br/>if it has advanced since he last bound it)
    Bob-)Alice: Bob's Commit, as a side-band frame — re-sent until the ack answers it
    Alice-)Alice: Applies Bob's Commit, then acks: a pathless partial commit in the PQ half of her own send group,<br/>importing a secret exported from the just-rekeyed group at its NEW epoch (deriving it proves the apply)
    Alice-)Bob: The ack rides Alice's next classical COMMIT as the APQPrivateMessage staple — not a side-band frame
    Bob-)Bob: Applies both halves from the staple → Bob is now the initiator
    Note over Alice,Bob: One round re-keys ONE group — the turn alternation brings the other group's round next.<br/>The turn flips when Bob applies the stapled ack.

Appendix A — Granular MLS Operations

The diagrams above treat each send group as one black box. In reality, Alice and Bob each operate a send group (the group they alone commit to) and a receive group (the other party’s send group, into which they only propose). A 1:1 TwoMLSPQ group therefore comprises two send groups:

  • ASG — Alice’s send group (Alice commits, Bob is a member)
  • BSG — Bob’s send group (Bob commits, Alice is a member)

Each send group is implemented as an APQ group (draft-ietf-mls-combiner-02) — not a single MLS group but two parallel RFC 9420 MLS groups with synchronized membership —

  • a PQ MLS group (PQ-KEM / PQ-DSA ciphersuite), and
  • a classical MLS group (traditional ciphersuite).

The two halves are bound by exporting a secret from the PQ group and importing it into the classical group as a PreSharedKey proposal.

Implemented binding (conforms to draft -02). The apq_psk follows the draft’s Safe Extensions recipe (draft-ietf-mls-extensions-08 §4.4) and is imported as an application(3) PSK:

apq_exporter = SafeExportSecret(component_id = 0xFF01)   # consumed leaf of the PQ epoch's exporter tree
apq_psk_id   = DeriveSecret(apq_exporter, "psk_id")
apq_psk      = DeriveSecret(apq_exporter, "psk")

The draft’s bookkeeping rides with it: an APQInfo GroupContext extension (type 0xF0A1) in both halves, and an AppDataUpdate proposal (type 0x0008) in both commits of every FULL commit attesting the post-commit (t_epoch, pq_epoch) pair. PSK Binding has the full recipe, group rules rule 7 the verification, and the Wire Format chapter the draft-02 conformance. Two deliberate deviations remain: APQInfo is written once at creation and never rewritten — epoch freshness lives in the per-commit AppDataUpdate, not a rewritten extension — and the A.4 injected secret S is Germ’s own addition, an external(1) PSK with id LE-u64(epoch) ‖ group_id ‖ 0x52, kept disjoint from the exported application ids.

A full session is thus four MLS groups: ASG-PQ, ASG-cl, BSG-PQ, BSG-cl.

Notation (following the combiner draft):

  • a trailing ' marks an object in the PQ MLS group (Commit', Welcome', Add', Upd', KP')
  • an object without ' is in the classical MLS group
  • PSK=apq_psk is the PreSharedKey proposal (psk_id=apq_psk_id) carrying the secret exported from the paired PQ group
  • bracket tags such as [ASG-cl] or [BSG-PQ] name the MLS group an operation runs in
  • the crate’s demo and tests share this orientation: the initiator is “Alice” and builds Group_A (≡ ASG), the acceptor is “Bob” and builds Group_B (≡ BSG)

Commit vocabulary. “Full/partial” is an MLS (RFC 9420) distinction, and this doc uses it only in that sense: a full commit carries an updatePath (fresh leaf — the PCS contribution); a partial commit is path-less. Draft -02 overloads the same words for a different axis — its FULL commit is the composite PQ-commit + paired-classical-commit operation (mandatory for Add/Remove), its PARTIAL commit a classical-only commit — but this design decomposes the draft’s FULL commit and does not use that pair. Our three operations, by name:

  • Classical-ratchet commit (A.2) — classical-only. Folds the peer’s approved Upd (so it carries an updatePath) and re-injects the cross-party TwoMLS PSK when the peer’s send group has advanced since the last binding (otherwise it carries no PSK — see §Classical Ratchet). In -02’s terms, a PARTIAL commit.
  • PQ ratchet bind (A.4) — both halves advance: a path-less PSK-injection Commit’ (cheap — no per-member PQ ciphertexts) plus the classical commit importing the re-exported apq_psk, carried together as the -02 APQPrivateMessage the message frame staples. Introduces PQ Post-Compromise Security.
  • PQ re-key (A.5) — ONE updatePath Commit’ in the PQ group alone (the expensive leaf rotation, run rarely, off the classical ratchet’s critical path), answered by the initiator’s ack — which is structurally the A.4 bind: a path-less partial Commit’ plus its classical partner, stapled as one APQPrivateMessage. One round re-keys one group; the turn alternation covers the other.

What -02 calls a FULL commit is, here, the bind — which closes every round (A.3, A.4 and A.5 alike); the re-key’s standalone updatePath Commit’ is our extension to the draft.

A.1 Session establishment (granular)

sequenceDiagram
    autonumber
    participant Alice
    participant KeyDir as Key Directory
    participant Bob

    Alice->>KeyDir: Publish APQKeyPackage = { KP' (PQ), KP (classical) } + delegate key
    Bob->>KeyDir: Publish APQKeyPackage = { KP' (PQ), KP (classical) } + delegate key

    Note over Alice,KeyDir: Alice connects to Bob
    Alice->>KeyDir: Fetch Bob's delegate key + APQ keyPackage
    KeyDir->>Alice: Bob's KP' (PQ) + KP (classical)

    Note over Alice: Build Alice's send group (ASG) as an APQ group
    Alice->>Alice: [ASG-PQ] Add'(Bob KP') + Commit' → ASG-PQ epoch 1
    Alice->>Alice: Export apq_psk from ASG-PQ (Safe Extensions, component 0xFF01)
    Alice->>Alice: [ASG-cl] Add(Bob KP) + PSK=apq_psk + Commit → ASG-cl epoch 1 (PQ-seeded)

    Alice->>Bob: One HPKE envelope [ app payload ∥ APQ Welcome = { Welcome' [ASG-PQ], Welcome(PSK) [ASG-cl] } ],<br/>sealed to the PQ EK in Bob's KP'. The signed app payload carries: the welcome pair, Alice's CLASSICAL<br/>return KP (for Bob's Add(Alice) into BSG-cl), and H(Alice's KP') — the PQ keyPackage itself travels<br/>in A.3 and must verify against this signed hash
    Alice->>Bob: App messages [ASG-cl] — each re-wrapped in a fresh HPKE envelope to Bob's KP' (as the initial frame above),<br/>re-stapling the APQ Welcome — any single one is a complete establishment vector (Bob can join and read it),<br/>so the initial frame need not survive. Alice has no receiving group to header-seal against until she joins BSG-cl —<br/>after that she header-seals, re-stapling the Welcome until her first commit
    Alice-->>Bob: (parallel) A.3 bootstrap KP frame [0x13][KP'] in its OWN fresh HPKE envelope, coin-flipped onto the outbox with the reply.<br/>Same raw-blob outer shape as the reply (no outer tag) — Bob holds it in memory until establishment, then feeds pq_bootstrap_respond (A.3)

    Note over Bob: Join Alice's send group (both halves)
    Bob->>Bob: Process Welcome' → join ASG-PQ, then Welcome(PSK) → join ASG-cl

    Note over Bob: Create Bob's send group (classical only, for now)
    Bob->>Bob: Mint an Alice-dedicated principal (handing off from the invitation principal)
    Bob->>Bob: Export key from ASG-cl
    Bob->>Bob: [BSG-cl] Create group + Add(Alice) seeded with PSK from ASG-cl → BSG-cl epoch 1,<br/>created under the dedicated principal
    Bob->>Bob: Mint the signed establishment handoff (identity/anchor delegates the dedicated key,<br/>binding sha256(Welcome)) and install it → staple becomes [0x0B][blob][Welcome]. Non-emittable until installed.
    Note over Alice,Bob: BSG-PQ is deferred so Bob can send app messages immediately (see A.3).<br/>Alice PAUSES on the 0x0B staple, verifies the delegation, then re-feeds to adopt Bob's dedicated principal.

The return key package is classical-only; the PQ key package travels in A.3, hash-bound. Bob’s send group is created classical-only (BSG-PQ is deferred to A.3), so the establishment envelope needs to carry only Alice’s classical return key package. Alice’s PQ key package for BSG-PQ is delivered where it is consumed — A.3’s first side-band leg — and the signed app payload binds it in advance: it carries a hash of the PQ key package, and Bob verifies the key package A.3 delivers against that hash before constructing BSG-PQ around it. The binding is what roots the PQ leaf in the signed identity envelope: the side-band channel is confidential to the established peer, but a bare MLS key package message carries no anchor signature of its own.

The implementation matches: initiate pre-commits the bootstrap key package (bootstrap_kp_commitment() exposes the hash for the host’s signed payload), receive/accept take the classical return KP plus the commitment, pq_bootstrap_begin sends the retained pre-committed KP — never a fresh mint — and pq_bootstrap_respond rejects a KP′ hashing to anything else (BootstrapKpMismatch). When the commitment is pinned it REPLACES the names-the-established-peer equality: it is strictly stronger (it pins the exact committed bytes, identity included), and unlike the live-principal check it still admits the committed KP after a Phase 8 rotation that completed before A.3 ran — the KP′ then carries the establishment credential, and A.5 hands the PQ leaves to the rotated one (PQ leaves lag credentials by design).

One envelope, two shapes (either/or). The “∥” above is not concatenation. A host app payload must be establishment-self-sufficient — it carries the welcome and the return key package inside the signed envelope — and when one is present the composer omits the bare welcome/return_key_package sections; the bare shape (no app payload) exists for hosts without an identity envelope. Exactly one of the two shapes is on the wire. Note also that step 8’s “app payload” is that signed identity envelope; the “app messages” of step 9 are the ordinary application ciphertexts stapled to each pre-establishment frame — different things.

Born-dedicated credential delegation (contract 26). Bob’s send group is created under a freshly-minted dedicated principal (step 7 above), so the credential Alice reads out of its creator leaf is one she initiated no exchange toward — unlike every other credential the receive path admits, nothing has signed a delegation to it. The cross-party PSK weld proves the group was made by the invitation holder; it does not prove the identity delegated the dedicated key. That delegation rides the establishment wire as a signed handoff: Bob’s staple becomes ESTABLISHMENT_HANDOFF_TAG (0x0B) — [0x0B][len][signed blob][len][APQWelcome_A] — wrapping the unmodified, spec-conformant welcome next to a host-minted artifact whose signatures bind sha256(welcome) (card: the identity’s IdentityDelegate + the dedicated agent’s proof-of-possession; anchor: the invitation/retired agent + anchor + new agent). The crate treats the blob as opaque — the app mints and verifies it.

The credential-differ rule keys everything off the leaf, not the parameter: receive mints the dedicated principal only when new_client_id differs from the invitation identity (an equal id degenerates to the nil topology, staple unchanged), and a session that owes a delegation is non-emittable at every door until install_establishment_envelope supplies it. On the initiator, a 0x0B staple pauses process_incoming (DecryptResult.pending_establishment — a pure parse: nothing joined, consumed, or persisted) so the app can verify the artifact against the surfaced welcome, then completes via the stateless re-feed process_incoming_approved, which pins the exact (envelope, welcome) pair by digest and requires the joined creator leaf to equal the id the verified delegation names. A bare welcome whose creator differs from the invitation identity is refused at the join (EstablishmentEnvelopeRequired) — a born-dedicated establishment must arrive enveloped, so the un-enveloped form can never smuggle an undelegated credential in on the weld alone. The envelope structurally outlives the initiator’s join: Bob’s staple is replaced only by his first fold, and no fold can occur before Alice joins (nothing has driven a commit), so a dropped early frame heals off any later one.

Envelope framing & parallel KP′ delivery. The §A.1 envelope is a RAW HPKE blob with no outer tag[u32-LE kem_output_len][kem_output][ciphertext] — because the invitation channel already routes it to the HPKE opener and an outer tag would only fingerprint which frames carry PQ material. The HPKE plaintext LEADS with an authenticated tag that selects the frame kind: ESTABLISHMENT_VECTOR_TAG (0x07) for the reply’s four sections ([app_payload][welcome][return_key_package][stapled_message]), or PQ_BOOTSTRAP_KP_TAG (0x13) for the verbatim A.3 bootstrap KP. This is the same discipline the tag space follows throughout — transport limits which keys decrypt, then the inner authenticated tag guides parsing (like the 0x03 message frame’s 0x01/0x00 staple slot). Because the KP bytes are pre-committed at initiate (pq_bootstrap_envelope), the initiator ships that KP frame IN PARALLEL with the reply — its own fresh HPKE blob, coin-flipped onto the outbox — so an acceptor already holding the KP′ when its return welcome goes out sends Welcome' alongside it and A.3 completes ~one round trip sooner. The first emit registers the A.3 round exactly as pq_bootstrap_begin does; every later pre-establishment send re-seals the retained frame under a fresh HPKE ephemeral (unlinkable) without advancing state. The acceptor holds an early-arriving KP frame in memory, unarchived, and applies it only after the AppWelcome verifies and the hash matches — the apply gate is structural (pq_bootstrap_respond cannot run before receive creates the session). See A.3.

The envelope seal binds the declared suite. Every §A.1 HPKE seal/open passes the declared suite’s framing bytes — [version][classical suite][pq suite] — as AAD, derived locally on both sides and never transmitted: the sender from its build, the receiver from its invitation (whose posted keyPackage publicly named the pair in the first place). A peer declaring a different pair or framing version fails the AEAD tag, so the classical half — which the HPKE operation alone never touches — is downgrade-bound into establishment at zero wire bytes. See wire format and cipher suites.

A.2 Classical ratchet (granular) — classical-only commits

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: App data + leaf updates ride the classical groups only.<br/>PQ groups stay idle — PQ security is retained from the last PQ ratchet bind (A.4) / PQ re-key (A.5).<br/>Credential rotation rides these same Upd proposals (TwoMLS AS): a staged candidate's<br/>credential travels in Upd(self) — the peer's approval authorizes it and the peer's commit canonicalizes it.

    Note over Alice,Bob: Alice round
    Alice->>Alice: [ASG-cl] If an app-approved Upd(Bob) is queued: Commit(Upd(Bob) [+ PSK from BSG-cl iff BSG-cl advanced]) → new epoch.<br/>Otherwise no commit — the previous Commit is re-stapled.
    Alice->>Bob: AppMessage [ASG-cl] + stapled Upd(Alice) proposal for [BSG-cl] + stapled latest Commit [ASG-cl]
    Bob->>Bob: Apply Commit to [ASG-cl] (if new), decrypt app msg, surface Upd(Alice) for approval (queue_proposal)

    Note over Alice,Bob: Bob round
    Bob->>Bob: [BSG-cl] Commit(approved Upd(Alice) [+ PSK from ASG-cl iff ASG-cl advanced]) → new epoch (or no commit if nothing approved)
    Bob->>Alice: AppMessage [BSG-cl] + stapled latest Commit [BSG-cl] + stapled Upd(Bob) proposal for [ASG-cl]
    Alice->>Alice: Apply Commit to [BSG-cl] (if new), decrypt app msg, surface Upd(Bob) for approval

A.3 Finish PQ setup (granular) — bootstrap BSG-PQ (Alice initiates)

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: BSG is classical-only after establishment. Create BSG-PQ via a<br/>variation of PQ re-key. Alice goes first.

    Alice-)Bob: PQ keyPackage KP'(Alice) for BSG-PQ, side-band frame  (in place of a Proposal).<br/>KP' must name the established peer, else the bootstrap is rejected.
    Bob-)Bob: [BSG-PQ] Create group + Add'(KP' Alice) + Commit',<br/>under Bob's current — already dedicated — principal
    Bob-)Alice: Welcome' [BSG-PQ], side-band frame  (in place of a Commit)
    Alice-)Alice: Join [BSG-PQ] via Welcome'

    Note over Alice,Bob: Alice closes the round with a bind, exactly as in A.4 — the only<br/>difference is where S comes from: a group exporter rather than a KEM exchange.

    Alice-)Alice: Export S from the birth epoch of [BSG-PQ] (cross-party domain)
    Alice-)Alice: [ASG-PQ] PSK=S + Commit' (no updatePath — PARTIAL) → pq_epoch++

    Note over Alice,Bob: Half-committed, exactly as in A.4 — and it matters more here, since<br/>the trigger is INBOUND: a welcome arrived, which says nothing about whether Alice<br/>has anything to send. Committing classically on arrival would advance her epoch<br/>with no frame to carry the bind across.

    Alice-)Alice: (at the next classical COMMIT) export apq_psk from the reserved ASG-PQ epoch
    Alice-)Alice: [ASG-cl] PSK=apq_psk + Commit, folding Bob's approved Upd → classical epoch++
    Alice-)Bob: Ordinary message frame — staple = APQPrivateMessage (t_message = the classical Commit, pq_message = Commit'), plus Upd(Alice) + app
    Bob-)Bob: Derive the same S from [BSG-PQ] — same epoch, same domain — then from the staple<br/>apply pq_message to [ASG-PQ], then t_message to [ASG-cl]
    Note over Alice,Bob: Both send groups are now full APQ groups. Bob's dedicated principal was<br/>adopted at establishment (A.1) — if a rotation has since been canonicalized in the classical<br/>ratchet (A.2), the new PQ leaves simply carry the current credential (catch-up). BSG-PQ binds<br/>into BSG-cl at the next PQ ratchet (A.4, run by Bob on his send group) — classical never blocks<br/>on PQ, so this defers freshness, not liveness. Turn flips — Bob is now the initiator.

Parallel pre-delivery. The KP′ of step 1 is the one pre-committed at establishment (initiate mints it; A.1’s signed payload carries its hash), so it need not wait for the round to open post-establishment. The initiator ships it IN PARALLEL with the A.1 reply, in its own fresh HPKE envelope (pq_bootstrap_envelope) — same raw-blob outer shape as the reply, told apart by the inner 0x13 tag. An acceptor that already holds the KP′ when its return welcome goes out runs step 2 immediately and sends Welcome' (step 3) alongside the return welcome, so A.3 completes ~one round trip sooner. Emitting the parallel frame registers the round on the initiator (so pq_bootstrap_begin becomes idempotent — it re-seals and returns the retained frame instead of registering again — and an early Welcome' is already expected); the acceptor holds an early KP frame in memory, unarchived, applying it only after the AppWelcome verifies and the hash matches — the apply gate is structural (pq_bootstrap_respond cannot run before receive creates the session). If the parallel frame is dropped, the round self-heals: the initiator re-sends it pre-establishment and falls back to the steady-state side-band (step 1 above) after the cutover.

Routing the parallel frame on the acceptor. The envelope carries no session id, and one reusable invitation spawns many sessions, so the acceptor cannot route the KP′ by transport address. It self-routes by content: the invitation keeps a commitment table (H(KP′) → spawned group id, populated at receive from the commitment it was already given), and bootstrap_kp_group_id(kp_frame) hashes the framed KP′ to resolve the owning session — the bootstrap-KP counterpart of forward_group_id/processed_welcome_group_id. Because the table key is H(KP′), a frame that resolves can never fail pq_bootstrap_respond’s own hash check.

Why the bind leg exists. Without it A.3 is the only two-leg operation, and the turn has to pass at Bob’s send rather than at an apply — so Bob’s next send would open the next A.4 round (advancement is send-driven) while his own Welcome’ is still unconfirmed, and the two operations contend. The bind makes A.3 a well-formed round (initiator → responder → initiator, as A.4 and A.5 already are), so the usual rule applies unchanged: the initiator relinquishes at its terminal send, the responder takes the turn on applying it.

The receipt is free. S is derivable only from inside [BSG-PQ], so a bind that applies at all is proof Alice joined — the confirmation is a side effect of entropy Alice had to chain anyway, not a payload. An ack frame would prove the same thing and do no work. Both parties derive the same S independently from the same (group, epoch, domain), so it is never transmitted.

Ordering constraint. The exporter leaf is consumed on first export, so A.3’s bind spends the cross-party leaf of [BSG-PQ]’s birth epoch — on both sides, each in its own copy: Alice exports it from her recv mirror to build the bind, and Bob exports it from his send group to apply it. A later A.5 re-key must not re-export that epoch from either. (Both watermarks are load-bearing; omitting the responder’s makes the next re-key fail on a consumed leaf.)

A.4 PQ ratchet (granular) — PQ partial commit (no updatePath) + classical commit, stapled

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: Alice is initiator → operates on her send group (ASG).<br/>Lightweight KEM EK/CT exchange injects fresh PQ entropy S without a PQ updatePath.<br/>The legs travel as app messages in each sender's own CLASSICAL send group — fresher<br/>authentication — while S stays bound to [ASG-PQ] through the seal below.

    Alice-)Alice: Generate fresh PQ EK, DK
    Alice-)Bob: PQ EK (fresh encapsulation key) — an MLS application message in [ASG-cl], as a dedicated side-band frame.<br/>Bob authenticates it (Alice's leaf signature AND current-epoch proof) by DECRYPTING it before responding
    Bob-)Bob: Pick a fresh random S, encapsulate to EK, and SEAL S under a key bound to the KEM shared<br/>secret + a repeatable export of [ASG-PQ] at its current epoch → [enc][sealed S]
    Bob-)Alice: [enc][sealed S] — an MLS application message in Bob's OWN send group [BSG-cl], as a dedicated<br/>side-band frame (his leaf signature and epoch proof authenticate the CT the same way).<br/>Not Alice's group — his mirror of it holds his uncommitted Upd, which would block the encrypt
    Alice-)Alice: Open S — the AEAD tag is the explicit receipt: a stale or misdirected ciphertext fails HERE,<br/>with EK/DK and her PQ leaf intact (ML-KEM decapsulation alone returns garbage, not an error)

    Alice-)Alice: [ASG-PQ] PSK=S + Commit' (no updatePath — PARTIAL, so it stays small) → pq_epoch++<br/>(psk_id carries the 0x52 injected-secret domain byte)
    Alice-)Alice: Discard DK and S — the secret is folded in, so nothing waits holding it

    Note over Alice,Bob: The APQ pair is now half-committed: PQ has moved, classical is OWED.<br/>The attestation (t_epoch, pq_epoch) is now a RESERVATION on the next classical commit.<br/>Alice keeps sending at her current classical epoch, so nothing stalls — and nothing<br/>is held but the PQ commit's bytes, since apq_psk is not exported yet.

    Alice-)Alice: (at the next classical COMMIT) export apq_psk from the reserved ASG-PQ epoch
    Alice-)Alice: [ASG-cl] PSK=apq_psk + Commit, folding Bob's approved Upd → classical epoch++
    Alice-)Bob: Ordinary message frame — staple = APQPrivateMessage (t_message = the classical Commit, pq_message = Commit'), plus Upd(Alice) + app
    Bob-)Bob: From the staple: apply pq_message to [ASG-PQ], then t_message to [ASG-cl], discard S
    Note over Alice,Bob: Turn flips — Bob initiates the next PQ ratchet on his send group (BSG)

Authenticating the legs (MLS signatures, not a ratcheted MAC). Each leg — the EK and the CT — travels as an MLS application message in its own sender’s send-classical group, not as raw key bytes. Decrypting a leg is therefore what authenticates it, with MLS’s two factors: the sender’s leaf signature and proof of the group’s current epoch (the receive ratchet will not decrypt without the epoch secrets). Two properties follow that a bare frame lacked:

  • Post-compromise security of the authentication. A stolen leaf signing key alone can no longer forge a leg the peer acts on — the forger also needs the current epoch secrets, which a round the attacker missed has already rotated away. Pre-healing (the attacker still holds current state) is unavoidable and equivalent to forking any ratchet; the point is that the forgery power does not outlive one healed round.
  • No forged-EK wedge. A leg that fails authentication is rejected with no state change: the header seal drops a network tamper before MLS is even invoked, and a forged or stale EK never drives the responder into a half-open Responding state (the failure mode a bare, signature-less EK created). The pq_inflight guard, checked before the mutating decrypt, is what keeps a re-sent leg idempotent — a replayed application frame does not re-decrypt.

Why the classical group carries them. Both factors heal faster there: the classical leaf and epoch secrets rotate every round and adopt a principal rotation as soon as it is canonicalized, while a send-PQ leaf lags until an A.5 catch-up. Nothing is given up in strength — both halves sign Ed25519 (the PQ suite is confidentiality-only), so the signature factor is classical either way, and the classical epoch secrets are ML-KEM-seeded through the APQ PSK. The round’s tie to [ASG-PQ] is unaffected: the seal over S is keyed by a repeatable export of that group, so a CT answering a different group or epoch still fails its open. Each leg rides its own sender’s group because the responder cannot mint into the mirror the EK arrived in — that mirror holds its uncommitted Upd, and mls-rs refuses to encrypt while a by-ref proposal is cached, which would strand the round with the EK already spent.

The cost is that a leg’s ciphertext is pinned to the classical epoch it was minted at, an epoch ordinary traffic advances — so an unanswered leg is re-minted at the current epoch on each send (rewrap_side_band). What survives a stall is the round, not the bytes. That re-minting is also why both receive paths reject a leg whose epoch is below the receiver’s: one leg now exists as several valid wraps, and answering a superseded one after its round had closed would park a responder against an ephemeral the initiator has already discarded.

In exchange, opening and answering a round are purely classical mutations — the PQ half is read once, through the repeatable exporter that keys the seal — so neither costs the Checkpoint the send-PQ form needed (Core already carries them); only the bind, which commits the PQ half, still checkpoints. The seal over S is unchanged — it still provides the explicit receipt below.

Why the PQ half cannot wait, and the classical half must. apq_psk is exported from the PQ group’s POST-commit epoch, so the classical commit cannot even be built until the PQ one has applied. That ordering is forced — and it is what lets S be folded in and wiped immediately rather than held.

The classical half is the opposite. Applying it advances the epoch Alice’s ordinary traffic rides, onto a commit whose apq_psk the peer can only derive from this bind’s PQ half. Applied at the trigger, every frame Alice sends before the bind lands is undeliverable — and in A.3 the trigger is inbound, so she may have nothing to send at all.

Note what is NOT held: apq_psk is exported at the commit that consumes it, not at the trigger. The exporter leaf is spent on first use and cannot be re-derived, so exporting early would mean carrying live key material — and archiving it — across a wait we do not bound. Deferring the export leaves only public commit bytes waiting.

The bind is not a frame. It is an APQPrivateMessage in the staple slot. draft-ietf-mls-combiner-02 §7 defines it, and defines no APQCommit — a FULL commit travels as a message carrying both halves:

struct {
  MLSPrivateMessage t_message;
  MLSPrivateMessage pq_message;
} APQPrivateMessage

So the bind rides the ordinary message frame, as its staple. Three things follow, each a mechanism we would otherwise have had to invent:

  • A lost bind heals itself. The staple is re-sent on every frame until superseded, so the PQ commit re-staples for free. Nothing else could carry it: a message frame that overtook a separate bind would staple a classical commit the peer cannot apply, lacking the apq_psk that only the PQ half supplies.
  • The round keeps its Upd and its fold. The bind IS the classical committing round, not an extra commit beside one, so it stages the routine proposal and folds the peer’s approved one like any other commit.
  • It is affordable only because Commit' is PARTIAL. Re-stapling is cheap because classical staples carry no PQ keys; a pathless PSK commit is a few hundred bytes. A.5’s updatePath commits must never ride the staple — already their rule, and this is what gives it teeth.

Why “the next classical commit” and not “the next send”. The attestation AppDataUpdate{t_epoch, pq_epoch} rides both halves and is checked twice by the receiver: each half pre-apply against that group’s context.epoch + 1, and both post-apply against the groups’ actual epochs. Alice fixes those numbers before the PQ commit, so they hold only if nothing else takes the epochs meanwhile. Hence two rules while a bind is owed:

  1. The next classical commit IS this bind. A routine fold taking that epoch would leave t_epoch one behind, and Bob rejects the bind pre-apply — with Alice’s PQ leaf already spent, which no retry can rebuild.
  2. No second PQ commit lands. It would move pq_epoch out from under the same reservation. Starting the next round stays free: an EK, or an A.5 Upd', commits nothing.

So PQ never holds up classical — non-committing rounds send ordinary frames throughout — while classical may in principle hold up the PQ ratchet. In practice it does not: the peer proposes an Upd on every frame, and folding one is exactly what makes a classical round commit.

A.5 PQ re-key (granular) — the one updatePath commit, isolated from classical

Note on -02 conformance. Draft -02 defines no standalone PQ-group commit: every PQ commit is one half of a simultaneous FULL commit (PQ + paired classical) with synchronized epoch bookkeeping. Germ’s PQ re-key deliberately runs its one large updatePath commit in the PQ group alone (so the classical ratchet is not blocked on large ML-KEM updatePaths), which is an extension beyond -02 — but the round ends conformantly: the initiator’s ack is an ordinary bind (a -02 FULL commit pair riding the message-frame staple as an APQPrivateMessage), whose AppDataUpdate reconciles the bumped pq_epoch in-round. The cross-injected PSK(from …-PQ) below is the TwoMLS PQ-to-PQ export between send groups, distinct from apq_psk. Like the classical ratchet (§Classical Ratchet), this cross-injection is event-driven: a re-key binds the opposite PQ send group only when it has advanced since the last binding, so a re-key that follows another with no intervening PQ commit from the peer carries no cross-party PSK (the leaf rotation still happens via the updatePath).

The round is X → Y → bind, like A.3 and A.4. The proposal replaces the proposer’s leaf (this is where the initiator’s credential handoff rides); the full commit replaces the committer’s leaf (this is where the responder’s own leaf catches up to an already-canonical credential); the pathless ack 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.

sequenceDiagram
    autonumber
    participant Alice
    participant Bob

    Note over Alice,Bob: Run on cadence, or to carry a credential the classical ratchet (A.2) has already<br/>canonicalized onto the PQ leaves (catch-up). The one updatePath commit runs<br/>in isolation on the PQ group so the classical ratchet is not blocked.

    Alice-)Bob: Upd'(Alice) proposal to update Alice's leaf in [BSG-PQ], side-band frame
    Bob-)Bob: Export PSK from [ASG-PQ]
    Bob-)Bob: [BSG-PQ] Commit'(Upd'(Alice)) with updatePath + PSK(from ASG-PQ) → pq_epoch++
    Note over Bob: The updatePath also replaces Bob's own leaf — his credential<br/>catch-up channel when a rotation has been canonicalized.
    Bob-)Alice: Commit' [BSG-PQ], side-band frame — re-sent until the ack answers it
    Alice-)Alice: Apply Commit' to [BSG-PQ]
    Alice-)Alice: Export S from [BSG-PQ] at its NEW epoch — deriving S proves the apply
    Alice-)Alice: [ASG-PQ] pathless partial commit importing S → pq_epoch++, classical half OWED
    Alice-)Bob: Ack rides Alice's next classical COMMIT as the APQPrivateMessage staple
    Bob-)Bob: Re-derives S from [BSG-PQ], applies both halves from the staple
    Note over Alice,Bob: The ack is a conformant FULL commit — its attestation reconciles the bumped<br/>pq_epoch in-round. Bob is now the initiator, and the next A.5 re-keys [ASG-PQ].

PSK Binding

Three PSK chains tie the construction together. The APQ-PSK binds the two halves of one APQ group — this is what makes a send group hybrid: an attacker must break the classical half and the ML-KEM-768 half to break a session. The cross-party TwoMLS-PSK ties one party’s send group to the group it receives on, so the two directions of a session share fate. The injected-secret PSK carries the per-round ML-KEM entropy of the A.4 ratchet.

The first two follow draft-ietf-mls-combiner-02 §6.2 exactly: they are derived with the Safe Extensions recipe of draft-ietf-mls-extensions-08 §4.4 and imported as psk_type = application(3) PSKs. The injected-secret PSK is Germ’s own extension and stays an external(1) PSK — it is externally-sourced KEM entropy, not a draft-02 apq_psk.

The conformant recipe (Safe Extensions)

Both the APQ-PSK and the cross-party PSK are derived, on a given group at a given epoch, from a leaf of that epoch’s Exporter Tree — a 2^16-leaf tree rooted at application_export_secret = DeriveSecret(epoch_secret, "application_export"), indexed by a ComponentID:

apq_exporter = SafeExportSecret(component_id)        # one leaf of the epoch's Exporter Tree
apq_psk_id   = DeriveSecret(apq_exporter, "psk_id")
apq_psk      = DeriveSecret(apq_exporter, "psk")
# the leaf is deleted after both derivations — forward secrecy

SafeExportSecret consumes the leaf: a given (group, epoch, component) can be exported exactly once, and its source key material is deleted per the RFC 9420 §9.2 deletion schedule. The component id is what separates the two chains — the DeriveSecret labels ("psk_id", "psk") are fixed across both:

ChainComponentIDDirection
APQ-PSK0xFF01 (APQ_COMPONENT_ID)PQ half → classical half of the same APQ group
cross-party TwoMLS-PSK0xFF02 (TWOMLS_COMPONENT_ID)receive group → send group

Both are imported with a PreSharedKey proposal carrying psk_type = application(3) with (component_id, psk_id). The value is installed in the group’s PSK store under the application storage id 0x03 ‖ component_id ‖ len(psk_id) ‖ psk_id. The committer live-injects the value into the resolving store immediately before building or processing the commit that references it, and drops it once consumed. Both parties derive the identical (psk_id, psk) independently — nothing PSK-related crosses the wire except the id inside the proposal.

Component ids are 16-bit because the Exporter Tree has only 2^16 leaves. 0xFF01/0xFF02 sit in the draft’s private-use range. (draft-08 types ComponentID as uint32; -09 narrows it to uint16 — the fork rejects any id ≥ 2^16.)

The APQ-PSK (hybrid binding, PQ → classical)

Each APQ group is created PQ half first; the classical half then absorbs the PQ half’s secrecy at birth:

Group_A.pq @ epoch N
  → export_psk(component = 0xFF01)          # SafeExportSecret + DeriveSecret psk_id/psk
  → imported as an application PSK into Group_A.classical's creation commit

Both parties are members of Group_A.pq, so the joiner independently re-derives the same PSK: it joins the PQ half first, registers the APQ-PSK, then joins the classical half whose Welcome demands it.

The cross-party TwoMLS-PSK (receive → send)

The acceptor’s send group is bound to the group it receives on:

Group_A.classical (the acceptor's receive group) @ its current epoch
  → export_psk(component = 0xFF02)
  → imported as an application PSK into Group_B.classical's creation commit

SafeExportSecret only derives at the group’s current epoch, and consumes the leaf. A frame that crossed one of the deriver’s own commits references an epoch the group can no longer export, so the session keeps a small send-PSK ledger of its send group’s recent epochs (derived once when each epoch is entered) and live-injects the right entry into the resolving store immediately before processing a bound Welcome or commit. The ledger rides the session archive as reconstructed values (ExportedPsk::from_parts recomputes the storage id) so a restore never re-exports a consumed leaf.

Refresh is event-driven

The cross-party binding refreshes only when the peer’s send group has new entropy — not on a fixed schedule. On a folding commit (one that consumes the peer’s approved Upd proposal), the committer re-exports the cross-party PSK from its receive group and binds it into its own send-group commit only if that receive group has advanced since the last binding (tracked by the last_cross_injected watermark). A commit with no new peer entropy carries no cross-party PSK: the previous binding’s entanglement still holds, and re-deriving would consume the same exporter leaf for nothing. It still folds the peer’s Upd and rotates its own leaf via the updatePath.

Establishment seeds the watermark to the epoch it bound at creation, so the acceptor’s first routine commit — which would otherwise redundantly re-bind the peer at the epoch establishment already covered — correctly skips. The establishment cross-party PSK is load-bearing: it is the sole PQ-protection path for the acceptor’s classical-only send group before the A.3 bootstrap.

The APQ-PSK refreshes on the A.4 ratchet: fresh ML-KEM entropy is injected into the send group’s PQ half, and the re-exported APQ-PSK (component 0xFF01) is bound into the classical half’s commit in the same round.

The A.5 PQ re-key adds the PQ-to-PQ use of the cross-party chain: each of its two Commit's cross-injects a component-0xFF02 PSK exported from the PQ half of the opposite send group (same recipe), tying the two directions’ PQ halves to each other while their updatePaths rotate the leaves. These exports are event-driven too, guarded by the PQ watermarks (last_cross_injected_pq, last_send_pq_exported) so a re-key round never re-exports a consumed PQ leaf.

The injected-secret PSK (A.4 KEM entropy)

The A.4 ratchet’s per-round secret S — the shared secret of an out-of-band ML-KEM encapsulation — is injected as an external(1) PSK, not an application PSK. It is externally-sourced entropy rather than an exporter-derived value, so it keeps its own structural id LE-u64(epoch) ‖ group_id ‖ 0x52 in both recipe phases. The trailing 0x52 domain byte, together with the length difference (41 bytes vs. the 38-byte application storage id), keeps its id space disjoint from the exported chains.

Invariants — never change

These values are protocol-specified; changing any of them silently breaks interoperability:

  • APQ-PSK component id: 0xFF01; cross-party TwoMLS-PSK component id: 0xFF02
  • DeriveSecret labels: "psk_id" and "psk" (fixed across both components)
  • Exporter-tree root label: "application_export"
  • application PSK storage id: 0x03 ‖ component_id ‖ len(psk_id) ‖ psk_id
  • import type: psk_type = application(3) for the APQ and cross-party PSKs
  • injected-secret PSK: external(1), id epoch.to_le_bytes() ‖ group_id ‖ 0x52, keeping the two id spaces disjoint

API Reference

This is a narrative overview; the authoritative reference is rustdoc (cargo doc -p two-mls-pq --open). All exported names are flat because UniFFI has no module paths — hence the TwoMlsPq* / Combiner* / Mls* prefixes (Combiner* is the code-name prefix for the APQ pieces — e.g. CombinerKeyPackage, an APQ group’s paired key packages).

Binding contract

binding_contract_version() -> u64 — a canary the Swift layer asserts at first construction. UniFFI’s own load-time checksums cover function signatures only; this value is bumped on any shape change to an exported record or the error enum, so a stale binding/binary pairing fails fast with an actionable message instead of trapping mid-flow.

References (Digests)

The FFI holds on to some state internally, e.g. queued Proposals, and passes references across the FFI to the app to use in subsequent calls to indicate the queued state.

The app can treat these references as opaque bytes-typed identifiers. They are in practice raw 32-byte values: SHA-256 over the stated object. That is this library’s own wire convention; no app-layer type tags appear on this surface.

The Swift wrapper applies the tag: everything it vends is [kind][digest] (33 bytes), derived and compared by PQDigest (Sources/TwoMLSPQ/PQDigest.swift). The kind tag belongs to the Swift package rather than to a shared identity type precisely because the hash is a suite facet — TwoMlsSuite::CURRENT.digest — so it must version with this crate. Since the crate carries no tags, the two sides restate the algorithm independently; DigestContractTests.digestDerivationMatchesTheCrate is what holds them together, and a suite whose digest is not SHA-256 must fail there.

TwoMlsPqPrincipal

The principal and key-package/invitation mint — deliberately not an mls-rs-style hub for group operations (see Concepts).

  • new(client_id) -> Arc<TwoMlsPqPrincipal> — build an identity for opaque ClientId bytes (carried as the Basic Credential); the MLS signing keys are generated internally and are independent of it.
  • client_id() -> ClientId — the identity bytes.
  • generate_key_package(suite) -> Vec<u8> — one MLS key package.
  • generate_combiner_key_package() -> CombinerKeyPackage — paired classical + ML-KEM-768 key packages sharing one ClientId.
  • generate_invitation(last_resort) -> Vec<u8> — capture a combiner key package’s private material, with the signing identity, into a self-contained invitation archive, purging the identity’s own copies. last_resort picks the key package’s lifetime, which TwoMLS manages itself rather than via mls-rs’s on-the-wire last-resort extension: true retains the key package so the invitation accepts many welcomes; false makes it single-use (consumed after the first accepted session).

TwoMlsPqInvitation

The receiving side of a published key package — no live client required.

  • restore(archive) — materialise from serialised bytes (from generate_invitation on first use, or a pushed blob on restore); they carry the signing identity, the key package’s private material, the consumed-remote set, the spawned-group forward table, and the processed-welcome ledger. Named restore, not new — the state lives in the bytes.
  • install_sink(sink) — attach the ArchiveSink this invitation pushes to after every state-advancing receive (once-only — a second call is SinkAlreadyInstalled; the first pushes a baseline Checkpoint). Persistence is push, not pull — the old archive() getter is off the FFI (see TwoMlsPqSession below for why). state_seq() reports the current push sequence.
  • client_id(), combiner_key_package() — what to publish.
  • receive(welcome, their_classical_key_package, bootstrap_kp_commitment, spawn_token, new_client_id, expected_remote, expected_app_binding) -> TwoMlsPqSession — establish from a remote initiator’s welcome; rejects a re-delivered welcome (byte-identical, via the processed-welcome ledger) and a repeat remote (both DuplicateWelcome), and, for a single-use invitation whose key package has already been consumed, any further welcome (InvitationSpent). their_classical_key_package is the initiator’s CLASSICAL return key package (a bare MLS KeyPackage message — §A.1: the return group starts classical-only). bootstrap_kp_commitment is H(initiator's PQ keyPackage) from the signed establishment payload, exactly 32 bytes: pq_bootstrap_respond refuses a bootstrap KP′ hashing to anything else (BootstrapKpMismatch). spawn_token is an opaque, replay-stable identifier for the initial frame, keying the forward table. new_client_id is an optional dedicated per-session principal: when Some, the spawned session’s send group is created directly under a freshly-minted principal carrying that ClientId (signing keys minted internally) — so the acceptor runs as the dedicated agent from birth (its creator leaf carries new_client_id) and the initiator sees the dedicated principal from the very first frame, with no founding→dedicated rotation, so nothing can displace the welcome staple. The receive-group join still uses the invitation identity (the welcome was addressed to its key package), and the session id — the initiator’s random group id — is unaffected by any of this, so both sides still name the same one. expected_remote is the identity the caller already expects the welcome from (Germ validates it from the decrypted initial frame): a key package naming anyone else is rejected as RemoteIdentityMismatch before any invitation state is claimed, so the invitation stays fully reusable. Independently of it, the welcome’s creator leaf must match the supplied key package (see Group Rules). expected_app_binding is the app-state binding the welcome must carry (the bytes the initiator passed to initiate(app_binding:)): an exact, symmetric match — Some requires byte-equality, None requires an unbound welcome; anything else (a stripped, unequal, or unexpected binding, or a PQ half smuggling one) is AppBindingMismatch, as is an empty expectation (empty is reserved — no group can carry an empty binding). Necessarily verified after the join (GroupContext rides the encrypted welcome) but still before any invitation state is claimed — a rejected welcome consumes nothing. The spawned session mirrors the verified binding onto its own send group.
  • forward_group_id(spawn_token) -> Option<MlsGroupId> — resolve a replayed initial frame to the spawned session’s receive group (its classical message-half id).
  • processed_welcome_group_id(welcome) -> Option<MlsGroupId> — the content-keyed counterpart: resolve a re-delivered welcome by the digest of its exact bytes, no host token convention needed.
  • bootstrap_kp_group_id(kp_frame) -> Option<MlsGroupId> — resolve a §A.1 bootstrap-KP frame ([0x13][KP′], the BootstrapKp variant open_initial opens) to the session that owes A.3 for it, keyed on H(KP′) against the commitment receive pinned. Lets a KP′ delivered as an envelope (rather than a rendezvous side-band frame) self-route even when a reusable invitation has spawned many sessions; None for a KP′ no session pinned. (A KP′ whose session already finished A.3 still resolves — the table is not pruned — and the duplicate is caught at pq_bootstrap_respond.) Route the frame to that session’s pq_bootstrap_respond.
  • open_initial(blob) -> OpenedInitial — open the initiator’s first frame (the §A.1 envelope initiate produced), dispatching on the plaintext’s inner tag to Establishment { frame } (the app-layer welcome and the MLS welcome to pass to receive) or BootstrapKp { frame } (the parallel A.3 KP′). Decrypt-only and does not consume the invitation (validate before joining); InvitationSpent once a single-use invitation is consumed. The main receive path is open_initial → validate → receive.
  • hpke_open(kem_output, ciphertext, info, aad) — the lower-level decrypt used by open_initial, kept exported for other stacks; the counterpart free function is hpke_seal_to_key_package.

Parsing & routing helpers

  • parse_mls_key_package(bytes) -> MlsKeyPackage { client_id, cipher_suite }
  • parse_combiner_key_package(kp) -> ParsedCombinerKeyPackage — validates both halves share a ClientId.
  • encode_combiner_key_package / decode_combiner_key_package — the pair as one opaque blob for layers that carry it as a single value.
  • MlsCipherSuite::is_combiner_pq() / is_combiner_classical() — routing signals (true for the PQ 0xFDEA and classical 0x0003 halves respectively).

The session id is the initiator’s group id. derive_session_id(a, b) was removed in contract 31 and its stored replacement active_session_id() in contract 32, because both were a hash of the two client ids — SHA-256(min(a,b) ‖ max(a,b)) — and that is a participant-pair fingerprint, not a session id: it is computable by anyone holding the two public ClientIds, and it is identical across every session the pair ever opens.

A real session id must be fresh and unpredictable per session. The one already in hand is the initiator’s randomly-generated group id: seeded at group creation, unique per session, and shared — the initiator’s send group is the acceptor’s receive group. Read it with send_group_id() on the initiator and receive_group_id() on the acceptor (the classical half, present from construction; both sides name the same value). It survives archive restore with the group state.

If you genuinely need a stable key for a pair before any session exists, compute your own SHA-256(min(a,b) ‖ max(a,b)) over the two ClientIds — nothing secret, nothing protocol-specific — but do not call it a session id.

TwoMlsPqSession

Constructors: initiate(client, their_key_package, app_binding) — the host’s opaque app-layer welcome is attached AFTER construction with set_initial_app_payload (it typically signs over the welcome and the return key package, so it cannot exist before initiate returns); the library composes it with the MLS welcome and HPKE-envelopes it to the peer’s KP′ so pending_outbound is one opaque blob the peer opens with TwoMlsPqInvitation::open_initial; app_binding is the optional app-state binding welded into the send group’s GroupContext at this moment and immutable for the session’s lifetime — pass a digest of the app’s immutable relationship identity, not raw identifiers, and never empty (empty is reserved as invalid; None is the unbound state) — the crate never interprets the bytes (see Group Rules rule 8); accept(client, welcome, their_classical_key_package, bootstrap_kp_commitment, expected_app_binding) — the plaintext-welcome path (tests/embedded); restore(core, checkpoint) — self-contained restore from the two pushed blobs: they carry the session’s signing identity, so restore rebuilds the exact client internally (no client argument), byte-exact in ClientId and signing keys, and the restored groups still sign with the keys embedded in their snapshots. It reconciles the pair (PQ halves from the checkpoint, the rest by higher state_seq) and fails closed (ArchiveInvalid) on a PQ-epoch manifest mismatch.

State: is_established, is_fully_established, has_receive_group, send_group_id / receive_group_id (the initiator’s send_group_id is the session id — see above; each is a CombinerGroupId whose classical half is present from construction), my_principal_state, their_principal_state, pending_outbound (the standalone copy of the own welcome — not consumed by encrypt; the welcome also rides every pre-commit frame as the staple), epochs, app_binding() -> Result<Option<Vec<u8>>> (Swift try appBinding() -> Data?; the app-state binding the session was created with, read from the send group’s GroupContext — it rides the persisted group state, so a restored session’s owner re-verifies here; errors only on a present-but-undecodable extension, so corruption can never read back as “unbound”).

Messaging: prepare_to_encrypt(proposing)Some(id) proposes a rotation to that ClientId on this round’s Upd, admitting the candidate on the fly (minting the successor’s signing keys and authorizing it if id is not already staged — so a rotation can ride the very first frame); None re-proposes the current identity and the commit path is unchanged; its result carries the staged Upd both raw (proposal_message — the exact message the paired encrypt staples) and digested (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; 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 A.4 bind); queued_remote_successor() -> Option<ClientId> — the credential currently queued, for the app’s replace policy. Proposing another Some(id) adds a candidate, never evicting one already sent, so several may be in flight (my_principal_state is Pending while any are); overflow beyond the in-flight window defers to a single slot proposed next round, and the peer’s commit picks the winner. See Group Rules for the Authentication Service semantics.

Header encryption: open_incoming(blob) -> Option<OpenedFrame { kind, frame }> removes the outer seal from a rendezvous-channel blob and returns the plaintext frame plus a routing kind (OpenedFrameKind::Messageprocess_incoming; PqSideBand { kind } → the named pq_* method); None means no key opened it (drop it). Every outbound blob (EncryptResult.cipher_text, pending_outbound, pq_take_pending_outbound, the pq_*_begin returns) is already sealed. process_incoming and the pq_* receivers also accept a sealed blob directly (they open it transparently), so open_incoming is strictly required only to route side-band frames. See Header Encryption.

Transport routing: should_listen_on() -> ListenChannels (send-group ids + one rendezvous address per retained epoch), send_rendezvous() (where to post), forwarded(spawn_token) (acknowledge a replayed initial frame routed here by the invitation’s forward table).

PQ side-band (see Session Lifecycle): my_pq_turn, pq_pending_outbound(sealing) / pq_take_pending_outbound, pq_bootstrap_begin(rotating) / pq_bootstrap_respond / pq_bootstrap_bind, and the ratchet/re-key responder and bind/apply legs — pq_ratchet_respond / pq_ratchet_bind and pq_rekey_respond / pq_rekey_apply. There is no pq_ratchet_begin / pq_rekey_begin: the session self-drives A.4 and A.5. On each encrypt, when it is our turn and the side-band is idle, the session auto-stages the next round’s opening frame (A.5 on a credential lag — announcing the session’s current principal as the handoff — else A.4), and the host takes it from pq_pending_outbound/pq_take_pending_outbound to send alongside the message. A.3 bootstrap stays host-driven (pq_bootstrap_begin, whose rotating parameter carries the principal credential handoff and must name the session’s current principal). The A.4 ratchet and A.3 bootstrap have no separate apply call: the initiator ingests the responder’s reply and stages the owed bind with pq_ratchet_bind / pq_bootstrap_bind, and that bind then rides the next message frame’s staple, which the peer applies through process_incoming (the v18 “a bind is the staple” model).

Side-band frame sizing (Feature B): set_pad_target(target) declares the frame-sizing intent. Some(n) pads each side-band frame up to the co-stapled message’s size, capped at the push-payload budget n bytes, so the two co-stapled payloads are size-indistinguishable to an on-path observer; None (the default) sends frames at their natural size. Like install_sink, it is live plumbing outside the archive — set it right after restore, before use.

Persistence (push): attach an ArchiveSink with install_sink (once-only — SinkAlreadyInstalled on a second call; the first pushes a baseline Checkpoint). The session then PUSHES its new state after every state-advancing mutation via persist(seq, kind, bytes), where kind is Core (everything but the two ML-KEM trees; written on classical mutations) or Checkpoint (the complete state incl. the PQ trees; written on PQ-touching mutations and at baseline). The state is total — a session is always encodable, in any state, so a push never refuses: it serialises the current signing identity, both group snapshots, the cross-party PSK ledger, the per-epoch listen map, the spawn token, a staged-but-uncommitted rotation, the full PQ round state (including a mid-A.4 KEM round), and every parked one-shot frame. The bytes are plaintext secret material: the sink must seal them before writing (the key belongs in the platform keystore). Serializing a mid-A.4 round costs at most one round of PCS against an archive thief who already holds the epoch secrets; discarding the round state instead would permanently desync the side-band, so it is not an option.

Push replaced a pull archive() getter that was a move, not a copy — using the live session after snapshotting it, then restoring, rewound the sender ratchet into AEAD nonce reuse against a real transcript (security review finding H1). The getter survives only as an in-crate test/fuzz helper, off the FFI. Every classical mutation pushes one Core and every PQ op one Checkpoint, atomically and independently; the PQ trees never move between checkpoints, so a Core is always consistent with the latest Checkpoint (the restore constructor above reconciles the pair).

Transmit gating: EncryptResult.depends_on_seq is the state_seq at which the commit a frame staples was persisted. A frame that publishes stored-private-key material (a fresh commit) must not go out until the app has durably persisted that seq — otherwise a crash-restore could rewind past keys the peer will rely on. A routine app message re-staples an already-persisted commit, so its depends_on_seq is already durable and imposes no wait; the durability gate covers only key-material frames (routine frames rely on MLS’s per-message reuse_guard, by design). state_seq() reports the current sequence for the frames — the establishment envelope, PQ side-band — whose return type carries none. In-library desync recovery is not planned — recovery is a re-establishment at the host layer.

Errors

All failures map to the flat TwoMlsPqError enum (Mls, InvalidKeyPackage, MissingWelcome, PskBinding, PqNotAvailable, SessionNotEstablished, SessionNotReady, ProposalRejected, DecryptionFailed, DuplicateWelcome, InvitationSpent, ArchiveInvalid, UnsupportedCipherSuite, CipherSuiteMismatch, EpochDesync, UnexpectedWelcome, InvalidClientId, RemoteIdentityMismatch, CredentialRejected, ApqInfoMismatch, AppBindingMismatch, SinkAlreadyInstalled, DuplicateSideBand, BootstrapKpMismatch, BindDischargeFailed, BindApplyFailed, StaleFrame, BindTriggerFailed). mls-rs error types never cross the FFI boundary. The three PQ-bind failures carry recovery semantics a caller must branch on, and they name the three places one bind can break. BindDischargeFailed is fatal — our classical commit discharging an owed bind failed, so the host re-establishes the session. BindApplyFailed (paired with the queryable pq_receive_broken()) is the peer’s staple failing on us: it marks receive-side PQ state as broken but leaves sending intact, and a restore from the last persisted state heals it. BindTriggerFailed (paired with the queryable pq_side_band_wedged()) is our own trigger failing past its point of no return — A.3’s join, A.4’s decapsulation or A.5’s applied Commit’ had already landed — so the round cannot be rebuilt and re-establishment is the exit. It is the one of the three whose latch is ARCHIVED, and deliberately: the bind triggers run inside a persist that captures partial mutations even on failure, so the tear reaches the blob and a restore would otherwise reproduce it while reporting healthy. Classical messaging keeps working under all three. DuplicateSideBand is a benign no-op (a re-delivered side-band frame); BootstrapKpMismatch rejects an A.3 bootstrap key package whose hash does not match the commitment receive was given. InvalidClientId rejects an empty principal id supplied to receive(new_client_id:) or prepare_to_encrypt(Some(id)) — empty is reserved (it is the ratchet-commit authenticated-data discriminator, so an empty id could never be announced to the peer). RemoteIdentityMismatch is an establishment identity-binding failure: an expected_remote the key package does not name, a welcome whose creator leaf differs from the supplied key package, or an A.3 bootstrap key package naming a principal that is not the established peer (see Group Rules). CredentialRejected is the Authentication Service’s refusal (an unauthorized credential succession) — retryable where it arises from a staple: authorize (queue_proposal on a fresh delivery) and reprocess. EpochDesync means a stapled commit is more than one epoch ahead of the receive group (the bridging commit no longer rides any frame) — re-establish the session, distinct from the transient DecryptionFailed. StaleFrame is an application message whose message key the group already spent — a replay — and is likewise distinct from DecryptionFailed: it is terminal, so a host discards it rather than retrying. Expect it as steady-state traffic wherever a host runs two delivery channels over one queue (a push relay alongside a socket), where the second copy of every frame is a replay. Epoch misses stay DecryptionFailed: an application message carries no epoch bound check, so a miss cannot be told apart from a frame that arrived ahead of the commit it needs. UnexpectedWelcome means a welcome differing from the one a live session was joined from arrived (re-deliveries of the same welcome are silently idempotent). CipherSuiteMismatch is raised when a peer key package or welcome carries a cipher-suite pair that isn’t the session’s fixed suite (PqNotAvailable when the peer offers no PQ half at all); UnsupportedCipherSuite is a local provider-capability gap at construction. AppBindingMismatch is the app-state binding’s verification failure: a welcome that does not carry the caller’s expected_app_binding (absent or unequal), a binding-carrying welcome against no expectation (never silently accepted), or a return welcome that fails to mirror the initiator’s own binding — on receive it is raised before any invitation state is claimed, so the invitation stays fully reusable (see Group Rules).

Walkthrough: Alice & Bob

The runnable version of this walkthrough lives in src/demo.rs as the demo_e2e_full_session test. Run it with output:

cargo test -p two-mls-pq demo_ -- --nocapture
# real ML-KEM-768:
cargo test -p two-mls-pq --features cryptokit demo_ -- --nocapture

The narrative, step by step:

  1. Identities — Alice and Bob each build a TwoMlsPqPrincipal for their ClientId (opaque identity bytes); the MLS signing key is generated internally.
  2. Key packages — each generates a CombinerKeyPackage (classical + ML-KEM-768 halves, same ClientId).
  3. Parsing — the peer’s halves parse to MlsKeyPackages; the classical suite is 0x0003, the PQ suite 0xFDEA; the two ClientIds must match.
  4. Establishmentinitiate(…) + set_initial_app_payload → envelope → open_initialreceiveAPQWelcome_Bprocess_incoming (standalone, or as the staple on Bob’s first frame — welcome re-deliveries are idempotent). Both sides are now established with the PSK chain bound.
  5. Routine round — Alice prepare_to_encrypt(None) + encrypt. The frame is the [staple][proposal][app] triple: her latest send-group commit (or her welcome, until the first commit) plus an Upd(Alice) proposal for Bob to approve; Bob decrypts, skipping any staple he has already applied.
  6. Full commit — Bob proposes; Alice queue_proposal then commits on her next send, advancing the epoch and refreshing the PSK.
  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).

For the full flow detail — the PQ side-band rounds, routing, and rotation — see the Session Lifecycle chapter, and the Wire Format chapter for the frame tags each step emits.

Benchmarks

Criterion benchmarks live in benches/, gated behind the benchmark_util feature.

# Real ML-KEM-768 on aws-lc (any platform)
just bench
# or: cargo bench -p two-mls-pq --features "benchmark_util awslc"

# Real ML-KEM-768 on Apple CryptoKit (macOS 26+)
just bench-pq
# or: cargo bench -p two-mls-pq --features "benchmark_util cryptokit"

HTML reports

Each run writes an interactive HTML report (criterion with the pure-Rust plotters backend — no external tools) to target/criterion/report/index.html: violin plots, per-benchmark PDF/iteration charts, and, when a previous run exists, before/after comparison plots. Open it after just bench:

open target/criterion/report/index.html   # macOS

target/ is gitignored, so reports stay local.

BenchmarkIds are labelled with the active suite and provider (ml_kem_768/awslc vs ml_kem_768/cryptokit) so the two runs are distinguishable in reports.

Groups

Bench fileMeasures
kp_generationsingle key-package and paired (classical + PQ) key-package generation
establishmentinitiate, and the full initiate/accept/join handshake
messagingsteady-state no-commit-round send (send_partial), and a send→decrypt round trip

Sessions mutate on commit, so messaging benches use iter_batched_ref with a freshly established session per iteration. Shared fixtures are in benches/common.rs (autobenches = false keeps it from being treated as its own bench target).

Additional groups (folding commit, rotation, parsing, and — once archive lands — archive round-trips) follow the same pattern.