Skip to content

Identify and verify ML-DSA certificates - #142

Draft
joshdrake wants to merge 1 commit into
masterfrom
feat/mldsa-algorithms
Draft

joshdrake wants to merge 1 commit into
masterfrom
feat/mldsa-algorithms

Conversation

@joshdrake

Copy link
Copy Markdown

Name of feature:

ML-DSA (FIPS 204, RFC 9881) signature and public key algorithms in x509.

Pain or issue this feature alleviates:

zcrypto parsed ML-DSA certificates with an unknown signature and public key algorithm. step certificate inspect --format json, which uses zcrypto's JSON encoding, therefore reports every ML-DSA certificate like this:

"signature_algorithm": { "name": "0", "oid": "" },
"subject_key_info": { "key_algorithm": { "name": "unknown_algorithm" } },
"signature": { "self_signed": false }

The text output (certinfo) and OpenSSL identify the same certificates correctly. step CLI v0.30.7 can now create ML-DSA certificates (smallstep/cli#1698), so this shows up for anyone who uses them.

With this change, built into step with Go 1.27.1 (ECDSA output and all text output unchanged):

certificate before after
ML-DSA-65 self-signed root "0", unknown_algorithm, self_signed: false ML-DSA-65 / 2.16.840.1.101.3.4.3.18, ML-DSA, self_signed: true
ML-DSA-44 leaf issued by that root "0", unknown_algorithm, self_signed: false ML-DSA-65 / …3.18, ML-DSA, self_signed: false

Why is this important to the project (if not answered above):

Post-quantum certificates are starting to be used, and JSON output is what automation consumes.

What changed:

  • MLDSA44, MLDSA65, MLDSA87 signature algorithms and an MLDSA public key algorithm, named exactly as Go 1.27's crypto/x509 names them (ML-DSA-44… and ML-DSA). A test asserts the names match the standard library. The new constants are appended to the enums, so existing values do not change.
  • Go 1.27+ (mldsa_go127.go): keys parse to *mldsa.PublicKey, and signatures are verified as pure ML-DSA with an empty context, as RFC 9881 requires. A signature whose parameter set differs from the key's is rejected, which follows crypto/x509.
  • Go 1.26 (mldsa_stub.go): certificates still parse and report their algorithms, but signatures return ErrUnsupportedAlgorithm. This is the same go1.27 build-tag and stub split that go.step.sm/crypto/mldsa uses. The stub can be deleted once the minimum Go is 1.27.
  • Public key length is checked on every toolchain, so a certificate parses the same way regardless of Go version.

Two deliberate differences from crypto/x509:

  1. Parameters present in the key's AlgorithmIdentifier are tolerated. RFC 9881 says they MUST be absent, and the standard library rejects the certificate. zcrypto already skips the equivalent RSA NULL-parameters check for leniency, so I did the same here.
  2. FIPS 140-3 module v1.0.0. crypto/mldsa is unavailable there. The standard library reports UnknownPublicKeyAlgorithm. This change instead still names the algorithm, parses the certificate with a nil public key, and leaves signatures unverifiable. That matters for FIPS builds such as step-ca-pro's -fips image.

Is there documentation on how to use this feature? If so, where?

No API to learn: ParseCertificate and the JSON encoding pick it up automatically.

In what environments or workflows is this feature supported?

  • Parsing, naming and JSON output: every Go version.
  • Signature verification: Go 1.27 and later. This covers SelfSigned, CheckSignature, and CheckSignatureFrom.

In what environments or workflows is this feature explicitly NOT supported (if any)?

  • Signature verification on Go 1.26. It reports ErrUnsupportedAlgorithm, not a verification failure.
  • CreateCertificate with ML-DSA keys (marshalPublicKey and signing). It is out of scope for this PR, which only covers parsing.
  • The JSON output does not include the ML-DSA public key bytes. It doesn't for Ed25519 either.

Testing

The test certificates are generated by x509/testdata/mldsa/gen.sh with OpenSSL 3.6, an ML-DSA implementation independent of Go's. OpenSSL verifies all of them except the deliberately tampered one. The set is:

  • self-signed roots for all three parameter sets;
  • an ML-DSA-44 leaf issued by the ML-DSA-65 root, so the signature and key parameter sets differ;
  • a root with a flipped signature byte.

The tests cover:

  • mldsa_test.go (all toolchains): the algorithm enums, the name functions, JSON signature_algorithm name and OID, JSON key_algorithm, and rejection of each key length ±1.
  • mldsa_go127_test.go:
    • SelfSigned is true for the roots and false for the tampered root;
    • the leaf verifies against the root through CheckSignatureFrom;
    • an ML-DSA-65 signature is rejected when checked with an ML-DSA-44 key;
    • names match crypto/x509.
  • mldsa_stub_test.go (Go 1.26): the certificate parses, has a nil key, SelfSigned is false, and signature checks return ErrUnsupportedAlgorithm.

How I ran it:

  • The ML-DSA tests pass on both go1.26.0 and go1.27.1.
  • go vet passes on both, and golangci-lint with the shared smallstep config reports 0 new issues on both.
  • The full go test ./... has the same 11 failures as master on both toolchains, with no new ones. Those failures predate this PR: the test fixtures use 512-bit RSA keys, which Go 1.24+ refuses, plus TestImports and some tls handshake tests. CI does not catch them because ci.yml sets run-test: false and run-lint: false. That also means CI will not run the tests in this PR, so please run them locally.
  • Built into step CLI through a go.work, where the JSON output matches the table above, and step's full go test -short ./... passes against zcrypto master plus this change.

Supporting links/other PRs/issues:

Certificates signed with or holding ML-DSA keys (RFC 9881) were parsed
with an unknown signature and public key algorithm, so their JSON
reported "signature_algorithm": {"name": "0", "oid": ""} and
"key_algorithm": {"name": "unknown_algorithm"}, and self-signed ML-DSA
roots were reported as not self-signed. This is what step certificate
inspect --format json shows for ML-DSA certificates.

Add the ML-DSA-44, ML-DSA-65 and ML-DSA-87 signature algorithms and an
ML-DSA public key algorithm, named as crypto/x509 names them. They are
identified on every toolchain. With Go 1.27 or later, public keys are
parsed into *mldsa.PublicKey and signatures are verified with
crypto/mldsa, rejecting a signature whose parameter set differs from the
key's. Older toolchains parse the certificate but report its signatures
as unsupported, following the stub pattern in go.step.sm/crypto/mldsa.

Key lengths are checked on every toolchain so a certificate parses the
same way regardless of Go version. When crypto/mldsa is unavailable at
runtime, as with the FIPS 140-3 Go Cryptographic Module v1.0.0, the
certificate still parses with a nil public key instead of failing.

The test certificates are generated with OpenSSL by
x509/testdata/mldsa/gen.sh, so they come from an ML-DSA implementation
independent of Go's.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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