22 KiB
Pulsar 2026-06 fresh four-dimension audit
Re-audit of
github.com/luxfi/pulsarat04cdc24against the public- permissionless-chain deployment dimension, TEE integration, GPU integration, and orphan-reference hygiene. Issued even thoughBLOCKERS.mdreads "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:
- Public-permissionless-chain safety.
- TEE integration (NVIDIA Confidential Compute / AMD SEV-SNP / Apple Secure Enclave).
- GPU acceleration (NTT batching against
lux-private/gpu-kernels/ops/lattice/{ntt,intt}_mldsa). - 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_iis per-party fresh randomness drawn from the FIPS 204(-γ_1', γ_1']uniform distribution (threshold_v03.go:660-675,polyDeriveUniformBounded),cis the public challenge derived from(μ, w1)(single SHAKE-256 pass,threshold_v03.go:861-869), andλ_iis 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) andAlgebraicAggregateCtx(threshold_v03.go:1143) both call it.NewAlgebraicThresholdSignerCtx,AlgebraicAggregateCtx, andOrchestrateV03SignCtxvalidatelen(ctx) <= 255at the API boundary (orchestrate.go:122).OrchestrateV03Sign(msg)is a thin wrapper forOrchestrateV03SignCtx(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
Verifyconstant-time over valid-signature class:ct/dudect/dudect_verify.cexercises the dudect harness on the valid-sig population (which is the operationally meaningful CT class; seect/dudect/README.mdlines 14-25 for the rationale).Combine/AlgebraicAggregateCtxconstant-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 inct/dudect/README.mdlines 31-37.- Jasmin
#[ct]CT type-checked across the whole jasmin/ corpus per commit04cdc24("All#[ct]contracts and#declassifypragmas 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):
- Verifiable hardware attestation chain-validated against the pinned
vendor root (AMD SEV-SNP Milan, NVIDIA NRAS, Intel TDX) via
mpc/cc/attest.Dispatch. - KMS
ReleaseGatethat pins the worker's RIM digest + hardware fingerprint and binds a single-use challenge nonce per-request. - Out-of-band human / programmatic approval signature via
mpc/pkg/approval.ApprovalProvider. - HSM-resident wrap-key store via
mpc/pkg/hsm.Providerso 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):
// 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, callslux_lattice_ntt_mldsa_batch, and unpacks. - otherwise loops over
polyscalling the existing pure-Gontt().
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 tospec/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 was deleted (forward-only): it proved byte-equality for
the v0.4 ctx-bound AlgebraicAggregateCtx — the ctx sibling of the removed
v0.3 reconstruct-then-sign leaking path — never compiled (missing
FIPS204_Axioms theory, no .eco), and is no longer in proofs/easycrypt/.
The remaining 12 top-level theories machine-check clean under easycrypt compile.
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_batchonce the accel C ABI ships. Byte-equal contract enforced by existingTestPulsar_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.