// Copyright (C) 2020-2026, Lux Industries, Inc. All rights reserved. // See the file LICENSE for licensing terms. // // ML-DSA-44 batch verify dispatch tests. // // ML-DSA-44 is the FIPS 204 NIST L2 parameter set (Dilithium2 in the legacy // CRYSTALS spec). In the Lux PQ stack it lives in the CLASSICAL_COMPAT_UNSAFE // tier — used only for devnet wallet keys that interop with externally- // published Dilithium2 vectors. The canonical strict-PQ identity is // ML-DSA-65; new code MUST default to that. // // These tests exercise the GPU-dispatch path for ML-DSA-44: // // 1. KAT replay — verify that GenerateKey + Sign reproduces the published // circl reference, so we know the underlying library is intact. // 2. Batch verify equivalence — feed a batch through BatchVerify and // confirm it matches the per-element CPU oracle byte-for-byte. When a // GPU plugin is registered (CRYPTO_BACKEND=gpu + backend.GPUAvailable // device) this exercises the real GPU substrate; otherwise it // validates the goroutine-parallel CPU fallback. // 3. Tamper detection — flip a bit and confirm both paths reject. package mldsa import ( "crypto/rand" "fmt" "testing" "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/luxfi/crypto/backend" ) // genBatch44 produces n ML-DSA-44 keypairs + signs distinct messages with // each. SLH-DSA 192f is the canonical Magnetar parameter set per the // PQ-naming-lock memory; ML-DSA-44 here is the FIPS 204 L2 tier exercised // only for compat-unsafe paths (devnet). func genBatch44(t testing.TB, n int) (pubs []*PublicKey, msgs [][]byte, sigs [][]byte) { t.Helper() pubs = make([]*PublicKey, n) msgs = make([][]byte, n) sigs = make([][]byte, n) for i := 0; i < n; i++ { priv, err := GenerateKey(rand.Reader, MLDSA44) if err != nil { t.Fatalf("GenerateKey[%d]: %v", i, err) } msg := []byte(fmt.Sprintf("ml-dsa-44-batch-msg-%d", i)) sig, err := priv.Sign(rand.Reader, msg, nil) if err != nil { t.Fatalf("Sign[%d]: %v", i, err) } pubs[i] = priv.PublicKey msgs[i] = msg sigs[i] = sig } return } // TestMLDSA44_KATReplay verifies our wrapper produces byte-equal public keys // to the underlying circl/mldsa44.NewKeyFromSeed for a fixed seed. This is // the KAT contract — if it breaks, the whole batch path is suspect. func TestMLDSA44_KATReplay(t *testing.T) { seed := [mldsa44.SeedSize]byte{ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, } pk, sk := mldsa44.NewKeyFromSeed(&seed) if pk == nil || sk == nil { t.Fatalf("circl mldsa44 NewKeyFromSeed returned nil") } pkBytes, err := pk.MarshalBinary() if err != nil { t.Fatalf("marshal pk: %v", err) } if len(pkBytes) != MLDSA44PublicKeySize { t.Fatalf("ML-DSA-44 pk size: got %d, want %d", len(pkBytes), MLDSA44PublicKeySize) } // Round-trip through our wrapper. wrapper, err := PublicKeyFromBytes(pkBytes, MLDSA44) if err != nil { t.Fatalf("PublicKeyFromBytes: %v", err) } if len(wrapper.publicKey) != len(pkBytes) { t.Fatalf("wrapper pk size: got %d, want %d", len(wrapper.publicKey), len(pkBytes)) } for i := range pkBytes { if wrapper.publicKey[i] != pkBytes[i] { t.Fatalf("byte mismatch at[%d]: wrapper=%02x circl=%02x", i, wrapper.publicKey[i], pkBytes[i]) } } // Sign+verify round-trip via the underlying circl ref to ensure the // (pk, sk) pair is internally consistent. msg := []byte("kat-44") sig := make([]byte, MLDSA44SignatureSize) if err := mldsa44.SignTo(sk, msg, nil, false, sig); err != nil { t.Fatalf("SignTo: %v", err) } if !mldsa44.Verify(pk, msg, nil, sig) { t.Fatalf("circl verify failed on its own signature") } if !wrapper.VerifySignature(msg, sig) { t.Fatalf("wrapper verify failed on circl-produced signature") } } // TestMLDSA44_BatchEquivalence_CPU_GPU pins the contract that the GPU- // dispatched batch verify path produces byte-equal accept/reject decisions // to the per-element CPU verify path for ML-DSA-44. FIPS 204 verify is // deterministic so the kernel substrate and the Go CPU reference MUST // agree on every signature. func TestMLDSA44_BatchEquivalence_CPU_GPU(t *testing.T) { n := BatchThreshold + 4 pubs, msgs, sigs := genBatch44(t, n) cpu := make([]bool, n) for i := range pubs { cpu[i] = pubs[i].VerifySignature(msgs[i], sigs[i]) if !cpu[i] { t.Fatalf("CPU verify[%d] = false on fresh signature — broken signing/verify", i) } } prev := backend.Default() backend.SetDefault(backend.GPU) defer backend.SetDefault(prev) gpu := make([]bool, n) dispatched, err := batchVerifyGPU(pubs, msgs, sigs, gpu) if err != nil { t.Fatalf("batchVerifyGPU: %v", err) } if dispatched { t.Logf("GPU dispatch active (gpu=%v)", backend.GPUAvailable()) for i := range cpu { if cpu[i] != gpu[i] { t.Fatalf("CPU/GPU mismatch at[%d]: cpu=%v gpu=%v", i, cpu[i], gpu[i]) } } } else { t.Logf("GPU dispatch unavailable; goroutine-parallel CPU is the active fast path") } all := BatchVerify(pubs, msgs, sigs) if len(all) != n { t.Fatalf("BatchVerify length: got %d, want %d", len(all), n) } for i := range cpu { if cpu[i] != all[i] { t.Fatalf("BatchVerify[%d] disagrees with CPU oracle: cpu=%v all=%v", i, cpu[i], all[i]) } } } // TestMLDSA44_Tampered confirms the GPU path rejects a tampered signature. // This catches the "GPU silently accepts everything" failure mode (which // is worse than rejecting everything because it's exploitable). func TestMLDSA44_Tampered(t *testing.T) { const n = 16 pubs, msgs, sigs := genBatch44(t, n) sigs[5] = append([]byte(nil), sigs[5]...) sigs[5][0] ^= 0xff prev := backend.Default() backend.SetDefault(backend.GPU) defer backend.SetDefault(prev) all := BatchVerify(pubs, msgs, sigs) if all[5] { t.Fatalf("BatchVerify accepted tampered signature at[5]") } for i := 0; i < n; i++ { if i == 5 { continue } if !all[i] { t.Fatalf("BatchVerify rejected valid signature at[%d]", i) } } } // TestMLDSA44_BatchSign_RoundTrip exercises the sign-then-verify round-trip // in batch mode. The parallel sign path must produce signatures that the // per-element verify accepts. func TestMLDSA44_BatchSign_RoundTrip(t *testing.T) { n := concurrentBatchThreshold + 2 privs := make([]*PrivateKey, n) msgs := make([][]byte, n) for i := 0; i < n; i++ { priv, err := GenerateKey(rand.Reader, MLDSA44) if err != nil { t.Fatalf("GenerateKey[%d]: %v", i, err) } privs[i] = priv msgs[i] = []byte(fmt.Sprintf("mldsa44-roundtrip-%d", i)) } sigs, err := BatchSign(rand.Reader, privs, msgs) if err != nil { t.Fatalf("BatchSign: %v", err) } if len(sigs) != n { t.Fatalf("BatchSign returned %d sigs, want %d", len(sigs), n) } for i := 0; i < n; i++ { if len(sigs[i]) != MLDSA44SignatureSize { t.Fatalf("sigs[%d] size: got %d, want %d", i, len(sigs[i]), MLDSA44SignatureSize) } if !privs[i].PublicKey.VerifySignature(msgs[i], sigs[i]) { t.Fatalf("Verify[%d] of batch-signed signature failed", i) } } } // BenchmarkMLDSA44_BatchVerify measures throughput of the ML-DSA-44 batch // verify dispatch ladder. Run with -bench=. to compare: // // BenchmarkMLDSA44_BatchVerify/serial-1 // BenchmarkMLDSA44_BatchVerify/concurrent-8 // BenchmarkMLDSA44_BatchVerify/concurrent-32 func BenchmarkMLDSA44_BatchVerify(b *testing.B) { for _, size := range []int{1, 8, 32, 64} { b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) { pubs, msgs, sigs := genBatch44(b, size) b.ResetTimer() for i := 0; i < b.N; i++ { out := BatchVerify(pubs, msgs, sigs) for j, v := range out { if !v { b.Fatalf("verify[%d] failed", j) } } } }) } }