Skip to content

ML-KEM: ASN.1 Module - add BOTH private key format and validate expanded keys - #3452

Open
jakemas wants to merge 11 commits into
aws:mainfrom
jakemas:mlkem-asn1-both
Open

ML-KEM: ASN.1 Module - add BOTH private key format and validate expanded keys#3452
jakemas wants to merge 11 commits into
aws:mainfrom
jakemas:mlkem-asn1-both

Conversation

@jakemas

@jakemas jakemas commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

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, and ML-KEM: reject trailing data after the private key CHOICE. The second needs KEM_check_key and the EVP_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 main the 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 both parsing added here.
Related to #2709 — this PR consumes the KEM_check_key machinery it adds.

Context and motivation

RFC 9935 section 6 defines three ML-KEM-XX-PrivateKey CHOICE encodings:

  ML-KEM-512-PrivateKey ::= CHOICE {
    seed [0] OCTET STRING (SIZE (64)),
    expandedKey OCTET STRING (SIZE (1632)),
    both SEQUENCE {
      seed OCTET STRING (SIZE (64)),
      expandedKey OCTET STRING (SIZE (1632))
      }
    }

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:

  1. both was not implemented. It was stubbed with a TODO in kem_priv_decode and 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.
  2. Expanded keys were accepted verbatim. Section 8 defers to FIPS 203 section 7.3, which requires a "hash check" before an expanded decapsulation key is used. We did not perform one at import, so a corrupted expandedKey was accepted and failed — or silently misbehaved — only later.

Description of changes

both CHOICE. Added to kem_priv_decode, dispatched on the SEQUENCE (0x30) tag alongside the existing [0] (0x80) and OCTET STRING (0x04) cases, with seed and expandedKey lengths validated against the parameter set. KEM_KEY_set_raw_keypair_from_both then performs the seed consistency check required by section 8: the expanded key is regenerated from the seed via ML-KEM.KeyGen_internal(d, z) and compared bytewise against the presented expandedKey.

The bytewise comparison is load-bearing, and this differs from the ML-DSA both handling in #2416, which compares derived public keys. For ML-KEM a public-key comparison is not enough: an expandedKey that differs from the seed only in z, 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's check_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 new KEM_KEY_set_raw_expanded_secret_key recovers ek and validates the resulting pair with #2709's KEM_check_key: the FIPS 203 section 7.3 hash check, plus a pairwise consistency test that catches corruption of dk_PKE that the hash check cannot see. Both doors an expanded key can arrive through now go via it — the PKCS#8 expandedKey CHOICE and EVP_PKEY_kem_new_raw_secret_key.

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 — see the note under review considerations for why. Its evp.h contract is updated to say so explicitly, and to route callers who need validation to EVP_PKEY_kem_new_raw_key plus EVP_PKEY_check, or to PKCS#8 parsing.

Keys parsed from both retain their seed, so kem_priv_encode re-serializes them in the seed format section 6 RECOMMENDS. kem_priv_encode itself 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:

C.4.1 example Corruption Rejected by Reason code
1 (both) seed and expandedKey disagree seed consistency check EVP_R_DECODE_ERROR
2 (expandedKey) mutated s_0, valid H(ek) pairwise consistency test EVP_R_KEM_PCT_FAILED
3 (expandedKey) mutated H(ek) FIPS 203 7.3 hash check EVP_R_INVALID_PRIVATE_KEY
4 (both) z only; public keys still match seed consistency check EVP_R_DECODE_ERROR

New KEMBothFormatTest suite, 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.2 expandedKey example, 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.ParsePrivateKeyBothInconsistent covers C.4.1 examples 1 and 4. Example 4 is the one that pins the bytewise comparison: without it, that key parses successfully.

KEMTest.ParsePrivateKeyExpandedInconsistent covers 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 through EVP_PKEY_kem_new_raw_secret_key as well as PKCS#8.

KEMTest.ParsePrivateKeyBothInvalidLength covers a truncated both SEQUENCE.

Local results: crypto_test 2933 passed, ssl_test 2579 passed, 0 failures (skips are pre-existing and platform-specific).

Interop testing

Both OpenSSL and BouncyCastle emit the both CHOICE by default, so before this change AWS-LC could not parse any ML-KEM private key either of them produced. Verified by building main + #2709 in a separate worktree and parsing the same files:

AWS-LC before (main + #2709) AWS-LC after
OpenSSL 3.6.3 genpkey -algorithm ML-KEM-{512,768,1024} DECODE_ERROR parses
BouncyCastle 1.82 KeyPairGenerator("ML-KEM-{512,768,1024}") DECODE_ERROR parses

Full matrix against OpenSSL 3.6.3 and BouncyCastle 1.82 (bcprov-jdk18on, JDK 21), for all three parameter sets — all pass:

  • peer generates a key (both CHOICE, 1706 / 2474 / 3242-byte privateKey for 512 / 768 / 1024) → AWS-LC parses it;
  • the public key AWS-LC derives from the embedded seed equals the one the peer exported separately;
  • peer encapsulates → AWS-LC decapsulates → shared secrets match;
  • AWS-LC encapsulates → peer decapsulates → shared secrets match;
  • AWS-LC re-encodes the key, which emits the seed CHOICE per section 6's RECOMMENDED (66-byte privateKey) → the peer reads it back and still decapsulates to the same secret.

That last point exercises the round trip in both directions: both in, seed out, and the peer accepts the result.

The four Appendix C.4.1 bad keys were also run through the openssl pkey tool 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::new accepts 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_dec already 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_check is not usable on a key from EVP_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 with EVP_R_MISSING_PUBLIC_KEY. A caller who wants the full check on raw bytes must supply both halves via EVP_PKEY_kem_new_raw_key. The evp.h comments now say this, and the neighbouring EVP_PKEY_kem_check_key comment — which claimed EVP_PKEY_check accepts a public-only key, when that is EVP_PKEY_public_check — is corrected alongside it.

Behavior change: an expandedKey parsed from PKCS#8 now has a public key. Because ek is recovered from dk, kem_priv_decode case 2 now populates public_key. Such a key previously failed EVP_PKEY_encapsulate outright, so this makes it usable, but it does retire a state the tests covered: KEMTest.PubCmpExpandedPrivateKeyNullPublic becomes PubCmpExpandedPrivateKey and asserts the recovered public key matches the published one. The NULL-public_key branch of kem_pub_cmp is still reachable and still matters — X509_check_private_key reaches it during PKCS12_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 privateKey OCTET STRING, so bytes following it are malformed DER. kem_priv_decode did not verify that key was fully consumed, so such bytes were silently ignored. EVP_parse_private_key rejects trailing data inside the outer PKCS#8 SEQUENCE but never re-examines the privateKey contents after priv_decode returns, so kem_priv_decode is the only place this can be caught.

This tightens the seed and expandedKey cases as well as both. p_kem_asn1.c was the outlier here: ed25519, x25519, dsa, rsa, ec and dh in the same directory all check CBS_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 the privateKey OCTET STRING and asserts EVP_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_decode has 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 expandedKey private key now runs a hash check plus one encaps/decaps. The hash check is negligible; the PCT is roughly keygen-scale. The seed and both CHOICEs are unaffected, and both was 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_key lives in crypto/fipsmodule/kem/kem.c alongside KEM_check_key. No public API or ABI change. The evp.h contract of EVP_PKEY_kem_new_raw_secret_key is updated to state explicitly that it takes in at face value and leaves the public key unset, and to name the two routes that do validate: EVP_PKEY_kem_new_raw_key followed by EVP_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.

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
jakemas requested a review from a team as a code owner August 26, 2026 20:58
@jakemas jakemas changed the title ML-KEM: ASN.1 Module - add parsing of BOTH private key format ML-KEM: ASN.1 Module - add BOTH private key format and validate expanded keys Aug 26, 2026
@jakemas
jakemas marked this pull request as draft August 26, 2026 21:19
@github-actions

Copy link
Copy Markdown
Contributor

🔒 Security ReviewView Report

Please review before merging.

@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.73842% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.09%. Comparing base (408de5e) to head (9a7e10c).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crypto/fipsmodule/kem/kem.c 77.30% 37 Missing ⚠️
crypto/evp_extra/p_kem_test.cc 95.20% 3 Missing and 3 partials ⚠️
crypto/evp_extra/p_kem_asn1.c 88.23% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jakemas
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants