Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions x509/mldsa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package x509

// Encoded ML-DSA public key sizes, FIPS 204 Table 2. They are declared here
// rather than taken from crypto/mldsa so that ML-DSA certificates parse
// identically on toolchains that predate that package; see mldsa_go127.go and
// mldsa_stub.go.
const (
mldsa44PublicKeySize = 1312
mldsa65PublicKeySize = 1952
mldsa87PublicKeySize = 2592
)
61 changes: 61 additions & 0 deletions x509/mldsa_go127.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//go:build go1.27

package x509

import (
"crypto/mldsa"
"encoding/asn1"
"fmt"
)

// parseMLDSAPublicKey returns the ML-DSA public key encoded in data as a
// *mldsa.PublicKey. The caller has already checked the length of data against
// the parameter set named by oid, so any failure here means ML-DSA itself is
// unavailable, as it is with the FIPS 140-3 Go Cryptographic Module v1.0.0.
// In that case it returns nil: the certificate still parses and reports its
// algorithms, but its signatures cannot be checked.
func parseMLDSAPublicKey(oid asn1.ObjectIdentifier, data []byte) interface{} {
var params mldsa.Parameters
switch {
case oid.Equal(oidKeyMLDSA44):
params = mldsa.MLDSA44()
case oid.Equal(oidKeyMLDSA65):
params = mldsa.MLDSA65()
case oid.Equal(oidKeyMLDSA87):
params = mldsa.MLDSA87()
default:
return nil
}
pub, err := mldsa.NewPublicKey(params, data)
if err != nil {
return nil
}
return pub
}

// checkMLDSASignature verifies a pure ML-DSA signature, with an empty context
// string, as RFC 9881 requires for X.509.
func checkMLDSASignature(publicKey interface{}, algo SignatureAlgorithm, signed, signature []byte) error {
pub, ok := publicKey.(*mldsa.PublicKey)
if !ok {
return ErrUnsupportedAlgorithm
}
var keyAlgo SignatureAlgorithm
switch pub.Parameters() {
case mldsa.MLDSA44():
keyAlgo = MLDSA44
case mldsa.MLDSA65():
keyAlgo = MLDSA65
case mldsa.MLDSA87():
keyAlgo = MLDSA87
default:
return ErrUnsupportedAlgorithm
}
if algo != keyAlgo {
return fmt.Errorf("x509: %s signature does not match %s public key", algo, keyAlgo)
}
if err := mldsa.Verify(pub, signed, signature, nil); err != nil {
return fmt.Errorf("x509: ML-DSA verification failure: %w", err)
}
return nil
}
77 changes: 77 additions & 0 deletions x509/mldsa_go127_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//go:build go1.27

package x509

import (
"crypto/mldsa"
stdx509 "crypto/x509"
"testing"
)

func TestMLDSASignatures(t *testing.T) {
roots := map[string]mldsa.Parameters{
"mldsa-44-root.pem": mldsa.MLDSA44(),
"mldsa-65-root.pem": mldsa.MLDSA65(),
"mldsa-87-root.pem": mldsa.MLDSA87(),
}
for filename, params := range roots {
t.Run(filename, func(t *testing.T) {
c := parseMLDSATestCertificate(t, filename)
pub, ok := c.PublicKey.(*mldsa.PublicKey)
if !ok {
t.Fatalf("PublicKey is %T, want *mldsa.PublicKey", c.PublicKey)
}
if pub.Parameters() != params {
t.Errorf("PublicKey parameters = %s, want %s", pub.Parameters(), params)
}
if !c.SelfSigned {
t.Error("SelfSigned = false, want true")
}
})
}

t.Run("invalid signature", func(t *testing.T) {
c := parseMLDSATestCertificate(t, "mldsa-65-root-invalid-sig.pem")
if c.SelfSigned {
t.Error("SelfSigned = true, want false")
}
})

t.Run("chain", func(t *testing.T) {
leaf := parseMLDSATestCertificate(t, "mldsa-44-leaf.pem")
root := parseMLDSATestCertificate(t, "mldsa-65-root.pem")
if err := leaf.CheckSignatureFrom(root); err != nil {
t.Errorf("CheckSignatureFrom(ML-DSA-65 root) = %v, want nil", err)
}
})

t.Run("parameter mismatch", func(t *testing.T) {
// The leaf's ML-DSA-65 signature must not be checked with an
// ML-DSA-44 key, even one whose certificate is otherwise usable.
leaf := parseMLDSATestCertificate(t, "mldsa-44-leaf.pem")
wrong := parseMLDSATestCertificate(t, "mldsa-44-root.pem")
if err := CheckSignatureFromKey(wrong.PublicKey, leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature); err == nil {
t.Error("CheckSignatureFromKey with an ML-DSA-44 key accepted an ML-DSA-65 signature")
}
})
}

// TestMLDSAMatchesStandardLibrary checks that zcrypto names ML-DSA algorithms
// the same way crypto/x509 does.
func TestMLDSAMatchesStandardLibrary(t *testing.T) {
for _, filename := range []string{"mldsa-44-root.pem", "mldsa-65-root.pem", "mldsa-87-root.pem", "mldsa-44-leaf.pem"} {
t.Run(filename, func(t *testing.T) {
c := parseMLDSATestCertificate(t, filename)
std, err := stdx509.ParseCertificate(c.Raw)
if err != nil {
t.Fatalf("crypto/x509: %s", err)
}
if got, want := c.SignatureAlgorithm.String(), std.SignatureAlgorithm.String(); got != want {
t.Errorf("SignatureAlgorithm = %q, crypto/x509 has %q", got, want)
}
if got, want := c.PublicKeyAlgorithm.String(), std.PublicKeyAlgorithm.String(); got != want {
t.Errorf("PublicKeyAlgorithm = %q, crypto/x509 has %q", got, want)
}
})
}
}
18 changes: 18 additions & 0 deletions x509/mldsa_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build !go1.27

package x509

import "encoding/asn1"

// parseMLDSAPublicKey returns nil because crypto/mldsa requires Go 1.27. ML-DSA
// certificates still parse and report their algorithms; only signature checks
// are unavailable. Remove this file once the minimum Go version is 1.27.
func parseMLDSAPublicKey(asn1.ObjectIdentifier, []byte) interface{} {
return nil
}

// checkMLDSASignature reports ML-DSA signatures as unsupported because
// crypto/mldsa requires Go 1.27.
func checkMLDSASignature(interface{}, SignatureAlgorithm, []byte, []byte) error {
return ErrUnsupportedAlgorithm
}
23 changes: 23 additions & 0 deletions x509/mldsa_stub_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//go:build !go1.27

package x509

import (
"errors"
"testing"
)

// Without crypto/mldsa, ML-DSA certificates parse but their signatures are
// reported as unsupported rather than invalid.
func TestMLDSASignaturesUnsupported(t *testing.T) {
c := parseMLDSATestCertificate(t, "mldsa-65-root.pem")
if c.PublicKey != nil {
t.Errorf("PublicKey = %T, want nil", c.PublicKey)
}
if c.SelfSigned {
t.Error("SelfSigned = true, want false")
}
if err := c.CheckSignature(c.SignatureAlgorithm, c.RawTBSCertificate, c.Signature); !errors.Is(err, ErrUnsupportedAlgorithm) {
t.Errorf("CheckSignature = %v, want ErrUnsupportedAlgorithm", err)
}
}
115 changes: 115 additions & 0 deletions x509/mldsa_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package x509

import (
"encoding/asn1"
"encoding/json"
"encoding/pem"
"os"
"testing"

"github.com/smallstep/zcrypto/x509/pkix"
)

const mldsaTestdataPrefix = testdataPrefix + "mldsa/"

func parseMLDSATestCertificate(t *testing.T, filename string) *Certificate {
t.Helper()
b, err := os.ReadFile(mldsaTestdataPrefix + filename)
if err != nil {
t.Fatalf("could not open %s: %s", filename, err)
}
p, _ := pem.Decode(b)
if p == nil {
t.Fatalf("bad pem %s", filename)
}
c, err := ParseCertificate(p.Bytes)
if err != nil {
t.Fatalf("could not parse %s: %s", filename, err)
}
return c
}

// TestParseMLDSACertificates checks that ML-DSA algorithms are identified on
// every toolchain, including those without crypto/mldsa.
func TestParseMLDSACertificates(t *testing.T) {
tests := []struct {
filename string
sigAlgo SignatureAlgorithm
sigName string
sigOID string
}{
{"mldsa-44-root.pem", MLDSA44, "ML-DSA-44", "2.16.840.1.101.3.4.3.17"},
{"mldsa-65-root.pem", MLDSA65, "ML-DSA-65", "2.16.840.1.101.3.4.3.18"},
{"mldsa-87-root.pem", MLDSA87, "ML-DSA-87", "2.16.840.1.101.3.4.3.19"},
// Issued by the ML-DSA-65 root, but holding an ML-DSA-44 key.
{"mldsa-44-leaf.pem", MLDSA65, "ML-DSA-65", "2.16.840.1.101.3.4.3.18"},
}
for _, test := range tests {
t.Run(test.filename, func(t *testing.T) {
c := parseMLDSATestCertificate(t, test.filename)
if c.SignatureAlgorithm != test.sigAlgo {
t.Errorf("SignatureAlgorithm = %v, want %v", c.SignatureAlgorithm, test.sigAlgo)
}
if got := c.SignatureAlgorithmName(); got != test.sigName {
t.Errorf("SignatureAlgorithmName() = %q, want %q", got, test.sigName)
}
if c.PublicKeyAlgorithm != MLDSA {
t.Errorf("PublicKeyAlgorithm = %v, want %v", c.PublicKeyAlgorithm, MLDSA)
}
if got := c.PublicKeyAlgorithmName(); got != "ML-DSA" {
t.Errorf("PublicKeyAlgorithmName() = %q, want %q", got, "ML-DSA")
}

b, err := json.Marshal(c)
if err != nil {
t.Fatalf("json.Marshal: %s", err)
}
var out struct {
SignatureAlgorithm struct {
Name string `json:"name"`
OID string `json:"oid"`
} `json:"signature_algorithm"`
SubjectKeyInfo struct {
KeyAlgorithm struct {
Name string `json:"name"`
} `json:"key_algorithm"`
} `json:"subject_key_info"`
}
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("json.Unmarshal: %s", err)
}
if out.SignatureAlgorithm.Name != test.sigName || out.SignatureAlgorithm.OID != test.sigOID {
t.Errorf("JSON signature_algorithm = %+v, want {Name:%s OID:%s}", out.SignatureAlgorithm, test.sigName, test.sigOID)
}
if out.SubjectKeyInfo.KeyAlgorithm.Name != "ML-DSA" {
t.Errorf("JSON subject_key_info.key_algorithm.name = %q, want %q", out.SubjectKeyInfo.KeyAlgorithm.Name, "ML-DSA")
}
})
}
}

func TestParseMLDSAPublicKeyLength(t *testing.T) {
tests := []struct {
name string
oid asn1.ObjectIdentifier
size int
}{
{"ML-DSA-44", oidKeyMLDSA44, mldsa44PublicKeySize},
{"ML-DSA-65", oidKeyMLDSA65, mldsa65PublicKeySize},
{"ML-DSA-87", oidKeyMLDSA87, mldsa87PublicKeySize},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, n := range []int{0, test.size - 1, test.size, test.size + 1} {
keyData := &publicKeyInfo{
Algorithm: pkix.AlgorithmIdentifier{Algorithm: test.oid},
PublicKey: asn1.BitString{Bytes: make([]byte, n), BitLength: 8 * n},
}
_, err := parsePublicKey(MLDSA, keyData)
if wantErr := n != test.size; (err != nil) != wantErr {
t.Errorf("%d-byte key: err = %v, want error: %t", n, err, wantErr)
}
}
})
}
}
35 changes: 35 additions & 0 deletions x509/testdata/mldsa/gen.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Regenerates the ML-DSA test certificates with OpenSSL 3.5 or later, an ML-DSA
# implementation independent of Go's crypto/mldsa. Run from this directory.
set -euo pipefail

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

# Self-signed roots, one per parameter set.
for n in 44 65 87; do
openssl req -x509 -newkey "ML-DSA-$n" -keyout "$tmp/root-$n.key" -nodes \
-subj "/CN=ML-DSA-$n Root" -days 36500 \
-addext basicConstraints=critical,CA:TRUE \
-addext keyUsage=critical,keyCertSign,cRLSign \
-out "mldsa-$n-root.pem"
done

# An ML-DSA-44 leaf issued by the ML-DSA-65 root, so the certificate's
# signature algorithm (ML-DSA-65) differs from its own key's (ML-DSA-44).
openssl req -new -newkey ML-DSA-44 -keyout "$tmp/leaf.key" -nodes \
-subj "/CN=leaf.test" -out "$tmp/leaf.csr"
openssl x509 -req -in "$tmp/leaf.csr" -CA mldsa-65-root.pem -CAkey "$tmp/root-65.key" \
-days 36500 -set_serial 2 -extfile <(printf 'subjectAltName=DNS:leaf.test\n') \
-out mldsa-44-leaf.pem

# The ML-DSA-65 root with one signature byte flipped. The signature is the last
# field of the certificate, so flipping the final byte leaves the DER valid.
openssl x509 -in mldsa-65-root.pem -outform DER -out "$tmp/root-65.der"
python3 - "$tmp/root-65.der" "$tmp/bad.der" <<'EOF'
import sys
b = bytearray(open(sys.argv[1], "rb").read())
b[-1] ^= 0x01
open(sys.argv[2], "wb").write(b)
EOF
openssl x509 -inform DER -in "$tmp/bad.der" -out mldsa-65-root-invalid-sig.pem
Loading
Loading