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 3 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's
crypto/mldsastdlib package. This enablesmldsa44,mldsa65, andmldsa87as validKeyRequest.Algovalues for post-quantum certificate authority key generation.Changes
Core ML-DSA support (~80 lines)
csr/csr.go:KeyRequest.Generate()callsmldsa.GenerateKey()with the correct parameter set for each variantcsr/csr.go:KeyRequest.SigAlgo()maps algo strings tox509.MLDSA44/65/87csr/csr.go:ParseRequest()marshals ML-DSA keys as PKCS#8 PEM ("PRIVATE KEY"block type) — replaces the oldpanic()default with proper PKCS#8 encodingsigner/signer.go:DefaultSigAlgo()handles*mldsa.PublicKey, dispatches byParameters()helpers/helpers.go:SignerAlgo()mirrorsDefaultSigAlgo()for ML-DSA keyshelpers/derhelpers/derhelpers.go:ParsePrivateKeyDER()recognizes*mldsa.PrivateKeyvia PKCS#8Tests (~140 lines)
TestMLDSAGeneration: table-driven over 44/65/87, verifies key type and parametersTestMLDSAGenerationIgnoresSize: edge case — size field ignored for ML-DSATestMLDSACertRequest: PKCS#8 PEM output, round-trip parsing, CSR withx509.MLDSATestDefaultSigAlgoMLDSA: signer-layer signature algorithm selectionTestSignerAlgoMLDSA: helper-layer signature algorithm selectionTestParsePrivateKeyDERMLDSA: PKCS#8 round-trip for all three variantsinitca: ML-DSA-44/65/87 invalidKeyParams,mldsa99ininvalidCryptoParamsGo 1.27 compatibility (~80 lines)
go.mod: bump fromgo 1.20togo 1.27transport/roots/system/root_darwin_armx.go: fixinitSystemRoots()signaturehelpers/helpers_test.go: fix 256-bit RSA key generation (rejected in Go 1.27)signer/local/local_test.go: OID-based CT poison lookup inTestSignFromPrecertsigner/local/local_test.go: updateTestLintexpectations for Go 1.27 zlint results.github/workflows/go.yml: Go 1.27 matrix,golangci-lint-action@v9Breaking Change
Raises
go.modminimum to Go 1.27 for all consumers.crypto/mldsadoes not exist in earlier Go versions and cannot be build-tagged.Context
mldsa*today — new code paths are unreachable until Phase 2 enables them in COMS.mldsa44p256hybrid composite (Phase 2b),initcalogic changes, KDL wrapping,/gencrl