The forged certificate that reached crates.io was killed at verification, but a
red review showed the hole had only moved to registration: a key was admitted on
key_validate alone, which proves a point is on the curve and nothing about who
holds its secret, and duplicate keys were merged into one validator carrying many
node ids — so a floor written to require many distinct signers was cleared by one
holder registering one key under several ids.
This is the Go oracle for the standard ruled in luxfi/conformance: it defines the
bytes Rust and C++ reproduce.
validator/pop is the primitive. A registrant signs its own identity followed by
its own key — node(20, ids.NodeID) ‖ pubkey(48, compressed G1), 68 bytes, no
separator — under the proof-of-possession domain BLS_POP_..._POP_, distinct from
the vote's ..._NUL_ so a vote is never a proof and a proof never a vote. The node
is bound because the pubkey-only IETF proof travels with the key: bound to
nothing, it re-registers under any identity. 20 bytes because a Lux validator's
identity IS its NodeID; the 32-byte consensus Id names blocks, not nodes, and has
no canonical inverse, so widening would invent a second name for one node.
validator/registration is the admission rule, written once: encoding, then
possession, then uniqueness, then weight — a pairing on non-points is undefined,
and weight counted before uniqueness is weight counted twice. A set is admitted
whole or refused whole, because a set that silently drops a signer no longer
describes its own weight. FlattenValidatorSet's merge of duplicate keys is retired
here: it was the only thing that made many-nodes-one-key representable, and after
the uniqueness rule that state does not exist to normalize. Order and
PublicKeyBytes still match the upstream flatten exactly, so a proven set and a
flattened-distinct set are the same set.
engine/chain keys the cert store on the DECISION — the signed canonical identity
— not the transport envelope id, which no signature covers: two envelopes of one
decision were two store entries for one finality fact, and a verified cert could
be filed under a block it never attested. Keyed by decision, a cert is found by
anything that finalized that decision, and Put is idempotent per decision.
The pubkey-only P-chain proof (luxfi/node AddValidatorTx) is NOT touched here:
moving it node-bound is a network upgrade, every validator re-proves, and it is
activation-gated as the deliberate last step. Anywhere a validator set that
shares a key across node ids is admitted today, this now refuses it — that is the
intended behaviour, and the coordination point with the P-chain step.
Co-authored-by: Hanzo Dev <[email protected]>
Two lineages refactored the same certificate path at once. This branch fixed the
BLS length inversion in place — the aggregator declared a 48-byte G1 signature
and sliced every 96-byte G2 vote down to it, so the aggregate was the error
path's zero bytes and forty validators voting with no signatures produced a
certificate that verified. Main went further and deleted the aggregate path
entirely, moving the per-key check into cert.rs, where a certificate is verified
against one public key per signature and keys are never summed.
Deleting the class beats fixing the instance, and main's version arrives with the
test that proves it: rogue_key.rs builds the Boneh-Drijvers-Neven forgery, shows
the four keys really do sum to one the attacker holds, and then shows it buys
nothing. So the Rust crate is main's.
Resolving that file by date would have taken this branch's lib.rs and left
cert.rs orphaned: main declares the module, this branch does not, and the new
test would not have resolved lux_consensus::cert at all.
The corpus stays this branch's. It was re-cut for the seed encoding, and the Go
definitions that generate it are here; main's is smaller because it de-duplicated
an older cut. Both languages read the re-cut one and agree.
Go: conformance and engine/chain green. Rust: 8 suites, 70 tests, 0 failures.
Co-authored-by: Hanzo Dev <[email protected]>
The seed preimage was be64(epoch) || chain_id || prev_block_hash, written end
to end. Nothing recorded where one field stopped and the next began, so the
boundary slid: DeriveEpochSeed(1, "chain-A", "") and DeriveEpochSeed(1,
"chain-", "A") are one seed. Machine-checked, both are
c89a52... under the old encoding.
That is not only untidy. The seed decides alpha, the count of votes a round
accepts on, and the input that makes it unpredictable is the parent hash --
the one value nobody holds until the previous epoch finalizes. Under a
concatenated preimage a chain that names itself lux-mainnet||H binds no parent
at all and still derives the seed lux-mainnet derives at parent H. The chain id
is a name the chain picks for itself; the parent is not. Trading one for the
other gives away the whole reason the parent is an input.
So every variable-length field is now written at its length, and the digest is
taken under a label of its own:
domain || be64(epoch) || be64(len(chain_id)) || chain_id
|| be64(len(prev_block_hash)) || prev_block_hash
with domain = "lux.consensus.fpc.seed". Lengths make the preimage read back
apart into exactly the triple that produced it, which is injectivity stated as
something a machine checks rather than something a comment claims:
TestOneTripleReachesOnePreimage parses each preimage back and compares. The
domain keeps this digest from colliding with any other the protocol takes over
similar bytes.
EpochSeedPreimage is exported because the corpus records the bytes, not only
the digest. Two implementations can agree on every digest in a corpus and still
disagree on the first case that is not in it; they cannot, if they agree on
what they hashed. The oracle asks the package for the preimage rather than
rebuilding it, so a vector is never a second copy of the encoding it records.
pkg/rust carries the same change, and the same reason: it is the crate the
Rust conformance harness holds against these vectors.
Co-authored-by: Hanzo Dev <[email protected]>
engine/driver.go filled the FPC seed from crypto/rand, so every node ran a
different threshold schedule. The threshold is the count of votes a round must
see before it moves, which means two honest validators sampling the same
committee were demanding different majorities of it: measured over 200
instances at k=20, the phase-1 threshold came out 11, 12, 13, 14, 15 and 16.
The seed is now DeriveEpochSeed(epoch, chain, parent), which every validator
computes alike and none can compute ahead of time — the parent hash is only
known once the previous epoch has finalized. The epoch arrives through
WithEpoch; a driver told no epoch derives the genesis epoch, which is a real
value rather than a draw. Sampling randomness is untouched: which peers a node
draws is deliberately its own business, what majority it then demands is not.
Wave and Driver gain Threshold(phase) so the number can be asked for. It is
the one number two implementations must agree on to agree at all, and a test
that cannot read it can only assert agreement rather than measure it.
Lux Partners Limited is not the entity behind this work. Every place the
old name stood — copyright lines, site footers, brand config, legal and
compliance text — now names the company that actually holds it.
lux.fund is the exception and is not renamed to match: that site belongs
to Lux Assets Management LLC, a different company, and it says so.
Two kinds of line were left for a person rather than rewritten. A dated
news item recording that Lux Partners Limited was established in the Isle
of Man is a historical fact, and renaming it would falsify the record. And
a sentence asserting that the company is domiciled in the Isle of Man is a
claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle
of Man company, so swapping the name there would assert something untrue.
Co-authored-by: Hanzo Dev <[email protected]>
Aggregation sums the named validators' keys and BLS is linear, so a repeated id
adds the same key again. t copies of one validator yield t*pk, and that validator
can produce t*sigma by itself — e(t*sigma, g) = e(H(m), t*pk). One validator
forged a t-of-n aggregate with nothing but its own honest signature scaled: no
key compromise, no collusion.
The threshold was also compared against SignerCount, a field the sender writes
into the message it is trying to get accepted. It is not evidence of anything.
Verification now refuses a repeated id and compares the threshold against the
number of distinct signers it actually resolved.
TestAggregateRejectsRepeatedSigner builds the forgery; without the rule it
verifies. TestAggregateCountsDistinctSignersNotTheDeclaredOne pins the count.
TestGenuineAggregateStillVerifies is the control that an honest quorum still
passes. All three executed both ways.
Co-authored-by: Hanzo Dev <[email protected]>
3a6fb954 cached the Polaris fixture and the package still hit its 900s cap on
CI. That fix was real but partial — I had measured only the seven tests I ran
locally, not the package. Measuring the whole package showed the remaining
cost sitting in twoSLHDSA: two FIPS-205 keygens at ParamsM192s, minted twelve
times for the same two fixed IDs, same weights, same threshold.
Shared safely — every test that alters a cert copies first. RollupRoot is a
[32]byte, so `bad := *f.mc` is a genuine copy, and the tamper test re-decodes
from f.mc.CertSet into fresh memory. Nothing writes through the scenario's
pointers, and the full package proves it: 11601 tests, same count, all pass.
Measured on one host, full package, -count=1:
before any caching ~117s
Polaris cached only 90s
both cached 50.5s
CI runs the same work on much slower hardware — 900s there against 90s here —
so cutting the package's real work is what matters, not the wall clock on this
machine.
Co-authored-by: Hanzo Dev <[email protected]>
These were hidden behind the gofmt failures fixed in 3c214d21 — golangci-lint
reports the formatter first, so CI only surfaced them once formatting passed:
engine/chain/engine.go:4196:3 QF1006 (staticcheck)
protocol/quasar/pulsar_sampled_security.go:116 ST1016 (staticcheck)
QF1006 — buildBlocksLocked opened `for { if t.pendingBuildBlocks <= 0 { break } }`.
The break was the first statement, so lifting it into the loop condition is
exactly equivalent: `for t.pendingBuildBlocks > 0 {`.
ST1016 — SortitionParams has five methods; four name the receiver `sp` and
Validate in pulsar_sortition.go named it `p`. Renamed to `sp`, so the reported
file is where the majority lives rather than where the odd one out does.
Verified: golangci-lint over ./engine/chain/... and ./protocol/quasar/...
reports "0 issues", the packages build, and the tests covering both sites pass
(engine/chain Build|Pending 1.1s, quasar Sortition 0.3s).
Co-authored-by: Hanzo Dev <[email protected]>
The `test` job has been failing on:
FAIL github.com/luxfi/consensus/protocol/quasar 900.063s
Not an assertion — the package was killed at its timeout. The goroutine dump
names the cost exactly:
circl slhdsa.forsSign <- slhSignInternal <- SignDeterministic
<- magnetar.slhSign(0x6) <- ValidatorSign
<- buildPolarisFixture (cert_policy_verify_test.go:122)
<- TestVerifyWithRealKeysPolaris_RejectsRemovedLegWhenKeyed
buildPolarisFixture mints three SLH-DSA keypairs and signs with each, using
magnetar.ParamsM192s — FIPS 205 in its SMALL-signature parameter set, which is
the slowest to sign. Seven tests call it, so the package paid for 21 keygen+
sign pairs and ran out of clock.
The fixture is deterministic in everything the tests assert: one fixed digest,
and fresh random keys nothing checks the value of. So it is built once and
shared.
Safe to share — no caller mutates it. The only two that alter a cert copy
first: `purePQ := *f.cert` before nil-ing BLS, and a fresh &QuasarCert built
with `append([]byte(nil), f.cert.BLS...)`. A plain package var needs no lock:
nothing in protocol/quasar calls t.Parallel().
Measured on the same machine, same tests, all seven PASS (none skipped):
rebuilt per test (control) : 32.3s
built once : 5.6s <- 5.33s in the first test, 0.00s after
CI's container is far slower than this host in absolute terms — hence 900s
there vs 32s here — but it does the same 6 redundant builds, and now does one.
Leaves the -short skip intact.
Co-authored-by: Hanzo Dev <[email protected]>
golangci-lint has been failing the `lint` job on:
engine/chain/attestation.go:89:1 (gofmt)
engine/chain/cert.go:86:1 (gofmt)
pkg/wire/policies.go:777:1 (gofmt)
protocol/quasar/compact_evidence.go:96:1 (gofmt)
protocol/quasar/consensus_cert.go:576:1 (gofmt)
protocol/quasar/pulsar_sampled_cert.go:50:1 (gofmt)
protocol/quasar/pulsar_sampled_subject.go:18:1 (gofmt)
engine/chain/integration.go:864:2 (ineffassign)
The gofmt diffs are entirely struct-field comment alignment — no code moves.
The ineffassign is real. followVerifiedBlock did
pending, exists := rt.Transitive.pendingBlocks[blockID]
and `pending` is never read: the only other mentions are the reassignment
inside `if !exists` and the store on the next line. Only presence matters, so
the value is no longer bound. Behaviour is identical — an already-tracked
block keeps its entry, a new one gets a fresh PendingBlock.
Scope note: `gofmt -l .` lists 30 files here, but `.golangci.yml` sets
`tests: false`, so the 23 test files are excluded and CI never sees them.
Only the 7 it actually flags are touched.
Verified: gofmt clean on all 8, `go build ./engine/... ./protocol/quasar/...
./pkg/wire/...` exits 0, and golangci-lint no longer reports gofmt or
ineffassign anywhere in those trees.
Does NOT address the separate `test` failure — protocol/quasar times out at
900s, which is its own investigation.
Co-authored-by: Hanzo Dev <[email protected]>
Five defects. The first four let a decision be reached on evidence never
gathered; the fifth is that main does not build.
**wave.Tick rounded the vote threshold down.** `int(float64(K) * Alpha)`
truncates: K=20 Alpha=0.69 is 13.8, so 13 votes of 20 cleared a threshold that
config.DefaultParams declares as AlphaPreference 14 — 65.0% on a chain
configured for 69%. Mainnet gave 14 where 15 is declared, testnet 7 where 8 is.
config.AlphaForK and fpc.Selector.SelectThreshold both round up; only this path
did not. Now math.Ceil.
**wave.New validated nothing.** Beta = 0 makes `Count >= Beta` in Tick true
before a vote is counted; K = 0 or Alpha = 0 puts the threshold at 0, which
yesVotes = 0 already meets. All three are zero values, so a Config missing a
field decided immediately. New refuses each. Alpha is checked only when FPC is
disabled, since the selector derives the threshold from ThetaMin/ThetaMax
otherwise — an existing FPC test passes Alpha 0 legitimately and caught the
first, over-strict version of this check.
**ray and field discarded that error.** Both NewDriver functions defaulted on
`== 0`, so a negative PollSize or Beta, or an Alpha outside (0, 1], reached
wave.New's refusal — and because the error is dropped, the Driver kept a zero
Wave whose nil state map panics on the first Tick. Verified: with the old
defaulting the new test panics with "assignment to entry in nil map". Both now
normalise the whole invalid range.
**engine.NewLuxConsensus clamped a negative beta to 0.** The comment said
"ensure beta is non-negative for uint32 conversion" — that fixes the
representation and breaks the meaning. NewLuxConsensus(20, 15, -5) built a
driver whose wave decides with Count = 0, and a test asserted `beta == 0`. Beta
now clamps to 1. k < 1 clamps too: it was a division by zero in
`float64(alpha)/float64(k)`. An alpha ratio outside (0, 1] falls back to
ConsensusSuperMajority, since above 1 no vote count reaches it and nothing ever
decides.
The chain layer is unaffected in safety: engine/chain gates acceptance on its
own distinct-signer count (`block.acceptVotes >= c.alpha`) as well as the
driver's decision, so wave's verdict is a liveness trigger there, not the safety
boundary. Its partition matrix stays green.
**go.sum recorded a hash luxfi/pq v1.0.3 no longer resolves to**, so main did
not build at all: "checksum mismatch, downloaded pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=,
go.sum ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=". Reconciled to the hash the
tag resolves to now.
Tests: protocol/wave/threshold_rounding_test.go pins each shipped profile's
threshold to its declared AlphaPreference and checks the boundary from both
sides — one vote below must not decide, exactly at must, on accept and reject —
plus every zero and out-of-range Config New must refuse.
protocol/{ray,field}/config_floor_test.go assert no malformed Config yields a
Driver that panics on Tick. engine/lux_consensus_test.go asserts beta >= 1, a
positive finite alpha ratio for k <= 0, and a clamped ratio for alpha > k.
go vet clean, full suite green.
Co-authored-by: Hanzo Dev <[email protected]>
v1.35.31's bound rejected PartyID >= ValidatorSetSize, but party IDs are
1-based (threshold convention: Shamir x=0 is the secret), so validators are
1..N and the Nth validator (PartyID == ValidatorSetSize) was wrongly dropped —
breaking every real N-of-N ceremony (quorum never reached). The bound is a
strict UPPER bound now (> ValidatorSetSize): DoS-safe (rejects unbounded
PartyID) and 1-based-correct. TestFinalize_BoundaryPartyIDEqualsValidatorSetSize
locks it. The v1.35.31 tag is left untouched (immutable); this is v1.35.32.
Re-targeted onto SHIPPED consensus v1.35.30 (cloud go.mod pin: pulsar
v1.9.0, threshold v1.12.1), the surface cloud actually embeds.
Item7(a) - unbounded allocation DoS (MINIMAL, NON-BREAKING):
PulsarRoundSigner.Finalize built the signer bitmap via
pulsarlib.CanonicalSignerSet + singletonBitmap, both of which size a
[]byte at (PartyID/8+1) from an attacker-controlled uint32 PartyID with
no upper bound. A single Round2 partial naming PartyID near
math.MaxUint32 drives a ~512MiB allocation. Fix, entirely consensus-side
(pulsarlib's CanonicalSignerSet signature is left untouched so v1.9.0 need
not change and nothing has to propagate):
- Finalize pre-filters the WHOLE partials slice, rejecting any
PartyID >= s.ValidatorSetSize with the new typed ErrPartyIDOutOfRange
BEFORE CanonicalSignerSet or any allocation. The whole slice (not just
the threshold-chosen subset) is checked so a hostile PartyID cannot
hide behind the first-threshold cut.
- singletonBitmap now takes validatorSetSize and bound-checks before its
allocation (defense-in-depth for any future direct caller).
- PulsarRoundSigner.ValidatorSetSize already existed as a declared field
for exactly this bound; it is now load-bearing.
Severity is LOW for cloud: the embedding's ingestLeg (driver.go) already
rejects any leg unless idx==PartyID and the author is a registered voter,
so PartyID is bounded upstream — this is library hygiene closing the gap
for any caller without that upstream guard.
New tests (dos_regression_test.go): oversized PartyID rejected on the real
Round2 -> Finalize path; rejected even when it would be excluded from the
chosen subset; in-range happy path still succeeds with a bitmap bounded by
ValidatorSetSize.
Item7(b) - structural Verify is not the finalization authority: confirmed
the shipped v1.35.30 surface is already SOUND — QuasarCert.VerifyUnderPolicy
(cert_policy_verify.go) is the policy-driven cryptographic authority and is
the path pkg/wire quantum finality calls (policies.go). The structural
QuasarCert.Verify(validators) has exactly one non-test caller, engine.go's
processBlock self-consistency gate on a cert THIS process just generated —
not a trust decision on adversarial bytes. Hardened its doc comment to say
so and to point at VerifyUnderPolicy, and added a regression lock
(verify_authority_test.go): a forged-but-shaped cert passes structural
Verify but is rejected by VerifyUnderPolicy (reaching and failing the BLS
signature check under a (PQ-off,Hybrid) policy) and by VerifyWithRealKeys /
VerifyWithRealKeysPolaris.
No go.mod/go.sum change. Built and tested green against the clean v1.35.30
dependency set (threshold v1.12.1 / pulsar v1.9.0) — no age go.sum
landmine on this surface.
Add protocol/quasar/pulsar -- the consensus-side adapter that wires
luxfi/pulsar's threshold ML-DSA RoundSigner into Quasar as a certificate
profile. Consensus consumes Pulsar; Pulsar never consumes consensus.
Decomplected ownership (the load-bearing boundary):
Consensus owns ORCHESTRATION: validator-set membership, committee
sampling, session binding, canonical nonce selection, QC aggregation,
signer bitmap, ConsensusCert assembly, retries, fallback, slashing.
Pulsar owns CRYPTO: the two-round shape (Round1 binds the nonce,
Round2 emits a proof-carrying z partial), z aggregation mod q, public
hint recovery (FindHint over w' = A*z - c*t1*2^d), and the FIPS 204
ML-DSA signature.
PulsarRoundSigner implements pulsar.RoundSigner (Round1/Round2/Finalize).
The secret-bearing FIPS 204 assembly stays package-private to luxfi/pulsar
and is injected as a pluggable core; until a reviewed Boundary-Cleared /
Carry-Elimination signer ships (pulsar.ProductionBCCSigningReady), Finalize
fails closed -- it returns a structurally-complete ConsensusCert with an
EMPTY signature and ErrProfileNotReady, and the orchestrator falls back to
Corona. No secret residual is ever opened on this path.
This forward-ports the profile onto the v1.35 release line. It consumes the
pulsar v1.9.0 pkg/pulsar package already pinned on this line -- no go.mod /
go.sum change, no dependency bump, and the new import adds zero transitive
sums. Purely additive: six new files, no existing file touched, so no
existing exported consensus symbol changes and no other consumer is affected.
The strict-PQ triple gate (engine.go generateCert: BLS + Corona + ML-DSA) is
untouched and still refuses to finalize without all three legs.
Co-authored-by: Hanzo Dev <[email protected]>
The node v1.34.12 canary crash-looped:
panic: threshold: scheme BLS already registered
Double registration: consensus quasar.go still blank-imported the OLD
github.com/luxfi/crypto/threshold/bls (whose init() registers BLS), while
node's warp/signature.go imports the NEW github.com/luxfi/threshold/scheme/bls
(also registers BLS). Both in one binary -> RegisterScheme panics.
The BLS Scheme impl moved crypto/threshold/bls -> luxfi/threshold/scheme/bls
in the threshold consolidation; every OTHER consumer repointed, but this one
blank import in quasar.go was missed and shipped through v1.35.22.
Repoint it. Verified: crypto/threshold/bls leaves the quasar+engine dep
closure entirely (go list -deps: 0), only threshold/scheme/bls remains (1) —
one registration path, no panic. Builds against crypto v1.19.26's interface
(the BLS interface never moved; only the impl did).
Co-authored-by: Hanzo Dev <[email protected]>
Move the consensus Quasar PQ integration to the dealerless threshold-signature
stack (RSS keygen + no-reconstruct signing + dealer ripped). Refresh the
re-tagged luxfi/pq v1.0.3 go.sum entry.
Fix a latent bug the bump exposed: ValidateMithrilSigningCommittee documented +
tested an N<=6 consensus small-committee cap but only called rss.ValidateCommittee
to enforce it. rss loosened its gate from a hard N<=6 to the norm bound
(tau*C*eta < gamma2, up to N<=63) at dkg v0.3.4+, so (2,7) — norm-viable — began
passing and the consensus policy cap silently vanished. Restored the explicit
N<=MithrilMaxCommittee cap (layered before the rss crypto bound), preserving the
small-committee security design (Avalanche subsampling carries chain safety;
Pulsar emits compact PQ evidence). Quasar suite green.
Adds REAL go test -bench measurements (M1 Max) of VERIFY latency, cert
SIZE, and SIGN cost for every Quasar finality lane, composed into the five
named policy tiers (BLS_FAST .. POLARIS_MAX). Drives the operator policy-tier
+ deployment decision with data, not estimates.
- bench_pq_policy_tiers_test.go: per-lane verify/sign sub-benchmarks over the
PRODUCTION verify functions and real crypto (Beam BLS, Pulsar TALUS compact,
Pulsar sampled r-of-m, Corona Ring-LWE, Magnetar-Quorum N SLH-DSA, P3Q direct
N ML-DSA) + a composed-tier summary table (verify ns, cert bytes, x-overhead
vs BLS) printed via fmt.Printf (go test truncates large b.Log in benchmarks).
- Widen 8 shared real-key test harness helpers from *testing.T to testing.TB
(the canonical Go idiom) so benchmarks reuse the exact production cert
builders; strict interface widening, zero behaviour/caller change.
No production code touched; test/bench-only. Suites green except the
pre-existing flaky quantum-bundle threshold tests (a different one fails each
run on clean main too).
Proves the Track-A claim end to end with real SLH-DSA-192s keys:
- independent FIPS-205 sigs -> MagnetarQuorumCert -> VerifyMagnetarQuorum
accepts (standalone + leg + full VerifyConsensusCert envelope);
- each inner record verifies under the STOCK FIPS-205 verifier (magnetar
VerifyCtx over circl/slhdsa) with no Lux machinery — the trustless,
independently-auditable wire-identity claim;
- tamper (flipped sig, re-committed), sub-threshold, and root-mismatch are
hard rejects; the BFT floor is the verifier's, not the cert's;
- cross-family diversity: an ML-DSA cert set is refused at BOTH build and
verify (ErrMagnetarNotHashBased) — a Module-LWE break can't satisfy the
hash-based leg;
- the STARK succinct seam fails closed (ErrMagnetarBackendNotAuditGated);
- suite dispatch can't cross lanes (P3Q suite rejected), hit an unknown
suite, downgrade the param set, or land on the wrong leg kind;
- POLARIS_HASH_PQ / POLARIS_MAX require the right legs and admit Magnetar
ONLY as the trustless rollup, NEVER a threshold sig;
- structural no-dealer/no-shared-secret: disjoint committees with fully
independent keypairs each verify; no shared public key anywhere.
All 16 green (53s).
Two new finality postures wiring the hash-based diversity lane:
POLARIS_HASH_PQ = Beam ∧ Pulsar ∧ Magnetar-Quorum
POLARIS_MAX = Beam ∧ Pulsar ∧ Corona ∧ Magnetar-Quorum
(Module-LWE ∥ Ring-LWE ∥ hash-based diversity)
The Magnetar leg is admitted ONLY as the trustless EvidenceMagnetarRollup
(independent per-validator FIPS-205 sigs; no dealer/DKG/shared secret), ONLY
under the policy's SLH-DSA param. Magnetar-Threshold (THBS-SE reveal-and-
aggregate, a signing-time TCB) is structurally excluded: SLH-DSA may not be
proven by a threshold-sig mode (I7), and no SLH-DSA threshold-sig mode exists.
POLARIS_MAX also extends the Corona allow-gate (previously STRICT-only).
- magnetarParam field (default SLH-DSA-192s) + WithMagnetarParam override.
- RequiredLegs / Allows updated for both postures; Pulsar required as the
compact threshold sig (the '∧ Pulsar'), Magnetar as the rollup.
Full quasar suite green (172s).
The Magnetar-Quorum relation: prove a >= policy-threshold WEIGHTED quorum of
INDEPENDENT validator SLH-DSA signatures each verify under stock FIPS-205 over
the same consensus subject M, every signer a committed validator-set member.
- VerifyMagnetarQuorum(policy, validators, cert, M, mc) — the standalone
public verifier the directive names; VerifyMagnetarQuorumLeg is the
LegEvidence dispatch form, wired into dispatchEvidence (EvidenceMagnetarRollup).
- Direct path (live today, trustless transparent aggregation): bind rollup
root -> raw SLH-DSA cert set; enforce the cross-family diversity invariant
(every inner record is SLH-DSA / FIPS-205, ErrMagnetarNotHashBased); then
COMPOSE the audited VerifyWeightedSigSet (N stock FIPS-205 verifies +
weighted-Merkle validator-set binding + BFT threshold floor + envelope
position). No dealer / DKG / shared secret — structurally independent keys.
- STARK path (AssumptionPQSuccinct): the OPTIMIZATION SEAM. Pin the public
statement so an audited backend can only ever attest THIS relation, then
FAIL CLOSED (ErrMagnetarBackendNotAuditGated) — the raw set stays
challengeable via Direct. Succinctness makes the lane compact, not more
trustless. No classical-assumption path for a hash-based lane.
The full SLH-DSA-verify STARK circuit is deferred; the trustless lane is the
Direct transparent-aggregation path, with the p3q-succinct proof clearly
labelled as the follow-on compaction (still trustless — no dealer/shared-secret).
Magnetar-Quorum (Track A) is the trustless-TODAY hash-based PQ finality lane:
validators hold INDEPENDENT ordinary FIPS-205 SLH-DSA keypairs (no DKG, no
dealer, no shared seed) and sign the same consensus subject; a P3Q rollup
proves a >= policy-threshold WEIGHTED quorum of those independent signatures
verified under the STOCK FIPS-205 verifier. It sidesteps threshold SLH-DSA
entirely — trustless the moment the rollup relation is sound.
This commit adds the value-level vocabulary and the wire type:
- MagnetarQuorumCert{SuiteID, RollupRoot, CertSet, Proof} + deterministic,
fail-closed codec (magnetar_codec.go), mirroring the P3Q ML-DSA pattern.
- MagnetarRollupRoot + magnetarPublicStatement domain-separated commitments.
- BuildMagnetarQuorumCert (Direct constructor) enforcing the cross-family
diversity invariant: every signer record must be SLH-DSA (FIPS-205).
- EvidenceMagnetarRollup evidence mode (own primitive, not bolted onto Pulsar).
- EvidenceMagnetarP3QSLHDSARollup kind 'magnetar-p3q-slhdsa-rollup' + lane
mapping to (LegMagnetarSLHDSA, EvidenceMagnetarRollup).
- Suites Lux-Magnetar-SLHDSA{192s,256s}-Direct-v1 + -192s-STARK-v1 (succinct
seam). NO classical-assumption suite — a hash-based diversity lane must
never rest on a pairing.
Composes the audited, scheme-generic WeightedQuorumCert (which already
dispatches SLH-DSA records to stock magnetar.VerifyCtx); verifier wired next.
Composable lane-verifier closures mapped onto the existing Quasar
evidence-mode table:
FAST = Beam only -> PolicyBLSFast
HYBRID_PQ = Beam AND VerifyPulsarSampled -> PolicyHybridPQCheckpoint (DEFAULT)
PQ_ROOT = P3Q >=2/3 weighted ML-DSA(+Beam)-> PolicyRecoveryMode
The policy owns ONLY the tier->required-lane AND structure; it constructs
no M and re-implements no Beam/P3Q. Each lane is a fully-bound closure the
caller wires (PulsarSampledLane adapts VerifyPulsarSampled). Fail-closed:
a missing required lane is ErrTierLaneMissing, never an implicit pass; the
first failing lane's error surfaces unchanged so errors.Is sees the
underlying cause. 8 tests incl. real end-to-end propagation of
ErrSampledPlanMismatch / ErrInsufficientCommittees through the tier.
Trust-minimised by re-derivation: the verifier re-derives the committee
plan from the unbiasable seed (so a cert naming an unsampled committee is
rejected) and re-derives the subject M from that plan's committeePlanHash
(so a cert whose committees signed any other M is rejected). The six
checks: (a) committeePlanHash bound into the re-derived M; (b) every
counted committee InPlan; (c) each a dealerless Mithril key-era (mode +
(t,n) + PubKeyHash binding the resolved key); (d) each sig stock-verifies
via verifyPulsarLeg (pulsarwire.VerifyBytes, unmodified FIPS-204) over M;
(e) >= r DISTINCT independent committees; (f) all over the same M.
Per-committee failures are EXCLUSIONS (offline-like) not whole-cert
errors — the r-of-m model tolerates up to m-r. CommitteeKeyResolver is
the one trusted input (registry of dealerless-DKG-activated keys); the
cert binds itself to its exact key bytes. 15 tests: positive + full-m +
r-of-m boundary + liveness slack + adversarial battery (out-of-plan,
wrong-M, non-dealerless, pubkeyhash mismatch, wrong (t,n), duplicates,
structural rejects, nil guards, wrong finality position).
The one canonical subject every r-of-m sampled Mithril committee signs.
Binds the finalized position (chainID/height/round/blockID/stateRoot),
the Beam QC over that block (beamQCHash, non-transferable across QCs),
the P-Chain-pinned signer set (signerSetID/pChainHeight), the policy
posture, and the frozen committeePlanHash — so committees are
un-reselectable after the block is seen. Distinct subject domain
('Lux/PulsarSampledSubject/v1') makes a sampled-committee signature
non-transferable vs the envelope single-committee Pulsar leg. Full
field-binding + non-transferability tests.
Deterministic, verifiable stake-weighted committee selection from an
UNBIASABLE seed (hash of already-final consensus state, never a
proposer-grindable value), with the committeePlanHash that commits the
entire sampling plan for binding into the finality subject M. m=12
committees of n=8 stake-weighted-without-replacement members each
(Pulsar-HYBRID-PQ-v1), SHAKE256 DRBG with rejection-sampled unbiased
draws. Fail-closed params + tests (determinism, unbiasable-inputs-change-
plan, distinctness, stake-proportionality, fail-closed).
The ML-DSA analogue of Avalanche's repeated-small-sample path: r-of-m
dealerless Mithril committees each sign the SAME finalized subject M;
post-quantum confidence is committee-capture probability accumulated over
repetition r (~p^r), NOT one committee as a finality quorum. Compact: r~8-16
ordinary FIPS-204 signatures, never 1000+. Cert value types only -- sortition,
subject, verify, policy decomplected into sibling files.
The dealerless RSS Pulsar (ML-DSA) leg is only stock-FIPS-204-signable for a
small committee (C(N,N-T+1) replicated shares grow combinatorially). Adds the
consensus-side bound — delegated to luxfi/dkg v0.3.0 rss.ValidateCommittee for
ONE definition — plus ClampMithrilCommitteeSize and the grouped-epoch sortition
cap, with the full security argument: consensus quorum != signing committee, so
broad security comes from Avalanche subsampling + rotating committees + 2/3
group quorum + dual-PQ AND-mode, never from committee size. Default group size
(3) already complies; the cap only constrains over-large configs. quasar suite
green (77s).
NOTE: pre-existing luxfi/[email protected] go.sum mismatch (force-moved tag) breaks the
whole-repo 'go build ./...' on main independent of this change (proven on clean
HEAD); the quasar package and its full test suite are green.
pulsar v1.2.0 removes the small-committee threshold-signer surface
(NewThresholdSigner / ThresholdSigner / Round1Message / Round2Message /
Combine) in favour of the TALUS no-reconstruct BCC/CEF path. The Pulsar
leg of a Quasar cert verifies under the UNMODIFIED FIPS-204 verifier, so
a single-key signature under the group key is valid leg evidence — no
DKG, no ceremony is needed to GENERATE valid leg bytes for a
cert-composition test.
Replace signPulsarThresholdLeg (deleted threshold ceremony) with
signPulsarLegStock: GenerateKey + Sign via threshold/protocols/pulsar's
stock single-key path, returning the same *pulsar.Signature /
*pulsar.PublicKey alias types that ComposePolaris and
pulsarGroupPK.MarshalBinary already consume. The negative tests
(removed / tampered / missing-key Pulsar leg) still reject, so the leg
bytes remain load-bearing, not a stub.
Corona keygen (threshold/protocols/corona.GenerateKeys) is unchanged —
the trusted-dealer fixture name survives in the threshold corona adapter,
so no rename was needed in these consensus tests.
Production build unaffected (0 non-test refs to the removed APIs).
go test ./protocol/quasar/... ./protocol/auth/... ./config/... green;
go vet ./protocol/quasar/ clean.
The compact group-key era was named for a place (Pulsar) when it is a
generic value serving every threshold lane, scheme-qualified. Drop the
Pulsar prefix and generalise the verifier so ONE type + ONE entry point
covers both the Pulsar (FIPS-204 ML-DSA) and Corona (Ring-LWE Ringtail)
lanes. Pure decomplect: same behaviour, value-not-place naming.
- Type: PulsarKeyEra -> KeyEra (file pulsar_key_era.go -> key_era.go)
- Field: MLDSAPubKey []byte -> GroupPubKey []byte (scheme-interpreted via
SchemeID; not ML-DSA-specific). The unrelated Validator.MLDSAPubKey
(*mldsa.PublicKey, a per-validator identity key) is left untouched.
- Registry: PulsarKeyEraRegistry -> KeyEraRegistry (lane-agnostic; holds
Pulsar and Corona eras alike), NewPulsarKeyEraRegistry -> NewKeyEraRegistry.
Generalised verifier dispatch:
- VerifyThresholdLeg(ev, M, era) is the ONE compact-threshold verify entry
point. It resolves the era's lane through the suite registry
(era.eraSuite, Pulsar|Corona only) and routes the group-signature check
to that lane's boring verifier: pulsarwire.VerifyBytes for Pulsar,
coronaThreshold.Verify for Corona (via new verifyCoronaGroupKeyLeg, which
decodes the era's wire-byte group key and reuses verifyCoronaLeg). Both
the era scheme and the evidence suite are resolved through the registry
and must name the SAME lane + param set, so no suite string can reach the
wrong verifier (reuses LookupSuite/resolveSuiteForLane lane safety).
- VerifyPulsar is retained as a thin Pulsar-suite wrapper that refuses any
non-Pulsar era at the door, then defers to VerifyThresholdLeg.
Tests: existing boring-verifier + suite-dispatch-safety (VerifyPulsar) and
dualpq tests hold unchanged; KeyEra registry test now proves lane-agnostic
acceptance (corona era registers, non-threshold Beam refused); new
TestCompact_VerifyThresholdLeg_CoronaLane proves a corona-suite era
verifies via the corona path through the same KeyEra/dispatch with a real
multi-node Ring-LWE signature, and that a pulsar suite can never reach the
corona verifier.
GOWORK=off go build ./... green; go test ./protocol/quasar/ green.
SECURITY FIX (self-review): the first P3Q direct verifier checked N ML-DSA
signatures and summed their CLAIMED weights but never bound the (validatorID,
pubkey, weight) leaves to the committed validator set — a zero-stake attacker
could forge a rollup of fresh keys with threshold weight and pass.
The 'MLDSACertSet' (independent per-validator ML-DSA certs + weighted quorum) IS
the existing WeightedQuorumCert (EvidenceWeightedSigSet), already bound to
validators.Root() via weighted-Merkle membership. P3Q now COMPOSES it: the
Direct path = rollup-root commitment over the marshalled WeightedQuorumCert,
then the audited validator-set-bound weighted-sig-set verify (membership + N
FIPS-204 verifies + threshold floor). No reinvented per-leaf verification, no
gap. Succinct STARK/Groth16 paths unchanged (audit-gated; classical caveat).
Also trim 4 unused speculative helpers (LaneFor + three IsPostQuantum methods) —
the suite registry is the single mapping authority.
Tests reworked to wrap a real fourMLDSA WeightedQuorumCert; sub-threshold and
corrupt-cert-set cases now prove the inner validator-set-bound verify runs.
- STRICT_QUASAR (Beam ∧ Pulsar ∧ Corona) over one M verifies with real BLS
aggregate + FIPS-204 ML-DSA + multi-node Ring-LWE threshold; missing Corona
rejects (AND-mode).
- Policy table enforced: BLS_FAST Beam-only; HYBRID accepts Pulsar OR P3Q;
RECOVERY accepts P3Q and REFUSES the Pulsar threshold mode; STRICT refuses
the P3Q mode.
- COMPACTNESS: Beam aggregate size invariant under committee growth; compact
checkpoint footprint is O(1) and 957x smaller than the naive O(N) 1000-raw-
ML-DSA-cert object at N=1000 (3466 B vs 3.32 MB).
- Boring VerifyPulsar + suite dispatch safety: wrong-era / suite-mismatch /
cross-lane suite / unknown suite / corrupt-sig / replayed-M each reject with
a distinct typed error; exhaustive suite-registry lane invariant; no suite
string reaches the wrong verifier.
- P3Q fallback: Direct path verifies a rollup root and rejects a tampered root,
a corrupt leaf, and sub-threshold weight; STARK gated; Groth16 refused unless
the policy opts into classical proof assumptions (then still backend-gated).
- PulsarKeyEra registry register/lookup/conflict; one-M property
(QuasarFinalityMessage == consensusCertMessage).
Four typed compact evidence kinds, a PulsarKeyEra registry + deliberately
boring VerifyPulsar, the named policy table (BLS_FAST / HYBRID_PQ_CHECKPOINT /
STRICT_QUASAR / RECOVERY_MODE), and the P3Q ML-DSA-rollup fallback leg — all
layered on the existing decomplected ConsensusCert envelope (LegKind x
EvidenceMode orthogonal axes), reusing not rewriting.
- compact_evidence.go : EvidenceKind vocabulary (beam-bls / pulsar-threshold-
mldsa / corona-ringtail / p3q-mldsa-rollup) + lane mapping + the suite-ID ->
verifier dispatch registry. No suite string can reach the wrong verifier;
suites carry the param set (ML-DSA-44/65/87).
- pulsar_key_era.go : PulsarKeyEra (one compact ML-DSA group key per validator-
set era) + VerifyPulsar (boring: normal FIPS-204 verify under the era key) +
the era registry. Production trust model (talus/ceremony/tee) is audit-only,
orthogonal to verification.
- quasar_finality.go : the ONE canonical finality message M; consensusCertMessage
now delegates to it, so all lanes provably sign byte-identical M.
- p3q_rollup.go / p3q_codec.go : the P3Q fallback as its own primitive (own
mode/verifier/codec). Direct path re-verifies the raw MLDSACertSet (PQ-safe,
O(N), challengeable); succinct STARK/Groth16 paths audit-gated; classical
proof assumption enforced via policy opt-in.
- cert_policy_table.go : the four postures; the Pulsar-OR-P3Q is a MODE gate on
one required leg KIND.
- consensus_cert.go : KeyEraID + StateRoot bound into M; EvidenceP3QRollup mode
wired into dispatch.
Beam/Pulsar/Corona stay O(1) (one sig each); P3Q is fallback only. Raw
1000-cert-sets are never the normal finality object.
Add the canonical parallel post-quantum finality posture: every cert carries
a Pulsar (Module-LWE / FIPS-204 ML-DSA) leg AND a Corona (Ring-LWE, dealerless)
leg, in parallel, AND-mode (both required to finalize). Security is the safest
surviving leg: an adversary must break BOTH the Module-LWE leg and the Ring-LWE
leg in the same round (orthogonal hardness, conjunctive acceptance).
dualpq.go (production):
- DualPQRequiredLegs(paramSet): the canonical {Pulsar, Corona} AND-mode
required-leg set (matches config CertModeStrict+CertVariantStrict).
- ComposeDualPQEvidence(...): pure-function composition of the two
threshold-sig leg evidences from a real Corona threshold signature + a
real Pulsar FIPS-204 signature over the same envelope message. Composition
over inheritance — wraps each primitive's output, extends neither.
consensus_cert_dualpq_test.go (multi-node proof):
- Corona leg: genuine multi-node two-round t-of-n threshold ceremony — N
separate single-share Signers, Round1/Round2 over an explicit message bus,
aggregated by one node from Round2 DATA (never shares).
- Pulsar leg: real FIPS-204 ML-DSA-65 signature accepted byte-for-byte by
the on-chain verifier (verifyPulsarLeg -> pulsarwire.VerifyBytes).
- Proves: AND-mode (both legs required + verify), sub-quorum on EITHER leg
fails finality, corrupt either leg fails, composer refuses a half cert.
- Single-share invariant: compile-time witness that the only Corona signing
constructor takes EXACTLY ONE *KeyShare; aggregator takes Round2 DATA.
Permissionless-safe today via the dealerless Corona leg in AND-mode; the
Pulsar leg's no-reconstruct t-of-n threshold production is documented as
blocked on a poly-vector secret-share type (ML-DSA s1 is non-linear in the
GF(257) seed-share) — see luxfi/pulsar BLOCKERS.md PULSAR-V12-PARALLEL-PQ.
No fakes: the Pulsar leg signature is a real FIPS-204 group-key signature,
not a claimed threshold ceremony.
Add scripts/no-trusted-dealer-guard.sh: greps non-test .go in the consensus
tree for NewTrustedDealer / func GenerateKeys / GenerateDualKeys /
GenerateThresholdKeys / GenerateDualThresholdKeys and exits non-zero if any
land in production code. Wired as a prerequisite of `make test` and
`make security` so the regression that dropped the test-only relocation
(97b2dfe7c) on a merge cannot silently return.
Also delete the stale polaris.go comment "Produced by the
pulsar.ThresholdSigner ceremony" — that v0.1 reconstruct-then-sign Combine
path was ripped; the leg is filled by the corrected lattice kernel.
GenerateDualKeys / GenerateThresholdKeys / GenerateDualThresholdKeys sampled
the full secret in one process (BLS NewTrustedDealer + coronaThreshold.
GenerateKeys) — a trusted-dealer keygen sitting in non-test code (quasar.go).
Every real caller is a package-quasar _test.go (dual_threshold, cert_size,
triple_sign, cert_roundtrip, witness_fixtures, comprehensive); none is
production.
Production epoch/genesis keying already runs the dealerless Pedersen DKG via
keyera.Bootstrap (EpochManager.InitializeEpoch, epoch.go) — unchanged. Moved
the three helpers verbatim into keygen_testsupport_test.go (package quasar) so
NO non-test code in the consensus engine can route keying through a trusted
dealer. Pairs with the wave_signer deletion (5fe753c42): consensus now has zero
non-test reconstruct-then-sign and zero non-test trusted-dealer keygen.
Verified: GOWORK=off go build ./... exit 0; go vet clean; gofmt clean;
go test ./protocol/quasar/ (Dual/Triple/Cert/Witness) ok.
(cherry picked from commit 97b2dfe7ceac24404395c9c2fdb08bc91fc39572)
RoundSigner/RunRound in protocol/quasar/wave_signer.go had ZERO non-test
callers. It was a thin per-round adapter wrapping the pulsar v0.1
reconstruct-then-sign ceremony; consensus's real PQ-threshold signing
lane is corona (epoch.go), and the cert's pulsar leg is verify-only on
the live path. Deleting it removes consensus's only live invocation of
pulsar's reconstruct-then-sign (Combine) path.
- Delete wave_signer.go + wave_signer_test.go.
- cert_policy_verify_test.go / polaris_compose_test.go: the Pulsar leg
in these cert-composition tests was produced by RoundSigner. Replace
with a test-only ceremony helper (pulsar_leg_test.go) that runs the
same canonical t-of-n ceremony directly over the DKG outputs the tests
already build — the leg stays a real threshold signature (no stub),
verifying under unmodified FIPS 204 ML-DSA via pulsar.VerifyCtx.
- Drop now-unused ids/prism/context imports from both tests.
Verified: GOWORK=off go build ./protocol/quasar green; cert-composition
+ policy tests pass (real ceremony exercised); full -short suite green.
corona v0.7.9 (commit 7c102d2, a descendant of the old single-return
4d9334ee) re-added the error return on Signer.Round1 as kernel-boundary
hardening on SignRound1. consensus quasar still called it single-value
(with now-stale 'error return was removed upstream' comments), so node
v1.30.40 failed to compile: 'assignment mismatch: 1 variable but Round1
returns 2 values' at epoch.go:679/1052, grouped_threshold.go:510,
quasar.go:269. Thread the error through all four production sites
(enclosing funcs already return error) and the 11 quasar test sites.
Forward-only fix; corona v0.7.9 is the intended current API.
The chain engine pins a block's weighted validator set to a P-CHAIN epoch
height: the set-root commitment, the 2/3-by-stake tally, AND per-voter pubkey
resolution all read the height-indexed validators.State at that ONE height
(MEDIUM-1/CRITICAL-1/RESIDUAL-B). The height is captured off the VM block at the
engine boundary (pChainHeightOf) into Block.pChainHeight, read back by
epochHeightLocked, and stamped into every VotePosition's set-root. When a block
exposes no PChainHeight the read falls back to height 0 (genesis set): non-empty,
identical on every node, <= current -> finality stays LIVE. The node-layer
wrapper (separate repo) delivers the REAL height so post-genesis staking-change
epochs track the LIVE set.
VerifyVote/Weight/TotalStake/ValidatorSetRoot now all take epochHeight so the
four reads share one height; the engine threads it from the block.
Adds exported TrackOwnProposalForTest: a cross-module test seam (the node b2 test
lives in another module) that inserts a verified own-proposal exactly as
buildBlocksLocked does and returns the bound position. It captures pChainHeightOf
off the supplied VM block and NEVER force-finalizes -> the real alpha-of-K cert
path is exercised end to end.
TDD: quorum_pchain_epoch_finality_test.go proves (A) a 2/3-stake epoch quorum
finalizes through the real source path, (B) the engine reads the set at the
P-chain epoch height not the value height, (C) a validator absent from the
current map but present at the epoch still has its vote verified (RESIDUAL-B),
(D) an empty epoch fails closed. Full engine/chain + quasar suites green
(CGO_ENABLED=0).
MEDIUM (epoch binding): stake-weighted finality must be measured at the
cert-position epoch, not at verification time. Bind the active weighted
validator-set commitment into VotePosition.ValidatorSetRoot and fold it into
CanonicalVoteMessage + the wire codec, so a cert is cryptographically pinned to
the exact set it was certified under: re-presenting an epoch-E cert as
certifying under a different epoch's set fails clause-6 signature verification,
and a later stake change cannot retroactively flip an already-correct cert.
Mirrors quasar.ConsensusCert.ValidatorSetRoot. New optional ValidatorSetRootSource
(WithValidatorSetRoot / NetworkConfig.ValidatorSetRoot), stamped in
blockPositionLocked; nil source => ids.Empty => behavior identical to before
(backward-safe for fixed-set chains). QuorumCertVersion bumped 1->2 so a
mixed-version cert fails loudly (version + signature) rather than mis-parsing —
a deliberate forward-only break (the set upgrades together in one cascade).
TDD: TestEpochBinding_{CrossEpochCertRejected,StakeReadAtCertHeight,RoundTripPreservesRoot}
(proven to fail without the canonical-message binding).
INFO-2 (SyncState monotonic guard): ChainConsensus.SyncState bypasses
markFinalizedLocked by design (import is an out-of-band reconcile), so the
monotonic invariant is re-asserted explicitly — a backward import (height below
the already-finalized height) is refused (ErrSyncStateRegression) and leaves
finalized state untouched; an equal-height re-import must agree on the block;
forward + empty-reset still succeed. SyncState now returns error; the sole caller
(Transitive.SyncState) propagates it and does NOT partially mutate on refusal.
TDD: TestSyncState_{RefusesBackwardRegression,EqualHeightDifferentBlockRefused,
ForwardImportAdvances,EmptyResetAllowed} (proven to fail without the guard).
INFO-3 (BFT message): ErrKTooLowForMainnet/Testnet now state the real bound
(2*AlphaPreference-K >= floor((K-1)/3)+1 with f>=3 / f>=1) instead of a bare
"K>=11" magic number. Sentinel-based assertions unaffected.
INFO-1 (dead code): delete the VerkleWitness apparatus (witness.go + its tests +
the VerkleWitness funcs in comprehensive_test.go + the verifyIPAOpening test) —
zero non-test callers; verifyBLSAggregate was a deserialize-only (no-pairing)
forgery sink if ever revived. The live deserialization path that matters is
crypto/bls.SignatureFromBytes (fixed + tested in the crypto module);
TestVerifyBLSAggregate_C1_ActualDeserialization was proven green in CGO_ENABLED=0
against the crypto fix BEFORE this deletion removed it.
pulsar v1.1.1->v1.1.5, magnetar v1.2.0->v1.2.3, threshold v1.9.4->v1.9.9,
corona v0.7.6->v0.7.9 (indirect). The ConsensusCert envelope + all legs now
verify against the just-finalized PQ crypto. corona/threshold.Round1 added an
error return; adapted 4 production + 12 test call sites. quasar suite ok, gate PASS.
The QuasarCert verifier made BLS mandatory and PQ legs optional/
cert-byte-driven: a CRQC that broke BLS-12-381 could ship a BLS-only
cert (omit every PQ leg) and verify true, while a legitimate pure-PQ
(no-BLS) cert was wrongly rejected. config.CertPolicy.RequiredLegs()
(the canonical leg-set authority) had zero production callers.
Wire it in:
- New cert_policy_verify.go: VerifyUnderPolicy(message, CertPolicy, CertKeys)
is the canonical verifier. For every leg in RequiredLegs() it rejects
unless the leg is BOTH present in the cert AND backed by a supplied key,
BEFORE any signature math — leg presence is policy-driven, not
attacker-controlled cert bytes. BLS is required iff the policy includes
it (Variant=Hybrid); a Strict-variant policy yields a pure-PQ required
set so a no-BLS cert verifies. Identity rollup is required under any PQ
policy (downgrade-resistant), present-implies-verify otherwise.
- Decomplect leg crypto into verify{BLS,Corona,Magnetar,MLDSARollup}Leg
helpers; VerifyWithRealKeys / VerifyWithRealKeysPolaris reimplemented on
them with the BLS-mandatory bug fixed (BLS required iff key supplied;
pure-PQ allowed; each PQ leg required iff its key supplied — an adversary
cannot strip a leg the verifier was configured to check).
- Migrate callers to policy intent: engine/pq gains SetCertPolicy +
policy-driven verify path; pkg/wire QuantumPolicy enforces requireRT
(Corona key mandatory) when set.
Tests (real crypto, no stubs): leg-removal negatives for each required
leg reject; missing-key-for-required-leg rejects; pure-PQ cert verifies
under Strict-variant policy AND via VerifyWithRealKeysPolaris(nil BLS);
Red PoC (BLS-only cert + PQ keys configured) now rejected. Existing
ComposeAllThreeSchemes / cert roundtrip / CertBundle suites unchanged.
Pre-existing unrelated failure TestVerifyBLSAggregate_C1_ActualDeserialization
(witness_test.go) fails identically on origin — out of scope.
Rename the Z-Chain parallel-witness rollup proof system from
classical Groth16/bn254 (pairing-based, quantum-breakable) to the
post-quantum STARK/FRI path (P3Q: Plonky3 fork, cSHAKE256 Merkle over
Goldilocks, FRI; no KZG, no pairings, no trusted setup).
This is a comment/identifier rename only: QuasarCert.MLDSARollup is
opaque bytes and the consensus module has no proof-system dependency,
so the cert composition + ZWitnessProducer interface are unchanged.
The verifier and prover live in chains/zkvm.
- witness_producer.go: ZWitnessProducer doc + MLDSAGroth16->MLDSAStark;
DisabledZWitnessProducer doc (STARK prover, AIR constraint count).
- types.go / polaris.go: MLDSARollup leg doc 'STARK/Groth16'->
'strict-PQ STARK/FRI (P3Q)'; wire-layout comment.
- engine/pq/consensus.go: FinalityEvent.MLDSARollup doc.
- pkg/wire/candidate.go: PolicyQuantum/PolicyPZ/WitnessZ docs.
QuasarCert composition gate test (TestQuasarCert_ComposesAllThreeSchemes)
still passes. No wire-format change.