Skip to content

Chapter 27: Hybrid schemes in practice

A hybrid scheme runs two algorithms in parallel and combines their outputs so that one component remaining secure is enough to protect the result, whether that result is a shared key or an authenticated signature. Ch 26 named hybrid constructions as one specific protocol-level agility mechanism. This chapter walks two of them, and they sit at different stages of standardisation. X25519MLKEM768, for key establishment, was published as RFC 10024 on the Standards Track in August 2026 (Kwiatkowski et al., 2026). The PKI composite-signature profile id-MLDSA65-Ed25519-SHA512 is still a draft, at draft-ietf-lamps-pq-composite-sigs-19 (Ounsworth et al., 2026). The gap between those two is the chapter’s second subject: hybrid key establishment and hybrid authentication are not one migration, and they have not moved at one pace. The running example stays on the Ch 25 touchpoints: tls_endpoint_api for the KEM, jwt_signing for the signature. A from-scratch implementation of both hybrids is at solutions/ch27-hybrid/.

The formal security argument is Bindel/Brendel/Fischlin/Goncalves/Stebila 2019 (Bindel et al., 2019): under a suitable KDF, the hybrid KEM is IND-CCA secure if at least one component KEM is IND-CCA. That is the precise statement of the at-least-one-holds property for the KEM case. The composite-signature analog is AND-mode verification: the hybrid signature is valid only when both component signatures validate, so an attacker who can forge one component still cannot produce a valid composite.

A TLS 1.3 client offering X25519MLKEM768 sends a single key_share entry under NamedGroup codepoint 0x11EC (decimal 4588) (IANA, 2026). The keyshare payload is 1216 bytes: the 1184-byte ML-KEM-768 encapsulation key followed by the 32-byte X25519 public key (Kwiatkowski et al., 2026). The server replies with a 1120-byte keyshare: the 1088-byte ML-KEM ciphertext followed by its own 32-byte X25519 ephemeral key.

RFC 10024 Section 4.3 specifies that the hybrid shared secret for this group is the concatenation ss_mlkem || ss_x25519, with ML-KEM secret first (Kwiatkowski et al., 2026). That order is a property of the group and not a general rule. RFC 10024 defines three groups, and the two built on NIST curves put the ECDHE secret first:

GroupCodepointSecret orderedRecommended
X25519MLKEM7680x11ECML-KEM firstY
SecP256r1MLKEM7680x11EBECDHE firstN
SecP384r1MLKEM10240x11EDECDHE firstN

RFC 10024 Section 5 gives the reason, and it is a compliance constraint rather than a cryptographic one. That RFC reads SP 800-56C Revision 2 as approving HKDF over two shared secrets only when the first is produced by a FIPS-approved key-establishment scheme (Kwiatkowski et al., 2026). The characterisation is the RFC’s own: what SP 800-56C Revision 2 Section 2 permits is a hybrid shared secret Z' = Z || T whose first component comes from a scheme specified in SP 800-56A or SP 800-56B (Barker et al., 2020). X25519 has no such certification path, so X25519MLKEM768 puts ML-KEM first and requires the ML-KEM implementation to be certified; the secp groups put ECDHE first and require the ECDHE implementation to be certified instead. Only X25519MLKEM768 carries Recommended = Y in the IANA registry (IANA, 2026).

The concatenation is fed as IKM into the existing TLS 1.3 key schedule, which handles the HKDF-Extract / HKDF-Expand chain under RFC 9846 Section 7.1 (Rescorla, 2026). Block 1 demonstrates the construction outside a TLS session, with ML-KEM stubbed so the example stays stdlib-only and with a standalone HKDF-SHA256 call standing in for the TLS key schedule. The real ML-KEM-768 encapsulation is at solutions/ch11-mlkem/; the runnable combiner is hybrid_kem_encaps and hybrid_kem_decaps in the ch27-hybrid package under solutions/.

# Block 1: X25519MLKEM768 combiner shape with stubbed ML-KEM, stdlib only.
import hashlib, hmac, os
def hkdf_sha256(ikm, info, length=32):
prk = hmac.new(b"\x00" * 32, ikm, hashlib.sha256).digest()
out, t, counter = b"", b"", 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
out += t
counter += 1
return out[:length]
def combine(ss_mlkem, ss_x25519):
# Concatenation order per RFC 10024 Section 4.3: ML-KEM secret first.
# The label below is a pedagogical stand-in for the TLS 1.3 key schedule.
return hkdf_sha256(ss_mlkem + ss_x25519, b"tls13 x25519_mlkem768", 32)
# Simulate two independently-generated 32-byte shared secrets.
ss_mlkem_alice = os.urandom(32)
ss_mlkem_bob = ss_mlkem_alice # ML-KEM: both sides agree on the same secret.
ss_x25519 = os.urandom(32) # X25519: both sides derive the same ECDH output.
k_alice = combine(ss_mlkem_alice, ss_x25519)
k_bob = combine(ss_mlkem_bob, ss_x25519)
print(k_alice == k_bob, len(k_alice))
# ==> True 32

Every Python block this chapter prints is also a standalone file in the companion repository, under chapter-code/ch27/, one file per block. Appendix C covers the clone and the environment they run on.

Block 1 is not the literal TLS 1.3 key schedule. It is a standalone pedagogical KDF call that preserves the concatenation order the specification fixes. The component ciphertexts never enter the derivation.

The real TLS 1.3 key schedule (RFC 9846 §7.1) is more structured. It feeds the hybrid concatenation ss_mlkem || ss_x25519 into the standard HKDF-Extract / Derive-Secret chain as the (EC)DHE input. The salt to that HKDF-Extract is the schedule’s propagated derived secret (itself the output of a prior HKDF-Expand-Label call), not the static zero salt the standalone block uses. The transcript hash enters separately, inside Derive-Secret, as the Context argument to HKDF-Expand-Label when binding the handshake and application traffic secrets to the specific handshake transcript.

The combiner sits between the two component KEMs and the TLS 1.3 key schedule; it does not replace either. A standalone runnable version of the same construction (HKDF-SHA256 with a fixed label in place of the TLS key schedule) is the combiner inside hybrid_kem_encaps in the ch27-hybrid package under solutions/, and is what Block 1 mirrors. Block 1, Block 2 and that package all derive their output from the two component secrets alone, with no ciphertext input. Outside TLS, none of the three is an instance of the combiner the security argument below covers. Appendix D, Chapter 27, Exercise 1 works through the gap.

Math preliminaries: IND-CCA and the at-least-one-holds property

Section titled “Math preliminaries: IND-CCA and the at-least-one-holds property”

IND-CCA is the security target for a KEM: no efficient adversary with access to a decapsulation oracle can distinguish the encapsulated shared secret from a random string of the same length. ML-KEM-768 targets NIST Category 3 IND-CCA (National Institute of Standards and Technology, 2024). Raw X25519 scalar multiplication is a Diffie-Hellman primitive, not a KEM. RFC 9180 Section 4.1 packages a Diffie-Hellman primitive into a KEM interface as DHKEM, including DHKEM(X25519, HKDF-SHA256) with 32-byte public keys, encapsulated keys, and shared secrets (Barnes et al., 2022). The construction is to apply HKDF over the DH output together with both public keys. RFC 9180 §9.1 analyses the resulting KEM as IND-CCA2 secure under assumptions about the underlying DH problem and KDF behavior. That is the conceptual model the Bindel et al. 2019 TLS-hybrid argument applies to the X25519 side of the combiner (Bindel et al., 2019), though neither TLS nor the package here runs DHKEM: both feed the raw scalar-multiplication output into the combiner.

Bindel, Brendel, Fischlin, Goncalves, and Stebila 2019 give the combiner argument in their Section 3 (Bindel et al., 2019). The construction they analyse is PRF(dPRF(k1, k2), c1 || c2): the two component secrets go through a dual-PRF, and that output keys a second PRF over the concatenated component ciphertexts. It preserves IND-CCA security if the KDF is modelled as a dual-PRF and at least one component KEM is IND-CCA. Their instantiation takes dPRF as HKDF-Extract and PRF as HKDF-Expand, which puts the component ciphertexts in the expand label, the slot TLS fills with the transcript hash. Section 3.2 shows why that input is required. Derive from the secrets alone, and an adversary who has broken one component can re-encapsulate that secret under a different ciphertext, then read the challenge key off a single decapsulation query.

An earlier treatment by Giacon, Heuer, and Poettering 2018 gives the parallel IND-CCA combiner construction for KEMs, keying a core function over the concatenated component ciphertexts under a property they introduce as split-key pseudorandomness, and the Bindel et al. analysis adapts that shape to the TLS hybrid setting (Giacon et al., 2018).

The dual-PRF hypothesis is the load-bearing modelling assumption: the KDF must look pseudorandom when keyed by either of its two inputs. HKDF-SHA256 is modelled as a dual-PRF in that analysis and in the TLS 1.3 key-schedule security literature (Bindel et al., 2019). HKDF itself is RFC 5869, HMAC-based extract-then-expand (Krawczyk & Eronen, 2010). NIST SP 800-56C Revision 2 Section 5 specifies a two-step extract-then-expand procedure of its own. Section 5.1 closes with a note that RFC 5869 “specifies a version of the above extraction-then-expansion key-derivation procedure using HMAC for both the extraction and expansion steps” (Barker et al., 2020). That note is an observation of kinship and not the section’s normative clause, which is that one of the SP 800-108 PRF-based key-derivation functions shall be used for key expansion. Neither the kinship nor the requirement settles whether a given deployment’s derivation is FIPS-approved. That is a question about a validated implementation under protocol-specific rules, and for TLS it is what RFC 10024 Section 5 addresses (Kwiatkowski et al., 2026). No conformance profile has been demonstrated for the standalone combiner below, which is a pedagogical construction.

Inline blocks use the standard library only. The runnable package at solutions/ch27-hybrid/ is the reference version, with ML-KEM imported from solutions/ch11-mlkem/.

KeyGen produces an ML-KEM-768 keypair and an X25519 keypair and concatenates the public halves. The wire-format public key is 1216 bytes: mlkem_ek (1184 bytes per FIPS 203 Table 3 (National Institute of Standards and Technology, 2024)) followed by the 32-byte X25519 public key per RFC 7748 (Langley et al., 2016). The secret key is the concatenation of mlkem_dk (2400 bytes) and the 32-byte X25519 private scalar.

Encaps runs both component encaps against the peer’s public key. ML-KEM-768 produces a 32-byte shared secret and a 1088-byte ciphertext (National Institute of Standards and Technology, 2024). X25519 clamps the ephemeral scalar per RFC 7748 Section 5 (the low three bits cleared, bit 254 set, bit 255 cleared). The scalar multiplication against the peer’s public key returns a 32-byte shared secret. RFC 7748 Section 6.1 says implementations MAY check for and abort on the all-zero u-coordinate (Langley et al., 2016). RFC 10024 Section 4.3 strengthens this to a normative MUST for TLS: the endpoint aborts the connection with an illegal_parameter alert if the X25519 shared secret is all zero (Kwiatkowski et al., 2026). The wire-format ciphertext is 1120 bytes: mlkem_ct followed by the sender’s 32-byte X25519 ephemeral public key. The group’s shared secret is ss_mlkem || ss_x25519, 64 bytes, and TLS hands it to the key schedule. Block 1 stands a single HKDF-SHA256 call in for that schedule.

Decaps mirrors encaps. The receiver splits the 1120-byte ciphertext, runs ML-KEM-768 decapsulation against its mlkem_dk and X25519 scalar multiplication against the sender’s ephemeral public key, and concatenates the component secrets in the same order. If both component decapsulations succeed, both sides hold the same 64-byte shared secret, and Block 1’s HKDF stand-in then derives the same 32-byte key on each side.

Figure 27.1 shows the three columns of the X25519MLKEM768 KEM flow: KeyGen, Encaps, and Decaps. The wire-format sizes connect them: 1216 bytes of public key flow from KeyGen to Encaps, 1120 bytes of ciphertext flow from Encaps to Decaps, and each side independently forms the same 64-byte shared secret, ML-KEM part first, for the TLS 1.3 key schedule.

X25519MLKEM768 hybrid KEM flow. Three columns labeled KeyGen, Encaps, and Decaps show the X25519MLKEM768 hybrid KEM. KeyGen runs ML-KEM-768 keygen and X25519 keygen, concatenates the public keys, ML-KEM key first, into a 1216-byte wire value, and passes it to Encaps. Encaps runs both component encapsulations and concatenates the two 32-byte secrets, ML-KEM secret first, into the group's 64-byte shared secret, which the TLS 1.3 key schedule consumes; the output is a 1120-byte ciphertext and that 64-byte secret. Decaps mirrors the operation and recovers the same 64-byte secret. KeyGen (Alice) Encaps (Bob) Decaps (Alice) ML-KEM-768 keygen ek: 1184 B, dk: 2400 B X25519 keygen pk: 32 B, sk: 32 B pk = ek || x_pk 1216 B on the wire ML-KEM-768 encaps ct: 1088 B, ss_mlkem: 32 B X25519 scalar mult x_pk_e: 32 B, ss_x: 32 B ss = ss_mlkem || ss_x 64 B, RFC 10024 §4.3 to TLS 1.3 key schedule ct = 1120 B ML-KEM-768 decaps ss_mlkem: 32 B X25519 scalar mult ss_x: 32 B ss = ss_mlkem || ss_x 64 B, RFC 10024 §4.3 same key schedule input Wire format: pk = mlkem_ek || x25519_pk (1216 B); ct = mlkem_ct || x25519_pk_e (1120 B). NamedGroup codepoint 0x11EC per RFC 10024.
Figure 27.1. The X25519MLKEM768 hybrid KEM flow. KeyGen concatenates the ML-KEM-768 and X25519 public keys into a 1216-byte wire value. Encaps runs both component encapsulations and concatenates the two 32-byte secrets into the group's 64-byte shared secret (RFC 10024 §4.3), which the TLS 1.3 key schedule consumes. The wire carries a 1120-byte ciphertext, and Decaps recovers the same secret. Blocks 1 and 2 stand a single HKDF-SHA256 call in for the key schedule and derive a 32-byte key.

Block 2 runs the encapsulation side of the combiner across two stubbed KEMs, each standing in for one component of the hybrid, so the concatenate-and-derive step runs without any ML-KEM or elliptic-curve code. It stops at the derived key and does not decapsulate, so it shows the combiner rather than agreement between two parties. The round trip is in the package below.

# Block 2: the encapsulation side of the combiner, with two stubbed
# component KEMs. No decapsulation here. Stdlib only.
import hashlib, hmac, os
def hkdf_sha256(ikm, info, length=32):
prk = hmac.new(b"\x00" * 32, ikm, hashlib.sha256).digest()
out, t, counter = b"", b"", 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
out += t
counter += 1
return out[:length]
# Stand-in KEM: a mock whose keygen/encaps agree on a deterministic shared secret.
def stub_keygen(seed):
return hashlib.sha256(b"pk" + seed).digest(), seed
def stub_encaps(pk, rand):
ss = hashlib.sha256(b"ss" + pk + rand).digest()
ct = hashlib.sha256(b"ct" + pk + rand).digest()
return ct, ss
# Round-trip the hybrid. Two stubs play the ML-KEM and X25519 roles.
pk_a, sk_a = stub_keygen(b"alice-mlkem-seed".ljust(32, b"0"))
pk_b, sk_b = stub_keygen(b"alice-x25519-seed".ljust(32, b"0"))
ct1, ss1 = stub_encaps(pk_a, b"m1".ljust(32, b"0"))
ct2, ss2 = stub_encaps(pk_b, b"m2".ljust(32, b"0"))
k = hkdf_sha256(ss1 + ss2, b"tls13 x25519_mlkem768")
print(len(k), k.hex()[:16])
# ==> 32 e9c2d5701e91f142

The block outputs a 32-byte hybrid key and its first eight bytes as hex. The specific hex prefix follows from the fixed seeds and the HKDF construction. Any change to either the seeds or the label changes the prefix.

The runnable hybrid_kem_keygen, hybrid_kem_encaps, and hybrid_kem_decaps in the ch27-hybrid package under solutions/ replace both stubs: the ML-KEM stub becomes ml_kem_keygen_internal + ml_kem_encaps_internal + ml_kem_decaps_internal from solutions/ch11-mlkem/, and the X25519 stub becomes the Montgomery ladder in x25519_scalarmult. The tests at tests/ch27/test_hybrid_kem_roundtrip.py exercise wire-format sizes (1216 / 1120 / 32), round-trip agreement, and one tampered ciphertext byte deriving a different secret.

The explicit-composite form in draft-ietf-lamps-pq-composite-sigs-19 pairs ML-DSA with a traditional signature algorithm. The draft defines combinations with Ed25519, Ed448, ECDSA-P256, and RSA-PSS, among others (Ounsworth et al., 2026). One compact composite profile for modern PKI is ML-DSA-65 with Ed25519, registered in Section 6 as id-MLDSA65-Ed25519-SHA512 with OID 1.3.6.1.5.5.7.6.48. Verify is AND-mode: both component signatures must validate.

The jwt_signing touchpoint from Ch 25 is used here as an application-layer signature example, with that scope limit understood.

Signing does not feed the raw message into both algorithms. Section 2.2 of the draft constructs a composite message representative

M=PrefixLabellen(ctx)ctxPH(M),M' = \mathit{Prefix} \,\|\, \mathit{Label} \,\|\, \mathrm{len}(\mathit{ctx}) \,\|\, \mathit{ctx} \,\|\, \mathrm{PH}(M),

where Prefix is the 32-byte ASCII constant CompositeAlgorithmSignatures2025, Label is the algorithm-specific string COMPSIG-MLDSA65-Ed25519-SHA512, ctx is an application-supplied context of at most 255 bytes, and PH is the prehash function (SHA-512 for this combination). The signer computes mldsa_sig = ML-DSA.Sign(mldsa_sk, M', mldsa_ctx=Label) and ed_sig = Ed25519.Sign(ed_sk, M'), then serializes the result as mldsa_sig || ed_sig per Section 4.3. ML-DSA’s signature comes first; the traditional signature follows.

Sizes come from the component specifications. ML-DSA-65 has a 1952-byte public key and a 3309-byte signature per FIPS 204 Table 2 (National Institute of Standards and Technology, 2024b). The Ed25519 public key is 32 bytes and its signature is 64 bytes per RFC 8032 (Josefsson & Liusvaara, 2017). The composite public key is 1984 bytes (1952 + 32) and the composite signature is 3373 bytes (3309 + 64). The serialization split point in a composite signature is therefore byte 3309: bytes [0:3309] are the ML-DSA-65 signature and bytes [3309:3373] are the Ed25519 signature.

Figure 27.2 shows the ML-DSA-65+Ed25519 composite signature structure per draft-ietf-lamps-pq-composite-sigs-19 Section 4.3. A single signed object carries the 3309-byte ML-DSA-65 signature followed by the 64-byte Ed25519 signature. The verify path runs both component verifiers over the shared message representative M' and the corresponding public-key half. The composite is accepted only when both return true.

ML-DSA-65+Ed25519 composite signature structure. A composite signed object carries two side-by-side boxes in the order specified by draft-ietf-lamps-pq-composite-sigs-19 Section 4.3: a 3309-byte ML-DSA-65 signature followed by a 64-byte Ed25519 signature. The two boxes are schematic and are not drawn to scale; the ML-DSA-65 half is about 52 times the size of the Ed25519 half, and each box states its own byte count. Below, two verifier boxes labeled ML-DSA-65 verify and Ed25519 verify feed into a single AND gate. The composite is accepted only when both component verifiers return true. Composite signed object: mldsa_sig || ed_sig mldsa_sig (bytes 0..3308) 3309 B (FIPS 204 Table 2) ed_sig (bytes 3309..3372) 64 B (RFC 8032) Verify path (AND-mode) ML-DSA-65 verify (mldsa_pk, M', mldsa_sig) → bool Ed25519 verify (ed_pk, M', ed_sig) → bool mldsa_ok AND ed_ok Composite valid only when both component verifications return true.
Figure 27.2. The ML-DSA-65+Ed25519 composite signature structure. The signed object serializes mldsa_sig (3309 bytes) followed by ed_sig (64 bytes) per draft-ietf-lamps-pq-composite-sigs-19 Section 4.3. Verification runs both component verifiers in parallel over the shared message representative M' and accepts only in AND-mode, when both return true.

Section 3.1 of the draft is normative on key reuse: a component key used in a composite signature MUST NOT be re-used in a non-composite standalone-algorithm context, nor in a different composite-algorithm combination. The constraint binds both the ML-DSA key and the Ed25519 key. A migration that wants to keep a single Ed25519 root and add ML-DSA as a parallel root cannot share the Ed25519 key with the composite. It needs a fresh Ed25519 key dedicated to the composite identifier.

Block 3 uses one-line HMAC stand-ins for both signature components so the inline code stays compact. It demonstrates AND-mode only. The byte layout, the message representative M', and the algorithm-specific context binding are all simplified, so Block 3 is not the LAMPS wire format. The runnable composite_sig_sign and composite_sig_verify in the ch27-hybrid package under solutions/ are the draft-aligned version: the package serializes mldsa_sig || ed_sig per Section 4.3, and it wires in a full Ed25519 implementation (the RFC 8032 Section 6 reference, as ed25519_sign and ed25519_verify) alongside a documented ML-DSA stub (mldsa65_sign_stub and mldsa65_verify_stub). The stub is a hash-based binding placeholder with the correct FIPS 204 byte lengths. Chapter 12 builds the real thing, and the mldsa package under solutions/ch12-mldsa matches the NIST ACVP vectors for ML-DSA-65 byte-for-byte. Swapping it in for the stub is a one-line change. The stub stays because the signature half of this chapter is about the combiner rather than about ML-DSA, and a placeholder with the right byte lengths exercises every line of the combiner that a real signer would.

# Block 3: AND-mode explicit-composite sign/verify with HMAC stand-ins, stdlib only.
import hashlib, hmac
SECRET_A = b"ed25519-sk-stand-in"
SECRET_B = b"mldsa-sk-stand-in"
def sign_a(msg): return hmac.new(SECRET_A, msg, hashlib.sha256).digest()
def sign_b(msg): return hmac.new(SECRET_B, msg, hashlib.sha512).digest()
def verify_a(msg, sig): return hmac.compare_digest(sig, sign_a(msg))
def verify_b(msg, sig): return hmac.compare_digest(sig, sign_b(msg))
def composite_sign(msg):
return sign_a(msg) + sign_b(msg)
def composite_verify(msg, sig):
sa, sb = sig[:32], sig[32:]
# AND-mode: both components must pass.
# Python's `and` short-circuits, so a failed verify_a skips verify_b.
# That is fine for a stdlib toy. Production verifiers evaluate both
# halves regardless to avoid partial-validity state and timing leaks.
return verify_a(msg, sa) and verify_b(msg, sb)
msg = b"webhook-payload-2026-04-17"
sig = composite_sign(msg)
tampered_ed = bytes([sig[0] ^ 1]) + sig[1:]
tampered_mldsa = sig[:32] + bytes([sig[32] ^ 1]) + sig[33:]
print(composite_verify(msg, sig),
composite_verify(msg, tampered_ed),
composite_verify(msg, tampered_mldsa))
# ==> True False False

Tampering either half makes the composite invalid. In Block 3, sign_a produces a 32-byte sha256 digest. sign_b uses sha512, and its 64-byte digest occupies sig[32:]. The split at byte 32 correctly assigns each segment to its verifier in the toy. The runnable package sig_combiner.py splits at byte 3309 instead, since the LAMPS draft serializes mldsa_sig || ed_sig and the real ML-DSA-65 signature is 3309 bytes per FIPS 204 Table 2. The tests/ch27/test_composite_sig_roundtrip.py suite exercises the AND-mode rule against the real Ed25519 implementation and the ML-DSA stub, with the package’s draft-aligned 3309-byte split point.

OR-mode composite signatures (the composite is valid if either component verifies) are discussed in the academic hybrid-signature literature but are not in the LAMPS composite-sigs draft, which specifies AND-mode only (Ounsworth et al., 2026). OR-mode is not used for post-quantum transition: its threat model (“either algorithm alone is enough”) negates the at-least-one-holds property the hybrid is designed to provide.

The combiner security argument is conditional on four properties, on top of the ciphertext input that keys the analysed construction’s second PRF. Each of the four can be violated by an implementation choice rather than by a new cryptanalytic result.

KDF choice. The Bindel et al. 2019 argument treats the KDF as a secure dual-PRF (Bindel et al., 2019). A weaker or unmodelled KDF means the cited combiner proof no longer applies. The construction may or may not be insecure in practice, but it is no longer justified by the Bindel-Giacon-style argument. NIST SP 800-56C Revision 2 Section 5 specifies a two-step extract-then-expand KDF for key-establishment schemes, with HMAC or AES-CMAC for the extraction step and an SP 800-108 PRF-based function for the expansion step (Barker et al., 2020). A combiner that substitutes a hand-rolled hash(a || b) sits outside the SP 800-56C procedure and outside the Bindel et al. dual-PRF model, so the formal argument no longer applies.

Independence of component randomness. The argument assumes the two component KEMs use independent randomness. A shared-state bug, where the same random bytes or non-domain-separated RNG state feeds both the ML-KEM encapsulation seed and the X25519 ephemeral scalar, can violate the independence assumption. Drawing two domain-separated values from a CSPRNG is fine; accidental reuse or correlation is not. The combiner itself cannot detect this. The only defense is at the RNG boundary.

Negotiation integrity. The hybrid must be negotiated as a single NamedGroup rather than two independently-downgradable groups. RFC 7696 Section 2.4 says algorithm selection or negotiation “SHOULD be integrity protected”, and names the downgrade attack as the consequence of skipping it (Housley, 2015). TLS 1.3 satisfies that for NamedGroups through the transcript: CertificateVerify signs a hash covering the ClientHello, so a modified group list makes the client’s verification fail (RFC 9846 §4.5.2) (Rescorla, 2026). A deployment that lists X25519 and X25519MLKEM768 as alternatives without the handshake-integrity binding is downgradable to the classical-only option.

Combiner implementation. The combiner is a small piece of code, but a bug in it compromises both components. An off-by-one in the HKDF info string, a truncation of one component secret to a single byte, or a swap of the two secrets can silently weaken the hybrid without breaking either component in isolation. The mitigation is test vectors: tests/ch27/test_hybrid_kem_kdf_consistency.py pins the combiner output against fixed inputs so a regression is caught before deployment.

FREAK (2015) and Logjam (2015) are the historical analogs. FREAK forced TLS clients and servers to accept 512-bit export-grade RSA as a valid alternative when the modern handshake was available; Logjam did the same for 512-bit export-grade Diffie-Hellman. Both attacks succeeded because the protocol accepted a weak cryptographic alternative without integrity protection binding the choice to the handshake. A hybrid defended only at the cryptographic layer, not at the negotiation layer, is exposed to the same class of attack.

The composite signature has a separate exposure that AND-mode verification does not address. AND-mode protects the recipient who runs it. It says nothing about a verifier somewhere else who is handed one component on its own. RFC 9955 works this through for hybrid signatures generally (Bindel et al., 2026). Strip one component signature off a concatenated hybrid, present it with the signed message to a standalone verifier for that component, and that verifier accepts. The result is an EUF-CMA forgery against the component key, because the standalone signer was never called on that message. Section 5 notes that the target “does not need to be the intended recipient of the hybrid-signed message and may even be in an entirely different system”. Enforcing hybrid verification at the intended recipient therefore does not close it.

The construction above resists this asymmetrically, and the asymmetry comes from the component algorithms rather than from the composite design. The ML-DSA half is signed with mldsa_ctx=Label, so a standalone ML-DSA verifier accepts the stripped signature only if it supplies that same context string. Ed25519 has no context parameter. Its half is a plain signature over the message representative MM', so any standalone Ed25519 verifier handed MM' accepts. The label COMPSIG-MLDSA65-Ed25519-SHA512 sits inside MM', which makes the separation visible to anyone who inspects what was signed, but the signature check itself still succeeds.

That shape is the one RFC 9955 uses to separate its two grades: an artifact carried in the message rather than in the signature. Under weak non-separability a separated component signature still verifies and leaves evidence behind. Under strong non-separability it fails verification outright (Sections 1.3.3 and 1.3.4). Section 5 puts the requirement as a choice of two: either component algorithm forgeries are impossible in the use case, or the hybrid is strongly non-separable. It is explicit that the weak form “is insufficient for mitigating risks of component algorithm forgeries”. The key-reuse prohibition in Section 3.1 of the composite draft is what this construction relies on instead, and RFC 9955 is precise about that mitigation’s standing: it “is still a policy requirement and not a cryptographic assurance”.

Bandwidth and compute sit at the core. The table names the byte-size cost for each construction next to its pure-classical and pure-PQ peers. All sizes are from the cited standards or reference implementations. The threat-model column names what the construction protects against.

ConstructionPublic keyCiphertext / signatureThreat model
X25519 alone (Langley et al., 2016)32 B32 B (ECDH output)Classical ECDLP only
ML-KEM-768 alone (National Institute of Standards and Technology, 2024a)1184 B1088 BModule-LWE; post-quantum IND-CCA
X25519MLKEM768 (Kwiatkowski et al., 2026)1216 B1120 BAt-least-one-holds: classical DH or Module-LWE
Ed25519 alone (Josefsson & Liusvaara, 2017)32 B64 BClassical EC-EdDSA only
ML-DSA-65 alone (National Institute of Standards and Technology, 2024b)1952 B3309 BModule-SIS / Module-LWE; post-quantum EUF-CMA
ML-DSA-65+Ed25519 (Ounsworth et al., 2026)1984 B3373 BAt-least-one-holds: ECDLP (Ed25519) or Module-SIS / Module-LWE, given AND-mode verification and a collision-resistant message representative

The bandwidth cost of the hybrid over the pure-PQ construction is small: 32 bytes extra public key and 32 bytes extra ciphertext for the KEM, 32 bytes extra public key and 64 bytes extra signature for the composite. The larger byte cost is always the ML-KEM or ML-DSA component. Compute cost does not follow bytes. On one measured 2023 x86-64 core an X25519 shared-secret computation takes about twice the cycles of an ML-KEM-768 encapsulation, and an Ed25519 verification costs about the same as a Dilithium3 verification (Bernstein & Lange, 2026). The two comparisons come off different SUPERCOP pages, the first off the Diffie-Hellman and KEM results and the second off the signature results. Which half of a hybrid dominates is therefore a property of the operation and the implementation, not of the scheme. Published TLS-handshake benchmarks report both halves as a fraction of a millisecond on commodity x86.

RFC 9794 distinguishes hybrid confidentiality (hybrid KEMs) from hybrid authentication (composite signatures) (Driscoll et al., 2025). The two categories do not move together, and the standards record now shows the gap directly. Hybrid confidentiality reached Standards Track as RFC 10024 in August 2026, with codepoint 0x11EC marked Recommended in the IANA registry (IANA, 2026; Kwiatkowski et al., 2026) and shipping implementations in browsers and TLS libraries. Hybrid authentication is still a draft in the RFC Editor queue, with no comparable deployment. Certificate chains that carry composite signatures multiply the classical plus post-quantum signature size at every intermediate, so the PKI implications become the load-bearing cost. Ch 29 treats PKI and code signing. The scope here is the cryptographic construction.

The revision of TLS 1.3 itself records the same shift. RFC 9846’s change list says it now uses “more generic language for the asymmetric key exchange, which was previously exclusively (EC)DHE, to reflect the use of KEM-based key exchange” (Rescorla, 2026). The specification stopped assuming its key exchange was a Diffie-Hellman one, which is the wording change a KEM-based hybrid needed before it could be described in the base protocol’s own terms rather than as an extension to it.

RFC 9955 is the signature-side companion to that vocabulary (Bindel et al., 2026). It names ten design goals for hybrid signatures, running from hybrid authentication and the two non-separability grades through to space efficiency and minimal duplicate information. It makes no concrete recommendation and proposes no instantiation, on the stated grounds that which of the ten a design needs is a property of the use case. Its vocabulary differs from this chapter’s in one place. RFC 9955 avoids “composite scheme” and “composite signature” (Section 1.1), because RFC 9794 reserves “composite” for a hybrid exposed as a single interface of the same type as its components. The LAMPS draft built above keeps the term.

The ML-DSA-65+Ed25519 row above is fee-and-gas-limited at the per-transaction layer on a chain. A 3,373-byte signature is large compared with the 64-byte ECDSA or Schnorr signature on a typical Layer-1 transaction. Ch 37 derives the gas and fee cost on Ethereum and the witness-byte cost on Bitcoin.

The same composite fits when it signs a long-lived governance key. The 1,984-byte composite public key (1,952 B ML-DSA-65 per FIPS 204 Table 2 (National Institute of Standards and Technology, 2024b) plus 32 B Ed25519) is fetched once at validator-set commit or at a hard-fork activation block and cached at every verifier. The 3,373-byte signature (3,309 B ML-DSA-65 per FIPS 204 Table 2 (National Institute of Standards and Technology, 2024b) plus 64 B Ed25519) lands at every governance event, not at every user transaction. The trade is hedging (both ML-DSA-65 and Ed25519 must be forged for compromise) against bandwidth. Long-lived governance keys absorb the bandwidth cost; per-transaction keys do not. Chapter 37 walks the L1 transaction signature migration; Chapter 39 walks consensus and staking signatures.

Where Chapter 27 ends and Chapter 28 picks up

Section titled “Where Chapter 27 ends and Chapter 28 picks up”

This chapter stopped at the construction: the combiner, the wire format, the security argument that makes the pair worth running, and the four ways an implementation can void that argument without breaking either component. It has said nothing about how a fleet gets from a TLS 1.2 endpoint to a hybrid one.

Chapter 28 takes the same tls_endpoint_api touchpoint from the Ch 25 inventory and runs it as an operation under a deadline. It assesses which components in the path already speak X25519MLKEM768, configures the servers, and rolls the change out progressively, using a NamedGroup rollup over the connection log as the signal to advance or stop. Its cryptanalysis section deliberately does not repeat this one. The combiner-level surface is settled here. What Ch 28 adds sits at the group-list level, where the question is what a fleet advertises rather than how the secrets are combined.

The signature half of this chapter goes elsewhere. Composite signatures matter where certificates do, so Ch 29 picks up id-MLDSA65-Ed25519-SHA512 in the PKI and code-signing setting, including the chain-wide cost this chapter’s Tradeoffs section only names.

  1. Replace the concatenate-then-HKDF combiner in Block 2 with a dual-PRF combiner of the form HKDF(ss_a || ss_b, label) XOR HKDF(ss_b || ss_a, label). Verify that the round-trip still agrees. Does the Bindel et al. 2019 argument still apply? Cite the specific result.

  2. Add SLH-DSA-SHA2-128s (National Institute of Standards and Technology, 2024c) as a third signature component to the AND-mode composite in Block 3. The SLH-DSA-SHA2-128s signature is 7,856 bytes per FIPS 205 Table 2. Write the combined sign and verify functions and compute the new composite signature size.

  3. Write a wire-format parser that takes a 1216-byte ClientHello X25519MLKEM768 keyshare and splits it into the ML-KEM-768 encapsulation key (1184 bytes) and the X25519 public key (32 bytes). Reject any input whose length is not 1216.

  4. A deployment advertises two supported NamedGroups: X25519 (0x001D) and X25519MLKEM768 (0x11EC). An attacker on the network strips X25519MLKEM768 from the ClientHello. Describe which integrity mechanism (TLS 1.3 transcript hash, RFC 7696 §2.4, or neither) prevents the client from accepting the X25519-only fallback. Cite the relevant section.

  5. Pick one of Ch 26’s six architectural areas where hybrid KEM is a direct mitigation and one where it is not. Explain why, referring to the specific Ch 26 subsection.

Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 27. A separate track, for rebuilding rather than reading: the package exercises/ch27-hybrid has every function the chapter teaches replaced by a stub. Run PQC_IMPL=exercises pytest tests/ch27 to grade your version against the suite that proves the reference one.

Barker, E., Chen, L., & Davis, R. (2020). Recommendation for Key-Derivation Methods in Key-Establishment Schemes. NIST Special Publication 800-56C, Revision 2. https://doi.org/10.6028/NIST.SP.800-56Cr2
Barnes, R., Bhargavan, K., Lipp, B., & Wood, C. A. (2022). Hybrid Public Key Encryption. IETF RFC 9180. https://doi.org/10.17487/RFC9180
Bernstein, D. J., & Lange, T. (2026). eBACS: ECRYPT Benchmarking of Cryptographic Systems, SUPERCOP results for amd64-hertz (AMD Ryzen 7 7700, Zen 4). eBACS amd64-hertz results, sign, kem and dh pages; supercop-20260831, read 14 September 2026. https://bench.cr.yp.to/results-sign/amd64-hertz.html
Bindel, N., Brendel, J., Fischlin, M., Goncalves, B., & Stebila, D. (2019). Hybrid Key Encapsulation Mechanisms and Authenticated Key Exchange. Post-Quantum Cryptography (PQCrypto 2019), 11505, 206–226. https://doi.org/10.1007/978-3-030-25510-7_12
Bindel, N., Hale, B., Connolly, D., & Driscoll, F. (2026). Hybrid Signature Spectrums. IETF RFC 9955. https://doi.org/10.17487/RFC9955
Driscoll, F., Parsons, M., & Hale, B. (2025). Terminology for Post-Quantum Traditional Hybrid Schemes. IETF RFC 9794. https://doi.org/10.17487/RFC9794
Giacon, F., Heuer, F., & Poettering, B. (2018). KEM Combiners. Public-Key Cryptography (PKC 2018), 10769, 190–218. https://doi.org/10.1007/978-3-319-76578-5_7
Housley, R. (2015). Guidelines for Cryptographic Algorithm Agility and Selecting Mandatory-to-Implement Algorithms. IETF RFC 7696 (BCP 201). https://doi.org/10.17487/RFC7696
IANA. (2026). Transport Layer Security (TLS) Parameters: Supported Groups. IANA registry tls-parameters-8 (Supported Groups). https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8
Josefsson, S., & Liusvaara, I. (2017). Edwards-Curve Digital Signature Algorithm (EdDSA). IETF RFC 8032. https://doi.org/10.17487/RFC8032
Krawczyk, H., & Eronen, P. (2010). HMAC-based Extract-and-Expand Key Derivation Function (HKDF). IETF RFC 5869. https://doi.org/10.17487/RFC5869
Kwiatkowski, K., Kampanakis, P., Westerbaan, B., & Stebila, D. (2026). Post-Quantum Traditional (PQ/T) Hybrid Key Agreement Mechanisms for TLS 1.3. RFC 10024. https://doi.org/10.17487/RFC10024
Langley, A., Hamburg, M., & Turner, S. (2016). Elliptic Curves for Security. IETF RFC 7748. https://doi.org/10.17487/RFC7748
National Institute of Standards and Technology. (2024a). FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.203
National Institute of Standards and Technology. (2024b). FIPS 204: Module-Lattice-Based Digital Signature Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.204
National Institute of Standards and Technology. (2024c). FIPS 205: Stateless Hash-Based Digital Signature Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.205
Ounsworth, M., Gray, J., Pala, M., Klaussner, J., & Fluhrer, S. (2026). Composite ML-DSA for Use in Internet PKI. IETF Internet-Draft, draft-ietf-lamps-pq-composite-sigs-19. https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-sigs/
Rescorla, E. (2026). The Transport Layer Security (TLS) Protocol Version 1.3. RFC 9846. https://doi.org/10.17487/RFC9846

Last updated: