Skip to content

ML-KEM: add ASN.1 key encoding and X.509 certificate support - #11352

Open
Frauschi wants to merge 9 commits into
wolfSSL:masterfrom
Frauschi:pqc-smime-2-mlkem-asn1-x509
Open

ML-KEM: add ASN.1 key encoding and X.509 certificate support#11352
Frauschi wants to merge 9 commits into
wolfSSL:masterfrom
Frauschi:pqc-smime-2-mlkem-asn1-x509

Conversation

@Frauschi

@Frauschi Frauschi commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Description

ML-KEM (FIPS 203) key encoding and X.509 certificate support in wolfCrypt: key OIDs (2.16.840.1.101.3.4.4.{1,2,3}), SubjectPublicKeyInfo and PKCS#8 encoders and decoders, MLKEM_TYPE public keys in certificates, and issuing one with wc_MakeCert_ex().

This is the key-material half of CNSA 2.0 S/MIME; the CMS side that consumes it (RFC 9629 KEMRecipientInfo) is a separate PR and is not needed to review this one. Nothing here touches TLS or PKCS#7.

Two things beyond the plumbing:

  • All three RFC 9935 Section 6 private key CHOICE forms, where only the expanded shape was accepted before. For the "both" shape the halves are compared and a mismatch rejected per Section 8. A WOLFSSL_MLKEM_NO_MAKE_KEY build cannot run that comparison, so it rejects every key carrying a seed rather than trusting the expanded half.
  • A DER decode can name the parameter set. wc_MlKemKey_Init() accepts the new WC_ML_KEM_TYPE_UNSET, so a caller holding a certificate or key file no longer has to read the algorithm OID out first.

Test material is in certs/mlkem/, signed by the ML-DSA-87 test CA with keyUsage critical asserting keyEncipherment alone per RFC 9935 Section 5, regenerated by certs/renewcerts.sh.

Why a flag rather than renumbering

WC_ML_KEM_512 is 0, so a zeroed MlKemKey is indistinguishable from one holding a valid ML-KEM-512 parameter set. Renumbering is the wrong trade: WC_ML_KEM_512 and KYBER512 are public, the enum is structured (MLKEM_KYBER = 0x10 is an OR-able variant flag), and the constants are referenced by wolfHSM, wolfProvider and wolfPKCS11 - so moving them silently changes which algorithm an already-compiled application asks for.

MLKEM_FLAG_TYPE_SET = 0x0010 records the distinction instead. Additive: no public constant moves, no ABI break, no cross-repo coordination.

Worth a look during review: of the four key->flags = 0 sites, wc_MlKemKey_Free() is right to clear everything, but wc_MlKemKey_MakeKeyWithRandom() and the PrivateKeyDecode scrub path discard key material, not the parameter set, so both now use key->flags &= MLKEM_FLAG_TYPE_SET.

Fixes carried along

  • DecodedCert.pkCurveOID's build guard listed only signature algorithms (asn.h:1987) while GetCertKey() writes it for FrodoKEM (asn.c:14133), so FrodoKEM certificates without ECC/Ed25519/Ed448/ML-DSA/Falcon/SLH-DSA/LMS/XMSS fail to compile on master today. The guard now names both KEMs.
  • wc_CheckPrivateKey() freed an ML-KEM key wc_MlKemKey_Init() had rejected, handing a zeroed key to a crypto callback at device id 0. Now gated on a successful init.
  • The crypto callback test faked entropy with a plain counter. _InitRng() health-tests the seed it gets back with the SP 800-90B repetition count and adaptive proportion tests, and once the counter reaches 0x010101xx three of every four bytes are identical - 384 in a 512-byte window against a cutoff of 325 - so the seed is rejected with ENTROPY_APT_E. Whether a build trips it depends only on how many seeds cryptocb_test has already drawn: master stops at 11, one short of the edge, and the RNG init in the new mlkem_asn1_test makes it 12. That is the all-amdrand CI failure, where the entropy scale factor is 512 and each seed is 24592 bytes. The stub now steps an xorshift.
  • The OID sum generator handles guarded entries, so oid_sum.h round-trips instead of losing its hand-added ACME_IDENTIFIER_OID block on regeneration.

WOLFSSL_MLKEM_NO_ASN1 is derived in settings.h from WOLFSSL_ASN_ORIGINAL and NO_ASN, not WOLFSSL_ASN_TEMPLATE - autotools puts that on the command line but CMake never defines it, so testing it there would switch the feature off in every CMake build.

Testing

New: mlkem_asn1_test (round trips plus parameter-set adoption, negative-controlled), mlkem_cert_test, mlkem_seed_consistency_test (all three CHOICE forms, wrong-length seed, disagreeing halves), mlkem_certgen_test. Seed vectors from RFC 9935 Appendix C/C.4.1; the expansion was confirmed against the public key RFC 9936 Appendix C derives from the same seed.

Out of scope

--enable-mlkem=original (Kyber-only) fails to build, identically to master.

The NOT_COMPILED_IN path for adopting a parameter set the build lacks has no test; it needs an embedded DER fixture rather than a generated one.

@Frauschi Frauschi self-assigned this Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

gcc-arm-cortex-m4-pkcs7

  • FLASH: .text +64 B (+0.0%, 217,628 B / 262,144 B, total: 83% used)

gcc-arm-cortex-m4-pq

  • FLASH: .rodata +24 B, .text +1,472 B (+0.5%, 303,068 B / 1,048,576 B, total: 29% used)

gcc-arm-cortex-m7-pq

  • FLASH: .rodata +24 B, .text +1,408 B (+0.5%, 303,964 B / 1,048,576 B, total: 29% used)

linuxkm-pie

  • Data: __patchable_function_entries +64 B (+0.2%, 27,048 B)

linuxkm-standard

@Frauschi
Frauschi force-pushed the pqc-smime-2-mlkem-asn1-x509 branch 3 times, most recently from e7b5748 to 5218d66 Compare September 2, 2026 15:49
@Frauschi
Frauschi force-pushed the pqc-smime-2-mlkem-asn1-x509 branch 3 times, most recently from e67f64d to 978db48 Compare September 4, 2026 10:10
The crypto callback test stands in for wc_GenerateSeed() with an
incrementing 32-bit counter. _InitRng() runs the SP 800-90B repetition
count and adaptive proportion tests over the seed it gets back, and once
the counter's upper bytes repeat - 0x010101xx and beyond - three of every
four bytes carry the same value, so a 512 byte window holds 384 copies of
it against a cutoff of 325 and the seed is rejected with ENTROPY_APT_E.

Which request lands in that range depends only on how many seeds the test
has already drawn, so nothing shows until a test that inits an RNG is
added. With --enable-amdrand the entropy scale factor is 512, making each
seed 24592 bytes, and the eleventh request already sits at 0x0100e828.

Step an xorshift instead. The bytes stay deterministic and the generator
stays dependency free, but no value dominates a window: over 400
consecutive requests the worst window holds 14 copies of one value and
the longest run of equal bytes is 3.
wolfCrypt implemented ML-KEM only in its raw FIPS 203 encodings. There
were no ML-KEM key OIDs anywhere in the ASN.1 layer and no way to read
or write a SubjectPublicKeyInfo or a PKCS#8 private key, so an ML-KEM
key could not be carried in any DER structure.

Add the FIPS 203 key OIDs from the NIST arc 2.16.840.1.101.3.4.4.x to
the OID sum generator and regenerate oid_sum.h. The old-scheme byte sums
(416 to 418) do not collide, so unlike FrodoKEM no remapping is needed.
Add the matching OID octet strings and OidFromId cases to asn.c.

Add wc_MlKemKey_PublicKeyToDer, wc_MlKemKey_PrivateKeyToDer,
wc_MlKemKey_PublicKeyDecode and wc_MlKemKey_PrivateKeyDecode, following
the FrodoKEM codec. The private key is emitted in the expanded form,
which SetAsymKeyDer wraps in the nested OCTET STRING that the standard
private key CHOICE uses for its expandedKey alternative. The seed and
seed-with-expanded alternatives are not produced.

Decoding requires the key object to be initialized for a parameter set
and the OID in the DER to name that same set. ML-KEM cannot auto-detect
the way FrodoKEM does, because WC_ML_KEM_512 is zero and so cannot be
told apart from an unset type in a zeroed object.

Tested by round-tripping every enabled parameter set through both
encodings and confirming that a key recovered from DER still agrees on
an encapsulated shared secret, that a public key DER naming a different
parameter set is rejected, and against OpenSSL asn1parse: the
SubjectPublicKeyInfo carries the expected OID with the parameters field
absent and a 1568 byte ML-KEM-1024 encapsulation key.
Build on the ML-KEM key OIDs and ASN.1 codec so an ML-KEM key can be
carried in a certificate, both when parsing one and when issuing one.

Add MLKEM_KEY and MLKEM_TYPE, accept the ML-KEM key OIDs when decoding a
certificate's SubjectPublicKeyInfo and when validating PKCS#8 algorithm
parameters, and teach the private key to certificate matching how to
pair an ML-KEM key with its certificate. That last path needs the
parameter set before it can initialize a key object, so
wc_MlKemKey_TypeFromOidSum maps a decoded key OID back to a parameter
set.

Thread an ML-KEM key through certificate generation alongside the
existing FrodoKEM key, covering MakeAnyCert, MakeCertReq and the subject
and authority key identifier helpers, so wc_MakeCert_ex accepts
MLKEM_TYPE. An ML-KEM encapsulation key exceeds MAX_PUBLIC_KEY_SZ, so
the key identifier helper sizes its buffer the way it already does for
FrodoKEM.

No change is needed to keep a KEM key away from signature verification.
ConfirmSignature has no case for these OIDs and its default rejects them
with ASN_UNKNOWN_OID_E, which is how FrodoKEM behaves too.

Certificate parsing does not reject an ML-KEM certificate that asserts
keyUsage bits beyond keyEncipherment. Refusing to parse it would make a
non-conforming certificate unreadable even for inspection, and the
requirement binds the issuer rather than the parser. The check belongs
where the key is used for key establishment.

Add ML-KEM test certificates under certs/mlkem. A KEM cannot sign, so
these cannot be self-signed and are issued by the ML-DSA-87 test key in
certs/mldsa instead. They follow the CNSA 2.0 key establishment profile:
keyUsage critical and asserting keyEncipherment alone, an ML-KEM
subjectPublicKeyInfo with the parameters field absent, and an ML-DSA-87
signature. They are generated in tree because ML-KEM certificate
issuance is not available in OpenSSL before 3.5.

Tested by parsing each certificate, confirming its subject public key
decodes only as the expected parameter set, and confirming the matching
private key reproduces the certificate's public key. Verified that a
build with ML-KEM disabled still compiles.
The ACME identifier OID was added to wolfssl/wolfcrypt/oid_sum.h by hand
after the file was generated, because scripts/asn1_oid_sum.pl had no way
to emit an entry wrapped in an #ifdef. Regenerating the header therefore
deleted it, silently, and the loss only showed up as a build failure in a
WOLFSSL_ACME_OID build.

Give print_enum an optional per-entry "guard" field that wraps just that
entry in #ifdef/#endif, in both the WOLFSSL_OLD_OID_SUM and the xor
halves, and describe the ACME identifier 1.3.6.1.5.5.7.1.31 in the
generator like every other extension OID.

The regenerated header is byte for byte what was in the tree, so the
hand-written sums were right; what was missing was the generator's
knowledge of them.
RFC 9935 Section 6 gives an ML-KEM privateKey three shapes: the 64 byte
seed under an implicit [0], the expanded decapsulation key as an OCTET
STRING, and a SEQUENCE carrying both. Only the expanded shape was
accepted, which turned away the seed form that RFC 9935 recommends and
that OpenSSL 3.5 emits by default.

The ASN.1 template decoder already recognised all three; the ML-KEM
decoder just was not asking for the seed. It now does, and expands a seed
with wc_MlKemKey_MakeKeyWithRandom, which is FIPS 203
ML-KEM.KeyGen_internal(d,z) and was confirmed against the public key
RFC 9936 Appendix C derives from the same seed.

For the "both" shape the expanded key is regenerated from the seed and
compared bytewise, so a key whose two halves disagree is rejected as
malformed. RFC 9935 Section 8 asks for this check; the examples in its
Appendix C.4.1 are what it is meant to catch, and the first of those was
used to confirm the rejection.

A build without key generation cannot expand a seed and so cannot run
that comparison. Rather than accept the expanded half unchecked, such a
build rejects every key that carries a seed.

The consistency check runs before the expanded key reaches the caller's
object, and the object is scrubbed when it fails, so a rejected key does
not leave a complete and usable private key behind.
Doxygen entries for the ML-KEM SubjectPublicKeyInfo and PKCS#8 encoders
and decoders and for the certificate key type, including which of the
RFC 9935 Section 6 private key CHOICE forms each accepts and what a
build without key generation does with a key that carries a seed.
The README described how to regenerate the ML-KEM certificates in prose,
which amounts to "write a program". They are produced by
certs/renewcerts.sh instead, using the same OpenSSL 3.5 or newer binary
its ML-DSA section already requires.

An ML-KEM key cannot sign, so it can sign neither a certificate nor a
certificate request. Each certificate therefore comes from a throwaway
request carrying only the subject name, with x509 -req -force_pubkey
swapping in the ML-KEM public key before the ML-DSA-87 test CA signs it,
and keyUsage marked critical asserting keyEncipherment and nothing else,
per RFC 9935 Section 5.

The keys are written in the priv-only PKCS#8 shape, which is the RFC 9935
Section 6 expandedKey form: that decodes in a build without key
generation, which the seed form does not.

ML-KEM is probed separately from ML-DSA so a binary carrying one provider
and not the other still regenerates what it can.

The MLKEM_TYPE certificate generation paths had no coverage, so
mlkem_certgen_test drives wc_MakeCert_ex, wc_SignCert_ex and
wc_SetSubjectKeyIdFromPublicKey_ex for all three parameter sets. It signs
with ECDSA rather than ML-DSA, which is available in far more builds and
which the paths under test do not care about.
WC_ML_KEM_512 is 0, so a zeroed MlKemKey is indistinguishable from one
holding a valid ML-KEM-512 parameter set. Nothing could tell "not
initialised" from "initialised to the smallest parameter set", which is
why both DER decoders had to insist the caller pick the parameter set
first and then rejected any DER that named a different one.

MLKEM_FLAG_TYPE_SET records the difference explicitly. wc_MlKemKey_Init
sets it; a zeroed object does not have it. The two places that reset
flags after discarding key material now keep the bit, since the
parameter set survives a regenerate or a scrubbed decode.

Renumbering the parameter sets would have been the other way to get
here, and is not worth it: WC_ML_KEM_512 and KYBER512 are public, so
moving them silently changes which algorithm an already-compiled
application asks for, and the values are shared with wolfHSM,
wolfProvider and wolfPKCS11.

With the bit in hand, wc_MlKemKey_Init also takes WC_ML_KEM_TYPE_UNSET,
which sets the object up without committing to a parameter set.
wc_MlKemKey_PublicKeyDecode and wc_MlKemKey_PrivateKeyDecode then pass
ANONk to the ASN.1 layer, which already auto-detects, and adopt whatever
the algorithm OID names. A caller that has a certificate or a key file
and does not know its parameter set no longer has to read the OID out
first with wc_MlKemKey_TypeFromOidSum. A key initialised for a parameter
set behaves as before and still holds the DER to it.

A parameter set the build does not have is refused with NOT_COMPILED_IN
rather than adopted.
The ML-KEM ASN.1, certificate and certificate generation tests hold the key
objects on the heap and size their DER buffers at run time, so they need an
allocator. Guard them on !WOLFSSL_NO_MALLOC: the codec itself works in such
a build, only the tests do not.
@Frauschi

Frauschi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Jenkins retest this please

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.

1 participant