ML-KEM: add ASN.1 key encoding and X.509 certificate support - #11352
Open
Frauschi wants to merge 9 commits into
Open
ML-KEM: add ASN.1 key encoding and X.509 certificate support#11352Frauschi wants to merge 9 commits into
Frauschi wants to merge 9 commits into
Conversation
|
Frauschi
force-pushed
the
pqc-smime-2-mlkem-asn1-x509
branch
3 times, most recently
from
September 2, 2026 15:49
e7b5748 to
5218d66
Compare
Frauschi
force-pushed
the
pqc-smime-2-mlkem-asn1-x509
branch
3 times, most recently
from
September 4, 2026 10:10
e67f64d to
978db48
Compare
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
force-pushed
the
pqc-smime-2-mlkem-asn1-x509
branch
from
September 4, 2026 13:14
978db48 to
68a0d1b
Compare
Contributor
Author
|
Jenkins retest this please |
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.
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_TYPEpublic keys in certificates, and issuing one withwc_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:
WOLFSSL_MLKEM_NO_MAKE_KEYbuild cannot run that comparison, so it rejects every key carrying a seed rather than trusting the expanded half.wc_MlKemKey_Init()accepts the newWC_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 withkeyUsagecritical assertingkeyEnciphermentalone per RFC 9935 Section 5, regenerated bycerts/renewcerts.sh.Why a flag rather than renumbering
WC_ML_KEM_512is0, so a zeroedMlKemKeyis indistinguishable from one holding a valid ML-KEM-512 parameter set. Renumbering is the wrong trade:WC_ML_KEM_512andKYBER512are public, the enum is structured (MLKEM_KYBER=0x10is 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 = 0x0010records 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 = 0sites,wc_MlKemKey_Free()is right to clear everything, butwc_MlKemKey_MakeKeyWithRandom()and thePrivateKeyDecodescrub path discard key material, not the parameter set, so both now usekey->flags &= MLKEM_FLAG_TYPE_SET.Fixes carried along
DecodedCert.pkCurveOID's build guard listed only signature algorithms (asn.h:1987) whileGetCertKey()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 keywc_MlKemKey_Init()had rejected, handing a zeroed key to a crypto callback at device id 0. Now gated on a successful init._InitRng()health-tests the seed it gets back with the SP 800-90B repetition count and adaptive proportion tests, and once the counter reaches0x010101xxthree of every four bytes are identical - 384 in a 512-byte window against a cutoff of 325 - so the seed is rejected withENTROPY_APT_E. Whether a build trips it depends only on how many seedscryptocb_testhas already drawn: master stops at 11, one short of the edge, and the RNG init in the newmlkem_asn1_testmakes it 12. That is theall-amdrandCI failure, where the entropy scale factor is 512 and each seed is 24592 bytes. The stub now steps an xorshift.oid_sum.hround-trips instead of losing its hand-addedACME_IDENTIFIER_OIDblock on regeneration.WOLFSSL_MLKEM_NO_ASN1is derived insettings.hfromWOLFSSL_ASN_ORIGINALandNO_ASN, notWOLFSSL_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_INpath for adopting a parameter set the build lacks has no test; it needs an embedded DER fixture rather than a generated one.