Add ML-KEM support to HPKE (draft-ietf-hpke-pq-05) - #3277
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3277 +/- ##
==========================================
+ Coverage 78.02% 78.13% +0.11%
==========================================
Files 699 699
Lines 124589 125147 +558
Branches 17286 17316 +30
==========================================
+ Hits 97205 97780 +575
+ Misses 26516 26498 -18
- Partials 868 869 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
🔒 Security Review — View Report Please review before merging. |
| // Note this grew from 32 to 1568 when the ML-KEM KEMs were added, and | ||
| // |EVP_HPKE_KEY| grew with it. Callers which stack-allocate an |EVP_HPKE_KEY| or | ||
| // size buffers by this constant must be rebuilt against this header. | ||
| #define EVP_HPKE_MAX_PUBLIC_KEY_LENGTH 1568 |
There was a problem hiding this comment.
Adding a note on how BoringSSL handled the same problem upstream, since it informed the approach here and it may save a reviewer the archaeology.
Upstream hit this exact situation four separate times as they added KEMs, and each time they grew the constants and kept evp_hpke_key_st a POD with inline arrays. They never moved to heap pointers:
| Change | MAX_PUBLIC_KEY_LENGTH |
MAX_ENC_LENGTH |
MAX_PRIVATE_KEY_LENGTH |
|---|---|---|---|
P-256 KEM (0a2d3a4de092) |
32 → 65 | 32 → 65 | 32 |
X-Wing (0697c8805166) |
65 → 1216 | 65 → 1120 | 32 |
ML-KEM-768 (b887f19ede3d) |
1216 | 1120 | 32 → 64 |
ML-KEM-1024 (706742e482d8) |
1216 → 1568 | 1120 → 1568 | 64 |
Two things worth drawing out of that history:
The P-256 change is where the struct stopped being X25519-shaped. That CL replaced uint8_t private_key[X25519_PRIVATE_KEY_LEN] / public_key[X25519_PUBLIC_VALUE_LEN] with private_key[EVP_HPKE_MAX_PRIVATE_KEY_LENGTH] / public_key[EVP_HPKE_MAX_PUBLIC_KEY_LENGTH], i.e. they deliberately tied the struct to these macros so that adding a KEM is a one-line constant bump. That is the shape this PR adopts, and it is why the struct here is
struct evp_hpke_key_st {
const EVP_HPKE_KEM *kem;
uint8_t private_key[EVP_HPKE_MAX_PRIVATE_KEY_LENGTH];
uint8_t public_key[EVP_HPKE_MAX_PUBLIC_KEY_LENGTH];
};rather than the three heap pointers an earlier revision of this PR used.
The ML-KEM-768 CL is independent confirmation of the seed format. It sets MAX_PRIVATE_KEY_LENGTH to 64, not to 2400, which is the same conclusion drawn here from draft-ietf-hpke-pq-05 §3: an ML-KEM private key is the 64-byte d || z seed, not the expanded decapsulation key. Current upstream values are 1568 / 64 / 1568, identical to this PR.
Relevant caveat for the ABI discussion: upstream makes no ABI promise and has no abidiff gate, so they absorbed these four size changes silently. We cannot, which is why the ABI question is called out separately in the description. But it does mean that following their approach keeps a future KEM addition to raising these constants rather than reshaping the struct.
Correction (superseded by later commits): this comment originally claimed the layout stays byte-compatible with upstream. That is no longer true. EVP_HPKE_KEY now also caches the expanded ML-KEM decapsulation key, taking sizeof to 4808 rather than 1640, because upstream's seed expansion is PCT-free and ours is not — re-deriving per decapsulation would run a key-generation health test, with fatal module-failure semantics and a DRBG draw, on a path reached from the network. The fixed-size-inline-array approach is shared with upstream; the exact layout is not. The PR description has been updated to match.
There was a problem hiding this comment.
But it does mean that matching their layout keeps the two libraries byte-compatible, and that a future KEM addition here will be a constant bump rather than another structural change.
I doubt this is still true with the latest changes.(?)
There was a problem hiding this comment.
Corrected in the comment above, and in the two places the description asserted it: sizeof(EVP_HPKE_KEY) is 4808 rather than 1640, and we share upstream's fixed-size-inline-array approach but are no longer byte-identical to it, since we cache the expanded decapsulation key and upstream does not.
The half that still holds is that adding a future KEM is a matter of raising these constants rather than reshaping the struct.
| if (meth->encapsulate_deterministic(out_enc, &enc_len, out_shared_secret, | ||
| &shared_secret_len, peer_public_key, | ||
| seed) != 0) { | ||
| OPENSSL_PUT_ERROR(EVP, EVP_R_INVALID_PEER_KEY); | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
What is the intended service-indicator behavior of the public HPKE APIs?
These direct backend calls bypass the explicit ML-KEM updates in EVP_PKEY_encapsulate and EVP_PKEY_decapsulate, but FIPS decapsulation's PCT calls RAND_bytes, which can update the indicator anyway. If HPKE is not an approved service, should the HPKE operation lock the indicator to suppress updates from internal approved primitives? If HPKE is approved, shouldn't it lock internally and update exactly once at the HPKE boundary? Could we add a test for the intended behavior?
There was a problem hiding this comment.
The intended behavior is that HPKE leaves the service indicator unchanged, since it is not an approved service. It now does: each call locks the indicator for its duration, so the approved primitives underneath cannot bump it.
That covers 11 of the 21 public functions — the ones that perform crypto: KEY_init, KEY_generate, the four sender-setup variants, both recipient-setup variants, seal, open and export. Each is now a thin wrapper that locks, makes one call and unlocks, with the body moved to a static. I did it that way rather than bracketing the returns because those functions have 27 exits between them, and one missed unlock would leave the indicator locked for the rest of the thread's life. The other ten are lifecycle and accessor functions that only move or clear memory, so there is nothing to suppress. Exported symbols are unchanged.
Where the updates were actually coming from: HKDF (hkdf.c:35-50), AES-GCM (AEAD_GCM_verify_service_indicator) and RAND_bytes (rand.c:588). Not X25519 — all seven FIPS_service_indicator_update_state sites in curve25519.c are Ed25519, and X25519 is correctly uninstrumented since it is not approved. ML-KEM has no update sites either. So HPKE over X25519 was already moving the counter before this change; this is not ML-KEM specific.
Verified on a FIPS build, since none of it is observable otherwise: 112 HPKE tests pass, including a ServiceIndicatorNotApproved case for each of the five suites, covering keygen, key import, sender setup, seal, recipient setup, open and export. Removing just the lock/unlock calls makes all five fail, so the tests detect the lock rather than passing by construction. Full FIPS crypto_test is green at 4117.
There is no ACVP coverage to add here: ACVP has no HPKE algorithm — it registers primitives plus a few named protocols, with ml-kem present only as the primitive — and util/fipstools/ has no HPKE support either.
One point I would like your read on: crypto/hpke is outside the module, and I did not find precedent for calling FIPS_service_indicator_lock_state from outside crypto/fipsmodule/. It links, and they are unregistered intra-library symbols, but say if you would rather reach them another way.
justsmth
left a comment
There was a problem hiding this comment.
The PR description is out of date.
| // Note this grew from 32 to 1568 when the ML-KEM KEMs were added, and | ||
| // |EVP_HPKE_KEY| grew with it. Callers which stack-allocate an |EVP_HPKE_KEY| or | ||
| // size buffers by this constant must be rebuilt against this header. | ||
| #define EVP_HPKE_MAX_PUBLIC_KEY_LENGTH 1568 |
There was a problem hiding this comment.
But it does mean that matching their layout keeps the two libraries byte-compatible, and that a future KEM addition here will be a constant bump rather than another structural change.
I doubt this is still true with the latest changes.(?)
justsmth
left a comment
There was a problem hiding this comment.
For ECHServerConfig::Init, ECH is only defined over DHKEM(X25519, HKDF-SHA256), and the client offers nothing else, but |EVP_HPKE_KEY| can hold other KEMs, so check explicitly.
if (ech_config_.kem_id != EVP_HPKE_DHKEM_X25519_HKDF_SHA256) {
OPENSSL_PUT_ERROR(SSL, SSL_R_UNSUPPORTED_ECH_SERVER_CONFIG);
return false;
}
Implement ML-KEM-512, ML-KEM-768 and ML-KEM-1024 as HPKE KEMs, enabling post-quantum HPKE. Also add HKDF-SHA384, so the priority ciphersuite HPKE(ML-KEM-1024, HKDF-SHA384, AES-256-GCM) is available. Built on the existing ML-KEM implementation in crypto/fipsmodule/ml_kem; no new cryptographic primitives are introduced. Per section 3 of the draft, a private key is serialized as the 64-byte (d || z) seed rather than the expanded decapsulation key returned by ML-KEM.KeyGen, so Nsk is 64 for every parameter set and EVP_HPKE_MAX_PRIVATE_KEY_LENGTH stays at 64. Only the seed is stored; the expanded key is re-derived per decapsulation with ml_kem_*_keypair_deterministic, which consumes exactly that seed. The ML-KEM shared secret is used directly as the HPKE shared secret, with no DHKEM ExtractAndExpand step, and the encapsulation key is checked on encap so that a bad key surfaces as an EncapError. ML-KEM cannot do AuthEncap/AuthDecap, so the auth hooks are NULL and the mode_auth entry points fail. Supporting a 1568-byte ML-KEM encapsulation key means evp_hpke_key_st can no longer hold keys in 32-byte inline arrays, so EVP_HPKE_MAX_ENC_LENGTH and EVP_HPKE_MAX_PUBLIC_KEY_LENGTH grow to 1568 and sizeof(EVP_HPKE_KEY) grows from 72 to 1640 bytes. The struct keeps fixed-size inline storage, matching BoringSSL's layout, rather than taking owning heap pointers behind an API documented as stack-allocatable. This is an ABI change and the abidiff jobs will report it. Known-answer tests come from the working group's test-vectors.json, the draft's [TestVectors] citation, vendored alongside RFC 9180's. Following upstream, they are generated into their own hpke_test_vectors_pq.txt; hpke_test_vectors.txt is unchanged. The vector harness gained an optional kem_id attribute, defaulting to DHKEM(X25519) when absent, and reads ikmE/enc for ML-KEM where DHKEM uses skEm/pkEm. Verified against draft-ietf-hpke-pq-05 (6 July 2026): section 3 is unchanged from -04, so nothing normative moved for ML-KEM. The published vectors were regenerated in -05, and the vendored test-vectors.json is already the -05 set, so no vectors needed regenerating.
|
On the Added, thanks — this was reachable through the public API, not just in principle. I put the check with the other unsupported-parameter checks, before the cipher suite loop, and added a case to |
Implements ML-KEM-512, ML-KEM-768 and ML-KEM-1024 as HPKE KEMs per draft-ietf-hpke-pq-05, enabling post-quantum HPKE. Also adds HKDF-SHA384, so the priority suite HPKE(ML-KEM-1024, HKDF-SHA384, AES-256-GCM) is available.
Built on AWS-LC's existing ML-KEM implementation in
crypto/fipsmodule/ml_kem/; no new cryptographic primitives are introduced.Spec conformance
Three points worth calling out explicitly:
The ML-KEM shared secret is used directly, with no
ExtractAndExpand. DHKEM runs its shared secret throughExtractAndExpand; ML-KEM does not. The draft defines Encap/Decap as ML-KEM.Encaps and ML-KEM.Decaps directly, and Nsecret = 32 matches ML-KEM's native shared secret length. The generic RFC 9180 key schedule is unchanged and still processes the shared secret.A private key is the 64-byte
d || zseed, not the expanded decapsulation key. FIPS 203 returns the expanded form,dk = dk_PKE || ek_PKE || H(ek_PKE) || z:FIPS 203 page 16

The draft does not use that form. It is explicit: "the decapsulation key is returned in seed format rather than the expanded form returned by ML-KEM.KeyGen", and Nsk is 64 for every parameter set. So
EVP_HPKE_KEM_private_key_len()returns 64, not 1632/2400/3168, andEVP_HPKE_MAX_PRIVATE_KEY_LENGTHstays at 64. That is the serialized form: the seed is whatEVP_HPKE_KEY_initaccepts and whatEVP_HPKE_KEY_private_keyemits. Internally the seed is expanded once, at import or generation, withml_kem_*_keypair_deterministic, which takes exactly the 64-byte seed, and the expanded key is cached in the struct — see the FIPS section for why. This matters for interoperability — every published test vector'sskRmis 64 bytes, and a peer implementing the draft cannot import an expanded key. BoringSSL reaches the same conclusion independently:EVP_HPKE_MAX_PRIVATE_KEY_LENGTHis 64 there too.Auth mode is refused for ML-KEM. ML-KEM cannot do AuthEncap/AuthDecap (draft §7.2), so the
auth_encap_with_seedandauth_decaphooks are NULL andEVP_HPKE_CTX_setup_auth_sender/_auth_recipientfail withEVP_R_OPERATION_NOT_SUPPORTED_FOR_THIS_KEYTYPE.The encapsulation key is validated on encap via
ml_kem_*_check_pk, since the draft requires an encapsulation key check failure to surface as an HPKE EncapError.mlk_kem_enc_derandin the backend already performs the same modulus check, so this call is belt-and-braces — it makes the intent explicit and gives the failure a distinguishable reason code.Error codes differ slightly from upstream: a bad ML-KEM peer key raises
EVP_R_INVALID_PEER_KEYwhere upstream raisesEVP_R_DECODE_ERROR. Ours is consistent with the X25519 path in the same file and with RFC 9180's EncapError, so it is intentional.Behaviour change to
EVP_HPKE_KEY_cleanupEVP_HPKE_KEY_cleanupwas a documented no-op. It now cleanses both secrets — the seed and the cached expanded decapsulation key — and clearskem, returning the key to the zero state, and tolerates NULL.Clearing
kemmatters more than it looks. Cleansing alone would leavekemset and the public key intact with an all-zero private key — and every 64-byte string is a valid ML-KEM seed, while a zero X25519 scalar is clamped to a valid one. A use-after-cleanup would therefore succeed, decapsulating under a key anyone can compute, rather than failing. Clearingkemmakes that path fail instead.Six entry points read
key->kemwithout checking it, so on a key with no KEM they dereference NULL:EVP_HPKE_KEY_public_key,EVP_HPKE_KEY_private_key,EVP_HPKE_CTX_setup_recipient, both auth-sender setups, andEVP_HPKE_CTX_setup_auth_recipient. That is pre-existing —mainhas the same unguarded code — but there it was only reachable by passing a key that had never been initialized, and clearingkemin cleanup adds a second route to it. All six now fail withEVP_R_NO_KEY_SET.HPKETest.ZeroedKeyFailsCleanlycovers them; with the guards removed it terminates with SIGSEGV rather than failing, so the test detects the guard rather than passing by construction.Secrets also no longer outlive the calls that use them. The derived shared secret is cleansed once the key schedule has consumed it, and the encapsulation entropy is cleansed after sender setup returns, in both the base and auth paths.
A failed
EVP_HPKE_KEY_initorEVP_HPKE_KEY_generatenow cleanses as well, rather than only clearingkem.mlkem_init_keyderives the expanded decapsulation key straight into the struct, so a failure after that point would otherwise leave key material behind in a key the caller has been told is unusable.FIPS builds
Service indicator. HPKE is not an approved service, so these APIs must leave the service indicator unchanged. Without that, an HPKE call leaves a counter differential from the approved primitives underneath — HKDF (
hkdf.c:35-50), AES-GCM (AEAD_GCM_verify_service_indicator) andRAND_bytes(rand.c:588) — which a caller would read as an approved service having been performed. The 11 public entry points that perform crypto now lock the indicator for the duration of the call, with the bodies moved tostaticfunctions; the other ten are lifecycle and accessor functions that only move or clear memory, so there is nothing to suppress. Exported symbols are unchanged. This is not ML-KEM specific — HPKE over X25519 was moving the counter before this change too. Verified on FIPS builds on x86-64 and aarch64: aServiceIndicatorNotApprovedtest per ML-KEM suite, plus an X25519 test covering the four entry points the ML-KEM suites cannot reach (auth-mode sender, auth-mode recipient, and both deterministic sender setups). Removing only the lock/unlock calls makes the corresponding tests fail, so they detect the lock rather than passing by construction.Where the keygen PCT lands. In FIPS builds the ML-KEM key generation entry point runs a pairwise consistency test — a full encapsulation and decapsulation — gated by
MLK_CONFIG_KEYGEN_PCT, which is set exactly when AWS-LC is built in FIPS mode. The expanded decapsulation key has to be derived through that entry point, becausecrypto/fipsmodule/ml_kem/ml_kem.hexposes no PCT-free seed-expansion function today andcrypto/fipsmodule/ml_kem/mlkem/is a pristine import driven byimporter.sh, so it should not be patched fromcrypto/hpke. The struct therefore caches the expanded key at import or generation, so the PCT is paid once per key rather than on every decapsulation. That placement is deliberate: without the cache, a key generation health test with fatal module-failure semantics would sit on a path reached from the network. One consequence worth noting for reviewers is thatEVP_HPKE_KEY_init, which imports a caller-supplied seed rather than generating a key, also pays the PCT.ABI impact — needs a maintainer decision
struct evp_hpke_key_stis public and stack-allocatable. An ML-KEM-1024 encapsulation key is 1568 bytes, so the struct cannot keep holding keys in 32-byte inline arrays;sizeof(EVP_HPKE_KEY)goes from 72 to 4808 bytes.EVP_HPKE_MAX_PUBLIC_KEY_LENGTHandEVP_HPKE_MAX_ENC_LENGTHalso change from 32 to 1568, and the struct additionally caches the expanded ML-KEM decapsulation key (EVP_HPKE_MAX_EXPANDED_PRIVATE_KEY_LENGTH, 3168 bytes) so that decapsulation performs no key generation. See the FIPS section above for why that cache is there.The four
abidiffjobs will therefore report an ABI change, and there is no suppression mechanism in.github/docker_images/abidiff/diff.sh— it fails on anyabidiffexit >= 4. Perdocs/SymbolVersioning.mdthis implies anABI_VERSIONbump and a new SONAME, which is a release-level decision affecting every consumer, so I have not made it here. Please advise whether you want the bump in this PR or handled as part of a release.Some notes to inform that decision:
sizeofsmall but madeEVP_HPKE_KEYa non-trivially-copyable type with owning pointers behind an API documented as stack-allocatable. That caused several problems — re-initialising a key leaked its old key material,EVP_HPKE_KEY_copy(k, k)freed the key and returned success, andEVP_HPKE_KEY_zerosilently stopped scrubbing the private key. Fixed-size inline storage removes that whole class of bug, and matches how the library handles the same situation elsewhere (union evp_aead_ctx_st_state'sopaque[564]ininclude/openssl/aead.h, andCRYPTO_MUTEX's sized padding ininclude/openssl/thread.h).Two ECH stack buffers in libssl are sized by these macros and grow accordingly (
ssl/handshake_client.cc:339,ssl/encrypted_client_hello.cc:522). ECH negotiates only DHKEM(X25519, HKDF-SHA256), so they cannot actually be filled beyond 32 bytes; sizing them by the X25519 lengths instead would avoid the growth, but that is a separate cleanup and is left out to keep this PR focused.Relatedly,
ECHServerConfig::Initnow rejects an ECHConfig whosekem_idis not DHKEM(X25519, HKDF-SHA256). It already required the config'skem_idto match the configured key, but anEVP_HPKE_KEYcan now hold an ML-KEM key, so a config and key which agreed on ML-KEM were accepted for a protocol that is only defined over X25519.SSL_marshal_ech_configtakes the KEM from the key, so such a config was reachable through the public API.SSLTest.UnsupportedECHConfigcovers it, and fails if the check is removed.Relationship to BoringSSL
Upstream implements the same draft. Links below are pinned to
e5a214a2:crypto/hpke/hpke.cc— their implementation, in particularstruct MLKEMHPKE,PRIVATE_KEY_LEN = MLKEM_SEED_BYTESandHpkeDecap, which re-expands the stored seed on every decapsulation. This change stores the same 64-byte seed, but expands it once at import or generation and caches the result, for the FIPS reason above.include/openssl/hpke.h— their public header, in particularstruct evp_hpke_key_standEVP_HPKE_MAX_PRIVATE_KEY_LENGTH 64, which independently corroborates the seed key format.This change deliberately follows upstream's public API while diverging on implementation:
EVP_HPKE_MLKEM512/_MLKEM768/_MLKEM1024(noKEM_infix), matching upstream's spelling, asEVP_hpke_mlkem768andEVP_HPKE_HKDF_SHA384already did.EVP_HPKE_MAX_PRIVATE_KEY_LENGTH 64and the seed-format private key match upstream too, so consumers built against either library see the same API and the same serialized key format.hpke.cc), part of a library-wide C-to-C++ migration — theircrypto/fipsmodulehas no.cfiles left. AWS-LC is not following that migration, so this stays in C. Upstream's ML-KEM parameterisation needs C++ templates because their ML-KEM API is built on opaque types; ours is byte buffers plus lengths, so a smallMLKEM_METHODtable of function pointers expresses the same thing and the three parameter sets share one implementation.<openssl/mlkem.h>and a BCM layer (BCM_mlkem768_encap_external_entropy) that AWS-LC does not have. We usecrypto/fipsmodule/ml_kem/, which already exposes deterministic encapsulation directly, so no equivalent plumbing is needed.Testing
Known-answer tests come from the WG's machine-readable vectors, the
[TestVectors]citation in the draft:crypto/hpke/test-vectors-pq.json, fetched from https://github.com/hpkewg/hpke-pq. These are vendored the same way RFC 9180'stest-vectors.jsonalready is, sotranslate_test_vectors.pystays reproducible.Following upstream, which keeps
hpke_test_vectors_pq.txtseparate from the RFC 9180 file, the PQ vectors are generated into their owncrypto/hpke/hpke_test_vectors_pq.txt.crypto/hpke/hpke_test_vectors.txtis regenerated and is byte-for-byte unchanged frommain.Three suites are covered — (ML-KEM-512, HKDF-SHA256, AES-128-GCM), (ML-KEM-768, HKDF-SHA256, AES-128-GCM) and (ML-KEM-1024, HKDF-SHA384, AES-256-GCM). The fourth ML-KEM vector in the JSON uses TurboSHAKE256, which this library does not implement, and is filtered out by the script.
These are real KATs, so they pin the wire format rather than just internal self-consistency:
encfixes Nenc and the encapsulation,skRmat 64 bytes fixes Nsk,pkRmfixes Npk, and the ciphertexts and exported values fix the key schedule including the HKDF-SHA384 (Nh = 48) path. I confirmed the vectors are genuinely exercised by corrupting oneencvalue and checking the suite fails, rather than trusting a green run.The vector harness gained an optional
kem_idattribute, defaulting to DHKEM(X25519) when absent so the RFC 9180 vectors are unaffected. ML-KEM vectors carryikmEandencwhere DHKEM vectors carryskEmandpkEm, because ML-KEM has no ephemeral key pair — encapsulation takes 32 bytes of entropy and emits a ciphertext.HPKETest.RoundTripnow sweeps every KEM rather than just X25519, as upstream's does, skipping auth mode for the ML-KEM KEMs. That covers combinations the fixed parameter table misses, notably ML-KEM-512/768 with HKDF-SHA384 and with ChaCha20-Poly1305, across threeinfoand threeadvalues.New tests beyond the KATs cover round-trip and multi-message sealing for each suite, key serialization round-trip, copy/move including self-copy and self-move, auth-mode rejection, re-initialising an already-initialised key, rejection of an invalid encapsulation key on encap, an ML-KEM-1024 encapsulation key passed where an
encis expected, seed perturbation producing a distinct valid key, implicit rejection of a corrupted encapsulation, use-after-cleanup failing, re-initialization after cleanup succeeding, the zero state after a failed initialization, every entry point rejecting a key with no KEM, cleanup of a NULL key and cleanup leaving the zero state, the service indicator behaviour described above, and the length and buffer-size error paths. Every negative test asserts the specific reason code rather than just a false return, matching the existing X25519 tests.One note on the vendored JSON: the WG file double-encodes
infoandpt— the values are the hex encoding of an ASCII hex string, soinfodecodes to the text4f6465206f6e2061204772656369616e2055726erather than to "Ode on a Grecian Urn". The published ciphertexts were computed over those literal bytes, so they are passed through verbatim. There is a comment intranslate_test_vectors.pyto stop someone "fixing" it later.Performance
1000 iterations. Encap/Decap are
setup_sender/setup_recipient, so they include the key schedule. Keygen is measured separately, and for ML-KEM it carries the seed expansion. Re-measured after the expanded key was cached, so decap no longer expands the seed.Non-FIPS
RelWithDebInfobuild, Apple M-series, macOS. Relative numbers are what matter; these are not comparable to the x86 figures in an earlier revision of this description.Every ML-KEM suite is faster than X25519 DHKEM here, including ML-KEM-1024. The seed expansion sits in the keygen column rather than in decap, which is the point of caching it: a key is imported or generated once and then decapsulates many times. FIPS builds add the keygen PCT to keygen and to key import, as described above; these numbers do not include it.
Re-verified against draft-ietf-hpke-pq-05
Draft -05 was published on 6 July 2026, after this work started. Section 3 (ML-KEM) is unchanged from -04, and the IANA parameter table rows for 0x0040/0x0041/0x0042 are identical, so nothing normative moved for this implementation and no code change was needed. The section numbers cited here and in the code (§3, §7.2, §8.1) are unchanged in -05.
The published test vectors were regenerated in -05 — none of the -04 ML-KEM values survive. The vendored
test-vectors-pq.jsonis already the -05 set: all four ML-KEM suites'ikmE,skRmandshared_secretvalues appear in -05 and none appear in -04. Regenerating from it leaveshpke_test_vectors_pq.txtbyte-identical, so the KATs are current and were not touched.One item for future work: TurboSHAKE is now RFC 9861 rather than
draft-irtf-cfrg-kangarootwelve, which matters if the single-stage SHAKE/TurboSHAKE KDFs (0x0010-0x0013) are implemented later.Out of scope
Deliberately not included, to keep this reviewable:
DeriveKeyPair/EVP_HPKE_KEY_derive, which the draft defines over SHAKE256. Upstream has it. Not required by anything here, since the KATs loadskRmdirectly, but it does mean the vectors'ikmRpath is not exercised.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license and the ISC license.