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

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