Files
chains/quantumvm/quantumvm_gpu.go
T
zeekay 85440e105d chains: scrub private-repo leakage from GPU bridges
OSS public chains module must not reference the private GPU plugin
implementation by repo name or filesystem path. Two leak types fixed:

1. Hard compile-time dependency in quantumvm:
   `#cgo CFLAGS: -I${SRCDIR}/../../../lux-private/gpu-kernels/include`
   Vendor the public ABI v14 header (backend_plugin.h + crypto.h, 1475
   LOC total) into chains/internal/luxgpu/include/lux/gpu/ and point
   CFLAGS at the in-tree path. Plugin DSO is still dlopen'd at runtime
   — only the public C ABI surface is vendored.

2. Soft leakage — `LUX_PRIVATE_GPU_KERNELS_DIR` env vocabulary, dev-tree
   probe paths to ~/work/lux-private/gpu-kernels/build/..., prose
   mentions of "lux-private/gpu-kernels" in package comments.
   All replaced with `LUX_GPU_PLUGIN_DIR` (universal env) and generic
   "GPU plugin" prose.

Bridges affected: aivm, bridgevm, dexvm, quantumvm, thresholdvm.
All 139 tests PASS post-scrub under both build tags. No functional
behavior change — only path / env / prose.
2026-06-05 15:46:00 -07:00

919 lines
29 KiB
Go

//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package quantumvm GPU backend — runtime-loaded plugin bridge via the
// ABI v14 vtbl entry point.
//
// Unlike cevm/cevm_cgo.go (which links libevm + libevm-gpu via pkg-config
// and reaches the C ABI through extern "C" gpu_* helpers), Q-Chain
// resolves its GPU substrate at PROCESS START via dlopen / dlsym against
// the lux-gpu-kernels plugin DSO and reaches the per-op kernel through
// function pointers inside a vtbl populated by the plugin's
// `lux_gpu_backend_init` entry point. This keeps the chains module
// compilable without the lux GPU plugin present in the build tree —
// the plugin is fully optional. When no libluxgpu_backend_*.{so,dylib}
// is findable on the dlopen search path, AutoBackend() returns
// BackendNone, every GPUBackend method returns ErrGPUNotAvailable, and
// the existing CPU verify path stays unchanged.
//
// Probe order (init()): cuda → hip → metal → vulkan → webgpu. First
// plugin that resolves the entry point AND reports a v14 ABI matching
// the header we compiled against AND exposes the three Q-Chain slots
// (op_mldsa_verify_batch, op_mldsa_sign_batch,
// op_slhdsa_verify_batch[_shake192f]) wins; remaining probes are
// skipped. The cookie checks (abi_version + vtbl_size) defend against
// a plugin that lies about its ABI version while shipping a truncated
// vtable (see backend_plugin.h §"Backend Descriptor" comment).
//
// Search path: the dlopen library name is the bare basename
// (libluxgpu_backend_<x>.{so,dylib}); the loader's standard
// LD_LIBRARY_PATH / DYLD_LIBRARY_PATH / rpath resolution finds it.
// LUX_GPU_PLUGIN_DIR overrides — if set, every probe joins it to the
// basename before calling dlopen.
package quantumvm
/*
#cgo darwin LDFLAGS: -ldl
#cgo linux LDFLAGS: -ldl
// The plugin DSO is dlopen'd at runtime; the only compile-time
// dependency is the C struct layout in backend_plugin.h, which is
// vendored under chains/internal/luxgpu/include/lux/gpu/ as the public
// ABI surface. No link against any external library.
#cgo CFLAGS: -I${SRCDIR}/../internal/luxgpu/include
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "lux/gpu/backend_plugin.h"
// ABI v14 vtbl entry-point thunk. dlsym returns void*; this struct lets
// Go pass the function pointer back through the bridge without losing
// type information at the cgo boundary.
typedef bool (*qvm_backend_init_fn)(lux_gpu_backend_desc* out);
// qvm_backend_init invokes the plugin's `lux_gpu_backend_init` symbol
// and returns 0 on success, non-zero (1..5) on a specific cookie /
// slot failure. The cookies (abi_version + vtbl_size) detect plugins
// built against a different header — fail fast at process start
// instead of UB at the first vtbl slot call (a stale plugin's
// op_mldsa_verify_batch at the wrong offset would be a junk function
// pointer dispatch otherwise).
//
// ret == 0 — desc populated AND all three Q-Chain slots non-NULL.
// ret == 1 — init function returned false (no runtime available on
// this host: missing driver, ICD, etc.). Caller treats
// as SKIP, not FAIL.
// ret == 2 — abi_version mismatch (plugin is from a different ABI
// than the Go bindings were compiled against).
// ret == 3 — vtbl_size mismatch (plugin shipped a truncated vtbl).
// ret == 4 — vtbl pointer is NULL (malformed descriptor).
// ret == 5 — vtbl is missing one of the three Q-Chain slots.
static int qvm_backend_init(void* sym, lux_gpu_backend_desc* desc) {
if (!sym || !desc) return 4;
memset(desc, 0, sizeof(*desc));
qvm_backend_init_fn fn = (qvm_backend_init_fn)sym;
if (!fn(desc)) return 1;
if (desc->abi_version != LUX_GPU_BACKEND_ABI_VERSION) return 2;
if (desc->vtbl_size != sizeof(lux_gpu_backend_vtbl)) return 3;
if (!desc->vtbl) return 4;
if (!desc->vtbl->create_context || !desc->vtbl->destroy_context) return 5;
// ML-DSA verify is the canonical Q-Chain slot. ML-DSA sign and
// SLH-DSA verify may be nullptr on backends that haven't wired
// their orchestrator yet — those methods then return
// LUX_BACKEND_ERROR_NOT_SUPPORTED at call time, which the Go
// wrapper translates to ErrGPUNotAvailable. The plugin is still
// viable if at least op_mldsa_verify_batch is wired (ML-DSA
// verify is the load-bearing Q-Chain op).
if (!desc->vtbl->op_mldsa_verify_batch) return 5;
return 0;
}
// Lifecycle helpers — exported so the Go side can open / close a
// LuxBackendContext per probe-result backend exactly once. device_index
// = 0 is the canonical "first available device" sentinel — backends
// like Metal that don't expose multi-GPU selection assume 0 and would
// throw on -1 (NSRangeException on Apple Silicon, observed).
static LuxBackendContext* qvm_create_context(const lux_gpu_backend_vtbl* vtbl) {
return vtbl->create_context(0);
}
static void qvm_destroy_context(const lux_gpu_backend_vtbl* vtbl, LuxBackendContext* ctx) {
if (ctx) vtbl->destroy_context(ctx);
}
// Per-slot wrappers. The trampolines unpack Go's flat parameter slices
// and dispatch through the vtbl function pointer. Each returns the raw
// LuxBackendError code — the Go side maps NOT_SUPPORTED (3) →
// ErrGPUNotAvailable and other non-zero codes → fmt.Errorf("rc=%d").
// op_mldsa_verify_batch — see backend_plugin.h v14 block. msg_lens is
// optional (NULL → use msg_width_hint for every element).
static int qvm_op_mldsa_verify(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
uint32_t msg_width_hint,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_mldsa_verify_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_mldsa_verify_batch(
ctx, pubkeys, messages, msg_lens,
msg_width_hint, signatures, results, count);
}
// op_mldsa_sign_batch — see backend_plugin.h v14 block. sig_lens_out is
// optional (NULL is fine — caller falls back to per-element verify if
// it needs accept / reject feedback).
static int qvm_op_mldsa_sign(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* skeys,
size_t sk_stride,
const uint8_t* msgs,
const size_t* msg_lens,
uint32_t msg_width_hint,
size_t count,
uint8_t* sigs_out,
uint32_t* sig_lens_out) {
if (!vtbl->op_mldsa_sign_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_mldsa_sign_batch(
ctx, skeys, sk_stride, msgs, msg_lens,
msg_width_hint, count, sigs_out, sig_lens_out);
}
// op_slhdsa_verify_batch — SHAKE-128f. See backend_plugin.h ABI v13.
static int qvm_op_slhdsa_verify_128f(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_slhdsa_verify_batch) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_slhdsa_verify_batch(
ctx, pubkeys, messages, msg_lens, signatures, results, count);
}
// op_slhdsa_verify_batch_shake192f — SHAKE-192f. See backend_plugin.h
// ABI v13.
static int qvm_op_slhdsa_verify_192f(
const lux_gpu_backend_vtbl* vtbl,
LuxBackendContext* ctx,
const uint8_t* const* pubkeys,
const uint8_t* const* messages,
const size_t* msg_lens,
const uint8_t* const* signatures,
bool* results,
size_t count) {
if (!vtbl->op_slhdsa_verify_batch_shake192f) return LUX_BACKEND_ERROR_NOT_SUPPORTED;
return (int)vtbl->op_slhdsa_verify_batch_shake192f(
ctx, pubkeys, messages, msg_lens, signatures, results, count);
}
// dlopen / dlsym wrappers — kept here so backend.go stays pure Go.
static void* qvm_dlopen(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
static void* qvm_dlsym(void* handle, const char* sym) {
return dlsym(handle, sym);
}
static const char* qvm_dlerror(void) {
return dlerror();
}
static void qvm_dlclose(void* handle) {
if (handle) dlclose(handle);
}
// Allocates a lux_gpu_backend_desc the Go side keeps for the lifetime
// of the GPUBackend. Heap-allocated so the Go object's address can be
// shared across cgo calls without escape-analysis surprises.
static lux_gpu_backend_desc* qvm_alloc_desc(void) {
lux_gpu_backend_desc* d = (lux_gpu_backend_desc*)calloc(1, sizeof(*d));
return d;
}
static void qvm_free_desc(lux_gpu_backend_desc* d) {
free(d);
}
*/
import "C"
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"unsafe"
)
// pluginEntrySymbol is the canonical ABI v14 entry point exported by every
// libluxgpu_backend_<x>.{so,dylib} plugin. Resolving this symbol yields a
// function pointer that populates a lux_gpu_backend_desc with abi_version,
// vtbl_size cookies, and the vtbl pointer the rest of this file walks.
const pluginEntrySymbol = "lux_gpu_backend_init"
// pluginEnv overrides the dlopen search path. When set, every probe joins
// the env value to the bare basename before calling dlopen — useful for
// in-tree development against a freshly-built plugin under
// the GPU plugin build tree without installing it.
const pluginEnv = "LUX_GPU_PLUGIN_DIR"
// probeOrder is the dlopen probe sequence fixed by the spec:
// cuda → hip → metal → vulkan → webgpu. First plugin that satisfies the
// cookie checks AND exposes the load-bearing op_mldsa_verify_batch slot
// wins; the remaining probes are skipped. Order is preserved at struct
// definition (not encoded as a map) so the iteration order is the same
// on every platform.
var probeOrder = []struct {
kind Backend
name string // basename without lib prefix or .so/.dylib suffix
}{
{BackendCUDA, "luxgpu_backend_cuda"},
{BackendHIP, "luxgpu_backend_hip"},
{BackendMetal, "luxgpu_backend_metal"},
{BackendVulkan, "luxgpu_backend_vulkan"},
{BackendWebGPU, "luxgpu_backend_webgpu"},
}
// pluginBasenames returns the platform-specific DSO basenames to attempt
// for a given backend. On darwin we try both .dylib (native) and .so
// (cross-tooling fallback). On linux / other we try .so first. The
// order within each entry's slice matches the lookup order used by
// dlopen — first hit wins.
func pluginBasenames(name string) []string {
switch runtime.GOOS {
case "darwin":
return []string{
"lib" + name + ".dylib",
"lib" + name + ".so",
}
case "windows":
return []string{
name + ".dll",
}
default:
return []string{
"lib" + name + ".so",
}
}
}
// =============================================================================
// gpuBackend — the cgo implementation of the GPUBackend interface.
//
// One struct per loaded plugin; pinned at package level by init()'s probe.
// All vtbl invocations route through C trampolines (qvm_op_*) defined in
// the cgo preamble above. mu serialises Close() against concurrent op
// dispatch — every vtbl call increments / decrements an in-flight counter
// so Close() can wait for outstanding calls before destroying the context.
// =============================================================================
type gpuBackend struct {
mu sync.Mutex
kind Backend
handle unsafe.Pointer // dlopen result (void*)
desc *C.lux_gpu_backend_desc // heap-allocated descriptor
ctx *C.LuxBackendContext // backend-managed opaque context
path string
}
// init runs the dlopen probe exactly once at process start. The probe
// walks probeOrder, attempts each candidate basename, and stops at the
// first plugin that:
//
// 1. Successfully dlopens.
// 2. Exposes the lux_gpu_backend_init symbol.
// 3. Reports abi_version == LUX_GPU_BACKEND_ABI_VERSION (14).
// 4. Reports vtbl_size == sizeof(lux_gpu_backend_vtbl) on our header.
// 5. Has op_mldsa_verify_batch wired (the load-bearing Q-Chain slot).
// 6. Successfully opens a LuxBackendContext via vtbl->create_context.
//
// On success the package-level activeBackend atomic is set to the
// matching Backend enum value. Failure at any stage is silent — we move
// to the next candidate. If no candidate succeeds, activeBackend stays
// BackendNone and ActiveGPUBackend() returns a noGPUBackend stub.
func init() {
if b, ok := tryProbe(); ok {
setActiveBackend(b.kind)
setActiveGPU(b)
return
}
setActiveBackend(BackendNone)
setActiveGPU(nil)
}
// tryProbe runs the probeOrder dlopen loop. Returns the loaded gpuBackend
// on success, or (nil, false) if no candidate satisfied all six checks.
// Errors are deliberately not surfaced — this is a probe, not a hard
// requirement. Callers see BackendNone and route to the CPU path.
func tryProbe() (*gpuBackend, bool) {
dir := os.Getenv(pluginEnv)
for _, p := range probeOrder {
for _, bn := range pluginBasenames(p.name) {
path := bn
if dir != "" {
path = filepath.Join(dir, bn)
}
b, err := openPlugin(p.kind, path)
if err == nil {
return b, true
}
}
}
return nil, false
}
// openPlugin attempts to dlopen `path`, resolve lux_gpu_backend_init,
// run the cookie checks, and create a backend context. Returns the
// loaded gpuBackend on success, or (nil, err) with the offending stage
// documented in the error. The dlopen handle is closed on any failure
// past the dlopen point — we never leak a half-bound plugin.
func openPlugin(kind Backend, path string) (*gpuBackend, error) {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
// Clear pending dlerror so a stale message from a prior failed call
// doesn't get mis-attributed to this dlopen.
C.qvm_dlerror()
handle := C.qvm_dlopen(cpath)
if handle == nil {
return nil, fmt.Errorf("quantumvm: dlopen(%s): %s",
path, C.GoString(C.qvm_dlerror()))
}
csym := C.CString(pluginEntrySymbol)
defer C.free(unsafe.Pointer(csym))
C.qvm_dlerror()
sym := C.qvm_dlsym(handle, csym)
if sym == nil {
C.qvm_dlclose(handle)
return nil, fmt.Errorf("quantumvm: dlsym(%s, %s): %s",
path, pluginEntrySymbol, C.GoString(C.qvm_dlerror()))
}
desc := C.qvm_alloc_desc()
if desc == nil {
C.qvm_dlclose(handle)
return nil, fmt.Errorf("quantumvm: calloc(lux_gpu_backend_desc) failed")
}
if rc := C.qvm_backend_init(sym, desc); rc != 0 {
C.qvm_free_desc(desc)
C.qvm_dlclose(handle)
return nil, fmt.Errorf(
"quantumvm: %s plugin init failed (rc=%d, path=%s)",
kind, int(rc), path)
}
ctx := C.qvm_create_context(desc.vtbl)
if ctx == nil {
C.qvm_free_desc(desc)
C.qvm_dlclose(handle)
return nil, fmt.Errorf(
"quantumvm: %s create_context returned NULL (no runtime on this host?)",
kind)
}
return &gpuBackend{
kind: kind,
handle: handle,
desc: desc,
ctx: ctx,
path: path,
}, nil
}
// =============================================================================
// Active-backend handle plumbing.
//
// activeGPU stores the *gpuBackend resolved by init()'s probe (or nil
// when no plugin loaded). Read via ActiveGPUBackend() — which returns
// the GPUBackend interface (gpuBackend implements it). The pointer is
// pinned at init time and never mutated thereafter; we still serialise
// access through a Mutex for safety against a future caller that might
// add an explicit re-probe path.
// =============================================================================
var (
activeGPUMu sync.RWMutex
activeGPU *gpuBackend
)
func setActiveGPU(b *gpuBackend) {
activeGPUMu.Lock()
activeGPU = b
activeGPUMu.Unlock()
}
// ActiveGPUBackend returns the package-level GPUBackend handle. Under
// cgo this is the *gpuBackend chosen by init()'s probe, or a sentinel
// noGPUBackend stub when no plugin loaded. The returned handle's
// methods are safe to call from any goroutine — every vtbl call
// holds the backend mutex.
func ActiveGPUBackend() GPUBackend {
activeGPUMu.RLock()
b := activeGPU
activeGPUMu.RUnlock()
if b == nil {
return noGPUBackend{}
}
return b
}
// noGPUBackend is the cgo build's stub for the "no plugin loaded" case.
// Distinct from the !cgo noGPUBackend so the !cgo file can ship without
// any cgo machinery; same method semantics.
type noGPUBackend struct{}
func (noGPUBackend) Backend() Backend { return BackendNone }
func (noGPUBackend) Close() error { return nil }
func (noGPUBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
_ = mode
_ = messages
_ = msgLens
_ = msgWidthHint
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
_ = mode
_ = skeys
_ = msgs
_ = msgLens
_ = msgWidthHint
_ = sigsOut
_ = sigLensOut
if count == 0 {
return nil
}
return ErrGPUNotAvailable
}
func (noGPUBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
_ = variant
_ = messages
_ = msgLens
_ = signatures
_ = results
if len(pubkeys) == 0 {
return nil
}
return ErrGPUNotAvailable
}
// =============================================================================
// gpuBackend method implementations.
// =============================================================================
func (b *gpuBackend) Backend() Backend {
if b == nil {
return BackendNone
}
return b.kind
}
// Close destroys the LuxBackendContext, dlcloses the plugin, and frees
// the descriptor. Safe on a nil receiver and idempotent — a second call
// is a no-op once the handle has been cleared.
func (b *gpuBackend) Close() error {
if b == nil {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.handle == nil {
return nil
}
if b.ctx != nil {
C.qvm_destroy_context(b.desc.vtbl, b.ctx)
b.ctx = nil
}
if b.desc != nil {
C.qvm_free_desc(b.desc)
b.desc = nil
}
C.qvm_dlclose(b.handle)
b.handle = nil
return nil
}
// MLDSAVerifyBatch dispatches op_mldsa_verify_batch from the loaded
// vtbl. See the GPUBackend interface doc + backend_plugin.h v14 block
// for the contract.
func (b *gpuBackend) MLDSAVerifyBatch(
mode MLDSAMode,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
msgWidthHint uint32,
signatures [][]byte,
results []bool,
) error {
if mode != MLDSAMode65 {
// v14 vtbl only exposes ML-DSA-65 at op_mldsa_verify_batch.
// Modes 44 / 87 will land at sibling slots in a future ABI
// bump (see backend_plugin.h v14 block).
return ErrGPUNotAvailable
}
n := len(pubkeys)
if n == 0 {
return nil
}
if len(messages) != n || len(signatures) != n || len(results) != n {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch: length mismatch (pubkeys=%d messages=%d signatures=%d results=%d)",
n, len(messages), len(signatures), len(results))
}
if msgLens != nil && len(msgLens) != n {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch: msgLens length %d != batch %d",
len(msgLens), n)
}
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
b.mu.Lock()
defer b.mu.Unlock()
// Pin every Go-side byte slice for the duration of the C call.
// runtime.Pinner guarantees the GC won't move (or collect) the
// backing arrays while the kernel reads them.
var pinner runtime.Pinner
defer pinner.Unpin()
pkPtrs := make([]*C.uint8_t, n)
msgPtrs := make([]*C.uint8_t, n)
sigPtrs := make([]*C.uint8_t, n)
var lensBuf []C.size_t
for i := 0; i < n; i++ {
if len(pubkeys[i]) != MLDSA65PublicKeySize {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: pubkey len %d != %d",
i, len(pubkeys[i]), MLDSA65PublicKeySize)
}
if len(signatures[i]) > MLDSA65SignatureSize {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: signature len %d > max %d",
i, len(signatures[i]), MLDSA65SignatureSize)
}
pinner.Pin(&pubkeys[i][0])
pkPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&pubkeys[i][0]))
// Allow zero-length message (legal — empty-message verify is
// a defined SHAKE256 absorb-zero operation per FIPS 204).
if len(messages[i]) > 0 {
pinner.Pin(&messages[i][0])
msgPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&messages[i][0]))
}
pinner.Pin(&signatures[i][0])
sigPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&signatures[i][0]))
}
if msgLens != nil {
lensBuf = make([]C.size_t, n)
for i := 0; i < n; i++ {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: MLDSAVerifyBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
lensBuf[i] = C.size_t(msgLens[i])
}
}
// bool layout in cgo: C.bool is one byte. Stage results in a
// scratch buffer; copy back to the caller's []bool after the call.
cresults := make([]C.bool, n)
pkPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&pkPtrs[0]))
msgPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&msgPtrs[0]))
sigPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&sigPtrs[0]))
var lensPtr *C.size_t
if lensBuf != nil {
lensPtr = (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
}
rc := C.qvm_op_mldsa_verify(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
C.uint32_t(msgWidthHint),
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
runtime.KeepAlive(pkPtrs)
runtime.KeepAlive(msgPtrs)
runtime.KeepAlive(sigPtrs)
runtime.KeepAlive(lensBuf)
runtime.KeepAlive(cresults)
if rc != 0 {
return mapBackendError(int(rc), "op_mldsa_verify_batch")
}
for i := 0; i < n; i++ {
results[i] = bool(cresults[i])
}
return nil
}
// MLDSASignBatch dispatches op_mldsa_sign_batch from the loaded vtbl.
// See the GPUBackend interface doc + backend_plugin.h v14 block.
func (b *gpuBackend) MLDSASignBatch(
mode MLDSAMode,
skeys []byte,
msgs []byte,
msgLens []int,
msgWidthHint uint32,
count int,
sigsOut []byte,
sigLensOut []uint32,
) error {
if mode != MLDSAMode65 {
return ErrGPUNotAvailable
}
if count == 0 {
return nil
}
const skStride = MLDSA65SecretKeySize
const sigStride = MLDSA65SignatureSize
if len(skeys) != count*skStride {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: skeys len %d != count*%d (%d)",
len(skeys), skStride, count*skStride)
}
if len(sigsOut) != count*sigStride {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: sigsOut len %d != count*%d (%d)",
len(sigsOut), sigStride, count*sigStride)
}
if msgLens != nil && len(msgLens) != count {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: msgLens length %d != count %d",
len(msgLens), count)
}
if sigLensOut != nil && len(sigLensOut) != count {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: sigLensOut length %d != count %d",
len(sigLensOut), count)
}
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
// Validate the msgs pool is large enough to cover the per-element
// lengths. NULL msgLens uses msgWidthHint uniformly — same check
// against count * msgWidthHint.
var total int
if msgLens != nil {
for i := 0; i < count; i++ {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: MLDSASignBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
total += msgLens[i]
}
} else {
total = int(msgWidthHint) * count
}
if len(msgs) < total {
return fmt.Errorf(
"quantumvm: MLDSASignBatch: msgs pool len %d < total %d",
len(msgs), total)
}
b.mu.Lock()
defer b.mu.Unlock()
var pinner runtime.Pinner
defer pinner.Unpin()
pinner.Pin(&skeys[0])
if total > 0 {
pinner.Pin(&msgs[0])
}
pinner.Pin(&sigsOut[0])
var lensBuf []C.size_t
var lensPtr *C.size_t
if msgLens != nil {
lensBuf = make([]C.size_t, count)
for i := 0; i < count; i++ {
lensBuf[i] = C.size_t(msgLens[i])
}
lensPtr = (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
}
var sigLensPtr *C.uint32_t
if sigLensOut != nil {
sigLensPtr = (*C.uint32_t)(unsafe.Pointer(&sigLensOut[0]))
}
var msgsPtr *C.uint8_t
if total > 0 {
msgsPtr = (*C.uint8_t)(unsafe.Pointer(&msgs[0]))
}
rc := C.qvm_op_mldsa_sign(
b.desc.vtbl,
b.ctx,
(*C.uint8_t)(unsafe.Pointer(&skeys[0])),
C.size_t(skStride),
msgsPtr,
lensPtr,
C.uint32_t(msgWidthHint),
C.size_t(count),
(*C.uint8_t)(unsafe.Pointer(&sigsOut[0])),
sigLensPtr,
)
runtime.KeepAlive(lensBuf)
if rc != 0 {
return mapBackendError(int(rc), "op_mldsa_sign_batch")
}
return nil
}
// SLHDSAVerifyBatch dispatches the SHAKE-128f or SHAKE-192f variant of
// op_slhdsa_verify_batch from the loaded vtbl. The 10 other FIPS 205
// variants are not exposed at v14 — they return ErrGPUNotAvailable.
func (b *gpuBackend) SLHDSAVerifyBatch(
variant SLHDSAVariant,
pubkeys [][]byte,
messages [][]byte,
msgLens []int,
signatures [][]byte,
results []bool,
) error {
is128f := variant == SLHDSAShake128f
is192f := variant == SLHDSAShake192f
if !is128f && !is192f {
return ErrGPUNotAvailable
}
n := len(pubkeys)
if n == 0 {
return nil
}
if len(messages) != n || len(signatures) != n || len(results) != n {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch: length mismatch (pubkeys=%d messages=%d signatures=%d results=%d)",
n, len(messages), len(signatures), len(results))
}
if msgLens != nil && len(msgLens) != n {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch: msgLens length %d != batch %d",
len(msgLens), n)
}
pkSize := variant.PublicKeySize()
sigSize := variant.SignatureSize()
if b == nil || b.ctx == nil || b.desc == nil {
return ErrGPUNotAvailable
}
b.mu.Lock()
defer b.mu.Unlock()
var pinner runtime.Pinner
defer pinner.Unpin()
pkPtrs := make([]*C.uint8_t, n)
msgPtrs := make([]*C.uint8_t, n)
sigPtrs := make([]*C.uint8_t, n)
lensBuf := make([]C.size_t, n)
for i := 0; i < n; i++ {
if len(pubkeys[i]) != pkSize {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: pubkey len %d != %d",
i, len(pubkeys[i]), pkSize)
}
if len(signatures[i]) != sigSize {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: signature len %d != %d",
i, len(signatures[i]), sigSize)
}
pinner.Pin(&pubkeys[i][0])
pkPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&pubkeys[i][0]))
if len(messages[i]) > 0 {
pinner.Pin(&messages[i][0])
msgPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&messages[i][0]))
}
pinner.Pin(&signatures[i][0])
sigPtrs[i] = (*C.uint8_t)(unsafe.Pointer(&signatures[i][0]))
// SLH-DSA has no msg_width_hint surface — msg_lens is
// mandatory at the vtbl boundary. Fill lensBuf from either
// msgLens or the message slice length.
if msgLens != nil {
if msgLens[i] < 0 {
return fmt.Errorf(
"quantumvm: SLHDSAVerifyBatch[%d]: negative msgLen %d",
i, msgLens[i])
}
lensBuf[i] = C.size_t(msgLens[i])
} else {
lensBuf[i] = C.size_t(len(messages[i]))
}
}
cresults := make([]C.bool, n)
pkPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&pkPtrs[0]))
msgPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&msgPtrs[0]))
sigPtrsPtr := (**C.uint8_t)(unsafe.Pointer(&sigPtrs[0]))
lensPtr := (*C.size_t)(unsafe.Pointer(&lensBuf[0]))
var rc C.int
if is128f {
rc = C.qvm_op_slhdsa_verify_128f(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
} else {
rc = C.qvm_op_slhdsa_verify_192f(
b.desc.vtbl,
b.ctx,
pkPtrsPtr,
msgPtrsPtr,
lensPtr,
sigPtrsPtr,
(*C.bool)(unsafe.Pointer(&cresults[0])),
C.size_t(n),
)
}
runtime.KeepAlive(pkPtrs)
runtime.KeepAlive(msgPtrs)
runtime.KeepAlive(sigPtrs)
runtime.KeepAlive(lensBuf)
runtime.KeepAlive(cresults)
if rc != 0 {
return mapBackendError(int(rc), "op_slhdsa_verify_batch")
}
for i := 0; i < n; i++ {
results[i] = bool(cresults[i])
}
return nil
}
// mapBackendError translates a LuxBackendError code to a Go error.
// NOT_SUPPORTED (3) maps to the package sentinel so callers can route
// to the CPU oracle via errors.Is. Other non-zero codes return a
// formatted error including the numeric code for diagnostics.
func mapBackendError(rc int, op string) error {
switch rc {
case 0:
return nil
case int(C.LUX_BACKEND_ERROR_NOT_SUPPORTED):
return ErrGPUNotAvailable
case int(C.LUX_BACKEND_ERROR_INVALID_ARGUMENT):
return fmt.Errorf("quantumvm: %s: invalid argument (rc=1)", op)
case int(C.LUX_BACKEND_ERROR_OUT_OF_MEMORY):
return fmt.Errorf("quantumvm: %s: out of memory (rc=2)", op)
case int(C.LUX_BACKEND_ERROR_DEVICE_LOST):
return fmt.Errorf("quantumvm: %s: device lost (rc=4)", op)
case int(C.LUX_BACKEND_ERROR_INTERNAL):
return fmt.Errorf("quantumvm: %s: internal error (rc=5)", op)
default:
return fmt.Errorf("quantumvm: %s: rc=%d", op, rc)
}
}