audit 2026-06: TEE + GPU + public-permissionless-chain safety

Fresh four-dimension audit at v1.1.1 (commit 736ee58). BLOCKERS.md stays
at "submission-ready. No open items." for existing claims; two
forward-looking v1.2 extensions file under the new Forward-looking
section.

Bottom line per dimension:

1. Public-permissionless-chain safety — no re-opener. Slashable
   equivocation extractable via AbortEvidence (4-field TLV +
   wire-stable codec); rogue-key bound via FIPS 204 tr =
   SHAKE-256(pk, 64) → μ; adaptive corruption gap is a research
   direction documented as openq in spec/pulsar.tex; front-running
   ruled out by Round-1 commit-and-reveal + ML-KEM-768-derived
   pairwise MAC keys; PULSAR-V04-CTX v0.4 ctx-bound μ derivation
   stays load-bearing via single canonical deriveMuCtx helper.

2. TEE — luxfi/threshold/protocols/mldsa-tee already ships the
   custody-mode TEE-gated master-seed surface (AMD SEV-SNP / Intel
   TDX / NVIDIA NRAS via mpc/cc/attest + KMS ReleaseGate + HSM
   wrap-key + approval). Adds in-pulsar AttestationContext(setup, msg)
   helper for the permissionless-quorum-gating case: canonical 32-byte
   TBS digest a validator's TEE quote should bind to. No wire change,
   no ABI break. Five unit tests pin stability, nil-safety, full input
   sensitivity, and customisation-tag domain separation.

3. GPU — pulsar/gpu (UseAccelerator) is wired to luxfi/lattice/v7/gpu
   dispatcher; lux-private/gpu-kernels/ops/lattice/ntt_mldsa ships the
   byte-equal-to-PQClean NTT across 5 backends (CUDA/HIP/Metal/Vulkan/
   WGSL). The substrate (lux_lattice_ntt_mldsa_batch + the existing
   stubbed lux_mldsa_verify_batch in accel/internal/capi/capi.go:503-531)
   is the v1.2 forward-looking item; routing through Round-2 batched
   NTT call sites is mechanical once the ABI ships. Byte-equality
   regression guard TestPulsar_GPU_ByteEqual already in place; passes
   vacuously today, pins divergence the moment GPU dispatch engages.

4. Cross-validation against pulsar-mptc legacy — clean. No orphan
   imports. All "MPTC" refs are to the NIST competition category.

Build sanity: go build / go vet clean; full ref/go/pkg/pulsar suite
green (14.9s, 1.4s -race on new tests); GPU subpackage + interop tests
green.

Files:
- AUDIT-2026-06.md (new) — comprehensive audit per dimension.
- BLOCKERS.md — adds Forward-looking (v1.2) section with
  PULSAR-V12-TEE-BIND + PULSAR-V12-GPU-NTT-WIRE. Existing Closed
  section untouched.
- ref/go/pkg/pulsar/attest.go (new) — AttestationContext helper
  (one pure function over already-transmitted Round-1 inputs).
- ref/go/pkg/pulsar/attest_test.go (new) — stability + nil-safety +
  input sensitivity + domain-separation pins.
This commit is contained in:
2026-06-03 11:40:02 -07:00
parent 04108e45b9
commit bbb0f9f550
4 changed files with 849 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
# Pulsar 2026-06 fresh four-dimension audit
> Re-audit of `github.com/luxfi/pulsar` at `04cdc24` against the public-
> permissionless-chain deployment dimension, TEE integration, GPU
> integration, and orphan-reference hygiene. Issued even though
> `BLOCKERS.md` reads "submission-ready. No open items." — the brief
> is to look fresh and re-open if anything surfaces. **Nothing
> surfaces that re-opens an Open finding.** Two forward-looking items
> are filed for v1.2 (PULSAR-V12-TEE-BIND, PULSAR-V12-GPU-NTT-WIRE);
> both are extensions, not gaps in existing claims.
`luxfi/pulsar` ships a FIPS 204 ML-DSA-65 threshold signature. v1.1.1
closed PULSAR-V04-CTX (FIPS 204 §5.4 ctx-bound threshold sign with
single canonical μ-derivation helper). The current head builds
clean, `go vet` clean, the load-bearing tests (`TestPulsar_Wire_*`,
`TestOrchestrateV03*`, `TestPrecompile_PulsarMPC*`,
`TestAlgebraic_FullCycle_*`, `TestPulsar_GPU_ByteEqual`) all pass.
The four-dimension audit covers:
1. Public-permissionless-chain safety.
2. TEE integration (NVIDIA Confidential Compute / AMD SEV-SNP / Apple
Secure Enclave).
3. GPU acceleration (NTT batching against
`lux-private/gpu-kernels/ops/lattice/{ntt,intt}_mldsa`).
4. Cross-validation against legacy `pulsar-mptc`.
The audit's bottom line per dimension is at the head of each section.
## 1. Public-permissionless-chain safety
**Bottom line.** No re-opener. The v0.3 algebraic-aggregate threshold
path is the canonical permissionless surface; nothing in the codebase
at v1.1.1 contradicts the safety claims already in `spec/pulsar.tex`,
`docs/threat-model.md`, and the v1.0.20 + v1.0.21 + v1.0.22 + v1.0.23 +
v1.1.0 + v1.1.1 closure record.
### 1.1 Slashable equivocation
Verified at
`ref/go/pkg/pulsar/abort.go:43-49` and `ref/go/pkg/pulsar/abort.go:101-125`.
Equivocation evidence is a 4-field TLV blob `(commit1, commit2,
broadcast_sig1, broadcast_sig2)` with kind-tagged structural
validation, ENCODED canonical TBS bytes for the accuser identity
signature (`TranscriptForComplaint` at `abort.go:310-325`), and an
adversarial uniqueness check that rejects `commit1 == commit2` via
`ErrEvidenceDuplicate`. `MarshalAbortEvidence` / `UnmarshalAbortEvidence`
give a wire-stable codec (kind || accuser || accused || epoch ||
len(ev) || ev || len(sig) || sig) that any chain validator,
audit observer, or slashing module can verify against the
accuser's published identity key. Double-signing on a Round-1 commit
is therefore extractable to a `ComplaintEquivocation` complaint that
binds to (accuser, accused, epoch) — exactly the slashable evidence
the host chain requires.
### 1.2 Rogue-key / key-substitution
The threshold path inherits FIPS 204's standard key-binding hash
`tr = SHAKE-256(pk, 64)` (`mldsa_keyderive.go:34, 164-167`). `tr` is
the SOLE μ-input besides `M'`:
```
μ = SHAKE-256(tr || M') where M' = 0x00 || |ctx| || ctx || M
```
The single canonical μ-derivation lives at `threshold_v03.go:1294-1303`
(`deriveMuCtx`) — one function, two callers (`round2EmitFull` and
`AlgebraicAggregateCtx`). Every threshold signer is therefore
key-prefix-bound to the **group** pk. A rogue-key attack on the
threshold path reduces to a rogue-key attack on FIPS 204 ML-DSA
itself, against which key-prefix-binding is the standard FIPS 204
defense (FIPS 204 §5.4 step 2, mirrored in cloudflare/circl's verifier
that pulsar's `Verify` dispatches into).
The DKG path (`threshold_v03.go:264-371`,
`DealAlgebraicV03Shares`) Shamir-shares the FIPS 204-derived (s1, s2,
t0) over the polynomial ring per-coefficient with a fresh random
polynomial under the FIPS 204-derived A; rogue contributions during
DKG would land in the algebraic aggregate as deviations detectable
by the v0.3 abort path (`MACFailure` complaint, `abort.go:149-166`).
### 1.3 Adaptive corruption
The static-corruption proof is the MPTC baseline; spec/pulsar.tex
§"Adaptive corruption proof" calls out the static-to-adaptive gap as
a known open research direction (mirroring the same open question
in the `boschini2024corona` baseline). This is a research gap, not
a deployment gap: every PQ threshold signature in the published
literature inherits the same static-vs-adaptive complexity-leveraging
loss. Lux validator-set governance restricts mid-round corruption via
epoch-bound committee selection (see consensus's epoch beacon), which
is the deployed mitigation pulsar.tex Theorem reshare-pkinv assumes.
**No re-opener.** Tracked in `spec/pulsar.tex` openq blocks.
### 1.4 Front-running / aggregation safety
Verified at `threshold_v03.go:757-934` (Round2Sign + AlgebraicAggregateCtx).
The per-party contribution is `z_i = y_i + c·λ_i·s_{1,i}` where:
- `y_i` is per-party fresh randomness drawn from the FIPS 204
`(-γ_1', γ_1']` uniform distribution (`threshold_v03.go:660-675`,
`polyDeriveUniformBounded`),
- `c` is the public challenge derived from `(μ, w1)` (single SHAKE-256
pass, `threshold_v03.go:861-869`), and
- `λ_i` is the Lagrange coefficient for party i over the quorum.
`y_i` masks `s_{1,i}` under M-LWE (the v0.3 information-theoretic
sketch is at `threshold_v03.go:749-756`). An adversary observing a
prefix `t-1` of contributions cannot bias the aggregate's c-derivation:
`c` depends on the public group pk via `tr`, the public ctx, the
public message, and the public packed `w1` — none of which the
adversary can adapt against the aggregator after broadcast.
The Round-1 commit (`threshold_v03.go:700-703`,
`s.myCommit = transcriptHash32(tagV03R1, wPacked || tau1)`) hash-commits
to `w_i` BEFORE Round-2 reveals; this is the standard commit-and-reveal
defense against an adversary biasing its own `y_i` after observing
others'. The MAC layer (`tau1` KMAC256, key per-pair from
`SymmetricSession` ML-KEM-768) authenticates each commit-and-reveal
under each pairwise identity-derived key, ruling out cross-quorum
front-running by an unauthenticated bystander.
### 1.5 v0.4 ctx-bound wire-shape ambiguity
PULSAR-V04-CTX closed (CHANGELOG `bfee719`, BLOCKERS.md). Verified
at v1.1.1 head:
- `deriveMuCtx` (`threshold_v03.go:1294-1303`) is the SINGLE source of
truth for μ derivation.
- `round2EmitFull` (`threshold_v03.go:858`) and `AlgebraicAggregateCtx`
(`threshold_v03.go:1143`) both call it.
- `NewAlgebraicThresholdSignerCtx`, `AlgebraicAggregateCtx`, and
`OrchestrateV03SignCtx` validate `len(ctx) <= 255` at the API
boundary (`orchestrate.go:122`).
- `OrchestrateV03Sign(msg)` is a thin wrapper for
`OrchestrateV03SignCtx(nil, msg)`; empty ctx byte-identical to
historical v0.3 output.
`TestOrchestrateV03SignCtx_VerifyMatchesFIPS204` cross-checks against
`cloudflare/circl` `mldsa65.VerifyCtx`. No re-opener.
### 1.6 Constant-time discipline
- `Verify` constant-time over valid-signature class:
`ct/dudect/dudect_verify.c` exercises the dudect harness on the
valid-sig population (which is the operationally meaningful CT
class; see `ct/dudect/README.md` lines 14-25 for the rationale).
- `Combine` / `AlgebraicAggregateCtx` constant-time in per-party
shares: `ct/dudect/dudect_combine.c`. A timing leak here is HARD
FAIL by the v0.3 contract.
- `Sign` (single-party FIPS 204 rejection-sampling): explicitly NOT
constant-time per FIPS 204 §3.3. Inherited from the underlying
primitive. Documented in `ct/dudect/README.md` lines 31-37.
- Jasmin `#[ct]` CT type-checked across the whole jasmin/ corpus per
commit `04cdc24` ("All `#[ct]` contracts and `#declassify` pragmas
preserved (CT type-checked at compile). Full Jasmin corpus: 23/23
compile (was 20/23).").
### 1.7 Memory-zeroization
Verified at `ref/go/pkg/pulsar/zeroize.go` (best-effort byte/seed/u16
wipe with the standard Go-no-memzero caveat in the file header).
Master-seed lifetime in `DealAlgebraicV03Shares` is bounded by a
`defer zeroizeKeyMaterial(km)` at `threshold_v03.go:308`; the
algebraic-aggregate path does NOT materialise sk at any quorum party
by construction.
### 1.8 Wire codec
Pulsar v1.1.0 / `wire.go` ships canonical PULS / PULG framed
signatures + group-keys with magic-byte domain separation from
corona's CORS / CORG. `TestPulsar_Wire_FIPS204Verifiable` cross-checks
that a PULS-framed sig, with the frame stripped, verifies under
unmodified `mldsa65.Verify`. No re-opener.
## 2. TEE integration
**Bottom line.** Custody-mode TEE integration is **shipped** in
`github.com/luxfi/threshold/protocols/mldsa-tee` (master-seed-bound,
chain-validated AMD SEV-SNP / Intel TDX / NVIDIA NRAS attestation via
`mpc/cc/attest`, KMS release-gate, HSM-resident wrap-key, approval
provider). The output is byte-identical to single-party FIPS 204
ML-DSA `SignDeterministic` and verifies via `pulsar.VerifyBytes`.
The permissionless-BFT v0.3 path does **not** require TEE integration
for safety (no party materialises sk by construction). What it CAN
benefit from — and what is filed as v1.2 forward-looking — is an
optional binding so a validator's Round-1 commit binds an external TEE
attestation digest. The chain can then enforce attestation-presence as
a per-validator-set policy at the consensus layer without touching
pulsar's wire shape.
### 2.1 What ships today
`luxfi/threshold/protocols/mldsa-tee`:
```
pulsar.OrchestrateV03Sign — permissionless-BFT; no TEE required
mldsatee.Signer.Sign — custody-mode; TEE required
```
The custody surface chains the four-layer release gate
(`doc.go:9-20`):
1. Verifiable hardware attestation chain-validated against the pinned
vendor root (AMD SEV-SNP Milan, NVIDIA NRAS, Intel TDX) via
`mpc/cc/attest.Dispatch`.
2. KMS `ReleaseGate` that pins the worker's RIM digest + hardware
fingerprint and binds a single-use challenge nonce per-request.
3. Out-of-band human / programmatic approval signature via
`mpc/pkg/approval.ApprovalProvider`.
4. HSM-resident wrap-key store via `mpc/pkg/hsm.Provider` so the
FIPS 204 master ML-DSA seed (32 bytes) is sealed-at-rest and only
ever unwrapped inside the attested TEE.
The output is the PULS-framed pulsar.Signature bytes. The SDK / chain
verifier path is identical to the permissionless case.
### 2.2 Apple Secure Enclave
`mpc/cc/attest` dispatches every attestation blob through a per-kind
verifier. Apple Secure Enclave (DCAP-equivalent) attestation lives in
`mpc/cc/attest/apple` IFF it is registered with the dispatcher. As of
the audit head, `mpc/cc/attest` ships AMD SEV-SNP + Intel TDX + NVIDIA
NRAS verifiers. Apple Secure Enclave is **outside the audit scope** —
it lives at the OS-level keychain layer, not at the validator-process
layer where pulsar runs. If a future deployment needs Secure Enclave-
backed validator key custody, the natural binding is to register
`attest.Kind = AppleSecureEnclave` with the
`CompositeAttestation.VerifyEvidence` dispatcher and reuse the same
`mldsatee.Envelope` shape unchanged.
### 2.3 Forward-looking — PULSAR-V12-TEE-BIND (v1.2)
For the permissionless-BFT v0.3 path, the audit recommends shipping a
small in-pulsar helper that constructs the canonical TBS bytes a
validator's TEE quote should bind to. This is **not** a wire change —
the binding lives inside the consensus envelope that carries the
Round-1 commit, not inside pulsar's `AlgebraicRound1Message`. The
helper makes attestation-binding unambiguous and gives the chain ONE
canonical recipe.
Proposed surface (no wire change, no ABI change):
```go
// AttestationContext returns the canonical TBS bytes a validator's
// TEE quote should bind to when participating in a public-
// permissionless Pulsar quorum that the host chain wants gated by
// attestation presence.
//
// The bound material is exactly what an attested validator promises
// is owned by its TEE-resident signer for the duration of this
// Round-1 commit:
//
// AttCtx := H( "pulsar-att-v1" ||
// left_encode(8) || sessionID(16) ||
// left_encode(4) || attempt(4) ||
// left_encode(32) || groupPub.Bytes(32) ||
// left_encode(32) || nodeID(32) ||
// left_encode(32) || round1.Commit(32) )
//
// where H is SHAKE-256 truncated to 32 bytes. The host chain's
// attestation-policy module consumes (round1, attCtx, quote) and
// verifies the TEE's nonce-bound quote matches attCtx via the
// existing mpc/cc/attest dispatcher.
//
// This function deliberately does NOT touch the AlgebraicRound1Message
// wire form. The binding is OUTSIDE the threshold protocol; the
// consensus envelope carries (commit, quote) and the chain enforces
// presence/policy. No backward-compat hazard.
func AttestationContext(
setup *AlgebraicSetup,
msg *AlgebraicRound1Message,
) [32]byte
```
Filed as **PULSAR-V12-TEE-BIND** in BLOCKERS.md → Forward-looking
(v1.2). Implementation is mechanical (one helper, one fixed
domain-separated SHAKE-256), ships alongside a test that asserts the
output is stable across runs and changes if any input field changes.
## 3. GPU acceleration
**Bottom line.** Pulsar's GPU surface is **partially wired**:
`ref/go/pkg/pulsar/gpu/gpu.go` is the canonical opt-in that registers
the pure-Go reference's per-ring SubRing against the
`luxfi/lattice/v7/gpu` dispatcher. The dispatcher today does not engage
on pulsar's production path (32-bit Montgomery [256]uint32 polys do
not flow through `ring.SubRing` []uint64). What IS shipped across the
five backends in `lux-private/gpu-kernels/ops/lattice/ntt_mldsa` (q =
8380417, R = 2^32, byte-equal to PQClean `MLDSA65_CLEAN_ntt`) is **the
exact NTT pulsar needs**, but it is not yet routed through Pulsar's
sign path.
`TestPulsar_GPU_ByteEqual` (`threshold_v03_gpu_byte_eq_test.go`) is
the load-bearing regression guard: today it passes vacuously (both
legs run pure-Go), but it will catch any byte divergence the moment
the GPU NTT is wired in.
### 3.1 What ships today
```
luxfi/lattice/v7/gpu — dispatcher (cgo plumbing)
luxfi/pulsar/ref/go/pkg/pulsar/gpu — opt-in glue (UseAccelerator)
lux-private/gpu-kernels/ops/lattice/ntt_mldsa — kernels (5 backends)
luxfi/accel.MLDSAVerifyBatch — ABI stub returning ErrNotSupported
```
The pulsar/gpu surface tracks corona/gpu exactly so multi-primitive
runtimes share one accelerator entry. `UseAccelerator` sets the
SubRing-dispatch threshold to 1024 (above pulsar's N=256), keeping
single-poly NTT on CPU; the dispatcher is armed for any future
batched-NTT lift. Idempotent. Pure-Go-safe (build-tag-free; on a
no-cgo build `lattice/v7/gpu.Available()` returns false and the
canonical pure-Go path runs).
### 3.2 lux-private/gpu-kernels coverage
`ops/lattice/ntt_mldsa/op.yaml` declares:
```
id: lattice.ntt_mldsa
standard: NIST FIPS 204 (ML-DSA-65) forward NTT
api: lux_lattice_ntt_mldsa_batch
support: cpu yes / metal yes / cuda yes / hip yes / vulkan yes / wgsl yes
notes: |
In-place forward NTT over Z_q[X]/(X^256 + 1) with q = 8380417.
Cooley-Tukey butterflies in Montgomery domain. 8 levels.
Byte-equal to PQCLEAN_MLDSA65_CLEAN_ntt at the FIPS 204 reference.
Same zeta table shared across all kernel languages.
```
Inverse NTT companion lives at `ops/lattice/intt_mldsa/`. Both kernels
exist for every backend (CUDA / HIP / Metal / Vulkan / WGSL / CPU).
These are exactly the per-poly primitives pulsar's
`mldsa_lattice.go:330-365` implement, with byte-identical Montgomery
constants (q = 8380417, qinv = 58728449, R = 2^32).
### 3.3 Where GPU dispatch wins on pulsar
Per-signer Round-2 (`threshold_v03.go:678-913`) computes per attempt:
```
yHat : L ntt calls (L = 5 for ML-DSA-65)
w : K matrix-vector dotHat calls (each K=6 dotHat involves no NTT
— A is in NTT domain already)
wCoeff: K invNTT calls (K = 6)
s1Hat : L ntt calls (per-party share NTT)
cs2 : K ntt + K invNTT calls
ct0 : K ntt + K invNTT calls
```
Total per attempt per party: `L + 3K = 5 + 18 = 23` forward NTTs and
`K + 2K = 18` inverse NTTs = **41 NTT calls per party per attempt**.
Each NTT is on a single 256-coefficient poly. For a quorum of t = 4
parties with the FIPS 204 expected ~5 attempts before acceptance, the
ceremony runs `41 × 4 × 5 = 820` NTT calls.
Single-poly GPU dispatch at N = 256 is slower than pure-Go on every
host (M1 Max measured, NVIDIA Blackwell measured: cf. corona benches);
the GPU win lives in batched dispatch. The natural batch is **the L
forward NTTs on yHat + the L NTTs on s1Hat + the K NTTs on cs2 + the K
NTTs on ct0 = 5 + 5 + 6 + 6 = 22 forward NTTs**, all on independent
polys, all in the same Round-2 frame. That fits the
`lux_lattice_ntt_mldsa_batch` ABI exactly.
### 3.4 Forward-looking — PULSAR-V12-GPU-NTT-WIRE (v1.2)
Wire pulsar's Round-2 NTT into the gpu-kernels batched path via the
luxfi/accel substrate. Three discrete pieces (none ABI-breaking):
**Piece A: accel substrate.** Expose `lux_lattice_ntt_mldsa_batch` in
`luxfi/accel/internal/capi/capi.go` as a real call (the current
`MLDSAVerifyBatch` stub at `capi.go:512` returns `ErrNotSupported`; the
NTT batch is a sibling). The C ABI ships in `lux-accel`; the Go side
is a one-function wrapper.
**Piece B: pulsar batched NTT entry.** Add a package-private
`batchNTT(polys []poly)` to `mldsa_lattice.go` that:
- if `pulsar/gpu.Enabled()` && `accel.Available()` && `len(polys) >=
batchThreshold`, packs the polys into the accel ABI buffer, calls
`lux_lattice_ntt_mldsa_batch`, and unpacks.
- otherwise loops over `polys` calling the existing pure-Go `ntt()`.
**Piece C: Round-2 batched call site.** In `threshold_v03.go:680-913`,
replace the per-poly NTT loops with a single `batchNTT` call per
logical batch (yHat, s1Hat, cs2-input, ct0-input). The output stays
byte-equal because the underlying transform is the same. Verified by
the existing `TestPulsar_GPU_ByteEqual` regression guard.
**Byte-equality contract.** The accel substrate's NTT is byte-equal to
PQClean `MLDSA65_CLEAN_ntt` at the FIPS 204 reference (cited in
`ops/lattice/ntt_mldsa/op.yaml` notes). Pulsar's `mldsa_lattice.go:330`
NTT is byte-equal to circl's `internal/dilithium` NTT
(`mldsa_lattice.go:8-13` calls out the byte-equality contract). PQClean
and circl agree on the FIPS 204 reference NTT. The accel-engaged
leg and the pure-Go leg therefore emit byte-identical signatures.
**Build / packaging.** No new cgo in `ref/go/pkg/pulsar` itself —
ref stays pure-Go for NIST reviewers. The cgo plumbing lives behind
`pulsar/gpu` (no build-tag) and `luxfi/accel` (cgo build-tagged). The
NIST submission tarball cut by `scripts/cut-submission.sh` excludes
both subpackages by default; reviewers building `go build
./ref/go/pkg/pulsar` see only the circl + golang.org/x/crypto + stdlib
transitive graph.
Filed as **PULSAR-V12-GPU-NTT-WIRE** in BLOCKERS.md → Forward-looking
(v1.2).
### 3.5 Engine-layer batch verify
`/Users/z/work/lux/consensus/engine/gpu_batch_pipeline.go:532-600`
ships GPU batch verify for ECDSA / Ed25519 / BLS. MLDSA is **not in
the engine batch pipeline**; the engine has the MLDSA single-shot
path at `gpu_batch_pipeline.go:919-932` (`verifyMLDSA`) but no batch
variant. `luxfi/accel.MLDSAVerifyBatch` is the ABI substrate that
needs to ship before the engine pipeline can call into it
(`accel/internal/capi/capi.go:503-520`: returns `ErrNotSupported` per
current API status doc).
Pulsar already has the CPU parallel batch entry
(`ref/go/pkg/pulsar/verify_batch.go`); it explicitly defers GPU
acceleration to the engine layer where the accel session is owned and
the native libs are installed:
> "The GPU fast-path (accel.LatticeOps.DilithiumVerifyBatch) lives at
> the engine layer ... Engine consumers should prefer the accel path
> for batches >= luxfi/accel.BLSBatchVerifyThreshold; everything below
> that threshold goes through this CPU implementation."
This is the correct decomplecting boundary: pulsar owns the algorithm
and the byte-shape contract; the engine pipeline owns the accelerator
session and the batch heuristics; the accel substrate owns the C ABI.
The forward-looking item is the SUBSTRATE
(`lux_mldsa_verify_batch`), not pulsar code.
## 4. Cross-validation against pulsar-mptc legacy
**Bottom line.** Clean. No orphan imports.
`grep -r 'pulsar-mptc\|pulsarmptc\|mptc' --include='*.go' --include='*.mod' --include='*.sum'`
returns:
- `./ref/go/pkg/pulsar/doc.go`: "targeting NIST MPTC Class N1" — refers
to the **NIST competition category**, not the legacy module.
- `./ref/go/pkg/pulsar/wire.go`, `./ref/go/pkg/pulsar/verify.go`: refs
to `spec/nist-mptc-category.tex` — also the competition category.
- `./CHANGELOG.md:157, 201`, `./BLOCKERS.md:14`: HISTORY narrative of
the v1.0.7 consolidation; intentional.
- `./estimator/README.md:19`, `./ct/dudect/README.md:34`,
`./spec/blockers.tex`, `./spec/pulsar.tex`, `./spec/system-model.tex`,
`./spec/parameters.tex`: all references to "NIST MPTC" the
competition.
No `import "github.com/luxfi/pulsar-mptc"`. No `pulsar-mptc` in
`go.mod` / `go.sum`. The v1.0.7 consolidation
(commit `7be057b`, BLOCKERS.md line 14) is complete and clean.
## 5. Build sanity
```
$ go build ./... — clean
$ go vet ./... — clean
$ go test ./ref/go/pkg/pulsar -run 'TestPulsar_Wire|TestOrchestrateV03|TestPrecompile|TestAlgebraic_FullCycle' -count=1
ok github.com/luxfi/pulsar/ref/go/pkg/pulsar 4.275s
$ go test ./ref/go/pkg/pulsar -run TestPulsar_GPU_ByteEqual -count=1
ok github.com/luxfi/pulsar/ref/go/pkg/pulsar 6.026s
```
Jasmin corpus: 23/23 compile (per commit `04cdc24`). EasyCrypt theory
`V04_Sign_Ctx.ec` ships in `proofs/easycrypt/`.
## 6. Audit verdict
`BLOCKERS.md` stays at "submission-ready. No open items." for the
existing claims.
Two forward-looking items file under v1.2. Both are **extensions** of
the current design (one decomplected hook each), not flaws in existing
claims:
- **PULSAR-V12-TEE-BIND** — in-pulsar canonical TBS-bytes helper that
the host chain's attestation-policy module can bind to. No wire
change. No ABI change. Implementation is mechanical.
- **PULSAR-V12-GPU-NTT-WIRE** — route pulsar's Round-2 NTT batches
through `lux_lattice_ntt_mldsa_batch` once the accel C ABI ships.
Byte-equal contract enforced by existing `TestPulsar_GPU_ByteEqual`.
The two are independent of each other and of any existing closed
finding. They can ship in either order or in parallel; both target
v1.2.
+84
View File
@@ -2,6 +2,11 @@
**Status: submission-ready. No open items.**
Forward-looking v1.2 extensions are tracked under
`## Forward-looking (v1.2)` below; they are EXTENSIONS surfaced by the
2026-06 fresh four-dimension audit (`AUDIT-2026-06.md`), not flaws in
the v1.1.1 claims.
This file is the closed-finding registry. New findings open under
`## Open`; on fix they move to `## Closed` with commit + tag. IDs
are cited from source-code comments and from
@@ -19,6 +24,85 @@ and the v1.0.7 sign-off.
None.
## Forward-looking (v1.2)
These are EXTENSIONS surfaced by the 2026-06 fresh four-dimension audit
(`AUDIT-2026-06.md`). Both are independent of every closed finding;
both ship as decomplected hooks that do NOT touch existing wire form,
ABI, or any FIPS 204 byte-equality contract. Neither is a flaw in the
current claims — they are deferred-by-design forward-pointing items
filed for the next minor.
### PULSAR-V12-TEE-BIND — TEE-quote binding helper for permissionless quorum gating
**Status**: SCAFFOLDED at audit time (commit pending). One pure
function `AttestationContext(setup, msg)` in
`ref/go/pkg/pulsar/attest.go` returning the canonical 32-byte TBS
digest a validator's TEE quote should bind to when the host chain
gates permissionless-quorum admission on attestation presence. Five
unit tests pin stability + nil-input safety + full input-coverage
sensitivity + customisation domain-separation
(`ref/go/pkg/pulsar/attest_test.go`).
**Why v1.2 not v1.1.x**: the audit treats the existing v1.1.1 claims
(byte-equality, public-BFT safety, FIPS 204 §5.4 ctx-bound) as the
single canonical home. The TEE-binding helper extends the surface
without altering it; bundling under v1.2 keeps the v1.1.x cut focused
on PULSAR-V04-CTX closure.
**What this is NOT**: it is not a TEE attestation verifier. The
chain-validating attestation verifier lives at
`github.com/luxfi/mpc/cc/attest`. Pulsar's role is to declare the
canonical TBS bytes; the verifier role belongs to the consensus
envelope.
**Custody-mode TEE path**: unchanged.
`github.com/luxfi/threshold/protocols/mldsa-tee` already ships the
SEV-SNP / TDX / NRAS-attested custody surface that materialises the
master seed inside an attested TEE. The permissionless v0.3 path does
NOT materialise sk at any party by construction, so attestation there
is a policy gate, not a safety primitive.
### PULSAR-V12-GPU-NTT-WIRE — route Round-2 NTTs through gpu-kernels batched dispatch
**Status**: SPEC only. The substrate
(`lux-private/gpu-kernels/ops/lattice/ntt_mldsa`, q = 8380417, R = 2^32,
byte-equal to PQClean `MLDSA65_CLEAN_ntt`) ships across all five
backends (CUDA, HIP, Metal, Vulkan, WGSL). The C ABI
`lux_lattice_ntt_mldsa_batch` is the natural batched-NTT entry. The
existing `pulsar/gpu.UseAccelerator` keeps the SubRing-dispatch
threshold above pulsar's N=256 production ring so single-poly GPU
dispatch is intentionally off (it loses to pure-Go at N=256 on every
host). The batched dispatch (22 forward NTTs per Round-2-party-attempt
on independent polys) is where the GPU win lives.
**Three discrete pieces**:
- **Substrate**: lift `lux_mldsa_verify_batch` and
`lux_lattice_ntt_mldsa_batch` from `ErrNotSupported` stub
(`luxfi/accel/internal/capi/capi.go:503-531`) to a real call.
- **Pulsar batched NTT entry**: package-private `batchNTT(polys []poly)`
in `mldsa_lattice.go` that dispatches via accel iff
`pulsar/gpu.Enabled()` and `accel.Available()` and
`len(polys) >= batchThreshold`; otherwise falls back to the existing
per-poly pure-Go `ntt()`.
- **Round-2 batched call sites**: replace per-poly loops in
`threshold_v03.go:680-913` with `batchNTT` calls per logical batch
(yHat, s1Hat, cs2-input, ct0-input).
**Byte-equality contract**: enforced by the existing
`TestPulsar_GPU_ByteEqual` regression guard
(`threshold_v03_gpu_byte_eq_test.go`). Today it passes vacuously (both
legs run pure-Go); after PULSAR-V12-GPU-NTT-WIRE lands it pins the
accel-engaged leg byte-equal to the pure-Go leg.
**Engine-layer batch verify**: pulsar's CPU
`VerifyBatch` already defers GPU acceleration to the engine layer per
`verify_batch.go:11-23`. The substrate (`MLDSAVerifyBatch`) is the
forward-looking item, not pulsar code; engine wiring at
`/Users/z/work/lux/consensus/engine/gpu_batch_pipeline.go` adds the
MLDSA batch path once the substrate ships.
## Closed
### PULSAR-V04-CTX — v0.4 ctx-bound threshold sign (FIPS 204 §5.4)
+102
View File
@@ -0,0 +1,102 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// attest.go — canonical TBS bytes for binding a validator's TEE
// quote to its Round-1 commit in a public-permissionless Pulsar
// quorum (PULSAR-V12-TEE-BIND from AUDIT-2026-06.md).
//
// Pulsar's public-permissionless v0.3 path requires no TEE for
// safety: AlgebraicAggregate produces a byte-identical FIPS 204
// ML-DSA-65 signature without materialising the master sk at any
// quorum party. The CUSTODY-mode TEE path lives outside this
// package at github.com/luxfi/threshold/protocols/mldsa-tee.
//
// Some host chains nonetheless want to GATE participation in the
// permissionless quorum on attestation presence — i.e. only validators
// running an attested binary in an attested TEE may join the quorum,
// even though the protocol itself is safe without it. The host
// chain's attestation-policy module needs ONE canonical TBS recipe
// that the validator's TEE quote binds to. This file provides that
// recipe.
//
// DECOMPLECTING NOTE
//
// This file deliberately does NOT touch AlgebraicRound1Message,
// AlgebraicRound2Message, or any other wire shape. The attestation
// binding lives ALONGSIDE the threshold protocol, not INSIDE it. The
// host chain's consensus envelope carries (commit, quote) as a single
// validator broadcast; the chain's attestation-policy verifier
// consumes (commit, AttestationContext(commit), quote) to decide
// whether to admit the commit into the quorum.
//
// Wire stability invariant: bytes-on-wire for AlgebraicRound1Message,
// AlgebraicRound2Message, Signature, GroupKey, and AbortEvidence are
// UNCHANGED by this file's existence.
// AttestationContextTag is the customisation string for the SP 800-185
// TupleHash binding the validator's TEE quote to its Round-1 commit.
// The tag is unique-per-pulsar to keep the attestation TBS bytes
// distinct from any other Pulsar transcript hash (Round-1 commit
// transcript, MAC keys, signature challenge), preventing cross-context
// collisions.
const AttestationContextTag = "pulsar-att-v1"
// AttestationContext returns the canonical 32-byte TBS digest a
// validator's TEE quote should bind to when participating in a
// public-permissionless Pulsar quorum that the host chain gates by
// attestation presence.
//
// The digest is computed via the SP 800-185 TupleHash primitive
// shared with the rest of the pulsar transcript layer (see
// transcript.go: transcriptHash32). The tuple is the ordered
// concatenation of:
//
// tag = "pulsar-att-v1" (customisation string)
// parts[0] = sessionID (16 bytes)
// parts[1] = attempt (big-endian 4 bytes)
// parts[2] = groupPubBytes (FIPS 204 pk-encoding bytes)
// parts[3] = nodeID (32 bytes)
// parts[4] = commit (32 bytes)
//
// Each part is framed by left_encode(bit_len) per SP 800-185 §2.3.1.
// Any field change shifts the digest unambiguously.
//
// The 32-byte output is the value the validator's TEE quote nonce
// (or report-data field, per vendor convention) MUST be set to. The
// host chain's attestation-policy verifier then:
//
// 1. Calls AttestationContext(setup, msg) to reconstruct the bound
// value from the (setup, msg) pair it received with the quote.
// 2. Calls the vendor's attestation verifier (mpc/cc/attest.Dispatch)
// to verify the quote against the policy-pinned root, asserting
// quote.Nonce == AttestationContext.
// 3. Admits the commit into the quorum iff both pass.
//
// Wire impact: NONE. This is a pure function over already-
// transmitted Round-1 inputs. The attestation envelope is the host
// chain's consensus-envelope concern, not pulsar's.
//
// Panic safety: returns the zero [32]byte for nil setup or nil msg
// rather than panicking, matching the rest of the package's no-panic
// boundary (per pulsar.tex §6.1 DD-007).
func AttestationContext(setup *AlgebraicSetup, msg *AlgebraicRound1Message) [32]byte {
if setup == nil || msg == nil || setup.Pub == nil {
var zero [32]byte
return zero
}
var attemptBytes [4]byte
attemptBytes[0] = byte(msg.Attempt >> 24)
attemptBytes[1] = byte(msg.Attempt >> 16)
attemptBytes[2] = byte(msg.Attempt >> 8)
attemptBytes[3] = byte(msg.Attempt)
return transcriptHash32(
AttestationContextTag,
msg.SessionID[:],
attemptBytes[:],
setup.Pub.Bytes,
msg.NodeID[:],
msg.Commit[:],
)
}
+169
View File
@@ -0,0 +1,169 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
// attest_test.go — TBS-digest contract for AttestationContext
// (PULSAR-V12-TEE-BIND).
//
// Two invariants pinned:
//
// 1. STABLE — the digest is deterministic across runs for the same
// (setup, msg) tuple.
// 2. SENSITIVE — changing ANY input field (sessionID, attempt,
// groupPub, nodeID, commit) changes the digest. This is the
// load-bearing property: an attacker that can flip ONE field
// while keeping the quote bound to the OLD digest must lose,
// so the digest function must be coverage-complete.
import (
"bytes"
"testing"
)
func attestFixtureSetup() *AlgebraicSetup {
pub := &PublicKey{Mode: ModeP65, Bytes: make([]byte, 1952)}
for i := range pub.Bytes {
pub.Bytes[i] = byte(i)
}
return &AlgebraicSetup{Mode: ModeP65, Pub: pub}
}
func attestFixtureMsg() *AlgebraicRound1Message {
m := &AlgebraicRound1Message{
SessionID: [16]byte{'s', 'i', 'd', '-', 'a', 't', 't', '-', '0', '0', '0', '1'},
Attempt: 0x01020304,
}
for i := range m.NodeID {
m.NodeID[i] = byte(0x10 + i)
}
for i := range m.Commit {
m.Commit[i] = byte(0x80 + i)
}
return m
}
// TestAttestationContext_Stable — same inputs => same digest.
func TestAttestationContext_Stable(t *testing.T) {
setup := attestFixtureSetup()
msg := attestFixtureMsg()
a := AttestationContext(setup, msg)
b := AttestationContext(setup, msg)
if a != b {
t.Fatalf("AttestationContext non-deterministic:\n a = %x\n b = %x", a, b)
}
var zero [32]byte
if a == zero {
t.Fatalf("AttestationContext returned zero on valid inputs")
}
}
// TestAttestationContext_NilInputs — defensive returns the zero digest.
func TestAttestationContext_NilInputs(t *testing.T) {
var zero [32]byte
if got := AttestationContext(nil, attestFixtureMsg()); got != zero {
t.Fatalf("nil setup did not return zero digest: %x", got)
}
if got := AttestationContext(attestFixtureSetup(), nil); got != zero {
t.Fatalf("nil msg did not return zero digest: %x", got)
}
setupNilPub := &AlgebraicSetup{Mode: ModeP65}
if got := AttestationContext(setupNilPub, attestFixtureMsg()); got != zero {
t.Fatalf("nil setup.Pub did not return zero digest: %x", got)
}
}
// TestAttestationContext_Sensitive — every input field, when mutated,
// MUST produce a different digest. This is the coverage-complete
// property; an attacker that wants to keep a TEE quote (bound to the
// old digest) valid while replaying with ANY altered field must lose.
func TestAttestationContext_Sensitive(t *testing.T) {
base := AttestationContext(attestFixtureSetup(), attestFixtureMsg())
t.Run("sessionID", func(t *testing.T) {
msg := attestFixtureMsg()
msg.SessionID[0] ^= 1
mut := AttestationContext(attestFixtureSetup(), msg)
if mut == base {
t.Fatalf("sessionID mutation did not change digest")
}
})
t.Run("attempt", func(t *testing.T) {
msg := attestFixtureMsg()
msg.Attempt ^= 0x01
mut := AttestationContext(attestFixtureSetup(), msg)
if mut == base {
t.Fatalf("attempt mutation did not change digest")
}
})
t.Run("groupPub", func(t *testing.T) {
setup := attestFixtureSetup()
setup.Pub.Bytes[0] ^= 1
mut := AttestationContext(setup, attestFixtureMsg())
if mut == base {
t.Fatalf("groupPub mutation did not change digest")
}
})
t.Run("nodeID", func(t *testing.T) {
msg := attestFixtureMsg()
msg.NodeID[0] ^= 1
mut := AttestationContext(attestFixtureSetup(), msg)
if mut == base {
t.Fatalf("nodeID mutation did not change digest")
}
})
t.Run("commit", func(t *testing.T) {
msg := attestFixtureMsg()
msg.Commit[0] ^= 1
mut := AttestationContext(attestFixtureSetup(), msg)
if mut == base {
t.Fatalf("commit mutation did not change digest")
}
})
}
// TestAttestationContext_DomainSeparation — the customisation tag
// must be reflected in the output. A digest computed with a different
// customisation string (over the same inputs) must differ. Asserted
// via cshake256 directly — we re-derive what the function should
// produce and compare.
func TestAttestationContext_DomainSeparation(t *testing.T) {
setup := attestFixtureSetup()
msg := attestFixtureMsg()
got := AttestationContext(setup, msg)
var attemptBytes [4]byte
attemptBytes[0] = byte(msg.Attempt >> 24)
attemptBytes[1] = byte(msg.Attempt >> 16)
attemptBytes[2] = byte(msg.Attempt >> 8)
attemptBytes[3] = byte(msg.Attempt)
want := transcriptHash32(
AttestationContextTag,
msg.SessionID[:],
attemptBytes[:],
setup.Pub.Bytes,
msg.NodeID[:],
msg.Commit[:],
)
if got != want {
t.Fatalf("AttestationContext disagrees with transcriptHash32:\n got = %x\n want = %x", got, want)
}
// Same parts under a different customisation must differ.
other := transcriptHash32(
"pulsar-att-different",
msg.SessionID[:],
attemptBytes[:],
setup.Pub.Bytes,
msg.NodeID[:],
msg.Commit[:],
)
if bytes.Equal(other[:], got[:]) {
t.Fatalf("customisation tag did not domain-separate the digest")
}
}