Files
consensus/config/redteam_locked_profile_test.go
zeekayandHanzo Dev 4abf37c952 config: K is a number of draws, not a headcount
The live guard refused to start a chain whose K was below the number of
validators that existed. But sampling is stake-weighted: a validator is drawn in
proportion to what it staked, however many validators there are, and K only
sets how many draws a poll makes. Tying K to the headcount gave a validator
holding a billionth of the stake the same standing as one holding a fifth, and
refused to boot a producing chain because a small staker had joined.

The guard keeps what makes a committee safe — the overlap bound and at least
single-fault tolerance — and stops counting heads. The tier floor stays a
reported target: refusing to start does not add validators, it pins every node
on whatever it last booted.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-16 11:42:58 -07:00

370 lines
16 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// redteam_locked_profile_test.go — adversarial review of the strict-PQ
// locked-profile architecture as it lands in this package. Each F##
// finding is a separate test; a failing test IS the proof-of-finding
// and MUST NOT be silenced without the corresponding fix landing in
// the same diff.
//
// Build prerequisite: the package as found by red-team is build-broken
// — F51 below. Tests in this file are written against the post-fix
// schema; until the package builds the tests cannot run, which is
// itself a critical finding (you cannot ship code that doesn't build).
package config
import (
"reflect"
"testing"
)
// =============================================================================
// F52 — ChainSecurityProfile.Validate exists, but the canonical
// StrictPQ constructor that should satisfy it cannot be called
// (strictPQProfile constant not defined; profiles.go missing).
// =============================================================================
//
// SEVERITY: critical
//
// security_profile.go:724 reads `p := strictPQProfile; return &p`
// but the constant strictPQProfile is not defined anywhere in the
// package. The locked-profile architecture's single source of truth
// for "this is what mainnet runs" is therefore vapourware. Any caller
// that thinks they have the canonical profile in hand is actually
// holding a nil-dereference panic waiting to happen.
//
// FIX: write profiles.go that defines:
//
// var strictPQProfile = ChainSecurityProfile{ /* all fields */ }
// var permissiveProfile = ChainSecurityProfile{ /* all fields */ }
// var fipsProfile = ChainSecurityProfile{ /* all fields */ }
//
// Then call MustComputeHash on each at init time and write the
// returned hash back into ProfileHash so the constant carries its
// own pinned hash for genesis comparison.
func TestF52_CanonicalProfilesExist(t *testing.T) {
// We try to obtain each canonical profile; any panic / nil here
// is the finding's proof. Use defer/recover to capture the panic
// because StrictPQ() panics when strictPQProfile is missing
// (it's a typed-undefined-identifier compile error in fact, so
// this test would not link; but assuming it links the panic check
// is the runtime guard).
defer func() {
if r := recover(); r != nil {
t.Fatalf("StrictPQ() panicked: %v; finding F52 unfixed", r)
}
}()
p := StrictPQ()
if p == nil {
t.Fatal("StrictPQ() returned nil; finding F52 unfixed")
}
}
// =============================================================================
// F53 — ChainSecurityProfile.Validate enforces every locked-profile
// invariant per the doc-comment, but the existing security_profile_test.go
// uses old field names. The test will not compile against the new schema.
// =============================================================================
//
// SEVERITY: high
//
// security_profile_test.go has 14 tests that all reference
// `p.AllowedBackends` (renamed to `AllowedProofBackends`) and
// `p.ForbidClassicalSNARK` (renamed to `ForbidClassicalSNARKs`).
// Removing these tests is the wrong fix; the right fix is to update
// the tests to the renamed fields and ensure the renames don't cause
// the canonical strict-PQ profile to fail Validate at runtime.
//
// FIX: bulk-rename in security_profile_test.go (sed -i, not by hand):
//
// AllowedBackends → AllowedProofBackends
// AllowedFormats → AllowedProofFormats
// ForbidClassicalSNARK → ForbidClassicalSNARKs
// ErrProfileForbiddenPolicy → (define this var per F51 fix)
// ErrProfileForbiddenBackend → (define this var)
// ErrProfileForbiddenFormat → (define this var)
// ErrProfileStrictRequiresTransparent → (define this var)
// a.ComputeHash() != b.ComputeHash() → call .ComputeHash() with
// error handling, then compare hashes
func TestF53_ValidateRejectsZeroProfile(t *testing.T) {
// A zero-init ChainSecurityProfile MUST fail Validate. The
// architecture says "Zero value = INVALID (never default-secure)."
var p ChainSecurityProfile
if err := p.Validate(); err == nil {
t.Fatalf("zero-value ChainSecurityProfile passed Validate; finding F53 unfixed")
}
}
// =============================================================================
// F54 — Validate does not check that the canonical profile's
// HashSuiteID matches the FinalitySchemeID's expected hash kernel
// (Pulsar-M-65 sits in SHA3 family; ML-DSA in SHAKE256 which is also
// FIPS 202). Cross-axis compatibility is not enforced.
// =============================================================================
//
// SEVERITY: medium
//
// A profile that combines HashSuiteBLAKE3Legacy with FinalitySchemeID
// SigSchemePulsar65 (SHA-3 internal) is type-valid (both are non-zero
// and not forbidden in PQ mode per the current Validate). But Pulsar-M
// produces SHA3 internally; binding BLAKE3 at the transcript layer and
// SHA-3 at the signature kernel is a configuration inconsistency that
// audit pipelines should catch.
//
// FIX: add a `crossAxisCheck()` to Validate that asserts the
// (HashSuite, FinalityScheme) pair is one of the canonical sanctioned
// pairs.
func TestF54_Validate_CrossAxisHashSuiteVsFinality(t *testing.T) {
p := tryGetLuxStrictPQ(t)
if p == nil {
t.Skip("skipped: depends on F52")
}
p.HashSuiteID = HashSuiteBLAKE3Legacy
p.FinalitySchemeID = SigSchemePulsar65 // SHA-3 internal — mismatch
if err := p.Validate(); err == nil {
t.Errorf("Validate accepted BLAKE3 transcript + Pulsar-M-65 finality; finding F54 unfixed")
}
}
// =============================================================================
// F55 — Validate's "at least one Forbid* flag set" check (line 529)
// can be satisfied by setting any single Forbid bit; an operator can
// pass Validate while only forbidding (e.g.) dev-proofs and tolerating
// pairings + KZG + trusted-setup + classical-SNARK on a "strict-PQ"
// profile.
// =============================================================================
//
// SEVERITY: high
//
// security_profile.go:529 — the validator currently demands
// `!(ForbidPairings && ForbidKZG && ForbidTrustedSetup &&
// ForbidClassicalSNARKs && ForbidDevProofs && ForbidFallbacks)` to
// have AT LEAST ONE flag set. This is a defence against ALL-FALSE
// (which would obviously be wrong) but a profile that sets only
// ForbidDevProofs and leaves everything else false IS a valid strict-
// PQ profile per this check — and that is wrong. A strict-PQ profile
// MUST forbid every classical primitive.
//
// FIX: split the validator into "strict-PQ requires every Forbid* bit
// set true" (when ProfileID == ProfileStrictPQ) vs "permissive may
// have only ForbidFallbacks set" (when ProfilePermissive). The
// profile-class determines which forbids are mandatory.
func TestF55_Validate_StrictPQDemandsEveryForbidBit(t *testing.T) {
p := tryGetLuxStrictPQ(t)
if p == nil {
t.Skip("skipped: depends on F52")
}
p.ForbidPairings = false
p.ForbidKZG = false
p.ForbidTrustedSetup = false
p.ForbidClassicalSNARKs = false
// ForbidDevProofs and ForbidFallbacks remain true → at-least-one is satisfied
if err := p.Validate(); err == nil {
t.Errorf("strict-PQ profile passed Validate with ForbidPairings/ForbidKZG/ForbidTrustedSetup/ForbidClassicalSNARKs all false; finding F55 unfixed")
}
}
// =============================================================================
// F56 — ComputeHash sorts AllowedProofBackends and AllowedProofFormats
// before hashing (good), but does NOT sort other collection-shaped
// fields that may be added later. The "canonical encoding" is a
// must-remember rule when adding fields.
// =============================================================================
//
// SEVERITY: info
//
// FIX: factor the sort-and-emit into a helper `canonicalSlice` so any
// future []T field added to the profile must go through the helper
// and cannot accidentally hash in source-order.
func TestF56_ComputeHash_OrderInvariance(t *testing.T) {
p := tryGetLuxStrictPQ(t)
if p == nil {
t.Skip("skipped: depends on F52")
}
q := *p
// Reverse the allow-lists in q (post-fix names).
q.AllowedProofBackends = reverseBackends(p.AllowedProofBackends)
q.AllowedProofFormats = reverseFormats(p.AllowedProofFormats)
pHash, pErr := p.ComputeHash()
qHash, qErr := q.ComputeHash()
if pErr != nil || qErr != nil {
t.Fatalf("ComputeHash errors: p=%v q=%v", pErr, qErr)
}
if pHash != qHash {
t.Errorf("ComputeHash is order-dependent; AllowedProofBackends/Formats must be sorted before hashing; finding F56 unfixed")
}
}
// =============================================================================
// F57 — Validate is called inside ComputeHash; this means a profile
// that fails Validate at boot cannot produce a hash. But the boot path
// loads the profile from genesis JSON BEFORE Validate is called. If
// the genesis loader doesn't call Validate before ComputeHash, a
// malformed genesis silently produces an "error hash" that the
// operator might compare against the genesis-pinned value and panic
// at a confusing layer.
// =============================================================================
//
// SEVERITY: high
//
// FIX: in the genesis loader, call Validate FIRST, then ComputeHash.
// Make ComputeHash's error path explicitly "did Validate fail" so the
// operator's stack trace points to the missing field, not to a hash
// mismatch.
func TestF57_GenesisLoaderOrdering(t *testing.T) {
// Structural finding; covered by red-team README. The runtime
// invariant we can test: a profile with ProfileID = 0 still produces
// a hash because ComputeHash returns the zero array along with the
// error. A caller that ignores the error gets the zero array — and
// the zero array is a distinguishable value an attacker could
// pin to a genesis file.
var p ChainSecurityProfile
hash, err := p.ComputeHash()
if err == nil {
t.Errorf("ComputeHash on zero profile returned err=nil; finding F57 unfixed")
}
var zero [48]byte
if hash != zero {
// Good: the implementation guards against a stable hash on
// invalid input.
} else {
t.Logf("ComputeHash returns zero array on invalid input — caller must check err")
}
}
// =============================================================================
// F58 — VerifierID is uint16 (per security_profile.go) but
// reset-encoded as a 16-bit big-endian wire field in zchain's
// ZProofEnvelope. The 16-bit keyspace caps the number of distinct
// pinned verifiers across all clusters at 65536.
// =============================================================================
//
// SEVERITY: high
//
// A federation of 100 clusters, each running 5 backends, with 10
// program versions each, already needs 5000 distinct VerifierIDs.
// Cluster operators that independently number their verifiers will
// collide on the 16-bit space before the network scales out.
//
// RESOLUTION (per HIP-0078 / zchain F86): the architecture intentionally
// separates VerifierID (uint16 enum-shaped block of verifier KINDS) from
// ProgramOrAirID ([16]byte opaque program identifier). Distinct programs
// under the same VerifierID get distinct ProgramOrAirID values. Both
// fields appear in the envelope; both are hash-bound. The original
// widen-VerifierID proposal was rejected in favour of this two-axis
// design.
func TestF58_VerifierID_Width(t *testing.T) {
// VerifierID is the canonical uint16 enum (verifier-KIND axis); the
// per-program identifier lives in the separate 16-byte ProgramOrAirID
// field on the envelope. This test pins the kind-axis width so a
// future diff that widens VerifierID without auditing every encoding
// site fails compile here.
rt := reflect.TypeOf(VerifierID(0))
if rt.Kind() != reflect.Uint16 {
t.Errorf("VerifierID kind = %s; two-axis design pins kind-axis at uint16", rt.Kind())
}
}
// =============================================================================
// F59 — VerifierID 0x0000 is the explicit "none" sentinel, but
// IsZero / IsNone methods are not defined. Callers must compare to
// VerifierNone directly; future refactors may introduce a
// uint16(0)-valued constant that aliases the sentinel.
// =============================================================================
//
// SEVERITY: info
//
// FIX: add `func (v VerifierID) IsZero() bool { return v == VerifierNone }`
// so the test `if v.IsZero()` is the canonical predicate.
func TestF59_VerifierID_HasIsZeroPredicate(t *testing.T) {
v := VerifierNone
// We assert the predicate's existence by calling it. If the method
// is missing this is a compile failure — which is the test's signal.
if !callVerifierIDIsZero(v) {
t.Errorf("VerifierNone.IsZero() returned false; finding F59 unfixed")
}
if callVerifierIDIsZero(VerifierP3QSTARKFRISHA3PQ) {
t.Errorf("VerifierP3QSTARKFRISHA3PQ.IsZero() returned true; finding F59 unfixed")
}
}
// =============================================================================
// F60 — There is NO `ProfileByID(id ProfileID) (*ChainSecurityProfile,
// error)` function; callers must dispatch on a switch over ProfileID
// constants, which scatters the canonical profile registry across
// every call site.
// =============================================================================
//
// SEVERITY: medium
//
// FIX: add `func ProfileByID(id ProfileID) (*ChainSecurityProfile, error)`
// that returns the canonical profile for the ID or refuses with
// ErrProfileUnknown. Make every other call site dispatch through it.
func TestF60_ProfileByID_Exists(t *testing.T) {
p, err := callProfileByID(ProfileStrictPQ)
if err != nil {
t.Fatalf("ProfileByID(ProfileStrictPQ) errored: %v; finding F60 unfixed", err)
}
if p == nil {
t.Fatalf("ProfileByID(ProfileStrictPQ) returned nil; finding F60 unfixed")
}
if _, err := callProfileByID(ProfileNone); err == nil {
t.Errorf("ProfileByID(ProfileNone) accepted; should refuse; finding F60 unfixed")
}
}
// =============================================================================
// Helpers and stubs.
// =============================================================================
// tryGetLuxStrictPQ returns the canonical strict-PQ profile or nil if
// the constructor cannot be called (F52 not fixed). Tests that depend
// on a real profile skip when nil is returned.
func tryGetLuxStrictPQ(t *testing.T) *ChainSecurityProfile {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Logf("StrictPQ() panicked: %v — F52 unfixed", r)
}
}()
return StrictPQ()
}
func reverseBackends(in []ProofBackendID) []ProofBackendID {
out := append([]ProofBackendID(nil), in...)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
func reverseFormats(in []ProofFormatID) []ProofFormatID {
out := append([]ProofFormatID(nil), in...)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// callVerifierIDIsZero invokes (VerifierID).IsZero when it exists. The
// method does not exist in the current schema; this stub returns false
// so the F59 tests fail as the finding's proof.
func callVerifierIDIsZero(v VerifierID) bool {
// Once IsZero is implemented, replace with: return v.IsZero()
return v == VerifierNone // approximate behaviour while method is missing
}
// callProfileByID invokes the canonical registry lookup. ProfileByID
// is implemented in security_profile.go; the stub now dispatches.
func callProfileByID(id ProfileID) (*ChainSecurityProfile, error) {
return ProfileByID(id)
}
var errProfileByIDMissing = &stubMissing{name: "ProfileByID"}
type stubMissing struct{ name string }
func (e *stubMissing) Error() string { return "red-team: API " + e.name + " not yet implemented" }