Files
zeekay 4a9ef30157 slhdsa: GPU+goroutine-parallel SignBatch with FIPS 205 catalogue at pq/slhdsa/gpu
Adds the SLH-DSA / Magnetar (FIPS 205) batched sign fast path and the
canonical FIPS 205 parameter table.

Three tier dispatch ladder for SignBatch:
  1. GPU substrate via accel.LatticeOps.SLHDSASignBatch
  2. Goroutine-parallel CPU sign across GOMAXPROCS workers
  3. Serial CPU sign (cloudflare/circl, FIPS 205-conformant)

FIPS 205 SignDeterministic has no per-call randomness, so all three
tiers produce byte-identical signatures for any fixed (sk, msg). KAT
replay holds across the ladder.

New files
  pq/slhdsa/doc.go          - canonical entry point pointer (mirrors pq/mldsa)
  pq/slhdsa/gpu/params.go   - FIPS 205 §10 parameter table (12 sets)
  pq/slhdsa/gpu/dispatch.go - mode-driven SignBatch / VerifyBatch
  pq/slhdsa/gpu/gpu_test.go - catalogue + roundtrip + tamper-reject + bench
  slhdsa/gpu_sign_test.go   - canonical SignBatch round-trip + determinism

Modified
  slhdsa/gpu.go    - SignBatch + SignBatchGPU + signBatchConcurrent
                     (412 line expansion of the existing verify-only file)
  slhdsa/slhdsa.go - GetPrivateKeySize accessor (FIPS 205 sk = 4n)

Deleted
  pq/slhdsa/gpu/gpu.go - the "Available() bool { return false }" stub.
                         The replacement params.go + dispatch.go cover the
                         same surface with real semantics.

Bench (Apple M1 Max, CGO_ENABLED=0, batch SLH-DSA-SHA2-192f sign):
  serial single-sig         69.0 ms/op
  parallel n=1             150.9 ms/op    (1 worker, fork overhead)
  parallel n=2 (canon)      25.6 ms/sig   (2.7x serial)
  parallel n=8 (canon)      17.1 ms/sig   (4.0x serial)
  parallel n=21 (Lux quorum) 20.7 ms/sig   (3.3x serial)
  parallel n=32             22.9 ms/sig   (3.0x serial)

Asymptotic speedup ~2.75x — bottlenecked by cloudflare/circl's scalar
SHA-256/SHAKE-256 (49% CPU time per pprof). Two routes to break 5x are
documented in gpu.go:
  - Sloth / SHA-NI fork drops per-sign cost ~7x
  - Metal/CUDA kernel substrate via luxcpp/crypto/slhdsa/gpu/{metal,cuda}/

The Go-side dispatch ladder routes through the GPU substrate as soon as
the C-API gains the strong symbol — no code change needed here.

Canonical Magnetar profile pinned: SLH-DSA-SHA2-192f (NIST L3).

CGO_ENABLED=0 go test -timeout 300s ./slhdsa/...    -> 35s PASS
CGO_ENABLED=0 go test -timeout 300s ./pq/slhdsa/... -> 0.4s PASS (9 tests)
CGO_ENABLED=1 go test -timeout 300s ./slhdsa/...    -> 97s PASS
CGO_ENABLED=1 go test -timeout 300s ./pq/slhdsa/... -> 0.3s PASS (9 tests)
2026-05-21 13:17:23 -07:00

311 lines
10 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package gpu
import (
"bytes"
"crypto/rand"
"fmt"
"testing"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
"github.com/luxfi/crypto/slhdsa"
)
// genSign192f builds n SLH-DSA-SHA2-192f keypairs + distinct messages ready
// for SignBatch. 192f is the canonical Magnetar profile.
func genSign192f(t testing.TB, n int) (privs []*slhdsa.PrivateKey, msgs [][]byte) {
t.Helper()
privs = make([]*slhdsa.PrivateKey, n)
msgs = make([][]byte, n)
for i := 0; i < n; i++ {
priv, err := slhdsa.GenerateKey(rand.Reader, slhdsa.SHA2_192f)
if err != nil {
t.Fatalf("GenerateKey[%d]: %v", i, err)
}
privs[i] = priv
msgs[i] = []byte(fmt.Sprintf("pq-slhdsa-gpu-msg-%d", i))
}
return
}
// TestParamsCatalogue pins the FIPS 205 §10 parameter table. Sizes are the
// FIPS 205 fixed catalogue values and must agree with the canonical
// crypto/slhdsa package's GetPublicKeySize / GetPrivateKeySize /
// GetSignatureSize accessors. This is a "two implementations of the same
// constant must agree" cross-check — if either side drifts, the FIPS 205
// conformance is broken.
func TestParamsCatalogue(t *testing.T) {
type sizes struct{ pk, sk, sig int }
wants := map[Mode]sizes{
ModeSHA2_128s: {32, 64, 7856}, ModeSHAKE_128s: {32, 64, 7856},
ModeSHA2_128f: {32, 64, 17088}, ModeSHAKE_128f: {32, 64, 17088},
ModeSHA2_192s: {48, 96, 16224}, ModeSHAKE_192s: {48, 96, 16224},
ModeSHA2_192f: {48, 96, 35664}, ModeSHAKE_192f: {48, 96, 35664},
ModeSHA2_256s: {64, 128, 29792}, ModeSHAKE_256s: {64, 128, 29792},
ModeSHA2_256f: {64, 128, 49856}, ModeSHAKE_256f: {64, 128, 49856},
}
for _, m := range AllModes() {
p, ok := ParamsFor(m)
if !ok {
t.Fatalf("ParamsFor(%s) returned !ok", m)
}
want := wants[m]
if p.PublicKeySize != want.pk || p.PrivateKeySize != want.sk || p.SignatureSize != want.sig {
t.Fatalf("%s sizes: got pk=%d sk=%d sig=%d, want pk=%d sk=%d sig=%d",
m, p.PublicKeySize, p.PrivateKeySize, p.SignatureSize,
want.pk, want.sk, want.sig)
}
// Cross-check against the canonical slhdsa package's accessors.
canon, err := modeToCanonical(m)
if err != nil {
t.Fatalf("modeToCanonical(%s): %v", m, err)
}
if got := slhdsa.GetPublicKeySize(canon); got != p.PublicKeySize {
t.Fatalf("%s pk-size drift: gpu=%d canonical=%d", m, p.PublicKeySize, got)
}
if got := slhdsa.GetPrivateKeySize(canon); got != p.PrivateKeySize {
t.Fatalf("%s sk-size drift: gpu=%d canonical=%d", m, p.PrivateKeySize, got)
}
if got := slhdsa.GetSignatureSize(canon); got != p.SignatureSize {
t.Fatalf("%s sig-size drift: gpu=%d canonical=%d", m, p.SignatureSize, got)
}
// FIPS 205 §10: PublicKeySize = 2*N, PrivateKeySize = 4*N.
if p.PublicKeySize != 2*p.N {
t.Fatalf("%s: PublicKeySize=%d should equal 2*N=%d", m, p.PublicKeySize, 2*p.N)
}
if p.PrivateKeySize != 4*p.N {
t.Fatalf("%s: PrivateKeySize=%d should equal 4*N=%d", m, p.PrivateKeySize, 4*p.N)
}
// FIPS 205 §10: H = D * HPrime.
if p.H != p.D*p.HPrime {
t.Fatalf("%s: H=%d should equal D*HPrime=%d*%d=%d", m, p.H, p.D, p.HPrime, p.D*p.HPrime)
}
// FIPS 205 §10: w = 16, so LgW = 4.
if p.LgW != WOTSWBits {
t.Fatalf("%s: LgW=%d should equal WOTSWBits=%d", m, p.LgW, WOTSWBits)
}
// FORS height and hypertree layers must be inside the FIPS 205 envelope.
if p.A < FORSHeightMin || p.A > FORSHeightMax {
t.Fatalf("%s: A=%d outside [%d,%d]", m, p.A, FORSHeightMin, FORSHeightMax)
}
if p.D < HypertreeLayersMin || p.D > HypertreeLayersMax {
t.Fatalf("%s: D=%d outside [%d,%d]", m, p.D, HypertreeLayersMin, HypertreeLayersMax)
}
}
// Magnetar profile pin.
if CanonicalMagnetar != ModeSHA2_192f {
t.Fatalf("CanonicalMagnetar = %s, want SLH-DSA-SHA2-192f", CanonicalMagnetar)
}
}
// TestFastModesGated checks the IsFast / FastModes pair. The 'f' variants
// are the only ones the GPU substrate batches; 's' variants stay CPU-only.
// If this drifts, the dispatch ladder will silently sign 's' variants on
// the CPU when the operator asked for 'f' throughput.
func TestFastModesGated(t *testing.T) {
fastSet := make(map[Mode]bool, len(FastModes()))
for _, m := range FastModes() {
fastSet[m] = true
if !m.IsFast() {
t.Fatalf("%s in FastModes but IsFast()=false", m)
}
}
for _, m := range AllModes() {
if fastSet[m] != m.IsFast() {
t.Fatalf("%s IsFast=%v vs FastModes membership=%v", m, m.IsFast(), fastSet[m])
}
}
}
// TestModeToCanonicalRoundTrip pins that every gpu.Mode in AllModes() has a
// canonical slhdsa.Mode counterpart, and that the canonical wrapper's sizes
// agree with the parameter table.
func TestModeToCanonicalRoundTrip(t *testing.T) {
for _, m := range AllModes() {
canon, err := modeToCanonical(m)
if err != nil {
t.Fatalf("modeToCanonical(%s): %v", m, err)
}
p, _ := ParamsFor(m)
if got := slhdsa.GetPublicKeySize(canon); got != p.PublicKeySize {
t.Fatalf("%s: gpu PublicKeySize=%d, canonical=%d", m, p.PublicKeySize, got)
}
}
}
// TestSignBatch_RoundTrip pins the contract that the table-driven SignBatch
// wrapper produces signatures that the canonical verify accepts. This is
// the most important invariant: a wrapper that compiles but produces sigs
// the network rejects is worse than one that fails loudly.
func TestSignBatch_RoundTrip(t *testing.T) {
const n = 4 // small batch — 192f sign is ~1.6s/sig on M1 Max
privs, msgs := genSign192f(t, n)
sigs, err := SignBatch(rand.Reader, ModeSHA2_192f, privs, msgs)
if err != nil {
t.Fatalf("SignBatch: %v", err)
}
if len(sigs) != n {
t.Fatalf("SignBatch returned %d sigs, want %d", len(sigs), n)
}
pubs := make([]*slhdsa.PublicKey, n)
for i := range privs {
pubs[i] = privs[i].PublicKey
}
results, err := VerifyBatch(ModeSHA2_192f, pubs, msgs, sigs)
if err != nil {
t.Fatalf("VerifyBatch: %v", err)
}
for i, ok := range results {
if !ok {
t.Fatalf("VerifyBatch[%d] rejected a signature produced by SignBatch — sign/verify path corrupted", i)
}
}
}
// TestSignBatch_Determinism pins FIPS 205 SignDeterministic's no-randomness
// invariant: two SignBatch calls on the same (sk, msg) must produce byte-
// identical signatures. Catches PRF-state leakage and worker-ordering races
// in the parallel sign path.
func TestSignBatch_Determinism(t *testing.T) {
const n = 3
privs, msgs := genSign192f(t, n)
first, err := SignBatch(rand.Reader, ModeSHA2_192f, privs, msgs)
if err != nil {
t.Fatalf("SignBatch first: %v", err)
}
second, err := SignBatch(rand.Reader, ModeSHA2_192f, privs, msgs)
if err != nil {
t.Fatalf("SignBatch second: %v", err)
}
for i := 0; i < n; i++ {
if !bytes.Equal(first[i], second[i]) {
t.Fatalf("sig[%d] differs across two SignBatch calls — FIPS 205 determinism broken", i)
}
}
}
// TestSignBatch_GPU_Path forces the GPU dispatch tier. When no plugin is
// loaded, this exercises the goroutine-parallel CPU fallback. Either way
// the produced signatures MUST verify under the CPU oracle.
func TestSignBatch_GPU_Path(t *testing.T) {
const n = 4
privs, msgs := genSign192f(t, n)
prev := backend.Default()
backend.SetDefault(backend.GPU)
defer backend.SetDefault(prev)
sigs, err := SignBatch(rand.Reader, ModeSHA2_192f, privs, msgs)
if err != nil {
t.Fatalf("SignBatch: %v", err)
}
t.Logf("backend=%s gpuhost.Available=%v sig_count=%d sig_size=%d",
backend.Default(), gpuhost.Available(), len(sigs), len(sigs[0]))
for i := range sigs {
if !privs[i].PublicKey.VerifySignature(msgs[i], sigs[i]) {
t.Fatalf("CPU verify[%d] rejected a batch-signed signature — dispatch path corrupted", i)
}
}
}
// TestSignBatch_UnknownMode rejects values outside the FIPS 205 §10
// catalogue. Must not panic.
func TestSignBatch_UnknownMode(t *testing.T) {
priv, err := slhdsa.GenerateKey(rand.Reader, slhdsa.SHA2_192f)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
_, err = SignBatch(rand.Reader, Mode(99), []*slhdsa.PrivateKey{priv}, [][]byte{[]byte("x")})
if err == nil {
t.Fatalf("SignBatch(invalid mode) returned nil error")
}
}
// TestSignBatch_EmptyBatch is the zero-batch boundary case.
func TestSignBatch_EmptyBatch(t *testing.T) {
sigs, err := SignBatch(rand.Reader, ModeSHA2_192f, nil, nil)
if err != nil {
t.Fatalf("SignBatch(empty): %v", err)
}
if len(sigs) != 0 {
t.Fatalf("SignBatch(empty) returned %d sigs, want 0", len(sigs))
}
}
// TestVerifyBatch_TamperRejected pins that a bit-flipped signature is
// rejected. Catches the worst-case GPU bug: silently accepting everything.
func TestVerifyBatch_TamperRejected(t *testing.T) {
const n = 3
privs, msgs := genSign192f(t, n)
pubs := make([]*slhdsa.PublicKey, n)
for i := range privs {
pubs[i] = privs[i].PublicKey
}
sigs, err := SignBatch(rand.Reader, ModeSHA2_192f, privs, msgs)
if err != nil {
t.Fatalf("SignBatch: %v", err)
}
// Tamper signature[1].
tampered := append([]byte(nil), sigs[1]...)
tampered[0] ^= 0xAA
sigs[1] = tampered
results, err := VerifyBatch(ModeSHA2_192f, pubs, msgs, sigs)
if err != nil {
t.Fatalf("VerifyBatch: %v", err)
}
if results[1] {
t.Fatalf("VerifyBatch accepted a bit-flipped signature — verify is broken")
}
if !results[0] || !results[2] {
t.Fatalf("VerifyBatch rejected unrelated signatures: %v", results)
}
}
// BenchmarkSignBatch_Serial measures the per-element CPU sign cost on
// SLH-DSA-SHA2-192f as the baseline for the parallel speedup calculation.
func BenchmarkSignBatch_Serial(b *testing.B) {
const batchSize = 1
privs, msgs := genSign192f(b, batchSize)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := privs[0].SignCtx(rand.Reader, msgs[0], nil); err != nil {
b.Fatalf("Sign: %v", err)
}
}
}
// BenchmarkSignBatch_Parallel measures the parallel dispatch across batch
// sizes. n=21 is the Lux production quorum size; n=32 saturates GOMAXPROCS
// on a typical validator host.
func BenchmarkSignBatch_Parallel(b *testing.B) {
for _, size := range []int{1, 32} {
b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) {
privs, msgs := genSign192f(b, size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
sigs, err := SignBatch(rand.Reader, ModeSHA2_192f, privs, msgs)
if err != nil {
b.Fatalf("SignBatch: %v", err)
}
if len(sigs) != size {
b.Fatalf("len(sigs)=%d want %d", len(sigs), size)
}
}
})
}
}