SECENG-13957: feat: add ML-DSA-44/65/87 post-quantum key support - #1445
SECENG-13957: feat: add ML-DSA-44/65/87 post-quantum key support#1445ang-cloudflare wants to merge 4 commits into
Conversation
Add ML-DSA (FIPS 204) key generation and signature algorithm support
using Go 1.27's crypto/mldsa stdlib package.
Changes:
- KeyRequest.Generate() supports mldsa44, mldsa65, mldsa87 algo values
- KeyRequest.SigAlgo() maps ML-DSA variants to x509.MLDSA44/65/87
- DefaultSigAlgo() and SignerAlgo() handle *mldsa.PublicKey
- ParsePrivateKeyDER() recognizes *mldsa.PrivateKey via PKCS#8
- ParseRequest() marshals ML-DSA keys as PKCS#8 ("PRIVATE KEY" PEM)
- initca.New() works end-to-end for all three ML-DSA parameter sets
Breaking change: raises go.mod minimum to Go 1.27. crypto/mldsa does
not exist in earlier versions and cannot be build-tagged.
Go 1.27 compatibility fixes:
- transport/roots/system: fix initSystemRoots() signature for Go 1.27
- helpers_test: fix 256-bit RSA key generation (rejected in Go 1.27)
- signer/local: use OID-based CT poison lookup in TestSignFromPrecert
- signer/local: update TestLint expectations for Go 1.27 zlint results
- CI: update to Go 1.27, upgrade golangci-lint-action to v9
ang-cloudflare
left a comment
There was a problem hiding this comment.
Scout Review
Comment — 3 findings worth addressing, none blocking.
The core ML-DSA support (key generation, sig algo mapping, PKCS#8 serialization, DER parsing) is correctly implemented. These are cross-boundary gaps in subsystems the PR doesn't touch yet — fixing them here keeps the feature internally consistent.
See inline comments for details.
| return x509.MLDSA65 | ||
| case pub.Parameters() == mldsa.MLDSA87(): | ||
| return x509.MLDSA87 | ||
| default: |
There was a problem hiding this comment.
[L1] Bundler rejects ML-DSA certs + KeyLength() returns 0
KeyLength() (line 66-79 in this file) has no *mldsa.PublicKey case — returns 0 for ML-DSA keys. Per the PQC func spec, key_size should be the public key byte size: 1312 / 1952 / 2592 for ML-DSA-44/65/87.
Downstream, bundler/bundler.go:600-609 hard-rejects ML-DSA certs (both key-present and keyless paths), and bundler/bundle.go:106-131 reports "Unknown" key type + 0 key size in JSON output.
All three sites need ML-DSA cases to match the support added here in SignerAlgo.
| {A: "ed25519"}, | ||
| {A: "mldsa44"}, | ||
| {A: "mldsa65"}, | ||
| {A: "mldsa87"}, |
There was a problem hiding this comment.
[L2] initca.RenewFromSigner rejects ML-DSA CA certs
Creation is tested and works (these lines), but RenewFromSigner() at initca/initca.go:195-224 has a key-type switch that falls to NotRSAOrECCOrEd25519 for ML-DSA. A CA created with initca.New() can't be renewed.
Needs a case ca.PublicKeyAlgorithm == x509.MLDSA: branch with *mldsa.PublicKey equality check.
| } | ||
| case ed25519.PublicKey: | ||
| return x509.PureEd25519 | ||
| case *mldsa.PublicKey: |
There was a problem hiding this comment.
[L3] SignatureString() / HashAlgoString() return "Unknown" for ML-DSA
SignatureString() (line 132-163) and HashAlgoString() (line 167-198) in this file have no cases for x509.MLDSA44/65/87. certinfo.ParseCertificate() at certinfo/certinfo.go:90 calls SignatureString(), so cfssl certinfo on an ML-DSA cert shows "Unknown Signature".
Add case x509.MLDSA44: return "MLDSA44" (and 65/87) to both functions.
… display functions - bundler: accept ML-DSA certs in Bundle() key validation (key-present and keyless paths), add MLDSA key type and PKCS#8 serialization to MarshalJSON - initca: add ML-DSA case to RenewFromSigner() key matching - helpers: KeyLength() returns public key byte size (1312/1952/2592) per PQC func spec; SignatureString() and HashAlgoString() return MLDSA44/65/87 instead of "Unknown"
| case x509.Ed25519: | ||
| keyType = "Ed25519" | ||
| case x509.MLDSA: | ||
| keyType = fmt.Sprintf("%d-byte ML-DSA", keyLength) |
There was a problem hiding this comment.
This doesn't make much sense—just use the name: ML-DSA-44, etc.
There was a problem hiding this comment.
Done — now uses the parameter set name directly: ML-DSA-44, ML-DSA-65, ML-DSA-87 (dispatched via Parameters() comparison).
| keyString = PemBlockToString(&pem.Block{Type: "Ed25519 PRIVATE KEY", Bytes: keyBytes}) | ||
| case *mldsa.PrivateKey: | ||
| keyBytes, _ = x509.MarshalPKCS8PrivateKey(key) | ||
| keyString = PemBlockToString(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) |
There was a problem hiding this comment.
Let's add test that it correctly roundtrips this example private key from RFC 9881:
-----BEGIN PRIVATE KEY-----
MDQCAQAwCwYJYIZIAWUDBAMRBCKAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZ
GhscHR4f
-----END PRIVATE KEY-----
There was a problem hiding this comment.
Added TestParsePrivateKeyDERMLDSARFC9881 — parses the RFC 9881 ML-DSA-44 seed-only PKCS#8, verifies it produces MLDSA44 parameters, marshals back to PKCS#8, and confirms the public key survives the round-trip.
| return rsaKey.N.BitLen() | ||
| } else if _, ok := key.(ed25519.PublicKey); ok { | ||
| return ed25519.PublicKeySize | ||
| } else if mldsaKey, ok := key.(*mldsa.PublicKey); ok { |
There was a problem hiding this comment.
Key length is not a very useful metric in the case of ML-DSA. In any case, you can call PublicKeySize() on Parameters() instead of hardcoding.
There was a problem hiding this comment.
Agreed — replaced the hardcoded values with mldsaKey.Parameters().PublicKeySize().
- bundler: use ML-DSA-44/65/87 names instead of byte count for keyType - helpers: use Parameters().PublicKeySize() instead of hardcoded values in KeyLength() - derhelpers: add RFC 9881 ML-DSA-44 example private key round-trip test
Summary
Add ML-DSA (FIPS 204) key generation and signature algorithm support using Go 1.27 crypto/mldsa. This enables the explicit mldsa44, mldsa65, and mldsa87 KeyRequest algorithm values for post-quantum certificate authority generation.
ML-DSA-44 is the downstream default when an ML-DSA variant is omitted. cfssl continues to accept only explicit parameter-set names; the omitted-selection default belongs in the calling service.
Changes
Core ML-DSA support
Verification
Go 1.27 migration
Dependency
Depends on #1434. Go 1.24 removed the x509sha1 compatibility override, and the existing SHA-1 test fixtures must be regenerated before the full suite can pass on Go 1.27. This PR does not disable those tests or duplicate the 76-file fixture rewrite.
Context