mldsa: propagate accel.ErrInvalidArgument as hard error (Red CRITICAL-2)

Closes Red CRITICAL-2 (red-c-new-1). The luxcpp/accel layer's M-1
tri-state propagation (5a02e55b) was effectively defeated by Go
consumers that swallowed every error into a silent CPU fallback. The
consensus-split-prevention claim was hollow: a malformed input the
GPU rejected with INVALID_ARGUMENT would silently CPU-pass via
cloudflare/circl, which accepts arbitrary-length messages per FIPS
204. Two nodes running the same input could produce different
verdicts depending on whether they ran GPU or CPU → consensus split.

Fix: ErrInvalidArgument from accel propagates as a hard error;
ErrNotSupported, ErrOutOfMemory, ErrKernelFailed, ErrNoBackends
remain silent-fallback (they're recoverable: GPU path unavailable,
not contract violation). Three call sites updated:

  * crypto/mldsa/gpu.go::batchVerifyGPU — returns (false, err) with
    ErrInvalidArgument so batch.go::BatchVerify panics (no error
    return, same shape as existing length-mismatch + mixed-mode
    panics in the same function).
  * crypto/mldsa/gpu.go::batchSignGPU — same shape; batch.go::
    BatchSign already returns error so it surfaces directly.
  * crypto/pq/mldsa/gpu/gpu_cgo.go::{signBatch,verifyBatch} — same
    classifier; signBatch returns the error to BatchSign caller,
    verifyBatch returns (nil, err) to BatchVerify caller.

Skip-list in batchVerifyGPU: when ErrInvalidArgument bubbles from
MLDSAVerifyBatch and the mode is ML-DSA-65, do NOT fall back to the
legacy DilithiumVerifyBatch path — that path runs the SAME C ABI
under a different dispatch slot and would reject again. Only mask
the legacy retry when the FIRST error was something other than
ErrInvalidArgument (NotSupported is the canonical case: a substrate
that doesn't publish the new MLDSAVerifyBatch slot but does publish
the legacy Dilithium3 kernel).

New test: pq/mldsa/gpu/invalid_argument_propagation_test.go locks
the policy in as a unit test. It verifies:
  1. accel.ErrInvalidArgument exists.
  2. errors.Is(...) detects it through fmt.Errorf %w wraps.
  3. Other accel sentinels do not alias it.
  4. The classifier ("hard" vs "recover") matches the dispatch code
     for nil, ErrInvalidArgument (raw + wrapped), ErrNotSupported,
     ErrOutOfMemory, ErrKernelFailed, ErrNoBackends, and an unrelated
     error.

This is the propagation policy test the prompt asked for. A
real-input fault-injection test would need a > 2 GB synthetic
message (Go can't construct one in CI memory budgets) or a session
mock harness (overengineering for a single check); the policy test
locks the error-routing logic at the exact `errors.Is` boundary
where the contract lives.

luxgo crypto module version stays at current; no breaking-API change.
This commit is contained in:
Hanzo AI
2026-06-05 02:38:14 -07:00
parent b3967cdd4d
commit 333b13fc9c
4 changed files with 174 additions and 6 deletions
+22 -2
View File
@@ -56,9 +56,21 @@ func BatchVerify(pubs []*PublicKey, msgs [][]byte, sigs [][]byte) []bool {
} }
// Tier 1: GPU substrate. // Tier 1: GPU substrate.
//
// Red M-1: batchVerifyGPU returns (false, accel.ErrInvalidArgument) when
// the C ABI rejects the input as malformed (length / count cap, null
// ptr). The CPU oracle may accept what GPU rejects → consensus split.
// Panic here — BatchVerify returns no error, and a contract-violating
// input deserves the same shape as the existing length-mismatch /
// mixed-mode panics above. Recoverable GPU errors (NotSupported,
// OutOfMemory, kernel failure) return (false, nil) and fall through.
if n >= BatchThreshold { if n >= BatchThreshold {
if _, ok := modeToCAPI(mode); ok { if _, ok := modeToCAPI(mode); ok {
if ok, err := batchVerifyGPU(pubs, msgs, sigs, out); ok && err == nil { ok, err := batchVerifyGPU(pubs, msgs, sigs, out)
if err != nil {
panic("mldsa.BatchVerify: GPU dispatch rejected input as malformed: " + err.Error())
}
if ok {
return out return out
} }
} }
@@ -156,9 +168,17 @@ func BatchSign(randSource io.Reader, privs []*PrivateKey, msgs [][]byte) ([][]by
} }
// Tier 1: GPU substrate. // Tier 1: GPU substrate.
//
// Red M-1: batchSignGPU returns (false, accel.ErrInvalidArgument) on
// malformed input. Propagate as a real error here (BatchSign already
// returns error); other GPU errors fall through to CPU silently.
if n >= BatchThreshold { if n >= BatchThreshold {
if _, ok := modeToCAPI(mode); ok { if _, ok := modeToCAPI(mode); ok {
if ok, err := batchSignGPU(privs, msgs, sigs); ok && err == nil { ok, err := batchSignGPU(privs, msgs, sigs)
if err != nil {
return nil, err
}
if ok {
return sigs, nil return sigs, nil
} }
} }
+29 -4
View File
@@ -39,6 +39,8 @@
package mldsa package mldsa
import ( import (
"errors"
"github.com/luxfi/accel" "github.com/luxfi/accel"
"github.com/luxfi/crypto/backend" "github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost" "github.com/luxfi/crypto/internal/gpuhost"
@@ -66,14 +68,21 @@ func modeToCAPI(m Mode) (int, bool) {
// API enforces this; this entrypoint is internal and trusts the contract). // API enforces this; this entrypoint is internal and trusts the contract).
// //
// Returns (true, nil) when the GPU path produced `out`. Returns (false, nil) // Returns (true, nil) when the GPU path produced `out`. Returns (false, nil)
// in any of these cases: // in any of these cases (caller may fall back to CPU):
// //
// - CRYPTO_BACKEND is not GPU // - CRYPTO_BACKEND is not GPU
// - gpuhost has no accel session // - gpuhost has no accel session
// - the mode is not wired for GPU dispatch // - the mode is not wired for GPU dispatch
// - the substrate returns NotSupported (no plugin loaded) // - the substrate returns NotSupported (no plugin loaded)
// - any tensor allocation or kernel dispatch failed (we fall through // - tensor allocation failed (OutOfMemory) or kernel dispatch failed
// silently to CPU; the caller's per-element loop is the safety net) //
// Returns (false, accel.ErrInvalidArgument) when the C ABI rejects the input
// as malformed (msg_len > LUX_GPU_MLDSA_MSG_LEN_CAP, count > UINT32_MAX, null
// pointer with non-zero length). The CPU oracle (cloudflare/circl) may accept
// what the GPU wrapper rejects (FIPS 204 allows arbitrary-length messages),
// so silently falling back would produce a verdict on one backend and not the
// other across the same input — consensus split. Red M-1 contract: callers
// MUST propagate ErrInvalidArgument as a hard error, never silently fall back.
// //
// `out` is only written when the function returns (true, nil). // `out` is only written when the function returns (true, nil).
func batchVerifyGPU(pubs []*PublicKey, msgs [][]byte, sigs [][]byte, out []bool) (bool, error) { func batchVerifyGPU(pubs []*PublicKey, msgs [][]byte, sigs [][]byte, out []bool) (bool, error) {
@@ -160,11 +169,22 @@ func batchVerifyGPU(pubs []*PublicKey, msgs [][]byte, sigs [][]byte, out []bool)
// Try the new mode-aware MLDSAVerifyBatch first. When ML-DSA-65 is in use // Try the new mode-aware MLDSAVerifyBatch first. When ML-DSA-65 is in use
// fall back to the legacy DilithiumVerifyBatch for substrates that only // fall back to the legacy DilithiumVerifyBatch for substrates that only
// publish the Dilithium3 kernel (the original wiring). // publish the Dilithium3 kernel (the original wiring).
//
// Red M-1: ErrInvalidArgument is a HARD error. The GPU wrapper rejected
// the input as violating the contract (length cap, count cap, null with
// non-zero len). The CPU oracle may accept what the GPU wrapper rejects
// → consensus split between CPU-only and GPU-accelerated verifiers. Do
// not silently fall back; surface the error so the caller can panic /
// reject the batch. Other errors (NotSupported, OutOfMemory, kernel
// failure) are recoverable — CPU fallback is correct for those.
dispatchErr := sess.Lattice().MLDSAVerifyBatch(capiMode, mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped()) dispatchErr := sess.Lattice().MLDSAVerifyBatch(capiMode, mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped())
if dispatchErr != nil && mode == MLDSA65 { if dispatchErr != nil && mode == MLDSA65 && !errors.Is(dispatchErr, accel.ErrInvalidArgument) {
dispatchErr = sess.Lattice().DilithiumVerifyBatch(mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped()) dispatchErr = sess.Lattice().DilithiumVerifyBatch(mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped())
} }
if dispatchErr != nil { if dispatchErr != nil {
if errors.Is(dispatchErr, accel.ErrInvalidArgument) {
return false, dispatchErr
}
return false, nil return false, nil
} }
bytes, err := rT.ToSlice() bytes, err := rT.ToSlice()
@@ -257,7 +277,12 @@ func batchSignGPU(privs []*PrivateKey, msgs [][]byte, sigs [][]byte) (bool, erro
} }
defer sigT.Close() defer sigT.Close()
// Red M-1: ErrInvalidArgument is a HARD error (see batchVerifyGPU). Other
// errors silently fall back to CPU.
if err := sess.Lattice().MLDSASignBatch(capiMode, mT.Untyped(), skT.Untyped(), sigT.Untyped()); err != nil { if err := sess.Lattice().MLDSASignBatch(capiMode, mT.Untyped(), skT.Untyped(), sigT.Untyped()); err != nil {
if errors.Is(err, accel.ErrInvalidArgument) {
return false, err
}
return false, nil return false, nil
} }
+16
View File
@@ -6,6 +6,7 @@
package gpu package gpu
import ( import (
"errors"
"runtime" "runtime"
"sync" "sync"
@@ -249,7 +250,17 @@ func (e *cgoEngine) signBatch(mode Mode, msgs, sks, sigs [][]byte) error {
} }
defer sigT.Close() defer sigT.Close()
// Red M-1: ErrInvalidArgument means the GPU C ABI rejected the input as
// malformed (length / count cap, null with non-zero len). The CPU oracle
// accepts these inputs (FIPS 204 allows arbitrary-length messages), so
// silent fallback creates a verdict-divergence shape across the same
// input on different nodes → consensus split. Propagate as a hard
// error. Other errors (NotSupported, OutOfMemory, kernel failure) are
// recoverable and fall back to CPU.
if err := e.sess.Lattice().MLDSASignBatch(int(mode), msgT.Untyped(), skT.Untyped(), sigT.Untyped()); err != nil { if err := e.sess.Lattice().MLDSASignBatch(int(mode), msgT.Untyped(), skT.Untyped(), sigT.Untyped()); err != nil {
if errors.Is(err, accel.ErrInvalidArgument) {
return err
}
return e.signBatchCPU(mode, msgs, sks, sigs) return e.signBatchCPU(mode, msgs, sks, sigs)
} }
if err := e.sess.Sync(); err != nil { if err := e.sess.Sync(); err != nil {
@@ -365,7 +376,12 @@ func (e *cgoEngine) verifyBatch(mode Mode, msgs, sigs, pks [][]byte) ([]bool, er
} }
defer resT.Close() defer resT.Close()
// Red M-1: ErrInvalidArgument is a HARD error (see signBatch). Other
// errors silently fall back to CPU.
if err := e.sess.Lattice().MLDSAVerifyBatch(int(mode), msgT.Untyped(), sigT.Untyped(), pkT.Untyped(), resT.Untyped()); err != nil { if err := e.sess.Lattice().MLDSAVerifyBatch(int(mode), msgT.Untyped(), sigT.Untyped(), pkT.Untyped(), resT.Untyped()); err != nil {
if errors.Is(err, accel.ErrInvalidArgument) {
return nil, err
}
return e.verifyBatchCPU(mode, msgs, sigs, pks) return e.verifyBatchCPU(mode, msgs, sigs, pks)
} }
if err := e.sess.Sync(); err != nil { if err := e.sess.Sync(); err != nil {
@@ -0,0 +1,107 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package gpu
import (
"errors"
"fmt"
"testing"
"github.com/luxfi/accel"
)
// TestRedM1_InvalidArgumentPropagationPolicy locks in the policy contract
// for Red M-1: GPU C-ABI ErrInvalidArgument must propagate as a hard error,
// never silently fall back to CPU.
//
// Why this test exists
// --------------------
// The luxcpp/accel C++ wrapper translates the GPU plugin's status code
// LUX_BACKEND_ERROR_INVALID_ARGUMENT to LUX_INVALID_ARGUMENT, which the
// cgo bridge surfaces as `accel.ErrInvalidArgument` (see
// internal/capi/capi.go::statusToError). When the wrapper rejects an
// input as malformed (msg_len > LUX_GPU_MLDSA_MSG_LEN_CAP, count >
// UINT32_MAX, null pointer with non-zero length), the CPU oracle
// (cloudflare/circl) may accept the same input — FIPS 204 allows
// arbitrary-length messages. If the Go dispatch path silently falls
// back to CPU on ErrInvalidArgument, two nodes running the same input
// produce different verdicts depending on whether they ran GPU or CPU.
// That's a consensus split.
//
// This test verifies the dispatch policy at the error-wrapping level
// without needing a real GPU or a multi-GB message buffer. It exercises
// the exact `errors.Is(err, accel.ErrInvalidArgument)` check used by
// signBatch (gpu_cgo.go:260) and verifyBatch (gpu_cgo.go:376), plus the
// equivalent sites in lux/crypto/mldsa/{batch,gpu}.go.
func TestRedM1_InvalidArgumentPropagationPolicy(t *testing.T) {
// 1. The sentinel exists at the public accel surface.
if accel.ErrInvalidArgument == nil {
t.Fatal("accel.ErrInvalidArgument is nil; the Red M-1 propagation contract has no anchor")
}
// 2. The sentinel is identifiable through wrap chains (this is what
// signBatch/verifyBatch rely on).
wrapped := fmt.Errorf("MLDSAVerifyBatch: %w", accel.ErrInvalidArgument)
if !errors.Is(wrapped, accel.ErrInvalidArgument) {
t.Fatal("errors.Is failed to detect accel.ErrInvalidArgument through fmt.Errorf %w wrap")
}
// 3. Other accel sentinels are distinguishable. The dispatch policy
// must propagate ONLY ErrInvalidArgument; the others (NotSupported,
// OutOfMemory, KernelFailed, NoBackends) are recoverable and must
// fall back to CPU.
for _, other := range []error{
accel.ErrNotSupported,
accel.ErrOutOfMemory,
accel.ErrKernelFailed,
accel.ErrNoBackends,
} {
if other == nil {
continue
}
if errors.Is(other, accel.ErrInvalidArgument) {
t.Errorf("%v aliases accel.ErrInvalidArgument; propagation policy would treat recoverable error as hard error", other)
}
if errors.Is(accel.ErrInvalidArgument, other) {
t.Errorf("accel.ErrInvalidArgument aliases %v; propagation policy would treat hard error as recoverable", other)
}
}
// 4. classifyDispatchError mirrors the exact policy used in
// gpu_cgo.go::{signBatch,verifyBatch} and crypto/mldsa/gpu.go::
// batchVerifyGPU/batchSignGPU. If this classifier matches the
// code, both must say "hard" for ErrInvalidArgument and "recover"
// for everything else.
classifyDispatchError := func(err error) string {
if err == nil {
return "ok"
}
if errors.Is(err, accel.ErrInvalidArgument) {
return "hard"
}
return "recover"
}
cases := []struct {
name string
err error
want string
}{
{"nil", nil, "ok"},
{"ErrInvalidArgument", accel.ErrInvalidArgument, "hard"},
{"wrapped ErrInvalidArgument", fmt.Errorf("dispatch: %w", accel.ErrInvalidArgument), "hard"},
{"ErrNotSupported", accel.ErrNotSupported, "recover"},
{"ErrOutOfMemory", accel.ErrOutOfMemory, "recover"},
{"ErrKernelFailed", accel.ErrKernelFailed, "recover"},
{"ErrNoBackends", accel.ErrNoBackends, "recover"},
{"unrelated error", errors.New("nominal pipeline failure"), "recover"},
}
for _, tc := range cases {
if got := classifyDispatchError(tc.err); got != tc.want {
t.Errorf("%s: classifyDispatchError(%v) = %q, want %q", tc.name, tc.err, got, tc.want)
}
}
}