Files
zeekay d753a24a30 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.
2026-05-10 19:56:00 -07:00

128 lines
4.5 KiB
Go

// 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)
}