Skip to content

SECENG-13957: feat: add ML-DSA-44/65/87 post-quantum key support - #1445

Draft
ang-cloudflare wants to merge 4 commits into
cloudflare:masterfrom
ang-cloudflare:ang/SECENG-13957
Draft

SECENG-13957: feat: add ML-DSA-44/65/87 post-quantum key support#1445
ang-cloudflare wants to merge 4 commits into
cloudflare:masterfrom
ang-cloudflare:ang/SECENG-13957

Conversation

@ang-cloudflare

@ang-cloudflare ang-cloudflare commented Aug 26, 2026

Copy link
Copy Markdown

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

  • Generate ML-DSA-44/65/87 keys and select the matching X.509 signature algorithm.
  • Marshal private keys as seed-only PKCS#8 and parse them through the existing DER helpers.
  • Support ML-DSA certificates in bundling, CA renewal, key metadata, and display helpers.
  • Preserve errors for unsupported algorithms without falling back to RSA or ECDSA.

Verification

  • Cover key generation, CSR creation, PKCS#8 round trips, signature selection, self-signed CA creation, renewal, and bundling for all three parameter sets.
  • Round-trip the RFC 9881 ML-DSA-44 example private key.
  • Generate, parse, and verify ML-DSA-issued CRLs for all three parameter sets.
  • Run focused ML-DSA tests with GOFIPS140=latest and GODEBUG=fips140=on.

Go 1.27 migration

  • Raise the module minimum from Go 1.20 to Go 1.27. crypto/mldsa is unavailable before Go 1.27 and cannot be build-tagged.
  • Update CI and golangci-lint, remove the deleted x509sha1 GODEBUG override, and fix Go 1.27 compatibility and vet failures.

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

  • Jira: SECENG-13957
  • Current spec: PQC ML-DSA managed-CA generation in COMS
  • Production qualification uses the Cloudflare Go 1.27.1-1 or newer package with GOFIPS140=latest; this public workflow verifies compatibility with the corresponding upstream FIPS mode.
  • Downstream scope: COMS selection/defaulting, KDL/GKA wrapping, and the wrapped-key /gencrl endpoint remain integration responsibilities outside this repository.
  • Not in scope: hybrid/composite mldsa44p256 certificates or CSR-signed PQ leaf certificates.

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
ang-cloudflare marked this pull request as draft August 26, 2026 23:08

@ang-cloudflare ang-cloudflare left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread helpers/helpers.go
return x509.MLDSA65
case pub.Parameters() == mldsa.MLDSA87():
return x509.MLDSA87
default:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread initca/initca_test.go
{A: "ed25519"},
{A: "mldsa44"},
{A: "mldsa65"},
{A: "mldsa87"},

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread helpers/helpers.go
}
case ed25519.PublicKey:
return x509.PureEd25519
case *mldsa.PublicKey:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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"
Comment thread bundler/bundle.go Outdated
case x509.Ed25519:
keyType = "Ed25519"
case x509.MLDSA:
keyType = fmt.Sprintf("%d-byte ML-DSA", keyLength)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't make much sense—just use the name: ML-DSA-44, etc.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — now uses the parameter set name directly: ML-DSA-44, ML-DSA-65, ML-DSA-87 (dispatched via Parameters() comparison).

Comment thread bundler/bundle.go
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})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add test that it correctly roundtrips this example private key from RFC 9881:

-----BEGIN PRIVATE KEY-----
MDQCAQAwCwYJYIZIAWUDBAMRBCKAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZ
GhscHR4f
-----END PRIVATE KEY-----

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread helpers/helpers.go
return rsaKey.N.BitLen()
} else if _, ok := key.(ed25519.PublicKey); ok {
return ed25519.PublicKeySize
} else if mldsaKey, ok := key.(*mldsa.PublicKey); ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
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