From 132c62d20dab64fc854f06cbf21508563164541e Mon Sep 17 00:00:00 2001 From: Herman Slatman Date: Thu, 16 Jul 2026 12:02:18 +0200 Subject: [PATCH] Add scaffolding for ML-DSA support --- keyutil/key.go | 16 +++++-- keyutil/key_test.go | 1 + keyutil/mldsa_go127_test.go | 58 +++++++++++++++++++++++ keyutil/mldsa_stub_test.go | 19 ++++++++ mldsa/mldsa.go | 60 ++++++++++++++++++++++++ mldsa/mldsa_go127.go | 70 ++++++++++++++++++++++++++++ mldsa/mldsa_go127_test.go | 70 ++++++++++++++++++++++++++++ mldsa/mldsa_stub.go | 90 ++++++++++++++++++++++++++++++++++++ mldsa/mldsa_stub_test.go | 31 +++++++++++++ mldsa/mldsa_test.go | 34 ++++++++++++++ pemutil/mldsa_go127_test.go | 50 ++++++++++++++++++++ pemutil/pem.go | 15 +++++- x509util/algorithms.go | 14 +++++- x509util/algorithms_go127.go | 24 ++++++++++ x509util/algorithms_stub.go | 9 ++++ x509util/mldsa_go127_test.go | 69 +++++++++++++++++++++++++++ x509util/mldsa_stub_test.go | 20 ++++++++ 17 files changed, 644 insertions(+), 6 deletions(-) create mode 100644 keyutil/mldsa_go127_test.go create mode 100644 keyutil/mldsa_stub_test.go create mode 100644 mldsa/mldsa.go create mode 100644 mldsa/mldsa_go127.go create mode 100644 mldsa/mldsa_go127_test.go create mode 100644 mldsa/mldsa_stub.go create mode 100644 mldsa/mldsa_stub_test.go create mode 100644 mldsa/mldsa_test.go create mode 100644 pemutil/mldsa_go127_test.go create mode 100644 x509util/algorithms_go127.go create mode 100644 x509util/algorithms_stub.go create mode 100644 x509util/mldsa_go127_test.go create mode 100644 x509util/mldsa_stub_test.go diff --git a/keyutil/key.go b/keyutil/key.go index a8ec53d8..b6ea7e82 100644 --- a/keyutil/key.go +++ b/keyutil/key.go @@ -16,6 +16,7 @@ import ( "github.com/pkg/errors" "golang.org/x/crypto/ssh" + "go.step.sm/crypto/mldsa" "go.step.sm/crypto/x25519" ) @@ -63,7 +64,7 @@ func PublicKey(priv interface{}) (crypto.PublicKey, error) { return k.Public(), nil case x25519.PrivateKey: return k.Public(), nil - case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, x25519.PublicKey: + case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, x25519.PublicKey, *mldsa.PublicKey: return k, nil case crypto.Signer: return k.Public(), nil @@ -87,7 +88,7 @@ func GenerateDefaultKeyPair() (crypto.PublicKey, crypto.PrivateKey, error) { // GenerateKey generates a key of the given type (kty). func GenerateKey(kty, crv string, size int) (crypto.PrivateKey, error) { switch kty { - case "EC", "RSA", "OKP": + case "EC", "RSA", "OKP", "MLDSA": return GenerateSigner(kty, crv, size) case "oct": return generateOctKey(size) @@ -122,6 +123,8 @@ func GenerateSigner(kty, crv string, size int) (crypto.Signer, error) { return generateRSAKey(size) case "OKP": return generateOKPKey(crv) + case "MLDSA": + return mldsa.GenerateSigner(crv) default: return nil, errors.Errorf("unrecognized key type: %s", kty) } @@ -134,7 +137,8 @@ func ExtractKey(in interface{}) (interface{}, error) { case *rsa.PublicKey, *rsa.PrivateKey, *ecdsa.PublicKey, *ecdsa.PrivateKey, ed25519.PublicKey, ed25519.PrivateKey, - x25519.PublicKey, x25519.PrivateKey: + x25519.PublicKey, x25519.PrivateKey, + *mldsa.PublicKey, *mldsa.PrivateKey: return in, nil case []byte: return in, nil @@ -190,6 +194,12 @@ func Equal(x, y any) bool { case x25519.PrivateKey: yy, ok := y.(x25519.PrivateKey) return ok && xx.Equal(yy) + case *mldsa.PublicKey: + yy, ok := y.(*mldsa.PublicKey) + return ok && xx.Equal(yy) + case *mldsa.PrivateKey: + yy, ok := y.(*mldsa.PrivateKey) + return ok && xx.Equal(yy) case []byte: // special case for symmetric keys yy, ok := y.([]byte) return ok && bytes.Equal(xx, yy) diff --git a/keyutil/key_test.go b/keyutil/key_test.go index d9f1d629..d397a397 100644 --- a/keyutil/key_test.go +++ b/keyutil/key_test.go @@ -203,6 +203,7 @@ func TestGenerateDefaultKey(t *testing.T) { } }, false}, {"eof", eofReader{}, func(t *testing.T, got interface{}) { + t.Helper() if !reflect.DeepEqual(got, nil) { t.Errorf("GenerateDefaultKey() got = %v, want nil", got) } diff --git a/keyutil/mldsa_go127_test.go b/keyutil/mldsa_go127_test.go new file mode 100644 index 00000000..50a66ad5 --- /dev/null +++ b/keyutil/mldsa_go127_test.go @@ -0,0 +1,58 @@ +//go:build go1.27 + +package keyutil + +import ( + "crypto" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/mldsa" +) + +func TestGenerateKey_MLDSA(t *testing.T) { + for _, crv := range []string{"ML-DSA-44", "ML-DSA-65", "ML-DSA-87"} { + t.Run(crv, func(t *testing.T) { + priv, err := GenerateKey("MLDSA", crv, 0) + require.NoError(t, err) + + signer, ok := priv.(*mldsa.PrivateKey) + require.True(t, ok) + + // PublicKey extracts the public key from the private key. + pub, err := PublicKey(priv) + require.NoError(t, err) + require.IsType(t, &mldsa.PublicKey{}, pub) + + // PublicKey passes through an existing public key. + same, err := PublicKey(pub) + require.NoError(t, err) + assert.True(t, Equal(pub, same)) + + // Equal matches / mismatches. + assert.True(t, Equal(pub, signer.Public())) + assert.True(t, Equal(priv, priv)) + other, err := GenerateKey("MLDSA", crv, 0) + require.NoError(t, err) + assert.False(t, Equal(pub, other.(crypto.Signer).Public())) + + // ExtractKey passes ML-DSA keys through unchanged. + gotPriv, err := ExtractKey(priv) + require.NoError(t, err) + assert.Equal(t, priv, gotPriv) + gotPub, err := ExtractKey(pub) + require.NoError(t, err) + assert.Equal(t, pub, gotPub) + + // VerifyPair succeeds for a matching pair. + require.NoError(t, VerifyPair(pub, priv)) + }) + } +} + +func TestGenerateKey_MLDSA_invalid(t *testing.T) { + _, err := GenerateKey("MLDSA", "ML-DSA-99", 0) + require.Error(t, err) +} diff --git a/keyutil/mldsa_stub_test.go b/keyutil/mldsa_stub_test.go new file mode 100644 index 00000000..80a3c7bc --- /dev/null +++ b/keyutil/mldsa_stub_test.go @@ -0,0 +1,19 @@ +//go:build !go1.27 + +package keyutil + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestGenerateKey_MLDSA_unsupported verifies that requesting an ML-DSA key on a +// Go toolchain older than 1.27 returns an error instead of panicking. +func TestGenerateKey_MLDSA_unsupported(t *testing.T) { + _, err := GenerateKey("MLDSA", "ML-DSA-65", 0) + require.Error(t, err) + + _, err = GenerateSigner("MLDSA", "ML-DSA-65", 0) + require.Error(t, err) +} diff --git a/mldsa/mldsa.go b/mldsa/mldsa.go new file mode 100644 index 00000000..8e2b4200 --- /dev/null +++ b/mldsa/mldsa.go @@ -0,0 +1,60 @@ +// Package mldsa provides a thin, version-independent bridge to the standard +// library's crypto/mldsa package (added in Go 1.27, implementing the ML-DSA +// post-quantum signature scheme specified in FIPS 204). +// +// On Go 1.27 and later the exported types are aliases for the standard library +// types, so keys produced by crypto/x509 (for example by ParsePKCS8PrivateKey) +// are the same concrete types used throughout go.step.sm/crypto. On older Go +// versions the package still compiles, but every operation returns +// [ErrUnsupported] instead. This lets the rest of the module—and its +// importers—reference ML-DSA unconditionally while keeping older toolchains +// building. +// +// The three parameter sets are identified by the canonical FIPS 204 names +// "ML-DSA-44", "ML-DSA-65" and "ML-DSA-87". +package mldsa + +import ( + "crypto" + "fmt" + "strings" +) + +// Parameter set names as defined in FIPS 204. These match the values returned +// by [Parameters.String]. +const ( + MLDSA44Name = "ML-DSA-44" + MLDSA65Name = "ML-DSA-65" + MLDSA87Name = "ML-DSA-87" +) + +// ParametersByName returns the [Parameters] for the given parameter set name. +// The name is matched case-insensitively against "ML-DSA-44", "ML-DSA-65" and +// "ML-DSA-87". +func ParametersByName(name string) (Parameters, error) { + switch { + case strings.EqualFold(name, MLDSA44Name): + return MLDSA44(), nil + case strings.EqualFold(name, MLDSA65Name): + return MLDSA65(), nil + case strings.EqualFold(name, MLDSA87Name): + return MLDSA87(), nil + default: + return Parameters{}, fmt.Errorf("unrecognized ML-DSA parameter set %q", name) + } +} + +// GenerateSigner generates a new ML-DSA private key for the named parameter set +// and returns it as a [crypto.Signer]. It returns an error wrapping +// [ErrUnsupported] when built with a Go toolchain older than 1.27. +func GenerateSigner(name string) (crypto.Signer, error) { + params, err := ParametersByName(name) + if err != nil { + return nil, err + } + sk, err := GenerateKey(params) + if err != nil { + return nil, err + } + return sk, nil +} diff --git a/mldsa/mldsa_go127.go b/mldsa/mldsa_go127.go new file mode 100644 index 00000000..ef051c3d --- /dev/null +++ b/mldsa/mldsa_go127.go @@ -0,0 +1,70 @@ +//go:build go1.27 + +package mldsa + +import ( + stdmldsa "crypto/mldsa" +) + +// Supported reports whether ML-DSA is available in the current build. It is +// true when compiled with Go 1.27 or later. +const Supported = true + +// These types are aliases for the standard library crypto/mldsa types, so keys +// returned by crypto/x509 and crypto/mldsa are interchangeable with the ones +// used throughout go.step.sm/crypto. +type ( + // PublicKey is an ML-DSA public key. + PublicKey = stdmldsa.PublicKey + // PrivateKey is an ML-DSA private key. It implements [crypto.Signer]. + PrivateKey = stdmldsa.PrivateKey + // Parameters represents one of the ML-DSA parameter sets defined in FIPS 204. + Parameters = stdmldsa.Parameters + // Options contains additional options for signing and verifying signatures. + Options = stdmldsa.Options +) + +// Signature and key sizes for each parameter set, re-exported from the standard +// library for convenience. +const ( + PrivateKeySize = stdmldsa.PrivateKeySize + MLDSA44PublicKeySize = stdmldsa.MLDSA44PublicKeySize + MLDSA65PublicKeySize = stdmldsa.MLDSA65PublicKeySize + MLDSA87PublicKeySize = stdmldsa.MLDSA87PublicKeySize + MLDSA44SignatureSize = stdmldsa.MLDSA44SignatureSize + MLDSA65SignatureSize = stdmldsa.MLDSA65SignatureSize + MLDSA87SignatureSize = stdmldsa.MLDSA87SignatureSize +) + +// MLDSA44 returns the ML-DSA-44 parameter set defined in FIPS 204. +func MLDSA44() Parameters { return stdmldsa.MLDSA44() } + +// MLDSA65 returns the ML-DSA-65 parameter set defined in FIPS 204. +func MLDSA65() Parameters { return stdmldsa.MLDSA65() } + +// MLDSA87 returns the ML-DSA-87 parameter set defined in FIPS 204. +func MLDSA87() Parameters { return stdmldsa.MLDSA87() } + +// GenerateKey generates a new random ML-DSA private key for the given parameter +// set. +func GenerateKey(params Parameters) (*PrivateKey, error) { + return stdmldsa.GenerateKey(params) +} + +// NewPrivateKey expands the given 32-byte seed into an ML-DSA private key for +// the given parameter set. +func NewPrivateKey(params Parameters, seed []byte) (*PrivateKey, error) { + return stdmldsa.NewPrivateKey(params, seed) +} + +// NewPublicKey decodes the given public key encoding for the given parameter +// set. +func NewPublicKey(params Parameters, encoding []byte) (*PublicKey, error) { + return stdmldsa.NewPublicKey(params, encoding) +} + +// Verify verifies the signature of message using the given public key and +// options. +func Verify(pk *PublicKey, message, signature []byte, opts *Options) error { + return stdmldsa.Verify(pk, message, signature, opts) +} diff --git a/mldsa/mldsa_go127_test.go b/mldsa/mldsa_go127_test.go new file mode 100644 index 00000000..34b06232 --- /dev/null +++ b/mldsa/mldsa_go127_test.go @@ -0,0 +1,70 @@ +//go:build go1.27 + +package mldsa + +import ( + "crypto" + "crypto/rand" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSupported(t *testing.T) { + assert.True(t, Supported) +} + +func TestGenerateSigner(t *testing.T) { + for _, name := range []string{MLDSA44Name, MLDSA65Name, MLDSA87Name} { + t.Run(name, func(t *testing.T) { + signer, err := GenerateSigner(name) + require.NoError(t, err) + + priv, ok := signer.(*PrivateKey) + require.True(t, ok) + + pub, ok := signer.Public().(*PublicKey) + require.True(t, ok) + assert.Equal(t, name, pub.Parameters().String()) + assert.True(t, priv.PublicKey().Equal(pub)) + + message := []byte("the quick brown fox jumps over the lazy dog") + + // Default signing and verification. + sig, err := signer.Sign(rand.Reader, message, &Options{}) + require.NoError(t, err) + require.NoError(t, Verify(pub, message, sig, &Options{})) + + // A signature must not verify against a different message. + require.Error(t, Verify(pub, []byte("other"), sig, &Options{})) + + // Signing/verifying with a context. + ctxOpts := &Options{Context: "test-context"} + sig, err = signer.Sign(rand.Reader, message, ctxOpts) + require.NoError(t, err) + require.NoError(t, Verify(pub, message, sig, ctxOpts)) + // The context must match on verification. + require.Error(t, Verify(pub, message, sig, &Options{})) + }) + } +} + +func TestNewPrivateKeyFromSeed(t *testing.T) { + seed := make([]byte, PrivateKeySize) + for i := range seed { + seed[i] = byte(i) + } + + // Deriving a key from the same seed twice yields the same key. + a, err := NewPrivateKey(MLDSA65(), seed) + require.NoError(t, err) + b, err := NewPrivateKey(MLDSA65(), seed) + require.NoError(t, err) + assert.True(t, a.Equal(b)) +} + +func TestOptionsHashFunc(t *testing.T) { + var opts crypto.SignerOpts = &Options{} + assert.Equal(t, crypto.Hash(0), opts.HashFunc()) +} diff --git a/mldsa/mldsa_stub.go b/mldsa/mldsa_stub.go new file mode 100644 index 00000000..8fbd221d --- /dev/null +++ b/mldsa/mldsa_stub.go @@ -0,0 +1,90 @@ +//go:build !go1.27 + +package mldsa + +import ( + "crypto" + "errors" + "io" +) + +// Supported reports whether ML-DSA is available in the current build. It is +// false when compiled with a Go toolchain older than 1.27. +const Supported = false + +// ErrUnsupported is returned by every ML-DSA operation when built with a Go +// toolchain older than 1.27. +var ErrUnsupported = errors.New("go.step.sm/crypto/mldsa: ML-DSA requires Go 1.27 or later") + +// Parameters represents one of the ML-DSA parameter sets defined in FIPS 204. +// On Go versions older than 1.27 it only carries the parameter set name. +type Parameters struct { + name string +} + +// String returns the name of the parameter set, e.g. "ML-DSA-44". +func (p Parameters) String() string { return p.name } + +// MLDSA44 returns the ML-DSA-44 parameter set defined in FIPS 204. +func MLDSA44() Parameters { return Parameters{name: MLDSA44Name} } + +// MLDSA65 returns the ML-DSA-65 parameter set defined in FIPS 204. +func MLDSA65() Parameters { return Parameters{name: MLDSA65Name} } + +// MLDSA87 returns the ML-DSA-87 parameter set defined in FIPS 204. +func MLDSA87() Parameters { return Parameters{name: MLDSA87Name} } + +// PublicKey is a placeholder ML-DSA public key. On Go versions older than 1.27 +// it is never populated; it exists so that type switches referencing ML-DSA +// keys compile. +type PublicKey struct{} + +// Bytes always returns nil on unsupported builds. +func (*PublicKey) Bytes() []byte { return nil } + +// Equal always returns false on unsupported builds. +func (*PublicKey) Equal(crypto.PublicKey) bool { return false } + +// Parameters returns the zero Parameters on unsupported builds. +func (*PublicKey) Parameters() Parameters { return Parameters{} } + +// PrivateKey is a placeholder ML-DSA private key. On Go versions older than 1.27 +// it is never populated; it exists so that type switches referencing ML-DSA +// keys compile. It implements [crypto.Signer]. +type PrivateKey struct{} + +// Public returns nil on unsupported builds. +func (*PrivateKey) Public() crypto.PublicKey { return nil } + +// Equal always returns false on unsupported builds. +func (*PrivateKey) Equal(crypto.PrivateKey) bool { return false } + +// Bytes always returns nil on unsupported builds. +func (*PrivateKey) Bytes() []byte { return nil } + +// Sign always returns ErrUnsupported on unsupported builds. +func (*PrivateKey) Sign(io.Reader, []byte, crypto.SignerOpts) ([]byte, error) { + return nil, ErrUnsupported +} + +// Options contains additional options for signing and verifying signatures. +type Options struct { + // Context can be used to distinguish signatures created for different + // purposes. It must be at most 255 bytes long, and it is empty by default. + Context string +} + +// HashFunc returns zero, to implement the [crypto.SignerOpts] interface. +func (*Options) HashFunc() crypto.Hash { return 0 } + +// GenerateKey returns ErrUnsupported on unsupported builds. +func GenerateKey(Parameters) (*PrivateKey, error) { return nil, ErrUnsupported } + +// NewPrivateKey returns ErrUnsupported on unsupported builds. +func NewPrivateKey(Parameters, []byte) (*PrivateKey, error) { return nil, ErrUnsupported } + +// NewPublicKey returns ErrUnsupported on unsupported builds. +func NewPublicKey(Parameters, []byte) (*PublicKey, error) { return nil, ErrUnsupported } + +// Verify returns ErrUnsupported on unsupported builds. +func Verify(*PublicKey, []byte, []byte, *Options) error { return ErrUnsupported } diff --git a/mldsa/mldsa_stub_test.go b/mldsa/mldsa_stub_test.go new file mode 100644 index 00000000..e88c1e10 --- /dev/null +++ b/mldsa/mldsa_stub_test.go @@ -0,0 +1,31 @@ +//go:build !go1.27 + +package mldsa + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnsupported(t *testing.T) { + assert.False(t, Supported) + + _, err := GenerateSigner(MLDSA65Name) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnsupported)) + + _, err = GenerateKey(MLDSA65()) + assert.True(t, errors.Is(err, ErrUnsupported)) + + _, err = NewPrivateKey(MLDSA65(), nil) + assert.True(t, errors.Is(err, ErrUnsupported)) + + _, err = NewPublicKey(MLDSA65(), nil) + assert.True(t, errors.Is(err, ErrUnsupported)) + + err = Verify(&PublicKey{}, nil, nil, &Options{}) + assert.True(t, errors.Is(err, ErrUnsupported)) +} diff --git a/mldsa/mldsa_test.go b/mldsa/mldsa_test.go new file mode 100644 index 00000000..ade1f595 --- /dev/null +++ b/mldsa/mldsa_test.go @@ -0,0 +1,34 @@ +package mldsa + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParametersByName(t *testing.T) { + tests := []struct { + name string + want string + wantErr bool + }{ + {"ML-DSA-44", "ML-DSA-44", false}, + {"ML-DSA-65", "ML-DSA-65", false}, + {"ML-DSA-87", "ML-DSA-87", false}, + {"ml-dsa-65", "ML-DSA-65", false}, // case-insensitive + {"ML-DSA-99", "", true}, + {"", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params, err := ParametersByName(tt.name) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, params.String()) + }) + } +} diff --git a/pemutil/mldsa_go127_test.go b/pemutil/mldsa_go127_test.go new file mode 100644 index 00000000..fabadff0 --- /dev/null +++ b/pemutil/mldsa_go127_test.go @@ -0,0 +1,50 @@ +//go:build go1.27 + +package pemutil + +import ( + "encoding/pem" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/mldsa" +) + +func TestSerializeParse_MLDSA(t *testing.T) { + for _, crv := range []string{"ML-DSA-44", "ML-DSA-65", "ML-DSA-87"} { + t.Run(crv, func(t *testing.T) { + priv, err := keyutil.GenerateKey("MLDSA", crv, 0) + require.NoError(t, err) + signer := priv.(*mldsa.PrivateKey) + pub := signer.Public() + + // Private key round-trip (PKCS#8, "PRIVATE KEY"). + block, err := Serialize(priv) + require.NoError(t, err) + assert.Equal(t, "PRIVATE KEY", block.Type) + parsedPriv, err := Parse(pem.EncodeToMemory(block)) + require.NoError(t, err) + assert.True(t, keyutil.Equal(priv, parsedPriv)) + + // Public key round-trip (PKIX, "PUBLIC KEY"). + block, err = Serialize(pub) + require.NoError(t, err) + assert.Equal(t, "PUBLIC KEY", block.Type) + parsedPub, err := Parse(pem.EncodeToMemory(block)) + require.NoError(t, err) + assert.True(t, keyutil.Equal(pub, parsedPub)) + + // Encrypted private key round-trip. + password := []byte("supersecret") + block, err = Serialize(priv, WithPassword(password)) + require.NoError(t, err) + assert.Equal(t, "ENCRYPTED PRIVATE KEY", block.Type) + decryptedPriv, err := Parse(pem.EncodeToMemory(block), WithPassword(password)) + require.NoError(t, err) + assert.True(t, keyutil.Equal(priv, decryptedPriv)) + }) + } +} diff --git a/pemutil/pem.go b/pemutil/pem.go index b7b41a25..a46a3b6c 100644 --- a/pemutil/pem.go +++ b/pemutil/pem.go @@ -24,6 +24,7 @@ import ( fileutils "go.step.sm/crypto/internal/utils/file" "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/mldsa" "go.step.sm/crypto/x25519" ) @@ -558,7 +559,7 @@ func Serialize(in interface{}, opts ...Options) (*pem.Block, error) { var p *pem.Block var isPrivateKey bool switch k := in.(type) { - case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey: + case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, *mldsa.PublicKey: b, err := x509.MarshalPKIXPublicKey(k) if err != nil { return nil, errors.WithStack(err) @@ -627,6 +628,18 @@ func Serialize(in interface{}, opts ...Options) (*pem.Block, error) { Bytes: b, } } + case *mldsa.PrivateKey: + // ML-DSA keys are always serialized as PKCS#8. + isPrivateKey = true + ctx.pkcs8 = true + b, err := x509.MarshalPKCS8PrivateKey(k) + if err != nil { + return nil, err + } + p = &pem.Block{ + Type: "PRIVATE KEY", + Bytes: b, + } case *x509.Certificate: p = &pem.Block{ Type: "CERTIFICATE", diff --git a/x509util/algorithms.go b/x509util/algorithms.go index be18e613..60db7580 100644 --- a/x509util/algorithms.go +++ b/x509util/algorithms.go @@ -27,6 +27,9 @@ const ( SHA384WithRSAPSS = "SHA384-RSAPSS" SHA512WithRSAPSS = "SHA512-RSAPSS" PureEd25519 = "Ed25519" + MLDSA44 = "ML-DSA-44" + MLDSA65 = "ML-DSA-65" + MLDSA87 = "ML-DSA-87" ) var ( @@ -51,12 +54,19 @@ var ( oidISOSignatureSHA1WithRSA = asn1.ObjectIdentifier{1, 3, 14, 3, 2, 29} ) -var signatureAlgorithmMapping = []struct { +type signatureAlgorithmDetail struct { name string value x509.SignatureAlgorithm oid asn1.ObjectIdentifier hash crypto.Hash -}{ +} + +// signatureAlgorithmMapping is the effective list of supported signature +// algorithms. It is the base list plus any algorithms that are only available +// on newer Go toolchains (e.g. ML-DSA on Go 1.27+, see algorithms_go127.go). +var signatureAlgorithmMapping = append(baseSignatureAlgorithmMapping, mldsaSignatureAlgorithms...) + +var baseSignatureAlgorithmMapping = []signatureAlgorithmDetail{ {"", x509.UnknownSignatureAlgorithm, nil, crypto.Hash(0)}, {MD2WithRSA, x509.MD2WithRSA, oidSignatureMD2WithRSA, crypto.Hash(0) /* no value for MD2 */}, {MD5WithRSA, x509.MD5WithRSA, oidSignatureMD5WithRSA, crypto.MD5}, diff --git a/x509util/algorithms_go127.go b/x509util/algorithms_go127.go new file mode 100644 index 00000000..9cf85bb5 --- /dev/null +++ b/x509util/algorithms_go127.go @@ -0,0 +1,24 @@ +//go:build go1.27 + +package x509util + +import ( + "crypto" + "crypto/x509" + "encoding/asn1" +) + +// ML-DSA public key / signature algorithm OIDs (RFC 9881, NIST CSOR). +var ( + oidSignatureMLDSA44 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 3, 17} + oidSignatureMLDSA65 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 3, 18} + oidSignatureMLDSA87 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 3, 19} +) + +// mldsaSignatureAlgorithms are the ML-DSA signature algorithms, available only +// on Go 1.27 and later. They are appended to baseSignatureAlgorithmMapping. +var mldsaSignatureAlgorithms = []signatureAlgorithmDetail{ + {MLDSA44, x509.MLDSA44, oidSignatureMLDSA44, crypto.Hash(0) /* no pre-hashing */}, + {MLDSA65, x509.MLDSA65, oidSignatureMLDSA65, crypto.Hash(0) /* no pre-hashing */}, + {MLDSA87, x509.MLDSA87, oidSignatureMLDSA87, crypto.Hash(0) /* no pre-hashing */}, +} diff --git a/x509util/algorithms_stub.go b/x509util/algorithms_stub.go new file mode 100644 index 00000000..f6211a21 --- /dev/null +++ b/x509util/algorithms_stub.go @@ -0,0 +1,9 @@ +//go:build !go1.27 + +package x509util + +// mldsaSignatureAlgorithms is empty on Go toolchains older than 1.27, where the +// standard library does not define the ML-DSA signature algorithms. Attempting +// to use an ML-DSA algorithm name will fail with an "unsupported +// signatureAlgorithm" error. +var mldsaSignatureAlgorithms []signatureAlgorithmDetail diff --git a/x509util/mldsa_go127_test.go b/x509util/mldsa_go127_test.go new file mode 100644 index 00000000..242ef9c7 --- /dev/null +++ b/x509util/mldsa_go127_test.go @@ -0,0 +1,69 @@ +//go:build go1.27 + +package x509util + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/mldsa" +) + +func TestSignatureAlgorithm_MLDSA_JSON(t *testing.T) { + tests := []struct { + name string + want x509.SignatureAlgorithm + }{ + {"ML-DSA-44", x509.MLDSA44}, + {"ML-DSA-65", x509.MLDSA65}, + {"ML-DSA-87", x509.MLDSA87}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sa SignatureAlgorithm + require.NoError(t, json.Unmarshal([]byte(`"`+tt.name+`"`), &sa)) + assert.Equal(t, tt.want, x509.SignatureAlgorithm(sa)) + + b, err := json.Marshal(sa) + require.NoError(t, err) + assert.JSONEq(t, `"`+tt.name+`"`, string(b)) + }) + } +} + +func TestCreateCertificate_MLDSA(t *testing.T) { + priv, err := keyutil.GenerateKey("MLDSA", "ML-DSA-65", 0) + require.NoError(t, err) + signer := priv.(*mldsa.PrivateKey) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "ml-dsa test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + // SignatureAlgorithm left unset: inferred from the ML-DSA signer. + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, signer.Public(), signer) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + assert.Equal(t, x509.MLDSA65, cert.SignatureAlgorithm) + assert.Equal(t, x509.MLDSA, cert.PublicKeyAlgorithm) + require.IsType(t, &mldsa.PublicKey{}, cert.PublicKey) + + // The certificate is self-signed and must verify against itself. + require.NoError(t, cert.CheckSignatureFrom(cert)) +} diff --git a/x509util/mldsa_stub_test.go b/x509util/mldsa_stub_test.go new file mode 100644 index 00000000..65413b4e --- /dev/null +++ b/x509util/mldsa_stub_test.go @@ -0,0 +1,20 @@ +//go:build !go1.27 + +package x509util + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSignatureAlgorithm_MLDSA_unsupported verifies that ML-DSA signature +// algorithm names are not recognized on Go toolchains older than 1.27. +func TestSignatureAlgorithm_MLDSA_unsupported(t *testing.T) { + for _, name := range []string{"ML-DSA-44", "ML-DSA-65", "ML-DSA-87"} { + var sa SignatureAlgorithm + err := json.Unmarshal([]byte(`"`+name+`"`), &sa) + require.Error(t, err) + } +}