Files
pulsar/AUDIT-2026-06.md
T

22 KiB
Raw Blame History

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):

// 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 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_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.