mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
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.
108 lines
4.0 KiB
Go
108 lines
4.0 KiB
Go
// 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)
|
|
}
|
|
}
|
|
}
|