Files
chains/quantumvm/quantumvm_gpu_test.go
T
zeekay fc0a2410db quantumvm: wire GPU plugin substrate via ABI v14 vtbl dlopen+dlsym
Adds four Go files to chains/quantumvm so the Q-Chain VM can call into
the lux-private/gpu-kernels plugin's batched ML-DSA verify, ML-DSA sign,
and SLH-DSA verify slots without a build-time pkg-config dependency:

* backend.go            cross-build types: Backend enum, GPUBackend
                        interface, MLDSAMode + MLDSA65* size constants,
                        SLHDSAVariant enum + per-variant pk/sig widths
                        (FIPS 204 / FIPS 205), ErrGPUNotAvailable,
                        AutoBackend(). Build-tag-free so vm.go can
                        reference the surface regardless of CGO_ENABLED.
* quantumvm_gpu.go      cgo dlopen probe at init() in order
   (cgo)                cuda -> hip -> metal -> vulkan -> webgpu,
                        resolving lux_gpu_backend_init and verifying
                        the ABI v14 cookies (abi_version + vtbl_size)
                        plus the load-bearing op_mldsa_verify_batch
                        slot before opening a LuxBackendContext. Three
                        vtbl-slot wrappers dispatch through C
                        trampolines into op_mldsa_verify_batch,
                        op_mldsa_sign_batch, and the SHAKE-128f /
                        SHAKE-192f variants of op_slhdsa_verify_batch.
* quantumvm_gpu_nocgo.go symmetric noGPUBackend stub returning
                        ErrGPUNotAvailable on every method so the
                        package surface is identical under !cgo.
* quantumvm_gpu_test.go round-trip test: dlopens the configured
                        plugin (LUX_GPU_PLUGIN_DIR override or default
                        loader path), generates an ML-DSA-65 keypair +
                        signs a 64-byte digest via circl, then submits
                        the triple through MLDSAVerifyBatch and asserts
                        results[0] == true. Cleanly SKIPs when no
                        plugin loaded or when the backend returns
                        NOT_SUPPORTED for op_mldsa_verify_batch (e.g.
                        plugins built without LUX_GPU_LINK_CRYPTO).

The probe is header-only: cgo CFLAGS points at
${SRCDIR}/../../../lux-private/gpu-kernels/include for the v14 vtbl
layout, but the .dylib / .so is dlopen'd at runtime - no pkg-config,
no static link, no luxcpp/install dependency. Existing quantumvm
vm.go, quantum/signer.go, and quantumvm/quasar.go are untouched;
consumers opt in explicitly when ready. Both 'go build ./quantumvm/...'
and 'CGO_ENABLED=1 go build ./quantumvm/...' are green.

Test output with LUX_GPU_PLUGIN_DIR pointing at a freshly-built
libluxgpu_backend_vulkan.dylib:
  --- PASS: TestAutoBackend_Probes (0.00s)          [backend=vulkan]
  --- PASS: TestMLDSAVerifyBatch_RoundTrip (5.56s)  [backend=vulkan,
            op_mldsa_verify_batch honest verify -> true]
  --- PASS: TestMLDSAVerifyBatch_LengthMismatch (0.00s)
  --- PASS: TestMLDSAVerifyBatch_NonMLDSA65Skips  (0.00s)
2026-06-05 14:59:54 -07:00

177 lines
6.1 KiB
Go

//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quantumvm
import (
"crypto/rand"
"errors"
"testing"
"github.com/luxfi/crypto/mldsa"
)
// TestAutoBackend_Probes documents what the init() probe found at process
// start. Skipped when no plugin loaded — this is the on-CI / clean-build
// signal that the binary stays portable. When a plugin IS loaded, we log
// its name so flakes can be tied back to a specific backend.
func TestAutoBackend_Probes(t *testing.T) {
b := AutoBackend()
if b == BackendNone {
t.Skip("no libluxgpu_backend_*.{so,dylib} loadable on the dlopen path — Q-Chain CPU path remains the only option")
}
t.Logf("AutoBackend() = %s", b)
}
// TestActiveGPUBackend_NoBackendReturnsErr exercises the noGPUBackend
// stub returned when no plugin was loaded. Every method must return
// ErrGPUNotAvailable so callers can route to the CPU verify path via
// errors.Is.
func TestActiveGPUBackend_NoBackendReturnsErr(t *testing.T) {
if AutoBackend() != BackendNone {
t.Skip("plugin loaded — noGPUBackend stub not exercised on this host")
}
g := ActiveGPUBackend()
if g.Backend() != BackendNone {
t.Fatalf("Backend() = %s, want none", g.Backend())
}
err := g.MLDSAVerifyBatch(MLDSAMode65, nil, nil, nil, 0, nil, nil)
if err != nil {
// Empty batch is a legal no-op even when no plugin loaded;
// length 0 short-circuits before the noGPUBackend stub runs.
t.Fatalf("MLDSAVerifyBatch(empty) = %v, want nil", err)
}
err = g.MLDSAVerifyBatch(MLDSAMode65,
[][]byte{make([]byte, MLDSA65PublicKeySize)},
[][]byte{nil},
nil, 0,
[][]byte{make([]byte, MLDSA65SignatureSize)},
make([]bool, 1))
if !errors.Is(err, ErrGPUNotAvailable) {
t.Fatalf("MLDSAVerifyBatch on noGPUBackend = %v, want ErrGPUNotAvailable", err)
}
}
// TestMLDSAVerifyBatch_RoundTrip — open a backend context, sign one
// message via the in-process circl ML-DSA-65 implementation, then run
// op_mldsa_verify_batch on the (pk, msg, sig) triple and assert
// results[0] == true.
//
// The plugin's wrapper consumes FIPS 204 wire-format (pk=1952 B,
// sig=3309 B max) which is exactly what circl's MarshalBinary() /
// SignTo() emit, so the round-trip is byte-compatible — no PQClean
// detour needed in the test.
//
// Skip conditions:
// * No plugin loaded -> AutoBackend() == BackendNone
// * Plugin loaded but op_mldsa_verify_batch is a -> rc=NOT_SUPPORTED
// CPU oracle returning NOT_SUPPORTED (e.g. some
// WebGPU phase-2 builds) — surfaces as ErrGPUNotAvailable.
//
// Either skip is a CLEAN exit, not a FAIL. The property under test is
// "ABI v14 wire-format round-trips through the loaded plugin", which
// is only meaningful when the plugin actually runs the verify path.
func TestMLDSAVerifyBatch_RoundTrip(t *testing.T) {
if AutoBackend() == BackendNone {
t.Skip("no GPU plugin loaded — round-trip test requires the dlopen probe to have succeeded at init()")
}
g := ActiveGPUBackend()
t.Logf("backend = %s", g.Backend())
// Generate one ML-DSA-65 keypair + signature via circl. The test
// is single-shot so we don't bother batching beyond N=1; the v14
// vtbl wrapper has the same shape for every batch size.
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("mldsa.GenerateKey: %v", err)
}
pkBytes := priv.PublicKey.Bytes()
if len(pkBytes) != MLDSA65PublicKeySize {
t.Fatalf("pubkey size = %d, want %d", len(pkBytes), MLDSA65PublicKeySize)
}
// 64-byte message — matches what the Q-Chain quantum stamp path
// hands the verifier (sha512 digest of the round digest + nonce +
// timestamp). Using a deterministic byte pattern keeps the test
// reproducible across reruns.
msg := make([]byte, 64)
for i := range msg {
msg[i] = byte(i)
}
// Sign with ctx=nil to match the orchestrator's ctx_len=0 wrap.
sig, err := priv.Sign(rand.Reader, msg, nil)
if err != nil {
t.Fatalf("priv.Sign: %v", err)
}
if got, max := len(sig), MLDSA65SignatureSize; got > max {
t.Fatalf("sig size = %d > max %d", got, max)
}
// Pad the signature out to the fixed 3309-byte width the orchestrator
// reads. Padding bytes after the real sig length are ignored by the
// FIPS 204 verify path (sig parsing terminates at the c̃ + z + h
// structural decode).
sigPadded := make([]byte, MLDSA65SignatureSize)
copy(sigPadded, sig)
results := make([]bool, 1)
err = g.MLDSAVerifyBatch(
MLDSAMode65,
[][]byte{pkBytes},
[][]byte{msg},
[]int{len(msg)},
/*msgWidthHint=*/ 0,
[][]byte{sigPadded},
results,
)
if errors.Is(err, ErrGPUNotAvailable) {
t.Skipf("plugin op_mldsa_verify_batch returned NOT_SUPPORTED (backend=%s) — surface contract honoured, round-trip not testable", g.Backend())
}
if err != nil {
t.Fatalf("MLDSAVerifyBatch: %v", err)
}
if !results[0] {
t.Fatalf("honest verify returned false (backend=%s) — wire-format incompatibility", g.Backend())
}
t.Logf("backend=%s op_mldsa_verify_batch honest verify -> true", g.Backend())
}
// TestMLDSAVerifyBatch_LengthMismatch covers the length-validation
// branch at the boundary. The wrapper must reject batches where the
// per-element slices disagree on cardinality before any C call.
func TestMLDSAVerifyBatch_LengthMismatch(t *testing.T) {
if AutoBackend() == BackendNone {
t.Skip("no GPU plugin loaded")
}
g := ActiveGPUBackend()
err := g.MLDSAVerifyBatch(
MLDSAMode65,
make([][]byte, 2),
make([][]byte, 1),
nil, 0,
make([][]byte, 2),
make([]bool, 2),
)
if err == nil {
t.Fatal("expected length-mismatch error, got nil")
}
}
// TestMLDSAVerifyBatch_NonMLDSA65Skips exercises the mode gate. Modes
// 44 / 87 aren't wired at v14 — passing them must surface as
// ErrGPUNotAvailable so callers fall through cleanly.
func TestMLDSAVerifyBatch_NonMLDSA65Skips(t *testing.T) {
g := ActiveGPUBackend()
for _, mode := range []MLDSAMode{MLDSAMode44, MLDSAMode87} {
err := g.MLDSAVerifyBatch(mode,
[][]byte{make([]byte, 1)},
[][]byte{nil}, nil, 0,
[][]byte{make([]byte, 1)},
make([]bool, 1))
if !errors.Is(err, ErrGPUNotAvailable) {
t.Fatalf("mode %d: got %v, want ErrGPUNotAvailable", mode, err)
}
}
}