ML-KEM: ASN.1 Module - add BOTH private key format and validate expanded keys - #3452
Open
jakemas wants to merge 11 commits into
Open
ML-KEM: ASN.1 Module - add BOTH private key format and validate expanded keys#3452jakemas wants to merge 11 commits into
jakemas wants to merge 11 commits into
Conversation
Hook up KEM_check_key to EVP_PKEY_check and EVP_PKEY_public_check,
enabling key validation for ML-KEM key types through the standard
EVP_PKEY interface.
KEM_check_key validates based on available key material:
- Public key only: validates encoding via ml_kem_*_check_pk
- Secret key present: validates both keys and performs a Pairwise
Consistency Test (PCT) via encaps/decaps comparison
Uses the existing ml_kem_{512,768,1024}_check_{pk,sk} functions and
performs PCT through the KEM_METHOD function pointers with CRYPTO_memcmp
for constant-time shared secret comparison.
…rors Addresses review feedback on the KEM key-checking path: - EVP_PKEY_check now requires the private key for KEM keys, mirroring the EC and RSA cases (EVP_PKEY_check validates the full key pair). It checks for the secret key via the new KEM_KEY_get0_secret_key accessor before calling KEM_check_key. EVP_PKEY_public_check is unchanged and still validates whatever material is available. - Replace the generic CRYPTO/ERR_R_INTERNAL_ERROR results in KEM_check_key with descriptive EVP reason codes: EVP_R_MISSING_PUBLIC_KEY, EVP_R_INVALID_PUBLIC_KEY, EVP_R_INVALID_PRIVATE_KEY, and EVP_R_KEM_PCT_FAILED for the Pairwise Consistency Test failure. - The unreachable default branches in kem_check_public_key/secret_key now report ERR_R_INTERNAL_ERROR rather than silently returning 0. - Update the EVP_PKEY_check / EVP_PKEY_public_check documentation in evp.h to describe the KEM behavior. - Update the KEMCheckKeyTests so that a public-key-only key fails EVP_PKEY_check but passes EVP_PKEY_public_check.
…goto - kem.c relied on bcm.c include order for EVP_R_* and NID_MLKEM* symbols; include <openssl/evp.h> directly so the file is self-contained. - Move ct_len/ss_enc_len/ss_dec_len declarations above the malloc-failure goto so it no longer jumps past their initialization.
OPENSSL_cleanse and OPENSSL_free both no-op on NULL, so the `if (x != NULL)` guards before them in the PCT cleanup block are unnecessary. Simplify to unconditional calls.
…terial Queue errors in kem_check_public_key/kem_check_secret_key rather than in KEM_check_key, so exactly one error is reported per failure. In kem_check_pct, distinguish library faults from a genuine pairwise consistency failure: encaps/decaps errors and length mismatches now raise ERR_R_INTERNAL_ERROR, and only a shared-secret mismatch raises EVP_R_KEM_PCT_FAILED. An allocation failure keeps the error OPENSSL_malloc already queued. Guard the KEM case of EVP_PKEY_check against an EVP_PKEY whose type is set but which holds no key material, and let KEM_KEY_get0_secret_key accept a NULL key, since callers use its return value to test for a secret key. Replace the duplicated encaps/decaps consistency check in EVP_PKEY_kem_check_key with a call to KEM_check_key, which performs the same check and additionally validates both key encodings. Assert the specific reason code for every negative case in KEMCheckKeyTests, and that the error queue is empty afterwards, so the one-error-per-failure behaviour is covered.
jakemas
force-pushed
the
mlkem-asn1-both
branch
from
August 26, 2026 21:15
5984887 to
2e9cda5
Compare
jakemas
marked this pull request as draft
August 26, 2026 21:19
jakemas
force-pushed
the
mlkem-asn1-both
branch
from
August 26, 2026 21:24
2e9cda5 to
db34caf
Compare
Contributor
|
🔒 Security Review — View Report Please review before merging. |
jakemas
force-pushed
the
mlkem-asn1-both
branch
from
August 26, 2026 21:40
db34caf to
539eb8b
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3452 +/- ##
==========================================
+ Coverage 78.06% 78.09% +0.03%
==========================================
Files 700 700
Lines 124704 125036 +332
Branches 17325 17366 +41
==========================================
+ Hits 97356 97653 +297
- Misses 26481 26511 +30
- Partials 867 872 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jakemas
marked this pull request as ready for review
August 27, 2026 17:25
…check EVP_PKEY_kem_check_key's comment now points at EVP_PKEY_check when a private key is available and EVP_PKEY_public_check for a public-only key, rather than saying EVP_PKEY_check accepts a public-only key, which it does not. EVP_PKEY_public_check's KEM branch now rejects an EVP_PKEY whose type is set but which has no KEM_KEY attached, reporting EVP_R_NO_KEY_SET instead of letting KEM_check_key report ERR_R_PASSED_NULL_PARAMETER. This matches the EVP_PKEY_check branch. KEMCheckKeyTests covers both entry points for that state; it fails on all three parameter sets without the guard.
Add the third ML-KEM-XX-PrivateKey CHOICE from RFC 9935 section 6,
both SEQUENCE { seed, expandedKey }, to kem_priv_decode.
KEM_KEY_set_raw_keypair_from_both performs the seed consistency check
from RFC 9935 section 8: the expanded key is regenerated from the seed
via ML-KEM.KeyGen_internal(d, z) and compared bytewise against the
presented expandedKey. Inconsistent key pairs are rejected.
RFC 9935 section 8 defers to FIPS 203 section 7.3, which requires a hash check before an expanded ML-KEM decapsulation key is used. kem_priv_decode accepted the expandedKey CHOICE verbatim, so the two expandedKey examples from RFC 9935 Appendix C.4.1 parsed cleanly. An expanded decapsulation key embeds its own encapsulation key (dk = dk_PKE || ek || H(ek) || z), so KEM_KEY_set_raw_expanded_secret_key recovers ek and validates the resulting pair with KEM_check_key: the section 7.3 hash check, plus a pairwise consistency test that catches corruption of dk_PKE which the hash check cannot see. This applies to the encoded private key path only. EVP_PKEY_kem_new_raw_secret_key keeps taking raw bytes at face value, as EVP_PKEY_kem_new_raw_key does; a caller importing a key of unknown provenance can validate it with EVP_PKEY_check. Its evp.h contract is updated to say so.
The CHOICE is the entire contents of the privateKey OCTET STRING, so bytes following it are malformed DER. EVP_parse_private_key rejects trailing data inside the outer PKCS#8 SEQUENCE but does not re-examine the privateKey contents after priv_decode returns, so kem_priv_decode has to catch this. All three cases now check that |key| is fully consumed. Every other decoder in crypto/evp_extra checks this already (ed25519, x25519, dsa, rsa, ec, dh), so the seed and expandedKey cases are brought in line rather than left as the outlier. Well-formed DER is unaffected: keys generated by OpenSSL 3.6 and BouncyCastle 1.82 still parse.
EVP_PKEY_kem_new_raw_secret_key leaves the public component unset, and KEM_check_key needs both halves to compare them, so EVP_PKEY_check on such a key always fails with EVP_R_MISSING_PUBLIC_KEY. Pointing callers at it was advice that cannot be followed. Direct them to supply both halves with EVP_PKEY_kem_new_raw_key, or to parse the PKCS#8 encoding, which validates. Also fix the EVP_PKEY_kem_check_key comment, which said EVP_PKEY_check accepts a public-only key. EVP_PKEY_check requires the private key; EVP_PKEY_public_check is the one that does not. Documentation only; no behavior change.
jakemas
force-pushed
the
mlkem-asn1-both
branch
from
August 27, 2026 17:28
857f918 to
9a7e10c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Stacked on #2709 — please merge that first. This branch is #2709's branch plus three commits:
ML-KEM: parse the RFC 9935 both private key format,ML-KEM: validate expanded keys when parsing a private key, andML-KEM: reject trailing data after the private key CHOICE. The second needsKEM_check_keyand theEVP_R_*reason codes that #2709 adds, so it does not build without it.#2709 is fork-based, so GitHub will not let me set it as this PR's base branch; the diff below therefore currently includes #2709's changes as well. Once #2709 merges to
mainthe diff here collapses to just the three commits above on its own. To review only this change in the meantime:git diff <#2709 head>..<this head>, or read the three commits individually.Related issues
Related to #2416 — the ML-DSA counterpart of the
bothparsing added here.Related to #2709 — this PR consumes the
KEM_check_keymachinery it adds.Context and motivation
RFC 9935 section 6 defines three
ML-KEM-XX-PrivateKeyCHOICE encodings:Two gaps, which together are why the RFC's four "bad private key" examples in Appendix C.4.1 all imported cleanly before this change:
bothwas not implemented. It was stubbed with aTODOinkem_priv_decodeand rejected outright. The format exists so a producer can serve peers that support only one of the two representations, so we should be able to consume it.expandedKeywas accepted and failed — or silently misbehaved — only later.Description of changes
bothCHOICE. Added tokem_priv_decode, dispatched on theSEQUENCE(0x30) tag alongside the existing[0](0x80) andOCTET STRING(0x04) cases, with seed and expandedKey lengths validated against the parameter set.KEM_KEY_set_raw_keypair_from_boththen performs the seed consistency check required by section 8: the expanded key is regenerated from the seed viaML-KEM.KeyGen_internal(d, z)and compared bytewise against the presentedexpandedKey.The bytewise comparison is load-bearing, and this differs from the ML-DSA
bothhandling in #2416, which compares derived public keys. For ML-KEM a public-key comparison is not enough: anexpandedKeythat differs from the seed only inz, the implicit rejection secret, still yields a matching public key and still passes a pairwise consistency check. That is C.4.1 example 4, and it is why the RFC specifies bytewise equality. It is also the one corruption that #2709'scheck_sk+ PCT cannot detect, as that PR's call-out notes — so the two mechanisms are complementary rather than redundant.Expanded key validation. An expanded decapsulation key embeds its own encapsulation key (
dk = dk_PKE || ek || H(ek) || z), so the newKEM_KEY_set_raw_expanded_secret_keyrecoversekand validates the resulting pair with #2709'sKEM_check_key: the FIPS 203 section 7.3 hash check, plus a pairwise consistency test that catches corruption ofdk_PKEthat the hash check cannot see. Both doors an expanded key can arrive through now go via it — the PKCS#8expandedKeyCHOICE andEVP_PKEY_kem_new_raw_secret_key.This applies to the encoded private key path only.
EVP_PKEY_kem_new_raw_secret_keykeeps taking raw bytes at face value, asEVP_PKEY_kem_new_raw_keydoes — see the note under review considerations for why. Itsevp.hcontract is updated to say so explicitly, and to route callers who need validation toEVP_PKEY_kem_new_raw_keyplusEVP_PKEY_check, or to PKCS#8 parsing.Keys parsed from
bothretain their seed, sokem_priv_encodere-serializes them in the seed format section 6 RECOMMENDS.kem_priv_encodeitself is unchanged.Testing
All vectors are from RFC 9935 Appendix C. With this change, every C.4.1 example is rejected, each by the mechanism the RFC describes and with a distinct reason code:
both)EVP_R_DECODE_ERRORexpandedKey)s_0, validH(ek)EVP_R_KEM_PCT_FAILEDexpandedKey)H(ek)EVP_R_INVALID_PRIVATE_KEYboth)zonly; public keys still matchEVP_R_DECODE_ERRORNew
KEMBothFormatTestsuite, parameterized over ML-KEM-512/768/1024 with the "Both Format" examples from C.1.1.3, C.1.2.3 and C.1.3.3:ParsePrivateKeyBoth— parses, and cross-checks against the other encodings of the same key pair: the expanded secret key matches the C.1.x.2expandedKeyexample, the retained seed matches the C.1.x.1 seed example, and the derived public key matches both the seed-only key and the C.2 example public key.BothFormatReEncodesAsSeed— re-serializing reproduces the seed-only example byte for byte.BothFormatEncapsDecapsRoundTrip— the parsed key is usable for decapsulation.KEMTest.ParsePrivateKeyBothInconsistentcovers C.4.1 examples 1 and 4. Example 4 is the one that pins the bytewise comparison: without it, that key parses successfully.KEMTest.ParsePrivateKeyExpandedInconsistentcovers C.4.1 examples 2 and 3, asserting the distinct reason codes above so the two checks are covered separately rather than as one pass/fail, and asserting rejection throughEVP_PKEY_kem_new_raw_secret_keyas well as PKCS#8.KEMTest.ParsePrivateKeyBothInvalidLengthcovers a truncatedbothSEQUENCE.Local results:
crypto_test2933 passed,ssl_test2579 passed, 0 failures (skips are pre-existing and platform-specific).Interop testing
Both OpenSSL and BouncyCastle emit the
bothCHOICE by default, so before this change AWS-LC could not parse any ML-KEM private key either of them produced. Verified by buildingmain+ #2709 in a separate worktree and parsing the same files:genpkey -algorithm ML-KEM-{512,768,1024}DECODE_ERRORKeyPairGenerator("ML-KEM-{512,768,1024}")DECODE_ERRORFull matrix against OpenSSL 3.6.3 and BouncyCastle 1.82 (bcprov-jdk18on, JDK 21), for all three parameter sets — all pass:
bothCHOICE, 1706 / 2474 / 3242-byteprivateKeyfor 512 / 768 / 1024) → AWS-LC parses it;seedCHOICE per section 6's RECOMMENDED (66-byteprivateKey) → the peer reads it back and still decapsulates to the same secret.That last point exercises the round trip in both directions:
bothin,seedout, and the peer accepts the result.The four Appendix C.4.1 bad keys were also run through the
openssl pkeytool built from this branch, confirming end to end (not just in unit tests) that each is rejected with the reason code in the table above.Review considerations
Validation is scoped to the encoded-key path, and deliberately not to
EVP_PKEY_kem_new_raw_secret_key. RFC 9935 section 8 governs parsing the ASN.1 private key, which is what this PR changes; a raw-bytes importer is a different contract, where the caller asserts validity. aws-lc-rs relies on that contract for ML-KEM —DecapsulationKey::newaccepts an all-zero decapsulation key of correct length, and aws-lc-rs asserts that a key built from raw private bytes has no encapsulation key — so validating there would be a breaking change for a first-party consumer.Scoping it this way also costs little in practice. FIPS 203 section 7.3 requires the hash check "before use", and mlkem-native's
mlk_kem_decalready performs it at decapsulation time (mlkem/kem.c), so a raw-imported bad key is not silently usable; validating at parse time mainly improves when and how clearly it is reported. The PCT is the only genuinely new check, and FIPS does not require a PCT on imported keys.Note that
EVP_PKEY_checkis not usable on a key fromEVP_PKEY_kem_new_raw_secret_key: ML-KEM validation compares the two halves of the pair, and that constructor leaves the public half unset, so the call fails withEVP_R_MISSING_PUBLIC_KEY. A caller who wants the full check on raw bytes must supply both halves viaEVP_PKEY_kem_new_raw_key. Theevp.hcomments now say this, and the neighbouringEVP_PKEY_kem_check_keycomment — which claimedEVP_PKEY_checkaccepts a public-only key, when that isEVP_PKEY_public_check— is corrected alongside it.Behavior change: an
expandedKeyparsed from PKCS#8 now has a public key. Becauseekis recovered fromdk,kem_priv_decodecase 2 now populatespublic_key. Such a key previously failedEVP_PKEY_encapsulateoutright, so this makes it usable, but it does retire a state the tests covered:KEMTest.PubCmpExpandedPrivateKeyNullPublicbecomesPubCmpExpandedPrivateKeyand asserts the recovered public key matches the published one. The NULL-public_keybranch ofkem_pub_cmpis still reachable and still matters —X509_check_private_keyreaches it duringPKCS12_parse— so the test keeps covering it by clearing the field.Trailing data after the CHOICE is now rejected, for all three CHOICEs. The CHOICE is the entire contents of the
privateKeyOCTET STRING, so bytes following it are malformed DER.kem_priv_decodedid not verify thatkeywas fully consumed, so such bytes were silently ignored.EVP_parse_private_keyrejects trailing data inside the outer PKCS#8 SEQUENCE but never re-examines theprivateKeycontents afterpriv_decodereturns, sokem_priv_decodeis the only place this can be caught.This tightens the
seedandexpandedKeycases as well asboth.p_kem_asn1.cwas the outlier here:ed25519,x25519,dsa,rsa,ecanddhin the same directory all checkCBS_len(key) != 0. Well-formed DER is unaffected — the OpenSSL and BouncyCastle keys in the interop matrix above all still parse.Covered by
KEMBothFormatTest.TrailingDataAfterChoiceRejected, which re-encodes each of the three CHOICEs with one extra byte inside theprivateKeyOCTET STRING and assertsEVP_R_DECODE_ERROR. It asserts the unmodified key parses first, so a failure is attributable to the trailing byte and not to the re-encoding.Note that
pqdsa_priv_decodehas the same omission for ML-DSA. That is outside this PR's scope; happy to align the two in a follow-up.Cost. Parsing an
expandedKeyprivate key now runs a hash check plus one encaps/decaps. The hash check is negligible; the PCT is roughly keygen-scale. TheseedandbothCHOICEs are unaffected, andbothwas already doing a keygen. Since section 6 RECOMMENDS the seed format, the common path does not change. Raw byte import is unaffected.FIPS boundary.
KEM_KEY_set_raw_expanded_secret_keylives incrypto/fipsmodule/kem/kem.calongsideKEM_check_key. No public API or ABI change. Theevp.hcontract ofEVP_PKEY_kem_new_raw_secret_keyis updated to state explicitly that it takesinat face value and leaves the public key unset, and to name the two routes that do validate:EVP_PKEY_kem_new_raw_keyfollowed byEVP_PKEY_check, or parsing the PKCS#8 encoding.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.