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 standardization. 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), and it is a theorem about the combiners they define. 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

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.

RFC 10024 Section 5 gives the reason for the two orders in the table above, 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 characterization 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).

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, 2024a).

Raw X25519 scalar multiplication is a Diffie-Hellman primitive, not a KEM. RFC 9180 packages a Diffie-Hellman primitive into a KEM interface as DHKEM, by applying HKDF over the DH output together with both public keys (Barnes et al., 2022, sec. 4.1). Its DHKEM(X25519, HKDF-SHA256) has 32-byte public keys, encapsulated keys, and shared secrets (Barnes et al., 2022, sec. 7.1). RFC 9180 reports a preliminary analysis in which Hybrid Public Key Encryption (HPKE) built on DHKEM keeps IND-CCA2 security (Barnes et al., 2022, sec. 9.1.2). That analysis assumes that the gap Diffie-Hellman problem is hard, that Extract behaves as a random oracle and Expand as a pseudorandom function, and that HPKE’s authenticated encryption is IND-CPA-secure with integrity of ciphertexts (Barnes et al., 2022, sec. 9.1.2).

Neither TLS nor the package here runs DHKEM: both feed the raw scalar-multiplication output into the combiner (Kwiatkowski et al., 2026, sec. 4.3; Rescorla, 2026, sec. 7.4.2). RFC 9954, the hybrid framework RFC 10024 applies (Kwiatkowski et al., 2026, sec. 1), says that Diffie-Hellman viewed as a KEM “does not formally satisfy IND-CCA2 security” but is safe for ephemeral key exchange in TLS 1.3 (Stebila et al., 2026, sec. 2).

Bindel, Brendel, Fischlin, Goncalves, and Stebila 2019 give the combiner argument in their Section 3 (Bindel et al., 2019). The construction they analyze 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 when the dual PRF and the PRF are secure and at least one component KEM is IND-CCA (Bindel et al., 2019, sec. 3.2). 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 the ciphertext 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. Raw X25519 needs no break for that. Each X25519 public key has several others that anyone can compute and that “produce the same shared secrets” (Langley et al., 2016, sec. 7), so swapping one in for the X25519 half gives a different ciphertext with the same secret. DHKEM hashes both public keys into its output for this reason: HPKE “mitigates malleability problems” by “including all public keys in the context of the key schedule” (Barnes et al., 2022, sec. 9.1). In TLS the shares sit in the handshake transcript, and RFC 10024 says the security analysis “relies crucially on the TLS 1.3 message transcript” (Kwiatkowski et al., 2026, sec. 6). The cryptanalysis section runs the attack as Block 4.

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 (Giacon et al., 2018). Bindel et al. describe their dual-PRF combiner as inspired by the key derivation in TLS 1.3 and as modeling a proposal for hybrid key exchange in TLS 1.3 (Bindel et al., 2019, sec. 3.2).

The dual-PRF hypothesis is the load-bearing modeling assumption: the KDF must look pseudorandom when keyed by either of its two inputs. That analysis models HKDF as a dual PRF (Bindel et al., 2019, sec. 3.2). RFC 9954 relies on the same assumption: for fixed-length shared secrets it matches its TLS construction to the Bindel et al. dual-PRF combiner, “which is shown to preserve security under the assumption that the hash function is a dual-PRF” (Stebila et al., 2026, sec. 6). 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, 2024a)) 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, 2024a). 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 an all-zero shared secret and abort (Langley et al., 2016). TLS 1.3 makes the check a MUST (Rescorla, 2026, sec. 7.4.2), and RFC 10024 Section 4.3 applies it to the hybrid’s X25519 half, aborting the connection with an illegal_parameter alert if it fails (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. Not every change does: flipping the top bit of the X25519 half, which RFC 7748 masks (Langley et al., 2016, sec. 5), changes the ciphertext and leaves the secret. Block 4, in the cryptanalysis section, gets the same failure from a different equivalent share.

The composite signatures in draft-ietf-lamps-pq-composite-sigs-19 pair 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′=Prefix ∥ Label ∥ len(ctx) ∥ ctx ∥ PH(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 closer to the draft. 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 and no context parameter, so the package leaves out the mldsa_ctx=Label argument that the draft passes. 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. 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, valid if either component verifies, are not in the LAMPS composite-sigs draft, whose composites operate in a strict AND mode (Ounsworth et al., 2026, sec. 9.1). The same section describes the “OR” mode some PQ/traditional hybrids offer instead, in which an application can use one algorithm, the other, or both, so that legacy and upgraded applications can coexist (Ounsworth et al., 2026, sec. 9.1). OR-mode therefore has a migration use. What it gives up is forgery resistance: a verifier that accepts either component alone accepts any forgery against a broken component. With AND-mode verification the composite stays EUF-CMA secure while at least one component is, provided the prehash is collision resistant (Ounsworth et al., 2026, sec. 9.2.1), and that is the property this chapter’s profile is chosen for.

The combiner security argument is conditional on four properties, on top of the ciphertext input to the analyzed 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 unmodeled 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 downgraded a connection to 512-bit export-grade RSA through a bug in several client implementations, and Logjam to 512-bit export-grade Diffie-Hellman through a protocol flaw (Adrian et al., 2015, sec. 3.2). In Logjam, the server’s signature covered its Diffie-Hellman parameters but not the chosen cipher suite, and the Finished messages that bind the transcript were keyed by the exchange the attacker broke before the handshake completed (Adrian et al., 2015, sec. 3.1 and 3.4). A hybrid defended only at the cryptographic layer, not at the negotiation layer, is exposed to the same class of attack.

The ciphertext input, the condition those four properties sit on top of, is the one Block 1, Block 2 and the package all leave out. With X25519 as a component, that leaves the combined KEM without IND-CCA security while neither component is broken. Block 4 runs the attack the preliminaries describe on RFC 7748’s own Diffie-Hellman example. It inverts the u-coordinate of Bob’s public key modulo p=2255−19p = 2^{255} - 19 to get a twin share, which takes no key, and feeds both shares through Block 1’s derivation.

# Block 4: two X25519 shares, one shared secret, one combiner key. Stdlib only.
import hashlib, hmac, os
P = 2**255 - 19
def x25519(k, u):
# RFC 7748 Section 5: clamp the scalar, mask bit 255, run the ladder.
# The swap below branches on secret bits, so this is not constant-time.
k = bytearray(k)
k[0] &= 248
k[31] = (k[31] & 127) | 64
k = int.from_bytes(k, "little")
x1 = int.from_bytes(u, "little") & ((1 << 255) - 1)
x2, z2, x3, z3, swap = 1, 0, x1, 1, 0
for t in reversed(range(255)):
bit = (k >> t) & 1
if swap ^ bit:
x2, x3, z2, z3 = x3, x2, z3, z2
swap = bit
a, b, c, d = x2 + z2, x2 - z2, x3 + z3, x3 - z3
aa, bb, da, cb = a * a % P, b * b % P, d * a % P, c * b % P
e = aa - bb
x3, z3 = (da + cb) ** 2 % P, x1 * (da - cb) ** 2 % P
x2, z2 = aa * bb % P, e * (aa + 121665 * e) % P
if swap:
x2, z2 = x3, z3
return (x2 * pow(z2, P - 2, P) % P).to_bytes(32, "little")
def hkdf_sha256(ikm, info, length=32):
# Block 1's derivation: the two secrets go in, the ciphertexts do not.
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]
# RFC 7748 Section 6.1: Alice's private key, and Bob's public key, which
# plays the X25519 half of the ciphertext sent to Alice.
alice_sk = bytes.fromhex("77076d0a7318a57d3c16c17251b26645"
"df4c2f87ebc0992ab177fba51db92c2a")
share = bytes.fromhex("de9edb7d7b7dc1b4d35b61c2ece43537"
"3f8343c85b78674dadfc7e146f882b4f")
# The adversary sees only the ciphertext. Inverting the share's
# u-coordinate modulo p takes no key.
twin = pow(int.from_bytes(share, "little"), P - 2, P).to_bytes(32, "little")
# Alice decapsulates both. The ML-KEM half is unchanged, so its secret
# is the same both times.
ss_mlkem = os.urandom(32)
label = b"tls13 x25519_mlkem768"
k_share = hkdf_sha256(ss_mlkem + x25519(alice_sk, share), label)
k_twin = hkdf_sha256(ss_mlkem + x25519(alice_sk, twin), label)
print(twin != share, x25519(alice_sk, twin).hex()[:16], k_share == k_twin)
# ==> True 4a5d9d5ba4ce2de1 True

The block prints that the two shares differ, that the twin still gives Alice RFC 7748’s shared secret, which begins 4a5d9d5b, and that Block 1’s derivation then gives one key for both.

The inverse works because of T=(0,0)T = (0, 0), a point of order 2 on the curve: it has v=0v = 0, so T=−TT = -T. The chord through TT and a point (u,v)(u, v) has slope v/uv/u. Substituting that line into Curve25519’s equation v2=u3+486662u2+uv^2 = u^3 + 486662u^2 + u (Langley et al., 2016, sec. 4.1) gives a cubic in the u-coordinate whose three roots, 00, uu and the u-coordinate of the chord’s third point, sum to (v/u)2−486662(v/u)^2 - 486662. The sum of TT and (u,v)(u, v) is that third point reflected, which keeps its u-coordinate, (v/u)2−486662−u=1/u(v/u)^2 - 486662 - u = 1/u, because (v/u)2=u+486662+1/u(v/u)^2 = u + 486662 + 1/u. A clamped scalar is a multiple of 8 (Langley et al., 2016, sec. 5), so it sends TT to the identity, and Alice’s scalar multiplication returns the same secret from either share.

An IND-CCA adversary handed the challenge ciphertext swaps in the twin share, which the decapsulation oracle must answer because the bytes differ, and reads the challenge key off the answer. So the at-least-one-holds property fails for this derivation with both components intact, in the sense that neither is broken: raw X25519 used as a KEM is not IND-CCA in the first place (Stebila et al., 2026, sec. 2). Nor is this an attack on X25519MLKEM768 in TLS, where the share is part of the transcript that the key schedule hashes into the handshake and application traffic secrets (Rescorla, 2026, sec. 7.1). Exercise 6 runs it against the package.

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 M′M', so any standalone Ed25519 verifier handed M′M' accepts. The label COMPSIG-MLDSA65-Ed25519-SHA512 sits inside M′M', 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. At the core’s 3,800 MHz clock, the four operations take about 7 to 31 microseconds each (Bernstein & Lange, 2026).

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 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, watching three signals at each phase and reading a NamedGroup rollup over the connection log for adoption. 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.

  6. Block 4 shows two X25519 shares that derive one key. Turn that into an attack on the package: take a ciphertext from hybrid_kem_encaps in solutions/ch27-hybrid as an IND-CCA challenge, and recover its key with one call to hybrid_kem_decaps on a different ciphertext. Then change the package’s _combine so that the component ciphertexts enter the derivation, as in the construction Bindel et al. analyze, and make the same call. What does each result show about the at-least-one-holds property, and what does neither show?

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.

Adrian, D., Bhargavan, K., Durumeric, Z., Gaudry, P., Green, M., Halderman, J. A., Heninger, N., Springall, D., Thomé, E., Valenta, L., VanderSloot, B., Wustrow, E., Zanella-Béguelin, S., & Zimmermann, P. (2015). Imperfect Forward Secrecy: How Diffie-Hellman Fails in Practice. Proceedings of the 22nd ACM SIGSAC Conference on Computer and Communications Security (CCS 2015), 5–17. https://doi.org/10.1145/2810103.2813707
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 Module-Lattice-Based Digital Signature Algorithm (ML-DSA) for use in X.509 Public Key Infrastructure. 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
Stebila, D., Fluhrer, S., & Gueron, S. (2026). Hybrid Key Exchange in TLS 1.3. IETF RFC 9954. https://doi.org/10.17487/RFC9954

Last updated: