Files
zeekay b4c8129831 fix(quasar/pulsar): bound PartyID before bitmap alloc; lock structural-Verify invariant
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.
2026-07-08 07:32:00 -07:00

52 lines
2.6 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// errors.go -- typed errors for the Pulsar cert profile. These are the
// fallback / retry signals the Quasar orchestrator matches against to decide
// whether to fall back to Corona, retry with a fresh nonce, or slash.
package pulsar
import "errors"
var (
// ErrNonceCertPoolEmpty signals the background NonceCert pool is empty.
// The orchestrator MUST fall back to Corona (or retry once the pool
// refills); the hot path NEVER generates a nonce inline.
ErrNonceCertPoolEmpty = errors.New("quasar/pulsar: NonceCert pool empty; fall back to Corona")
// ErrProfileNotReady signals the secret-bearing FIPS 204 signature-assembly
// core is not registered (pulsar.ProductionBCCSigningReady() is false: the
// BCC/CEF ZK proofs are fail-closed). Finalize still returns a
// structurally-complete ConsensusCert with an EMPTY Signature; the
// orchestrator falls back to Corona.
ErrProfileNotReady = errors.New("quasar/pulsar: BCC signing core not registered; fall back to Corona")
// ErrSessionFieldUnset rejects a PulsarSession with an all-zero
// security-relevant root (unbound field). Signing under it would allow
// cross-round replay.
ErrSessionFieldUnset = errors.New("quasar/pulsar: session has an unset security-relevant field")
// ErrNonCanonicalNonce signals the supplied nonce id is not the canonical
// pool selection for this session. Every signer recomputes the canonical
// index and refuses a non-canonical nonce (anti-grind).
ErrNonCanonicalNonce = errors.New("quasar/pulsar: non-canonical nonce for session")
// ErrInsufficientShares signals fewer collected shares than the threshold.
ErrInsufficientShares = errors.New("quasar/pulsar: fewer shares than threshold")
// ErrNoNonceCert signals Round2/Finalize were called without a bound
// Round1 (no NonceCert).
ErrNoNonceCert = errors.New("quasar/pulsar: no bound NonceCert (call Round1 first)")
// ErrPartyIDOutOfRange signals a partial names a PartyID that cannot
// belong to the configured ValidatorSetSize. Finalize rejects such a
// partial BEFORE any PartyID-sized bitmap allocation: pulsarlib's
// CanonicalSignerSet / singletonBitmap size a []byte at (PartyID/8+1),
// so an unbounded attacker-controlled uint32 PartyID would drive an
// unbounded (~512MiB from one MaxUint32) allocation — a memory-
// exhaustion DoS. Bounding PartyID here closes it without touching the
// pulsarlib CanonicalSignerSet signature.
ErrPartyIDOutOfRange = errors.New("quasar/pulsar: partyID out of validator set range")
)