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.
|
// 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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user