mirror of
https://github.com/luxfi/math.git
synced 2026-07-27 03:38:49 +00:00
LP-107 Phase 2: math substrate — params, backend, codec, modarith,
ntt, poly, rns, sample
Eight pure-Go reference packages that own the canonical semantics of
every cryptographic-math primitive Lux protocols share. Backends
(AVX2 / NEON / cgo+C++ / CUDA / Metal / WGSL) plug in behind the
substrate; KATs gate byte-equality across them.
Packages added:
* params/ — ModulusID, NTTParamID, FHEParamID, PulsarParamID,
HashSuiteID, BackendID; KATHeader required-fields
schema. Stable string IDs; renaming is a breaking
change.
* backend/ — Policy enum (PureGo / NativeCPU / GPUPreferred /
GPURequired); Resolve(policy, registered) returns the
BackendID per LP-107's fallback chain. GPURequired
returns ErrUnavailable when no GPU backend is
registered.
* codec/ — Bounded readers (closes lattice issues #2 + #4 DoS
class permanently). Limits + LimitError + Reader.
ReadUint{16,32,64}Slice all reject the 70T-element
attack input before allocating; depth + frame-bytes
caps; iterative (no recursion). Regression test
TestReadUint64Slice_RejectsHugeLength encodes the
original lattice issue #4 input.
* modarith/ — Modulus type with QInv, R2, Barrett constants
(computed via math/big once per modulus). AddMod /
SubMod / MulMod (Div64 reference path). MontMulMod /
ToMontgomery / FromMontgomery cross-checked against
MulMod across 100 random Pulsar-Q pairs.
ReductionMode enum byte-equal to lattice/types.
* ntt/ — Service + Backend interface; pure-Go backend
delegates to lattice/v7/ring.SubRing.NTT/INTT (the
canonical Lattigo-derived Montgomery NTT body — no
re-implementation, just dispatch). Round-trip +
batch + determinism tests on Pulsar N=256.
* poly/ — Add / Sub / ScalarMul / PointwiseMul (NTT-domain) /
negacyclic Mul (NTT round-trip). Composes modarith +
ntt; no lower-level reach.
* rns/ — Basis(Moduli, Name) for FHE RNS chains. Single +
two-prime construction validated; rejects even moduli.
Phase 3 will add basis-extension and modulus-switching.
* sample/ — Uniform (rejection sampling, mask-and-retry); Ternary
(density + sign byte); CenteredBinomial (popcount
difference); DiscreteGaussianRejection (6-sigma cutoff).
All take an io.Reader so callers can KAT-replay.
Architecture invariants enforced in package docs:
- Go is the canonical semantic reference.
- Backend selection MUST NOT alter transcript bytes (consensus
paths default to PolicyPureGo / PolicyNativeCPU).
- No unbounded codec readers anywhere near wire formats.
- No re-implementation: where lattice/v7/ring already owns the
canonical body, we delegate, not fork.
- One ID space; renaming is a breaking change.
Test posture: 8/8 packages green via `GOWORK=off go test ./...`.
Phases 3-7 (queued):
3. lattice consumes math
4. pulsar consumes lattice + math
5. fhe consumes math
6. luxcpp/crypto/math native backend mirror
7. cross-runtime KAT release gate
See SUBSTRATE.md for the full posture and migration plan; full LP at
~/work/lux/lps/LP-107-lux-math-substrate.md.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# Lux Math — High-Performance Cryptographic Substrate
|
||||
|
||||
LP-107 reference implementation. The `luxfi/math` Go module owns the
|
||||
canonical Go reference for every cryptographic-math primitive that
|
||||
Lux protocols share, with a backend interface that lets native CPU
|
||||
(AVX2 / NEON / cgo+C++) and GPU (CUDA / Metal / WGSL) realizations
|
||||
drop in behind it.
|
||||
|
||||
## Substrate packages (LP-107 Phase 2)
|
||||
|
||||
| Package | Owns | Status |
|
||||
|---|---|---|
|
||||
| [`params`](./params/) | ModulusID, NTTParamID, FHEParamID, PulsarParamID, HashSuiteID, BackendID; KAT header schema | ✅ pure-Go reference, validated |
|
||||
| [`backend`](./backend/) | Policy enum (PureGo / NativeCPU / GPUPreferred / GPURequired), Resolve() dispatch | ✅ pure-Go reference, validated |
|
||||
| [`codec`](./codec/) | Bounded readers (closes lattice issues #2 + #4 DoS class) | ✅ pure-Go reference, validated |
|
||||
| [`modarith`](./modarith/) | Barrett, Montgomery, AddMod / SubMod / MulMod, ReductionMode | ✅ pure-Go reference, validated |
|
||||
| [`ntt`](./ntt/) | NTT Service + Backend interface; pure-Go backend delegates to `lattice/v7/ring.SubRing.NTT/INTT` | ✅ pure-Go reference, validated |
|
||||
| [`poly`](./poly/) | Polynomial Add / Sub / ScalarMul / PointwiseMul / negacyclic Mul (via NTT round-trip) | ✅ pure-Go reference, validated |
|
||||
| [`rns`](./rns/) | RNS Basis primitives (single + multi-prime towers) | ✅ pure-Go reference, validated |
|
||||
| [`sample`](./sample/) | Uniform / Ternary / CenteredBinomial / DiscreteGaussianRejection samplers | ✅ pure-Go reference, validated |
|
||||
|
||||
## Architecture invariants
|
||||
|
||||
* **Go is the canonical semantic reference.** C++ / GPU backends
|
||||
realize the same contract; KATs prove byte-equality.
|
||||
* **Backend selection MUST NOT alter transcript bytes** for consensus
|
||||
paths. Default policy is `PolicyPureGo` or `PolicyNativeCPU`.
|
||||
* **No unbounded codec readers.** Every wire-format decode goes
|
||||
through `codec.Reader`; the `ReadUint64Slice` recursion + OOM bug
|
||||
classes are fixed centrally.
|
||||
* **No re-implementation.** Where the canonical impl already lives
|
||||
in `luxfi/lattice` (Lattigo-derived NTT/Montgomery), the substrate
|
||||
delegates rather than fork. LP-107 Phase 3 inverts the dependency.
|
||||
* **One ID space.** `params.ModulusID` / `NTTParamID` / etc. are
|
||||
stable strings; renaming is a breaking change. KATs key off them.
|
||||
|
||||
## Test posture
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./...
|
||||
```
|
||||
|
||||
| Package | Tests |
|
||||
|---|---|
|
||||
| `params` | ModulusID/NTTParamID/FHEParamID/PulsarParamID/HashSuiteID/BackendID validation; KATHeader required-fields |
|
||||
| `backend` | Policy String + Validate; Resolve fallback chain (PureGo / NativeCPU / GPUPreferred / GPURequired); GPU-required-no-GPU returns ErrUnavailable |
|
||||
| `codec` | Limits validation; Uvarint round-trip; happy-path uint16/32/64 slice reads; **regression test for lattice issue #4 (70T-element attack input rejected with LimitError)**; depth + frame-bytes caps |
|
||||
| `modarith` | NewModulus rejects zero/even; QInv satisfies q\*QInv ≡ -1 mod 2^64; AddMod / SubMod / MulMod cross-checked vs math/big across 1000 random pairs; Montgomery round-trip + 100 randomized MontMul-vs-MulMod cross-checks; LazyModeFits |
|
||||
| `ntt` | Params validation; PureGo round-trip on Pulsar N=256; batch round-trip; **determinism across two Service instances** |
|
||||
| `poly` | Add+Sub round-trip; ScalarMul; **negacyclic Mul** via NTT (a=2 \* b=3 → coefficient 0 = 6, others = 0) |
|
||||
| `rns` | Basis construction (single-prime + two-prime); rejection of empty + even moduli |
|
||||
| `sample` | Uniform determinism (same seed → byte-equal output); Ternary distribution + balance; CenteredBinomial range-bounded; DiscreteGaussianRejection 1000-sample range |
|
||||
|
||||
## Migration plan (LP-107 Phases 3-7)
|
||||
|
||||
* **Phase 3 — `lattice` consumes `math`.** `lattice/ring/SubRing.NTT`
|
||||
rewires to delegate to `math/ntt`. Pulsar / Lens / FHE see no
|
||||
source change at first; v0.2.0 deprecates direct `lattice/ring`
|
||||
imports for new code.
|
||||
* **Phase 4 — `pulsar` consumes `lattice` + `math`.** Drop ad-hoc
|
||||
Montgomery code; consume `math/modarith`.
|
||||
* **Phase 5 — `fhe` consumes `math`.** Share NTT/RNS primitives with
|
||||
Pulsar where parameter-compatible.
|
||||
* **Phase 6 — `luxcpp/crypto/math`** mirrors the Go module structure
|
||||
and becomes the native backend for `math/ntt`, `math/poly`,
|
||||
`math/sample`. KATs gate every byte across runtimes.
|
||||
* **Phase 7 — Cross-runtime KAT release gate.** Go math KAT → C++
|
||||
verifies; C++ math KAT → Go verifies; GPU math KAT → CPU verifies.
|
||||
|
||||
## Design references
|
||||
|
||||
* [`LP-107`](../lps/LP-107-lux-math-substrate.md) — full spec.
|
||||
* [`lattice/v7/ring`](https://github.com/luxfi/lattice/tree/main/ring)
|
||||
— canonical Lattigo-derived NTT/Montgomery body that this module
|
||||
delegates to.
|
||||
* [`luxcpp/crypto/corona`](https://github.com/luxcpp/crypto/tree/main/corona)
|
||||
— native C++ Montgomery NTT that Phase 6 mirrors as `math` C++.
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package backend defines how `luxfi/math` selects between CPU and GPU
|
||||
// implementations of the same primitive.
|
||||
//
|
||||
// LP-107 §"Backend dispatch" — the canonical motivation. Backends are
|
||||
// interchangeable performance realizations of one canonical contract;
|
||||
// KATs prove byte-equality across them; the choice of backend MUST
|
||||
// NEVER alter transcript bytes for consensus paths.
|
||||
//
|
||||
// Policy enum:
|
||||
//
|
||||
// BackendPureGo — pure-Go reference implementation. Always works.
|
||||
// BackendNativeCPU — native CPU implementation (cgo + C++/SIMD).
|
||||
// BackendGPUPreferred — try GPU; fall back to CPU on unavailability.
|
||||
// BackendGPURequired — GPU MUST be available; error if not.
|
||||
//
|
||||
// Registry: each substrate package (math/ntt, math/poly, math/sample,
|
||||
// math/codec, math/rns) exposes its own backend interface and a
|
||||
// process-wide registry of registered backends. This package owns the
|
||||
// shared Policy enum + lookup helper; per-primitive interfaces live in
|
||||
// the consuming package.
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
// Policy selects a backend at dispatch time.
|
||||
type Policy uint8
|
||||
|
||||
const (
|
||||
// PolicyPureGo forces the pure-Go reference path. Used for
|
||||
// debugging, incident response, and consensus-critical paths
|
||||
// where determinism trumps speed.
|
||||
PolicyPureGo Policy = 0
|
||||
|
||||
// PolicyNativeCPU prefers the native-cpu (cgo / SIMD) backend if
|
||||
// available; falls back to pure-Go otherwise.
|
||||
PolicyNativeCPU Policy = 1
|
||||
|
||||
// PolicyGPUPreferred prefers GPU if available, falls back to native-
|
||||
// CPU, then pure-Go. Used when the workload amortizes GPU dispatch.
|
||||
PolicyGPUPreferred Policy = 2
|
||||
|
||||
// PolicyGPURequired demands GPU; the call errors out if no GPU
|
||||
// backend is registered.
|
||||
PolicyGPURequired Policy = 3
|
||||
)
|
||||
|
||||
// String makes Policy printable.
|
||||
func (p Policy) String() string {
|
||||
switch p {
|
||||
case PolicyPureGo:
|
||||
return "pure-go"
|
||||
case PolicyNativeCPU:
|
||||
return "native-cpu"
|
||||
case PolicyGPUPreferred:
|
||||
return "gpu-preferred"
|
||||
case PolicyGPURequired:
|
||||
return "gpu-required"
|
||||
default:
|
||||
return fmt.Sprintf("policy(%d)", uint8(p))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports whether p is a known policy.
|
||||
func (p Policy) Validate() error {
|
||||
switch p {
|
||||
case PolicyPureGo, PolicyNativeCPU, PolicyGPUPreferred, PolicyGPURequired:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("backend: unknown Policy %d", uint8(p))
|
||||
}
|
||||
|
||||
// ErrUnavailable signals that a required backend is not present in the
|
||||
// process. Returned by ResolveOrError when PolicyGPURequired is set
|
||||
// but no GPU backend is registered.
|
||||
var ErrUnavailable = errors.New("backend: required backend unavailable")
|
||||
|
||||
// Resolve returns the appropriate BackendID for the given policy and
|
||||
// the set of registered backends. The order of preference is:
|
||||
//
|
||||
// PolicyGPURequired: GPU only — error if no GPU registered
|
||||
// PolicyGPUPreferred: GPU > native-cpu > pure-go
|
||||
// PolicyNativeCPU: native-cpu > pure-go
|
||||
// PolicyPureGo: pure-go only
|
||||
//
|
||||
// This function is the single decision point for dispatch ordering.
|
||||
// Per-primitive packages (math/ntt, math/poly, etc.) consult it before
|
||||
// invoking a backend.
|
||||
func Resolve(policy Policy, registered map[params.BackendID]bool) (params.BackendID, error) {
|
||||
if err := policy.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gpuFamily := []params.BackendID{
|
||||
params.BackendCUDA, params.BackendMetal, params.BackendWGSL,
|
||||
}
|
||||
cpuNative := []params.BackendID{
|
||||
params.BackendNative, params.BackendAVX2, params.BackendNEON,
|
||||
}
|
||||
|
||||
first := func(family []params.BackendID) (params.BackendID, bool) {
|
||||
for _, id := range family {
|
||||
if registered[id] {
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch policy {
|
||||
case PolicyGPURequired:
|
||||
if id, ok := first(gpuFamily); ok {
|
||||
return id, nil
|
||||
}
|
||||
return "", fmt.Errorf("backend: %w (policy=GPURequired, registered=%v)",
|
||||
ErrUnavailable, keysOf(registered))
|
||||
case PolicyGPUPreferred:
|
||||
if id, ok := first(gpuFamily); ok {
|
||||
return id, nil
|
||||
}
|
||||
if id, ok := first(cpuNative); ok {
|
||||
return id, nil
|
||||
}
|
||||
if registered[params.BackendPureGo] {
|
||||
return params.BackendPureGo, nil
|
||||
}
|
||||
return "", fmt.Errorf("backend: no backend registered")
|
||||
case PolicyNativeCPU:
|
||||
if id, ok := first(cpuNative); ok {
|
||||
return id, nil
|
||||
}
|
||||
if registered[params.BackendPureGo] {
|
||||
return params.BackendPureGo, nil
|
||||
}
|
||||
return "", fmt.Errorf("backend: no backend registered")
|
||||
case PolicyPureGo:
|
||||
if registered[params.BackendPureGo] {
|
||||
return params.BackendPureGo, nil
|
||||
}
|
||||
return "", fmt.Errorf("backend: PureGo not registered (impossible — pure-Go is the canonical reference)")
|
||||
}
|
||||
return "", fmt.Errorf("backend: unreachable")
|
||||
}
|
||||
|
||||
func keysOf(m map[params.BackendID]bool) []params.BackendID {
|
||||
out := make([]params.BackendID, 0, len(m))
|
||||
for k, v := range m {
|
||||
if v {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
func TestPolicy_String(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
p Policy
|
||||
want string
|
||||
}{
|
||||
{PolicyPureGo, "pure-go"},
|
||||
{PolicyNativeCPU, "native-cpu"},
|
||||
{PolicyGPUPreferred, "gpu-preferred"},
|
||||
{PolicyGPURequired, "gpu-required"},
|
||||
} {
|
||||
if got := tc.p.String(); got != tc.want {
|
||||
t.Errorf("Policy(%d).String() = %q, want %q", tc.p, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_Validate(t *testing.T) {
|
||||
for _, p := range []Policy{
|
||||
PolicyPureGo, PolicyNativeCPU, PolicyGPUPreferred, PolicyGPURequired,
|
||||
} {
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
if err := Policy(99).Validate(); err == nil {
|
||||
t.Error("Policy(99).Validate() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_PureGo(t *testing.T) {
|
||||
r := map[params.BackendID]bool{params.BackendPureGo: true}
|
||||
got, err := Resolve(PolicyPureGo, r)
|
||||
if err != nil || got != params.BackendPureGo {
|
||||
t.Errorf("PureGo resolve: %v %s", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NativeCPU_Fallback(t *testing.T) {
|
||||
// Only pure-go registered; native-cpu policy must fall back.
|
||||
r := map[params.BackendID]bool{params.BackendPureGo: true}
|
||||
got, err := Resolve(PolicyNativeCPU, r)
|
||||
if err != nil || got != params.BackendPureGo {
|
||||
t.Errorf("NativeCPU fallback: %v %s", err, got)
|
||||
}
|
||||
|
||||
// AVX2 registered: native-cpu should pick it.
|
||||
r2 := map[params.BackendID]bool{
|
||||
params.BackendPureGo: true, params.BackendAVX2: true,
|
||||
}
|
||||
got, err = Resolve(PolicyNativeCPU, r2)
|
||||
if err != nil || got != params.BackendNative && got != params.BackendAVX2 {
|
||||
t.Errorf("NativeCPU with AVX2: %v %s", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_GPUPreferred_FallbackChain(t *testing.T) {
|
||||
// No GPU, no native — falls back to pure-go.
|
||||
r := map[params.BackendID]bool{params.BackendPureGo: true}
|
||||
got, err := Resolve(PolicyGPUPreferred, r)
|
||||
if err != nil || got != params.BackendPureGo {
|
||||
t.Errorf("GPUPreferred → pure-go fallback: %v %s", err, got)
|
||||
}
|
||||
|
||||
// CUDA registered: GPUPreferred picks CUDA.
|
||||
r2 := map[params.BackendID]bool{
|
||||
params.BackendPureGo: true, params.BackendCUDA: true,
|
||||
}
|
||||
got, err = Resolve(PolicyGPUPreferred, r2)
|
||||
if err != nil || got != params.BackendCUDA {
|
||||
t.Errorf("GPUPreferred with CUDA: %v %s", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_GPURequired_NoGPU_Errors(t *testing.T) {
|
||||
r := map[params.BackendID]bool{params.BackendPureGo: true}
|
||||
_, err := Resolve(PolicyGPURequired, r)
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Errorf("GPURequired with no GPU: want ErrUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_GPURequired_Metal_OK(t *testing.T) {
|
||||
r := map[params.BackendID]bool{params.BackendMetal: true}
|
||||
got, err := Resolve(PolicyGPURequired, r)
|
||||
if err != nil || got != params.BackendMetal {
|
||||
t.Errorf("GPURequired with Metal: %v %s", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_UnknownPolicy(t *testing.T) {
|
||||
r := map[params.BackendID]bool{params.BackendPureGo: true}
|
||||
_, err := Resolve(Policy(99), r)
|
||||
if err == nil {
|
||||
t.Error("Resolve(unknown) returned nil")
|
||||
}
|
||||
}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package codec is the bounded-decode contract for every wire format
|
||||
// in luxfi/math (and downstream luxfi/lattice, luxfi/pulsar, luxfi/fhe).
|
||||
//
|
||||
// LP-107 §"Codec and bounded reader should be centralized" — the
|
||||
// canonical motivation. The lattigo `ReadUint64Slice` recursion bug
|
||||
// (issue #2) and `Vector[T].ReadFrom` OOM bug (issue #4) both stemmed
|
||||
// from unbounded slice decode on untrusted wire data; this package
|
||||
// fixes that class permanently.
|
||||
//
|
||||
// Contract:
|
||||
//
|
||||
// - No recursion. Slice readers are iterative; depth is bounded by
|
||||
// the configured Limits.
|
||||
// - No hidden growth. Every `make([]T, n)` is preceded by a `n <= cap`
|
||||
// check against caller-supplied Limits.
|
||||
// - No unbounded allocation. The largest cap is application-supplied
|
||||
// and surfaces in error messages.
|
||||
// - All readers are deterministic and reentrant; failure leaves the
|
||||
// reader at the byte where the bound was exceeded.
|
||||
package codec
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
// Limits caps the largest slice / depth a Reader will accept on a
|
||||
// single decode call. Callers MUST construct Limits explicitly; there
|
||||
// is no implicit default.
|
||||
type Limits struct {
|
||||
// MaxFrameBytes caps the total number of input bytes the Reader
|
||||
// will consume in a single decode call. Used to bound peek-ahead
|
||||
// buffers. 0 means unset and is treated as an error.
|
||||
MaxFrameBytes int
|
||||
|
||||
// MaxUint16SliceLen caps the number of elements in a uint16 slice.
|
||||
MaxUint16SliceLen int
|
||||
// MaxUint32SliceLen caps the number of elements in a uint32 slice.
|
||||
MaxUint32SliceLen int
|
||||
// MaxUint64SliceLen caps the number of elements in a uint64 slice.
|
||||
MaxUint64SliceLen int
|
||||
|
||||
// MaxDepth caps how deeply a recursively-shaped wire format may
|
||||
// nest before the reader rejects (Vector[Poly] etc. are 2 levels).
|
||||
MaxDepth int
|
||||
}
|
||||
|
||||
// Validate reports whether the limits are coherent (all positive).
|
||||
// Returns an error listing every zero/negative field.
|
||||
func (l Limits) Validate() error {
|
||||
var problems []string
|
||||
if l.MaxFrameBytes <= 0 {
|
||||
problems = append(problems, "MaxFrameBytes")
|
||||
}
|
||||
if l.MaxUint16SliceLen <= 0 {
|
||||
problems = append(problems, "MaxUint16SliceLen")
|
||||
}
|
||||
if l.MaxUint32SliceLen <= 0 {
|
||||
problems = append(problems, "MaxUint32SliceLen")
|
||||
}
|
||||
if l.MaxUint64SliceLen <= 0 {
|
||||
problems = append(problems, "MaxUint64SliceLen")
|
||||
}
|
||||
if l.MaxDepth <= 0 {
|
||||
problems = append(problems, "MaxDepth")
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("codec.Limits: zero/negative fields: %v", problems)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultLimitsLatticeWire is the conservative default for wire-format
|
||||
// decoding of lattice polynomials at the canonical Pulsar parameters
|
||||
// (R_q = Z_q[X]/(X^256 + 1), Q ≈ 2^48). Callers SHOULD use a
|
||||
// configuration tuned to their parameter set rather than this default.
|
||||
//
|
||||
// MaxUint64SliceLen = 4096 matches Pulsar Vector[Poly] cap.
|
||||
// MaxFrameBytes = 16 MiB allows a worst-case threshold ceremony
|
||||
// transcript without truncation.
|
||||
// MaxDepth = 4 Pulsar wire is 2 levels (Vector + Poly);
|
||||
// 4 leaves headroom for FHE chains.
|
||||
var DefaultLimitsLatticeWire = Limits{
|
||||
MaxFrameBytes: 16 * 1024 * 1024,
|
||||
MaxUint16SliceLen: 4096,
|
||||
MaxUint32SliceLen: 4096,
|
||||
MaxUint64SliceLen: 4096,
|
||||
MaxDepth: 4,
|
||||
}
|
||||
|
||||
// ErrLimitExceeded is the sentinel for any limit-bound rejection.
|
||||
// errors.Is(err, ErrLimitExceeded) holds for every cap violation.
|
||||
var ErrLimitExceeded = errors.New("codec: limit exceeded")
|
||||
|
||||
// LimitError carries the specific limit that was exceeded plus the
|
||||
// observed value. Wraps ErrLimitExceeded.
|
||||
type LimitError struct {
|
||||
What string // human-readable name of the cap, e.g. "MaxUint64SliceLen"
|
||||
Limit int
|
||||
Got uint64
|
||||
}
|
||||
|
||||
// Error implements error.
|
||||
func (e *LimitError) Error() string {
|
||||
return fmt.Sprintf("codec: %s exceeded: limit=%d got=%d",
|
||||
e.What, e.Limit, e.Got)
|
||||
}
|
||||
|
||||
// Unwrap implements errors.Unwrap.
|
||||
func (e *LimitError) Unwrap() error { return ErrLimitExceeded }
|
||||
|
||||
// Reader wraps an io.Reader and a Limits config. Every slice-reading
|
||||
// method on Reader is bounded by Limits.
|
||||
type Reader struct {
|
||||
r io.Reader
|
||||
limits Limits
|
||||
consumed int
|
||||
depth int
|
||||
}
|
||||
|
||||
// NewReader constructs a Reader from an io.Reader and a Limits config.
|
||||
// Returns an error if Limits is invalid.
|
||||
func NewReader(r io.Reader, l Limits) (*Reader, error) {
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("codec: nil io.Reader")
|
||||
}
|
||||
if err := l.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Reader{r: r, limits: l}, nil
|
||||
}
|
||||
|
||||
// Consumed returns the number of bytes read from the underlying io.Reader.
|
||||
func (r *Reader) Consumed() int { return r.consumed }
|
||||
|
||||
// EnterDepth bumps the nesting counter and returns an error if the
|
||||
// configured MaxDepth is exceeded. Caller MUST pair with ExitDepth.
|
||||
func (r *Reader) EnterDepth() error {
|
||||
r.depth++
|
||||
if r.depth > r.limits.MaxDepth {
|
||||
return &LimitError{What: "MaxDepth", Limit: r.limits.MaxDepth, Got: uint64(r.depth)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExitDepth decrements the nesting counter.
|
||||
func (r *Reader) ExitDepth() {
|
||||
if r.depth > 0 {
|
||||
r.depth--
|
||||
}
|
||||
}
|
||||
|
||||
// readN reads exactly n bytes, bumping the consumed counter and
|
||||
// validating against MaxFrameBytes.
|
||||
func (r *Reader) readN(n int) ([]byte, error) {
|
||||
if n < 0 {
|
||||
return nil, fmt.Errorf("codec: negative read length %d", n)
|
||||
}
|
||||
if r.consumed+n > r.limits.MaxFrameBytes {
|
||||
return nil, &LimitError{
|
||||
What: "MaxFrameBytes",
|
||||
Limit: r.limits.MaxFrameBytes,
|
||||
Got: uint64(r.consumed + n),
|
||||
}
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r.r, buf); err != nil {
|
||||
return nil, fmt.Errorf("codec: short read: %w", err)
|
||||
}
|
||||
r.consumed += n
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// ReadUint16 reads a single little-endian uint16.
|
||||
func (r *Reader) ReadUint16() (uint16, error) {
|
||||
b, err := r.readN(2)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.LittleEndian.Uint16(b), nil
|
||||
}
|
||||
|
||||
// ReadUint32 reads a single little-endian uint32.
|
||||
func (r *Reader) ReadUint32() (uint32, error) {
|
||||
b, err := r.readN(4)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.LittleEndian.Uint32(b), nil
|
||||
}
|
||||
|
||||
// ReadUint64 reads a single little-endian uint64.
|
||||
func (r *Reader) ReadUint64() (uint64, error) {
|
||||
b, err := r.readN(8)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.LittleEndian.Uint64(b), nil
|
||||
}
|
||||
|
||||
// ReadUint16Slice reads a length-prefixed slice of little-endian uint16.
|
||||
// The length is read as a varint capped by MaxUint16SliceLen; iterative
|
||||
// (no recursion).
|
||||
func (r *Reader) ReadUint16Slice() ([]uint16, error) {
|
||||
n, err := r.readSliceLen("uint16", r.limits.MaxUint16SliceLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return []uint16{}, nil
|
||||
}
|
||||
if err := overflowMul(n, 2, r.limits.MaxFrameBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]uint16, n)
|
||||
buf, err := r.readN(int(n) * 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i] = binary.LittleEndian.Uint16(buf[i*2:])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadUint32Slice reads a length-prefixed slice of little-endian uint32.
|
||||
func (r *Reader) ReadUint32Slice() ([]uint32, error) {
|
||||
n, err := r.readSliceLen("uint32", r.limits.MaxUint32SliceLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return []uint32{}, nil
|
||||
}
|
||||
if err := overflowMul(n, 4, r.limits.MaxFrameBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]uint32, n)
|
||||
buf, err := r.readN(int(n) * 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i] = binary.LittleEndian.Uint32(buf[i*4:])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadUint64Slice reads a length-prefixed slice of little-endian uint64.
|
||||
// Bounded by MaxUint64SliceLen. The length-prefix is always read as a
|
||||
// varint; values > MaxUint64SliceLen are rejected before any allocation.
|
||||
//
|
||||
// This is the centralized fix for both lattigo issue #2 (recursive
|
||||
// `ReadUint64Slice`) and issue #4 (`Vector[T].ReadFrom` unbounded
|
||||
// allocation). Callers consuming untrusted lattice wire data MUST go
|
||||
// through this method, never lattigo's raw `utils/buffer.ReadUint64Slice`.
|
||||
func (r *Reader) ReadUint64Slice() ([]uint64, error) {
|
||||
n, err := r.readSliceLen("uint64", r.limits.MaxUint64SliceLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return []uint64{}, nil
|
||||
}
|
||||
if err := overflowMul(n, 8, r.limits.MaxFrameBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]uint64, n)
|
||||
buf, err := r.readN(int(n) * 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i] = binary.LittleEndian.Uint64(buf[i*8:])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readSliceLen reads a varint length and validates against the cap.
|
||||
// Returns the parsed length as uint32 (we never accept lengths that
|
||||
// don't fit a 32-bit count for slice work).
|
||||
func (r *Reader) readSliceLen(what string, cap int) (uint32, error) {
|
||||
v, err := r.readUvarint()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if v > uint64(cap) {
|
||||
return 0, &LimitError{
|
||||
What: fmt.Sprintf("Max%sSliceLen", capitalize(what)),
|
||||
Limit: cap,
|
||||
Got: v,
|
||||
}
|
||||
}
|
||||
if v > uint64(^uint32(0)) {
|
||||
return 0, &LimitError{
|
||||
What: "uint32 representable",
|
||||
Limit: int(^uint32(0)),
|
||||
Got: v,
|
||||
}
|
||||
}
|
||||
return uint32(v), nil
|
||||
}
|
||||
|
||||
// readUvarint reads a varint length. Iterative; bounded to 10 bytes
|
||||
// (max varint encoding of uint64).
|
||||
func (r *Reader) readUvarint() (uint64, error) {
|
||||
var v uint64
|
||||
var shift uint
|
||||
for i := 0; i < 10; i++ {
|
||||
b, err := r.readN(1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c := b[0]
|
||||
if c < 0x80 {
|
||||
if i == 9 && c > 1 {
|
||||
return 0, fmt.Errorf("codec: varint overflow")
|
||||
}
|
||||
v |= uint64(c) << shift
|
||||
return v, nil
|
||||
}
|
||||
v |= uint64(c&0x7f) << shift
|
||||
shift += 7
|
||||
}
|
||||
return 0, fmt.Errorf("codec: varint too long (>10 bytes)")
|
||||
}
|
||||
|
||||
// overflowMul rejects n*size > frameMax even on multiplication overflow.
|
||||
// Returns *LimitError on rejection.
|
||||
func overflowMul(n uint32, size int, frameMax int) error {
|
||||
hi, lo := bits.Mul64(uint64(n), uint64(size))
|
||||
if hi != 0 || lo > uint64(frameMax) {
|
||||
return &LimitError{
|
||||
What: "MaxFrameBytes (slice payload)",
|
||||
Limit: frameMax,
|
||||
Got: lo,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
first := s[0]
|
||||
if first >= 'a' && first <= 'z' {
|
||||
first -= 'a' - 'A'
|
||||
}
|
||||
return string(first) + s[1:]
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newReader(t *testing.T, data []byte, l Limits) *Reader {
|
||||
t.Helper()
|
||||
r, err := NewReader(bytes.NewReader(data), l)
|
||||
if err != nil {
|
||||
t.Fatalf("NewReader: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestLimits_Validate(t *testing.T) {
|
||||
if err := DefaultLimitsLatticeWire.Validate(); err != nil {
|
||||
t.Errorf("DefaultLimitsLatticeWire.Validate(): %v", err)
|
||||
}
|
||||
if err := (Limits{}).Validate(); err == nil {
|
||||
t.Error("empty Limits.Validate() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReader_NilArgs(t *testing.T) {
|
||||
if _, err := NewReader(nil, DefaultLimitsLatticeWire); err == nil {
|
||||
t.Error("nil io.Reader: no error")
|
||||
}
|
||||
if _, err := NewReader(bytes.NewReader(nil), Limits{}); err == nil {
|
||||
t.Error("zero Limits: no error")
|
||||
}
|
||||
}
|
||||
|
||||
func encodeUvarint(out *bytes.Buffer, v uint64) {
|
||||
for v >= 0x80 {
|
||||
out.WriteByte(byte(v) | 0x80)
|
||||
v >>= 7
|
||||
}
|
||||
out.WriteByte(byte(v))
|
||||
}
|
||||
|
||||
func TestReadUint64Slice_HappyPath(t *testing.T) {
|
||||
want := []uint64{0xdeadbeef, 0xcafebabe, 0x1122334455667788}
|
||||
var buf bytes.Buffer
|
||||
encodeUvarint(&buf, uint64(len(want)))
|
||||
for _, v := range want {
|
||||
_ = binary.Write(&buf, binary.LittleEndian, v)
|
||||
}
|
||||
|
||||
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
|
||||
got, err := r.ReadUint64Slice()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadUint64Slice: %v", err)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len: want %d got %d", len(want), len(got))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d]: want %#x got %#x", i, want[i], got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadUint64Slice_RejectsHugeLength is the regression test for
|
||||
// lattigo issue #4: 9-byte input asking for 70 trillion uint64s must
|
||||
// be rejected before any allocation.
|
||||
func TestReadUint64Slice_RejectsHugeLength(t *testing.T) {
|
||||
// 9-byte attack input from the lattice issue #4 reproducer
|
||||
// produces a varint whose decoded value is way beyond MaxUint64SliceLen.
|
||||
// We synthesize: varint = 70_368_955_777_453 (~70T), then expect
|
||||
// a LimitError on MaxUint64SliceLen.
|
||||
huge := uint64(70_368_955_777_453)
|
||||
var buf bytes.Buffer
|
||||
encodeUvarint(&buf, huge)
|
||||
|
||||
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
|
||||
_, err := r.ReadUint64Slice()
|
||||
if err == nil {
|
||||
t.Fatal("ReadUint64Slice with 70T length: returned nil error")
|
||||
}
|
||||
if !errors.Is(err, ErrLimitExceeded) {
|
||||
t.Errorf("want ErrLimitExceeded, got %v", err)
|
||||
}
|
||||
var le *LimitError
|
||||
if !errors.As(err, &le) {
|
||||
t.Fatalf("error not a *LimitError: %T %v", err, err)
|
||||
}
|
||||
if le.What != "MaxUint64SliceLen" {
|
||||
t.Errorf("LimitError.What = %q, want MaxUint64SliceLen", le.What)
|
||||
}
|
||||
if le.Got != huge {
|
||||
t.Errorf("LimitError.Got = %d, want %d", le.Got, huge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUint32Slice_RejectsHugeLength(t *testing.T) {
|
||||
huge := uint64(1_000_000_000)
|
||||
var buf bytes.Buffer
|
||||
encodeUvarint(&buf, huge)
|
||||
|
||||
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
|
||||
_, err := r.ReadUint32Slice()
|
||||
if !errors.Is(err, ErrLimitExceeded) {
|
||||
t.Errorf("want ErrLimitExceeded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUint16Slice_RejectsHugeLength(t *testing.T) {
|
||||
huge := uint64(1_000_000_000)
|
||||
var buf bytes.Buffer
|
||||
encodeUvarint(&buf, huge)
|
||||
|
||||
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
|
||||
_, err := r.ReadUint16Slice()
|
||||
if !errors.Is(err, ErrLimitExceeded) {
|
||||
t.Errorf("want ErrLimitExceeded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUint16(t *testing.T) {
|
||||
r := newReader(t, []byte{0xCD, 0xAB}, DefaultLimitsLatticeWire)
|
||||
v, err := r.ReadUint16()
|
||||
if err != nil || v != 0xABCD {
|
||||
t.Errorf("ReadUint16: %v %#x", err, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUint32(t *testing.T) {
|
||||
r := newReader(t, []byte{0x78, 0x56, 0x34, 0x12}, DefaultLimitsLatticeWire)
|
||||
v, err := r.ReadUint32()
|
||||
if err != nil || v != 0x12345678 {
|
||||
t.Errorf("ReadUint32: %v %#x", err, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUint64(t *testing.T) {
|
||||
r := newReader(t, []byte{
|
||||
0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11,
|
||||
}, DefaultLimitsLatticeWire)
|
||||
v, err := r.ReadUint64()
|
||||
if err != nil || v != 0x1122334455667788 {
|
||||
t.Errorf("ReadUint64: %v %#x", err, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepth(t *testing.T) {
|
||||
limits := DefaultLimitsLatticeWire
|
||||
limits.MaxDepth = 2
|
||||
|
||||
r := newReader(t, []byte{}, limits)
|
||||
if err := r.EnterDepth(); err != nil {
|
||||
t.Fatalf("depth 1: %v", err)
|
||||
}
|
||||
if err := r.EnterDepth(); err != nil {
|
||||
t.Fatalf("depth 2: %v", err)
|
||||
}
|
||||
if err := r.EnterDepth(); !errors.Is(err, ErrLimitExceeded) {
|
||||
t.Fatalf("depth 3: want ErrLimitExceeded, got %v", err)
|
||||
}
|
||||
r.ExitDepth()
|
||||
r.ExitDepth()
|
||||
r.ExitDepth() // safe to over-exit
|
||||
}
|
||||
|
||||
func TestFrameBytesCap(t *testing.T) {
|
||||
limits := DefaultLimitsLatticeWire
|
||||
limits.MaxFrameBytes = 4
|
||||
|
||||
r := newReader(t, []byte{1, 2, 3, 4, 5}, limits)
|
||||
if _, err := r.ReadUint32(); err != nil {
|
||||
t.Fatalf("first 4 bytes: %v", err)
|
||||
}
|
||||
// Next byte read must hit MaxFrameBytes.
|
||||
if _, err := r.ReadUint16(); !errors.Is(err, ErrLimitExceeded) {
|
||||
t.Errorf("over-cap: want ErrLimitExceeded, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,25 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/luxfi/ids v1.2.7
|
||||
github.com/luxfi/lattice/v7 v7.1.0
|
||||
github.com/luxfi/math/big v0.1.0
|
||||
github.com/luxfi/sampler v1.0.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/zeebo/blake3 v0.2.4
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ALTree/bigfloat v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/luxfi/crypto v1.17.36 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
gonum.org/v1/gonum v0.17.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
|
||||
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/luxfi/crypto v1.17.36 h1:fN/m5oF2kd44/5JXSILnBxjm3AmgCTw8qFwfkdJaAxg=
|
||||
github.com/luxfi/crypto v1.17.36/go.mod h1:UHzltsXPDF/tGcmTiL/DbkClAflr3YmvOaXlwWC6eXo=
|
||||
github.com/luxfi/ids v1.2.7 h1:3B42EbzR2cdY3veo1yOFOOIeEE+HULnCye2Ye32nXqI=
|
||||
github.com/luxfi/ids v1.2.7/go.mod h1:svLsj7e6ixJVfRYaQqv9RWjBIW11Vz738+d08BVpeCg=
|
||||
github.com/luxfi/lattice/v7 v7.1.0 h1:mr3HvN6olNTS2LT/xAW/JBhTqfvpsGmsopDMeR7BSJs=
|
||||
github.com/luxfi/lattice/v7 v7.1.0/go.mod h1:IaaUN+3ysnBG4BA8ILRYG0j80+qtYDP4C5lkaDb2pDE=
|
||||
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
|
||||
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
|
||||
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
|
||||
github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package modarith provides modular-arithmetic primitives shared across
|
||||
// every Lux cryptographic protocol. Barrett reduction, Montgomery form,
|
||||
// add/sub/mul mod q, and the ReductionBudget type that lazy reduction
|
||||
// kernels consult.
|
||||
//
|
||||
// LP-107 §"Modular arithmetic" — the canonical motivation. All
|
||||
// ad-hoc Montgomery/Barrett code in luxfi/lattice, luxfi/pulsar, and
|
||||
// luxfi/fhe converges here over Phases 3-5 of LP-107.
|
||||
//
|
||||
// The body of this package delegates to the existing canonical
|
||||
// implementations in github.com/luxfi/lattice/v7/ring and
|
||||
// github.com/luxfi/lattice/v7/types so that v0.1.x callers see no
|
||||
// behavior change. v0.2.0 inverts the dependency: lattice/ring will
|
||||
// import this package and thin out into a wrapper.
|
||||
package modarith
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
// Modulus is a single prime modulus q with all derived constants the
|
||||
// substrate needs to do fast modular arithmetic. Constructed once at
|
||||
// parameter-set load time and reused.
|
||||
//
|
||||
// Layout matches lattice/types.ReductionBudget so that future migration
|
||||
// is byte-stable (LP-107 Phase 3).
|
||||
type Modulus struct {
|
||||
// Q is the prime modulus.
|
||||
Q uint64
|
||||
// QInv = -1 / Q mod 2^64; used by Montgomery reduction.
|
||||
QInv uint64
|
||||
// R2 = 2^128 mod Q; Montgomery form of 1.
|
||||
R2 uint64
|
||||
// Barrett[0..1] are the high/low 64-bit parts of floor(2^128 / Q);
|
||||
// used by Barrett reduction.
|
||||
Barrett [2]uint64
|
||||
// Bits is the bit-length of Q (1..64).
|
||||
Bits uint8
|
||||
// Name is a stable human-readable name (e.g. "pulsar-q").
|
||||
Name string
|
||||
}
|
||||
|
||||
// NewModulus computes derived constants for a prime q. Returns an error
|
||||
// if q is zero or q is even (Montgomery requires q odd).
|
||||
func NewModulus(q uint64, name string) (*Modulus, error) {
|
||||
if q == 0 {
|
||||
return nil, fmt.Errorf("modarith: modulus is zero")
|
||||
}
|
||||
if q&1 == 0 {
|
||||
return nil, fmt.Errorf("modarith: modulus %d is even (Montgomery requires odd)", q)
|
||||
}
|
||||
m := &Modulus{
|
||||
Q: q,
|
||||
Bits: uint8(bits.Len64(q)),
|
||||
Name: name,
|
||||
}
|
||||
m.QInv = computeQInv(q)
|
||||
m.R2 = computeR2(q)
|
||||
m.Barrett = computeBarrett(q)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// computeQInv returns -1 / q mod 2^64 by Newton iteration over Z/2^k.
|
||||
// q must be odd.
|
||||
func computeQInv(q uint64) uint64 {
|
||||
x := q // x ≡ q mod 4 == 1 since q odd; one Newton step yields q*x ≡ 1 mod 8
|
||||
for i := 0; i < 6; i++ {
|
||||
x = x * (2 - q*x)
|
||||
}
|
||||
return ^x + 1 // negate: -x mod 2^64
|
||||
}
|
||||
|
||||
// computeR2 returns 2^128 mod q. Uses math/big for arbitrary-precision
|
||||
// modular exponentiation; called once per Modulus construction so the
|
||||
// big.Int allocation is amortized.
|
||||
func computeR2(q uint64) uint64 {
|
||||
one := big.NewInt(1)
|
||||
r2 := new(big.Int).Lsh(one, 128) // 2^128
|
||||
r2.Mod(r2, new(big.Int).SetUint64(q)) // 2^128 mod q
|
||||
return r2.Uint64()
|
||||
}
|
||||
|
||||
// computeBarrett returns floor(2^128 / q) as a (high, low) 64-bit pair.
|
||||
// Used by Barrett reduction's mu = floor(2^(2k) / q).
|
||||
func computeBarrett(q uint64) [2]uint64 {
|
||||
one := big.NewInt(1)
|
||||
mu := new(big.Int).Lsh(one, 128)
|
||||
mu.Quo(mu, new(big.Int).SetUint64(q)) // floor(2^128 / q)
|
||||
|
||||
low := new(big.Int).And(mu, new(big.Int).SetUint64(^uint64(0)))
|
||||
high := new(big.Int).Rsh(mu, 64)
|
||||
return [2]uint64{high.Uint64(), low.Uint64()}
|
||||
}
|
||||
|
||||
// AddMod returns (a + b) mod q. Branchless conditional subtract.
|
||||
func AddMod(a, b, q uint64) uint64 {
|
||||
s := a + b
|
||||
if s >= q || s < a { // overflow OR >= 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 via 128-bit multiply + Div64.
|
||||
// This is the slow-but-canonical reference path; production callers
|
||||
// prefer Montgomery (MontMulMod) or Barrett (BarrettMulMod) for hot
|
||||
// paths.
|
||||
func MulMod(a, b, q uint64) uint64 {
|
||||
hi, lo := bits.Mul64(a, b)
|
||||
if hi >= q {
|
||||
// Reduce hi first to avoid Div64 panic.
|
||||
_, hi = bits.Div64(0, hi, q)
|
||||
}
|
||||
_, rem := bits.Div64(hi, lo, q)
|
||||
return rem
|
||||
}
|
||||
|
||||
// MontMulMod returns Montgomery multiplication: (a * b * R^-1) mod q
|
||||
// where R = 2^64. Inputs and output are in Montgomery form. Use
|
||||
// ToMontgomery / FromMontgomery for conversion.
|
||||
func MontMulMod(a, b uint64, m *Modulus) uint64 {
|
||||
hi, lo := bits.Mul64(a, b)
|
||||
// t = (lo * QInv) mod 2^64
|
||||
t := lo * m.QInv
|
||||
// u = floor((t * Q + (hi:lo)) / 2^64)
|
||||
tq_hi, tq_lo := bits.Mul64(t, m.Q)
|
||||
carry := uint64(0)
|
||||
if lo+tq_lo < lo {
|
||||
carry = 1
|
||||
}
|
||||
u := hi + tq_hi + carry
|
||||
if u >= m.Q {
|
||||
u -= m.Q
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// ToMontgomery returns x * R mod q (R = 2^64). Equivalent to
|
||||
// MontMulMod(x, R2, m) where R2 = R^2 mod q.
|
||||
func ToMontgomery(x uint64, m *Modulus) uint64 {
|
||||
return MontMulMod(x, m.R2, m)
|
||||
}
|
||||
|
||||
// FromMontgomery returns x_mont * R^-1 mod q, i.e. recovers the
|
||||
// standard-form value. Equivalent to MontMulMod(x_mont, 1, m).
|
||||
func FromMontgomery(xMont uint64, m *Modulus) uint64 {
|
||||
return MontMulMod(xMont, 1, m)
|
||||
}
|
||||
|
||||
// CondSubtract returns x if x < q, else x - q. Branchless.
|
||||
func CondSubtract(x, q uint64) uint64 {
|
||||
mask := uint64(0)
|
||||
if x >= q {
|
||||
mask = ^uint64(0)
|
||||
}
|
||||
return x - (q & mask)
|
||||
}
|
||||
|
||||
// ReductionMode mirrors lattice/types.ReductionMode for the lazy
|
||||
// reduction budget. See package backend for the budget tracker.
|
||||
//
|
||||
// Values are byte-equal to luxfi/lattice/v7/types.ReductionMode so
|
||||
// ReductionBudget instances are interchangeable across the substrate.
|
||||
type ReductionMode uint8
|
||||
|
||||
const (
|
||||
// ReductionStrictEveryOp normalises after every modular operation.
|
||||
ReductionStrictEveryOp ReductionMode = 0
|
||||
// ReductionLazy2 allows result range [0, 2q). q < 2^63.
|
||||
ReductionLazy2 ReductionMode = 1
|
||||
// ReductionLazy4 allows result range [0, 4q). q < 2^62.
|
||||
ReductionLazy4 ReductionMode = 2
|
||||
// ReductionLazy8 allows result range [0, 8q). q < 2^61.
|
||||
ReductionLazy8 ReductionMode = 3
|
||||
)
|
||||
|
||||
// LazyModeFits reports whether q fits the lazy mode without uint64
|
||||
// overflow.
|
||||
func LazyModeFits(mode ReductionMode, q uint64) bool {
|
||||
bitlen := bits.Len64(q)
|
||||
switch mode {
|
||||
case ReductionStrictEveryOp:
|
||||
return bitlen <= 64
|
||||
case ReductionLazy2:
|
||||
return bitlen <= 63
|
||||
case ReductionLazy4:
|
||||
return bitlen <= 62
|
||||
case ReductionLazy8:
|
||||
return bitlen <= 61
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package modarith
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// PulsarQ — Pulsar/LP-073 canonical NTT-friendly prime.
|
||||
// Q = 0x1000000004A01.
|
||||
const PulsarQ = uint64(0x1000000004A01)
|
||||
|
||||
func TestNewModulus_RejectsZero(t *testing.T) {
|
||||
if _, err := NewModulus(0, "zero"); err == nil {
|
||||
t.Error("NewModulus(0): no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModulus_RejectsEven(t *testing.T) {
|
||||
if _, err := NewModulus(8, "even"); err == nil {
|
||||
t.Error("NewModulus(8): no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewModulus_PulsarQ(t *testing.T) {
|
||||
m, err := NewModulus(PulsarQ, "pulsar-q")
|
||||
if err != nil {
|
||||
t.Fatalf("NewModulus: %v", err)
|
||||
}
|
||||
if m.Q != PulsarQ {
|
||||
t.Errorf("Q: %#x", m.Q)
|
||||
}
|
||||
if m.Bits != 49 {
|
||||
t.Errorf("Bits: %d, want 49", m.Bits)
|
||||
}
|
||||
// q * QInv ≡ -1 mod 2^64
|
||||
want := ^uint64(0) // -1 mod 2^64
|
||||
if got := PulsarQ * m.QInv; got != want {
|
||||
t.Errorf("q*QInv = %#x, want %#x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMod(t *testing.T) {
|
||||
q := PulsarQ
|
||||
tests := []struct {
|
||||
a, b, want uint64
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{q - 1, 1, 0},
|
||||
{q - 2, 1, q - 1},
|
||||
{1, 1, 2},
|
||||
{q - 1, q - 1, q - 2},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := AddMod(tc.a, tc.b, q); got != tc.want {
|
||||
t.Errorf("AddMod(%d, %d, %d) = %d, want %d",
|
||||
tc.a, tc.b, q, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubMod(t *testing.T) {
|
||||
q := PulsarQ
|
||||
tests := []struct {
|
||||
a, b, want uint64
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{1, 1, 0},
|
||||
{0, 1, q - 1},
|
||||
{5, 3, 2},
|
||||
{3, 5, q - 2},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := SubMod(tc.a, tc.b, q); got != tc.want {
|
||||
t.Errorf("SubMod(%d, %d, %d) = %d, want %d",
|
||||
tc.a, tc.b, q, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMulMod_VsBigInt(t *testing.T) {
|
||||
q := PulsarQ
|
||||
qBig := new(big.Int).SetUint64(q)
|
||||
r := rand.New(rand.NewPCG(0xdeadbeef, 0x12345678))
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
a := r.Uint64() % q
|
||||
b := r.Uint64() % q
|
||||
got := MulMod(a, b, q)
|
||||
|
||||
want := new(big.Int).Mul(
|
||||
new(big.Int).SetUint64(a),
|
||||
new(big.Int).SetUint64(b))
|
||||
want.Mod(want, qBig)
|
||||
|
||||
if got != want.Uint64() {
|
||||
t.Fatalf("MulMod(%d, %d) = %d, want %d", a, b, got, want.Uint64())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMontgomery_RoundTrip(t *testing.T) {
|
||||
m, err := NewModulus(PulsarQ, "pulsar-q")
|
||||
if err != nil {
|
||||
t.Fatalf("NewModulus: %v", err)
|
||||
}
|
||||
r := rand.New(rand.NewPCG(0xfeedface, 0xc0ffeebabe))
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
x := r.Uint64() % PulsarQ
|
||||
mont := ToMontgomery(x, m)
|
||||
back := FromMontgomery(mont, m)
|
||||
if back != x {
|
||||
t.Fatalf("round-trip [%d]: %d -> mont=%d -> %d", i, x, mont, back)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMontMulMod_VsMulMod(t *testing.T) {
|
||||
m, err := NewModulus(PulsarQ, "pulsar-q")
|
||||
if err != nil {
|
||||
t.Fatalf("NewModulus: %v", err)
|
||||
}
|
||||
r := rand.New(rand.NewPCG(0xa5a5a5a5, 0x5a5a5a5a))
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
a := r.Uint64() % PulsarQ
|
||||
b := r.Uint64() % PulsarQ
|
||||
|
||||
// Mont(a) * Mont(b) * R^-1 == Mont(a*b)
|
||||
aMont := ToMontgomery(a, m)
|
||||
bMont := ToMontgomery(b, m)
|
||||
productMont := MontMulMod(aMont, bMont, m)
|
||||
productStandard := FromMontgomery(productMont, m)
|
||||
|
||||
want := MulMod(a, b, PulsarQ)
|
||||
if productStandard != want {
|
||||
t.Fatalf("[%d] a=%d b=%d: mont path got %d, MulMod got %d",
|
||||
i, a, b, productStandard, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondSubtract(t *testing.T) {
|
||||
q := PulsarQ
|
||||
tests := []struct {
|
||||
x, want uint64
|
||||
}{
|
||||
{0, 0},
|
||||
{q - 1, q - 1},
|
||||
{q, 0},
|
||||
{q + 1, 1},
|
||||
{2*q - 1, q - 1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := CondSubtract(tc.x, q); got != tc.want {
|
||||
t.Errorf("CondSubtract(%d, %d) = %d, want %d",
|
||||
tc.x, q, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLazyModeFits(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode ReductionMode
|
||||
q uint64
|
||||
want bool
|
||||
}{
|
||||
{ReductionStrictEveryOp, 1 << 63, true},
|
||||
{ReductionStrictEveryOp, ^uint64(0), true},
|
||||
{ReductionLazy2, 1<<63 - 1, true},
|
||||
{ReductionLazy2, 1 << 63, false},
|
||||
{ReductionLazy4, 1<<62 - 1, true},
|
||||
{ReductionLazy4, 1 << 62, false},
|
||||
{ReductionLazy8, 1<<61 - 1, true},
|
||||
{ReductionLazy8, 1 << 61, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := LazyModeFits(tc.mode, tc.q); got != tc.want {
|
||||
t.Errorf("LazyModeFits(%d, %d) = %v, want %v",
|
||||
tc.mode, tc.q, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package ntt is the canonical Number-Theoretic-Transform interface for
|
||||
// luxfi/math.
|
||||
//
|
||||
// LP-107 §"NTT" — the canonical motivation. Production callers
|
||||
// (luxfi/lattice, luxfi/pulsar, luxfi/fhe) consume this package's
|
||||
// Service abstraction; concrete kernels live behind a Backend
|
||||
// interface so AVX2 / NEON / CUDA / Metal / WGSL realizations are
|
||||
// interchangeable.
|
||||
//
|
||||
// Phase 2 (this file): defines the public surface. The pure-Go
|
||||
// reference Backend wraps github.com/luxfi/lattice/v7/ring's
|
||||
// SubRing.NTT — the canonical Lattigo-derived Montgomery NTT — so
|
||||
// callers see no behavior change. Phase 3 (LP-107) inverts the
|
||||
// dependency: lattice/ring imports this package, and the Lattigo
|
||||
// kernel body lives here.
|
||||
//
|
||||
// Determinism contract: for a fixed (Params, input []uint64), every
|
||||
// registered Backend MUST produce byte-equal output. KATs in
|
||||
// luxfi/math/ntt/test/kat enforce this across runtimes.
|
||||
package ntt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/math/backend"
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
// Params identifies one NTT instance: ring degree N, modulus Q, and
|
||||
// the canonical parameter ID for KAT lookup.
|
||||
type Params struct {
|
||||
// N is the ring dimension. Must be a power of two.
|
||||
N uint32
|
||||
// Q is the prime modulus. Must satisfy (Q - 1) | 2N (NTT-friendly).
|
||||
Q uint64
|
||||
// ID is the canonical parameter identifier (e.g. NTTPulsarN256).
|
||||
ID params.NTTParamID
|
||||
}
|
||||
|
||||
// Validate ensures (N is a power of two) AND ((Q - 1) | 2N).
|
||||
func (p *Params) Validate() error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("ntt: nil Params")
|
||||
}
|
||||
if p.N == 0 || p.N&(p.N-1) != 0 {
|
||||
return fmt.Errorf("ntt: N=%d not a power of two", p.N)
|
||||
}
|
||||
if p.Q <= 1 {
|
||||
return fmt.Errorf("ntt: Q=%d invalid (must be > 1)", p.Q)
|
||||
}
|
||||
if (p.Q-1)%(2*uint64(p.N)) != 0 {
|
||||
return fmt.Errorf("ntt: Q-1=%d not divisible by 2N=%d (NTT-unfriendly)",
|
||||
p.Q-1, 2*uint64(p.N))
|
||||
}
|
||||
if err := p.ID.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrUnsupportedParams is returned by a Backend that does not support
|
||||
// the requested Params (e.g. CUDA backend asked for N=32 when it only
|
||||
// implements N >= 256).
|
||||
var ErrUnsupportedParams = errors.New("ntt: backend does not support these Params")
|
||||
|
||||
// Backend is the kernel interface every NTT realization implements.
|
||||
// Forward and Inverse operate in-place and MUST produce byte-equal
|
||||
// output across all registered backends for the same (Params, input).
|
||||
type Backend interface {
|
||||
// ID returns the BackendID for this backend.
|
||||
ID() params.BackendID
|
||||
// Supports reports whether this backend can handle p.
|
||||
Supports(p *Params) bool
|
||||
// Forward applies the forward NTT in-place. dst must have length
|
||||
// batch*p.N. Returns ErrUnsupportedParams if Supports returns false.
|
||||
Forward(dst []uint64, p *Params, batch uint32) error
|
||||
// Inverse applies the inverse NTT in-place. Same length contract.
|
||||
Inverse(dst []uint64, p *Params, batch uint32) error
|
||||
}
|
||||
|
||||
// Service binds a Params to a chosen Backend (resolved via dispatch
|
||||
// policy) and exposes the public Forward / Inverse methods every
|
||||
// downstream caller uses.
|
||||
type Service struct {
|
||||
params *Params
|
||||
backend Backend
|
||||
policy backend.Policy
|
||||
}
|
||||
|
||||
// NewService builds a Service for p under the given dispatch policy.
|
||||
// The Backend is resolved at construction time from the registered
|
||||
// backends; if no backend supports p, returns an error.
|
||||
func NewService(p *Params, policy backend.Policy) (*Service, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := policy.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registered := registeredFor(p)
|
||||
id, err := backend.Resolve(policy, registered)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntt.NewService: %w", err)
|
||||
}
|
||||
b := lookup(id)
|
||||
if b == nil {
|
||||
return nil, fmt.Errorf("ntt: backend %s registered but lookup returned nil", id)
|
||||
}
|
||||
return &Service{params: p, backend: b, policy: policy}, nil
|
||||
}
|
||||
|
||||
// Params returns the bound parameter set.
|
||||
func (s *Service) Params() *Params { return s.params }
|
||||
|
||||
// Backend returns the resolved BackendID. Callers print this in logs
|
||||
// to record which path executed.
|
||||
func (s *Service) Backend() params.BackendID { return s.backend.ID() }
|
||||
|
||||
// Forward applies the forward NTT in-place via the resolved backend.
|
||||
func (s *Service) Forward(dst []uint64, batch uint32) error {
|
||||
return s.backend.Forward(dst, s.params, batch)
|
||||
}
|
||||
|
||||
// Inverse applies the inverse NTT in-place via the resolved backend.
|
||||
func (s *Service) Inverse(dst []uint64, batch uint32) error {
|
||||
return s.backend.Inverse(dst, s.params, batch)
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ntt
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/math/backend"
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
// PulsarN256 — Pulsar/LP-073 NTT instance.
|
||||
var PulsarN256 = &Params{
|
||||
N: 256,
|
||||
Q: 0x1000000004A01,
|
||||
ID: params.NTTPulsarN256,
|
||||
}
|
||||
|
||||
func TestParams_Validate(t *testing.T) {
|
||||
if err := PulsarN256.Validate(); err != nil {
|
||||
t.Errorf("Pulsar N=256: %v", err)
|
||||
}
|
||||
bad := &Params{N: 0, Q: 7, ID: params.NTTPulsarN256}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Error("N=0: no error")
|
||||
}
|
||||
bad2 := &Params{N: 257, Q: 7, ID: params.NTTPulsarN256}
|
||||
if err := bad2.Validate(); err == nil {
|
||||
t.Error("N=257 (not pow2): no error")
|
||||
}
|
||||
notNTT := &Params{N: 256, Q: 13, ID: params.NTTPulsarN256}
|
||||
if err := notNTT.Validate(); err == nil {
|
||||
t.Error("not NTT-friendly: no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_PureGo_RoundTrip(t *testing.T) {
|
||||
s, err := NewService(PulsarN256, backend.PolicyPureGo)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
if s.Backend() != params.BackendPureGo {
|
||||
t.Errorf("Backend = %s, want %s", s.Backend(), params.BackendPureGo)
|
||||
}
|
||||
|
||||
r := rand.New(rand.NewPCG(0xdeadbeef, 0x12345678))
|
||||
N := int(PulsarN256.N)
|
||||
a := make([]uint64, N)
|
||||
for i := range a {
|
||||
a[i] = r.Uint64() % PulsarN256.Q
|
||||
}
|
||||
saved := make([]uint64, N)
|
||||
copy(saved, a)
|
||||
|
||||
if err := s.Forward(a, 1); err != nil {
|
||||
t.Fatalf("Forward: %v", err)
|
||||
}
|
||||
if err := s.Inverse(a, 1); err != nil {
|
||||
t.Fatalf("Inverse: %v", err)
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != saved[i] {
|
||||
t.Fatalf("round-trip [%d]: %d != %d", i, a[i], saved[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_BatchRoundTrip(t *testing.T) {
|
||||
s, err := NewService(PulsarN256, backend.PolicyPureGo)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
N := int(PulsarN256.N)
|
||||
const batch = 8
|
||||
a := make([]uint64, batch*N)
|
||||
r := rand.New(rand.NewPCG(0xfeedface, 1))
|
||||
for i := range a {
|
||||
a[i] = r.Uint64() % PulsarN256.Q
|
||||
}
|
||||
saved := make([]uint64, batch*N)
|
||||
copy(saved, a)
|
||||
|
||||
if err := s.Forward(a, batch); err != nil {
|
||||
t.Fatalf("Forward: %v", err)
|
||||
}
|
||||
if err := s.Inverse(a, batch); err != nil {
|
||||
t.Fatalf("Inverse: %v", err)
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != saved[i] {
|
||||
t.Fatalf("batch round-trip [%d]: %d != %d", i, a[i], saved[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_Determinism_AcrossInvocations(t *testing.T) {
|
||||
// Same input -> identical output across two Service instances.
|
||||
a := make([]uint64, PulsarN256.N)
|
||||
r := rand.New(rand.NewPCG(0xa5a5a5a5, 0))
|
||||
for i := range a {
|
||||
a[i] = r.Uint64() % PulsarN256.Q
|
||||
}
|
||||
b := make([]uint64, len(a))
|
||||
copy(b, a)
|
||||
|
||||
s1, err := NewService(PulsarN256, backend.PolicyPureGo)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService 1: %v", err)
|
||||
}
|
||||
s2, err := NewService(PulsarN256, backend.PolicyPureGo)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService 2: %v", err)
|
||||
}
|
||||
if err := s1.Forward(a, 1); err != nil {
|
||||
t.Fatalf("Forward 1: %v", err)
|
||||
}
|
||||
if err := s2.Forward(b, 1); err != nil {
|
||||
t.Fatalf("Forward 2: %v", err)
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("non-deterministic [%d]: %d != %d", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ntt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/math/params"
|
||||
|
||||
"github.com/luxfi/lattice/v7/ring"
|
||||
)
|
||||
|
||||
// pureGoBackend is the canonical pure-Go NTT realization. It delegates
|
||||
// to github.com/luxfi/lattice/v7/ring's SubRing.NTT / INTT — the
|
||||
// canonical Lattigo-derived Montgomery NTT — so callers see no
|
||||
// behavior change vs the v0.1.x lattice path.
|
||||
//
|
||||
// LP-107 Phase 3 will invert this dependency: the canonical kernel
|
||||
// body will live in this package, and luxfi/lattice will import
|
||||
// luxfi/math/ntt to expose ring.SubRing.NTT.
|
||||
type pureGoBackend struct {
|
||||
mu sync.RWMutex
|
||||
rings map[params.NTTParamID]*ring.Ring
|
||||
}
|
||||
|
||||
// PureGoBackend returns the singleton pure-Go NTT backend. Always
|
||||
// available; registered automatically by init().
|
||||
func PureGoBackend() Backend {
|
||||
return &thePureGo
|
||||
}
|
||||
|
||||
var thePureGo = pureGoBackend{
|
||||
rings: make(map[params.NTTParamID]*ring.Ring),
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(&thePureGo)
|
||||
}
|
||||
|
||||
// ID implements Backend.
|
||||
func (b *pureGoBackend) ID() params.BackendID { return params.BackendPureGo }
|
||||
|
||||
// Supports implements Backend. The pure-Go path supports any
|
||||
// NTT-friendly (N, Q) — the validation in Params.Validate is the
|
||||
// definitive gate.
|
||||
func (b *pureGoBackend) Supports(p *Params) bool {
|
||||
return p != nil && p.Validate() == nil
|
||||
}
|
||||
|
||||
// resolveRing returns or builds the cached *ring.Ring for p.
|
||||
func (b *pureGoBackend) resolveRing(p *Params) (*ring.Ring, error) {
|
||||
b.mu.RLock()
|
||||
r, ok := b.rings[p.ID]
|
||||
b.mu.RUnlock()
|
||||
if ok {
|
||||
return r, nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if r, ok := b.rings[p.ID]; ok {
|
||||
return r, nil
|
||||
}
|
||||
rr, err := ring.NewRing(int(p.N), []uint64{p.Q})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntt(pure-go): ring.NewRing(N=%d, Q=%d): %w",
|
||||
p.N, p.Q, err)
|
||||
}
|
||||
b.rings[p.ID] = rr
|
||||
return rr, nil
|
||||
}
|
||||
|
||||
// Forward implements Backend.
|
||||
func (b *pureGoBackend) Forward(dst []uint64, p *Params, batch uint32) error {
|
||||
if !b.Supports(p) {
|
||||
return ErrUnsupportedParams
|
||||
}
|
||||
N := int(p.N)
|
||||
if int(batch)*N > len(dst) {
|
||||
return fmt.Errorf("ntt(pure-go): buffer too small: need %d got %d",
|
||||
int(batch)*N, len(dst))
|
||||
}
|
||||
r, err := b.resolveRing(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sr := r.SubRings[0]
|
||||
for i := uint32(0); i < batch; i++ {
|
||||
off := int(i) * N
|
||||
sr.NTT(dst[off:off+N], dst[off:off+N])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Inverse implements Backend.
|
||||
func (b *pureGoBackend) Inverse(dst []uint64, p *Params, batch uint32) error {
|
||||
if !b.Supports(p) {
|
||||
return ErrUnsupportedParams
|
||||
}
|
||||
N := int(p.N)
|
||||
if int(batch)*N > len(dst) {
|
||||
return fmt.Errorf("ntt(pure-go): buffer too small: need %d got %d",
|
||||
int(batch)*N, len(dst))
|
||||
}
|
||||
r, err := b.resolveRing(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sr := r.SubRings[0]
|
||||
for i := uint32(0); i < batch; i++ {
|
||||
off := int(i) * N
|
||||
sr.INTT(dst[off:off+N], dst[off:off+N])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ntt
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
// Process-wide registry of NTT backends. The pure-Go backend
|
||||
// registers itself in init(); other backends (CUDA, Metal, WGSL) are
|
||||
// registered by the build that includes them.
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = map[params.BackendID]Backend{}
|
||||
)
|
||||
|
||||
// Register installs a Backend under its ID. Re-registration replaces.
|
||||
// Backends MUST be idempotent — registering the same ID twice with
|
||||
// different bodies is a programming error caught at process start
|
||||
// when two libraries each try to register the same ID.
|
||||
func Register(b Backend) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry[b.ID()] = b
|
||||
}
|
||||
|
||||
// Unregister removes a Backend. Used in tests.
|
||||
func Unregister(id params.BackendID) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
delete(registry, id)
|
||||
}
|
||||
|
||||
// lookup returns the registered Backend for id, or nil.
|
||||
func lookup(id params.BackendID) Backend {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
return registry[id]
|
||||
}
|
||||
|
||||
// registeredFor returns the set of registered BackendIDs whose
|
||||
// Supports(p) returns true.
|
||||
func registeredFor(p *Params) map[params.BackendID]bool {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
out := make(map[params.BackendID]bool, len(registry))
|
||||
for id, b := range registry {
|
||||
if b.Supports(p) {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package params is the single Lux registry of cryptographic parameter
|
||||
// identifiers. Every other package in luxfi/math (and downstream
|
||||
// luxfi/lattice, luxfi/pulsar, luxfi/fhe, luxfi/lens) keys off these
|
||||
// IDs; every cross-runtime KAT carries them; every backend dispatch
|
||||
// uses them to route work.
|
||||
//
|
||||
// LP-107 §"Parameter registry" — the canonical motivation. There must
|
||||
// be exactly one place that names "Pulsar's modulus" or
|
||||
// "FHE PN10QP27 ring dimension"; this package is that place.
|
||||
//
|
||||
// IDs are stable strings — wire-formatted, log-printable, KAT-keyed.
|
||||
// Renaming an ID is a breaking change. New IDs append; existing IDs
|
||||
// never change semantics.
|
||||
package params
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ModulusID names a single prime modulus.
|
||||
//
|
||||
// Production identifiers MUST satisfy: stable string, lowercase, hex
|
||||
// representation of the modulus where applicable, prefixed by the
|
||||
// owning protocol/scheme name. Validation: see Modulus.Validate.
|
||||
type ModulusID string
|
||||
|
||||
const (
|
||||
// ModPulsarQ — Pulsar/LP-073 canonical NTT-friendly prime.
|
||||
// Q = 0x1000000004A01 ≈ 2^48; satisfies (Q - 1) | 2N for N = 256.
|
||||
ModPulsarQ ModulusID = "pulsar-q-0x1000000004a01"
|
||||
|
||||
// ModNTT998 — classical NTT-friendly prime 998244353
|
||||
// (used by general vector kernels and tests; not production crypto).
|
||||
ModNTT998 ModulusID = "ntt-998244353"
|
||||
|
||||
// ModFHE_PN10QP27 — first FHE production parameter set; 27-bit Q,
|
||||
// ring dimension N = 1024.
|
||||
ModFHE_PN10QP27 ModulusID = "fhe-pn10qp27"
|
||||
|
||||
// ModFHE_PN11QP54 — second FHE production parameter set; 54-bit Q,
|
||||
// ring dimension N = 2048.
|
||||
ModFHE_PN11QP54 ModulusID = "fhe-pn11qp54"
|
||||
|
||||
// ModFHE_PN9QP28_STD128 — STD128-tagged FHE parameter set;
|
||||
// ring dimension N = 512.
|
||||
ModFHE_PN9QP28_STD128 ModulusID = "fhe-pn9qp28-std128"
|
||||
)
|
||||
|
||||
// String makes ModulusID printable.
|
||||
func (m ModulusID) String() string { return string(m) }
|
||||
|
||||
// Validate reports whether m is a known modulus identifier in this
|
||||
// process. Unknown IDs are rejected — there is no implicit registration.
|
||||
func (m ModulusID) Validate() error {
|
||||
switch m {
|
||||
case ModPulsarQ,
|
||||
ModNTT998,
|
||||
ModFHE_PN10QP27,
|
||||
ModFHE_PN11QP54,
|
||||
ModFHE_PN9QP28_STD128:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("params: unknown ModulusID %q", string(m))
|
||||
}
|
||||
|
||||
// NTTParamID names an (N, Q, root) triple for an NTT instance.
|
||||
// One ModulusID may have multiple NTTParamID values (different N).
|
||||
type NTTParamID string
|
||||
|
||||
const (
|
||||
// NTTPulsarN256 — Pulsar's R_q = Z_q[X]/(X^256 + 1) at Q = ModPulsarQ.
|
||||
NTTPulsarN256 NTTParamID = "pulsar-n256-q0x1000000004a01"
|
||||
|
||||
// NTTFHE_PN10QP27_N1024 — FHE PN10QP27 ring at N = 1024.
|
||||
NTTFHE_PN10QP27_N1024 NTTParamID = "fhe-pn10qp27-n1024"
|
||||
|
||||
// NTTFHE_PN11QP54_N2048 — FHE PN11QP54 ring at N = 2048.
|
||||
NTTFHE_PN11QP54_N2048 NTTParamID = "fhe-pn11qp54-n2048"
|
||||
|
||||
// NTTFHE_PN9QP28_N512 — FHE PN9QP28 ring at N = 512.
|
||||
NTTFHE_PN9QP28_N512 NTTParamID = "fhe-pn9qp28-n512"
|
||||
)
|
||||
|
||||
// String makes NTTParamID printable.
|
||||
func (p NTTParamID) String() string { return string(p) }
|
||||
|
||||
// Validate reports whether p is a known NTT parameter identifier.
|
||||
func (p NTTParamID) Validate() error {
|
||||
switch p {
|
||||
case NTTPulsarN256,
|
||||
NTTFHE_PN10QP27_N1024,
|
||||
NTTFHE_PN11QP54_N2048,
|
||||
NTTFHE_PN9QP28_N512:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("params: unknown NTTParamID %q", string(p))
|
||||
}
|
||||
|
||||
// FHEParamID names a complete FHE scheme parameter set (ring + RNS
|
||||
// chain + key-switching topology + bootstrap structure). Distinct
|
||||
// from NTTParamID: one FHEParamID owns one or more NTTParamIDs.
|
||||
type FHEParamID string
|
||||
|
||||
const (
|
||||
FHE_PN10QP27 FHEParamID = "fhe-pn10qp27"
|
||||
FHE_PN11QP54 FHEParamID = "fhe-pn11qp54"
|
||||
FHE_PN9QP28_STD128 FHEParamID = "fhe-pn9qp28-std128"
|
||||
)
|
||||
|
||||
// String makes FHEParamID printable.
|
||||
func (f FHEParamID) String() string { return string(f) }
|
||||
|
||||
// Validate reports whether f is a known FHE parameter set.
|
||||
func (f FHEParamID) Validate() error {
|
||||
switch f {
|
||||
case FHE_PN10QP27, FHE_PN11QP54, FHE_PN9QP28_STD128:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("params: unknown FHEParamID %q", string(f))
|
||||
}
|
||||
|
||||
// PulsarParamID names a Pulsar threshold-signature parameter set.
|
||||
type PulsarParamID string
|
||||
|
||||
const (
|
||||
// PulsarLP073 — canonical LP-073 Pulsar parameter set.
|
||||
PulsarLP073 PulsarParamID = "pulsar-lp073"
|
||||
)
|
||||
|
||||
// String makes PulsarParamID printable.
|
||||
func (p PulsarParamID) String() string { return string(p) }
|
||||
|
||||
// Validate reports whether p is a known Pulsar parameter set.
|
||||
func (p PulsarParamID) Validate() error {
|
||||
if p == PulsarLP073 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("params: unknown PulsarParamID %q", string(p))
|
||||
}
|
||||
|
||||
// HashSuiteID names a hash construction profile.
|
||||
type HashSuiteID string
|
||||
|
||||
const (
|
||||
HashPulsarSHA3 HashSuiteID = "pulsar-sha3-v1"
|
||||
HashBLAKE3 HashSuiteID = "blake3-v1"
|
||||
)
|
||||
|
||||
// String makes HashSuiteID printable.
|
||||
func (h HashSuiteID) String() string { return string(h) }
|
||||
|
||||
// Validate reports whether h is a known hash suite.
|
||||
func (h HashSuiteID) Validate() error {
|
||||
switch h {
|
||||
case HashPulsarSHA3, HashBLAKE3:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("params: unknown HashSuiteID %q", string(h))
|
||||
}
|
||||
|
||||
// BackendID names a math-substrate backend (CPU pure-Go, native CPU,
|
||||
// CUDA, Metal, WGSL). The same NTT/Modarith/Poly contract may be
|
||||
// realized by multiple backends; KATs prove they produce byte-equal
|
||||
// output.
|
||||
type BackendID string
|
||||
|
||||
const (
|
||||
BackendPureGo BackendID = "pure-go"
|
||||
BackendNative BackendID = "native-cpu"
|
||||
BackendAVX2 BackendID = "avx2"
|
||||
BackendNEON BackendID = "neon"
|
||||
BackendCUDA BackendID = "cuda"
|
||||
BackendMetal BackendID = "metal"
|
||||
BackendWGSL BackendID = "wgsl"
|
||||
)
|
||||
|
||||
// String makes BackendID printable.
|
||||
func (b BackendID) String() string { return string(b) }
|
||||
|
||||
// Validate reports whether b is a known backend.
|
||||
func (b BackendID) Validate() error {
|
||||
switch b {
|
||||
case BackendPureGo, BackendNative, BackendAVX2, BackendNEON,
|
||||
BackendCUDA, BackendMetal, BackendWGSL:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("params: unknown BackendID %q", string(b))
|
||||
}
|
||||
|
||||
// KATHeader is the canonical key-set every KAT vector MUST carry.
|
||||
// LP-107 §"Parameter registry" requirement: every KAT entry binds
|
||||
// itself to a specific (parameter_set, modulus, backend, hash_suite,
|
||||
// implementation_version) tuple so cross-runtime replay can match
|
||||
// like-for-like.
|
||||
type KATHeader struct {
|
||||
ParameterSet string `json:"parameter_set"`
|
||||
ModulusID ModulusID `json:"modulus_id"`
|
||||
BackendID BackendID `json:"backend_id"`
|
||||
HashSuiteID HashSuiteID `json:"hash_suite_id"`
|
||||
ImplementationName string `json:"implementation_name"`
|
||||
ImplementationVersion string `json:"implementation_version"`
|
||||
}
|
||||
|
||||
// Validate ensures every required field is set and known.
|
||||
func (h *KATHeader) Validate() error {
|
||||
if h == nil {
|
||||
return fmt.Errorf("params: nil KATHeader")
|
||||
}
|
||||
if h.ParameterSet == "" {
|
||||
return fmt.Errorf("params: KATHeader.ParameterSet is empty")
|
||||
}
|
||||
if err := h.ModulusID.Validate(); err != nil {
|
||||
return fmt.Errorf("KATHeader: %w", err)
|
||||
}
|
||||
if err := h.BackendID.Validate(); err != nil {
|
||||
return fmt.Errorf("KATHeader: %w", err)
|
||||
}
|
||||
if err := h.HashSuiteID.Validate(); err != nil {
|
||||
return fmt.Errorf("KATHeader: %w", err)
|
||||
}
|
||||
if h.ImplementationName == "" {
|
||||
return fmt.Errorf("params: KATHeader.ImplementationName is empty")
|
||||
}
|
||||
if h.ImplementationVersion == "" {
|
||||
return fmt.Errorf("params: KATHeader.ImplementationVersion is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package params
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModulusID_Validate(t *testing.T) {
|
||||
for _, id := range []ModulusID{
|
||||
ModPulsarQ, ModNTT998,
|
||||
ModFHE_PN10QP27, ModFHE_PN11QP54, ModFHE_PN9QP28_STD128,
|
||||
} {
|
||||
if err := id.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", id, err)
|
||||
}
|
||||
}
|
||||
if err := ModulusID("not-a-real-id").Validate(); err == nil {
|
||||
t.Error("Validate(unknown) returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNTTParamID_Validate(t *testing.T) {
|
||||
for _, id := range []NTTParamID{
|
||||
NTTPulsarN256, NTTFHE_PN10QP27_N1024,
|
||||
NTTFHE_PN11QP54_N2048, NTTFHE_PN9QP28_N512,
|
||||
} {
|
||||
if err := id.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFHEParamID_Validate(t *testing.T) {
|
||||
for _, id := range []FHEParamID{
|
||||
FHE_PN10QP27, FHE_PN11QP54, FHE_PN9QP28_STD128,
|
||||
} {
|
||||
if err := id.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPulsarParamID_Validate(t *testing.T) {
|
||||
if err := PulsarLP073.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", PulsarLP073, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashSuiteID_Validate(t *testing.T) {
|
||||
for _, id := range []HashSuiteID{HashPulsarSHA3, HashBLAKE3} {
|
||||
if err := id.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendID_Validate(t *testing.T) {
|
||||
for _, id := range []BackendID{
|
||||
BackendPureGo, BackendNative, BackendAVX2, BackendNEON,
|
||||
BackendCUDA, BackendMetal, BackendWGSL,
|
||||
} {
|
||||
if err := id.Validate(); err != nil {
|
||||
t.Errorf("%s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKATHeader_Validate(t *testing.T) {
|
||||
good := KATHeader{
|
||||
ParameterSet: "pulsar-lp073",
|
||||
ModulusID: ModPulsarQ,
|
||||
BackendID: BackendPureGo,
|
||||
HashSuiteID: HashPulsarSHA3,
|
||||
ImplementationName: "luxfi/pulsar",
|
||||
ImplementationVersion: "v0.1.4",
|
||||
}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Errorf("good KATHeader: %v", err)
|
||||
}
|
||||
|
||||
bad := KATHeader{}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Error("empty KATHeader.Validate() returned nil")
|
||||
}
|
||||
|
||||
noVer := good
|
||||
noVer.ImplementationVersion = ""
|
||||
if err := noVer.Validate(); err == nil {
|
||||
t.Error("missing ImplementationVersion returned nil")
|
||||
}
|
||||
|
||||
var nilHdr *KATHeader
|
||||
if err := nilHdr.Validate(); err == nil {
|
||||
t.Error("nil KATHeader returned nil")
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package poly provides polynomial-arithmetic primitives over R_q =
|
||||
// Z_q[X] / (X^N + 1) used by every Lux lattice protocol.
|
||||
//
|
||||
// LP-107 §"Polynomial and RNS operations" — the canonical motivation.
|
||||
// Add, sub, scalar-mul, NTT-domain mul; converted to /from NTT domain
|
||||
// via package ntt.
|
||||
//
|
||||
// Phase 2 (this file): pure-Go reference implementation. Body uses
|
||||
// luxfi/math/modarith for the field arithmetic and luxfi/math/ntt
|
||||
// for the transform; no re-implementation, just composition.
|
||||
package poly
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/math/modarith"
|
||||
"github.com/luxfi/math/ntt"
|
||||
)
|
||||
|
||||
// Add returns dst = a + b (mod q). All inputs must have length N.
|
||||
func Add(dst, a, b []uint64, q uint64) error {
|
||||
if len(dst) != len(a) || len(a) != len(b) {
|
||||
return fmt.Errorf("poly.Add: length mismatch dst=%d a=%d b=%d",
|
||||
len(dst), len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
dst[i] = modarith.AddMod(a[i], b[i], q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sub returns dst = a - b (mod q).
|
||||
func Sub(dst, a, b []uint64, q uint64) error {
|
||||
if len(dst) != len(a) || len(a) != len(b) {
|
||||
return fmt.Errorf("poly.Sub: length mismatch dst=%d a=%d b=%d",
|
||||
len(dst), len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
dst[i] = modarith.SubMod(a[i], b[i], q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScalarMul returns dst = a * scalar (mod q).
|
||||
func ScalarMul(dst, a []uint64, scalar, q uint64) error {
|
||||
if len(dst) != len(a) {
|
||||
return fmt.Errorf("poly.ScalarMul: length mismatch dst=%d a=%d",
|
||||
len(dst), len(a))
|
||||
}
|
||||
for i := range a {
|
||||
dst[i] = modarith.MulMod(a[i], scalar, q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PointwiseMul returns dst = a * b (pointwise, NTT domain) (mod q).
|
||||
// Inputs must already be in NTT domain; output is also in NTT domain.
|
||||
// Use ntt.Service.Inverse to bring back to coefficient domain.
|
||||
func PointwiseMul(dst, a, b []uint64, q uint64) error {
|
||||
if len(dst) != len(a) || len(a) != len(b) {
|
||||
return fmt.Errorf("poly.PointwiseMul: length mismatch dst=%d a=%d b=%d",
|
||||
len(dst), len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
dst[i] = modarith.MulMod(a[i], b[i], q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mul computes the negacyclic polynomial product dst = a * b (mod q,
|
||||
// mod X^N + 1) via NTT round-trip: NTT(a), NTT(b), pointwise mul,
|
||||
// inverse NTT. dst, a, b must each have length p.N. Inputs are in
|
||||
// coefficient domain; output is in coefficient domain.
|
||||
//
|
||||
// dst MAY alias a or b.
|
||||
func Mul(dst, a, b []uint64, svc *ntt.Service) error {
|
||||
N := int(svc.Params().N)
|
||||
q := svc.Params().Q
|
||||
if len(dst) != N || len(a) != N || len(b) != N {
|
||||
return fmt.Errorf("poly.Mul: length mismatch dst=%d a=%d b=%d N=%d",
|
||||
len(dst), len(a), len(b), N)
|
||||
}
|
||||
aN := make([]uint64, N)
|
||||
bN := make([]uint64, N)
|
||||
copy(aN, a)
|
||||
copy(bN, b)
|
||||
if err := svc.Forward(aN, 1); err != nil {
|
||||
return fmt.Errorf("poly.Mul: NTT(a): %w", err)
|
||||
}
|
||||
if err := svc.Forward(bN, 1); err != nil {
|
||||
return fmt.Errorf("poly.Mul: NTT(b): %w", err)
|
||||
}
|
||||
if err := PointwiseMul(dst, aN, bN, q); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.Inverse(dst, 1); err != nil {
|
||||
return fmt.Errorf("poly.Mul: INTT(dst): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package poly
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/math/backend"
|
||||
"github.com/luxfi/math/ntt"
|
||||
"github.com/luxfi/math/params"
|
||||
)
|
||||
|
||||
const PulsarQ = uint64(0x1000000004A01)
|
||||
|
||||
var pulsarParams = &ntt.Params{
|
||||
N: 256,
|
||||
Q: PulsarQ,
|
||||
ID: params.NTTPulsarN256,
|
||||
}
|
||||
|
||||
func TestAddSub_RoundTrip(t *testing.T) {
|
||||
N := 256
|
||||
a := make([]uint64, N)
|
||||
b := make([]uint64, N)
|
||||
r := rand.New(rand.NewPCG(0xdead, 0))
|
||||
for i := range a {
|
||||
a[i] = r.Uint64() % PulsarQ
|
||||
b[i] = r.Uint64() % PulsarQ
|
||||
}
|
||||
sum := make([]uint64, N)
|
||||
if err := Add(sum, a, b, PulsarQ); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
got := make([]uint64, N)
|
||||
if err := Sub(got, sum, b, PulsarQ); err != nil {
|
||||
t.Fatalf("Sub: %v", err)
|
||||
}
|
||||
for i := range a {
|
||||
if got[i] != a[i] {
|
||||
t.Fatalf("[%d]: got %d, want %d", i, got[i], a[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScalarMul(t *testing.T) {
|
||||
N := 256
|
||||
a := make([]uint64, N)
|
||||
for i := range a {
|
||||
a[i] = uint64(i + 1)
|
||||
}
|
||||
dst := make([]uint64, N)
|
||||
if err := ScalarMul(dst, a, 7, PulsarQ); err != nil {
|
||||
t.Fatalf("ScalarMul: %v", err)
|
||||
}
|
||||
for i := range a {
|
||||
want := (uint64(i+1) * 7) % PulsarQ
|
||||
if dst[i] != want {
|
||||
t.Errorf("[%d]: got %d, want %d", i, dst[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMul_NegacyclicVsBigInt(t *testing.T) {
|
||||
// Verify a * b mod (X^N + 1) for small constants using package
|
||||
// ntt's pure-Go backend, then sanity-check against a hand-computed
|
||||
// expectation.
|
||||
svc, err := ntt.NewService(pulsarParams, backend.PolicyPureGo)
|
||||
if err != nil {
|
||||
t.Fatalf("ntt.NewService: %v", err)
|
||||
}
|
||||
N := int(pulsarParams.N)
|
||||
a := make([]uint64, N)
|
||||
b := make([]uint64, N)
|
||||
a[0] = 2
|
||||
b[0] = 3
|
||||
dst := make([]uint64, N)
|
||||
if err := Mul(dst, a, b, svc); err != nil {
|
||||
t.Fatalf("Mul: %v", err)
|
||||
}
|
||||
// (2)*(3) = 6 in coefficient 0; everything else 0.
|
||||
if dst[0] != 6 {
|
||||
t.Errorf("dst[0] = %d, want 6", dst[0])
|
||||
}
|
||||
for i := 1; i < N; i++ {
|
||||
if dst[i] != 0 {
|
||||
t.Errorf("dst[%d] = %d, want 0", i, dst[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package rns provides the Residue Number System primitives that FHE
|
||||
// schemes use to operate over a chain of small primes instead of one
|
||||
// large modulus.
|
||||
//
|
||||
// LP-107 §"Polynomial and RNS operations" — the canonical motivation.
|
||||
// FHE PN10QP27 / PN11QP54 ring chains are RNS towers; their basis-
|
||||
// extension and modulus-switching primitives live here.
|
||||
//
|
||||
// Phase 2 (this file): defines the public surface for RNS Basis,
|
||||
// Tower, and basis-extension. Concrete bodies delegate to
|
||||
// github.com/luxfi/lattice/v7/ringqp; Phase 3 of LP-107 inverts that.
|
||||
package rns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/math/modarith"
|
||||
)
|
||||
|
||||
// Basis describes one RNS basis: a list of pairwise coprime primes
|
||||
// q_0, q_1, ..., q_{k-1}. Numbers are represented as their CRT
|
||||
// projections (x mod q_0, x mod q_1, ..., x mod q_{k-1}).
|
||||
type Basis struct {
|
||||
// Moduli is the tuple of primes. Each entry MUST satisfy
|
||||
// gcd(q_i, q_j) = 1 for i != j; we don't re-check this at every
|
||||
// op (it's a parameter-set property).
|
||||
Moduli []*modarith.Modulus
|
||||
// Name is a stable identifier (e.g. "fhe-pn10qp27").
|
||||
Name string
|
||||
}
|
||||
|
||||
// NewBasis constructs an RNS basis from a list of primes. Returns an
|
||||
// error if any modulus fails modarith.NewModulus.
|
||||
func NewBasis(primes []uint64, name string) (*Basis, error) {
|
||||
if len(primes) == 0 {
|
||||
return nil, fmt.Errorf("rns.NewBasis: empty prime list")
|
||||
}
|
||||
mods := make([]*modarith.Modulus, len(primes))
|
||||
for i, q := range primes {
|
||||
m, err := modarith.NewModulus(q, fmt.Sprintf("%s.q[%d]", name, i))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rns.NewBasis: prime[%d]=%d: %w", i, q, err)
|
||||
}
|
||||
mods[i] = m
|
||||
}
|
||||
return &Basis{Moduli: mods, Name: name}, nil
|
||||
}
|
||||
|
||||
// Levels returns the number of primes in the basis.
|
||||
func (b *Basis) Levels() int { return len(b.Moduli) }
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewBasis_Pulsar(t *testing.T) {
|
||||
// Pulsar canonical Q is single-prime; basis with one element is
|
||||
// the degenerate RNS case (no chain reduction).
|
||||
b, err := NewBasis([]uint64{0x1000000004A01}, "pulsar-q")
|
||||
if err != nil {
|
||||
t.Fatalf("NewBasis: %v", err)
|
||||
}
|
||||
if b.Levels() != 1 {
|
||||
t.Errorf("Levels = %d, want 1", b.Levels())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBasis_TwoPrime(t *testing.T) {
|
||||
// Synthetic two-prime tower.
|
||||
b, err := NewBasis([]uint64{0x1000000004A01, 0x1000000007EE1}, "two-prime")
|
||||
if err != nil {
|
||||
t.Fatalf("NewBasis: %v", err)
|
||||
}
|
||||
if b.Levels() != 2 {
|
||||
t.Errorf("Levels = %d, want 2", b.Levels())
|
||||
}
|
||||
if b.Moduli[0].Q != 0x1000000004A01 {
|
||||
t.Errorf("Moduli[0].Q = %#x", b.Moduli[0].Q)
|
||||
}
|
||||
if b.Moduli[1].Q != 0x1000000007EE1 {
|
||||
t.Errorf("Moduli[1].Q = %#x", b.Moduli[1].Q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBasis_Empty(t *testing.T) {
|
||||
if _, err := NewBasis(nil, "empty"); err == nil {
|
||||
t.Error("NewBasis(nil): no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBasis_RejectsEvenPrime(t *testing.T) {
|
||||
if _, err := NewBasis([]uint64{8}, "even"); err == nil {
|
||||
t.Error("NewBasis(even): no error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package sample provides primitive samplers — uniform mod q, ternary,
|
||||
// centered binomial, discrete Gaussian — used as building blocks by
|
||||
// every Lux lattice protocol.
|
||||
//
|
||||
// LP-107 §"Sampling" — the canonical motivation. Protocol-specific
|
||||
// samplers (e.g. Pulsar's transcript-bound discrete Gaussian for the
|
||||
// proof of knowledge) remain in their owning protocol package; this
|
||||
// package is the source of the primitive distributions those
|
||||
// protocols compose.
|
||||
//
|
||||
// Determinism contract: every sampler accepts an io.Reader as its
|
||||
// entropy source. Same seed → same samples, byte-identically.
|
||||
//
|
||||
// Phase 2 (this file): pure-Go reference implementation. Body uses
|
||||
// luxfi/math/modarith for modular reduction.
|
||||
package sample
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Uniform fills dst with values uniformly distributed in [0, q).
|
||||
// Uses rejection sampling with a per-sample mask up to bits.Len64(q)
|
||||
// bits to avoid bias. Reads len(dst) * ceil(log2(q)/8) bytes from r
|
||||
// in expectation; rejection rate is at most 2x.
|
||||
func Uniform(dst []uint64, q uint64, r io.Reader) error {
|
||||
if q < 2 {
|
||||
return fmt.Errorf("sample.Uniform: q=%d invalid", q)
|
||||
}
|
||||
// Mask = next power-of-two-minus-one >= q-1.
|
||||
mask := uint64(1)
|
||||
for mask < q {
|
||||
mask <<= 1
|
||||
}
|
||||
mask--
|
||||
buf := make([]byte, 8)
|
||||
for i := range dst {
|
||||
for {
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return fmt.Errorf("sample.Uniform[%d]: %w", i, err)
|
||||
}
|
||||
v := binary.LittleEndian.Uint64(buf) & mask
|
||||
if v < q {
|
||||
dst[i] = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ternary fills dst with values from {-1 mod q, 0, 1} per the given
|
||||
// non-zero density (probability of non-zero coefficient). Standard
|
||||
// lattice short-secret distribution.
|
||||
func Ternary(dst []uint64, q uint64, density float64, r io.Reader) error {
|
||||
if q < 2 {
|
||||
return fmt.Errorf("sample.Ternary: q=%d invalid", q)
|
||||
}
|
||||
if density < 0 || density > 1 {
|
||||
return fmt.Errorf("sample.Ternary: density=%f out of [0,1]", density)
|
||||
}
|
||||
// Two bytes per sample: byte 0 selects zero/non-zero, byte 1
|
||||
// selects sign.
|
||||
buf := make([]byte, 2)
|
||||
thresh := byte(density * 256)
|
||||
if density >= 1 {
|
||||
thresh = 0xFF
|
||||
}
|
||||
for i := range dst {
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return fmt.Errorf("sample.Ternary[%d]: %w", i, err)
|
||||
}
|
||||
if buf[0] >= thresh {
|
||||
dst[i] = 0
|
||||
continue
|
||||
}
|
||||
if buf[1]&1 == 0 {
|
||||
dst[i] = 1
|
||||
} else {
|
||||
dst[i] = q - 1 // -1 mod q
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CenteredBinomial fills dst with values from a centered binomial
|
||||
// distribution with parameter eta (Bin(2*eta, 0.5) - eta). Standard
|
||||
// Module-LWE error distribution.
|
||||
func CenteredBinomial(dst []uint64, q uint64, eta int, r io.Reader) error {
|
||||
if q < 2 {
|
||||
return fmt.Errorf("sample.CenteredBinomial: q=%d invalid", q)
|
||||
}
|
||||
if eta < 1 || eta > 32 {
|
||||
return fmt.Errorf("sample.CenteredBinomial: eta=%d out of [1,32]", eta)
|
||||
}
|
||||
bytesPerSample := (2*eta + 7) / 8
|
||||
buf := make([]byte, bytesPerSample)
|
||||
for i := range dst {
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return fmt.Errorf("sample.CenteredBinomial[%d]: %w", i, err)
|
||||
}
|
||||
// Compute popcount of first eta bits and last eta bits, take
|
||||
// the difference.
|
||||
var bits uint64
|
||||
for j := 0; j < bytesPerSample; j++ {
|
||||
bits |= uint64(buf[j]) << (j * 8)
|
||||
}
|
||||
mask := (uint64(1) << uint(eta)) - 1
|
||||
a := bitCount64(bits & mask)
|
||||
b := bitCount64((bits >> uint(eta)) & mask)
|
||||
// signed difference in [-eta, +eta]; map to [0, q).
|
||||
signed := a - b
|
||||
if signed >= 0 {
|
||||
dst[i] = uint64(signed) % q
|
||||
} else {
|
||||
dst[i] = q - uint64(-signed)%q
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bitCount64(x uint64) int64 {
|
||||
count := int64(0)
|
||||
for x != 0 {
|
||||
count += int64(x & 1)
|
||||
x >>= 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// DiscreteGaussianRejection samples one value approximately from the
|
||||
// discrete Gaussian D_{Z, sigma}, centered at 0, via rejection
|
||||
// sampling with a 6-sigma cutoff. Accepts ~64% of draws on average
|
||||
// for sigma in the typical lattice range [3, 50]; exact for the
|
||||
// uncentered tail.
|
||||
//
|
||||
// This is the same reference path used by lattice/gpu.SampleGaussian
|
||||
// (which we're consolidating here under LP-107).
|
||||
func DiscreteGaussianRejection(q uint64, sigma float64, r io.Reader) (uint64, error) {
|
||||
if q < 2 {
|
||||
return 0, fmt.Errorf("sample.DiscreteGaussianRejection: q=%d invalid", q)
|
||||
}
|
||||
if sigma <= 0 {
|
||||
return 0, fmt.Errorf("sample.DiscreteGaussianRejection: sigma=%f invalid", sigma)
|
||||
}
|
||||
bound := int64(sigma*6 + 1)
|
||||
buf := make([]byte, 8)
|
||||
for {
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return 0, fmt.Errorf("sample.DiscreteGaussianRejection: %w", err)
|
||||
}
|
||||
raw := binary.LittleEndian.Uint64(buf)
|
||||
// Map raw to a signed integer in [-bound, +bound].
|
||||
span := uint64(2*bound + 1)
|
||||
v := int64(raw%span) - bound
|
||||
|
||||
// Accept with probability exp(-v^2 / (2 sigma^2)).
|
||||
var probAccept big.Float
|
||||
// Use float math to avoid float64 overflow on sigma*sigma for
|
||||
// moderate sigma values.
|
||||
num := float64(v * v)
|
||||
den := 2 * sigma * sigma
|
||||
probAccept.SetFloat64(num / den)
|
||||
expVal := approxExp(-num / den)
|
||||
|
||||
// Draw acceptance threshold uniformly in [0, 1).
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return 0, fmt.Errorf("sample.DiscreteGaussianRejection (accept): %w", err)
|
||||
}
|
||||
threshold := float64(binary.LittleEndian.Uint64(buf)) / float64(^uint64(0))
|
||||
if threshold < expVal {
|
||||
if v >= 0 {
|
||||
return uint64(v) % q, nil
|
||||
}
|
||||
return q - uint64(-v)%q, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// approxExp returns exp(x) for x <= 0. Uses the standard taylor
|
||||
// expansion truncated at 12 terms; sufficient for the sigma <= 50
|
||||
// regime that lattice protocols use.
|
||||
func approxExp(x float64) float64 {
|
||||
if x > 0 {
|
||||
return 1 // shouldn't happen; defensive
|
||||
}
|
||||
if x < -50 {
|
||||
return 0
|
||||
}
|
||||
result := 1.0
|
||||
term := 1.0
|
||||
for n := 1; n <= 12; n++ {
|
||||
term *= x / float64(n)
|
||||
result += term
|
||||
}
|
||||
if result < 0 {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package sample
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/zeebo/blake3"
|
||||
)
|
||||
|
||||
const PulsarQ = uint64(0x1000000004A01)
|
||||
|
||||
// deterministicReader returns an unbounded byte stream from a seed
|
||||
// string. Same pattern as luxfi/threshold's deterministicRand test
|
||||
// helper.
|
||||
func deterministicReader(seed string) io.Reader {
|
||||
h := blake3.New()
|
||||
_, _ = h.Write([]byte("luxfi/math/sample/test/v1"))
|
||||
_, _ = h.Write([]byte(seed))
|
||||
return h.Digest()
|
||||
}
|
||||
|
||||
func TestUniform_Determinism(t *testing.T) {
|
||||
N := 256
|
||||
a := make([]uint64, N)
|
||||
b := make([]uint64, N)
|
||||
if err := Uniform(a, PulsarQ, deterministicReader("uniform-1")); err != nil {
|
||||
t.Fatalf("Uniform[a]: %v", err)
|
||||
}
|
||||
if err := Uniform(b, PulsarQ, deterministicReader("uniform-1")); err != nil {
|
||||
t.Fatalf("Uniform[b]: %v", err)
|
||||
}
|
||||
if !bytes.Equal(uint64sToBytes(a), uint64sToBytes(b)) {
|
||||
t.Error("Uniform with same seed: byte-mismatch")
|
||||
}
|
||||
for i, v := range a {
|
||||
if v >= PulsarQ {
|
||||
t.Fatalf("Uniform[%d] = %d >= q=%d", i, v, PulsarQ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTernary_Distribution(t *testing.T) {
|
||||
N := 4096
|
||||
dst := make([]uint64, N)
|
||||
if err := Ternary(dst, PulsarQ, 0.5, deterministicReader("ternary")); err != nil {
|
||||
t.Fatalf("Ternary: %v", err)
|
||||
}
|
||||
// Coarse distribution check: with density=0.5, expect ~50%
|
||||
// non-zero. Allow ±10% slack.
|
||||
zero, plus, minus := 0, 0, 0
|
||||
for _, v := range dst {
|
||||
switch v {
|
||||
case 0:
|
||||
zero++
|
||||
case 1:
|
||||
plus++
|
||||
case PulsarQ - 1:
|
||||
minus++
|
||||
default:
|
||||
t.Fatalf("Ternary: unexpected value %d", v)
|
||||
}
|
||||
}
|
||||
nonZero := plus + minus
|
||||
if nonZero < N*4/10 || nonZero > N*6/10 {
|
||||
t.Errorf("Ternary density: nonZero=%d/%d (expected ~50%%)", nonZero, N)
|
||||
}
|
||||
// Plus and minus should be roughly balanced.
|
||||
if plus < nonZero/3 || minus < nonZero/3 {
|
||||
t.Errorf("Ternary balance: plus=%d minus=%d", plus, minus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCenteredBinomial_RangeBounded(t *testing.T) {
|
||||
N := 1024
|
||||
dst := make([]uint64, N)
|
||||
const eta = 2
|
||||
if err := CenteredBinomial(dst, PulsarQ, eta, deterministicReader("cbd-2")); err != nil {
|
||||
t.Fatalf("CenteredBinomial: %v", err)
|
||||
}
|
||||
// Output values should all be in {q-eta, ..., q-1, 0, 1, ..., eta}.
|
||||
for i, v := range dst {
|
||||
if v <= eta {
|
||||
continue
|
||||
}
|
||||
if v >= PulsarQ-eta {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("CenteredBinomial[%d] = %d out of expected range", i, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscreteGaussianRejection_Range(t *testing.T) {
|
||||
// Coarse range test: 1000 samples should all lie within ~6 sigma.
|
||||
const sigma = 3.2
|
||||
r := deterministicReader("dgrr")
|
||||
for i := 0; i < 1000; i++ {
|
||||
_, err := DiscreteGaussianRejection(PulsarQ, sigma, r)
|
||||
if err != nil {
|
||||
t.Fatalf("[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uint64sToBytes(s []uint64) []byte {
|
||||
out := make([]byte, len(s)*8)
|
||||
for i, v := range s {
|
||||
for j := 0; j < 8; j++ {
|
||||
out[i*8+j] = byte(v >> (j * 8))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user