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
16 changes: 13 additions & 3 deletions keyutil/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/pkg/errors"
"golang.org/x/crypto/ssh"

"go.step.sm/crypto/mldsa"
"go.step.sm/crypto/x25519"
)

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions keyutil/key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
58 changes: 58 additions & 0 deletions keyutil/mldsa_go127_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
19 changes: 19 additions & 0 deletions keyutil/mldsa_stub_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
60 changes: 60 additions & 0 deletions mldsa/mldsa.go
Original file line number Diff line number Diff line change
@@ -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
}
70 changes: 70 additions & 0 deletions mldsa/mldsa_go127.go
Original file line number Diff line number Diff line change
@@ -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)
}
70 changes: 70 additions & 0 deletions mldsa/mldsa_go127_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading