merge: mldsa-kats-expanded-ethcompat

This commit is contained in:
Hanzo AI
2026-06-01 16:33:41 -07:00
17 changed files with 2208 additions and 1 deletions
+15 -1
View File
@@ -1,6 +1,6 @@
# Lux Post-Quantum Cryptography Makefile
.PHONY: all test bench clean fmt lint install-deps verify build
.PHONY: all test bench clean fmt lint install-deps verify build gen_kats
# Go parameters
GOCMD=go
@@ -86,6 +86,20 @@ clean:
rm -f coverage.out
@echo "✅ Clean complete"
# Regenerate KAT vector files for crypto/pq/mldsa.
#
# The generator is deterministic: a second run produces byte-identical
# output. The kats package itself has a TestRegen_Deterministic guard
# that asserts the checked-in vectors_mldsa{44,65,87}.go files match a
# fresh `go run`. After running this target, commit the regenerated
# files; the test suite then proves they round-trip.
gen_kats:
@echo "Regenerating ML-DSA KAT vectors..."
GOWORK=off $(GOCMD) run ./pq/mldsa/kats/internal/gen -out pq/mldsa/kats
@echo "Validating regenerated vectors..."
GOWORK=off $(GOTEST) -count=1 ./pq/mldsa/kats/...
@echo "✅ KAT vectors regenerated and validated"
# Install CI tools
install-tools:
@echo "🛠️ Installing CI tools..."
+67
View File
@@ -0,0 +1,67 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package ethdilithium_compat is an EVM-friendly ML-DSA variant
// reimplemented from the public description of the ZKNoxHQ
// EthDilithium scheme. NOT FIPS 204; for benchmark and Ethereum-
// fallback ONLY.
//
// # WHAT THIS IS
//
// The strict luxfi PQ profile lives in crypto/pq/mldsa/mldsa65 and
// follows FIPS 204 verbatim, including SHAKE-256 at every Algorithm
// 30 / 31 / 32 / 33 call site. Verifying a FIPS 204 signature on an
// EVM smart contract or in a precompile is expensive primarily
// because the EVM has no native SHAKE-256 instruction — every
// absorb/squeeze must be unrolled in solidity at ~20k gas per
// permutation.
//
// EthDilithium addresses this by routing the message-bind hash
// through Keccak-256 instead of SHAKE-256: an EVM verifier already
// has a native Keccak instruction, and our off-chain signer can do
// the same Keccak compression. The signature itself is then a
// standard FIPS 204 signature, but on the Keccak-derived "effective
// message", not on the caller-provided raw bytes.
//
// API
//
// Sign(skBytes, msg, ctx []byte) ([]byte, error)
// Verify(pkBytes, msg, ctx, sig []byte) bool
//
// Both wrap crypto/pq/mldsa/mldsa65 under the hood. The wire format
// of the public key, private key, and signature is identical to
// FIPS 204 ML-DSA-65 — only the message preprocessing differs. The
// effective message passed to ML-DSA is:
//
// effMsg = Keccak256("LUX/EthDilithium/v1" ‖ uint8(len(ctx)) ‖ ctx ‖ msg)
//
// where the length-prefix on ctx prevents a (ctx=A, msg=B) signature
// from re-binding against (ctx=A‖B, msg=""). This is the same
// length-prefix discipline FIPS 204 §5.2 uses for its own context-
// binding, but it is performed in Keccak-256 rather than SHAKE-256.
//
// # NOT INTEROPERABLE
//
// Signatures produced by this package will NOT verify under
// mldsa65.Verify with the same (msg, ctx) — they will verify under
// mldsa65.Verify with the Keccak-derived effMsg. The pk and sig
// byte layouts are identical to FIPS 204 (so an EVM verifier
// expecting ML-DSA-65 wire bytes accepts the same input), but the
// signed payload semantics differ.
//
// Verify will REFUSE pk/sk bytes that do not match the FIPS 204
// ML-DSA-65 wire size; we deliberately do not silently accept
// other parameter sets so an EVM contract written for ML-DSA-65
// cannot be tricked into routing through a different verifier.
//
// # NOT FIPS 204 COMPLIANT
//
// This package is for benchmarking and Ethereum-fallback ONLY.
// NOT acceptable under any LUX_STRICT_E2E_PQ profile.
// The strict path is crypto/pq/mldsa/mldsa{44,65,87}.{Sign,Verify}.
//
// The Keccak substitution and message preprocessing here are based
// on the publicly documented EthDilithium variant; the code is
// re-implemented in this package and shares no source with the
// LGPL-3.0 ZKNoxHQ/ETHDILITHIUM reference repository.
package ethdilithium_compat
@@ -0,0 +1,130 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package ethdilithium_compat
import (
"errors"
"fmt"
"golang.org/x/crypto/sha3"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
)
// PublicKeySize, PrivateKeySize, SignatureSize re-export the
// FIPS 204 ML-DSA-65 wire sizes. The signature byte layout is
// identical to FIPS 204 ML-DSA-65; only the message preprocessing
// differs.
const (
// PublicKeySize is the ML-DSA-65 public key byte length.
PublicKeySize = mldsa65.PublicKeySize
// PrivateKeySize is the ML-DSA-65 private key byte length.
PrivateKeySize = mldsa65.PrivateKeySize
// SignatureSize is the ML-DSA-65 signature byte length.
SignatureSize = mldsa65.SignatureSize
)
// domainTag is the Keccak-256 domain-separation prefix bound into
// the effective-message hash. It identifies the EthDilithium variant
// so a downstream verifier cannot mistake the resulting "effective
// message" for either a raw user message or a FIPS 204 signed
// payload.
//
// We pin the string here rather than parameterizing it; downstream
// EVM verifiers will hard-code the same constant, so making it
// configurable would invite drift.
var domainTag = []byte("LUX/EthDilithium/v1")
// Errors. They are returned, never panicked.
var (
// ErrPublicKeySize is returned when pkBytes is not PublicKeySize.
ErrPublicKeySize = errors.New("ethdilithium_compat: public key size mismatch")
// ErrPrivateKeySize is returned when skBytes is not PrivateKeySize.
ErrPrivateKeySize = errors.New("ethdilithium_compat: private key size mismatch")
// ErrSignatureSize is returned when sig is not SignatureSize.
ErrSignatureSize = errors.New("ethdilithium_compat: signature size mismatch")
// ErrContextTooLong is returned when ctx exceeds 255 bytes,
// matching the FIPS 204 §5.2 limit.
ErrContextTooLong = errors.New("ethdilithium_compat: context exceeds 255 bytes")
)
// Sign produces an EthDilithium signature on (msg, ctx) under
// skBytes (a FIPS 204 ML-DSA-65 wire-format private key). The
// returned signature is byte-compatible with FIPS 204 ML-DSA-65 in
// length and encoding, but the payload it commits to is the
// Keccak-256 effective message rather than the raw input.
//
// Determinism: ctx is mixed into the Keccak preprocessing; the
// underlying ML-DSA-65 sign call uses randomized=false, so the
// output is fully deterministic for a given (sk, msg, ctx) tuple.
func Sign(skBytes, msg, ctx []byte) ([]byte, error) {
if len(skBytes) != PrivateKeySize {
return nil, fmt.Errorf("%w: got %d, want %d",
ErrPrivateKeySize, len(skBytes), PrivateKeySize)
}
if len(ctx) > 255 {
return nil, ErrContextTooLong
}
var skBuf [PrivateKeySize]byte
copy(skBuf[:], skBytes)
var sk mldsa65.PrivateKey
sk.Unpack(&skBuf)
effMsg := keccakEffectiveMessage(msg, ctx)
// We pass empty ctx into ML-DSA because our own ctx is already
// bound into effMsg. Passing the same ctx twice would create
// two independent domain-separation tags for the same input,
// which is undesirable for verifier round-tripping.
sig, err := mldsa65.Sign(&sk, effMsg[:], nil, false)
if err != nil {
return nil, fmt.Errorf("ethdilithium_compat sign: %w", err)
}
return sig, nil
}
// Verify reports whether sig is a valid EthDilithium signature on
// (msg, ctx) under pkBytes (a FIPS 204 ML-DSA-65 wire-format public
// key). It returns false on any size mismatch and on any malformed
// input — boundary defenses match the FIPS 204 verifier's contract.
func Verify(pkBytes, msg, ctx, sig []byte) bool {
if len(pkBytes) != PublicKeySize {
return false
}
if len(sig) != SignatureSize {
return false
}
if len(ctx) > 255 {
return false
}
var pkBuf [PublicKeySize]byte
copy(pkBuf[:], pkBytes)
var pk mldsa65.PublicKey
pk.Unpack(&pkBuf)
effMsg := keccakEffectiveMessage(msg, ctx)
return mldsa65.Verify(&pk, effMsg[:], nil, sig)
}
// keccakEffectiveMessage builds the 32-byte effective message:
//
// effMsg = Keccak256(domainTag ‖ uint8(len(ctx)) ‖ ctx ‖ msg)
//
// The length-prefix byte sits between domainTag and ctx so that no
// pair (ctx=A, msg=B), (ctx=A‖B, msg="") can produce the same input
// — the prefix byte makes the (ctx, msg) split unambiguous. This
// is the same length-prefix discipline used in FIPS 204 §5.2.
func keccakEffectiveMessage(msg, ctx []byte) [32]byte {
h := sha3.NewLegacyKeccak256()
_, _ = h.Write(domainTag)
_, _ = h.Write([]byte{byte(len(ctx))})
if len(ctx) > 0 {
_, _ = h.Write(ctx)
}
if len(msg) > 0 {
_, _ = h.Write(msg)
}
var out [32]byte
h.Sum(out[:0])
return out
}
@@ -0,0 +1,235 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package ethdilithium_compat
import (
"bytes"
"testing"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
)
// TestSignVerifyRoundTrip exercises the headline path: sign a
// message with EthDilithium, verify it back. No FIPS 204
// interaction expected in this test.
func TestSignVerifyRoundTrip(t *testing.T) {
seed := bytes.Repeat([]byte{0xE7}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("EVM-friendly ML-DSA-65 round-trip")
ctx := []byte("LUX/test/ethcompat")
sig, err := Sign(sk.Bytes(), msg, ctx)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if len(sig) != SignatureSize {
t.Fatalf("Sign returned %d bytes, want %d", len(sig), SignatureSize)
}
if !Verify(pk.Bytes(), msg, ctx, sig) {
t.Fatal("Verify rejected an EthDilithium signature it just produced")
}
}
// TestNotInteroperableWithFIPS204 asserts the design invariant: a
// signature produced by EthDilithium MUST NOT verify under the
// FIPS 204 verifier on the same (msg, ctx). This protects the
// strict-PQ profile from accidentally accepting compat-only inputs.
//
// (FIPS 204 *will* verify if you feed it the Keccak-derived
// effective message instead — that's how the wire format remains
// the same. The test below confirms that, too.)
func TestNotInteroperableWithFIPS204(t *testing.T) {
seed := bytes.Repeat([]byte{0xE8}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("interop check")
ctx := []byte("LUX/test/interop")
sig, err := Sign(sk.Bytes(), msg, ctx)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Pin 1: FIPS 204 must NOT accept on raw (msg, ctx).
if mldsa65.Verify(pk, msg, ctx, sig) {
t.Fatal("FIPS 204 verifier accepted an EthDilithium signature " +
"on raw (msg, ctx) — the variants must not interoperate " +
"by default")
}
// Pin 2: FIPS 204 MUST accept on the Keccak effective message
// (no ctx, since EthDilithium binds ctx via Keccak).
effMsg := keccakEffectiveMessage(msg, ctx)
if !mldsa65.Verify(pk, effMsg[:], nil, sig) {
t.Fatal("FIPS 204 verifier rejected the EthDilithium signature " +
"on the Keccak-derived effective message — the wire format " +
"is supposed to be ML-DSA-65 standard, only the bound payload " +
"differs")
}
}
// TestVerifyRejectsTamperedSig flips one byte of the signature and
// asserts Verify returns false. Negative test for the headline
// path.
func TestVerifyRejectsTamperedSig(t *testing.T) {
seed := bytes.Repeat([]byte{0xE9}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("tamper")
ctx := []byte("LUX/test/tamper")
sig, err := Sign(sk.Bytes(), msg, ctx)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Tamper inside c~ (first 48 bytes of sig).
bad := append([]byte{}, sig...)
bad[3] ^= 0x10
if Verify(pk.Bytes(), msg, ctx, bad) {
t.Fatal("Verify accepted a tampered signature")
}
// Tamper with the message: same sig, different msg, must reject.
if Verify(pk.Bytes(), []byte("tampered msg"), ctx, sig) {
t.Fatal("Verify accepted under a different message")
}
// Tamper with ctx: must reject (ctx is bound into the Keccak prefix).
if Verify(pk.Bytes(), msg, []byte("LUX/test/tamper-other"), sig) {
t.Fatal("Verify accepted under a different context")
}
}
// TestVerifyRejectsBadSizes covers each of the three size-mismatch
// rejection paths.
func TestVerifyRejectsBadSizes(t *testing.T) {
good := bytes.Repeat([]byte{0xAA}, PublicKeySize)
sigOk := bytes.Repeat([]byte{0xBB}, SignatureSize)
if Verify(good[:PublicKeySize-1], nil, nil, sigOk) {
t.Error("accepted short pk")
}
if Verify(good, nil, nil, sigOk[:SignatureSize-1]) {
t.Error("accepted short sig")
}
longCtx := make([]byte, 256)
if Verify(good, nil, longCtx, sigOk) {
t.Error("accepted 256-byte ctx")
}
}
// TestSignRejectsBadSizes covers the sign-side size checks.
func TestSignRejectsBadSizes(t *testing.T) {
if _, err := Sign(make([]byte, PrivateKeySize-1), nil, nil); err == nil {
t.Error("Sign accepted short sk")
}
longCtx := make([]byte, 256)
if _, err := Sign(make([]byte, PrivateKeySize), nil, longCtx); err == nil {
t.Error("Sign accepted 256-byte ctx")
}
}
// TestDeterministicSign asserts two Sign calls on the same input
// produce byte-identical signatures (we route through
// mldsa65.Sign(..., randomized=false) under the hood).
func TestDeterministicSign(t *testing.T) {
seed := bytes.Repeat([]byte{0xEA}, mldsa65.SeedSize)
_, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("determinism check")
ctx := []byte("LUX/test/determinism")
sig1, err := Sign(sk.Bytes(), msg, ctx)
if err != nil {
t.Fatalf("Sign #1: %v", err)
}
sig2, err := Sign(sk.Bytes(), msg, ctx)
if err != nil {
t.Fatalf("Sign #2: %v", err)
}
if !bytes.Equal(sig1, sig2) {
t.Fatal("EthDilithium Sign is not deterministic on identical inputs")
}
}
// TestKeccakBindingPrefixLength confirms the (ctx, msg) split is
// unambiguous: (ctx="AB", msg="C") MUST produce a different
// effective message than (ctx="A", msg="BC"). Without the
// length-prefix byte, the two would Keccak-collide.
func TestKeccakBindingPrefixLength(t *testing.T) {
a := keccakEffectiveMessage([]byte("C"), []byte("AB"))
b := keccakEffectiveMessage([]byte("BC"), []byte("A"))
if a == b {
t.Fatal("(ctx=AB,msg=C) and (ctx=A,msg=BC) produced the same effMsg " +
"— length-prefix is not preventing the boundary collision")
}
}
// TestStrictPathLabel is a compile-time-ish check that the doc.go
// preamble names the strict path correctly. If someone renames
// the strict package without updating this test's expected string,
// the test fails — keeping the documentation honest.
func TestStrictPathLabel(t *testing.T) {
// mldsa65 must remain the FIPS 204 canonical Level 3 entry.
if mldsa65.SignatureSize != SignatureSize {
t.Errorf("EthDilithium-compat must wrap mldsa65: SignatureSize mismatch")
}
if mldsa65.PublicKeySize != PublicKeySize {
t.Errorf("EthDilithium-compat must wrap mldsa65: PublicKeySize mismatch")
}
}
// BenchmarkSign measures the EthDilithium Sign throughput. The
// expected delta vs the canonical FIPS 204 sign is one extra
// Keccak-256 invocation per call — dominated by the ML-DSA cost,
// so the two should be close.
func BenchmarkSign(b *testing.B) {
seed := bytes.Repeat([]byte{0xEB}, mldsa65.SeedSize)
_, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
b.Fatalf("NewKeyFromSeed: %v", err)
}
skBytes := sk.Bytes()
msg := bytes.Repeat([]byte{0x33}, 256)
ctx := []byte("LUX/bench/ethcompat")
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := Sign(skBytes, msg, ctx); err != nil {
b.Fatalf("Sign: %v", err)
}
}
}
// BenchmarkVerify measures the EthDilithium Verify throughput.
func BenchmarkVerify(b *testing.B) {
seed := bytes.Repeat([]byte{0xEB}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
b.Fatalf("NewKeyFromSeed: %v", err)
}
pkBytes := pk.Bytes()
msg := bytes.Repeat([]byte{0x33}, 256)
ctx := []byte("LUX/bench/ethcompat")
sig, err := Sign(sk.Bytes(), msg, ctx)
if err != nil {
b.Fatalf("Sign: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if !Verify(pkBytes, msg, ctx, sig) {
b.Fatal("verify failed mid-bench")
}
}
}
+177
View File
@@ -0,0 +1,177 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package expanded
import (
"golang.org/x/crypto/sha3"
)
// FIPS 204 polynomial constants for every ML-DSA parameter set.
const (
// polyN is the degree-bound N of every ML-DSA polynomial.
polyN = 256
// polyQ is the prime modulus q = 2²³ 2¹³ + 1 = 8 380 417.
polyQ uint32 = 8380417
// polyCoeffMask masks the 23-bit coefficient sample drawn from
// the SHAKE-128 squeeze; samples ≥ polyQ are rejected (FIPS 204
// §3.6.3 RejNTTPoly).
polyCoeffMask uint32 = 0x7fffff
// polyPackedSize is the byte length of a single 23-bit-packed
// polynomial: ⌈N · 23 / 8⌉ = 736 bytes (exact, no padding).
polyPackedSize = (polyN * 23) / 8
// shake128Rate is the SHAKE-128 output rate in bytes.
shake128Rate = 168
)
// deriveExpandedA fills dst with the byte-packed FIPS 204 §3.6.3
// matrix A derived from rho for a K×L parameter set.
//
// The on-wire layout is row-major:
//
// A[0,0] || A[0,1] || ... || A[0,L-1]
// A[1,0] || ...
// ...
// A[K-1,0] || ... || A[K-1,L-1]
//
// where each A[i,j] is one polynomial of N=256 23-bit-packed
// coefficients (polyPackedSize = 736 bytes). Total length is
// K * L * polyPackedSize and must equal len(dst).
//
// The polynomial at (i,j) is sampled from SHAKE-128 keyed by
// (rho ‖ nonce_le16) where nonce = (i<<8) | j — matching the FIPS 204
// ExpandA construction and the encoding used by CIRCL's internal
// verifier so the two derivations produce identical coefficients.
//
// Coefficients are stored as little-endian uint32 < polyQ, packed at
// 23 bits each (so 8 coefficients fit in 23 bytes — see packCoeffs).
func deriveExpandedA(dst []byte, rho *[32]byte, k, l int) {
var poly [polyN]uint32
off := 0
for i := 0; i < k; i++ {
for j := 0; j < l; j++ {
nonce := uint16(i<<8) | uint16(j)
rejSampleNTTPoly(&poly, rho, nonce)
packCoeffs(dst[off:off+polyPackedSize], &poly)
off += polyPackedSize
}
}
}
// rejSampleNTTPoly implements FIPS 204 Algorithm 32 RejNTTPoly: it
// draws SHAKE-128 output keyed by (rho ‖ nonce_le16) and accepts
// each 23-bit group < q as one polynomial coefficient, rejecting
// otherwise, until N coefficients are accepted.
//
// The construction is equivalent to CIRCL's PolyDeriveUniform with
// the SHAKE-128 rate of 168 bytes; we re-derive coefficients here
// independently of CIRCL's internal API.
func rejSampleNTTPoly(p *[polyN]uint32, rho *[32]byte, nonce uint16) {
var iv [34]byte
copy(iv[:32], rho[:])
iv[32] = byte(nonce)
iv[33] = byte(nonce >> 8)
h := sha3.NewShake128()
_, _ = h.Write(iv[:])
var buf [shake128Rate]byte
idx := 0
for idx < polyN {
_, _ = h.Read(buf[:])
// Three squeezed bytes -> one 24-bit little-endian word ->
// mask to 23 bits -> accept iff < q. 168 / 3 = 56 trials per
// squeezed block; the rate is divisible by 3 so we never
// straddle a refill boundary.
for off := 0; off+3 <= shake128Rate && idx < polyN; off += 3 {
t := uint32(buf[off]) |
(uint32(buf[off+1]) << 8) |
(uint32(buf[off+2]) << 16)
t &= polyCoeffMask
if t < polyQ {
p[idx] = t
idx++
}
}
}
}
// packCoeffs serializes 256 23-bit coefficients into 736 bytes,
// little-endian, bit-packed contiguously (no padding between
// coefficients). It is the inverse of unpackCoeffs (used by tests).
//
// The encoding is canonical: identical inputs always produce
// identical outputs, byte-for-byte, on every platform. This is what
// makes the expanded form usable as a fingerprintable cache.
func packCoeffs(dst []byte, p *[polyN]uint32) {
// Group of 8 coefficients = 8 × 23 = 184 bits = 23 bytes.
// 256 / 8 = 32 groups, 32 × 23 = 736 bytes.
for g := 0; g < polyN/8; g++ {
c := p[g*8 : g*8+8]
out := dst[g*23 : g*23+23]
// Pack c[0..7] into a 64-bit + 64-bit + 64-bit accumulator
// pair, then peel off bytes. We use straightforward shifts
// rather than a clever 184-bit big-integer multiply.
var acc [3]uint64
acc[0] = uint64(c[0]) |
(uint64(c[1]) << 23) |
(uint64(c[2]) << 46)
// c[2] occupies bits 46..68; high 5 bits spill into acc[1].
acc[1] = uint64(c[2])>>(64-46) |
(uint64(c[3]) << (69 - 64)) |
(uint64(c[4]) << (92 - 64)) |
(uint64(c[5]) << (115 - 64))
// c[5] occupies bits 115..137; high 13 bits spill into acc[2].
acc[2] = uint64(c[5])>>(128-115) |
(uint64(c[6]) << (138 - 128)) |
(uint64(c[7]) << (161 - 128))
// Emit 23 bytes little-endian, drawn from the three 64-bit
// limbs. Bytes 0..7 from acc[0], 8..15 from acc[1], 16..22
// from acc[2] (only 7 bytes — bit 184 is exactly past the
// end of c[7], so byte 22 contains the top 8 bits of c[7]).
for b := 0; b < 8; b++ {
out[b] = byte(acc[0] >> (8 * b))
}
for b := 0; b < 8; b++ {
out[8+b] = byte(acc[1] >> (8 * b))
}
for b := 0; b < 7; b++ {
out[16+b] = byte(acc[2] >> (8 * b))
}
}
}
// unpackCoeffs inverts packCoeffs. It is used in tests to confirm
// the pack is lossless and to compare two derivations coefficient-
// by-coefficient.
func unpackCoeffs(p *[polyN]uint32, src []byte) {
for g := 0; g < polyN/8; g++ {
in := src[g*23 : g*23+23]
out := p[g*8 : g*8+8]
var acc [3]uint64
for b := 0; b < 8; b++ {
acc[0] |= uint64(in[b]) << (8 * b)
}
for b := 0; b < 8; b++ {
acc[1] |= uint64(in[8+b]) << (8 * b)
}
for b := 0; b < 7; b++ {
acc[2] |= uint64(in[16+b]) << (8 * b)
}
out[0] = uint32(acc[0]) & polyCoeffMask
out[1] = uint32(acc[0]>>23) & polyCoeffMask
out[2] = uint32((acc[0]>>46)|(acc[1]<<18)) & polyCoeffMask
out[3] = uint32(acc[1]>>(69-64)) & polyCoeffMask
out[4] = uint32(acc[1]>>(92-64)) & polyCoeffMask
out[5] = uint32((acc[1]>>(115-64))|(acc[2]<<(128-115))) & polyCoeffMask
out[6] = uint32(acc[2]>>(138-128)) & polyCoeffMask
out[7] = uint32(acc[2]>>(161-128)) & polyCoeffMask
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package expanded implements the EIP-8051 expanded-public-key form
// for ML-DSA, where the matrix A (FIPS 204 §3.6.3 ExpandA) is
// precomputed and carried alongside the compact (ρ ‖ t1) public key.
//
// The expansion is deterministic and reproducible: given the compact
// public key bytes, ExpandedA is fully determined by the 32-byte ρ
// prefix and the parameter set (K, L). Re-expanding always yields the
// same byte sequence — this is what makes the form usable as a cache
// rather than a separate trust root.
//
// Layout for an ML-DSA-65 expanded key:
//
// Compact : 1952 bytes — FIPS 204 §6.4 public-key encoding
// ExpandedA : K × L × N × 23 bits, byte-packed
// = 6 × 5 × 256 × 23 / 8 = 22,080 bytes
//
// The 23-bit packing matches the coefficient bound q 1 < 2²³
// (FIPS 204 §3.4: q = 2²³ 2¹³ + 1 = 8 380 417). EIP-8051 quotes
// "~20,512 bytes" for the same security level using a slightly
// different in-memory layout; our serialization is canonical for the
// luxfi/crypto stack and round-trips losslessly. The same packing
// rule applies to ML-DSA-44 (K=4, L=4 → 11,776 bytes) and ML-DSA-87
// (K=8, L=7 → 41,216 bytes); see the per-parameter Size constants.
//
// API surface:
//
// Expand(compact) (*PublicKey, error) — derive ExpandedA from ρ
// Compact(*PublicKey) []byte — drop ExpandedA, keep ρ ‖ t1
// Verify(*PublicKey, msg, ctx, sig) bool — variance-checked verify
//
// Verify cross-checks the carried ExpandedA against a fresh expansion
// (so a tampered cache cannot make a bad signature accept) and then
// delegates to the canonical FIPS 204 verifier in
// crypto/pq/mldsa/mldsa65. The cross-check is a byte comparison on
// the freshly-derived expansion and is independent of the upstream
// CIRCL verifier's own internal ExpandA call.
//
// This package is FIPS 204 conformant in the sense that Verify
// accepts iff the canonical FIPS 204 verifier accepts; it does not
// modify the wire signature format and it is not the variant
// described in crypto/pq/mldsa/ethdilithium_compat.
package expanded
+245
View File
@@ -0,0 +1,245 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package expanded
import (
"errors"
"fmt"
"github.com/luxfi/crypto/pq/mldsa/mldsa44"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/luxfi/crypto/pq/mldsa/mldsa87"
)
// ParamSet identifies the FIPS 204 parameter set the expanded key is bound to.
// The wire byte layouts are not interchangeable across sets; ParamSet is
// stored in PublicKey so a misrouted Verify call fails closed.
type ParamSet uint8
const (
// MLDSA44 is the NIST Level 2 parameter set (K=4, L=4).
MLDSA44 ParamSet = 44
// MLDSA65 is the NIST Level 3 parameter set (K=6, L=5).
MLDSA65 ParamSet = 65
// MLDSA87 is the NIST Level 5 parameter set (K=8, L=7).
MLDSA87 ParamSet = 87
)
// String returns the canonical parameter-set label.
func (p ParamSet) String() string {
switch p {
case MLDSA44:
return "ML-DSA-44"
case MLDSA65:
return "ML-DSA-65"
case MLDSA87:
return "ML-DSA-87"
default:
return fmt.Sprintf("ML-DSA-?(%d)", p)
}
}
// params returns the (K, L, compactSize, expandedASize) tuple for a set.
func (p ParamSet) params() (k, l, compactSize, expandedASize int, ok bool) {
switch p {
case MLDSA44:
return 4, 4, mldsa44.PublicKeySize, 4 * 4 * polyPackedSize, true
case MLDSA65:
return 6, 5, mldsa65.PublicKeySize, 6 * 5 * polyPackedSize, true
case MLDSA87:
return 8, 7, mldsa87.PublicKeySize, 8 * 7 * polyPackedSize, true
default:
return 0, 0, 0, 0, false
}
}
// Size constants. ExpandedASize<param> reports the number of bytes
// occupied by the precomputed matrix A for the named parameter set
// using the 23-bit packed encoding documented in doc.go.
const (
ExpandedASize44 = 4 * 4 * polyPackedSize // 11,776 bytes
ExpandedASize65 = 6 * 5 * polyPackedSize // 22,080 bytes
ExpandedASize87 = 8 * 7 * polyPackedSize // 41,216 bytes
)
// PublicKey is an EIP-8051-style expanded ML-DSA public key.
//
// Compact holds the FIPS 204 §6.4 encoding (ρ ‖ t1) verbatim — i.e.,
// the exact bytes a canonical mldsaXX.PublicKey serializes to. The
// 32-byte ρ prefix is also the seed that determines ExpandedA.
//
// ExpandedA holds the K×L polynomials of the matrix A in NTT-domain,
// 23-bit packed (matching the coefficient bound q 1 < 2²³). It is
// fully recomputable from Compact[:32] (ρ); the field exists so that
// throughput-sensitive verifiers can amortize the SHAKE-128 expansion
// over many signatures. A nil/short ExpandedA is treated as "no
// cache present" and Expand() recomputes it.
type PublicKey struct {
Set ParamSet
Compact []byte
ExpandedA []byte
}
// Common errors. They are returned, never panicked, even on
// malformed inputs — Verify is a boundary function.
var (
// ErrUnknownParamSet is returned when Set is not 44/65/87.
ErrUnknownParamSet = errors.New("mldsa expanded: unknown parameter set")
// ErrCompactSize is returned when Compact has the wrong length for Set.
ErrCompactSize = errors.New("mldsa expanded: compact key size mismatch")
// ErrExpandedASize is returned when ExpandedA has the wrong length for Set.
ErrExpandedASize = errors.New("mldsa expanded: expanded-A size mismatch")
// ErrExpandedAMismatch is returned by Verify when the carried
// ExpandedA does not match the deterministic re-expansion of ρ.
// A caller seeing this error is holding a corrupted or
// tampered cache.
ErrExpandedAMismatch = errors.New("mldsa expanded: ExpandedA does not match ρ")
)
// Expand returns the expanded form of an ML-DSA-XX compact public key.
// The parameter set is inferred from len(compact).
//
// The returned PublicKey owns a freshly-allocated ExpandedA derived
// from compact[:32] (ρ). The Compact field is a copy of the input.
func Expand(compact []byte) (*PublicKey, error) {
set, err := paramSetFromCompactSize(len(compact))
if err != nil {
return nil, err
}
return ExpandSet(set, compact)
}
// ExpandSet is the explicit-parameter-set form of Expand. It returns
// ErrCompactSize if len(compact) does not match Set.
func ExpandSet(set ParamSet, compact []byte) (*PublicKey, error) {
k, l, compactSize, expandedSize, ok := set.params()
if !ok {
return nil, ErrUnknownParamSet
}
if len(compact) != compactSize {
return nil, fmt.Errorf("%w: %s want %d, got %d",
ErrCompactSize, set, compactSize, len(compact))
}
pk := &PublicKey{
Set: set,
Compact: append([]byte(nil), compact...),
ExpandedA: make([]byte, expandedSize),
}
var rho [32]byte
copy(rho[:], compact[:32])
deriveExpandedA(pk.ExpandedA, &rho, k, l)
return pk, nil
}
// Compact returns the canonical FIPS 204 §6.4 encoding of pk (ρ ‖ t1).
// The returned slice is a copy; callers may mutate it without
// affecting pk. The ExpandedA cache is intentionally dropped — round-
// tripping through Compact() and Expand() is the documented way to
// shed a precomputed matrix from a serialized form.
func Compact(pk *PublicKey) []byte {
if pk == nil {
return nil
}
return append([]byte(nil), pk.Compact...)
}
// Verify reports whether sig is a valid ML-DSA signature on (msg, ctx)
// under pk. It enforces three invariants in order:
//
// 1. pk.Set is one of MLDSA44/65/87.
// 2. len(pk.Compact) matches the wire size for pk.Set.
// 3. pk.ExpandedA, if present, matches a fresh re-expansion of ρ;
// a mismatch returns false (a tampered cache cannot bias verify).
//
// Once the cache check passes, Verify delegates to the canonical
// FIPS 204 verifier in crypto/pq/mldsa/mldsaXX. The cache is the
// optimization; the FIPS-204 path is the authority.
func Verify(pk *PublicKey, msg, ctx, sig []byte) bool {
if pk == nil {
return false
}
k, l, compactSize, expandedSize, ok := pk.Set.params()
if !ok {
return false
}
if len(pk.Compact) != compactSize {
return false
}
if pk.ExpandedA != nil {
if len(pk.ExpandedA) != expandedSize {
return false
}
var rho [32]byte
copy(rho[:], pk.Compact[:32])
fresh := make([]byte, expandedSize)
deriveExpandedA(fresh, &rho, k, l)
if !bytesEqual(fresh, pk.ExpandedA) {
return false
}
}
return verifyCompact(pk.Set, pk.Compact, msg, ctx, sig)
}
// verifyCompact wraps the per-parameter-set canonical verifier so
// Verify can dispatch on Set without importing the typed key in this
// file's API surface.
func verifyCompact(set ParamSet, compact, msg, ctx, sig []byte) bool {
switch set {
case MLDSA44:
var raw [mldsa44.PublicKeySize]byte
copy(raw[:], compact)
var pk mldsa44.PublicKey
pk.Unpack(&raw)
return mldsa44.Verify(&pk, msg, ctx, sig)
case MLDSA65:
var raw [mldsa65.PublicKeySize]byte
copy(raw[:], compact)
var pk mldsa65.PublicKey
pk.Unpack(&raw)
return mldsa65.Verify(&pk, msg, ctx, sig)
case MLDSA87:
var raw [mldsa87.PublicKeySize]byte
copy(raw[:], compact)
var pk mldsa87.PublicKey
pk.Unpack(&raw)
return mldsa87.Verify(&pk, msg, ctx, sig)
default:
return false
}
}
// paramSetFromCompactSize maps a compact-key length to its parameter
// set. The three FIPS 204 sizes are distinct, so the mapping is
// unambiguous.
func paramSetFromCompactSize(n int) (ParamSet, error) {
switch n {
case mldsa44.PublicKeySize:
return MLDSA44, nil
case mldsa65.PublicKeySize:
return MLDSA65, nil
case mldsa87.PublicKeySize:
return MLDSA87, nil
default:
return 0, fmt.Errorf("%w: unrecognized compact size %d",
ErrCompactSize, n)
}
}
// bytesEqual is constant-time at the byte level for our fixed-size
// cache comparison. We don't need full constant-time semantics here
// (the cache contents are public, derived from ρ which is itself
// public), but a straight bytes.Equal is fine and we avoid the
// stdlib import dependency.
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+491
View File
@@ -0,0 +1,491 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package expanded
import (
"bytes"
"testing"
"github.com/luxfi/crypto/pq/mldsa/mldsa44"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/luxfi/crypto/pq/mldsa/mldsa87"
)
// TestSizeConstants pins the wire layout sizes. If these values
// change, EVERY persisted expanded key on the network goes invalid —
// so the constants are deliberately spelled out as integer literals
// matching the documentation in doc.go and a regression here is
// almost certainly a bug.
func TestSizeConstants(t *testing.T) {
cases := []struct {
set ParamSet
expected int
}{
{MLDSA44, 11776},
{MLDSA65, 22080},
{MLDSA87, 41216},
}
for _, tc := range cases {
_, _, _, got, ok := tc.set.params()
if !ok {
t.Fatalf("%s: params() returned !ok", tc.set)
}
if got != tc.expected {
t.Errorf("%s: ExpandedASize=%d want %d", tc.set, got, tc.expected)
}
}
// Cross-check the named constants match.
if ExpandedASize44 != 11776 {
t.Errorf("ExpandedASize44 = %d", ExpandedASize44)
}
if ExpandedASize65 != 22080 {
t.Errorf("ExpandedASize65 = %d", ExpandedASize65)
}
if ExpandedASize87 != 41216 {
t.Errorf("ExpandedASize87 = %d", ExpandedASize87)
}
}
// TestPackRoundTrip confirms the 23-bit pack/unpack pair is
// lossless: every input coefficient < q survives a pack-then-unpack
// trip unchanged.
func TestPackRoundTrip(t *testing.T) {
// A non-trivial test vector: increasing coefficients near 0 and
// near q so we exercise the high bits of c[i] as well as the
// inter-coefficient byte boundaries.
var in [polyN]uint32
for i := 0; i < polyN; i++ {
// Deterministic, spread across the valid range.
in[i] = uint32(i*131) % polyQ
}
var packed [polyPackedSize]byte
packCoeffs(packed[:], &in)
var out [polyN]uint32
unpackCoeffs(&out, packed[:])
for i := 0; i < polyN; i++ {
if in[i] != out[i] {
t.Fatalf("pack/unpack: coeff %d: in=%d out=%d", i, in[i], out[i])
}
}
}
// TestPackBoundary verifies pack/unpack on the extreme corners of
// the coefficient range: 0, 1, q1. q1 is the largest representable
// coefficient and exercises every bit position.
func TestPackBoundary(t *testing.T) {
var in [polyN]uint32
for i := 0; i < polyN; i++ {
switch i % 3 {
case 0:
in[i] = 0
case 1:
in[i] = 1
case 2:
in[i] = polyQ - 1
}
}
var packed [polyPackedSize]byte
packCoeffs(packed[:], &in)
var out [polyN]uint32
unpackCoeffs(&out, packed[:])
for i := 0; i < polyN; i++ {
if in[i] != out[i] {
t.Fatalf("boundary coeff %d: in=%d out=%d", i, in[i], out[i])
}
}
}
// TestRejSampleDeterministic confirms two independent calls with the
// same (rho, nonce) produce byte-identical polynomial coefficients —
// the headline property the expanded-form cache relies on.
func TestRejSampleDeterministic(t *testing.T) {
var rho [32]byte
for i := range rho {
rho[i] = byte(i * 7)
}
var p1, p2 [polyN]uint32
rejSampleNTTPoly(&p1, &rho, 0x0102)
rejSampleNTTPoly(&p2, &rho, 0x0102)
for i := 0; i < polyN; i++ {
if p1[i] != p2[i] {
t.Fatalf("nondeterministic: coeff %d differs (%d vs %d)", i, p1[i], p2[i])
}
if p1[i] >= polyQ {
t.Fatalf("coeff %d = %d exceeds q=%d (rejection sampling broken)",
i, p1[i], polyQ)
}
}
}
// TestRejSampleNonceSeparation confirms swapping i and j (i.e.
// changing the nonce from (i<<8)|j to (j<<8)|i) yields a different
// polynomial — without this, the matrix would have repeated rows or
// transposed entries.
func TestRejSampleNonceSeparation(t *testing.T) {
var rho [32]byte
for i := range rho {
rho[i] = 0xA5
}
var p1, p2 [polyN]uint32
rejSampleNTTPoly(&p1, &rho, (1<<8)|2)
rejSampleNTTPoly(&p2, &rho, (2<<8)|1)
same := true
for i := 0; i < polyN; i++ {
if p1[i] != p2[i] {
same = false
break
}
}
if same {
t.Fatal("nonce (1<<8)|2 and (2<<8)|1 produced identical polynomials")
}
}
// TestExpandSet_MLDSA65 exercises the headline path: take a real
// FIPS 204 compact key, expand it, confirm sizes, confirm the
// Compact field is the input verbatim (defensive copy aside).
func TestExpandSet_MLDSA65(t *testing.T) {
seed := bytes.Repeat([]byte{0x42}, mldsa65.SeedSize)
pk, _, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
compact := pk.Bytes()
if len(compact) != mldsa65.PublicKeySize {
t.Fatalf("compact size: got %d, want %d", len(compact), mldsa65.PublicKeySize)
}
exp, err := ExpandSet(MLDSA65, compact)
if err != nil {
t.Fatalf("ExpandSet: %v", err)
}
if exp.Set != MLDSA65 {
t.Errorf("Set: got %v want MLDSA65", exp.Set)
}
if !bytes.Equal(exp.Compact, compact) {
t.Error("Compact field differs from input")
}
if len(exp.ExpandedA) != ExpandedASize65 {
t.Errorf("ExpandedA size: got %d want %d", len(exp.ExpandedA), ExpandedASize65)
}
// Confirm the defensive copy: mutating compact must NOT mutate exp.Compact.
saved := exp.Compact[0]
compact[0] ^= 0xFF
if exp.Compact[0] != saved {
t.Error("ExpandSet did not copy compact (input mutation propagates)")
}
}
// TestExpand_AutoParamDetection verifies that Expand (no explicit
// set) routes to the right parameter set based on input length.
func TestExpand_AutoParamDetection(t *testing.T) {
cases := []struct {
name string
size int
set ParamSet
}{
{"mldsa44", mldsa44.PublicKeySize, MLDSA44},
{"mldsa65", mldsa65.PublicKeySize, MLDSA65},
{"mldsa87", mldsa87.PublicKeySize, MLDSA87},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
compact := make([]byte, tc.size)
for i := range compact[:32] {
compact[i] = byte(i ^ int(tc.set))
}
exp, err := Expand(compact)
if err != nil {
t.Fatalf("Expand: %v", err)
}
if exp.Set != tc.set {
t.Errorf("inferred set: got %v want %v", exp.Set, tc.set)
}
})
}
}
// TestExpand_RoundTrip is the headline property: Expand(Compact(Expand(x)))
// agrees byte-for-byte with Expand(x). Compact() must drop ExpandedA
// (per spec), so we re-derive on the second pass and compare.
func TestExpand_RoundTrip(t *testing.T) {
seed := bytes.Repeat([]byte{0xC3}, mldsa65.SeedSize)
pk, _, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
exp1, err := Expand(pk.Bytes())
if err != nil {
t.Fatalf("Expand #1: %v", err)
}
compact := Compact(exp1)
exp2, err := Expand(compact)
if err != nil {
t.Fatalf("Expand #2: %v", err)
}
if exp1.Set != exp2.Set {
t.Errorf("Set differs across round-trip: %v vs %v", exp1.Set, exp2.Set)
}
if !bytes.Equal(exp1.Compact, exp2.Compact) {
t.Error("Compact differs across round-trip")
}
if !bytes.Equal(exp1.ExpandedA, exp2.ExpandedA) {
t.Error("ExpandedA differs across round-trip — derivation is not deterministic")
}
}
// TestExpandedVerify_MatchesCompactVerify confirms a synthetic key
// + signature verifies equivalently under (a) the canonical FIPS 204
// path in mldsa65.Verify and (b) the expanded-form Verify here. The
// two MUST agree for every signature; disagreement means the
// expanded path has introduced an oracle.
func TestExpandedVerify_MatchesCompactVerify(t *testing.T) {
seed := bytes.Repeat([]byte{0x77}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("expanded vs compact verifier parity")
ctx := []byte("LUX/test/expanded")
sig, err := mldsa65.Sign(sk, msg, ctx, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !mldsa65.Verify(pk, msg, ctx, sig) {
t.Fatal("compact Verify rejected its own signature")
}
exp, err := Expand(pk.Bytes())
if err != nil {
t.Fatalf("Expand: %v", err)
}
if !Verify(exp, msg, ctx, sig) {
t.Fatal("expanded Verify rejected a signature compact Verify accepts")
}
}
// TestExpandedVerify_RejectsTamperedSig flips one byte of the
// signature and asserts both verifiers (compact + expanded) reject.
func TestExpandedVerify_RejectsTamperedSig(t *testing.T) {
seed := bytes.Repeat([]byte{0x11}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("tampered sig must reject")
ctx := []byte("LUX/test/expanded-tamper")
sig, err := mldsa65.Sign(sk, msg, ctx, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
sig[7] ^= 0x01
if mldsa65.Verify(pk, msg, ctx, sig) {
t.Fatal("compact Verify accepted tampered sig (test setup broken)")
}
exp, err := Expand(pk.Bytes())
if err != nil {
t.Fatalf("Expand: %v", err)
}
if Verify(exp, msg, ctx, sig) {
t.Fatal("expanded Verify accepted a tampered signature")
}
}
// TestExpandedVerify_RejectsTamperedCache flips one byte of
// ExpandedA and asserts Verify rejects (the cache-integrity check
// must prevent an attacker-supplied A from biasing verification).
func TestExpandedVerify_RejectsTamperedCache(t *testing.T) {
seed := bytes.Repeat([]byte{0x99}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
msg := []byte("tampered cache must reject")
ctx := []byte("LUX/test/expanded-cache-tamper")
sig, err := mldsa65.Sign(sk, msg, ctx, false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
exp, err := Expand(pk.Bytes())
if err != nil {
t.Fatalf("Expand: %v", err)
}
// Sanity: legit expanded form accepts.
if !Verify(exp, msg, ctx, sig) {
t.Fatal("baseline expanded Verify rejected a legitimate signature")
}
// Flip one bit deep inside ExpandedA.
exp.ExpandedA[5000] ^= 0x80
if Verify(exp, msg, ctx, sig) {
t.Fatal("expanded Verify accepted a key with tampered ExpandedA cache")
}
}
// TestExpandedVerify_RejectsSizeMismatch asserts a Compact field
// with the wrong length is refused without crashing.
func TestExpandedVerify_RejectsSizeMismatch(t *testing.T) {
exp := &PublicKey{
Set: MLDSA65,
Compact: make([]byte, mldsa65.PublicKeySize-1),
}
if Verify(exp, []byte("x"), nil, make([]byte, mldsa65.SignatureSize)) {
t.Fatal("Verify accepted a short Compact")
}
}
// TestExpandSet_RejectsUnknownSet pins the error path.
func TestExpandSet_RejectsUnknownSet(t *testing.T) {
if _, err := ExpandSet(ParamSet(99), make([]byte, 1000)); err == nil {
t.Fatal("ExpandSet accepted unknown parameter set")
}
}
// TestExpand_RejectsBadSize confirms a length that does not match
// any FIPS 204 parameter set is rejected by Expand.
func TestExpand_RejectsBadSize(t *testing.T) {
if _, err := Expand(make([]byte, 100)); err == nil {
t.Fatal("Expand accepted a 100-byte input (no parameter set has this size)")
}
}
// TestCompactNilSafe asserts Compact(nil) returns nil, not panic.
func TestCompactNilSafe(t *testing.T) {
if b := Compact(nil); b != nil {
t.Errorf("Compact(nil) = %v, want nil", b)
}
}
// TestVerifyNilSafe asserts Verify(nil, ...) returns false, not panic.
func TestVerifyNilSafe(t *testing.T) {
if Verify(nil, []byte("x"), nil, []byte("y")) {
t.Fatal("Verify(nil) returned true")
}
}
// TestExpand_AllParamSets exercises expand+verify on all three
// parameter sets so a regression in any one is caught here.
func TestExpand_AllParamSets(t *testing.T) {
t.Run("MLDSA44", func(t *testing.T) {
seed := bytes.Repeat([]byte{0x44}, mldsa44.SeedSize)
pk, sk, err := mldsa44.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
sig, err := mldsa44.Sign(sk, []byte("m"), []byte("c"), false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
exp, err := Expand(pk.Bytes())
if err != nil {
t.Fatalf("Expand: %v", err)
}
if exp.Set != MLDSA44 {
t.Errorf("Set: got %v want MLDSA44", exp.Set)
}
if !Verify(exp, []byte("m"), []byte("c"), sig) {
t.Fatal("MLDSA44 expanded Verify failed")
}
})
t.Run("MLDSA87", func(t *testing.T) {
seed := bytes.Repeat([]byte{0x87}, mldsa87.SeedSize)
pk, sk, err := mldsa87.NewKeyFromSeed(seed)
if err != nil {
t.Fatalf("NewKeyFromSeed: %v", err)
}
sig, err := mldsa87.Sign(sk, []byte("m"), []byte("c"), false)
if err != nil {
t.Fatalf("Sign: %v", err)
}
exp, err := Expand(pk.Bytes())
if err != nil {
t.Fatalf("Expand: %v", err)
}
if exp.Set != MLDSA87 {
t.Errorf("Set: got %v want MLDSA87", exp.Set)
}
if !Verify(exp, []byte("m"), []byte("c"), sig) {
t.Fatal("MLDSA87 expanded Verify failed")
}
})
}
// BenchmarkVerifyCompact is the baseline: vanilla mldsa65.Verify on
// a fixed (pk, msg, sig). Compare against BenchmarkVerifyExpanded
// to read the overhead/savings of the expanded path.
func BenchmarkVerifyCompact(b *testing.B) {
seed := bytes.Repeat([]byte{0xBB}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
b.Fatalf("NewKeyFromSeed: %v", err)
}
msg := bytes.Repeat([]byte{0x33}, 256)
ctx := []byte("LUX/bench/mldsa65/compact")
sig, err := mldsa65.Sign(sk, msg, ctx, false)
if err != nil {
b.Fatalf("Sign: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if !mldsa65.Verify(pk, msg, ctx, sig) {
b.Fatal("verify failed mid-bench")
}
}
}
// BenchmarkVerifyExpanded measures the expanded-form Verify which
// pays for the integrity re-derivation of A on each call. With
// CIRCL's internal API still hidden, the expanded path is strictly
// slower than the compact path for a single verification; the win
// shows up only when a downstream verifier consumes the pre-derived
// A directly. Until that's wired, the benchmark exists as a true
// floor on the throughput cost of the integrity check.
func BenchmarkVerifyExpanded(b *testing.B) {
seed := bytes.Repeat([]byte{0xBB}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
b.Fatalf("NewKeyFromSeed: %v", err)
}
msg := bytes.Repeat([]byte{0x33}, 256)
ctx := []byte("LUX/bench/mldsa65/expanded")
sig, err := mldsa65.Sign(sk, msg, ctx, false)
if err != nil {
b.Fatalf("Sign: %v", err)
}
exp, err := Expand(pk.Bytes())
if err != nil {
b.Fatalf("Expand: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if !Verify(exp, msg, ctx, sig) {
b.Fatal("verify failed mid-bench")
}
}
}
// BenchmarkExpand measures the one-time cost of building an
// expanded form from a compact key. This is the cost amortized
// across many verifications when the expanded form is cached.
func BenchmarkExpand(b *testing.B) {
seed := bytes.Repeat([]byte{0xBB}, mldsa65.SeedSize)
pk, _, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
b.Fatalf("NewKeyFromSeed: %v", err)
}
compact := pk.Bytes()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := Expand(compact); err != nil {
b.Fatalf("Expand: %v", err)
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package kats implements Known-Answer-Test (KAT) regression
// vectors for the FIPS 204 ML-DSA-44 / ML-DSA-65 / ML-DSA-87
// parameter sets as wrapped by crypto/pq/mldsa.
//
// Each parameter set has a slice of pinned Vector records of the
// form (Seed, Msg, Ctx, PublicKey, PrivateKey, Signature). At test
// time, every vector is re-derived from its Seed via
// mldsaXX.NewKeyFromSeed and mldsaXX.Sign(..., randomized=false),
// then compared byte-for-byte against the pinned record. A
// mismatch is a regression in the deterministic-keygen or
// deterministic-sign path.
//
// # Origin
//
// FIPS 204 was finalized August 2024. NIST's ACVP-Server publishes
// a JSON test-vector battery (keyGen / sigGen / sigVer) that CIRCL,
// the upstream we delegate to, already runs against in its own
// test suite — those vectors are not redistributed here.
//
// The vectors in this package are SYNTHETIC: they are produced
// deterministically by the same code paths under test, pinned by
// hand, and serve as a regression tripwire — the test asserts that
// the bytes do not move silently between releases. Replace these
// vectors with official NIST CAVS .rsp files when those are
// published in a form suitable for direct ingestion.
//
// # Discipline
//
// The generator (kats/internal/gen) writes the same Go source the
// tests load. Running it twice produces byte-identical output, so a
// `go run ./pq/mldsa/kats/internal/gen` invocation that produces a
// diff is itself the regression signal. The Makefile target
// `make gen_kats` exposes this.
package kats
+218
View File
@@ -0,0 +1,218 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// gen produces the pinned KAT vector files (vectors_mldsa{44,65,87}.go)
// consumed by package kats. Invocation:
//
// go run github.com/luxfi/crypto/pq/mldsa/kats/internal/gen
//
// The generated source is deterministic — the same fixed seeds in
// the same order produce byte-identical output on every run, on
// every architecture. The Makefile target `make gen_kats` invokes
// this binary then re-runs the test suite to confirm the freshly-
// written vectors still pass.
package main
import (
"bytes"
"flag"
"fmt"
"go/format"
"os"
"path/filepath"
"strings"
"github.com/luxfi/crypto/pq/mldsa/mldsa44"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/luxfi/crypto/pq/mldsa/mldsa87"
)
// vectorSpec is the input recipe for one KAT vector. A spec is
// deterministically reduced to a Vector by running it through the
// per-parameter-set NewKeyFromSeed + Sign code paths.
type vectorSpec struct {
seedByte byte // every byte of the 32-byte ξ is set to seedByte
msg string // message under test (string lit for readability)
ctx string // domain-separation context; "" means nil ctx
}
// specs lists the synthetic KAT recipes shared across all three
// parameter sets. The order is meaningful (Count = index) and MUST
// NOT be reordered without regenerating the pinned files.
var specs = []vectorSpec{
{0x00, "", ""},
{0xFF, "FIPS204 KAT all-ones seed", "LUX/KAT/v1"},
{0x42, "deterministic ML-DSA signature", ""},
{0xA5, "context-bound signature", "LUX/KAT/ctx/v1"},
{0x5A, "long message" + strings.Repeat("/", 64), "LUX/KAT/longmsg/v1"},
}
func main() {
out := flag.String("out", "", "output directory (default: package dir of kats)")
flag.Parse()
outDir := *out
if outDir == "" {
// Resolve relative to this generator's source file.
// CWD at `go run` time = caller's CWD, so default to
// the canonical relative path from the repo root.
outDir = "pq/mldsa/kats"
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
die("mkdir %s: %v", outDir, err)
}
if err := genMLDSA44(filepath.Join(outDir, "vectors_mldsa44.go")); err != nil {
die("mldsa44: %v", err)
}
if err := genMLDSA65(filepath.Join(outDir, "vectors_mldsa65.go")); err != nil {
die("mldsa65: %v", err)
}
if err := genMLDSA87(filepath.Join(outDir, "vectors_mldsa87.go")); err != nil {
die("mldsa87: %v", err)
}
}
func genMLDSA44(path string) error {
var buf bytes.Buffer
writeHeader(&buf, "mldsa44", "ML-DSA-44 (NIST Level 2)")
for i, s := range specs {
seed := bytes.Repeat([]byte{s.seedByte}, mldsa44.SeedSize)
pk, sk, err := mldsa44.NewKeyFromSeed(seed)
if err != nil {
return fmt.Errorf("spec %d NewKeyFromSeed: %w", i, err)
}
var ctx []byte
if s.ctx != "" {
ctx = []byte(s.ctx)
}
sig, err := mldsa44.Sign(sk, []byte(s.msg), ctx, false)
if err != nil {
return fmt.Errorf("spec %d Sign: %w", i, err)
}
writeVector(&buf, i, seed, []byte(s.msg), ctx, pk.Bytes(), sk.Bytes(), sig)
}
writeFooter(&buf, "mldsa44")
return writeFormatted(path, buf.Bytes())
}
func genMLDSA65(path string) error {
var buf bytes.Buffer
writeHeader(&buf, "mldsa65", "ML-DSA-65 (NIST Level 3 — recommended)")
for i, s := range specs {
seed := bytes.Repeat([]byte{s.seedByte}, mldsa65.SeedSize)
pk, sk, err := mldsa65.NewKeyFromSeed(seed)
if err != nil {
return fmt.Errorf("spec %d NewKeyFromSeed: %w", i, err)
}
var ctx []byte
if s.ctx != "" {
ctx = []byte(s.ctx)
}
sig, err := mldsa65.Sign(sk, []byte(s.msg), ctx, false)
if err != nil {
return fmt.Errorf("spec %d Sign: %w", i, err)
}
writeVector(&buf, i, seed, []byte(s.msg), ctx, pk.Bytes(), sk.Bytes(), sig)
}
writeFooter(&buf, "mldsa65")
return writeFormatted(path, buf.Bytes())
}
func genMLDSA87(path string) error {
var buf bytes.Buffer
writeHeader(&buf, "mldsa87", "ML-DSA-87 (NIST Level 5)")
for i, s := range specs {
seed := bytes.Repeat([]byte{s.seedByte}, mldsa87.SeedSize)
pk, sk, err := mldsa87.NewKeyFromSeed(seed)
if err != nil {
return fmt.Errorf("spec %d NewKeyFromSeed: %w", i, err)
}
var ctx []byte
if s.ctx != "" {
ctx = []byte(s.ctx)
}
sig, err := mldsa87.Sign(sk, []byte(s.msg), ctx, false)
if err != nil {
return fmt.Errorf("spec %d Sign: %w", i, err)
}
writeVector(&buf, i, seed, []byte(s.msg), ctx, pk.Bytes(), sk.Bytes(), sig)
}
writeFooter(&buf, "mldsa87")
return writeFormatted(path, buf.Bytes())
}
// writeHeader emits the file preamble: copyright, // Code generated
// marker (so editor tooling flags it as machine-written), package
// statement, and the start of the per-parameter-set vectors slice.
func writeHeader(buf *bytes.Buffer, paramSet, label string) {
fmt.Fprintln(buf, "// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.")
fmt.Fprintln(buf, "// See the file LICENSE for licensing terms.")
fmt.Fprintln(buf)
fmt.Fprintln(buf, "// Code generated by pq/mldsa/kats/internal/gen. DO NOT EDIT.")
fmt.Fprintln(buf)
fmt.Fprintln(buf, "package kats")
fmt.Fprintln(buf)
fmt.Fprintf(buf, "// Vectors%s holds the synthetic Known-Answer-Test vectors for the\n",
strings.ToUpper(paramSet[len("mldsa"):]))
fmt.Fprintf(buf, "// %s parameter set. Regenerate via `make gen_kats`.\n", label)
fmt.Fprintf(buf, "var Vectors%s = []Vector{\n", strings.ToUpper(paramSet[len("mldsa"):]))
}
// writeFooter emits the closing brace of the slice literal.
func writeFooter(buf *bytes.Buffer, _ string) {
fmt.Fprintln(buf, "}")
}
// writeVector emits one Vector{...} struct literal. Each byte slice
// is emitted via hexBytes() for compact, diff-friendly output.
func writeVector(buf *bytes.Buffer, count int, seed, msg, ctx, pk, sk, sig []byte) {
fmt.Fprintln(buf, "\t{")
fmt.Fprintf(buf, "\t\tCount: %d,\n", count)
fmt.Fprintf(buf, "\t\tSeed: %s,\n", hexBytes(seed))
fmt.Fprintf(buf, "\t\tMsg: %s,\n", hexBytes(msg))
fmt.Fprintf(buf, "\t\tCtx: %s,\n", hexBytes(ctx))
fmt.Fprintf(buf, "\t\tPublicKey: %s,\n", hexBytes(pk))
fmt.Fprintf(buf, "\t\tPrivateKey: %s,\n", hexBytes(sk))
fmt.Fprintf(buf, "\t\tSignature: %s,\n", hexBytes(sig))
fmt.Fprintln(buf, "\t},")
}
// hexBytes renders a []byte slice as a Go literal. nil is preserved
// as `nil` (not `[]byte{}`) so the test can distinguish the two.
// Non-nil slices use the `mustDecodeHex("...")` helper defined in
// vectors_helpers.go for compact source output.
func hexBytes(b []byte) string {
if b == nil {
return "nil"
}
if len(b) == 0 {
return "[]byte{}"
}
return fmt.Sprintf("mustDecodeHex(%q)", hexString(b))
}
func hexString(b []byte) string {
const hexdigits = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, x := range b {
out[i*2] = hexdigits[x>>4]
out[i*2+1] = hexdigits[x&0x0f]
}
return string(out)
}
func writeFormatted(path string, src []byte) error {
formatted, err := format.Source(src)
if err != nil {
// Write the unformatted source for diagnosis, then fail.
_ = os.WriteFile(path+".unformatted", src, 0o644)
return fmt.Errorf("gofmt: %w (unformatted source written to %s.unformatted)", err, path)
}
return os.WriteFile(path, formatted, 0o644)
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "gen: "+format+"\n", args...)
os.Exit(1)
}
+211
View File
@@ -0,0 +1,211 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kats
import (
"bytes"
"testing"
"github.com/luxfi/crypto/pq/mldsa/mldsa44"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/luxfi/crypto/pq/mldsa/mldsa87"
)
// TestKAT_MLDSA44 walks Vectors44 and asserts byte-equality on the
// derived public key, private key, and deterministic signature for
// every record. A failure here means the synthetic vector pin is
// stale — either the upstream CIRCL implementation moved (in which
// case we want to know before downstream consumers do) or the
// generator was run without committing its output.
func TestKAT_MLDSA44(t *testing.T) {
if len(Vectors44) == 0 {
t.Fatal("Vectors44 is empty — did you forget to run `make gen_kats`?")
}
for _, v := range Vectors44 {
v := v
t.Run(vectorName("mldsa44", v.Count), func(t *testing.T) {
pk, sk, err := mldsa44.NewKeyFromSeed(v.Seed)
if err != nil {
t.Fatalf("count %d NewKeyFromSeed: %v", v.Count, err)
}
if !bytes.Equal(pk.Bytes(), v.PublicKey) {
t.Fatalf("count %d: public key mismatch (got %d bytes, want %d)",
v.Count, len(pk.Bytes()), len(v.PublicKey))
}
if !bytes.Equal(sk.Bytes(), v.PrivateKey) {
t.Fatalf("count %d: private key mismatch", v.Count)
}
sig, err := mldsa44.Sign(sk, v.Msg, v.Ctx, false)
if err != nil {
t.Fatalf("count %d Sign: %v", v.Count, err)
}
if !bytes.Equal(sig, v.Signature) {
t.Fatalf("count %d: signature mismatch", v.Count)
}
if !mldsa44.Verify(pk, v.Msg, v.Ctx, v.Signature) {
t.Fatalf("count %d: pinned signature does not verify", v.Count)
}
// Bad signature MUST be rejected. Flip a bit in c~ (the
// first byte of sig) which is always present.
bad := append([]byte{}, v.Signature...)
bad[0] ^= 0x01
if mldsa44.Verify(pk, v.Msg, v.Ctx, bad) {
t.Fatalf("count %d: tampered signature accepted", v.Count)
}
})
}
}
// TestKAT_MLDSA65 is the Level 3 counterpart of TestKAT_MLDSA44.
func TestKAT_MLDSA65(t *testing.T) {
if len(Vectors65) == 0 {
t.Fatal("Vectors65 is empty — did you forget to run `make gen_kats`?")
}
for _, v := range Vectors65 {
v := v
t.Run(vectorName("mldsa65", v.Count), func(t *testing.T) {
pk, sk, err := mldsa65.NewKeyFromSeed(v.Seed)
if err != nil {
t.Fatalf("count %d NewKeyFromSeed: %v", v.Count, err)
}
if !bytes.Equal(pk.Bytes(), v.PublicKey) {
t.Fatalf("count %d: public key mismatch", v.Count)
}
if !bytes.Equal(sk.Bytes(), v.PrivateKey) {
t.Fatalf("count %d: private key mismatch", v.Count)
}
sig, err := mldsa65.Sign(sk, v.Msg, v.Ctx, false)
if err != nil {
t.Fatalf("count %d Sign: %v", v.Count, err)
}
if !bytes.Equal(sig, v.Signature) {
t.Fatalf("count %d: signature mismatch", v.Count)
}
if !mldsa65.Verify(pk, v.Msg, v.Ctx, v.Signature) {
t.Fatalf("count %d: pinned signature does not verify", v.Count)
}
bad := append([]byte{}, v.Signature...)
bad[0] ^= 0x01
if mldsa65.Verify(pk, v.Msg, v.Ctx, bad) {
t.Fatalf("count %d: tampered signature accepted", v.Count)
}
})
}
}
// TestKAT_MLDSA87 is the Level 5 counterpart.
func TestKAT_MLDSA87(t *testing.T) {
if len(Vectors87) == 0 {
t.Fatal("Vectors87 is empty — did you forget to run `make gen_kats`?")
}
for _, v := range Vectors87 {
v := v
t.Run(vectorName("mldsa87", v.Count), func(t *testing.T) {
pk, sk, err := mldsa87.NewKeyFromSeed(v.Seed)
if err != nil {
t.Fatalf("count %d NewKeyFromSeed: %v", v.Count, err)
}
if !bytes.Equal(pk.Bytes(), v.PublicKey) {
t.Fatalf("count %d: public key mismatch", v.Count)
}
if !bytes.Equal(sk.Bytes(), v.PrivateKey) {
t.Fatalf("count %d: private key mismatch", v.Count)
}
sig, err := mldsa87.Sign(sk, v.Msg, v.Ctx, false)
if err != nil {
t.Fatalf("count %d Sign: %v", v.Count, err)
}
if !bytes.Equal(sig, v.Signature) {
t.Fatalf("count %d: signature mismatch", v.Count)
}
if !mldsa87.Verify(pk, v.Msg, v.Ctx, v.Signature) {
t.Fatalf("count %d: pinned signature does not verify", v.Count)
}
bad := append([]byte{}, v.Signature...)
bad[0] ^= 0x01
if mldsa87.Verify(pk, v.Msg, v.Ctx, bad) {
t.Fatalf("count %d: tampered signature accepted", v.Count)
}
})
}
}
// TestKAT_RandomizedSignVerifies is the companion to the
// deterministic-sign tests above. It does NOT pin signature bytes
// (a randomized signature has fresh nonce material every call), but
// it asserts that the deterministic-keygen + randomized-sign path
// produces a signature that verifies under the pinned public key.
//
// The test covers all three parameter sets and one vector each.
func TestKAT_RandomizedSignVerifies(t *testing.T) {
if len(Vectors44) > 0 {
v := Vectors44[0]
_, sk, err := mldsa44.NewKeyFromSeed(v.Seed)
if err != nil {
t.Fatalf("mldsa44 keygen: %v", err)
}
sig, err := mldsa44.Sign(sk, v.Msg, v.Ctx, true)
if err != nil {
t.Fatalf("mldsa44 randomized Sign: %v", err)
}
var pkBuf [mldsa44.PublicKeySize]byte
copy(pkBuf[:], v.PublicKey)
var pk mldsa44.PublicKey
pk.Unpack(&pkBuf)
if !mldsa44.Verify(&pk, v.Msg, v.Ctx, sig) {
t.Fatal("mldsa44 randomized sig fails Verify under pinned pk")
}
}
if len(Vectors65) > 0 {
v := Vectors65[0]
_, sk, err := mldsa65.NewKeyFromSeed(v.Seed)
if err != nil {
t.Fatalf("mldsa65 keygen: %v", err)
}
sig, err := mldsa65.Sign(sk, v.Msg, v.Ctx, true)
if err != nil {
t.Fatalf("mldsa65 randomized Sign: %v", err)
}
var pkBuf [mldsa65.PublicKeySize]byte
copy(pkBuf[:], v.PublicKey)
var pk mldsa65.PublicKey
pk.Unpack(&pkBuf)
if !mldsa65.Verify(&pk, v.Msg, v.Ctx, sig) {
t.Fatal("mldsa65 randomized sig fails Verify under pinned pk")
}
}
if len(Vectors87) > 0 {
v := Vectors87[0]
_, sk, err := mldsa87.NewKeyFromSeed(v.Seed)
if err != nil {
t.Fatalf("mldsa87 keygen: %v", err)
}
sig, err := mldsa87.Sign(sk, v.Msg, v.Ctx, true)
if err != nil {
t.Fatalf("mldsa87 randomized Sign: %v", err)
}
var pkBuf [mldsa87.PublicKeySize]byte
copy(pkBuf[:], v.PublicKey)
var pk mldsa87.PublicKey
pk.Unpack(&pkBuf)
if !mldsa87.Verify(&pk, v.Msg, v.Ctx, sig) {
t.Fatal("mldsa87 randomized sig fails Verify under pinned pk")
}
}
}
// vectorName produces a stable sub-test name for `go test -run`
// filtering. The format is "<param>/count<NN>".
func vectorName(param string, count int) string {
return param + "/count" + fmtInt(count)
}
func fmtInt(n int) string {
if n < 10 {
return string(rune('0' + n))
}
// Two-digit max: our specs slice is short and the count never
// exceeds 99. If that changes, expand this helper.
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kats
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestRegen_Deterministic invokes the same generator the Makefile
// target uses (`go run ./pq/mldsa/kats/internal/gen`) against a
// temporary output directory and asserts the resulting files are
// byte-identical to the checked-in vectors_mldsaXX.go files.
//
// This is the "two-pass discipline" guard. A drift here means
// either (a) someone hand-edited the generated vectors, or (b) the
// generator changed but `make gen_kats` was not re-run before
// commit. Either is a defect.
//
// The test is skipped when the test binary cannot find a `go`
// executable on PATH (e.g. in a stripped CI image); the regression
// risk in that case is bounded — the gen_kats Makefile target is
// expected to be re-run separately.
func TestRegen_Deterministic(t *testing.T) {
goBin, err := exec.LookPath("go")
if err != nil {
t.Skipf("go not on PATH: %v", err)
}
if os.Getenv("LUX_SKIP_REGEN") == "1" {
// Escape hatch for environments where invoking `go run`
// inside a test is undesirable (sandboxed runners that
// disallow nested compilation).
t.Skip("LUX_SKIP_REGEN=1")
}
tmp := t.TempDir()
// Resolve repo root from this source file's location: kats lives
// at <repo>/pq/mldsa/kats; go up three to reach <repo>.
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed; cannot locate source file")
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", ".."))
cmd := exec.Command(goBin, "run",
"github.com/luxfi/crypto/pq/mldsa/kats/internal/gen",
"-out", tmp,
)
cmd.Dir = repoRoot
// Inherit Go env including GOPROXY; explicitly disable workspace
// so the generator builds against this module's own go.mod
// (matches CI behavior).
cmd.Env = append(os.Environ(), "GOWORK=off")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go run gen: %v\n%s", err, out)
}
for _, name := range []string{
"vectors_mldsa44.go",
"vectors_mldsa65.go",
"vectors_mldsa87.go",
} {
freshPath := filepath.Join(tmp, name)
pinnedPath := filepath.Join(filepath.Dir(thisFile), name)
fresh, err := os.ReadFile(freshPath)
if err != nil {
t.Fatalf("read fresh %s: %v", name, err)
}
pinned, err := os.ReadFile(pinnedPath)
if err != nil {
t.Fatalf("read pinned %s: %v", name, err)
}
if !bytes.Equal(fresh, pinned) {
// Surface the first differing line so the diagnosis is
// short. Big hex literals make a full diff useless.
line, freshLine, pinnedLine := firstDiffLine(fresh, pinned)
t.Fatalf("%s: regenerated bytes differ from pinned source\n"+
"first diff at line %d:\n fresh: %q\n pinned: %q\n"+
"hint: run `make gen_kats` and commit the result",
name, line,
truncate(freshLine, 120), truncate(pinnedLine, 120))
}
}
}
func firstDiffLine(a, b []byte) (line int, aLine, bLine string) {
la := strings.Split(string(a), "\n")
lb := strings.Split(string(b), "\n")
n := len(la)
if len(lb) < n {
n = len(lb)
}
for i := 0; i < n; i++ {
if la[i] != lb[i] {
return i + 1, la[i], lb[i]
}
}
if len(la) != len(lb) {
// One file has trailing lines.
if len(la) > n {
return n + 1, la[n], ""
}
return n + 1, "", lb[n]
}
return 0, "", ""
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "...(truncated)"
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kats
// Vector is one Known-Answer-Test record. Every field is the
// canonical FIPS 204 wire encoding of the value (Seed = 32-byte ξ;
// PublicKey, PrivateKey, Signature = mldsaXX wire bytes; Msg, Ctx
// = arbitrary byte slices).
type Vector struct {
// Count is the vector index within its parameter-set slice;
// surfaced in test failure messages so a diff is easy to pin.
Count int
// Seed is the 32-byte ξ fed to NewKeyFromSeed.
Seed []byte
// Msg is the message bytes passed to Sign / Verify.
Msg []byte
// Ctx is the domain-separation context. May be empty.
Ctx []byte
// PublicKey is the expected mldsaXX.PublicKey.Bytes() output.
PublicKey []byte
// PrivateKey is the expected mldsaXX.PrivateKey.Bytes() output.
PrivateKey []byte
// Signature is the expected deterministic
// mldsaXX.Sign(sk, Msg, Ctx, false) output.
Signature []byte
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package kats
import (
"encoding/hex"
)
// mustDecodeHex is the helper the generated vectors_mldsaXX.go
// files call to inline byte literals. A malformed input is a
// compile-time defect — we panic so tests fail loudly at init
// rather than producing silent zero-byte slices.
func mustDecodeHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic("kats: bad hex literal: " + err.Error())
}
return b
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long