Closes the long-standing Red HIGH B2 gap that forced luxfi/threshold's
JSON-RPC dispatcher (pkg/thresholdd/corona.go, pulsar.go) to refuse
every Corona op because Signature and GroupKey lacked stable
cross-process wire formats.
Adds:
Signature.MarshalBinary / UnmarshalBinary
GroupKey.MarshalBinary / UnmarshalBinary
VerifyBytes(gkBytes, msg, sigBytes) bool
Layout is domain-separated (CORS magic for Signature, CORG for
GroupKey) so the dispatcher cannot confuse the two slots. Versioning
(2-byte v1) leaves room for byte-format evolution under explicit
opt-in. Bounded validation flows through corona/wire so a malformed
length cannot trigger an oversized allocation.
VerifyBytes is the stateless surface that pkg/thresholdd consumes:
bytes-in, bool-out, no per-process state, byte-equal re-marshaling on
the roundtrip. Cross-wire feeds (GroupKey magic in the Signature slot)
are explicitly rejected.
Tests cover happy path (sign -> marshal -> unmarshal -> verify, plus
byte-equal re-marshal), tampered sig and tampered message rejection,
malformed input rejection (empty, too-short, wrong magic, wrong
version), and cross-wire confusion rejection.
Cannot t.Parallel: threshold.GenerateKeys mutates the sign package
globals K and Threshold (threshold.go:123-124); parallel use of the
kernel races on those globals. The wire codec itself is pure.
Add corona/gpu package — the single, decomplecting point where corona
opts into the lattice library's per-SubRing GPU NTT dispatcher.
Architecture (decomplected).
The lattice library already owns ALL build-tag plumbing for GPU NTT:
ring.SetGPUDispatchers (subring_ops.go) is the canonical hook; the
lattice/gpu package installs it under `cgo && gpu` build tags and
provides a real CPU fallback under !cgo or !gpu. Output is byte-equal
to ring.SubRing.NTT by lattice's own contract.
corona/gpu adds the corona-side bridge: UseAccelerator() flips a
global flag, NewParams() across corona consults the flag via
MaybeRegister and binds each created Ring's SubRings into the lattice
GPU registry. Single source of truth for the opt-in; no build tags
inside corona.
Threshold gating (honest).
Single-poly Metal NTT at corona's production N=256 is roughly 4-6x
SLOWER than pure-Go ring.SubRing.NTT (measured: BenchmarkPulsarSign_
5of7 force-GPU 7.1s vs CPU 1.1s; 14of21 force-GPU 23.5s vs CPU 5.9s).
The GPU win exists only in BATCHED dispatch (many polynomials per
kernel launch), which requires future engine-layer plumbing of
lattice/gpu.MontgomeryNTTContext.Forward(data, batch>=4) bypassing
the per-poly r.NTT() pinch point.
Therefore UseAccelerator() picks defaultThreshold=1024 — above
corona's N=256 — so the SubRing dispatch is armed but does not fire
on single-poly NTT. The registry remains primed for any future batch
caller (e.g. FHE bootstraps in thresholdvm sharing this library).
UseAcceleratorForce() (threshold=1) is provided strictly for the
correctness gate: every NTT call routes through the GPU so the
byte-equality test in threshold/threshold_gpu_test.go exercises the
GPU path end-to-end. Production callers use UseAccelerator() instead.
Byte-equality.
TestThresholdSign_CPU_vs_GPU_ByteIdentical runs the full 2-round
Pulsar signing protocol with GPU dispatch off and forced-on (same
deterministic dealer randomness, same message) and asserts byte-equal
sig.C / sig.Z / sig.Delta. Passes under CGO_ENABLED=0, CGO_ENABLED=1,
and CGO_ENABLED=1 -tags gpu. Existing TestDKG2_GPU_ByteEqual coverage
extends across n=3,5,7,11,21 (production shape).
Wiring.
NewParams() in sign-bound packages — threshold, dkg2, dkg, reshare —
calls corona/gpu.MaybeRegister(r) for the main Q ring. RXi and RNu
are power-of-two moduli; the NTT path is not taken on them.
Tests.
Full corona test suite passes under both build modes:
- CGO_ENABLED=0 go test ./... => all green
- CGO_ENABLED=1 -tags gpu test => all green
Corona signatures are Ring-LWE (C, Z, Delta) ring-polynomial triples,
not FIPS 204 byte-equal (that's Pulsar's Module-LWE sibling). Single
Verify is the only place this package touches the sign.Verify
predicate; VerifyBatch composes it across N (groupKey, message,
signature) tuples in parallel via a GOMAXPROCS-bounded worker pool.
Public surface added:
- VerifyBatch(gks, msgs, sigs) -> ([]bool, error)
- VerifyBatchAll(gks, msgs, sigs) -> (bool, error)
- ErrBatchSizeMismatch
Pure Go: NO CGO / accel dep. A future Metal/CUDA NTT-batch kernel
over the polynomial-arithmetic hotspot would live in luxfi/accel
behind the same byte interface; consumers wanting GPU dispatch call
accel directly against the wire bytes. This file is the portable
fallback everyone gets for free.
Tests (GOWORK=off go test ./threshold/ -run TestVerifyBatch):
TestVerifyBatch_AllValid (n=4, all valid) PASS
TestVerifyBatch_OneCorrupt (message swap @1) PASS
TestVerifyBatch_StructuralMismatch (slice len mismatch) PASS
TestVerifyBatch_Empty PASS
Full ./threshold suite: ok 2.068s.
Leftover from Corona's "Pulsar-R" lineage. Pulsar (M-LWE) and Corona
(R-LWE) are independent constructions with separate hardness assumptions,
so their cSHAKE personalisation strings must be distinct.
Changes (Go, non-luxcpp):
- hash tags: PULSAR-HC-v1 -> CORONA-HC-v1, etc. (HU, TRANSCRIPT, PRF, MAC, PAIRWISE)
- profile IDs: "Pulsar-SHA3" -> "Corona-SHA3", "Pulsar-BLAKE3" -> "Corona-BLAKE3"
- context strings: pulsar.dkg2.A.v1 -> corona.dkg2.A.v1, etc.
- env vars: PULSAR_RESHARE_KAT_PATH -> CORONA_RESHARE_KAT_PATH, etc.
- struct names: pulsarSHA3 -> coronaSHA3
- KAT derive roots: sign_e2e_pulsar -> sign_e2e_corona
What's preserved (different scope):
- luxcpp/crypto/pulsar/* path references in comments (separate repo,
out of scope; the C++ side will rename in its own commit)
- Cross-runtime KAT files on disk (will regenerate next CI run)
All 11 packages test green: dkg, dkg2, hash, keyera, networking,
primitives, reshare, sign, threshold, utils, wire.
Repo forks from luxfi/pulsar (R-LWE production threshold for Lux
consensus) to luxfi/corona under the family rename. Same Ring-LWE
code, same hash family (Pulsar-SHA3 / Pulsar-BLAKE3 -- wire-format
customization tags stay stable per MED-2). Only the Go module path
and internal imports change.
Lux family alignment:
- Pulsar = M-LWE / FIPS 204 / NIST MPTC N1 (was luxfi/pulsar-m)
- Corona = R-LWE / NIST MPTC S1 (this repo)
- Corona = academic upstream + Lux DKG expansions
Whitespace-only struct-tag alignment changes. Closes the CI Lint gate
that was failing across multiple recent commits on otherwise-green
build+test runs.
cmd/sign_oracle/ — emits the canonical Pulsar sign+verify KAT JSON
(Gen + SignRound1 + SignRound2{Preprocess,} + SignFinalize + Verify) for
(t, n) ∈ {2/3, 3/5, 5/7, 7/11}. The C++ port at
luxcpp/crypto/pulsar/cpp/sign/ replays these entries byte-equal.
cmd/cross_runtime_oracle/ — Go writer for the cross-runtime release
gate. Emits a SHA-256 manifest tying together the three canonical
Pulsar KATs (sign, reshare, dkg2). C++ side at
luxcpp/crypto/pulsar/test/cross_runtime_test.cpp replays each KAT and
checks byte-equality (forward direction: 30/30 PASS).
cmd/cross_runtime_verify/ — Go reader for the reverse direction
(C++ writes, Go verifies). Currently 3/3 PASS against the SHA-256s
emitted by luxcpp/crypto/pulsar/cmd/cross_runtime_oracle/.
threshold/testdata/fuzz/FuzzPulsarSign1Round1Data/ — corpus seed for
the in-tree fuzz harness; mitigates the second lattice DoS (issue #4)
where Vector[T].ReadFrom calls make([]T, size) with no bound.
23 fuzz harnesses × 30s = 10.9M execs, 0 panics.
FuzzPulsarSign1Round1Data found a 9-byte input that crashes the
goroutine with `fatal error: out of memory: cannot allocate
105589698985984-byte block` in `lattice/v7/utils/structs.Vector[T].
ReadFrom`. Recovery cannot catch the fatal OOM — the only safe path
is to reject malformed frames before `make([]T, size)` runs.
This is a SECOND DoS surface in lattice/v7, distinct from the
ReadUintNSlice recursion fixed by luxfi/lattice#3 (still open):
ReadFrom calls `make([]T, size)` with `size` read from the wire
before any bound check. PR #3 fixes the inner slice readers but the
outer Vector wrapper allocation remains exploitable.
Filed upstream as luxfi/lattice#4 with reproduction. The new
validateVectorPolyFrameInline walker mirrors the existing
warp/pulsar.validateVectorPolyFrame: walks the wire frame
end-to-end, asserts every length field is <= MaxLatticeUintSliceLen
(4096), and rejects truncated / over-large frames before the
lattigo decoder runs.
Result: FuzzPulsarSign1Round1Data passes 30s with the new walker
in place (28577 execs, 0 panics). Failing input
testdata/fuzz/FuzzPulsarSign1Round1Data/a80b8d313b40fa55 is now
rejected cleanly with "vector length 70368955777453 exceeds 4096".
Refs: luxfi/lattice#2 (open), luxfi/lattice#3 (open),
luxfi/lattice#4 (filed 2026-05-04)