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:
| Facet | Drawn from | Drives |
|---|---|---|
pair() | both halves | group construction (crypto_config), the wire suite id (invitation/session archive headers, APQInfo), the APQ mode |
hpke() | the PQ half | the §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 AEAD | the 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 hash | every 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
| Role | Value | Suite |
|---|---|---|
| Classical | 0x0003 | MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519 (RFC 9420 §17.1) |
| Post-quantum | 0xFDEA | MLS_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
| Flag | Meaning |
|---|---|
cryptokit | Apple CryptoKit backs both halves (classical suites + native ML-KEM-768, FIPS 203). macOS 26 / iOS 26+ only. The shipped configuration. |
awslc | aws-lc backs both halves. Portable (Linux CI runs the full suite with it) and wire-compatible with cryptokit. |
benchmark_util | Gates 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).