mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
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:
+22
-2
@@ -56,9 +56,21 @@ func BatchVerify(pubs []*PublicKey, msgs [][]byte, sigs [][]byte) []bool {
|
||||
}
|
||||
|
||||
// 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 _, 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
|
||||
}
|
||||
}
|
||||
@@ -156,9 +168,17 @@ func BatchSign(randSource io.Reader, privs []*PrivateKey, msgs [][]byte) ([][]by
|
||||
}
|
||||
|
||||
// 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 _, 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
|
||||
}
|
||||
}
|
||||
|
||||
+29
-4
@@ -39,6 +39,8 @@
|
||||
package mldsa
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/luxfi/accel"
|
||||
"github.com/luxfi/crypto/backend"
|
||||
"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).
|
||||
//
|
||||
// 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
|
||||
// - gpuhost has no accel session
|
||||
// - the mode is not wired for GPU dispatch
|
||||
// - the substrate returns NotSupported (no plugin loaded)
|
||||
// - any tensor allocation or kernel dispatch failed (we fall through
|
||||
// silently to CPU; the caller's per-element loop is the safety net)
|
||||
// - tensor allocation failed (OutOfMemory) or kernel dispatch failed
|
||||
//
|
||||
// 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).
|
||||
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
|
||||
// fall back to the legacy DilithiumVerifyBatch for substrates that only
|
||||
// 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())
|
||||
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())
|
||||
}
|
||||
if dispatchErr != nil {
|
||||
if errors.Is(dispatchErr, accel.ErrInvalidArgument) {
|
||||
return false, dispatchErr
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
bytes, err := rT.ToSlice()
|
||||
@@ -257,7 +277,12 @@ func batchSignGPU(privs []*PrivateKey, msgs [][]byte, sigs [][]byte) (bool, erro
|
||||
}
|
||||
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 errors.Is(err, accel.ErrInvalidArgument) {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package gpu
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
@@ -249,7 +250,17 @@ func (e *cgoEngine) signBatch(mode Mode, msgs, sks, sigs [][]byte) error {
|
||||
}
|
||||
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 errors.Is(err, accel.ErrInvalidArgument) {
|
||||
return err
|
||||
}
|
||||
return e.signBatchCPU(mode, msgs, sks, sigs)
|
||||
}
|
||||
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()
|
||||
|
||||
// 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 errors.Is(err, accel.ErrInvalidArgument) {
|
||||
return nil, err
|
||||
}
|
||||
return e.verifyBatchCPU(mode, msgs, sigs, pks)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user