Add ML-DSA (FIPS 204) support to PKCS#7/CMS SignedData per RFC 9882#24
Open
Frauschi wants to merge 7 commits into
Open
Add ML-DSA (FIPS 204) support to PKCS#7/CMS SignedData per RFC 9882#24Frauschi wants to merge 7 commits into
Frauschi wants to merge 7 commits into
Conversation
Add post-quantum ML-DSA signature support to the CMS/PKCS#7 SignedData encode and verify paths, covering ML-DSA-44, -65 and -87. Unlike RSA/ECDSA, ML-DSA uses CMS "pure" mode (RFC 9882): the signature is computed over the complete message (the DER SET OF signed attributes, or the eContent when none are present) rather than a pre-computed digest, with an empty context string and the signatureAlgorithm parameters absent. To keep the design open to further PQC schemes (e.g. SLH-DSA, FN-DSA), the "signs full message vs. digest" distinction is centralized in a single classifier (wc_PKCS7_SigAlgRequiresFullMsg) consulted by both dispatchers, and the per-algorithm work is isolated in small helpers (wc_PKCS7_BuildPureSigMessage, wc_PKCS7_MlDsaSign, wc_PKCS7_MlDsaVerify, wc_PKCS7_MlDsaLevelFromOID). Adding the next pure-mode algorithm only requires extending these, not the core control flow. Details: - Encode: emit the ML-DSA signatureAlgorithm OID (parameters absent), sign the reconstructed message immediately (like ECDSA) since the signature size must be known before layout. ML-DSA signatures are multi-kilobyte and do not fit the fixed encContentDigest[] buffer, so they are held in a heap buffer (ESD.pqcSig) freed in the encode cleanup path. - Verify: map the ML-DSA signature OID to the key OID, rebuild the signed message and verify with the signer certificate's public key. - Enlarge wc_PKCS7.publicKey and relax the InitWithCert key-size check to accommodate PQC public keys (up to MAX_PQC_PUBLIC_KEY_SZ). - hash: map the SHAKE128/SHAKE256 digest OIDs in wc_OidGetHash so CMS SignedData using a SHAKE message-digest (as produced by other ML-DSA CMS implementations) can be processed; the generic hash wrapper already squeezes the default fixed output length. All new code is gated on WOLFSSL_HAVE_MLDSA (with sign/verify sub-gates); the non-ML-DSA build is unaffected. https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
Add pkcs7signed_mldsa_test(), invoked from pkcs7signed_test(), which encodes and then verifies a CMS/PKCS#7 SignedData bundle for each enabled ML-DSA parameter set (ML-DSA-44/65/87) using a SHA-512 message digest, and additionally with a SHAKE256 digest where SHA-3/SHAKE256 is available, to exercise the new SHAKE digest-OID handling. The test confirms the signature verifies and the encapsulated content is recovered intact. The test loads the signer certificate and a matching ML-DSA private key from DER files. The existing certs/mldsa private-key files do not correspond to the certs/mldsa certificates, so add DER encodings of the private keys that match each certificate (derived from the existing matching PEM keys) and list them in the certs include.am. Gated on WOLFSSL_HAVE_MLDSA with sign/verify and filesystem availability; builds without ML-DSA are unaffected. https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
Correctness and robustness fixes following review: - Size wc_PKCS7.publicKey with MAX_PUBLIC_KEY_SZ (full SubjectPublicKeyInfo bound) instead of MAX_PQC_PUBLIC_KEY_SZ (raw key). The latter left zero headroom for the ML-DSA-87 SPKI and was the wrong macro. - Sign ML-DSA only once per encode. ML-DSA signatures are a fixed size per parameter set, so route ML-DSA through the size-reservation path (like RSA) via wc_PKCS7_GetSignSize() and sign a single time on the final pass over the finalized signed attributes, instead of signing during both the sizing and final passes. - Scope SHAKE digest handling to PKCS#7. Revert the global wc_OidGetHash() change and add a local wc_PKCS7_OidGetHash() wrapper that resolves the SHAKE128/SHAKE256 digest OIDs, leaving the global OID->hash mapping untouched. - Use the portable expanded-only PKCS#8 form for the test private keys (mldsa<N>-key.der) so the unit test does not depend on seed-format private key decoding; document them in the certs README. - Allow a zero-length eContent in the no-signed-attributes signing path. - Test: drop the unused vector description field and add SHAKE128 message digest vectors so the SHAKE128 path is exercised. https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
Follow-up review fixes: - RFC 8702 requires the SHAKE128/SHAKE256 digest AlgorithmIdentifier to omit its parameters field. The SignedData.digestAlgorithms and SignerInfo digestAlgorithm were encoded with a NULL parameter when a SHAKE digest was selected. Add wc_PKCS7_DigestParamsAbsent() and use it at both sites so SHAKE digests are emitted with absent parameters (other digests continue to honor pkcs7->hashParamsAbsent). Verified that the resulting bundles still verify with OpenSSL and BouncyCastle. - Document the fixed-signature-size invariant of the size-reservation encode path: the size reserved by wc_PKCS7_GetSignSize() must equal the length the final signing pass produces (true for RSA and ML-DSA; a variable-length algorithm must not use this path). - Tests: assert that SHAKE digest AlgorithmIdentifiers are encoded with absent (not NULL) parameters, so the encoding defect cannot regress. Restrict the SHAKE128 message-digest vector to ML-DSA-44, where the 128-bit digest strength matches the signature; pairing it with stronger levels would weaken the content binding. https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
…riant Follow-up review fixes: - The SHAKE absent-parameters test checked only the first occurrence of the digest OID, so it validated SignedData.digestAlgorithms but not the SignerInfo.digestAlgorithm. Rework the helper to inspect every digest AlgorithmIdentifier (anchored on the SEQUENCE tag to avoid coincidental matches in signature/key bytes) and reject any non-absent parameters field (not just a literal NULL), and require that both expected occurrences are seen. - Enforce the fixed-signature-size invariant at runtime: if the final signing pass produces a length differing from the size reserved during the sizing pass, fail with BUFFER_E instead of emitting length-corrupted DER. Only the deterministic-size algorithms (RSA, ML-DSA) reach this path. - Clarify in wc_PKCS7_DigestParamsAbsent() why SHA-2 digests retain their parameter (RFC 5754: SHOULD be absent, MUST accept both) while SHAKE is forced absent (RFC 8702: MUST), and that the PKCS#1 v1.5 DigestInfo used for RSA is intentionally out of scope. https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
Memory-handling review for resource-constrained microcontrollers: - wc_MlDsaKey embeds the full key buffers (sizeof ~7.7 KB for the default, non-dynamic-key layout). wc_PKCS7_MlDsaVerify placed it on the stack in non-WOLFSSL_SMALL_STACK builds, creating a multi-kilobyte stack frame. Always heap-allocate the key (matching asn.c and the sign-side helpers) so the verify path keeps a small stack footprint regardless of build config; the DecodedCert continues to follow the file's WOLFSSL_SMALL_STACK pattern. - Allocate all ML-DSA key objects with DYNAMIC_TYPE_MLDSA rather than DYNAMIC_TYPE_TMP_BUFFER, matching asn.c and giving static-memory builds correct allocation bucketing. No functional change; verified with default and --enable-smallstack builds and the existing OpenSSL/BouncyCastle interop. https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
InitWithCert copied every signer certificate's public key into the fixed wc_PKCS7.publicKey buffer. Supporting ML-DSA had required enlarging that buffer to the PQC SubjectPublicKeyInfo size (~2.6 KB), growing every wc_PKCS7 instance in PQC-enabled builds. That buffer is only ever read back on the RSA/ECC raw-sign callback paths (guarded by publicKeySz > 0); ML-DSA verification re-parses the key from the certificate and never consults it. So skip storing the public key for ML-DSA key types (publicKeySz = 0) and restore the buffer to its original RSA size. The wc_PKCS7 structure is now the same size with or without ML-DSA enabled (~2 KB smaller than before for PQC builds), which matters on constrained targets. Verified that ML-DSA sign/verify, the RSA/ECC PKCS#7 paths, and the OpenSSL/BouncyCastle interop are unaffected. https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
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.
Summary
Adds post-quantum ML-DSA (FIPS 204) signature support to the PKCS#7 / CMS
SignedDataencode and verify paths, covering ML-DSA-44, -65 and -87, per RFC 9882 (Use of the ML-DSA Signature Algorithm in the CMS).ML-DSA uses CMS "pure" mode: the signature is computed over the complete message — the DER
SET OFsigned attributes, or theeContentwhen none are present — with an empty context string and thesignatureAlgorithmparameters field absent. This differs fundamentally from RSA/ECDSA, which sign a pre-computed digest, and the integration is built around that distinction.Design (built for future PQC expansion)
The "signs full message vs. signs a digest" decision and the per-algorithm work are isolated in small helpers (
wc_PKCS7_BuildPureSigMessage,wc_PKCS7_MlDsaSign,wc_PKCS7_MlDsaVerify,wc_PKCS7_MlDsaLevelFromOID), so adding the next pure-mode scheme (SLH-DSA, FN-DSA) is a localized change rather than a rework of the core encode/decode control flow. The encode path mirrors the existing RSA/ECDSA idiom and the proven ML-DSA handling already inasn.c.Highlights:
signatureAlgorithmOID with absent parameters; ML-DSA's deterministic signature size lets it reserve space like RSA and sign exactly once over the finalized attributes. Multi-kilobyte signatures live in a heap buffer (they do not fit the fixedencContentDigest[]).wc_PKCS7_OidGetHash()resolves the SHAKE128/256 digest OIDs (the global mapping is untouched), and SHAKE digestAlgorithmIdentifiers are encoded with absent parameters per RFC 8702.Interoperability testing
Validated against two independent implementations, both built from source: OpenSSL 3.5 and BouncyCastle 1.81. Full 3×3 matrix (each tool signs, the other two verify), all three security levels — 27/27 pass, including wolfSSL verifying SHAKE-digest bundles produced by BouncyCastle, and OpenSSL/BouncyCastle verifying wolfSSL output.
Tests
In-tree
pkcs7signed_mldsa_test()(run frompkcs7signed_test) does an encode→verify round-trip for each enabled level with SHA-512 and SHAKE256 digests (plus SHAKE128 at ML-DSA-44, the security-matched pairing), asserts the SHAKE digestAlgorithmIdentifiers carry absent parameters, and confirms the encapsulated content is recovered. Matching DER test keys are added undercerts/mldsa/.Notes for resource-constrained targets
wc_MlDsaKey(~7.7 KB by default) is always heap-allocated, allocations useDYNAMIC_TYPE_MLDSA, and large objects follow the file'sWOLFSSL_SMALL_STACKconventions.wc_PKCS7.publicKeybuffer (verification re-parses from the certificate), sosizeof(wc_PKCS7)is unchanged whether or not ML-DSA is enabled.All new code is gated on
WOLFSSL_HAVE_MLDSA(with sign/verify sub-gates); non-ML-DSA builds are unaffected. Verified clean builds and passing tests for default,--enable-mldsa,--enable-smallstack, and non-ML-DSA configurations.https://claude.ai/code/session_01YVFp6RKpvRG9PcWPKoKd9n
Generated by Claude Code