Several luxfi versions carry TWO different contents across this workspace,
because published tags were moved instead of a new patch being cut:
age@v1.5.0, pq@v1.0.3, threshold@v1.9.4, zap@v0.6.0, zap@v0.8.1.
Adjudicated before editing, since "checksum mismatch / SECURITY ERROR" is also
what a real supply-chain attack looks like. It is not one here: sum.golang.org
holds the OLD hash while proxy.golang.org and a direct fetch BOTH serve the same
NEW bytes. Two independent transports agreeing means nothing is rewriting
content in flight — the tag moved at source. The sumdb entry is a fossil:
GOPRIVATE covers github.com/luxfi/* with GOSUMDB=off, so our own modules never
consult the checksum DB and a moved tag splits consumers silently.
The old bytes are served by nothing now, so a stale pin can never build.
Corrected to the only content that exists, in BOTH line forms (h1: and
/go.mod h1:) — Go reports these one at a time, so a partial fix just relocates
the error.
Deliberately no `go mod tidy`: this changes no selected version, only the
recorded hash of versions already chosen.
Verified: `go list -m all` resolves with no checksum error.
The durable fix is upstream: never move a published tag, cut x.y.z+1 instead.
Item7(a) library-side defense-in-depth, on SHIPPED pulsar v1.9.0 (the tag
cloud's consensus v1.35.30 pins). Non-breaking: CanonicalSignerSet's
2-arg signature is UNCHANGED so the existing consensus consumer compiles
and runs unmodified.
CanonicalSignerSet sized the signer bitmap at make([]byte, maxID/8+1)
with maxID taken straight from an attacker-controlled uint32 Partial.
PartyID and no upper bound — a single Partial naming PartyID near
math.MaxUint32 drives a ~512MiB allocation (memory-exhaustion DoS).
Added MaxCanonicalPartyID = 1<<20 (bitmap <= 128 KiB) and a typed
ErrPartyIDOutOfRange; CanonicalSignerSet now rejects any partial in
`valid` whose PartyID >= the cap BEFORE the sort or the allocation. The
whole slice is checked, not just the chosen subset, so a hostile PartyID
cannot hide behind the threshold cut. The cap sits far above any real
validator set (Lux runs hundreds to low thousands) so legitimate inputs
are never rejected.
This is orthogonal to — not a replacement for — the consumer's own
exact-ValidatorSetSize bound (consensus PulsarRoundSigner.Finalize, its
companion commit): the library needs no external context and stays
signature-stable, so this hardening protects any future caller lacking
that upstream guard.
New test: TestCanonicalSignerSet_RejectsOversizedPartyID (MaxUint32,
excluded-from-chosen, at-cap boundary, and the just-below-cap happy path
with a bounded bitmap). No go.mod/go.sum change.
Moves the Go reference implementation to the idiomatic repo-root pkg/ (library)
+ cmd/ (genkat tool) layout, dropping the ref/go/ nesting. Imports become
github.com/luxfi/pulsar/pkg/pulsar. The pulsar DAEMON (formerly the unhomed
standalone github.com/luxfi/pulsard module) now lives canonically at
github.com/luxfi/pulsar/pkg/pulsard. No replace directives.
BREAKING: consumers must update github.com/luxfi/pulsar/ref/go/pkg/... ->
github.com/luxfi/pulsar/pkg/... and bump to v1.9.0.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
The legacy_trusted_dealer quarantine is dead weight now that dealerless Mithril
RSS keygen + no-reconstruct signing are the production path. DELETED 9 files
(large_{types,reshare,dkg,threshold}.go, largeshamir.go, their tests,
bootstrap_dealer_test.go) — the reconstruct-at-combiner LargeCombine (the H-1
footgun) and the DealAlgShares trusted dealer are GONE, not quarantined.
Converts a POLICY guarantee (the tag is excluded from the build) into a
STRUCTURAL one (the path does not exist). The 3 surviving test consumers of the
dealer were migrated to a test-only buildAlgShareFixture (byte-faithful: the RED
nonce-reuse attack still recovers s1, so GATE A keeps its teeth). The CI invariant
was repurposed to TestCI_ReconstructAndDealerRipIsComplete — RED-fault-injected:
it FAILS if func LargeCombine(/func DealAlgShares(/the legacy tag reappear, and
FAILS if MithrilRSSKeygen/AggregateBCC disappear.
The legacy code is preserved (archived, not used) in luxfi/dealer.
RED-verified MERGE: 5/5 vectors hold; GATE-1/2/A/B/C + RSS gold proof + hyperball
+ capstone green; default + legacy-tag builds clean (tag now selects nothing);
106 .go files scanned, zero reconstruct/dealer symbols. obstruction guard
(naive_additive_seta_obstruction.go) kept + reframed (Mithril RSS is the escape,
not a dealer). Docs (VERSIONS/PULSAR/CHANGELOG) cleaned of the legacy track.
VERSIONS.md + PULSAR.md described code that no longer exists. The keygen track
is now simply: dealerless Mithril RSS (production) + no-reconstruct signing.
- VERSIONS.md: delete the 'Trusted-dealer bootstrap (DealAlgShares)' and
'Legacy GF(q) seed-share committee (large_*.go, quarantined behind
legacy_trusted_dealer)' table rows; drop LargeCombine from the banned-primitive
list; update the CI-invariant bullet to TestCI_ReconstructAndDealerRipIsComplete;
rewrite the canonical one-line scope (dealerless KEYGEN DONE via Mithril RSS,
leak-free NonceMPC the remaining gen-side residual). Honest scope preserved
(standard-verifier PROVEN vs distribution-equivalence residual R1).
- PULSAR.md: un-name the dead legacy_trusted_dealer tag in the v0.5 history row.
CHANGELOG.md already states the dealerless-RSS end state (no edit needed).
Delete the quarantined reconstruct-at-combiner stack and the trusted-dealer
keygen — dealerless Mithril RSS keygen + no-reconstruct signing are the
production path, so the quarantine is dead weight (it lives in git history).
Deleted (9 files):
production: large_types.go large_reshare.go large_dkg.go large_threshold.go
largeshamir.go (the LargeCombine reconstruct-at-sign stack)
tests: large_e2e_test.go largeshamir_test.go precompile_e2e_test.go
dealer: bootstrap_dealer_test.go (DealAlgShares trusted-dealer keygen)
Also removed the untracked scratch zz_diag16_test.go.
Repurposed/updated the gates so they are clean, not dangling:
- ci_build_invariant_test.go: the two legacy tests (assert the tag gates files
/ dealer is test-only) are obsolete — there is no legacy to exclude. Replaced
with TestCI_ReconstructAndDealerRipIsComplete: scans EVERY .go file and fails
if func LargeCombine / func DealAlgShares / the legacy_trusted_dealer tag ever
reappear, AND asserts the live MithrilRSSKeygen + AggregateBCC remain (rip
didn't overreach). Stronger + permanent. This is the sole sanctioned holder of
the forbidden strings. Kept TestCI_NoAssemblyOrCgoFiles verbatim.
- gate2_reachability_test.go: drop the deleted LargeCombine/DealAlgShares from
the banned-primitive set (the ci-invariant owns 'they don't exist'); the gate
keeps proving the LIVE reconstruct primitives are unreachable from the sign
path. Cleaned stale comments.
- no_reconstruct_committee_test.go: drop the func LargeCombine declaration scan
and the deleted-file check from GATE-2 structural (now the ci-invariant's job);
GATE-1, GATE-2 behavioural, and the KeyFromSeed allowlist scan stay green.
Default build, legacy-tag build (now a no-op), and full suite all green.
Comment-only fixups so no surviving production file names the symbols being
ripped:
- distributed_bcc.go: the keygen fence is no longer a trusted dealer — point
to Mithril RSS (mithril_rss.go) + the hyperball sibling; drop the redundant
'DealAlgShares RIPPED OUT' narration block.
- types.go: drop the dangling 'LargeCombine (large_threshold.go)' reference.
- talus_dkg_vss.go: the stock-verifiable key is dealerless Mithril RSS, not a
trusted dealer.
- mithril_rss.go: 'instead of a trusted dealer' (un-name DealAlgShares).
- naive_additive_seta_obstruction.go: fix the stale distributed_bcc_dkg.go
filename; Mithril RSS is now IMPLEMENTED (the realized dealerless escape),
not 'fall back to a dealer'; reframe the error + entry-point docs. The
COMPUTED S_eta obstruction math is unchanged.
The obstruction guard's tests still pin the arithmetic; no symbol removed.
The DistributedBCCSigner / AggregateBCC tests bootstrapped their Shamir
s1-share committees via DealAlgShares (the trusted-dealer keygen). Replace
that with buildAlgShareFixture, an honest TEST FIXTURE: it samples one
ordinary S_eta ML-DSA key in-process and Shamir-shares s1 over GF(q) purely
to seed the signing proofs.
This is byte-faithful to the old fixture (same key, same shares) so GATE-1
(circl verify), GATE-2 (no-reconstruct) and the RED nonce-reuse attack stay
green. The production dealerless keygen is Mithril RSS (mithril_rss.go),
untouched; the production SIGN path never forms s1 — a property of signing,
independent of how the fixture dealt the shares. Prepares DealAlgShares for
deletion.
Decomplected, one concern: the per-op poly<->mlwe.Poly marshal the Phase-4
DRY de-dup put into the ring hot loop (toMLWE/fromMLWE). Isolates pure
conversion vs ring math: toMLWE, fromMLWE, RoundTrip (pure tax),
NTT/INTT/MulNTT via mlwe.
VERDICT: de-dup did NOT regress perf where it matters.
- Allocations: ZERO. ntt/invNTT/mulHat all report 0 allocs/op. toMLWE in
ISOLATION shows 1 alloc/2048B only because the sink forces the result to
escape; -gcflags=-m proves toMLWE is inlined at every ring-op call site
(mldsa_lattice.go ntt:230, invNTT:244, mulHat:174x2) with
'make([]uint64,256) does not escape' -> stack-allocated. Only toMLWE's own
generic return path (line 96) escapes, never the hot loop.
- CPU: pure conversion RoundTrip 249ns is ~12% of an NTT (2043ns), ~25% of
MulNTT (1507ns) -- a modest constant factor, the price of one audited ring
core (luxfi/mlwe), NOT a GC/alloc regression.
BRAID FLAGGED (not fixed -- bench task must not refactor production crypto):
the conversion is braided into every ring op because luxfi/mlwe's Ring
speaks []uint64 while pulsar's wire/FindHint frame needs [256]uint32. A
zero-copy mlwe API over borrowed buffers could reclaim the constant factor;
out of scope here.
Decomplected, one concern: VerifyCtx on a genuine committee-produced
threshold signature (dealerless RSS keygen + threshold Sign in setup),
ML-DSA-65/87. The timed op is verification only.
Finding: committee output verifies at EXACTLY stock single-key cost --
P65 140 us vs single-key 136 us, P87 223 us vs 226 us, both 5 allocs/op
and identical byte footprint (33KB / 57.8KB). The verifier never learns
the signature was threshold-produced. This is the cheap baseline the
dealerless signing paths are measured against.
Decomplected, one concern: the DistributedBCCSigner (DealAlgShares Shamir
key) no-reconstruct path. Two isolated measurements, keygen in setup:
- BenchmarkBCCDistributedSign: the FULL ceremony per signature (fresh
signers + NonceMPC deal + Round1/Round2/Finalize + rejection restarts).
P65 10-17 ms, P87 14-24 ms across 5-of-3 / 5-of-4 / 7-of-5.
- BenchmarkBCCAggregate: the AggregateBCC combine path in isolation — one
ceremony in setup captures a known-good (aggregator, R1, partials); the
timed op is the aggregator's deterministic Finalize on those fixed inputs
(per-partial proof verify + flat z-sum + public-w' hint recovery +
assembly + self-verify). 1.79-3.66 ms; allocs scale exactly with quorum
(322/424/526 = +102/partial). ~5-7x cheaper than the full ceremony.
*testing.B fixture builder + ceremony driver mirror the proven
newBCCFixture/runBCCCeremony, reusing the bccFixture struct (additive).
Decomplected, one concern: SignHyperball (no-reconstruct 3-round threshold
signer) cost per (mode in {65,87}, family). Keygen is setup; the timed op
is the full sign. Reports derived K parallel slots, per-party subset load,
sig bytes, and the mean rejection-round count (rounds/op).
Real finding (Quasar finality deployment): the no-reconstruct path runs
K=126-256 parallel commitment slots -> 250-580 ms/op and 1.8-4.6M allocs/op,
a 120-290x tax over the reconstruct path (BenchmarkHyperballReconstructTax,
1.85-2.4 ms). n=16,t=14 EXHAUSTS the 64-round budget on the hyperball path
(reported as a skip) though the reconstruct path signs it in ~8 ms -- the
real no-reconstruct scaling boundary.
Per-round cost is not independently invocable (one-shot signer loops the 3
rounds x K slots internally); reported as derived rounds/op, flagged in the
file header as a public-surface observation (not refactored).
Decomplected benchmark, one concern: MithrilRSSKeygen cost across the
committee families the dealerless line uses, swept over ML-DSA-44/65/87.
Reports ground-truth rss.NumSubsets (C(N,N-T+1)) + tau*C*eta vs gamma2 so
the combinatorial cost is measurable: C=8 (n8t8) -> 28 (n8t7) -> 560
(n16t14, the v0.6.3 overflow-fix family). The rejected n16t12 (C=4368,
tau*C*eta=856128 >= gamma2) surfaces its real ValidateCommittee reason,
making the viability wall part of the curve.
allocs/op scales ~linearly with C (load-robust): 216 -> 581 -> 10328.
The keygen-track tables claimed 'KEYGEN stays trusted-dealer (Residual B)' and
'Dealerless byte-FIPS-204 KEY DKG | PROVEN UNREACHABLE' — both false since v0.6.0.
Corrected to the accurate scoped truth: dealerless committee keygen via Mithril
RSS (mithril_rss.go) is the production keygen, standard-verifier-compatible
(proven); only the NAIVE additive S_eta lift is unreachable (not a class
impossibility — RSS is the published escape). Added v0.6.0-v0.6.3 + HYPERBALL
signer to the version ledger. Distribution-equivalence stays a labeled residual.
Docs-only; matches PULSAR.md (e5b6d6e).
Updates the certificate-layer spec and the DKG proof-boundary spec/vectors
from their v0.5.0 grounding (docs/pulsar-final, 24 commits behind) to the real
v0.6.3 tree (3a6c2ea). DOCS ONLY — no code touched. Every implementation claim
re-grounded file:line in ref/go/pkg/pulsar/ at v0.6.3.
The load-bearing change: the keygen residual is CLOSED for Claim (A). v0.6.0+
ships DEALERLESS RSS keygen (mithril_rss.go MithrilRSSKeygen — Mithril short
Replicated Secret Sharing, ia.cr/2026/013; the dealer is dead at keygen too),
whose threshold signatures verify byte-for-byte under stock circl mldsa65.Verify
(mithril_rss_test.go:38 all 15 committees N<=6; mithril_rss_n8_test.go:16 T==N at
n=8,n=16). The v0.6.3 accumulateSubset per-subset mod-q reduction (mithril_rss.go
:122) fixes a uint32 overflow that wrapped the key once C(N,N-T+1)>=512,
unblocking large committees (n=16,t=14, C(16,3)=560; reconstructed ||s2||inf~205
<< gamma2). The Mithril 3-round HYPERBALL no-reconstruct signer
(mithril_rss_hyperball.go SignHyperball, gates 5/6) signs the RSS key with NO
party/coordinator ever forming the full s1/s2/y/w0/sk: each party emits only
z_j = y_j + c*s1_(j); the summed z is a standard FIPS-204 response; the secret
mask y is never serialised (commit 2c1458f). Verifies under stock circl
(mithril_rss_hyperball_test.go:41).
PULSAR.md: full v0.6.3 architecture (dealerless RSS keygen, hyperball
no-reconstruct signer, BCC/CSCP v0.4, malicious hardening v0.5, mlwe de-dup
v0.6.1/.2, overflow fix v0.6.3); the version lineage (v0.3 algebraic-broadcast
REMOVED -> ... -> v0.6.3 + hyperball); and the Avalanche-native sampled-cert
committee math — sample MANY small dealerless RSS committees, r-of-m binomial
tail, default Pulsar-HYBRID-PQ-v1 n=8,t=7,m=12,r=8 (one-committee p~2^-8.6,
P_fail~2^-59.8; NOT n=64/t=5), unbiasable stake-weighted VRF sortition seeded by
the prev finalized block, committeePlanHash bound into the Quasar subject, three
tiers FAST/HYBRID_PQ/PQ_ROOT.
DKG-PROOF-BOUNDARY.md + vectors: keygen residual restated (CLOSED via dealerless
RSS for Claim A; full FIPS-204 KeyGen-distribution-equivalence is the labelled
open-research residual). Keeps the 7 deliverables; adds RSS keygen + hyperball to
the transcript spec, DST table, share-commitment format, vectors (3 new positive,
5 new negative), and the proof checklist (18 rows). Line numbers refreshed v0.5.0
-> v0.6.3 throughout.
Claim discipline held: (A) standard-verifier-compatible is PROVEN (circl accepts;
tests cited); (B) full FIPS-204 KeyGen-distribution-equivalence + malicious-secure
CSCP/identifiable-abort are labelled residuals. Never claims FIPS/NIST-certified
threshold ML-DSA, fully-malicious-secure-proven, or global-1000-validator DKG.
MithrilRSSKeygen / ReconstructKeyMaterial summed all C(N,M) per-subset short
secrets with raw uint32 poly.add and reduced mod q only once at the end. Each
chi_eta coefficient sits in the [q-eta, q+eta] rep (~q~2^23), so the accumulator
overflows once C(N,M) >= floor(2^32/q) = 512 — wrapping the coefficient into a
large-secret (unsignable) wall key. This silently broke the owner fault-tolerant
default n=16,t=14 (C(16,3)=560): reconstructed ||s2||_inf came out ~q/2 instead
of <= C*eta = 2240, so no signature could verify. n=8,t=7 (C=28) and all prior
committees (C<=20) were below threshold, hence unnoticed.
Fix: accumulateSubset reduces mod q PER subset. modQ is an additive homomorphism
=> the short Sum_S secret is IDENTICAL to a single final reduction for small
committees (n<=8 bit-identical) while every intermediate stays below 2q << 2^32.
Byte-preserving for small committees: golden KAT vectors unchanged, RSS gold
proof + full suite green (77s). Single-file keygen-correctness fix.
Unites main's mlwe-dedup line with the trustless-signing line: general Algorithm-6
balanced partition (n=8,t=7 + n=16,t=14 sign), the per-add mod-q overflow fix (C>=512),
and the Mithril 3-round hyperball no-reconstruct threshold signing — no party/coordinator
forms the full ML-DSA key at sign time. Gate 5 closed + verified, stock-circl-verifiable.
Finishes the Pulsar->luxfi/mlwe consolidation begun in v0.6.1 (transcript,
Shamir). Phase 3 routes the FIPS-204 SHAKE samplers (ExpandA/ExpandS/
ExpandMask/SampleInBall) onto mlwe/sample/shake; Phase 4 routes the
NTT/INTT/MulNTT ring core (and deletes the duplicate 256-entry zeta
tables + montReduceLe2Q) onto mlwe/ring/mldsa. Both byte-identical by
construction: mlwe was lifted verbatim from this package.
Wire frame (bit-packers, a0+q Decompose/Power2Round) and FindHint/
no-makeHint (boundary.go) stay Pulsar-side — mlwe exposes those only via
the normalized [0,q) interface, a different byte representation.
GATE 3 + GATE 4: KAT byte-identical (9 keygen, 9 sign, 6 verify, 4
threshold-sign, 3 DKG); targeted suite 30/30; GATE-1/2, GATE A/B/C,
capstone, Mithril RSS gold proof all green. Vector SHAs unchanged.
Net -197 LOC.
poly.ntt/invNTT/mulHat now delegate to the canonical mlwe/ring/mldsa
Ring (pulsarRing), which was lifted verbatim from this file — byte-
identical by construction. Deleted the duplicate 256-entry zeta tables
(nttZetas, nttInvZetas), montReduceLe2Q, and the now-dead mldsaQinv /
mldsaROver256 constants. Added toMLWE widening helper (fromMLWE narrows).
CT Montgomery convention preserved: bare INTT(NTT(p))==R*p, the real
contract INTT(MulNTT(NTT(a),NTT(b)))==a*b. mlwe's Ring bundles
reduce/normalize into INTT/MulNTT; this is byte-identical to the former
hand-reduced call sites because every multiply chain terminates in
normalize (only congruence-preserving add/sub intervene) before any
representation-sensitive use — audited at all invNTT/mulHat sites.
Kept Pulsar-side (mlwe exposes only the normalized [0,q) interface,
incompatible with these representations):
- raw add/sub (no-reduction accumulation in polyDotHat)
- reduceLe2Q/le2qModQ/modQ (Barrett reductions used by call sites)
- decompose/power2round (a0+q wire representation; FindHint + PackT0)
- useHint/highBitsCoeff/centeredLowBits (per-coefficient FindHint,
boundary.go, kept Pulsar-side per spec)
- exceeds, mulBy2toD, all FIPS-204 bit-packers (wire frame)
GATE 4: KAT byte-identical (9 keygen, 9 sign, 6 verify, 4 threshold-sign,
3 DKG); targeted suite 30/30; full suite green (minus pre-existing
RNG-flaky TestRED_PoC_MEDIUM). Vector SHAs unchanged.
ExpandA/ExpandS/ExpandMask/SampleInBall now come from the canonical
github.com/luxfi/mlwe/sample/shake copy, which was lifted verbatim from
this package — byte-identical by construction. New mldsa_sample.go is the
sole Pulsar<->mlwe sampler bridge (fromMLWE converter + four adapters in
Pulsar poly shapes). Deleted the duplicate sampler bodies:
- polyDeriveUniform, polyDeriveUniformLeqEta, polyUnpackLeGamma1
(mldsa_lattice.go) and its now-unused encoding/binary + sha3 imports
- polyDeriveUniformBall (mldsa_lattice.go -> bridge, thin SampleInBall)
- expandMaskPoly (bcc_sign.go -> bridge, single-poly ExpandMask via L=1)
- expandAPulsar (talus_dkg_vss.go -> bridge, vector ExpandA)
keyderive + mithril_rss route ExpandA/ExpandS through expandAPulsar /
expandSPulsar. Wire-frame packers (polyPackLeGamma1/T0/T1/W1/LeqEta,
polyVecPackHint) stay Pulsar-side. SampleInBall/ExpandMask keep
Pulsar-named per-poly entries (prod + no-leak/transcript tests call them
per polynomial).
GATE 3: KAT byte-identical (9 keygen, 9 sign, 6 verify, 4 threshold-sign,
3 DKG); targeted suite 30/30; full suite green (minus pre-existing
RNG-flaky TestRED_PoC_MEDIUM). Vector SHAs unchanged.
Phase 1: SP 800-185 transcript primitives (cSHAKE/KMAC/encoders) routed to
mlwe/transcript — the ONE copy; Pulsar keeps only protocol-specific hash methods
(transcriptHash prepends left_encode(nParts), not SP-800-185 TupleHash, so it
stays Pulsar-side over mlwe encoders).
Phase 2: Shamir Lagrange reconstruction + GF(257)/GF(q) field arithmetic routed
to mlwe/share; dead modInv/modPow deleted. KEPT Pulsar-side: the seeded deal
(2-byte GF257 / 4-byte GFq per-coeff draw — byte-incompatible with mlwe.Split's
8-byte rejection sampler, exactly as Corona kept its seeded deal) + wire types +
the O(n) single-point Lagrange (mlwe vends only O(n^2); routing reshare loops
through it would regress to O(n^3)).
Byte-preserving: golden KAT vectors UNCHANGED (kat_test.go replays them green);
full suite + gold proof (RSS stock-circl) + GATE-1/2/A/B/C all green. No wire
byte / modulus / domain tag / test vector changed; no-reconstruct signing math
and RSS keygen untouched. One-and-one-way DRY: Pulsar + Corona now share
mlwe/transcript + mlwe/share.
The GF(257) and GF(q=8380417) Lagrange interpolation and modular field
arithmetic now route through github.com/luxfi/mlwe/share, the single Shamir
surface shared with Corona. Pulsar keeps what is protocol-specific and
byte-incompatible with mlwe's sampler:
- the seeded deal (shamirDealRandom*/Accel, LargeShamir.Deal/EvalAt) — its
2-byte (GF257) / 4-byte (GFq) coeffStream draw + mod-reduce is byte-
incompatible with mlwe's 8-byte rejection sampler, so it stays Pulsar-side
(exactly as Corona kept its seeded ShamirSecretSharingGeneralWithSeed);
- the share wire types (shamirShare uint16 / shamirShareQ uint32) and their
(de)serialisation; EvalPointFromID/Q.
Routed:
- shamirReconstructGF / shamirReconstructGFQ → share.Lagrange(xs,0,field)
over share.NewPrimeField(257) and share.MLDSAField (O(n^2), unchanged);
Pulsar's zero/dup error identity (ErrZeroEvalPoint/ErrDuplicateEvalPoint)
is preserved by checking before the call.
- LagrangeAtZeroQ and reshare's lagrangeAtZero keep their O(n) single-
coefficient form (mlwe vends only the O(n^2) all-coefficients Lagrange;
routing the BGW/reshare per-party loops through it would be O(n^3)) but
now do ALL modular arithmetic via share.Field.Sub/Mul/Inv — so no field
arithmetic is duplicated.
Deleted the now-dead modInvSmall/modPowSmall/modInvQ/modPowQ and their
redundant unit tests (the field inverse is owned + KAT'd in mlwe/share).
Byte-identity gate: golden vectors unchanged; KAT replays byte-identical
(9 keygen, 9 sign, 6 verify, 4 threshold-sign, 3 DKG); Shamir deal/reconstruct,
wire round-trips, zero/dup error identity, LagrangeAtZeroQ-agrees-with-recon,
reshare, Talus BGW/MPC (signing-path single-point Lagrange) all green.
The SP 800-185 primitives (cSHAKE256/KMAC256 + LeftEncode/RightEncode/
EncodeString/BytePad) now live solely in github.com/luxfi/mlwe/transcript,
the single hash surface shared with Corona. Pulsar keeps only what is
protocol-specific:
- the customisation tags and N="Pulsar" function name,
- cshake256/kmac256 Pulsar-named entries (bodies delegate to mlwe),
- transcriptHash/transcriptHash32 (Pulsar's length-prefixed digest,
NOT SP 800-185 TupleHash256 — re-expressed over the shared encoders).
Deleted the duplicate encoder/primitive bodies; routed the six direct
encoder callers (abort, threshold, protocol_auth, dkg_wellformed_proof,
nonce_transcript_proof, partial_proof) to transcript.LeftEncode/EncodeString.
Byte-identity gate: x/crypto resolves to v0.52.0 (== baseline, no skew);
golden vectors unchanged; KAT replays pass byte-identical (9 keygen, 9 sign,
6 verify, 4 threshold-sign, 3 DKG); full suite green.
Matches the Corona onto-mlwe precedent (keep protocol hash methods, route
bodies; generic encoders called directly). Mirrors corona refactor/onto-mlwe.
TestHyperballMaskNeverOnWire: using the manual harness (exact yInt readable),
assert no party's mask y_{j,k} bytes appear in the Round-2 (w=A·y) / Round-3
(z=y+c·s1) wire data — completing the gate-5(b) no-leak proof for y.
TestHyperballSubThresholdFailsClosed: |active|≠T / unsorted / dup / OOR all
rejected before signing (no partial-quorum signature).
TestHyperballBiasedPartialCaughtAndBlamed: a biased partial makes every slot
miss FindHint/self-verify → fail-closed (no signature); blameSlot pinpoints
the culprit revealing T_j only up to it (Σ_j T_j = t−s2 never formed).
TestHyperballEquivocationCaught: a party changing w between R1 and R2 is
rejected by the binding check.
TestHyperballNonceReuseFatal: demonstrates reuse leaks the share
(z1−z2=(c1−c2)·s1, confirmable; wrong guess fails) and asserts the per-round
nonce derivation is fresh-across-rounds + deterministic-within-round.
TestHyperballStockCirclVerify: dealerless RSS ML-DSA-65 key signed via the
3-round hyperball protocol (NO reconstruction) verifies byte-for-byte under
unmodified circl mldsa65.Verify for n=8,t=8 and all 15 N≤6 committees — each
in a single 3-round shot. Tamper/wrong-msg/wrong-ctx rejected (non-vacuous).
TestHyperballNoReconstructStructural: (1) AST scan proves the signing source
contains no ReconstructKeyMaterial call; (2) runtime transcript oracle proves
no party's share s1_(j) nor the full reconstructed s1/s2 bytes appear on the
wire; (3) any T-1 coalition misses ≥1 subset.
mithril_rss_hyperball.go: SignHyperball runs the dealerless RSS ML-DSA-65
key with NO key reconstruction. Each active party holds only its balanced-
partition share s1_(j), samples its own hyperball mask y_j (Box-Muller
uniform-in-ball), and emits only z_j = y_j + c*s1_(j) under a leak-free
Excess gate; the coordinator sums z = Σ z_j = y + c*s1 and recovers the hint
from the PUBLIC w' = A*z − c*t1*2^d (BoundaryClear + FindHint). No party or
coordinator ever forms full s1/s2/t0/y/w/w0/sk. K-parallel slots + maxRounds
re-runs amortise acceptance; fail-closed self-verify before emit. blameSlot
gives leak-free identification (stops at culprit so Σ T_j = t−s2 never forms).
Explicit hyperballParty (private share) vs hyperballCoordinator (public
aggregates only) types make the no-reconstruct boundary structural.
Δ/δ = √(2κln2/n) leak-free gap (κ=64, n=L·N=1280, α=∞ conservative),
validated against the Mithril ML-DSA-44 reference table (predicts Δ≈56
on the diagonal vs the table's 55-57). One-sided BCC adaptation (ν=1,
no s2-side); r1 from the L∞ norm budget. Companion to the upcoming
mithril_rss_hyperball.go.
Completes the dealerless story: v0.5.x made committee SIGNING no-reconstruct +
malicious-hardened; this makes KEYGEN dealerless via Mithril short replicated
secret sharing (luxfi/dkg v0.3.4), closing the keygen-side reconstruction
residual. Each committee's RSS-generated group key signs under the STANDARD
unmodified FIPS-204 verifier (cloudflare/circl mldsa65.Verify) — gold-proof
verified live: (t=8,n=8), (t=16,n=16), and all small committees T=2..N N=2..6
via real per-party rejection sampling (1-24 attempts). Beats the pick-2 / naive
S_eta wall by RSS-with-short-shares + local rejection (Mithril, ia.cr/2026/013),
NOT a trusted dealer, NOT centralized reconstruction.
Additive: new mithril_rss.go + dealerless RSS FIPS-leg Sign API (fail-closed,
quorum-gated); does not alter the v0.5.x no-reconstruct signing path. Integrates
clean with v0.5.1 (auth safe-by-default, epoch-pruned ledger, no-asm CI).
SCOPED: standard-verifier-compatible dealerless keygen; full FIPS-204 KeyGen-
distribution-equivalence (simulation/hiding/abort-bias) remains a labeled residual.
Full suite green. Dealerless RSS signs under stock circl at n=8,t=8 + n=16,t=16 (T==N,
N>6, owner n-of-n families); fault-tolerant n=8,t=7 awaits the general Algorithm-6
partition (Sign fails-closed 'no balanced partition' until then).
The per-(N,T) norm bound admits the owner's high-t families. T==N committees (n=8,t=8;
n=16,t=16) sign under stock circl now (algorithmic base-case partition). T<N at N>6
(n=8,t=7 default) needs the general Algorithm-6 balanced partition (canonicalSharing
is table-limited to N≤6) — tracked as the next gap.
VERSIONS.md: add v0.5.0 (merged) + v0.5.1 (current) rows; update the malicious-
hardening status bullets — nonce single-use now epoch-pruned (bounded lifetime,
no live-window reuse), authenticated blame now SAFE-BY-DEFAULT (nil verifier =>
ErrOriginAuthRequired fail-closed; explicit UnauthenticatedAggregation opt-out),
CI invariant now also asserts pure-Go (no .s/.c/cgo) sealing GATE-C's blind spot.
Persistent-ledger residual narrowed to crash-restart only (lifetime now bounded).
PROOF-CLAIMS.md: update the residual paragraph for the same three items.
Scope kept HONEST: malicious-HARDENED, not fully-malicious-secure-PROVEN; the
'NEVER claim FIPS/NIST-certified . fully-malicious-secure-proven' line is intact.
GATE-C's reachability + indirection lint is complete for Go ASTs (+ go:linkname)
but cannot model hand-written assembly (.s) or C (.c/cgo): a banned reconstruct
primitive in asm/C, or a cgo escape hatch, would be invisible to the AST gates.
The no-reconstruct soundness claim therefore rests on the package being pure Go.
TestCI_NoAssemblyOrCgoFiles asserts that, three ways:
(a) go/build classification of the default build — every non-Go compiled-source
bucket (SFiles/CFiles/HFiles/CXX/M/F/Cgo/SWIG/syso) is empty;
(b) raw dir scan for .s/.c/.h/.cc/.cpp/.m/.f/.syso/.swig* — catches files a
build constraint hides from (a) but that could link under a tag;
(c) AST ImportsOnly scan of EVERY .go file (incl. constrained + _test) for a cgo
import 'C' — catches a constraint-hidden cgo escape.
Negative-control verified: a probe .s file is caught ('GATE-C blind spot OPENED'),
removed, suite green again. GATE C reachability + indirection stay green.
The single-use ledger map grew unbounded over a validator's lifetime. Add
epoch-based pruning that bounds it to a sliding retained-epoch window WITHOUT
reopening reuse inside the live window:
- (*InMemoryNonceLedger).PruneBefore(minEpoch) drops reservations with
binding.Epoch < minEpoch; entries in [minEpoch, inf) are kept, so reuse there
is still refused (ErrNonceReused). Caller picks minEpoch >= finality/reorg
depth (a nonce from a pruned, past-finality epoch cannot be usefully replayed).
- NoncePruner — the optional pruning capability, kept OFF the base NonceLedger
interface (reserve/check stays minimal); persistent ledgers may prune via TTL.
- PruneShareLedgers(minEpoch) — operator lever: sweeps the process-global
per-share registry, pruning every ledger that implements NoncePruner. Snapshots
under the registry lock, prunes each under its own lock (never two locks at once).
Test: PruneBefore(8) frees epochs 1..7; reuse within retained [8,10] still
ErrNonceReused; pruned slots reclaimed. Registry sweep prunes across per-share
ledgers, retained-window reuse still refused. GATE A (nonce single-use default
path + cross-committee dedup) stays green.
AggregateBCC/AggregateBCCWithBlame previously aggregated WITHOUT origin
authentication when auth==nil (SetIdentity opt-in), so a production caller that
forgot to wire the verifier silently reverted to the exclude-honest-victim
footgun. Now:
- auth == nil => ErrOriginAuthRequired (FAIL-CLOSED), matching the nonce
ledger's no-fail-open posture.
- the unauthenticated path is reachable ONLY via the EXPLICIT opt-out sentinel
UnauthenticatedAggregation (trusted-channel / test). The sentinel panics if
ever invoked as a real verifier (fail loud, never silent).
- DistributedBCCSigner.FinalizeWithBlame forwards d.idVerify, so a signer with
no SetIdentity refuses FAIL-CLOSED too; opt out with
SetIdentity(nil, UnauthenticatedAggregation).
Test (origin_auth_gate_test.go): the DEFAULT (nil verifier) free-fn AND signer
paths refuse with ErrOriginAuthRequired; the explicit opt-out and a real verifier
both aggregate to a FIPS-valid signature; the sentinel is not a usable verifier.
Trusted in-memory ceremonies (runBCCCeremony, talus, sub-quorum, sound-proof,
no-reconstruct behavioural) opt out explicitly. GATE B / RED MEDIUM flip /
capstone / no-reconstruct all stay green.
MithrilKey.Sign reconstructs key material from any T-quorum's holdings, BCC-signs
(hint from public w', no c·s2/c·t0 leak), and FAIL-CLOSED self-verifies against
FIPS-204 before emitting. Output verifies under stock circl mldsa65.Verify.
Proven for multiple qualifying quorums under one group key. Full pulsar suite
green (65s).
Every admissible Mithril committee (2≤T≤N≤6) signs and verifies under stock
circl mldsa65.Verify within a small attempt budget (1-90 attempts; even (4,6)
with C(N,M)=20, ‖s2‖∞≤80 in ~9). The centralized reconstruct+BCC signing range
equals the scheme viability range — c·s2 stays small because c is sparse (τ=49
±1), so the hint fits ω even at the larger committees.
The FIPS signing key now comes from dealerless Replicated Secret Sharing
(Mithril, ePrint 2026/013) instead of trusted-dealer DealAlgShares. Each
M-subset's short χ_η secret is sampled by its leader; the key is the SUM of
C(N,M) short shares so ‖s2‖∞ ≤ C(N,M)·η is genuinely small (vs ‖B·u‖∞≈q/2 for
the vss key). Combinatorics from luxfi/dkg/rss; FIPS-204 arithmetic is pulsar's
own circl-identical lattice code.
PROVEN (TestMithrilRSSStockCirclVerify): a NO-DEALER RSS key signs via the BCC
signer and verifies BYTE-FOR-BYTE under unmodified cloudflare/circl
mldsa65.Verify — committees (2,2)(2,3)(3,3)(2,4)(4,4) in 9-24 attempts; tamper
and wrong-message rejected (non-vacuous). Dealerless structural proof (no party
holds all subsets, no T-1 covers all) + any-quorum-same-key for all (T,N).
go.mod replace → local dkg/rss pending v0.3.0 tag.
Update VERSIONS.md + PROOF-CLAIMS.md so no claim overstates:
- nonce single-use is now claimed ENFORCED BY DEFAULT (per-share registry on the
real API), not opt-in; w1-only dedup closes cross-committee reuse.
- authenticated-PartyID blame: blame gated on identity-signature validity; an
attacker cannot frame/exclude an honest victim; never blame off a raw PartyID.
- GATE-C: 'complete for DIRECT calls' + companion indirection lint — drop the
'never miss one' overstatement; the pair (not either half) closes the banned set.
- residuals restated honestly: persistent ledger (in-process default safe now),
networked transport auth, BDLOP valid-sigma-wrong-z.
The AST name call-graph follows DIRECT named calls only; it misses function-
value / closure / go:linkname indirection (RED PoC: e:=deriveKeyMaterial;
s:=bccSign; s(e(...)) not flagged). The old comment overstated 'never miss one'.
Fix: pair the reach graph with a companion lint forbidBannedPrimitiveIndirection
that forbids taking ANY banned primitive as a non-call value or aliasing it via
//go:linkname anywhere in the default build. With that lint green, every banned-
primitive use in the build IS a direct call the graph follows — so the PAIR is
complete for the banned set (neither half claimed complete alone). Wording
softened to state exactly this.
GATE: TestGATE_C_NoBannedPrimitiveValueIndirection — (a) the real build takes no
banned primitive as a value / linkname; (b) the name graph alone MISSES the
e:=deriveKeyMaterial; s:=bccSign indirection while the lint CATCHES it; (c)
go:linkname aliasing a banned primitive is caught.
AggregateBCCWithBlame consumed raw []Partial; PartyID was unauthenticated, so an
attacker could stamp a victim's slot on a forged partial and (via first-per-
PartyID + duplicate rules) get the honest victim BLAMED and front-run-EXCLUDED.
No forge/leak (capstone holds), but blame misattributed.
Fix: bind each partial to its producer's long-term identity key.
- Partial gains Author + AuthSig; Round2 signs (author ‖ slot ‖ H(PartyID,
session,nonce,ZShare,Proof)) via an injected IdentitySigner (SetIdentity).
- AggregateBCCWithBlame gains (quorum, epoch, AbortSignatureVerifier). When a
verifier is supplied it AUTHENTICATES each partial BEFORE attribution: accept
only if AuthSig verifies AND Author == quorum[PartyID]; drop forged/wrong-slot/
unsigned partials with NO blame against the slot's honest owner, BEFORE the
first-per-PartyID/duplicate logic (so a forgery cannot evict the victim).
- Blame is emitted ONLY for authenticated partials and NEVER off a raw PartyID
(auth==nil => no blame produced — attribution fails closed). Wraps
protocol_auth.SignedProtocolMessage (one identity layer). Networked transport
auth stays a flagged residual.
GATE B updated to authenticated partials (a deviator SIGNS its bad partial =>
attributable; a transport-tampered one breaks its sig => dropped, not framed).
New TestRED_PoC_MEDIUM_CannotFrameOrExcludeHonestVictim: forged victim-slot
partial is dropped (attacker can't sign as victim) -> victim NOT blamed, NOT
excluded, round still signs; contrast shows the verifier is load-bearing.
The dedup key was H(committeeID‖w1). The same joint nonce (same w1) used in two
committees that share a victim validator produced DISTINCT keys, so the victim's
ledger did not dedup across committees -> victim emits a partial in each -> the
attacker collects two partials on one nonce.
Fix: key on w1 alone. This is sound precisely BECAUSE the ledger is now
per-validator-share (resolved by shareIdentityKey): within the victim's one
ledger the same w1 collapses to ONE key regardless of committee. committeeID
stays on the NonceTicket/binding for audit and on TicketID; only the MaterialKey
drops it.
GATE: TestRED_LOW_CrossCommittee_SameNonce_Deduped — two distinct committees
sharing a victim, same w1 -> equal MaterialKey -> second reserve ErrNonceReused.
NewDistributedBCCSigner gave each instance its OWN empty in-memory ledger;
since a signer fixes (sid,ctx,msg) at construction, signing two messages needs
two signer objects -> two empty ledgers -> cross-message nonce reuse slipped
unless the integrator hand-threaded ONE ledger via SetNonceLedger. The
reference runBCCCeremony never did, so the shipped default was fail-open and
RED recovered the victim's full s1-share on the bare API.
Fix: resolve the single-use ledger from a process-global registry keyed by
SHARE IDENTITY (shareIdentityKey = H(mode,nodeID,evalPoint,secret-share)).
Every signer over the same key-share resolves to ONE ledger -> cross-instance
reuse refused by DEFAULT, no per-instance empty-ledger path. SetNonceLedger now
installs a persistent ledger into the same per-share slot (first-writer-wins);
the in-process default is safe NOW, persistence stays a flagged residual.
GATE A extended: TestRED_PoC_DefaultLedger_NonceReuse_Refused drives the REAL
API like runBCCCeremony (fresh signer per message, NO SetNonceLedger) and
asserts the second Round2 on the reused nonce + the relabel variant both return
ErrNonceReused -> RED's PoC flips, s1 is unrecoverable.