Files
Hanzo AI 333b13fc9c 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.
2026-06-05 02:38:14 -07:00

301 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// ML-DSA / Dilithium (FIPS 204) GPU dispatch.
//
// Dispatch path:
//
// backend.IsGPU()
// ⇒ Resolved() picks the GPU substrate after probing CGo + GPU availability
// ⇒ accel.LatticeOps.MLDSAVerifyBatch / MLDSASignBatch on the shared session
// ⇒ luxcpp/lux-accel C API lux_mldsa_{verify,sign}_batch (under development)
// ⇒ when a backend plugin registers a strong substrate:
// luxcpp/crypto/mldsa/gpu/{metal,cuda}/ kernel substrate
// ⇒ otherwise the substrate returns NotSupported and we fall through to
// per-element CPU verify (cloudflare/circl, FIPS 204-conformant).
//
// Mode dispatch:
//
// ML-DSA-44 → C-API mode=2 (NIST L2, devnet, compat tier)
// ML-DSA-65 → C-API mode=3 (NIST L3, canonical strict-PQ)
// ML-DSA-87 → C-API mode=5 (NIST L5, high-value tier)
//
// Each mode has its own (pk_size, sig_size, sk_size) per FIPS 204:
//
// 44 : pk=1312 sk=2560 sig=2420
// 65 : pk=1952 sk=4032 sig=3309
// 87 : pk=2592 sk=4896 sig=4627
//
// The kernel substrate uses NTT-resident batch verify pattern: a single
// NTT(t1) batch lift of the public-key matrix lattice followed by per-
// signature challenge polynomial samples (FIPS 204 §6.3 Verify steps 7-10).
// The intra-batch parallelism is along the n × (msg, sig, pk) dimension;
// each lane runs its own t1·NTT product without cross-lane data hazards.
//
// Equivalence: FIPS 204 verify is deterministic — given identical (msg, sig,
// pk) the substrate and circl reference produce byte-equal accept/reject
// decisions. Asserted by TestMLDSABatchEquivalence_CPU_GPU_{44,65,87}.
package mldsa
import (
"errors"
"github.com/luxfi/accel"
"github.com/luxfi/crypto/backend"
"github.com/luxfi/crypto/internal/gpuhost"
)
// modeToCAPI maps an ML-DSA Mode to the FIPS 204 NIST level integer accepted
// by the lux-accel C ABI (mode = 2 / 3 / 5 for ML-DSA-44 / 65 / 87). Returns
// ok=false for modes not wired through GPU dispatch.
func modeToCAPI(m Mode) (int, bool) {
switch m {
case MLDSA44:
return 2, true
case MLDSA65:
return 3, true
case MLDSA87:
return 5, true
default:
return 0, false
}
}
// batchVerifyGPU dispatches a batch of ML-DSA verifications to the GPU
// substrate when available. Mode is per the first public key in the batch —
// callers MUST ensure all entries share the same mode (the public BatchVerify
// 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 (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)
// - 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) {
if len(pubs) == 0 {
return true, nil
}
if len(msgs) != len(pubs) || len(sigs) != len(pubs) || len(out) != len(pubs) {
return false, nil
}
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
if sess == nil {
return false, nil
}
mode := pubs[0].mode
capiMode, ok := modeToCAPI(mode)
if !ok {
return false, nil
}
for i := 1; i < len(pubs); i++ {
if pubs[i].mode != mode {
return false, nil
}
}
pkSize := GetPublicKeySize(mode)
sigSize := GetSignatureSize(mode)
if pkSize == 0 || sigSize == 0 {
return false, nil
}
n := len(pubs)
width := 1
for _, m := range msgs {
if len(m) > width {
width = len(m)
}
}
mFlat := make([]uint8, n*width)
for i, m := range msgs {
copy(mFlat[i*width:(i+1)*width], m)
}
pFlat := make([]uint8, n*pkSize)
for i, p := range pubs {
if len(p.publicKey) != pkSize {
return false, nil
}
copy(pFlat[i*pkSize:(i+1)*pkSize], p.publicKey)
}
sFlat := make([]uint8, n*sigSize)
for i, s := range sigs {
if len(s) != sigSize {
return false, nil
}
copy(sFlat[i*sigSize:(i+1)*sigSize], s)
}
mT, err := accel.NewTensorWithData[uint8](sess, []int{n, width}, mFlat)
if err != nil {
return false, nil
}
defer mT.Close()
sT, err := accel.NewTensorWithData[uint8](sess, []int{n, sigSize}, sFlat)
if err != nil {
return false, nil
}
defer sT.Close()
pT, err := accel.NewTensorWithData[uint8](sess, []int{n, pkSize}, pFlat)
if err != nil {
return false, nil
}
defer pT.Close()
rT, err := accel.NewTensor[uint8](sess, []int{n})
if err != nil {
return false, nil
}
defer rT.Close()
// 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 && !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()
if err != nil {
return false, nil
}
// Successful C ABI dispatch means the plugin's strong override of
// lux_mldsa_verify_batch is resolved (or the legacy Dilithium kernel
// for ML-DSA-65, which is the same surface). GetProvenance can
// honestly report TierGPUSubstrate from here on.
recordPluginStrongSymbol(true)
for i, b := range bytes {
out[i] = b == 1
}
return true, nil
}
// batchSignGPU dispatches a batch of ML-DSA signing operations to the GPU
// substrate. Returns (dispatched, error) — see batchVerifyGPU for the
// fall-through contract.
//
// All privs MUST share the same mode. On success, `sigs[i]` is overwritten
// with the signature of `msgs[i]` under `privs[i]`.
func batchSignGPU(privs []*PrivateKey, msgs [][]byte, sigs [][]byte) (bool, error) {
if len(privs) == 0 {
return true, nil
}
if len(msgs) != len(privs) || len(sigs) != len(privs) {
return false, nil
}
if !backend.IsGPU() {
return false, nil
}
sess := gpuhost.Session()
if sess == nil {
return false, nil
}
mode := privs[0].mode
capiMode, ok := modeToCAPI(mode)
if !ok {
return false, nil
}
for i := 1; i < len(privs); i++ {
if privs[i].mode != mode {
return false, nil
}
}
skSize := GetPrivateKeySize(mode)
sigSize := GetSignatureSize(mode)
if skSize == 0 || sigSize == 0 {
return false, nil
}
n := len(privs)
width := 1
for _, m := range msgs {
if len(m) > width {
width = len(m)
}
}
mFlat := make([]uint8, n*width)
for i, m := range msgs {
copy(mFlat[i*width:(i+1)*width], m)
}
skFlat := make([]uint8, n*skSize)
for i, p := range privs {
if len(p.secretKey) != skSize {
return false, nil
}
copy(skFlat[i*skSize:(i+1)*skSize], p.secretKey)
}
mT, err := accel.NewTensorWithData[uint8](sess, []int{n, width}, mFlat)
if err != nil {
return false, nil
}
defer mT.Close()
skT, err := accel.NewTensorWithData[uint8](sess, []int{n, skSize}, skFlat)
if err != nil {
return false, nil
}
defer skT.Close()
sigT, err := accel.NewTensor[uint8](sess, []int{n, sigSize})
if err != nil {
return false, nil
}
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
}
sigBytes, err := sigT.ToSlice()
if err != nil {
return false, nil
}
// See batchVerifyGPU.
recordPluginStrongSymbol(true)
for i := 0; i < n; i++ {
sigs[i] = make([]byte, sigSize)
copy(sigs[i], sigBytes[i*sigSize:(i+1)*sigSize])
}
return true, nil
}