Skip to content

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

Draft
ang-cloudflare wants to merge 3 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 3 commits into
cloudflare:masterfrom
ang-cloudflare:ang/SECENG-13957

Conversation

@ang-cloudflare

Copy link
Copy Markdown

Summary

Add ML-DSA (FIPS 204) key generation and signature algorithm support using Go 1.27's crypto/mldsa stdlib package. This enables mldsa44, mldsa65, and mldsa87 as valid KeyRequest.Algo values for post-quantum certificate authority key generation.

Changes

Core ML-DSA support (~80 lines)

  • csr/csr.go: KeyRequest.Generate() calls mldsa.GenerateKey() with the correct parameter set for each variant
  • csr/csr.go: KeyRequest.SigAlgo() maps algo strings to x509.MLDSA44/65/87
  • csr/csr.go: ParseRequest() marshals ML-DSA keys as PKCS#8 PEM ("PRIVATE KEY" block type) — replaces the old panic() default with proper PKCS#8 encoding
  • signer/signer.go: DefaultSigAlgo() handles *mldsa.PublicKey, dispatches by Parameters()
  • helpers/helpers.go: SignerAlgo() mirrors DefaultSigAlgo() for ML-DSA keys
  • helpers/derhelpers/derhelpers.go: ParsePrivateKeyDER() recognizes *mldsa.PrivateKey via PKCS#8

Tests (~140 lines)

  • TestMLDSAGeneration: table-driven over 44/65/87, verifies key type and parameters
  • TestMLDSAGenerationIgnoresSize: edge case — size field ignored for ML-DSA
  • TestMLDSACertRequest: PKCS#8 PEM output, round-trip parsing, CSR with x509.MLDSA
  • TestDefaultSigAlgoMLDSA: signer-layer signature algorithm selection
  • TestSignerAlgoMLDSA: helper-layer signature algorithm selection
  • TestParsePrivateKeyDERMLDSA: PKCS#8 round-trip for all three variants
  • initca: ML-DSA-44/65/87 in validKeyParams, mldsa99 in invalidCryptoParams

Go 1.27 compatibility (~80 lines)

  • go.mod: bump from go 1.20 to go 1.27
  • transport/roots/system/root_darwin_armx.go: fix initSystemRoots() signature
  • helpers/helpers_test.go: fix 256-bit RSA key generation (rejected in Go 1.27)
  • signer/local/local_test.go: OID-based CT poison lookup in TestSignFromPrecert
  • signer/local/local_test.go: update TestLint expectations for Go 1.27 zlint results
  • .github/workflows/go.yml: Go 1.27 matrix, golangci-lint-action@v9

Breaking Change

Raises go.mod minimum to Go 1.27 for all consumers. crypto/mldsa does not exist in earlier Go versions and cannot be build-tagged.

Context

  • Jira: SECENG-13957
  • Spec: Func Spec: PQC Managed Gateway CA
  • Safe to merge: No callers send mldsa* today — new code paths are unreachable until Phase 2 enables them in COMS.
  • Not in scope: mldsa44p256 hybrid composite (Phase 2b), initca logic changes, KDL wrapping, /gencrl

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