Skip to content

Add ML-DSA-65 and ML-DSA-87 certificate parsing and verification - #312

Open
justsem wants to merge 5 commits into
apple:mainfrom
wendylabsinc:mldsa-support
Open

justsem wants to merge 5 commits into
apple:mainfrom
wendylabsinc:mldsa-support

Conversation

@justsem

@justsem justsem commented Aug 11, 2026

Copy link
Copy Markdown

swift-certificates can't yet parse a certificate signed with ML-DSA: it throws on the issuer's signature algorithm before verification even starts, even though swift-crypto already supports these algorithms. That turned out to be a blocker in one of our projects involving PQ PKI.

This PR adds parsing and verification for ML-DSA-65 and ML-DSA-87, per RFC 9881.

  • First commit bumps swift-crypto to 4.0.0, where MLDSA65/MLDSA87 was introduced.
  • Certificates signed by an ML-DSA CA now parse and round-trip byte-for-byte. ML-DSA public keys work through the usual Certificate.PublicKey API, and chains validate through Verifier with RFC5280Policy.
  • ML-DSA requires macOS/iOS 26 at runtime (since it comes from CryptoKit there). Below that, decoding an ML-DSA key fails with an error.

Comment thread Benchmarks/Package.swift Outdated
@Joannis

Joannis commented Aug 11, 2026

Copy link
Copy Markdown

CC @fabianfett if you're back, could we get a CI run on this?

Comment thread Sources/X509/MLDSA.swift Outdated
Comment thread Sources/X509/MLDSA.swift Outdated
Comment thread Sources/X509/MLDSA.swift Outdated
Comment thread Sources/X509/CertificatePublicKey.swift Outdated
Comment thread Sources/X509/MLDSA.swift Outdated
Comment thread Sources/X509/MLDSA.swift Outdated
Comment thread Tests/X509Tests/MLDSATests.swift Outdated
Comment thread Tests/X509Tests/MLDSATests.swift Outdated
Comment thread Tests/X509Tests/MLDSATests.swift Outdated
Comment thread Tests/X509Tests/MLDSATests.swift
Comment thread Tests/X509Tests/MLDSATests.swift Outdated
@aryan-25 aryan-25 added the 🆕 semver/minor Adds new public API. label Aug 13, 2026
@Lukasa

Lukasa commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

There are also some test failures that need looking at.

@Joannis

Joannis commented Sep 1, 2026

Copy link
Copy Markdown

@Lukasa can you re-trigger CI?

@justsem
justsem requested review from Lukasa and aryan-25 September 1, 2026 16:27
Motivation:

The upcoming ML-DSA support uses the MLDSA65/MLDSA87 APIs that first
appeared in swift-crypto 4.0.0. Raising the floor in a minor release
follows this repository's own precedent (apple#39, apple#275, apple#300). A SwiftPM
trait was considered instead, but traits are used in the ecosystem to
gate optional dependencies, not versions of a required one, and
SE-0450 cannot express a per-trait version floor.

Modifications:

- Raise the swift-crypto lower bound from 3.12.3 to 4.0.0 in
  Package.swift and Benchmarks/Package.swift.

Result:

swift-certificates requires swift-crypto 4.0.0 or later, which
dependency resolution already selects for any graph that does not pin
swift-crypto below 4.
Motivation:

RFC 9881 registers algorithm identifiers for ML-DSA (FIPS 204) in
X.509, and swift-crypto ships MLDSA65 and MLDSA87. swift-certificates
could not even parse a certificate whose issuer signed with ML-DSA:
Certificate.init threw on the issuer's signature algorithm before any
verification was attempted, blocking post-quantum CA hierarchies.

Modifications:

- Add the id-ml-dsa-65 and id-ml-dsa-87 OIDs and AlgorithmIdentifier
  statics (parameters absent, per RFC 9881), and
  Certificate.SignatureAlgorithm.mldsa65/.mldsa87.
- Extend Certificate.Signature with a backing case carrying the raw
  signature bytes, mirroring Ed25519.
- Add MLDSA.swift: ML-DSA public keys are stored as validated raw
  bytes and rebuilt per verification, because CryptoKit's ML-DSA types
  are macOS 26+ on Darwin and enum cases cannot carry availability.
  Below macOS 26 an ML-DSA subject public key fails loudly at decode;
  on Linux support is unconditional.
- Extend Certificate.PublicKey with SPKI decode/encode, verification
  dispatch, and the standard wrap/unwrap initializers.
- Add MLDSATests with OpenSSL 3.6.3 interop fixtures covering parse,
  byte-identical round-trips, self-signature known-answer tests,
  tampered/wrong-parameter-set/wrong-root/padding-bits negatives,
  ML-DSA-44 rejection, and chain validation through Verifier with
  RFC5280Policy.

Signing, ML-DSA-44, HashML-DSA, and TLS signature-scheme codepoints
are deliberately not supported and throw unsupportedSignatureAlgorithm.

Result:

Certificates signed with ML-DSA-65 or ML-DSA-87 parse, round-trip,
and verify, including full chains through the normal verifier path.
Motivation:

Review feedback on the ML-DSA verification path: the #available guard
in MLDSAPublicKeyBytes.isValidSignature can never fail at runtime,
because every initializer of the type itself requires ML-DSA
availability. Returning false there would present a broken library
invariant as a signature-verification failure.

Modifications:

- Replace the guard's `return false` with a fatalError encoding the
  unreachability justification, and a comment explaining why the
  guard must exist at all (the function is callable from
  baseline-availability dispatch code while the swift-crypto calls
  are macOS 26+ on Darwin).

Result:

A violated construction invariant now traps loudly instead of being
reported as an invalid signature.
Motivation:

Code review on the ML-DSA pull request asked for several consistency
and concision changes.

Modifications:

- Drop the unused SwiftASN1 import from MLDSA.swift.
- Rename MLDSAPublicKeyBytes.bytes to rawRepresentation, matching
  swift-crypto's ML-DSA public key API.
- Extract the variant-to-AlgorithmIdentifier mapping into
  AlgorithmIdentifier.init(mldsaVariant:).
- Condense the MLDSAVariant, MLDSAPublicKeyBytes, and fatalError
  comments, and several test and fixture comments.
- Annotate the ML-DSA test methods with @available instead of
  guarding each body with #available and XCTSkip.

Result:

No functional change; the diff shrinks by ~45 lines and matches the
reviewer's suggestions.
Motivation:

Review feedback: keep the swift-crypto floor at 3.12.3 and make ML-DSA
opt-in. This also fixes the macOS and CMake CI jobs, which build
against SDKs/checkouts whose swift-crypto has no ML-DSA and so must
not see the MLDSA65/MLDSA87 types in a default build, and the docs
check, which rejected doc links with the wrong parameter list.

Modifications:

- Restore the 3.12.3 floor and add a default-off `MLDSA` trait that
  defines SWIFT_CERTIFICATES_MLDSA on X509 and X509Tests.
- Gate all code naming MLDSA65/MLDSA87 behind the trait. Parsing
  certificates *signed* with ML-DSA stays unconditional.
- Gate the key-material tests; restore the test that decoding an
  ML-DSA key without the trait fails loudly.
- Fix doc links to unsupportedSignatureAlgorithm(reason:file:line:).

Result:

Default builds keep the 3.12.3 floor and compile on toolchains
without ML-DSA (532 tests pass); `swift test --traits MLDSA` opts in
(544 tests pass).
@justsem

justsem commented Oct 15, 2026

Copy link
Copy Markdown
Author

Rebased onto current main (715a2f4); the merge commit is gone. The only conflict was Package.swift: the trait now sits on top of main's "3.12.3"..<"6.0.0" swift-crypto range, so the default floor stays at 3.12.3. Turning on the MLDSA trait still needs swift-crypto 4.0.0 or later.

Review feedback:

  • Package trait instead of raising the floor (@Joannis, @Lukasa): done. ML-DSA key support is behind a default-off MLDSA trait. Parsing certificates signed with ML-DSA works without it. Without the trait, an ML-DSA subject key fails at decode with unsupportedPublicKeyAlgorithm.
  • Unreachable availability branch (@Lukasa): now a fatalError that says why it is unreachable, using @aryan-25's wording.
  • Unused import in MLDSA.swift (@aryan-25): removed.
  • bytes → rawRepresentation (@aryan-25): renamed.
  • Variant → AlgorithmIdentifier mapping (@aryan-25): moved into AlgorithmIdentifier.init(mldsaVariant:).
  • Shorter type and doc comments (@aryan-25): MLDSAVariant and MLDSAPublicKeyBytes comments trimmed to the two points you listed.
  • Tests: @available instead of #available/XCTSkip, redundant comments removed, suggested fixture comments (@aryan-25): applied.
  • CI failures on ea8a091 (@Lukasa): macOS 6.1 and the CMake check failed because their swift-crypto has no MLDSA65/MLDSA87. The trait fixes both, since default builds no longer refer to those types. The docs check failed on doc links with the wrong parameter list; those links are fixed. The Benchmarks nightly-main/next jobs failed only with benchmarkThresholdImprovement (results better than the thresholds), which this PR does not cause.

Local results (Linux, Swift 6.4.1, swift-crypto 4.5.1): swift build passes. swift test passes all 527 XCTest and 9 Swift Testing tests. swift test --traits MLDSA --filter MLDSATests passes all 16 tests. The CI runs on a4b011b and the merge commit timed out waiting for workflow approval, so this head has not been through CI yet. Could someone approve the workflows?

@justsem

justsem commented Oct 15, 2026

Copy link
Copy Markdown
Author

Note: the branch head is temporarily back at 913619a. Downstream packages pin a commit of this PR by SHA from this repository's URL and lost it when the branch was rebased; they are moving to the rebased head first. The rebased work (onto main 715a2f4, Package.swift conflict resolved, all tests green) is at wendylabsinc/swift-certificates branch mldsa-support-rebased-1c664223 and will replace this head once those pins have moved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Adds new public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants