mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user