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

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.