crypto/pq/mldsa: export NewKeyFromSeed for all three parameter sets

Unblocks deterministic ML-DSA key derivation from cSHAKE-derived child
seeds (HD wallet derivation, validator identity, DKG round seeds).

mldsa44, mldsa65, mldsa87 each expose:
  NewKeyFromSeed(seed []byte) (PublicKey, PrivateKey, error)

Seed contract:
  len < 32        -> ErrSeedTooShort (FIPS 204 minimum)
  len == 32       -> used verbatim as xi (FIPS 204 5.1)
  len > 32        -> xi = cSHAKE-256(seed, S=per-set customization)

cSHAKE customization strings ("LUX/FIPS204/MLDSA{44,65,87}/seed") provide
parameter-set domain separation: the same oversized parent seed handed to
all three packages yields three independent keypairs (NIST SP 800-185 3.3).

Each subpackage also re-exports GenerateKey / Sign / Verify so consumers
do not have to import the underlying primitive alongside.

Patch-bump.
This commit is contained in:
Hanzo AI
2026-05-10 19:56:00 -07:00
parent d8de0bab87
commit 26cd8bce0d
9 changed files with 1106 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mldsa is the canonical Lux entry point for ML-DSA
// (Module-Lattice-Based Digital Signature Algorithm, FIPS 204).
//
// Each FIPS 204 parameter set lives in its own subpackage. Choose the
// security level at compile time by importing the right one:
//
// github.com/luxfi/crypto/pq/mldsa/mldsa44 // NIST Level 2
// github.com/luxfi/crypto/pq/mldsa/mldsa65 // NIST Level 3 (recommended)
// github.com/luxfi/crypto/pq/mldsa/mldsa87 // NIST Level 5
//
// Each subpackage exposes:
//
// GenerateKey(rand io.Reader) — random keygen
// NewKeyFromSeed(seed []byte) — deterministic keygen (FIPS 204 §5.1)
// Sign(sk, msg, ctx) ([]byte, error) — domain-separated signing
// Verify(pk, msg, ctx, sig) bool — domain-separated verification
//
// Deterministic keygen is the entry point HD-wallet derivation, validator
// identity provisioning, and DKG round-seed paths use to materialize a
// keypair from a child seed produced by a cSHAKE-derived chain. The
// per-parameter-set constant SeedSize is exactly the FIPS 204 §5.1 ξ
// length (32 bytes).
package mldsa
+72
View File
@@ -0,0 +1,72 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package seed implements the shared FIPS 204 §5.1 ξ derivation rule used
// by every mldsaXX.NewKeyFromSeed entry point.
//
// FIPS 204 §5.1 KeyGen consumes a 32-byte seed ξ. Our deterministic-keygen
// callers (HD wallets, validator provisioners, DKG round seeds) frequently
// hand us seeds wider than 32 bytes — BIP-32 child seeds, HKDF outputs,
// SHAKE-derived chain segments. Rather than push that branch into every
// call site, we standardise the contract here:
//
// - len(seed) < SeedSize → reject (FIPS 204 minimum violated)
// - len(seed) == SeedSize → use the seed as ξ verbatim
// - len(seed) > SeedSize → ξ = cSHAKE-256(seed, N="", S=customization)
//
// The customization string S is per parameter set (e.g. "LUX/FIPS204/MLDSA65/seed")
// so a single oversized parent seed cannot collide across mldsa44/65/87 — and
// equally cannot collide with any other primitive that uses cSHAKE-256 with
// a different S.
//
// NIST SP 800-185 §3.3 defines cSHAKE: when N is empty and S is non-empty,
// the function is cSHAKE_256(X, L, N="", S) and is distinct from raw
// SHAKE-256 on the same input — exactly the domain separation we want.
package seed
import (
"errors"
"fmt"
"golang.org/x/crypto/sha3"
)
// SeedSize is the FIPS 204 §5.1 KeyGen ξ length in bytes.
// All three parameter sets (mldsa44, mldsa65, mldsa87) use the same ξ size.
const SeedSize = 32
// ErrSeedTooShort is returned when the caller-supplied seed has fewer than
// SeedSize bytes. FIPS 204 §5.1 mandates a 32-byte ξ; rejecting short
// inputs prevents accidentally seeding KeyGen with low-entropy material.
var ErrSeedTooShort = errors.New("mldsa: seed must be at least 32 bytes (FIPS 204 §5.1)")
// Expand reduces seed to the 32-byte ξ that FIPS 204 §5.1 KeyGen consumes.
//
// customization is the cSHAKE-256 S parameter (NIST SP 800-185 §3.3) used
// for parameter-set domain separation. Pass the per-parameter-set label
// ("LUX/FIPS204/MLDSA44/seed", etc.) so an oversized parent seed cannot
// produce colliding ξ across security levels.
//
// out is written in place so the caller controls the lifetime of the
// expanded ξ (allowing callers to wipe it after KeyGen returns).
func Expand(seed []byte, customization []byte, out *[SeedSize]byte) error {
if len(seed) < SeedSize {
return fmt.Errorf("%w: got %d bytes", ErrSeedTooShort, len(seed))
}
if len(seed) == SeedSize {
copy(out[:], seed)
return nil
}
// len(seed) > SeedSize: cSHAKE-256 with the per-parameter-set
// customization string. Empty N matches SP 800-185 §3.3's "plain"
// cSHAKE — the customization S alone provides domain separation.
h := sha3.NewCShake256(nil, customization)
if _, err := h.Write(seed); err != nil {
return fmt.Errorf("mldsa seed expand: cshake write: %w", err)
}
if _, err := h.Read(out[:]); err != nil {
return fmt.Errorf("mldsa seed expand: cshake read: %w", err)
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mldsa44 implements the ML-DSA-44 (NIST Level 2) parameter set
// of FIPS 204 with both random and deterministic keygen.
//
// See github.com/luxfi/crypto/pq/mldsa for the parameter-set overview;
// this file is the Level 2 counterpart of mldsa65 and mldsa87. The API
// surface is identical across parameter sets, so callers can switch
// security levels by changing one import line.
package mldsa44
import (
"errors"
"fmt"
"io"
circl "github.com/cloudflare/circl/sign/mldsa/mldsa44"
"github.com/luxfi/crypto/pq/mldsa/internal/seed"
)
// Size constants. The ξ length is fixed at 32 bytes for every FIPS 204
// parameter set; the public / private / signature sizes are NIST Level 2.
const (
// SeedSize is the FIPS 204 §5.1 ξ length in bytes.
SeedSize = seed.SeedSize
// PublicKeySize is the encoded ML-DSA-44 public key length.
PublicKeySize = circl.PublicKeySize
// PrivateKeySize is the encoded ML-DSA-44 private key length.
PrivateKeySize = circl.PrivateKeySize
// SignatureSize is the encoded ML-DSA-44 signature length.
SignatureSize = circl.SignatureSize
)
// PublicKey is the ML-DSA-44 public key. Aliased to the upstream type so
// callers can interoperate with code that consumes either path.
type PublicKey = circl.PublicKey
// PrivateKey is the ML-DSA-44 private key. Aliased to the upstream type.
type PrivateKey = circl.PrivateKey
// seedCustomization is the cSHAKE-256 S parameter (NIST SP 800-185 §3.3)
// used by NewKeyFromSeed when expanding an oversized parent seed down to
// the 32-byte ξ FIPS 204 §5.1 consumes. The label binds the resulting ξ
// to ML-DSA-44 so the same parent seed cannot collide with mldsa65 or
// mldsa87.
var seedCustomization = []byte("LUX/FIPS204/MLDSA44/seed")
// GenerateKey draws a uniformly random keypair using entropy from rand.
// If rand is nil, crypto/rand.Reader is used.
func GenerateKey(rand io.Reader) (*PublicKey, *PrivateKey, error) {
pk, sk, err := circl.GenerateKey(rand)
if err != nil {
return nil, nil, fmt.Errorf("mldsa44 generate: %w", err)
}
return pk, sk, nil
}
// NewKeyFromSeed deterministically derives an ML-DSA-44 keypair from the
// supplied seed (FIPS 204 §5.1 KeyGen from ξ).
//
// The seed contract follows the Lux pq/mldsa convention:
//
// - len(seed) < SeedSize (32) → returns ErrSeedTooShort.
// - len(seed) == SeedSize → used verbatim as ξ.
// - len(seed) > SeedSize → reduced to ξ via
// cSHAKE-256(seed, N="", S="LUX/FIPS204/MLDSA44/seed").
//
// The cSHAKE customization string provides per-parameter-set domain
// separation: the same parent seed handed to mldsa44.NewKeyFromSeed and
// mldsa65.NewKeyFromSeed yields independent keypairs.
func NewKeyFromSeed(s []byte) (*PublicKey, *PrivateKey, error) {
var xi [SeedSize]byte
if err := seed.Expand(s, seedCustomization, &xi); err != nil {
return nil, nil, err
}
pk, sk := circl.NewKeyFromSeed(&xi)
// Wipe the local ξ buffer; the seed material now lives inside sk.
for i := range xi {
xi[i] = 0
}
return pk, sk, nil
}
// Sign signs msg with sk and domain-separating context ctx. Per FIPS 204
// §5.2, ctx is bound into the signature; callers SHOULD pass a non-empty,
// protocol-specific ctx to prevent cross-protocol signature replay.
// ctx may be at most 255 bytes.
func Sign(sk *PrivateKey, msg, ctx []byte, randomized bool) ([]byte, error) {
if sk == nil {
return nil, errors.New("mldsa44 sign: private key is nil")
}
sig := make([]byte, SignatureSize)
if err := circl.SignTo(sk, msg, ctx, randomized, sig); err != nil {
return nil, fmt.Errorf("mldsa44 sign: %w", err)
}
return sig, nil
}
// Verify reports whether sig is a valid ML-DSA-44 signature on msg under
// pk with domain-separating context ctx (FIPS 204 §5.3). ctx must match
// the value passed to Sign; ctx may be at most 255 bytes.
func Verify(pk *PublicKey, msg, ctx, sig []byte) bool {
if pk == nil {
return false
}
return circl.Verify(pk, msg, ctx, sig)
}
+174
View File
@@ -0,0 +1,174 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsa44
import (
"bytes"
"crypto/rand"
"testing"
)
func TestNewKeyFromSeed_Deterministic(t *testing.T) {
seed := make([]byte, SeedSize)
if _, err := rand.Read(seed); err != nil {
t.Fatalf("rand: %v", err)
}
pk1, sk1, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed #1: %v", err)
}
pk2, sk2, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed #2: %v", err)
}
if !bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("deterministic keygen: public keys differ for identical seed")
}
if !bytes.Equal(sk1.Bytes(), sk2.Bytes()) {
t.Fatal("deterministic keygen: private keys differ for identical seed")
}
}
func TestNewKeyFromSeed_DifferentSeeds(t *testing.T) {
seed1 := make([]byte, SeedSize)
if _, err := rand.Read(seed1); err != nil {
t.Fatalf("rand: %v", err)
}
seed2 := append([]byte{}, seed1...)
seed2[0] ^= 0x01
pk1, _, err := NewKeyFromSeed(seed1)
if err != nil {
t.Fatalf("NewKeyFromSeed seed1: %v", err)
}
pk2, _, err := NewKeyFromSeed(seed2)
if err != nil {
t.Fatalf("NewKeyFromSeed seed2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("different seeds produced identical public keys")
}
}
func TestNewKeyFromSeed_SignVerifyRoundtrip(t *testing.T) {
seed := bytes.Repeat([]byte{0xA5}, SeedSize)
pk, sk, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("ML-DSA-44 deterministic keygen round-trip")
ctx := []byte("LUX/test/mldsa44")
sig, err := Sign(sk, msg, ctx, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if len(sig) != SignatureSize {
t.Fatalf("Sign: got %d bytes, want %d", len(sig), SignatureSize)
}
if !Verify(pk, msg, ctx, sig) {
t.Fatal("Verify rejected a signature produced by the derived keypair")
}
tampered := append([]byte{}, msg...)
tampered[0] ^= 0xFF
if Verify(pk, tampered, ctx, sig) {
t.Fatal("Verify accepted a tampered message")
}
if Verify(pk, msg, []byte("LUX/test/mldsa44/other"), sig) {
t.Fatal("Verify accepted under a different context")
}
}
func TestNewKeyFromSeed_ShortSeedRejected(t *testing.T) {
for _, n := range []int{0, 1, 16, 31} {
short := make([]byte, n)
if _, _, err := NewKeyFromSeed(short); err == nil {
t.Fatalf("NewKeyFromSeed accepted %d-byte seed (want error)", n)
}
}
}
func TestNewKeyFromSeed_ExactSeedSize(t *testing.T) {
seed := make([]byte, SeedSize)
pk, sk, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
if pk == nil || sk == nil {
t.Fatal("NewKeyFromSeed returned nil keypair without error")
}
if len(pk.Bytes()) != PublicKeySize {
t.Fatalf("public key size: got %d, want %d", len(pk.Bytes()), PublicKeySize)
}
if len(sk.Bytes()) != PrivateKeySize {
t.Fatalf("private key size: got %d, want %d", len(sk.Bytes()), PrivateKeySize)
}
}
func TestNewKeyFromSeed_OversizedSeedExpanded(t *testing.T) {
long1 := bytes.Repeat([]byte{0xCC}, 96)
long2 := append([]byte{}, long1...)
long2[64] ^= 0x01
pk1, sk1, err := NewKeyFromSeed(long1)
if err != nil {
t.Fatalf("NewKeyFromSeed long1: %v", err)
}
pk2, _, err := NewKeyFromSeed(long2)
if err != nil {
t.Fatalf("NewKeyFromSeed long2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("oversized seeds differing in tail produced identical public keys")
}
msg := []byte("oversized seed roundtrip")
sig, err := Sign(sk1, msg, nil, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !Verify(pk1, msg, nil, sig) {
t.Fatal("Verify rejected oversized-seed-derived keypair signature")
}
}
func TestNewKeyFromSeed_NoPrefixTruncation(t *testing.T) {
prefix := bytes.Repeat([]byte{0x11}, SeedSize)
long1 := append(append([]byte{}, prefix...), bytes.Repeat([]byte{0x22}, 32)...)
long2 := append(append([]byte{}, prefix...), bytes.Repeat([]byte{0x33}, 32)...)
pk1, _, err := NewKeyFromSeed(long1)
if err != nil {
t.Fatalf("NewKeyFromSeed long1: %v", err)
}
pk2, _, err := NewKeyFromSeed(long2)
if err != nil {
t.Fatalf("NewKeyFromSeed long2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("seeds sharing only the first 32 bytes produced identical keys " +
"(implementation is doing prefix truncation, not cSHAKE expansion)")
}
}
func TestGenerateKey_NotDeterministic(t *testing.T) {
pk1, _, err := GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey #1: %v", err)
}
pk2, _, err := GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey #2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("GenerateKey produced identical keypairs on two calls " +
"(rand source not being consumed)")
}
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mldsa65 implements the ML-DSA-65 (NIST Level 3) parameter set
// of FIPS 204 with both random and deterministic keygen.
//
// The package re-exports the Cloudflare CIRCL types directly so consumers
// of either path see the same PublicKey / PrivateKey, then adds the Lux
// canonical NewKeyFromSeed entry point that the HD-wallet, validator-
// identity, and DKG round-seed paths use.
//
// Random keygen:
//
// pk, sk, err := mldsa65.GenerateKey(rand.Reader)
//
// Deterministic keygen from a 32-byte ξ (FIPS 204 §5.1):
//
// pk, sk, err := mldsa65.NewKeyFromSeed(seed)
//
// Sign / Verify use the FIPS 204 §5.2/§5.3 domain-separating context
// string (callers SHOULD pass a non-empty ctx to prevent cross-protocol
// signature replay).
package mldsa65
import (
"errors"
"fmt"
"io"
circl "github.com/cloudflare/circl/sign/mldsa/mldsa65"
"github.com/luxfi/crypto/pq/mldsa/internal/seed"
)
// Size constants. The ξ length is fixed at 32 bytes for every FIPS 204
// parameter set; the public / private / signature sizes are NIST Level 3.
const (
// SeedSize is the FIPS 204 §5.1 ξ length in bytes.
SeedSize = seed.SeedSize
// PublicKeySize is the encoded ML-DSA-65 public key length.
PublicKeySize = circl.PublicKeySize
// PrivateKeySize is the encoded ML-DSA-65 private key length.
PrivateKeySize = circl.PrivateKeySize
// SignatureSize is the encoded ML-DSA-65 signature length.
SignatureSize = circl.SignatureSize
)
// PublicKey is the ML-DSA-65 public key. Aliased to the upstream type so
// callers can interoperate with code that consumes either path.
type PublicKey = circl.PublicKey
// PrivateKey is the ML-DSA-65 private key. Aliased to the upstream type.
type PrivateKey = circl.PrivateKey
// seedCustomization is the cSHAKE-256 S parameter (NIST SP 800-185 §3.3)
// used by NewKeyFromSeed when expanding an oversized parent seed down to
// the 32-byte ξ FIPS 204 §5.1 consumes. The label binds the resulting ξ
// to ML-DSA-65 so the same parent seed cannot collide with mldsa44 or
// mldsa87.
var seedCustomization = []byte("LUX/FIPS204/MLDSA65/seed")
// GenerateKey draws a uniformly random keypair using entropy from rand.
// If rand is nil, crypto/rand.Reader is used.
func GenerateKey(rand io.Reader) (*PublicKey, *PrivateKey, error) {
pk, sk, err := circl.GenerateKey(rand)
if err != nil {
return nil, nil, fmt.Errorf("mldsa65 generate: %w", err)
}
return pk, sk, nil
}
// NewKeyFromSeed deterministically derives an ML-DSA-65 keypair from the
// supplied seed (FIPS 204 §5.1 KeyGen from ξ).
//
// The seed contract follows the Lux pq/mldsa convention:
//
// - len(seed) < SeedSize (32) → returns ErrSeedTooShort.
// - len(seed) == SeedSize → used verbatim as ξ.
// - len(seed) > SeedSize → reduced to ξ via
// cSHAKE-256(seed, N="", S="LUX/FIPS204/MLDSA65/seed").
//
// The cSHAKE customization string provides per-parameter-set domain
// separation: the same parent seed handed to mldsa44.NewKeyFromSeed and
// mldsa65.NewKeyFromSeed yields independent keypairs.
func NewKeyFromSeed(s []byte) (*PublicKey, *PrivateKey, error) {
var xi [SeedSize]byte
if err := seed.Expand(s, seedCustomization, &xi); err != nil {
return nil, nil, err
}
pk, sk := circl.NewKeyFromSeed(&xi)
// Wipe the local ξ buffer; the seed material now lives inside sk.
for i := range xi {
xi[i] = 0
}
return pk, sk, nil
}
// Sign signs msg with sk and domain-separating context ctx. Per FIPS 204
// §5.2, ctx is bound into the signature; callers SHOULD pass a non-empty,
// protocol-specific ctx to prevent cross-protocol signature replay.
// ctx may be at most 255 bytes.
//
// randomized=false produces a deterministic signature (recommended for
// reproducible test vectors); randomized=true mixes 32 bytes of fresh
// entropy from crypto/rand into the signing nonce.
func Sign(sk *PrivateKey, msg, ctx []byte, randomized bool) ([]byte, error) {
if sk == nil {
return nil, errors.New("mldsa65 sign: private key is nil")
}
sig := make([]byte, SignatureSize)
if err := circl.SignTo(sk, msg, ctx, randomized, sig); err != nil {
return nil, fmt.Errorf("mldsa65 sign: %w", err)
}
return sig, nil
}
// Verify reports whether sig is a valid ML-DSA-65 signature on msg under
// pk with domain-separating context ctx (FIPS 204 §5.3). ctx must match
// the value passed to Sign; ctx may be at most 255 bytes.
func Verify(pk *PublicKey, msg, ctx, sig []byte) bool {
if pk == nil {
return false
}
return circl.Verify(pk, msg, ctx, sig)
}
+218
View File
@@ -0,0 +1,218 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsa65
import (
"bytes"
"crypto/rand"
"testing"
)
// TestNewKeyFromSeed_Deterministic asserts the headline contract of
// NewKeyFromSeed: identical input seed → identical keypair across two
// independent calls.
func TestNewKeyFromSeed_Deterministic(t *testing.T) {
seed := make([]byte, SeedSize)
if _, err := rand.Read(seed); err != nil {
t.Fatalf("rand: %v", err)
}
pk1, sk1, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed #1: %v", err)
}
pk2, sk2, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed #2: %v", err)
}
if !bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("deterministic keygen: public keys differ for identical seed")
}
if !bytes.Equal(sk1.Bytes(), sk2.Bytes()) {
t.Fatal("deterministic keygen: private keys differ for identical seed")
}
}
// TestNewKeyFromSeed_DifferentSeeds asserts that flipping a single bit of
// the seed produces a different keypair (sanity check that the seed
// actually feeds KeyGen rather than being silently discarded).
func TestNewKeyFromSeed_DifferentSeeds(t *testing.T) {
seed1 := make([]byte, SeedSize)
if _, err := rand.Read(seed1); err != nil {
t.Fatalf("rand: %v", err)
}
seed2 := append([]byte{}, seed1...)
seed2[0] ^= 0x01
pk1, _, err := NewKeyFromSeed(seed1)
if err != nil {
t.Fatalf("NewKeyFromSeed seed1: %v", err)
}
pk2, _, err := NewKeyFromSeed(seed2)
if err != nil {
t.Fatalf("NewKeyFromSeed seed2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("different seeds produced identical public keys")
}
}
// TestNewKeyFromSeed_SignVerifyRoundtrip asserts the keypair derived from
// a seed is usable: Sign then Verify round-trips on a non-trivial message
// with a domain-separating context.
func TestNewKeyFromSeed_SignVerifyRoundtrip(t *testing.T) {
seed := bytes.Repeat([]byte{0xA5}, SeedSize)
pk, sk, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("ML-DSA-65 deterministic keygen round-trip")
ctx := []byte("LUX/test/mldsa65")
sig, err := Sign(sk, msg, ctx, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if len(sig) != SignatureSize {
t.Fatalf("Sign: got %d bytes, want %d", len(sig), SignatureSize)
}
if !Verify(pk, msg, ctx, sig) {
t.Fatal("Verify rejected a signature produced by the derived keypair")
}
// Tamper with the message → must reject.
tampered := append([]byte{}, msg...)
tampered[0] ^= 0xFF
if Verify(pk, tampered, ctx, sig) {
t.Fatal("Verify accepted a tampered message")
}
// Tamper with the context → must reject (FIPS 204 §5.3 binds ctx).
if Verify(pk, msg, []byte("LUX/test/mldsa65/other"), sig) {
t.Fatal("Verify accepted under a different context")
}
}
// TestNewKeyFromSeed_ShortSeedRejected asserts the FIPS 204 §5.1 minimum
// is enforced. Anything below 32 bytes must produce ErrSeedTooShort.
func TestNewKeyFromSeed_ShortSeedRejected(t *testing.T) {
for _, n := range []int{0, 1, 16, 31} {
short := make([]byte, n)
if _, _, err := NewKeyFromSeed(short); err == nil {
t.Fatalf("NewKeyFromSeed accepted %d-byte seed (want error)", n)
}
}
}
// TestNewKeyFromSeed_ExactSeedSize asserts a 32-byte seed is accepted and
// is fed verbatim as ξ (i.e. no cSHAKE expansion applied).
//
// The "verbatim" property is checked indirectly: with the all-zero ξ, the
// keypair is fully determined by FIPS 204 KeyGen, and the same ξ presented
// via NewKeyFromSeed (32 bytes of 0x00) must produce a non-error result.
func TestNewKeyFromSeed_ExactSeedSize(t *testing.T) {
seed := make([]byte, SeedSize) // all zero
pk, sk, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
if pk == nil || sk == nil {
t.Fatal("NewKeyFromSeed returned nil keypair without error")
}
if len(pk.Bytes()) != PublicKeySize {
t.Fatalf("public key size: got %d, want %d", len(pk.Bytes()), PublicKeySize)
}
if len(sk.Bytes()) != PrivateKeySize {
t.Fatalf("private key size: got %d, want %d", len(sk.Bytes()), PrivateKeySize)
}
}
// TestNewKeyFromSeed_OversizedSeedExpanded asserts that oversized seeds
// (the common HD-wallet / HKDF case) are accepted, hashed via cSHAKE-256
// down to ξ, and produce a usable keypair. Two oversized seeds that
// differ in their trailing bytes must yield different keypairs (the
// cSHAKE expansion sees the entire input).
func TestNewKeyFromSeed_OversizedSeedExpanded(t *testing.T) {
long1 := bytes.Repeat([]byte{0xCC}, 96)
long2 := append([]byte{}, long1...)
long2[64] ^= 0x01 // change a byte that lives in the cSHAKE input tail
pk1, sk1, err := NewKeyFromSeed(long1)
if err != nil {
t.Fatalf("NewKeyFromSeed long1: %v", err)
}
pk2, _, err := NewKeyFromSeed(long2)
if err != nil {
t.Fatalf("NewKeyFromSeed long2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("oversized seeds differing in tail produced identical public keys")
}
// Round-trip the long1 keypair.
msg := []byte("oversized seed roundtrip")
sig, err := Sign(sk1, msg, nil, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !Verify(pk1, msg, nil, sig) {
t.Fatal("Verify rejected oversized-seed-derived keypair signature")
}
}
// TestNewKeyFromSeed_CrossSetSeparation asserts that the cSHAKE
// customization string actually domain-separates parameter sets when an
// oversized parent seed is reused. Same long seed → mldsa65 expands with
// the "LUX/FIPS204/MLDSA65/seed" customization, which means the derived
// ξ differs from what mldsa44/mldsa87 would compute on the same input.
//
// We can only check this property against mldsa65 itself here (the
// cross-package check lives in pq/mldsa cross-set tests), but we can
// verify that two oversized seeds that ONLY differ by a single byte at
// position 0 still produce different keypairs even though the first
// SeedSize bytes already differ — the test ensures the cSHAKE path is
// taken instead of a naive prefix truncation.
func TestNewKeyFromSeed_NoPrefixTruncation(t *testing.T) {
// Construct two long seeds that share their first SeedSize bytes
// but differ in the tail. A naive prefix-truncation implementation
// would yield the same keypair; the cSHAKE expansion must not.
prefix := bytes.Repeat([]byte{0x11}, SeedSize)
long1 := append(append([]byte{}, prefix...), bytes.Repeat([]byte{0x22}, 32)...)
long2 := append(append([]byte{}, prefix...), bytes.Repeat([]byte{0x33}, 32)...)
pk1, _, err := NewKeyFromSeed(long1)
if err != nil {
t.Fatalf("NewKeyFromSeed long1: %v", err)
}
pk2, _, err := NewKeyFromSeed(long2)
if err != nil {
t.Fatalf("NewKeyFromSeed long2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("seeds sharing only the first 32 bytes produced identical keys " +
"(implementation is doing prefix truncation, not cSHAKE expansion)")
}
}
// TestGenerateKey_NotDeterministic asserts the random keygen path
// actually produces different keypairs on repeated calls (i.e. it's
// reading from rand, not pinned to a constant).
func TestGenerateKey_NotDeterministic(t *testing.T) {
pk1, _, err := GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey #1: %v", err)
}
pk2, _, err := GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey #2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("GenerateKey produced identical keypairs on two calls " +
"(rand source not being consumed)")
}
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mldsa87 implements the ML-DSA-87 (NIST Level 5) parameter set
// of FIPS 204 with both random and deterministic keygen.
//
// See github.com/luxfi/crypto/pq/mldsa for the parameter-set overview;
// this file is the Level 5 counterpart of mldsa44 and mldsa65. The API
// surface is identical across parameter sets, so callers can switch
// security levels by changing one import line.
package mldsa87
import (
"errors"
"fmt"
"io"
circl "github.com/cloudflare/circl/sign/mldsa/mldsa87"
"github.com/luxfi/crypto/pq/mldsa/internal/seed"
)
// Size constants. The ξ length is fixed at 32 bytes for every FIPS 204
// parameter set; the public / private / signature sizes are NIST Level 5.
const (
// SeedSize is the FIPS 204 §5.1 ξ length in bytes.
SeedSize = seed.SeedSize
// PublicKeySize is the encoded ML-DSA-87 public key length.
PublicKeySize = circl.PublicKeySize
// PrivateKeySize is the encoded ML-DSA-87 private key length.
PrivateKeySize = circl.PrivateKeySize
// SignatureSize is the encoded ML-DSA-87 signature length.
SignatureSize = circl.SignatureSize
)
// PublicKey is the ML-DSA-87 public key. Aliased to the upstream type so
// callers can interoperate with code that consumes either path.
type PublicKey = circl.PublicKey
// PrivateKey is the ML-DSA-87 private key. Aliased to the upstream type.
type PrivateKey = circl.PrivateKey
// seedCustomization is the cSHAKE-256 S parameter (NIST SP 800-185 §3.3)
// used by NewKeyFromSeed when expanding an oversized parent seed down to
// the 32-byte ξ FIPS 204 §5.1 consumes. The label binds the resulting ξ
// to ML-DSA-87 so the same parent seed cannot collide with mldsa44 or
// mldsa65.
var seedCustomization = []byte("LUX/FIPS204/MLDSA87/seed")
// GenerateKey draws a uniformly random keypair using entropy from rand.
// If rand is nil, crypto/rand.Reader is used.
func GenerateKey(rand io.Reader) (*PublicKey, *PrivateKey, error) {
pk, sk, err := circl.GenerateKey(rand)
if err != nil {
return nil, nil, fmt.Errorf("mldsa87 generate: %w", err)
}
return pk, sk, nil
}
// NewKeyFromSeed deterministically derives an ML-DSA-87 keypair from the
// supplied seed (FIPS 204 §5.1 KeyGen from ξ).
//
// The seed contract follows the Lux pq/mldsa convention:
//
// - len(seed) < SeedSize (32) → returns ErrSeedTooShort.
// - len(seed) == SeedSize → used verbatim as ξ.
// - len(seed) > SeedSize → reduced to ξ via
// cSHAKE-256(seed, N="", S="LUX/FIPS204/MLDSA87/seed").
//
// The cSHAKE customization string provides per-parameter-set domain
// separation: the same parent seed handed to mldsa65.NewKeyFromSeed and
// mldsa87.NewKeyFromSeed yields independent keypairs.
func NewKeyFromSeed(s []byte) (*PublicKey, *PrivateKey, error) {
var xi [SeedSize]byte
if err := seed.Expand(s, seedCustomization, &xi); err != nil {
return nil, nil, err
}
pk, sk := circl.NewKeyFromSeed(&xi)
// Wipe the local ξ buffer; the seed material now lives inside sk.
for i := range xi {
xi[i] = 0
}
return pk, sk, nil
}
// Sign signs msg with sk and domain-separating context ctx. Per FIPS 204
// §5.2, ctx is bound into the signature; callers SHOULD pass a non-empty,
// protocol-specific ctx to prevent cross-protocol signature replay.
// ctx may be at most 255 bytes.
func Sign(sk *PrivateKey, msg, ctx []byte, randomized bool) ([]byte, error) {
if sk == nil {
return nil, errors.New("mldsa87 sign: private key is nil")
}
sig := make([]byte, SignatureSize)
if err := circl.SignTo(sk, msg, ctx, randomized, sig); err != nil {
return nil, fmt.Errorf("mldsa87 sign: %w", err)
}
return sig, nil
}
// Verify reports whether sig is a valid ML-DSA-87 signature on msg under
// pk with domain-separating context ctx (FIPS 204 §5.3). ctx must match
// the value passed to Sign; ctx may be at most 255 bytes.
func Verify(pk *PublicKey, msg, ctx, sig []byte) bool {
if pk == nil {
return false
}
return circl.Verify(pk, msg, ctx, sig)
}
+174
View File
@@ -0,0 +1,174 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsa87
import (
"bytes"
"crypto/rand"
"testing"
)
func TestNewKeyFromSeed_Deterministic(t *testing.T) {
seed := make([]byte, SeedSize)
if _, err := rand.Read(seed); err != nil {
t.Fatalf("rand: %v", err)
}
pk1, sk1, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed #1: %v", err)
}
pk2, sk2, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed #2: %v", err)
}
if !bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("deterministic keygen: public keys differ for identical seed")
}
if !bytes.Equal(sk1.Bytes(), sk2.Bytes()) {
t.Fatal("deterministic keygen: private keys differ for identical seed")
}
}
func TestNewKeyFromSeed_DifferentSeeds(t *testing.T) {
seed1 := make([]byte, SeedSize)
if _, err := rand.Read(seed1); err != nil {
t.Fatalf("rand: %v", err)
}
seed2 := append([]byte{}, seed1...)
seed2[0] ^= 0x01
pk1, _, err := NewKeyFromSeed(seed1)
if err != nil {
t.Fatalf("NewKeyFromSeed seed1: %v", err)
}
pk2, _, err := NewKeyFromSeed(seed2)
if err != nil {
t.Fatalf("NewKeyFromSeed seed2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("different seeds produced identical public keys")
}
}
func TestNewKeyFromSeed_SignVerifyRoundtrip(t *testing.T) {
seed := bytes.Repeat([]byte{0xA5}, SeedSize)
pk, sk, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("ML-DSA-87 deterministic keygen round-trip")
ctx := []byte("LUX/test/mldsa87")
sig, err := Sign(sk, msg, ctx, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if len(sig) != SignatureSize {
t.Fatalf("Sign: got %d bytes, want %d", len(sig), SignatureSize)
}
if !Verify(pk, msg, ctx, sig) {
t.Fatal("Verify rejected a signature produced by the derived keypair")
}
tampered := append([]byte{}, msg...)
tampered[0] ^= 0xFF
if Verify(pk, tampered, ctx, sig) {
t.Fatal("Verify accepted a tampered message")
}
if Verify(pk, msg, []byte("LUX/test/mldsa87/other"), sig) {
t.Fatal("Verify accepted under a different context")
}
}
func TestNewKeyFromSeed_ShortSeedRejected(t *testing.T) {
for _, n := range []int{0, 1, 16, 31} {
short := make([]byte, n)
if _, _, err := NewKeyFromSeed(short); err == nil {
t.Fatalf("NewKeyFromSeed accepted %d-byte seed (want error)", n)
}
}
}
func TestNewKeyFromSeed_ExactSeedSize(t *testing.T) {
seed := make([]byte, SeedSize)
pk, sk, err := NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
if pk == nil || sk == nil {
t.Fatal("NewKeyFromSeed returned nil keypair without error")
}
if len(pk.Bytes()) != PublicKeySize {
t.Fatalf("public key size: got %d, want %d", len(pk.Bytes()), PublicKeySize)
}
if len(sk.Bytes()) != PrivateKeySize {
t.Fatalf("private key size: got %d, want %d", len(sk.Bytes()), PrivateKeySize)
}
}
func TestNewKeyFromSeed_OversizedSeedExpanded(t *testing.T) {
long1 := bytes.Repeat([]byte{0xCC}, 96)
long2 := append([]byte{}, long1...)
long2[64] ^= 0x01
pk1, sk1, err := NewKeyFromSeed(long1)
if err != nil {
t.Fatalf("NewKeyFromSeed long1: %v", err)
}
pk2, _, err := NewKeyFromSeed(long2)
if err != nil {
t.Fatalf("NewKeyFromSeed long2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("oversized seeds differing in tail produced identical public keys")
}
msg := []byte("oversized seed roundtrip")
sig, err := Sign(sk1, msg, nil, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !Verify(pk1, msg, nil, sig) {
t.Fatal("Verify rejected oversized-seed-derived keypair signature")
}
}
func TestNewKeyFromSeed_NoPrefixTruncation(t *testing.T) {
prefix := bytes.Repeat([]byte{0x11}, SeedSize)
long1 := append(append([]byte{}, prefix...), bytes.Repeat([]byte{0x22}, 32)...)
long2 := append(append([]byte{}, prefix...), bytes.Repeat([]byte{0x33}, 32)...)
pk1, _, err := NewKeyFromSeed(long1)
if err != nil {
t.Fatalf("NewKeyFromSeed long1: %v", err)
}
pk2, _, err := NewKeyFromSeed(long2)
if err != nil {
t.Fatalf("NewKeyFromSeed long2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("seeds sharing only the first 32 bytes produced identical keys " +
"(implementation is doing prefix truncation, not cSHAKE expansion)")
}
}
func TestGenerateKey_NotDeterministic(t *testing.T) {
pk1, _, err := GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey #1: %v", err)
}
pk2, _, err := GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey #2: %v", err)
}
if bytes.Equal(pk1.Bytes(), pk2.Bytes()) {
t.Fatal("GenerateKey produced identical keypairs on two calls " +
"(rand source not being consumed)")
}
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mldsa_test
import (
"bytes"
"testing"
"github.com/luxfi/crypto/pq/mldsa/mldsa44"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/luxfi/crypto/pq/mldsa/mldsa87"
)
// TestCrossParameterSet_OversizedSeedDomainSeparation asserts that the
// per-parameter-set cSHAKE customization string actually domain-separates
// mldsa44 / mldsa65 / mldsa87 when the same oversized parent seed is
// re-used across all three.
//
// The expansion rule (see internal/seed) is:
//
// ξ_44 = cSHAKE-256(seed, S="LUX/FIPS204/MLDSA44/seed")
// ξ_65 = cSHAKE-256(seed, S="LUX/FIPS204/MLDSA65/seed")
// ξ_87 = cSHAKE-256(seed, S="LUX/FIPS204/MLDSA87/seed")
//
// The three S strings differ, so ξ_44 ≠ ξ_65 ≠ ξ_87 (NIST SP 800-185 §3.3).
// We can't compare ξ directly (it's internal), so we compare a derived
// signature instead: with deterministic signing and a fixed (msg, ctx),
// the signature is a function of (sk, msg, ctx). If sk differs, the
// signature differs.
//
// Why this matters: a single 64-byte BIP-32 child seed is the typical
// HD-wallet input. If the customization strings collided, the validator
// identity (mldsa65) and any future mldsa87-based protocol would derive
// the same private key from the same parent seed — a key-reuse violation
// of FIPS 204's per-scheme key separation.
func TestCrossParameterSet_OversizedSeedDomainSeparation(t *testing.T) {
parentSeed := bytes.Repeat([]byte{0x5C}, 64) // > SeedSize → triggers cSHAKE
msg := []byte("LUX cross-parameter-set domain separation")
ctx := []byte("LUX/test/mldsa-cross")
pk44, sk44, err := mldsa44.NewKeyFromSeed(parentSeed)
if err != nil {
t.Fatalf("mldsa44 NewKeyFromSeed: %v", err)
}
pk65, sk65, err := mldsa65.NewKeyFromSeed(parentSeed)
if err != nil {
t.Fatalf("mldsa65 NewKeyFromSeed: %v", err)
}
pk87, sk87, err := mldsa87.NewKeyFromSeed(parentSeed)
if err != nil {
t.Fatalf("mldsa87 NewKeyFromSeed: %v", err)
}
// Different parameter sets → different public-key sizes, so the
// byte comparison is unambiguous. Public keys MUST differ in length
// (parameter-set sanity) AND in content (domain separation).
if mldsa44.PublicKeySize == mldsa65.PublicKeySize ||
mldsa65.PublicKeySize == mldsa87.PublicKeySize {
t.Fatal("parameter-set public-key sizes collide (build error)")
}
// Sign with each derived sk and confirm the signature verifies
// under its OWN pk but NOT under the others (cross-set keys must
// not interoperate).
sig44, err := mldsa44.Sign(sk44, msg, ctx, false)
if err != nil {
t.Fatalf("mldsa44 Sign: %v", err)
}
sig65, err := mldsa65.Sign(sk65, msg, ctx, false)
if err != nil {
t.Fatalf("mldsa65 Sign: %v", err)
}
sig87, err := mldsa87.Sign(sk87, msg, ctx, false)
if err != nil {
t.Fatalf("mldsa87 Sign: %v", err)
}
if !mldsa44.Verify(pk44, msg, ctx, sig44) {
t.Fatal("mldsa44: derived keypair fails self-verify")
}
if !mldsa65.Verify(pk65, msg, ctx, sig65) {
t.Fatal("mldsa65: derived keypair fails self-verify")
}
if !mldsa87.Verify(pk87, msg, ctx, sig87) {
t.Fatal("mldsa87: derived keypair fails self-verify")
}
// Length sanity: three signatures of three distinct lengths.
if len(sig44) == len(sig65) || len(sig65) == len(sig87) {
t.Fatal("parameter-set signature sizes collide (build error)")
}
}