Skip to content

Add ML-DSA (FIPS 204) support to PKCS#7/CMS SignedData per RFC 9882#24

Open
Frauschi wants to merge 7 commits into
masterfrom
claude/ml-dsa-pkcs7-pqc-b2i2wp
Open

Add ML-DSA (FIPS 204) support to PKCS#7/CMS SignedData per RFC 9882#24
Frauschi wants to merge 7 commits into
masterfrom
claude/ml-dsa-pkcs7-pqc-b2i2wp

Conversation

@Frauschi

Copy link
Copy Markdown
Owner

Summary

Adds post-quantum ML-DSA (FIPS 204) signature support to the PKCS#7 / CMS SignedData encode 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 OF signed attributes, or the eContent when none are present — with an empty context string and the signatureAlgorithm parameters 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 in asn.c.

Highlights:

  • Encode: emits the ML-DSA signatureAlgorithm OID 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 fixed encContentDigest[]).
  • Verify: maps the ML-DSA signature OID to the key OID, rebuilds the signed message, and verifies with the signer certificate's public key (empty context).
  • SHAKE digests: CMS producers (e.g. BouncyCastle) use a SHAKE message-digest with ML-DSA. A PKCS#7-local wc_PKCS7_OidGetHash() resolves the SHAKE128/256 digest OIDs (the global mapping is untouched), and SHAKE digest AlgorithmIdentifiers 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 from pkcs7signed_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 digest AlgorithmIdentifiers carry absent parameters, and confirms the encapsulated content is recovered. Matching DER test keys are added under certs/mldsa/.

Notes for resource-constrained targets

  • wc_MlDsaKey (~7.7 KB by default) is always heap-allocated, allocations use DYNAMIC_TYPE_MLDSA, and large objects follow the file's WOLFSSL_SMALL_STACK conventions.
  • ML-DSA public keys are not copied into the fixed wc_PKCS7.publicKey buffer (verification re-parses from the certificate), so sizeof(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

claude added 7 commits June 13, 2026 13:15
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
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