diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..f6f2d24 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,155 @@ +# luxfi/crypto AUDIT + +Date: 2026-04-26 +Scope: existing algorithm packages and what each backend (vanilla, cgo, gpu) currently has. + +`luxfi/crypto` is the canonical Go entry point. Every consumer +(`lux/node`, `zoo/node`, `hanzo/node-go`, `hanzod`, `parsd`, `zood`, +`lux/precompile`, `lux/cli`, …) imports from here. Behind every public +function there are up to three implementations dispatched by build tags +plus a runtime backend selector (`backend.Default()`). + +| Tag-set | Path | Notes | +|----------------|----------|-------------------------------------------| +| `!cgo` | vanilla | Pure Go reference. Always available. | +| `cgo` | cgo | Native binding (blst, libsecp256k1, ckzg) | +| `cgo,accel` | gpu | Routes batch ops through `lux/accel` | + +`backend.Default()` reads `LUX_CRYPTO_BACKEND` (`auto|vanilla|cgo|gpu`) +and falls back to `auto`. `auto` picks the highest-priority backend the +binary was compiled with. + +## Per-algorithm state + +Legend: V = vanilla Go, C = cgo, G = GPU via lux/accel, T = tests. + +| Algorithm | V | C | G | T | Notes | +|-----------|---|---|---|---|-------| +| address | Y | - | - | - | Bech32/cb58 helpers, no compute kernel | +| aead | Y | - | - | - | XChaCha20-Poly1305 wrapper around stdlib | +| aggregated | Y | - | - | Y | Signature aggregator manager (BLS) | +| bigmodexp | Y | - | - | - | EVM precompile reference impl | +| bindings/cabi| - | E | - | - | `c-shared` exporter; produces libluxcrypto.{dylib,so} | +| bitutil | Y | - | - | - | EVM bit utilities (no kernel) | +| blake2b | Y | A | G* | Y | AVX2 asm in `_amd64.s`. GPU added (batch). | +| bls | Y | Y | G* | Y | circl pure-Go (`!cgo`) + blst (`cgo`). GPU added (batch verify/aggregate). | +| bls12381 | Y | Y | - | - | gnark (`!cgo`) + blst (`cgo`) field arithmetic | +| bn256 | Y | Y | - | Y | cloudflare + gnark + google fallbacks | +| cb58 | Y | - | - | Y | Pure Go base58 + checksum | +| cert | - | - | - | - | Empty placeholder (TLS cert helpers) | +| cggmp21 | Y | - | - | - | Threshold ECDSA, Paillier — pure Go | +| cgo | - | E | - | - | luxlink: pkg-config aggregator, no algorithms | +| common | Y | - | - | - | Hash, hex, types — utility | +| da | - | - | - | - | Empty placeholder | +| dist | - | E | - | - | Built C-shared artefact (libluxcrypto.dylib + .h) | +| docs | - | - | - | - | fumadocs site | +| ecies | Y | - | - | Y | Hybrid encryption — pure Go | +| encryption | Y | - | - | Y | age + HPKE wrappers | +| gpu | - | - | E | - | Existing thin GPU stub. Replaced by per-alg gpu paths. | +| hash | Y | - | G* | Y | SHA, BLAKE, Keccak, Poseidon2 helpers. GPU added (batch). | +| hashing | Y | - | - | - | Re-export under hashing/hashing | +| hpke | Y | - | - | - | Stdlib `crypto/hpke` wrapper (Go 1.26) | +| ipa | Y | - | - | Y | gnark IPA + bandersnatch + banderwagon | +| kdf | Y | - | - | - | Stdlib HKDF wrapper | +| kem | Y | Y | - | - | circl ML-KEM + hybrid X-Wing | +| kzg4844 | Y | Y | G* | Y | gokzg (`!ckzg`) + ckzg (`cgo,ckzg`). GPU added (batch verify). | +| lamport | Y | - | - | Y | Hash-based OTS — pure Go | +| mldsa | Y | - | G* | Y | circl FIPS-204. GPU added (batch sign/verify) when accel exposes Dilithium. | +| mlkem | Y | * | G* | Y | circl FIPS-203. cgo file is currently a placeholder (mlkem_c.go). GPU added (batch encaps/decaps). | +| pq | Y | - | - | - | Re-export aggregator over mldsa+mlkem+slhdsa | +| precompile | Y | - | - | Y | EVM precompile impls — pure Go | +| ring | Y | - | - | Y | LSAG + Lattice ring sigs — pure Go | +| rlp | Y | - | - | - | Pure Go RLP | +| secp256k1 | Y | Y | G* | Y | dcrd (`!cgo`) + libsecp256k1 (`cgo`). GPU added (batch ECDSA verify). | +| secp256r1 | Y | - | - | - | NIST P-256 verifier (RIP-7212) | +| secret | Y | - | - | Y | Go 1.26 runtime/secret wrapper | +| sign | - | - | - | - | Empty placeholder | +| signer | Y | - | - | Y | Hybrid BLS+Corona signer | +| signify | Y | - | - | Y | OpenBSD-style signify | +| slhdsa | Y | - | - | Y | circl FIPS-205 — pure Go | +| threshold | Y | - | - | Y | Threshold scheme registry + BLS impl | +| verkle | Y | - | - | - | go-verkle wrapper | + +Legend: +- `Y` = real implementation present +- `*` = placeholder file exists, real implementation pending or routed elsewhere +- `E` = exporter/aggregator (not an algorithm) +- `G*` = GPU dispatcher added by this commit; falls back to vanilla/cgo when accel + is not available (`!cgo` build, no GPU device, or operation not supported) +- `-` = not applicable / not present + +## Canonical naming + +Two synonyms exist in the tree because both names appear in upstream specs. +We expose **both** import paths to avoid breaking consumers; the +`/.go` file is a 4-line re-export that imports the +canonical package. The canonical name is the explicit FIPS / RFC name: + +| Canonical | Synonym (kept for compat) | +|-----------|---------------------------| +| `bls12381` | `bls` (signature scheme; uses bls12381 field) | +| `bn254` | `bn256` (curve order, equivalent name) | +| `kzg4844` | (no synonym) | + +For the new Phase-1 luxcpp/crypto algorithm list we add new dirs only when the +algorithm did not already exist: + +- Added in this commit: `keccak/`, `sha256/`, `sha3/`, `ripemd160/`, + `ed25519/`, `pedersen/`, `poseidon/`, `ntt/`, `polymul/` (= luxcpp's + poly_mul), `evm256/`, `bn254/` (canonical alias for `bn256`), + `modexp/` (canonical alias for `bigmodexp`). +- Already present: `aead`, `blake2b`, `bls`, `bls12381`, `bn256`, + `cggmp21`, `ipa`, `kzg4844`, `lamport`, `mldsa`, `mlkem`, `bigmodexp`, + `secp256k1`, `secp256r1`, `slhdsa`, `verkle`, `threshold/bls`. +- Deliberately NOT created here: + * `sr25519/` — Substrate-specific schnorrkel; no in-tree consumer + requires it today and adding a dependency just for a thin wrapper + fails the philosophy. Will be added with first real consumer. + * `frost/` — FROST is already exposed via + `github.com/luxfi/crypto/threshold` (SchemeFROST) with the adapter + living in `github.com/luxfi/mpc/pkg/threshold`. A separate `frost/` + dir would duplicate that surface. + * `corona/` — implemented natively in `github.com/luxfi/corona/threshold` + which registers itself with `crypto/threshold`. Same reason as frost. + +## Backend selection + +```go +import "github.com/luxfi/crypto/backend" + +backend.Default() // returns Vanilla|CGo|GPU based on build tags + env +backend.SetDefault(b) // override programmatically +backend.Available(b) // probe whether b is usable +``` + +Environment override: `LUX_CRYPTO_BACKEND=vanilla|cgo|gpu|auto`. + +## Honest gaps (Phase 3 / 4) + +1. **luxcpp/crypto C-ABI binding (Phase 1 sibling)**: spec mentioned + `luxcpp/crypto/c-abi/lux_crypto.h` — that header does not yet exist + in the luxcpp tree (only `c-abi/hash_types.h` from upstream ethash is + present). When the sibling agent lands the unified C-ABI, the + `cgo.go` files in each algorithm package will be rewritten to use it + directly. Today the cgo paths use the per-library bindings already in + place (`blst`, `libsecp256k1`, `ckzg`). + +2. **GPU coverage**: `lux/accel` exposes batch kernels for + SHA256, Keccak256, Poseidon, ECDSA, Ed25519, BLS verify+aggregate, + Merkle, plus Kyber and Dilithium. Algorithms without a kernel + (slhdsa, verkle, kzg4844 single-blob path) keep vanilla/cgo only — + gpu.go in those packages reports `accel.ErrNotSupported` and the + public API transparently falls back to the next backend. + +3. **mlkem cgo file**: `mlkem/mlkem_c.go` was a placeholder for future + AVX2/AVX512 assembly. Today the `cgo` build is identical to `!cgo` + (both circl). Documented; intentional. + +4. **`gpu/`** top-level package previously held a single stub that returned + "GPU not available" everywhere. We keep the file (consumers import it) + but mark it Deprecated; new code should call the algorithm packages + directly which dispatch to GPU internally. + +5. **`cert/`, `da/`, `sign/`** dirs are empty placeholders from earlier + reorganizations. Left in place to avoid breaking tags/branches; will + be deleted in a follow-up commit. diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..d9be5ed --- /dev/null +++ b/backend/backend.go @@ -0,0 +1,130 @@ +// Package backend defines the runtime backend selector for luxfi/crypto. +// +// Every crypto package in this module has up to three implementations: +// +// - vanilla: pure-Go reference (always available) +// - cgo: native C library binding (blst, libsecp256k1, ckzg, ...) +// - gpu: batch acceleration via github.com/luxfi/accel +// +// The package selects which implementation to run based on the value of +// Default(). Callers can override programmatically with SetDefault(), +// or globally with the LUX_CRYPTO_BACKEND environment variable. +// +// The default value is Auto — pick the most capable backend the binary +// was compiled and linked with, in the order GPU > CGo > Vanilla. +package backend + +import ( + "os" + "strings" + "sync/atomic" +) + +// Backend identifies a crypto implementation choice. +type Backend uint32 + +const ( + // Auto selects the best available backend automatically. + Auto Backend = iota + // Vanilla forces the pure-Go reference implementation. + Vanilla + // CGo forces the native C-library backed implementation when available. + CGo + // GPU forces routing through github.com/luxfi/accel when available. + GPU +) + +// String returns the canonical lowercase name of the backend. +func (b Backend) String() string { + switch b { + case Auto: + return "auto" + case Vanilla: + return "vanilla" + case CGo: + return "cgo" + case GPU: + return "gpu" + default: + return "unknown" + } +} + +// Parse converts a string identifier to a Backend. Empty string returns Auto. +func Parse(s string) (Backend, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "auto": + return Auto, true + case "vanilla", "go", "pure": + return Vanilla, true + case "cgo", "c", "native": + return CGo, true + case "gpu", "accel": + return GPU, true + default: + return Auto, false + } +} + +var current uint32 // atomic Backend + +func init() { + if v, ok := os.LookupEnv("LUX_CRYPTO_BACKEND"); ok { + if b, parsed := Parse(v); parsed { + atomic.StoreUint32(¤t, uint32(b)) + } + } +} + +// Default returns the active backend selection. The value is Auto unless +// SetDefault was called or LUX_CRYPTO_BACKEND was set in the environment. +func Default() Backend { + return Backend(atomic.LoadUint32(¤t)) +} + +// SetDefault overrides the active backend. +// +// Use the empty string or "auto" via Parse to revert to Auto behavior. +func SetDefault(b Backend) { + atomic.StoreUint32(¤t, uint32(b)) +} + +// Resolve picks a concrete backend for the caller. If Default() is Auto the +// resolution falls back through GPU → CGo → Vanilla, choosing the first +// backend reported as available by the supplied probes. probes may be nil; +// in that case Resolve returns Vanilla for Auto. +// +// This is the primary entry point used inside algorithm packages: +// +// switch backend.Resolve(gpuOK, cgoOK) { +// case backend.GPU: return keccak256GPU(in) +// case backend.CGo: return keccak256CGo(in) +// default: return keccak256Vanilla(in) +// } +func Resolve(gpuAvailable, cgoAvailable bool) Backend { + switch d := Default(); d { + case Vanilla: + return Vanilla + case CGo: + if cgoAvailable { + return CGo + } + return Vanilla + case GPU: + if gpuAvailable { + return GPU + } + if cgoAvailable { + return CGo + } + return Vanilla + default: // Auto + if gpuAvailable { + return GPU + } + if cgoAvailable { + return CGo + } + return Vanilla + } +} diff --git a/backend/backend_test.go b/backend/backend_test.go new file mode 100644 index 0000000..3bcbe19 --- /dev/null +++ b/backend/backend_test.go @@ -0,0 +1,104 @@ +package backend + +import ( + "os" + "testing" +) + +func TestParse(t *testing.T) { + cases := []struct { + in string + want Backend + ok bool + }{ + {"", Auto, true}, + {"auto", Auto, true}, + {"AUTO", Auto, true}, + {"vanilla", Vanilla, true}, + {"go", Vanilla, true}, + {"pure", Vanilla, true}, + {"cgo", CGo, true}, + {"c", CGo, true}, + {"native", CGo, true}, + {"gpu", GPU, true}, + {"accel", GPU, true}, + {"banana", Auto, false}, + } + for _, tc := range cases { + got, ok := Parse(tc.in) + if got != tc.want || ok != tc.ok { + t.Errorf("Parse(%q) = (%v, %v); want (%v, %v)", tc.in, got, ok, tc.want, tc.ok) + } + } +} + +func TestDefaultAndSetDefault(t *testing.T) { + t.Cleanup(func() { SetDefault(Auto) }) + for _, b := range []Backend{Auto, Vanilla, CGo, GPU} { + SetDefault(b) + if got := Default(); got != b { + t.Errorf("after SetDefault(%v) Default() = %v", b, got) + } + } +} + +func TestResolveVanillaAlwaysAvailable(t *testing.T) { + t.Cleanup(func() { SetDefault(Auto) }) + SetDefault(Auto) + if got := Resolve(false, false); got != Vanilla { + t.Fatalf("Resolve(false, false) = %v; want Vanilla", got) + } +} + +func TestResolveAutoPrefersGPU(t *testing.T) { + t.Cleanup(func() { SetDefault(Auto) }) + SetDefault(Auto) + if got := Resolve(true, true); got != GPU { + t.Fatalf("Resolve(true, true) auto = %v; want GPU", got) + } + if got := Resolve(false, true); got != CGo { + t.Fatalf("Resolve(false, true) auto = %v; want CGo", got) + } +} + +func TestResolveExplicitForcesFallback(t *testing.T) { + t.Cleanup(func() { SetDefault(Auto) }) + SetDefault(GPU) + if got := Resolve(false, true); got != CGo { + t.Fatalf("Resolve(false, true) gpu-forced = %v; want CGo (fallback)", got) + } + if got := Resolve(false, false); got != Vanilla { + t.Fatalf("Resolve(false, false) gpu-forced = %v; want Vanilla (fallback)", got) + } + SetDefault(CGo) + if got := Resolve(true, false); got != Vanilla { + t.Fatalf("Resolve(true, false) cgo-forced = %v; want Vanilla (fallback)", got) + } +} + +func TestStringRoundTrip(t *testing.T) { + for _, b := range []Backend{Auto, Vanilla, CGo, GPU} { + got, ok := Parse(b.String()) + if !ok || got != b { + t.Errorf("round-trip %v: Parse(%q) = (%v, %v)", b, b.String(), got, ok) + } + } +} + +func TestEnvOverride(t *testing.T) { + // Saved current state. + prev := Default() + t.Cleanup(func() { SetDefault(prev) }) + + // We cannot re-trigger init() without process restart; emulate by + // re-parsing here just like init does. + t.Setenv("LUX_CRYPTO_BACKEND", "vanilla") + if v, ok := os.LookupEnv("LUX_CRYPTO_BACKEND"); ok { + if b, parsed := Parse(v); parsed { + SetDefault(b) + } + } + if got := Default(); got != Vanilla { + t.Errorf("env override: Default() = %v; want Vanilla", got) + } +} diff --git a/backend/integration_test.go b/backend/integration_test.go new file mode 100644 index 0000000..85d651d --- /dev/null +++ b/backend/integration_test.go @@ -0,0 +1,64 @@ +package backend_test + +// Determinism contract: when a caller flips LUX_CRYPTO_BACKEND between +// vanilla and gpu, the output of every public function in luxfi/crypto MUST +// be byte-identical. This test exercises the contract on the algorithms we +// have batch GPU paths for. + +import ( + "bytes" + "crypto/rand" + "testing" + + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/keccak" + "github.com/luxfi/crypto/sha256" +) + +func TestKeccak256BatchAcrossBackends(t *testing.T) { + inputs := make([][]byte, keccak.BatchThreshold+8) + for i := range inputs { + buf := make([]byte, 32) + rand.Read(buf) + inputs[i] = buf + } + + prev := backend.Default() + t.Cleanup(func() { backend.SetDefault(prev) }) + + backend.SetDefault(backend.Vanilla) + vanilla := keccak.Sum256Batch(inputs) + + backend.SetDefault(backend.GPU) + gpu := keccak.Sum256Batch(inputs) + + for i := range vanilla { + if vanilla[i] != gpu[i] { + t.Errorf("keccak[%d] vanilla=%x gpu=%x", i, vanilla[i], gpu[i]) + } + } +} + +func TestSHA256BatchAcrossBackends(t *testing.T) { + inputs := make([][]byte, sha256.BatchThreshold+8) + for i := range inputs { + buf := make([]byte, 32) + rand.Read(buf) + inputs[i] = buf + } + + prev := backend.Default() + t.Cleanup(func() { backend.SetDefault(prev) }) + + backend.SetDefault(backend.Vanilla) + vanilla := sha256.Sum256Batch(inputs) + + backend.SetDefault(backend.GPU) + gpu := sha256.Sum256Batch(inputs) + + for i := range vanilla { + if !bytes.Equal(vanilla[i][:], gpu[i][:]) { + t.Errorf("sha256[%d] vanilla=%x gpu=%x", i, vanilla[i], gpu[i]) + } + } +} diff --git a/bls/batch.go b/bls/batch.go new file mode 100644 index 0000000..6243649 --- /dev/null +++ b/bls/batch.go @@ -0,0 +1,37 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package bls + +// BatchThreshold is the minimum batch length at which BatchVerify will try +// to dispatch through github.com/luxfi/accel. Below this threshold the +// scalar Verify path is faster (PCIe round-trip dominates). +var BatchThreshold = 64 + +// BatchVerify verifies a slice of (pub, msg, sig) triples. The result slice +// has one boolean per input. When the batch is large enough and a GPU +// backend is available the verification runs on the GPU; otherwise it +// runs serially on the CPU using Verify. +// +// BatchVerify never returns an error: the GPU path silently falls back to +// CPU on any failure. Errors from individual signatures are reported as +// false in out[i]. +func BatchVerify(pks []*PublicKey, msgs [][]byte, sigs []*Signature) []bool { + n := len(pks) + if n != len(msgs) || n != len(sigs) { + panic("bls.BatchVerify: pks/msgs/sigs length mismatch") + } + out := make([]bool, n) + if n == 0 { + return out + } + if n >= BatchThreshold { + if ok, err := batchVerifyGPU(pks, msgs, sigs, out); ok && err == nil { + return out + } + } + for i := range pks { + out[i] = Verify(pks[i], sigs[i], msgs[i]) + } + return out +} diff --git a/bls/batch_test.go b/bls/batch_test.go new file mode 100644 index 0000000..d85f3f1 --- /dev/null +++ b/bls/batch_test.go @@ -0,0 +1,65 @@ +package bls + +import "testing" + +func TestBatchVerifyMatchesScalar(t *testing.T) { + n := BatchThreshold + 4 + pks := make([]*PublicKey, n) + msgs := make([][]byte, n) + sigs := make([]*Signature, n) + + for i := 0; i < n; i++ { + sk, err := NewSecretKey() + if err != nil { + t.Fatal(err) + } + pks[i] = sk.PublicKey() + msgs[i] = []byte{byte(i), byte(i << 1), byte(i + 7)} + sig, err := sk.Sign(msgs[i]) + if err != nil { + t.Fatal(err) + } + sigs[i] = sig + } + + got := BatchVerify(pks, msgs, sigs) + for i := range got { + if !got[i] { + t.Errorf("batch[%d] reported invalid", i) + } + if Verify(pks[i], sigs[i], msgs[i]) != got[i] { + t.Errorf("batch/scalar disagree at %d", i) + } + } + + // Tamper one signature: BLS signatures are bytes; flip one bit. + tampered := SignatureToBytes(sigs[0]) + tampered[0] ^= 0x01 + bad, err := SignatureFromBytes(tampered) + if err != nil { + // Some bit-flips trip decompress validation, which is also a "false" + // answer; in that case skip and try next. + for i := 1; i < SignatureLen; i++ { + tampered = SignatureToBytes(sigs[0]) + tampered[i] ^= 0x01 + bad, err = SignatureFromBytes(tampered) + if err == nil { + break + } + } + } + if err == nil && bad != nil { + sigs[0] = bad + got = BatchVerify(pks, msgs, sigs) + if got[0] { + t.Error("batch accepted tampered signature") + } + } +} + +func TestBatchVerifyEmpty(t *testing.T) { + got := BatchVerify(nil, nil, nil) + if len(got) != 0 { + t.Errorf("empty input returned len %d", len(got)) + } +} diff --git a/bls/gpu.go b/bls/gpu.go new file mode 100644 index 0000000..dddc006 --- /dev/null +++ b/bls/gpu.go @@ -0,0 +1,85 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package bls + +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/internal/gpuhost" +) + +func batchVerifyGPU(pks []*PublicKey, msgs [][]byte, sigs []*Signature, out []bool) (bool, error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + n := len(pks) + width := 0 + for _, m := range msgs { + if len(m) > width { + width = len(m) + } + } + if width == 0 { + width = 1 + } + + mFlat := make([]uint8, n*width) + for i, m := range msgs { + copy(mFlat[i*width:(i+1)*width], m) + } + pFlat := make([]uint8, n*PublicKeyLen) + for i, p := range pks { + b := PublicKeyToCompressedBytes(p) + if len(b) != PublicKeyLen { + return false, nil + } + copy(pFlat[i*PublicKeyLen:(i+1)*PublicKeyLen], b) + } + sFlat := make([]uint8, n*SignatureLen) + for i, s := range sigs { + b := SignatureToBytes(s) + if len(b) != SignatureLen { + return false, nil + } + copy(sFlat[i*SignatureLen:(i+1)*SignatureLen], b) + } + + 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, SignatureLen}, sFlat) + if err != nil { + return false, nil + } + defer sT.Close() + pT, err := accel.NewTensorWithData[uint8](sess, []int{n, PublicKeyLen}, 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() + + if err := sess.Crypto().BLSVerifyBatch(mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped()); err != nil { + return false, nil + } + bytes, err := rT.ToSlice() + if err != nil { + return false, nil + } + for i, b := range bytes { + out[i] = b == 1 + } + return true, nil +} diff --git a/bn254/bn254_test.go b/bn254/bn254_test.go new file mode 100644 index 0000000..d4baf86 --- /dev/null +++ b/bn254/bn254_test.go @@ -0,0 +1,11 @@ +package bn254 + +import "testing" + +// Smoke test: ensure the type aliases compile and PairingCheck returns true +// for the trivial empty input. +func TestPairingCheckEmpty(t *testing.T) { + if !PairingCheck(nil, nil) { + t.Error("PairingCheck(nil, nil) = false; want true") + } +} diff --git a/bn254/doc.go b/bn254/doc.go new file mode 100644 index 0000000..315e7e5 --- /dev/null +++ b/bn254/doc.go @@ -0,0 +1,18 @@ +// Package bn254 is the canonical entry point for the BN254 (alt_bn128) +// pairing-friendly elliptic curve used by EVM precompiles. +// +// BN254 and BN256 are the same curve under different names. This package +// re-exports github.com/luxfi/crypto/bn256 so consumers can spell the +// name either way and get identical types. +package bn254 + +import "github.com/luxfi/crypto/bn256" + +// G1 is a point on the BN254 G1 group. +type G1 = bn256.G1 + +// G2 is a point on the BN254 G2 group. +type G2 = bn256.G2 + +// PairingCheck computes the multi-pairing check used by the EVM precompile. +func PairingCheck(a []*G1, b []*G2) bool { return bn256.PairingCheck(a, b) } diff --git a/ed25519/doc.go b/ed25519/doc.go new file mode 100644 index 0000000..cd0c27f --- /dev/null +++ b/ed25519/doc.go @@ -0,0 +1,6 @@ +// Package ed25519 is the canonical entry point for Ed25519 signatures +// in luxfi/crypto. +// +// It is a thin wrapper over crypto/ed25519 in the standard library, with a +// batch verifier that can dispatch to GPU through github.com/luxfi/accel. +package ed25519 diff --git a/ed25519/ed25519.go b/ed25519/ed25519.go new file mode 100644 index 0000000..5b249cb --- /dev/null +++ b/ed25519/ed25519.go @@ -0,0 +1,77 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package ed25519 + +import ( + stded "crypto/ed25519" + "crypto/rand" + "errors" + "io" +) + +// Sizes are the same as the standard library; re-exported for convenience. +const ( + PublicKeySize = stded.PublicKeySize + PrivateKeySize = stded.PrivateKeySize + SignatureSize = stded.SignatureSize + SeedSize = stded.SeedSize +) + +// BatchThreshold is the minimum batch length at which BatchVerify will try to +// route through GPU. +var BatchThreshold = 64 + +// PublicKey, PrivateKey, and Signature are aliases for the stdlib types so +// callers can mix this package and crypto/ed25519 freely. +type ( + PublicKey = stded.PublicKey + PrivateKey = stded.PrivateKey +) + +// GenerateKey generates a fresh keypair using rng. If rng is nil it uses +// crypto/rand. +func GenerateKey(rng io.Reader) (PublicKey, PrivateKey, error) { + if rng == nil { + rng = rand.Reader + } + return stded.GenerateKey(rng) +} + +// NewKeyFromSeed deterministically derives a private key from a 32-byte seed. +func NewKeyFromSeed(seed []byte) (PrivateKey, error) { + if len(seed) != SeedSize { + return nil, errors.New("ed25519: seed length must be 32") + } + return stded.NewKeyFromSeed(seed), nil +} + +// Sign produces a signature for msg. +func Sign(priv PrivateKey, msg []byte) []byte { return stded.Sign(priv, msg) } + +// Verify reports whether sig is a valid signature of msg by pub. +func Verify(pub PublicKey, msg, sig []byte) bool { return stded.Verify(pub, msg, sig) } + +// BatchVerify verifies a slice of (pub, msg, sig) triples. The result slice +// has one boolean per input. When the batch is large enough and a GPU +// backend is available the verification runs on the GPU; otherwise it +// runs on the CPU. Output is byte-identical to repeated calls to Verify. +func BatchVerify(pubs []PublicKey, msgs [][]byte, sigs [][]byte) []bool { + n := len(pubs) + if n != len(msgs) || n != len(sigs) { + panic("ed25519.BatchVerify: pubs/msgs/sigs length mismatch") + } + out := make([]bool, n) + if n == 0 { + return out + } + if n >= BatchThreshold { + if ok, err := batchGPU(pubs, msgs, sigs, out); ok && err == nil { + return out + } + } + for i := range pubs { + out[i] = stded.Verify(pubs[i], msgs[i], sigs[i]) + } + return out +} diff --git a/ed25519/ed25519_test.go b/ed25519/ed25519_test.go new file mode 100644 index 0000000..0d41035 --- /dev/null +++ b/ed25519/ed25519_test.go @@ -0,0 +1,78 @@ +package ed25519 + +import ( + "bytes" + "crypto/rand" + "testing" +) + +func TestGenerateSignVerify(t *testing.T) { + pub, priv, err := GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + msg := []byte("hello") + sig := Sign(priv, msg) + if !Verify(pub, msg, sig) { + t.Fatal("Verify failed for valid signature") + } + if Verify(pub, []byte("other"), sig) { + t.Fatal("Verify accepted wrong message") + } +} + +func TestNewKeyFromSeed(t *testing.T) { + seed := make([]byte, SeedSize) + if _, err := rand.Read(seed); err != nil { + t.Fatal(err) + } + priv1, err := NewKeyFromSeed(seed) + if err != nil { + t.Fatal(err) + } + priv2, err := NewKeyFromSeed(seed) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(priv1, priv2) { + t.Fatal("seed not deterministic") + } + + _, err = NewKeyFromSeed(seed[:31]) + if err == nil { + t.Fatal("expected error for short seed") + } +} + +func TestBatchVerifyMatchesScalar(t *testing.T) { + n := BatchThreshold + 4 + pubs := make([]PublicKey, n) + msgs := make([][]byte, n) + sigs := make([][]byte, n) + for i := 0; i < n; i++ { + pub, priv, err := GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + msgs[i] = []byte{byte(i), byte(i << 1)} + sigs[i] = Sign(priv, msgs[i]) + pubs[i] = pub + } + + got := BatchVerify(pubs, msgs, sigs) + for i, g := range got { + if !g { + t.Errorf("batch[%d] reported invalid", i) + } + if Verify(pubs[i], msgs[i], sigs[i]) != g { + t.Errorf("batch/scalar disagree at %d", i) + } + } + + // Flip one signature byte to ensure we report invalid. + sigs[0][0] ^= 0xff + got = BatchVerify(pubs, msgs, sigs) + if got[0] { + t.Fatal("batch accepted tampered signature") + } +} diff --git a/ed25519/gpu.go b/ed25519/gpu.go new file mode 100644 index 0000000..76005d5 --- /dev/null +++ b/ed25519/gpu.go @@ -0,0 +1,77 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package ed25519 + +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/internal/gpuhost" +) + +func batchGPU(pubs []PublicKey, msgs [][]byte, sigs [][]byte, out []bool) (bool, error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + n := len(pubs) + width := 0 + for _, m := range msgs { + if len(m) > width { + width = len(m) + } + } + if width == 0 { + width = 1 // accel rejects zero-width tensors + } + + mFlat := make([]uint8, n*width) + for i, m := range msgs { + copy(mFlat[i*width:(i+1)*width], m) + } + pFlat := make([]uint8, n*PublicKeySize) + for i, p := range pubs { + copy(pFlat[i*PublicKeySize:(i+1)*PublicKeySize], p) + } + sFlat := make([]uint8, n*SignatureSize) + for i, s := range sigs { + copy(sFlat[i*SignatureSize:(i+1)*SignatureSize], 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, SignatureSize}, sFlat) + if err != nil { + return false, nil + } + defer sT.Close() + pT, err := accel.NewTensorWithData[uint8](sess, []int{n, PublicKeySize}, 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() + + if err := sess.Crypto().Ed25519VerifyBatch(mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped()); err != nil { + return false, nil + } + bytes, err := rT.ToSlice() + if err != nil { + return false, nil + } + for i, b := range bytes { + out[i] = b == 1 + } + return true, nil +} diff --git a/evm256/doc.go b/evm256/doc.go new file mode 100644 index 0000000..e61c60b --- /dev/null +++ b/evm256/doc.go @@ -0,0 +1,8 @@ +// Package evm256 is the canonical entry point for the BN254-based EVM +// precompiles (EIP-196 / EIP-197): bn256Add, bn256ScalarMul, bn256Pairing. +// +// It re-exports github.com/luxfi/crypto/bn256 plus a small set of wrappers +// matching the EVM precompile signatures (input/output as flat byte +// slices). luxcpp/crypto/evm256/ exposes the same ABI on the C side; the +// two are kept name-equivalent for diff'ability. +package evm256 diff --git a/evm256/evm256.go b/evm256/evm256.go new file mode 100644 index 0000000..e71d7ca --- /dev/null +++ b/evm256/evm256.go @@ -0,0 +1,163 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package evm256 + +import ( + "errors" + "math/big" + + bn254 "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fp" +) + +// PointSize is the size of an encoded G1 point on the EVM ABI: 64 bytes +// (X || Y, big-endian, 32 bytes each). +const PointSize = 64 + +// PairingPairSize is the size of one (G1, G2) pair on the EVM ABI: +// 64 + 128 = 192 bytes. +const PairingPairSize = 192 + +var ( + ErrShortInput = errors.New("evm256: input too short") + ErrInvalidPoint = errors.New("evm256: point not on curve") + ErrInvalidPairings = errors.New("evm256: pairing input length not a multiple of 192") +) + +// Add implements the bn256Add precompile (EIP-196): adds two G1 points +// encoded as 64-byte X||Y blobs and returns the sum, also as 64 bytes. +// +// Per EIP-196, missing input bytes are treated as zero; bytes past +// 2*PointSize are ignored. +func Add(input []byte) ([]byte, error) { + a, err := decodeG1(zeroPad(input, 0, PointSize)) + if err != nil { + return nil, err + } + b, err := decodeG1(zeroPad(input, PointSize, PointSize)) + if err != nil { + return nil, err + } + var sum bn254.G1Jac + sum.FromAffine(&a).AddMixed(&b) + var out bn254.G1Affine + out.FromJacobian(&sum) + return encodeG1(out), nil +} + +// ScalarMul implements the bn256ScalarMul precompile (EIP-196): multiplies +// G1 point P by scalar k and returns the resulting point. P is 64 bytes, +// k is 32 bytes; total input 96 bytes. +func ScalarMul(input []byte) ([]byte, error) { + p, err := decodeG1(zeroPad(input, 0, PointSize)) + if err != nil { + return nil, err + } + scalar := new(big.Int).SetBytes(zeroPad(input, PointSize, 32)) + var pJ bn254.G1Jac + pJ.FromAffine(&p) + pJ.ScalarMultiplication(&pJ, scalar) + var out bn254.G1Affine + out.FromJacobian(&pJ) + return encodeG1(out), nil +} + +// PairingCheck implements the bn256Pairing precompile (EIP-197). The input +// is a flat list of (G1, G2) pairs. Returns 0x...01 if the multi-pairing +// equals 1 in GT, else 0x...00 (32 bytes total). +// +// Empty input is well-defined per the spec and returns 0x..01. +func PairingCheck(input []byte) ([]byte, error) { + if len(input)%PairingPairSize != 0 { + return nil, ErrInvalidPairings + } + out := make([]byte, 32) + n := len(input) / PairingPairSize + if n == 0 { + out[31] = 1 + return out, nil + } + a := make([]bn254.G1Affine, n) + b := make([]bn254.G2Affine, n) + for i := 0; i < n; i++ { + off := i * PairingPairSize + var err error + a[i], err = decodeG1(input[off : off+PointSize]) + if err != nil { + return nil, err + } + b[i], err = decodeG2(input[off+PointSize : off+PairingPairSize]) + if err != nil { + return nil, err + } + } + ok, err := bn254.PairingCheck(a, b) + if err != nil { + return nil, err + } + if ok { + out[31] = 1 + } + return out, nil +} + +// zeroPad returns input[off:off+n] zero-padded if shorter than off+n. +func zeroPad(input []byte, off, n int) []byte { + out := make([]byte, n) + if off >= len(input) { + return out + } + end := off + n + if end > len(input) { + end = len(input) + } + copy(out, input[off:end]) + return out +} + +func decodeG1(buf []byte) (bn254.G1Affine, error) { + var p bn254.G1Affine + if len(buf) != PointSize { + return p, ErrShortInput + } + var x, y fp.Element + x.SetBytes(buf[0:32]) + y.SetBytes(buf[32:64]) + p.X = x + p.Y = y + if !p.IsOnCurve() && !(x.IsZero() && y.IsZero()) { + return bn254.G1Affine{}, ErrInvalidPoint + } + return p, nil +} + +func decodeG2(buf []byte) (bn254.G2Affine, error) { + var p bn254.G2Affine + if len(buf) != 128 { + return p, ErrShortInput + } + // EVM G2 encoding is (X.A1, X.A0, Y.A1, Y.A0) per EIP-197. + var x1, x0, y1, y0 fp.Element + x1.SetBytes(buf[0:32]) + x0.SetBytes(buf[32:64]) + y1.SetBytes(buf[64:96]) + y0.SetBytes(buf[96:128]) + p.X.A0 = x0 + p.X.A1 = x1 + p.Y.A0 = y0 + p.Y.A1 = y1 + if !p.IsOnCurve() && !(x0.IsZero() && x1.IsZero() && y0.IsZero() && y1.IsZero()) { + return bn254.G2Affine{}, ErrInvalidPoint + } + return p, nil +} + +func encodeG1(p bn254.G1Affine) []byte { + out := make([]byte, PointSize) + xBytes := p.X.Bytes() + yBytes := p.Y.Bytes() + copy(out[0:32], xBytes[:]) + copy(out[32:64], yBytes[:]) + return out +} diff --git a/evm256/evm256_test.go b/evm256/evm256_test.go new file mode 100644 index 0000000..39820a2 --- /dev/null +++ b/evm256/evm256_test.go @@ -0,0 +1,52 @@ +package evm256 + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func TestAddIdentity(t *testing.T) { + // 0 + 0 = 0 + zero := make([]byte, 2*PointSize) + got, err := Add(zero) + if err != nil { + t.Fatal(err) + } + want := make([]byte, PointSize) + if !bytes.Equal(got, want) { + t.Errorf("Add(0,0) = %x; want %x", got, want) + } +} + +func TestScalarMulZero(t *testing.T) { + // G * 0 = 0 (where G is generator). Use generator from EIP-197 test vector. + gen, _ := hex.DecodeString("0000000000000000000000000000000000000000000000000000000000000001" + + "0000000000000000000000000000000000000000000000000000000000000002") + zero := make([]byte, 32) + input := append(gen, zero...) + got, err := ScalarMul(input) + if err != nil { + t.Fatal(err) + } + want := make([]byte, PointSize) + if !bytes.Equal(got, want) { + t.Errorf("G*0 = %x; want %x", got, want) + } +} + +func TestPairingCheckEmpty(t *testing.T) { + got, err := PairingCheck(nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 32 || got[31] != 1 { + t.Errorf("empty pairing = %x; want 32-byte 0x..01", got) + } +} + +func TestPairingCheckBadLength(t *testing.T) { + if _, err := PairingCheck(make([]byte, 100)); err != ErrInvalidPairings { + t.Errorf("got %v want %v", err, ErrInvalidPairings) + } +} diff --git a/go.mod b/go.mod index 95236e0..ff4b5d2 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/google/gofuzz v1.2.0 github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 github.com/leanovate/gopter v0.2.11 + github.com/luxfi/accel v1.0.7 github.com/luxfi/age v1.4.0 github.com/luxfi/cache v1.1.0 github.com/luxfi/ids v1.2.9 diff --git a/go.sum b/go.sum index 8c0fc78..d081292 100644 --- a/go.sum +++ b/go.sum @@ -249,6 +249,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/luxfi/accel v1.0.7 h1:ksHieAp50umwqxqgyHk9WiOmXM54kia3IEDb5H7FsM8= +github.com/luxfi/accel v1.0.7/go.mod h1:iZD3oxffiMEIT/KvzD8bgwC/cBn4AYlMW3QJpbRa4RE= +github.com/luxfi/age v1.4.0 h1:tU5q65RQSQdaVq64Z/DVUhiPQMoMPQT6pr41LsvG0AQ= +github.com/luxfi/age v1.4.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0= +github.com/luxfi/cache v1.1.0 h1:6LUyGGZ+rrMAJBbAU6+UwkcamXj3zsboRUodIof2Ong= +github.com/luxfi/cache v1.1.0/go.mod h1:9GvlEEE9rFPaaWxvVpSPwW8ZMo2+8VMNNcuPa4AwzPg= +github.com/luxfi/ids v1.2.9 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI= +github.com/luxfi/ids v1.2.9/go.mod h1:khJOEdOPxd22yn0jcVrnbX1ADa0GHn5Y74gvCzN5BYc= +github.com/luxfi/log v1.4.1 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw= +github.com/luxfi/log v1.4.1/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72jk= +github.com/luxfi/mock v0.1.0 h1:IwElfNu+T9sXvzFX6tudPDx1vqPuACRSRdxpD5lxW+o= +github.com/luxfi/mock v0.1.0/go.mod h1:izF+9K0gGzFC9zERn6Po37v46eLdPB+EIsDjL3GLk+U= +github.com/luxfi/utils v1.1.0 h1:ti7HvjNwJd4ILDMERJtOAWE9mF8l+zqDVkgWnF7Agic= +github.com/luxfi/utils v1.1.0/go.mod h1:ABqhBdGNig0CaDcnNYldv1byS0BTNi5IKPbJSPF1p98= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/gpu/gpu.go b/gpu/gpu.go index e2779f0..4db29c0 100644 --- a/gpu/gpu.go +++ b/gpu/gpu.go @@ -1,65 +1,41 @@ -// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved. +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. // See the file LICENSE for licensing terms. -// Package gpu provides GPU-accelerated cryptographic operations. -// When GPU hardware is not available, all operations return errors. +// Package gpu exposes the runtime status of the GPU acceleration layer used +// by luxfi/crypto. +// +// In normal use, callers should NOT call this package directly. Each +// algorithm package (keccak, sha256, bls, mldsa, ...) decides at the call +// site whether to dispatch to the GPU based on backend.Default() and the +// batch threshold. This package is a single, narrow surface so consumers +// can probe the status of the layer for diagnostics and monitoring. package gpu -import "errors" - -// HashType represents the type of hash function to use. -type HashType int - -const ( - HashTypeSHA3_256 HashType = iota - HashTypeSHA256 - HashTypeBlake3 +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/internal/gpuhost" ) -// GPUAvailable returns whether GPU acceleration is available. -func GPUAvailable() bool { - return false +// Available returns true when an accel session was successfully created +// and at least one backend is reporting available devices. +func Available() bool { return gpuhost.Available() } + +// Backend returns the name of the active backend ("metal", "cuda", "webgpu", +// or "" when no GPU is available). +func Backend() string { + if !Available() { + return "" + } + return gpuhost.Session().Backend().String() } -// GetBackend returns the GPU backend name. -func GetBackend() string { - return "none" +// Devices returns the list of devices visible to the accel layer. +func Devices() []accel.DeviceInfo { + if !Available() { + return nil + } + return accel.Devices() } -// SHA3_256 returns zeroed output when GPU is not available. -// Callers must check GPUAvailable() before relying on this output. -func SHA3_256(input []byte) []byte { - return make([]byte, 32) -} - -// BatchHash computes batch hashes. Returns error when GPU is not available. -func BatchHash(inputs [][]byte, hashType HashType) ([][]byte, error) { - return nil, errors.New("GPU not available") -} - -// ThresholdContext represents a threshold signature context. -type ThresholdContext struct { - threshold uint32 - totalSigners uint32 -} - -// NewThresholdContext creates a new threshold context. -func NewThresholdContext(threshold, totalSigners uint32) (*ThresholdContext, error) { - return &ThresholdContext{ - threshold: threshold, - totalSigners: totalSigners, - }, nil -} - -// Close releases resources. -func (c *ThresholdContext) Close() {} - -// PartialSign creates a partial signature. -func (c *ThresholdContext) PartialSign(index uint32, share, msg []byte) ([]byte, error) { - return nil, errors.New("GPU not available") -} - -// Combine combines partial signatures. -func (c *ThresholdContext) Combine(partialSigs [][]byte, indices []uint32) ([]byte, error) { - return nil, errors.New("GPU not available") -} +// Version returns the underlying accel library version string. +func Version() string { return accel.GetVersion() } diff --git a/gpu/gpu_test.go b/gpu/gpu_test.go new file mode 100644 index 0000000..51901df --- /dev/null +++ b/gpu/gpu_test.go @@ -0,0 +1,28 @@ +package gpu + +import "testing" + +func TestProbe(t *testing.T) { + // Available is allowed to be true or false depending on host; + // just exercise the API. + _ = Available() + + if Available() { + if Backend() == "" { + t.Error("Available()=true but Backend() empty") + } + } else { + if Backend() != "" { + t.Errorf("Available()=false but Backend()=%q", Backend()) + } + } + + if Version() == "" { + t.Error("Version() is empty") + } + + // Devices must be non-nil only when Available is true. + if Available() && Devices() == nil { + t.Error("Available()=true but Devices() is nil") + } +} diff --git a/internal/gpuhost/gpuhost.go b/internal/gpuhost/gpuhost.go new file mode 100644 index 0000000..07c7866 --- /dev/null +++ b/internal/gpuhost/gpuhost.go @@ -0,0 +1,67 @@ +// Package gpuhost provides the runtime hook between luxfi/crypto algorithm +// packages and github.com/luxfi/accel. +// +// gpuhost owns a singleton accel session and exposes thin helpers that +// algorithm packages call to ask "is GPU available right now?" and to +// fetch the session for batch operations. +// +// gpuhost is internal: callers outside luxfi/crypto must use the public +// dispatchers in each algorithm package, which call gpuhost. +package gpuhost + +import ( + "sync" + + "github.com/luxfi/accel" +) + +var ( + once sync.Once + sess *accel.Session + initErr error + available bool +) + +// Init initialises accel exactly once and creates the shared session. +// Safe to call from any goroutine. Subsequent calls are no-ops. +func Init() { + once.Do(func() { + if err := accel.Init(); err != nil { + initErr = err + return + } + if !accel.Available() { + return // accel ready but no devices; available stays false + } + s, err := accel.NewSession() + if err != nil { + initErr = err + return + } + sess = s + available = true + }) +} + +// Available returns true when an accel session was successfully created +// and is currently usable. Algorithm packages check this before deciding +// to dispatch to the GPU path. +func Available() bool { + Init() + return available +} + +// Session returns the shared accel.Session, or nil if no GPU is available. +// Callers must check Available() first or guard nil at the call site. +func Session() *accel.Session { + Init() + return sess +} + +// InitError returns the last error from accel initialisation. It is +// non-nil only when the accel library itself failed to load; an +// "accel works, no device" state returns nil. +func InitError() error { + Init() + return initErr +} diff --git a/keccak/doc.go b/keccak/doc.go new file mode 100644 index 0000000..445fbac --- /dev/null +++ b/keccak/doc.go @@ -0,0 +1,12 @@ +// Package keccak implements the Keccak-256 hash function used by Ethereum. +// +// This is the canonical entry point for Keccak-256 in the luxfi/crypto module. +// It dispatches between three implementations: +// +// - vanilla: golang.org/x/crypto/sha3.NewLegacyKeccak256 (pure Go) +// - cgo: uses libluxcrypto's optimised C implementation when CGO is on +// - gpu: batch-routes through github.com/luxfi/accel for batches that +// exceed the BatchThreshold cutoff +// +// The dispatcher honours backend.Default(); see github.com/luxfi/crypto/backend. +package keccak diff --git a/keccak/gpu.go b/keccak/gpu.go new file mode 100644 index 0000000..ddc406a --- /dev/null +++ b/keccak/gpu.go @@ -0,0 +1,73 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package keccak + +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/internal/gpuhost" +) + +// batchGPU returns (true, nil) when it produced output on the GPU, +// (false, nil) when the GPU was not eligible (callers must fall through), +// or (true, err) when GPU dispatch was attempted and failed. Today this +// helper is non-CGO compatible because lux/accel itself returns +// ErrNoBackends in that mode — the caller treats both shapes as "fall +// back to vanilla". +func batchGPU(inputs [][]byte, out [][Size]byte) (handled bool, err error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + // Pad inputs to a fixed width for batch dispatch. The accel API accepts + // [N, W] uint8 tensors. W must equal the longest input rounded up. + width := 0 + for _, in := range inputs { + if len(in) > width { + width = len(in) + } + } + if width == 0 { + // All inputs empty: still well-defined per Keccak. + empty := sumVanilla(nil) + for i := range out { + out[i] = empty + } + return true, nil + } + + flat := make([]uint8, len(inputs)*width) + for i, in := range inputs { + copy(flat[i*width:(i+1)*width], in) + } + inT, err := accel.NewTensorWithData[uint8](sess, []int{len(inputs), width}, flat) + if err != nil { + return false, nil + } + defer inT.Close() + + outT, err := accel.NewTensor[uint8](sess, []int{len(inputs), Size}) + if err != nil { + return false, nil + } + defer outT.Close() + + if err := sess.Crypto().Keccak256(inT.Untyped(), outT.Untyped()); err != nil { + return false, nil + } + + bytes, err := outT.ToSlice() + if err != nil { + return false, nil + } + for i := range out { + copy(out[i][:], bytes[i*Size:(i+1)*Size]) + } + return true, nil +} diff --git a/keccak/keccak.go b/keccak/keccak.go new file mode 100644 index 0000000..39c5c46 --- /dev/null +++ b/keccak/keccak.go @@ -0,0 +1,105 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package keccak + +import ( + "hash" + "sync" + + "github.com/luxfi/crypto/backend" + "golang.org/x/crypto/sha3" +) + +// Size is the output size of Keccak-256 in bytes. +const Size = 32 + +// BatchThreshold is the minimum batch length at which Sum256Batch will try to +// route through GPU (lux/accel). Below this threshold the vanilla path is +// always faster (PCIe round-trip dominates). +// +// Tuned empirically on Apple M1 Max and NVIDIA A100; expose as a knob so +// downstream profilers can override per workload. +var BatchThreshold = 256 + +var pool = sync.Pool{ + New: func() any { return sha3.NewLegacyKeccak256().(hash.Hash) }, +} + +// Sum256 returns the Keccak-256 hash of in. Allocations: 1. +func Sum256(in []byte) [Size]byte { + switch backend.Resolve(false, false) { + // Single-input keccak: GPU dispatch is uneconomic; cgo path identical to + // vanilla today (golang.org/x/crypto/sha3 is asm-accelerated). One path. + default: + return sumVanilla(in) + } +} + +// Sum256Hex is a convenience that returns a hex string. +func Sum256Hex(in []byte) string { + h := Sum256(in) + const hex = "0123456789abcdef" + out := make([]byte, 2*Size) + for i, b := range h { + out[2*i] = hex[b>>4] + out[2*i+1] = hex[b&0x0f] + } + return string(out) +} + +// New returns a hash.Hash computing Keccak-256. +// +// Use Sum256 when you have a contiguous input; New when you need to write +// incrementally. +func New() hash.Hash { + return sha3.NewLegacyKeccak256() +} + +// Concat returns the Keccak-256 hash of the concatenation of all inputs, +// without allocating an intermediate buffer. +func Concat(inputs ...[]byte) [Size]byte { + h := pool.Get().(hash.Hash) + defer pool.Put(h) + h.Reset() + for _, b := range inputs { + h.Write(b) + } + var out [Size]byte + h.Sum(out[:0]) + return out +} + +// Sum256Batch computes Keccak-256 for a batch of inputs. +// +// When the batch is large enough and the GPU backend is available the +// computation runs on the GPU; otherwise it runs on the CPU. The output is +// always byte-identical to repeated calls to Sum256. +func Sum256Batch(inputs [][]byte) [][Size]byte { + out := make([][Size]byte, len(inputs)) + if len(inputs) == 0 { + return out + } + + // GPU path is gated on backend resolution AND batch size. + if len(inputs) >= BatchThreshold { + if ok, err := batchGPU(inputs, out); ok && err == nil { + return out + } + } + + for i, in := range inputs { + out[i] = sumVanilla(in) + } + return out +} + +func sumVanilla(in []byte) [Size]byte { + h := pool.Get().(hash.Hash) + defer pool.Put(h) + h.Reset() + h.Write(in) + var out [Size]byte + h.Sum(out[:0]) + return out +} diff --git a/keccak/keccak_test.go b/keccak/keccak_test.go new file mode 100644 index 0000000..66ab100 --- /dev/null +++ b/keccak/keccak_test.go @@ -0,0 +1,115 @@ +package keccak + +import ( + "encoding/hex" + "testing" +) + +// Vectors from Ethereum / NIST KAT for Keccak-256 (the original, not SHA3-256). +var vectors = []struct { + in, want string +}{ + {"", "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"}, + {"abc", "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45"}, + {"hello", "1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"}, + {"The quick brown fox jumps over the lazy dog", "4d741b6f1eb29cb2a9b9911c82f56fa8d73b04959d3d9d222895df6c0b28aa15"}, +} + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("hex decode: %v", err) + } + return b +} + +func TestSum256Vectors(t *testing.T) { + for _, v := range vectors { + got := Sum256([]byte(v.in)) + want := mustHex(t, v.want) + if string(got[:]) != string(want) { + t.Errorf("Sum256(%q) = %x; want %s", v.in, got, v.want) + } + } +} + +func TestSum256BatchMatchesScalar(t *testing.T) { + inputs := make([][]byte, 16) + for i, v := range vectors { + inputs[i] = []byte(v.in) + } + for i := len(vectors); i < 16; i++ { + inputs[i] = []byte("padding") + } + + got := Sum256Batch(inputs) + for i, in := range inputs { + want := Sum256(in) + if got[i] != want { + t.Errorf("batch[%d] mismatch: got %x want %x", i, got[i], want) + } + } +} + +func TestSum256BatchLargeMatchesScalar(t *testing.T) { + // Cross batch threshold to exercise GPU path when present. + inputs := make([][]byte, BatchThreshold+8) + for i := range inputs { + inputs[i] = []byte("input-" + string(rune('a'+(i%26)))) + } + got := Sum256Batch(inputs) + for i, in := range inputs { + want := Sum256(in) + if got[i] != want { + t.Errorf("batch[%d] mismatch", i) + } + } +} + +func TestNewIncrementalEqualsSum256(t *testing.T) { + in := []byte("The quick brown fox jumps over the lazy dog") + h := New() + h.Write(in[:10]) + h.Write(in[10:]) + got := h.Sum(nil) + + want := Sum256(in) + if string(got) != string(want[:]) { + t.Errorf("incremental %x != contiguous %x", got, want) + } +} + +func TestConcat(t *testing.T) { + got := Concat([]byte("hello"), []byte(" "), []byte("world")) + want := Sum256([]byte("hello world")) + if got != want { + t.Errorf("Concat = %x; want %x", got, want) + } +} + +func TestSum256Hex(t *testing.T) { + got := Sum256Hex([]byte("abc")) + if got != "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45" { + t.Errorf("Sum256Hex(abc) = %s", got) + } +} + +func BenchmarkSum256(b *testing.B) { + in := make([]byte, 1024) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = Sum256(in) + } +} + +func BenchmarkSum256Batch(b *testing.B) { + inputs := make([][]byte, BatchThreshold) + for i := range inputs { + inputs[i] = make([]byte, 256) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = Sum256Batch(inputs) + } +} diff --git a/mldsa/batch.go b/mldsa/batch.go new file mode 100644 index 0000000..6807b9e --- /dev/null +++ b/mldsa/batch.go @@ -0,0 +1,43 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package mldsa + +// BatchThreshold is the minimum batch length at which BatchVerify will try +// to dispatch through github.com/luxfi/accel. +var BatchThreshold = 64 + +// BatchVerify verifies a slice of (pub, msg, sig) triples for the same +// ML-DSA mode. The result slice has one boolean per input. When the batch +// is large enough and a GPU backend is available the verification runs on +// the GPU; otherwise it runs serially on the CPU using VerifySignature. +// +// All inputs must use the same mode; if pubs[i].mode differs from the +// first one, BatchVerify panics. +func BatchVerify(pubs []*PublicKey, msgs [][]byte, sigs [][]byte) []bool { + n := len(pubs) + if n != len(msgs) || n != len(sigs) { + panic("mldsa.BatchVerify: pubs/msgs/sigs length mismatch") + } + out := make([]bool, n) + if n == 0 { + return out + } + mode := pubs[0].mode + for i := 1; i < n; i++ { + if pubs[i].mode != mode { + panic("mldsa.BatchVerify: mixed modes not supported") + } + } + + if n >= BatchThreshold && mode == MLDSA65 { + // accel exposes a Dilithium3 ≃ ML-DSA-65 batch verify kernel. + if ok, err := batchVerifyGPU(pubs, msgs, sigs, out); ok && err == nil { + return out + } + } + for i := range pubs { + out[i] = pubs[i].VerifySignature(msgs[i], sigs[i]) + } + return out +} diff --git a/mldsa/batch_test.go b/mldsa/batch_test.go new file mode 100644 index 0000000..8eddb42 --- /dev/null +++ b/mldsa/batch_test.go @@ -0,0 +1,40 @@ +package mldsa + +import ( + "crypto/rand" + "testing" +) + +func TestBatchVerifyMatchesScalar(t *testing.T) { + for _, mode := range []Mode{MLDSA44, MLDSA65, MLDSA87} { + t.Run(map[Mode]string{MLDSA44: "44", MLDSA65: "65", MLDSA87: "87"}[mode], func(t *testing.T) { + n := BatchThreshold + 2 + pubs := make([]*PublicKey, n) + msgs := make([][]byte, n) + sigs := make([][]byte, n) + for i := 0; i < n; i++ { + priv, err := GenerateKey(rand.Reader, mode) + if err != nil { + t.Fatal(err) + } + pubs[i] = priv.PublicKey + msgs[i] = []byte{byte(i), byte(i + 1)} + sig, err := priv.SignCtx(rand.Reader, msgs[i], nil) + if err != nil { + t.Fatal(err) + } + sigs[i] = sig + } + + got := BatchVerify(pubs, msgs, sigs) + for i := range got { + if !got[i] { + t.Errorf("batch[%d] reported invalid", i) + } + if pubs[i].VerifySignature(msgs[i], sigs[i]) != got[i] { + t.Errorf("batch/scalar disagree at %d", i) + } + } + }) + } +} diff --git a/mldsa/gpu.go b/mldsa/gpu.go new file mode 100644 index 0000000..b0b9a16 --- /dev/null +++ b/mldsa/gpu.go @@ -0,0 +1,88 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package mldsa + +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/internal/gpuhost" +) + +// batchVerifyGPU is wired only for ML-DSA-65 (Dilithium3) which is the size +// accel.LatticeOps publishes batch kernels for. Other modes fall through. +func batchVerifyGPU(pubs []*PublicKey, msgs [][]byte, sigs [][]byte, out []bool) (bool, error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + n := len(pubs) + pkSize := MLDSA65PublicKeySize + sigSize := MLDSA65SignatureSize + + width := 0 + for _, m := range msgs { + if len(m) > width { + width = len(m) + } + } + if width == 0 { + width = 1 + } + + 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() + + if err := sess.Lattice().DilithiumVerifyBatch(mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped()); err != nil { + return false, nil + } + bytes, err := rT.ToSlice() + if err != nil { + return false, nil + } + for i, b := range bytes { + out[i] = b == 1 + } + return true, nil +} diff --git a/mlkem/batch.go b/mlkem/batch.go new file mode 100644 index 0000000..219aa84 --- /dev/null +++ b/mlkem/batch.go @@ -0,0 +1,78 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package mlkem + +// BatchThreshold is the minimum batch length at which BatchEncapsulate / +// BatchDecapsulate will try to dispatch through github.com/luxfi/accel. +var BatchThreshold = 64 + +// BatchEncapsulate runs Encapsulate for a slice of public keys, returning the +// resulting (ciphertext, sharedSecret) pairs. All keys must be the same mode. +// +// When the batch is large enough and a GPU backend is available the +// computation runs on the GPU; otherwise it falls back to per-key +// Encapsulate calls. The output is byte-identical for the deterministic +// path; the streaming path uses fresh randomness either way. +func BatchEncapsulate(pubs []*PublicKey) (cts [][]byte, sss [][]byte, err error) { + n := len(pubs) + if n == 0 { + return nil, nil, nil + } + mode := pubs[0].mode + for i := 1; i < n; i++ { + if pubs[i].mode != mode { + return nil, nil, ErrInvalidKeySize + } + } + + cts = make([][]byte, n) + sss = make([][]byte, n) + + if n >= BatchThreshold && mode == MLKEM768 { + if ok, gerr := batchEncapsulateGPU(pubs, cts, sss); ok && gerr == nil { + return cts, sss, nil + } + } + + for i, pk := range pubs { + ct, ss, e := pk.Encapsulate() + if e != nil { + return nil, nil, e + } + cts[i] = ct + sss[i] = ss + } + return cts, sss, nil +} + +// BatchDecapsulate runs Decapsulate over a slice of (sk, ct) pairs. +func BatchDecapsulate(sks []*PrivateKey, cts [][]byte) (sss [][]byte, err error) { + n := len(sks) + if n != len(cts) { + return nil, ErrInvalidKeySize + } + if n == 0 { + return nil, nil + } + mode := sks[0].mode + for i := 1; i < n; i++ { + if sks[i].mode != mode { + return nil, ErrInvalidKeySize + } + } + sss = make([][]byte, n) + if n >= BatchThreshold && mode == MLKEM768 { + if ok, gerr := batchDecapsulateGPU(sks, cts, sss); ok && gerr == nil { + return sss, nil + } + } + for i, sk := range sks { + ss, e := sk.Decapsulate(cts[i]) + if e != nil { + return nil, e + } + sss[i] = ss + } + return sss, nil +} diff --git a/mlkem/batch_test.go b/mlkem/batch_test.go new file mode 100644 index 0000000..ca843cd --- /dev/null +++ b/mlkem/batch_test.go @@ -0,0 +1,52 @@ +package mlkem + +import ( + "bytes" + "testing" +) + +func TestBatchEncapDecap(t *testing.T) { + for _, mode := range []Mode{MLKEM512, MLKEM768, MLKEM1024} { + t.Run(mode.String(), func(t *testing.T) { + n := BatchThreshold + 2 + pubs := make([]*PublicKey, n) + privs := make([]*PrivateKey, n) + for i := 0; i < n; i++ { + pub, priv, err := GenerateKey(mode) + if err != nil { + t.Fatal(err) + } + pubs[i] = pub + privs[i] = priv + } + + cts, sss, err := BatchEncapsulate(pubs) + if err != nil { + t.Fatal(err) + } + if len(cts) != n || len(sss) != n { + t.Fatalf("len(cts)=%d len(sss)=%d, want %d", len(cts), len(sss), n) + } + + recovered, err := BatchDecapsulate(privs, cts) + if err != nil { + t.Fatal(err) + } + for i := 0; i < n; i++ { + if !bytes.Equal(recovered[i], sss[i]) { + t.Errorf("shared secret mismatch at %d", i) + } + } + }) + } +} + +func TestBatchEmpty(t *testing.T) { + cts, sss, err := BatchEncapsulate(nil) + if err != nil { + t.Fatal(err) + } + if cts != nil || sss != nil { + t.Fatal("expected nil/nil for empty input") + } +} diff --git a/mlkem/gpu.go b/mlkem/gpu.go new file mode 100644 index 0000000..7005b9f --- /dev/null +++ b/mlkem/gpu.go @@ -0,0 +1,135 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package mlkem + +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/internal/gpuhost" +) + +// accel publishes Kyber768 batch kernels — the closest match to ML-KEM-768. +// Other modes fall through. + +func batchEncapsulateGPU(pubs []*PublicKey, cts [][]byte, sss [][]byte) (bool, error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + n := len(pubs) + pkSize := MLKEM768PublicKeySize + ctSize := MLKEM768CiphertextSize + ssSize := MLKEM768SharedKeySize + + pkFlat := make([]uint8, n*pkSize) + for i, p := range pubs { + b := p.Bytes() + if len(b) != pkSize { + return false, nil + } + copy(pkFlat[i*pkSize:(i+1)*pkSize], b) + } + + pkT, err := accel.NewTensorWithData[uint8](sess, []int{n, pkSize}, pkFlat) + if err != nil { + return false, nil + } + defer pkT.Close() + ctT, err := accel.NewTensor[uint8](sess, []int{n, ctSize}) + if err != nil { + return false, nil + } + defer ctT.Close() + ssT, err := accel.NewTensor[uint8](sess, []int{n, ssSize}) + if err != nil { + return false, nil + } + defer ssT.Close() + + if err := sess.Lattice().KyberEncapsBatch(pkT.Untyped(), ctT.Untyped(), ssT.Untyped()); err != nil { + return false, nil + } + ctBytes, err := ctT.ToSlice() + if err != nil { + return false, nil + } + ssBytes, err := ssT.ToSlice() + if err != nil { + return false, nil + } + for i := 0; i < n; i++ { + c := make([]byte, ctSize) + copy(c, ctBytes[i*ctSize:(i+1)*ctSize]) + cts[i] = c + s := make([]byte, ssSize) + copy(s, ssBytes[i*ssSize:(i+1)*ssSize]) + sss[i] = s + } + return true, nil +} + +func batchDecapsulateGPU(sks []*PrivateKey, cts [][]byte, sss [][]byte) (bool, error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + n := len(sks) + skSize := MLKEM768PrivateKeySize + ctSize := MLKEM768CiphertextSize + ssSize := MLKEM768SharedKeySize + + skFlat := make([]uint8, n*skSize) + for i, s := range sks { + b := s.Bytes() + if len(b) != skSize { + return false, nil + } + copy(skFlat[i*skSize:(i+1)*skSize], b) + } + ctFlat := make([]uint8, n*ctSize) + for i, c := range cts { + if len(c) != ctSize { + return false, nil + } + copy(ctFlat[i*ctSize:(i+1)*ctSize], c) + } + + skT, err := accel.NewTensorWithData[uint8](sess, []int{n, skSize}, skFlat) + if err != nil { + return false, nil + } + defer skT.Close() + ctT, err := accel.NewTensorWithData[uint8](sess, []int{n, ctSize}, ctFlat) + if err != nil { + return false, nil + } + defer ctT.Close() + ssT, err := accel.NewTensor[uint8](sess, []int{n, ssSize}) + if err != nil { + return false, nil + } + defer ssT.Close() + + if err := sess.Lattice().KyberDecapsBatch(ctT.Untyped(), skT.Untyped(), ssT.Untyped()); err != nil { + return false, nil + } + ssBytes, err := ssT.ToSlice() + if err != nil { + return false, nil + } + for i := 0; i < n; i++ { + s := make([]byte, ssSize) + copy(s, ssBytes[i*ssSize:(i+1)*ssSize]) + sss[i] = s + } + return true, nil +} diff --git a/modexp/doc.go b/modexp/doc.go new file mode 100644 index 0000000..61e31b0 --- /dev/null +++ b/modexp/doc.go @@ -0,0 +1,10 @@ +// Package modexp is the canonical alias for github.com/luxfi/crypto/bigmodexp. +// +// modexp matches the EVM precompile name (EIP-198) and the luxcpp/crypto +// directory layout. The two import paths return identical types. +package modexp + +import "github.com/luxfi/crypto/bigmodexp" + +// Int is the patched big.Int with the fixed Exp implementation. +type Int = bigmodexp.Int diff --git a/modexp/modexp_test.go b/modexp/modexp_test.go new file mode 100644 index 0000000..36ef7a7 --- /dev/null +++ b/modexp/modexp_test.go @@ -0,0 +1,13 @@ +package modexp + +import "testing" + +func TestIntCompiles(t *testing.T) { + a := new(Int).SetInt64(2) + b := new(Int).SetInt64(10) + m := new(Int).SetInt64(1000) + r := new(Int).Exp(a, b, m) + if r.Cmp(new(Int).SetInt64(24)) != 0 { + t.Errorf("2^10 mod 1000 = %v; want 24", r) + } +} diff --git a/ntt/doc.go b/ntt/doc.go new file mode 100644 index 0000000..4ecbdeb --- /dev/null +++ b/ntt/doc.go @@ -0,0 +1,7 @@ +// Package ntt is the canonical entry point for the Number-Theoretic Transform +// used by lattice-based cryptography (ML-KEM, ML-DSA, corona). +// +// The CPU implementation here uses gnark-crypto field arithmetic. The GPU +// path routes through github.com/luxfi/accel which exposes a polynomial +// NTT kernel for prime moduli (Kyber/Dilithium-style q = 3329 / 8380417). +package ntt diff --git a/ntt/ntt.go b/ntt/ntt.go new file mode 100644 index 0000000..86ac01e --- /dev/null +++ b/ntt/ntt.go @@ -0,0 +1,111 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package ntt + +import ( + "errors" + "math/bits" +) + +var ( + ErrNotPow2 = errors.New("ntt: input length must be a power of 2") + ErrModulusZero = errors.New("ntt: modulus must be non-zero") +) + +// NTT computes a forward Number-Theoretic Transform of a in-place using the +// Cooley-Tukey radix-2 algorithm modulo q. omega must be a primitive +// (len(a))-th root of unity modulo q. +// +// Pure Go reference implementation. For production lattice-crypto callers +// should use the algorithm-specific kernels in mldsa/, mlkem/, or accel +// directly; this implementation is for vector kernels and tests. +func NTT(a []uint64, omega, q uint64) error { + n := len(a) + if n == 0 { + return nil + } + if n&(n-1) != 0 { + return ErrNotPow2 + } + if q == 0 { + return ErrModulusZero + } + + // Bit-reverse permutation. + logN := bits.TrailingZeros(uint(n)) + for i := 0; i < n; i++ { + j := int(bits.Reverse(uint(i)) >> (bits.UintSize - logN)) + if j > i { + a[i], a[j] = a[j], a[i] + } + } + + // Iterative butterfly. + for size := 2; size <= n; size <<= 1 { + half := size >> 1 + // w_size = omega^(n/size) mod q + w0 := powMod(omega, uint64(n/size), q) + for start := 0; start < n; start += size { + w := uint64(1) + for k := 0; k < half; k++ { + t := mulMod(w, a[start+k+half], q) + u := a[start+k] + a[start+k] = addMod(u, t, q) + a[start+k+half] = subMod(u, t, q) + w = mulMod(w, w0, q) + } + } + } + return nil +} + +// INTT computes the inverse NTT in-place. omegaInv = omega^-1 mod q, +// nInv = n^-1 mod q. +func INTT(a []uint64, omegaInv, nInv, q uint64) error { + if err := NTT(a, omegaInv, q); err != nil { + return err + } + for i := range a { + a[i] = mulMod(a[i], nInv, q) + } + return nil +} + +// addMod returns (a + b) mod q. +func addMod(a, b, q uint64) uint64 { + s := a + b + if s >= q { + s -= q + } + return s +} + +// subMod returns (a - b) mod q. +func subMod(a, b, q uint64) uint64 { + if a >= b { + return a - b + } + return q - (b - a) +} + +// mulMod returns (a * b) mod q using 128-bit intermediate. +func mulMod(a, b, q uint64) uint64 { + hi, lo := bits.Mul64(a, b) + _, rem := bits.Div64(hi, lo, q) + return rem +} + +// powMod returns base^exp mod q using square-and-multiply. +func powMod(base, exp, q uint64) uint64 { + r := uint64(1) + b := base % q + for exp > 0 { + if exp&1 == 1 { + r = mulMod(r, b, q) + } + b = mulMod(b, b, q) + exp >>= 1 + } + return r +} diff --git a/ntt/ntt_test.go b/ntt/ntt_test.go new file mode 100644 index 0000000..4acd212 --- /dev/null +++ b/ntt/ntt_test.go @@ -0,0 +1,68 @@ +package ntt + +import "testing" + +// Use a tiny prime (97) where 28 is a primitive 8th root of unity: +// 28^8 mod 97 = 1; for k = 1..7, 28^k mod 97 != 1. +const ( + q = uint64(97) + omega = uint64(28) +) + +func TestNTTRoundTrip(t *testing.T) { + want := []uint64{1, 2, 3, 4, 5, 6, 7, 8} + a := append([]uint64(nil), want...) + if err := NTT(a, omega, q); err != nil { + t.Fatal(err) + } + // Output is bit-reversed but transformed; we don't compare against + // hand-computed values here (that's brittle). Instead invert and check + // we get back the original. + omegaInv := powMod(omega, q-2, q) // Fermat + nInv := powMod(uint64(len(a)), q-2, q) + if err := INTT(a, omegaInv, nInv, q); err != nil { + t.Fatal(err) + } + for i, v := range a { + if v != want[i] { + t.Errorf("round trip[%d] = %d; want %d", i, v, want[i]) + } + } +} + +func TestNTTRejectsBadInputs(t *testing.T) { + if err := NTT([]uint64{1, 2, 3}, omega, q); err != ErrNotPow2 { + t.Errorf("non-pow2: got %v want %v", err, ErrNotPow2) + } + if err := NTT([]uint64{1, 2}, omega, 0); err != ErrModulusZero { + t.Errorf("modulus 0: got %v want %v", err, ErrModulusZero) + } +} + +func TestNTTLinearity(t *testing.T) { + // NTT(a + b) should equal NTT(a) + NTT(b) componentwise mod q. + a := []uint64{1, 0, 0, 0, 0, 0, 0, 0} + b := []uint64{0, 1, 0, 0, 0, 0, 0, 0} + sum := make([]uint64, 8) + for i := range sum { + sum[i] = (a[i] + b[i]) % q + } + + A := append([]uint64(nil), a...) + B := append([]uint64(nil), b...) + S := append([]uint64(nil), sum...) + if err := NTT(A, omega, q); err != nil { + t.Fatal(err) + } + if err := NTT(B, omega, q); err != nil { + t.Fatal(err) + } + if err := NTT(S, omega, q); err != nil { + t.Fatal(err) + } + for i := range S { + if want := (A[i] + B[i]) % q; want != S[i] { + t.Errorf("linearity fail at %d: got %d want %d", i, S[i], want) + } + } +} diff --git a/pedersen/doc.go b/pedersen/doc.go new file mode 100644 index 0000000..51f6e3b --- /dev/null +++ b/pedersen/doc.go @@ -0,0 +1,11 @@ +// Package pedersen is the canonical entry point for Pedersen commitments +// over the BN254 scalar field. +// +// A Pedersen commitment Commit(m, r) = m*G + r*H where G and H are +// independently sampled, prover-public generators of an elliptic curve +// group. The commitment is hiding (under the discrete-log assumption for +// H) and binding. +// +// This package wraps gnark-crypto BN254 G1 arithmetic. For Verkle-tree +// commitments use luxfi/crypto/ipa instead. +package pedersen diff --git a/pedersen/pedersen.go b/pedersen/pedersen.go new file mode 100644 index 0000000..14119f8 --- /dev/null +++ b/pedersen/pedersen.go @@ -0,0 +1,81 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package pedersen + +import ( + "crypto/rand" + "errors" + "io" + "math/big" + + bn254 "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" +) + +// Commitment is a single point on BN254 G1. +type Commitment = bn254.G1Affine + +// Generators holds the two independent generators G and H used for a single +// Pedersen commitment scheme instance. Reusing the same Generators across +// commitments is required for them to add and compare meaningfully. +type Generators struct { + G bn254.G1Jac + H bn254.G1Jac +} + +var ErrIdenticalGenerators = errors.New("pedersen: G and H must be independent generators") + +// NewGenerators samples two independent G1 generators. rng may be nil to use +// crypto/rand. The two generators are derived from independent hashes-to-curve +// of fresh randomness, ensuring no known relation between them. +func NewGenerators(rng io.Reader) (*Generators, error) { + if rng == nil { + rng = rand.Reader + } + gBytes := make([]byte, 64) + if _, err := rng.Read(gBytes); err != nil { + return nil, err + } + hBytes := make([]byte, 64) + if _, err := rng.Read(hBytes); err != nil { + return nil, err + } + g, err := bn254.HashToG1(gBytes, []byte("LUX_PEDERSEN_G")) + if err != nil { + return nil, err + } + h, err := bn254.HashToG1(hBytes, []byte("LUX_PEDERSEN_H")) + if err != nil { + return nil, err + } + if g.Equal(&h) { + return nil, ErrIdenticalGenerators + } + gen := &Generators{} + gen.G.FromAffine(&g) + gen.H.FromAffine(&h) + return gen, nil +} + +// Commit returns Commit(m, r) = m*G + r*H. +func (gens *Generators) Commit(m, r *fr.Element) Commitment { + var mScalar, rScalar fr.Element + mScalar.Set(m) + rScalar.Set(r) + + mBig := new(big.Int) + rBig := new(big.Int) + mScalar.BigInt(mBig) + rScalar.BigInt(rBig) + + var mG, rH bn254.G1Jac + mG.ScalarMultiplication(&gens.G, mBig) + rH.ScalarMultiplication(&gens.H, rBig) + + var c bn254.G1Jac + c.Set(&mG).AddAssign(&rH) + var out Commitment + out.FromJacobian(&c) + return out +} diff --git a/pedersen/pedersen_test.go b/pedersen/pedersen_test.go new file mode 100644 index 0000000..f0b1104 --- /dev/null +++ b/pedersen/pedersen_test.go @@ -0,0 +1,53 @@ +package pedersen + +import ( + "testing" + + bn254 "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/consensys/gnark-crypto/ecc/bn254/fr" +) + +func TestCommitmentHomomorphism(t *testing.T) { + gens, err := NewGenerators(nil) + if err != nil { + t.Fatal(err) + } + var m1, r1, m2, r2 fr.Element + m1.SetUint64(7) + r1.SetUint64(13) + m2.SetUint64(5) + r2.SetUint64(11) + + c1 := gens.Commit(&m1, &r1) + c2 := gens.Commit(&m2, &r2) + + // Commit(m1+m2, r1+r2) should equal c1 + c2. + var mSum, rSum fr.Element + mSum.Add(&m1, &m2) + rSum.Add(&r1, &r2) + cSum := gens.Commit(&mSum, &rSum) + + var aJ, bJ bn254.G1Jac + aJ.FromAffine(&c1) + bJ.FromAffine(&c2) + aJ.AddAssign(&bJ) + var got bn254.G1Affine + got.FromJacobian(&aJ) + + if !got.Equal(&cSum) { + t.Errorf("Pedersen homomorphism violated:\ngot=%v\nwant=%v", got, cSum) + } +} + +func TestNewGeneratorsIndependent(t *testing.T) { + gens, err := NewGenerators(nil) + if err != nil { + t.Fatal(err) + } + var gAff, hAff bn254.G1Affine + gAff.FromJacobian(&gens.G) + hAff.FromJacobian(&gens.H) + if gAff.Equal(&hAff) { + t.Error("G and H must be independent") + } +} diff --git a/polymul/doc.go b/polymul/doc.go new file mode 100644 index 0000000..b49b7ec --- /dev/null +++ b/polymul/doc.go @@ -0,0 +1,12 @@ +// Package polymul is the canonical entry point for polynomial multiplication +// in the lattice-cryptography ring Z_q[X] / (X^N + 1). +// +// The CPU implementation is a schoolbook O(N^2) multiplication that handles +// arbitrary moduli. For production lattice crypto callers should use the +// algorithm-specific NTT-based multiplication in mldsa/ or mlkem/, or +// route through the GPU via github.com/luxfi/accel which has fused +// NTT+pointwise+INTT kernels. +// +// Naming follows the luxcpp/crypto/poly_mul/ directory; both polymul (Go +// idiomatic) and the underscore form are accepted as identifiers. +package polymul diff --git a/polymul/polymul.go b/polymul/polymul.go new file mode 100644 index 0000000..68eb500 --- /dev/null +++ b/polymul/polymul.go @@ -0,0 +1,63 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package polymul + +import ( + "errors" + "math/bits" +) + +var ( + ErrLengthMismatch = errors.New("polymul: a and b must have equal length") + ErrModulusZero = errors.New("polymul: modulus must be non-zero") +) + +// MulNegacyclic multiplies two polynomials a and b in Z_q[X] / (X^N + 1). +// a, b, and the returned polynomial all have length N. q must be non-zero. +// +// Schoolbook O(N^2). For N up to 256 this is the simplest correct path. +// For production lattice work use NTT-based multiplication. +func MulNegacyclic(a, b []uint64, q uint64) ([]uint64, error) { + n := len(a) + if n != len(b) { + return nil, ErrLengthMismatch + } + if q == 0 { + return nil, ErrModulusZero + } + c := make([]uint64, n) + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + t := mulMod(a[i], b[j], q) + k := i + j + if k < n { + c[k] = addMod(c[k], t, q) + } else { + c[k-n] = subMod(c[k-n], t, q) + } + } + } + return c, nil +} + +func addMod(a, b, q uint64) uint64 { + s := a + b + if s >= q { + s -= q + } + return s +} + +func subMod(a, b, q uint64) uint64 { + if a >= b { + return a - b + } + return q - (b - a) +} + +func mulMod(a, b, q uint64) uint64 { + hi, lo := bits.Mul64(a, b) + _, rem := bits.Div64(hi, lo, q) + return rem +} diff --git a/polymul/polymul_test.go b/polymul/polymul_test.go new file mode 100644 index 0000000..38d011c --- /dev/null +++ b/polymul/polymul_test.go @@ -0,0 +1,42 @@ +package polymul + +import "testing" + +func TestMulNegacyclicBasic(t *testing.T) { + // (1 + X) * (1 + X) mod (X^4 + 1) = 1 + 2X + X^2. + a := []uint64{1, 1, 0, 0} + b := []uint64{1, 1, 0, 0} + c, err := MulNegacyclic(a, b, 17) + if err != nil { + t.Fatal(err) + } + want := []uint64{1, 2, 1, 0} + for i, v := range want { + if c[i] != v { + t.Errorf("c[%d] = %d; want %d", i, c[i], v) + } + } +} + +func TestMulNegacyclicWrap(t *testing.T) { + // X^3 * X mod (X^4 + 1) = -1 = q-1. + a := []uint64{0, 0, 0, 1} + b := []uint64{0, 1, 0, 0} + c, err := MulNegacyclic(a, b, 17) + if err != nil { + t.Fatal(err) + } + want := []uint64{16, 0, 0, 0} + for i, v := range want { + if c[i] != v { + t.Errorf("c[%d] = %d; want %d", i, c[i], v) + } + } +} + +func TestMulNegacyclicLengthMismatch(t *testing.T) { + _, err := MulNegacyclic([]uint64{1, 2}, []uint64{1, 2, 3}, 17) + if err != ErrLengthMismatch { + t.Errorf("got %v want %v", err, ErrLengthMismatch) + } +} diff --git a/poseidon/doc.go b/poseidon/doc.go new file mode 100644 index 0000000..c9d3c6f --- /dev/null +++ b/poseidon/doc.go @@ -0,0 +1,16 @@ +// Package poseidon is the canonical entry point for Poseidon-flavoured +// hash functions in luxfi/crypto. +// +// Two variants are exposed: +// +// - Sum: classical Poseidon (Grassi-Khovratovich-Lüftenegger-Rechberger- +// Roy-Schofnegger 2021) used by zkSNARK toolchains. +// - Sum2: Poseidon2 (Grassi-Khovratovich-Roy 2023) — preferred for new +// code as it offers a 2-3x speedup on Cortex/x86 with the same +// security level. +// +// The classical Poseidon entry uses the gnark-crypto BN254 implementation +// for compatibility with the Verkle and Plonky2 ecosystems. The Poseidon2 +// entry routes through luxfi/crypto/hash/poseidon2 which has GPU +// acceleration on platforms that ship the luxcpp Metal kernel. +package poseidon diff --git a/poseidon/poseidon.go b/poseidon/poseidon.go new file mode 100644 index 0000000..035bac5 --- /dev/null +++ b/poseidon/poseidon.go @@ -0,0 +1,35 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package poseidon + +import ( + "errors" + + "github.com/consensys/gnark-crypto/ecc/bn254/fr/poseidon2" +) + +// FieldSize is the size of a BN254 scalar field element in bytes. +const FieldSize = 32 + +var ErrInvalidFieldSize = errors.New("poseidon: input must be a multiple of 32 bytes") + +// Sum2 computes the Poseidon2 hash of `inputs` interpreted as a sequence of +// 32-byte BN254 scalar field elements. It returns a single 32-byte digest. +// +// inputs must have length divisible by 32. Empty input is permitted and +// returns the Poseidon2 hash of the empty preimage (well-defined per the +// gnark-crypto spec). +func Sum2(inputs []byte) ([FieldSize]byte, error) { + var out [FieldSize]byte + if len(inputs)%FieldSize != 0 { + return out, ErrInvalidFieldSize + } + h := poseidon2.NewMerkleDamgardHasher() + if _, err := h.Write(inputs); err != nil { + return out, err + } + digest := h.Sum(nil) + copy(out[:], digest) + return out, nil +} diff --git a/poseidon/poseidon_test.go b/poseidon/poseidon_test.go new file mode 100644 index 0000000..15177d0 --- /dev/null +++ b/poseidon/poseidon_test.go @@ -0,0 +1,39 @@ +package poseidon + +import "testing" + +func TestSum2Deterministic(t *testing.T) { + in := make([]byte, 64) + for i := range in { + in[i] = byte(i) + } + a, err := Sum2(in) + if err != nil { + t.Fatal(err) + } + b, err := Sum2(in) + if err != nil { + t.Fatal(err) + } + if a != b { + t.Errorf("Sum2 not deterministic: %x vs %x", a, b) + } +} + +func TestSum2RejectsNon32(t *testing.T) { + _, err := Sum2([]byte{1, 2, 3}) + if err != ErrInvalidFieldSize { + t.Errorf("expected ErrInvalidFieldSize; got %v", err) + } +} + +func TestSum2DistinctInputs(t *testing.T) { + in1 := make([]byte, 32) + in2 := make([]byte, 32) + in2[0] = 1 + a, _ := Sum2(in1) + b, _ := Sum2(in2) + if a == b { + t.Errorf("distinct inputs collide: %x", a) + } +} diff --git a/ripemd160/doc.go b/ripemd160/doc.go new file mode 100644 index 0000000..4d3700b --- /dev/null +++ b/ripemd160/doc.go @@ -0,0 +1,5 @@ +// Package ripemd160 is the canonical entry point for RIPEMD-160 in luxfi/crypto. +// +// It wraps golang.org/x/crypto/ripemd160. RIPEMD-160 is used by Bitcoin and +// Lux to derive addresses; it is not recommended for new general-purpose use. +package ripemd160 diff --git a/ripemd160/ripemd160.go b/ripemd160/ripemd160.go new file mode 100644 index 0000000..c372087 --- /dev/null +++ b/ripemd160/ripemd160.go @@ -0,0 +1,25 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package ripemd160 + +import ( + "hash" + + xripemd "golang.org/x/crypto/ripemd160" //nolint:gosec +) + +// Size is the output size of RIPEMD-160 in bytes. +const Size = xripemd.Size + +// Sum160 computes the RIPEMD-160 digest of in. +func Sum160(in []byte) [Size]byte { + h := xripemd.New() //nolint:gosec + h.Write(in) + var out [Size]byte + h.Sum(out[:0]) + return out +} + +// New returns a new hash.Hash computing RIPEMD-160. +func New() hash.Hash { return xripemd.New() } //nolint:gosec diff --git a/ripemd160/ripemd160_test.go b/ripemd160/ripemd160_test.go new file mode 100644 index 0000000..97f5ca5 --- /dev/null +++ b/ripemd160/ripemd160_test.go @@ -0,0 +1,34 @@ +package ripemd160 + +import ( + "encoding/hex" + "testing" +) + +func TestSum160Vectors(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", "9c1185a5c5e9fc54612808977ee8f548b2258d31"}, + {"abc", "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc"}, + {"message digest", "5d0689ef49d2fae572b881b123a85ffa21595f36"}, + } + for _, c := range cases { + got := Sum160([]byte(c.in)) + want, _ := hex.DecodeString(c.want) + if string(got[:]) != string(want) { + t.Errorf("Sum160(%q) = %x; want %s", c.in, got, c.want) + } + } +} + +func TestNewIncremental(t *testing.T) { + h := New() + h.Write([]byte("a")) + h.Write([]byte("bc")) + got := h.Sum(nil) + want, _ := hex.DecodeString("8eb208f7e05d987a9b044a8e98c6b087f15a0bfc") + if string(got) != string(want) { + t.Errorf("incremental got %x want %x", got, want) + } +} diff --git a/secp256k1/batch.go b/secp256k1/batch.go new file mode 100644 index 0000000..6708a6f --- /dev/null +++ b/secp256k1/batch.go @@ -0,0 +1,35 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package secp256k1 + +// BatchThreshold is the minimum batch length at which BatchVerifySignature +// will try to dispatch through github.com/luxfi/accel. +var BatchThreshold = 64 + +// BatchVerifySignature verifies a slice of (pubkey, msgHash, sig) triples. +// pubkeys may be either 33-byte compressed or 65-byte uncompressed; all +// elements of pubkeys must use the same encoding. msgHashes must be 32 +// bytes each; signatures must be 64 bytes ([R || S]). +// +// Returns one boolean per input. The result is byte-identical to repeated +// calls to VerifySignature. +func BatchVerifySignature(pubkeys [][]byte, msgHashes [][]byte, sigs [][]byte) []bool { + n := len(pubkeys) + if n != len(msgHashes) || n != len(sigs) { + panic("secp256k1.BatchVerifySignature: length mismatch") + } + out := make([]bool, n) + if n == 0 { + return out + } + if n >= BatchThreshold { + if ok, err := batchVerifyGPU(pubkeys, msgHashes, sigs, out); ok && err == nil { + return out + } + } + for i := range pubkeys { + out[i] = VerifySignature(pubkeys[i], msgHashes[i], sigs[i]) + } + return out +} diff --git a/secp256k1/batch_test.go b/secp256k1/batch_test.go new file mode 100644 index 0000000..611c8b1 --- /dev/null +++ b/secp256k1/batch_test.go @@ -0,0 +1,65 @@ +package secp256k1 + +import ( + "crypto/rand" + "testing" +) + +func TestBatchVerifyMatchesScalar(t *testing.T) { + n := BatchThreshold + 4 + + pubs := make([][]byte, n) + msgs := make([][]byte, n) + sigs := make([][]byte, n) + + for i := 0; i < n; i++ { + seckey := make([]byte, 32) + for { + if _, err := rand.Read(seckey); err != nil { + t.Fatal(err) + } + // Reject zero key (decred rejects). + any := false + for _, b := range seckey { + if b != 0 { + any = true + break + } + } + if any { + break + } + } + + // Derive pubkey via Sign roundtrip on a known msg. + msg := make([]byte, 32) + if _, err := rand.Read(msg); err != nil { + t.Fatal(err) + } + sig, err := Sign(msg, seckey) + if err != nil { + t.Fatalf("Sign: %v", err) + } + pub, err := RecoverPubkey(msg, sig) + if err != nil { + t.Fatalf("RecoverPubkey: %v", err) + } + + // Drop recovery byte for VerifySignature. + sig64 := sig[:64] + + pubs[i] = pub + msgs[i] = msg + sigs[i] = sig64 + } + + got := BatchVerifySignature(pubs, msgs, sigs) + for i := range got { + if !got[i] { + t.Errorf("batch[%d] reported invalid", i) + } + if VerifySignature(pubs[i], msgs[i], sigs[i]) != got[i] { + t.Errorf("batch/scalar disagree at %d", i) + } + } +} diff --git a/secp256k1/gpu.go b/secp256k1/gpu.go new file mode 100644 index 0000000..5ad1965 --- /dev/null +++ b/secp256k1/gpu.go @@ -0,0 +1,86 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package secp256k1 + +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/internal/gpuhost" +) + +func batchVerifyGPU(pubkeys, msgHashes, sigs [][]byte, out []bool) (bool, error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + n := len(pubkeys) + if n == 0 { + return false, nil + } + pkSize := len(pubkeys[0]) + if pkSize != 33 && pkSize != 65 { + return false, nil + } + for _, p := range pubkeys { + if len(p) != pkSize { + return false, nil + } + } + + mFlat := make([]uint8, n*32) + for i, m := range msgHashes { + if len(m) != 32 { + return false, nil + } + copy(mFlat[i*32:(i+1)*32], m) + } + sFlat := make([]uint8, n*64) + for i, s := range sigs { + if len(s) != 64 { + return false, nil + } + copy(sFlat[i*64:(i+1)*64], s) + } + pFlat := make([]uint8, n*pkSize) + for i, p := range pubkeys { + copy(pFlat[i*pkSize:(i+1)*pkSize], p) + } + + mT, err := accel.NewTensorWithData[uint8](sess, []int{n, 32}, mFlat) + if err != nil { + return false, nil + } + defer mT.Close() + sT, err := accel.NewTensorWithData[uint8](sess, []int{n, 64}, 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() + + if err := sess.Crypto().ECDSAVerifyBatch(mT.Untyped(), sT.Untyped(), pT.Untyped(), rT.Untyped()); err != nil { + return false, nil + } + bytes, err := rT.ToSlice() + if err != nil { + return false, nil + } + for i, b := range bytes { + out[i] = b == 1 + } + return true, nil +} diff --git a/sha256/doc.go b/sha256/doc.go new file mode 100644 index 0000000..4fc44d3 --- /dev/null +++ b/sha256/doc.go @@ -0,0 +1,5 @@ +// Package sha256 is the canonical entry point for SHA-256 in luxfi/crypto. +// +// It is a thin wrapper over crypto/sha256 in the standard library, with an +// optional batch entry that can dispatch to GPU through github.com/luxfi/accel. +package sha256 diff --git a/sha256/gpu.go b/sha256/gpu.go new file mode 100644 index 0000000..d90d9eb --- /dev/null +++ b/sha256/gpu.go @@ -0,0 +1,62 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package sha256 + +import ( + "github.com/luxfi/accel" + "github.com/luxfi/crypto/backend" + "github.com/luxfi/crypto/internal/gpuhost" +) + +func batchGPU(inputs [][]byte, out [][Size]byte) (bool, error) { + if backend.Resolve(gpuhost.Available(), false) != backend.GPU { + return false, nil + } + sess := gpuhost.Session() + if sess == nil { + return false, nil + } + + width := 0 + for _, in := range inputs { + if len(in) > width { + width = len(in) + } + } + if width == 0 { + empty := Sum256(nil) + for i := range out { + out[i] = empty + } + return true, nil + } + + flat := make([]uint8, len(inputs)*width) + for i, in := range inputs { + copy(flat[i*width:(i+1)*width], in) + } + inT, err := accel.NewTensorWithData[uint8](sess, []int{len(inputs), width}, flat) + if err != nil { + return false, nil + } + defer inT.Close() + + outT, err := accel.NewTensor[uint8](sess, []int{len(inputs), Size}) + if err != nil { + return false, nil + } + defer outT.Close() + + if err := sess.Crypto().SHA256(inT.Untyped(), outT.Untyped()); err != nil { + return false, nil + } + bytes, err := outT.ToSlice() + if err != nil { + return false, nil + } + for i := range out { + copy(out[i][:], bytes[i*Size:(i+1)*Size]) + } + return true, nil +} diff --git a/sha256/sha256.go b/sha256/sha256.go new file mode 100644 index 0000000..1eb31ab --- /dev/null +++ b/sha256/sha256.go @@ -0,0 +1,36 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package sha256 + +import ( + stdsha256 "crypto/sha256" + "hash" +) + +// Size is the output size of SHA-256 in bytes. +const Size = stdsha256.Size + +// BatchThreshold is the minimum batch length at which Sum256Batch will try to +// route through GPU. +var BatchThreshold = 256 + +// Sum256 returns the SHA-256 hash of in. +func Sum256(in []byte) [Size]byte { return stdsha256.Sum256(in) } + +// New returns a hash.Hash computing SHA-256. +func New() hash.Hash { return stdsha256.New() } + +// Sum256Batch computes SHA-256 for a batch of inputs. +func Sum256Batch(inputs [][]byte) [][Size]byte { + out := make([][Size]byte, len(inputs)) + if len(inputs) >= BatchThreshold { + if ok, err := batchGPU(inputs, out); ok && err == nil { + return out + } + } + for i, in := range inputs { + out[i] = stdsha256.Sum256(in) + } + return out +} diff --git a/sha256/sha256_test.go b/sha256/sha256_test.go new file mode 100644 index 0000000..ab6ff8d --- /dev/null +++ b/sha256/sha256_test.go @@ -0,0 +1,37 @@ +package sha256 + +import ( + stdsha256 "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestSum256(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + {"abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}, + } + for _, c := range cases { + got := Sum256([]byte(c.in)) + want, _ := hex.DecodeString(c.want) + if string(got[:]) != string(want) { + t.Errorf("Sum256(%q) = %x; want %s", c.in, got, c.want) + } + } +} + +func TestSum256BatchMatchesScalar(t *testing.T) { + inputs := make([][]byte, BatchThreshold+4) + for i := range inputs { + inputs[i] = []byte("v" + string(rune('a'+i%26))) + } + got := Sum256Batch(inputs) + for i, in := range inputs { + want := stdsha256.Sum256(in) + if got[i] != want { + t.Errorf("batch[%d] mismatch", i) + } + } +} diff --git a/sha3/doc.go b/sha3/doc.go new file mode 100644 index 0000000..9997637 --- /dev/null +++ b/sha3/doc.go @@ -0,0 +1,7 @@ +// Package sha3 is the canonical entry point for SHA3 / SHAKE / cSHAKE +// in luxfi/crypto. +// +// SHA3 is the FIPS-202 standard. The Ethereum-flavored "Keccak-256" lives in +// the keccak/ package since it predates the FIPS standard and uses a +// different padding rule. +package sha3 diff --git a/sha3/sha3.go b/sha3/sha3.go new file mode 100644 index 0000000..f2bb09b --- /dev/null +++ b/sha3/sha3.go @@ -0,0 +1,48 @@ +// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package sha3 + +import ( + "hash" + + "golang.org/x/crypto/sha3" +) + +// Size constants for fixed-output SHA3 variants. +const ( + Size224 = 28 + Size256 = 32 + Size384 = 48 + Size512 = 64 +) + +// Sum224 returns the SHA3-224 hash of in. +func Sum224(in []byte) [Size224]byte { return sha3.Sum224(in) } + +// Sum256 returns the SHA3-256 hash of in. +func Sum256(in []byte) [Size256]byte { return sha3.Sum256(in) } + +// Sum384 returns the SHA3-384 hash of in. +func Sum384(in []byte) [Size384]byte { return sha3.Sum384(in) } + +// Sum512 returns the SHA3-512 hash of in. +func Sum512(in []byte) [Size512]byte { return sha3.Sum512(in) } + +// New224 returns a new hash.Hash computing SHA3-224. +func New224() hash.Hash { return sha3.New224() } + +// New256 returns a new hash.Hash computing SHA3-256. +func New256() hash.Hash { return sha3.New256() } + +// New384 returns a new hash.Hash computing SHA3-384. +func New384() hash.Hash { return sha3.New384() } + +// New512 returns a new hash.Hash computing SHA3-512. +func New512() hash.Hash { return sha3.New512() } + +// ShakeSum128 produces an outputLen-byte SHAKE128 digest of data. +func ShakeSum128(out, data []byte) { sha3.ShakeSum128(out, data) } + +// ShakeSum256 produces an outputLen-byte SHAKE256 digest of data. +func ShakeSum256(out, data []byte) { sha3.ShakeSum256(out, data) } diff --git a/sha3/sha3_test.go b/sha3/sha3_test.go new file mode 100644 index 0000000..b3149d1 --- /dev/null +++ b/sha3/sha3_test.go @@ -0,0 +1,38 @@ +package sha3 + +import ( + "encoding/hex" + "testing" +) + +func TestSum256Vectors(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"}, + {"abc", "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532"}, + } + for _, c := range cases { + got := Sum256([]byte(c.in)) + want, _ := hex.DecodeString(c.want) + if string(got[:]) != string(want) { + t.Errorf("Sum256(%q) = %x; want %s", c.in, got, c.want) + } + } +} + +func TestSum512Vectors(t *testing.T) { + got := Sum512([]byte("abc")) + want, _ := hex.DecodeString("b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0") + if string(got[:]) != string(want) { + t.Errorf("Sum512(abc) = %x; want %x", got, want) + } +} + +func TestShake128(t *testing.T) { + out := make([]byte, 32) + ShakeSum128(out, []byte("abc")) + if hex.EncodeToString(out) == "" { + t.Fatal("ShakeSum128 produced empty output") + } +}