mirror of
https://github.com/luxfi/zk.git
synced 2026-10-30 15:11:33 +00:00
670 lines
28 KiB
Go
670 lines
28 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// redteam_zchain_test.go — adversarial review of the Z-Chain proof
|
|
// envelope, VerifierManifestRegistry, and the (missing)
|
|
// VerifyZProofUnderProfile dispatch. Each F## test pins one footgun.
|
|
//
|
|
// Build prerequisite: github.com/luxfi/consensus/config must compile —
|
|
// the ProofFormatID and VerifierID duplicate declarations resolved
|
|
// to a single canonical definition each.
|
|
|
|
package zk
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/luxfi/consensus/config"
|
|
"github.com/luxfi/log"
|
|
)
|
|
|
|
// noopLog returns a no-op logger for tests. Wrapper so the symbol used
|
|
// here matches whichever stable accessor the log package exposes.
|
|
func noopLog() log.Logger { return log.Noop() }
|
|
|
|
// =============================================================================
|
|
// F80 — VerifyZProofUnderProfile exists; nil-input handling.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: info (post-fix verification)
|
|
//
|
|
// The function landed (verify.go). This test confirms it refuses nil
|
|
// inputs with typed errors (ErrZProofNilProfile / ErrZProofNilRegistry
|
|
// / ErrZProofNilInput / ErrZProofNilProof) so callers can route the
|
|
// failure.
|
|
func TestF80_VerifyZProofUnderProfile_RefusesNilProfile(t *testing.T) {
|
|
err := callVerifyZProofUnderProfile(nil, nil, nil, nil)
|
|
if !errors.Is(err, ErrZProofNilProfile) {
|
|
t.Errorf("VerifyZProofUnderProfile(nil...) returned %v; want ErrZProofNilProfile", err)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F81 — VerifierManifestRegistry.Lookup returns the internal pointer;
|
|
// callers can mutate the stored manifest at runtime.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: critical
|
|
//
|
|
// VerifierManifestRegistry.Lookup (verifier_manifest.go:200) returns
|
|
// the *VerifierManifest stored in the internal map. The doc says
|
|
// "callers MUST treat it as read-only" but nothing enforces this.
|
|
// A caller — including a deliberate attacker who gets a single
|
|
// Lookup-call-equivalent into the verifier — can mutate
|
|
// m.SupportsPolicyIDs, m.VerifierKeyHash, m.ProofFormatID, etc.
|
|
//
|
|
// The registry is "monotonic for the process lifetime" only at the
|
|
// keyspace level; the values are open to in-place mutation.
|
|
//
|
|
// ATTACK: any call site that does:
|
|
//
|
|
// m, _ := registry.Lookup(vid)
|
|
// m.SupportsPolicyIDs = append(m.SupportsPolicyIDs, ProofPolicyGroth16BN254Forbid)
|
|
//
|
|
// has just authorised a classical Groth16 proof under the existing
|
|
// VerifierID without going through Register's checks (which would
|
|
// have refused VerifierID conflicts and required Version /
|
|
// BuildProfile).
|
|
//
|
|
// FIX: Lookup MUST return a defensive copy (similar to what Register
|
|
// does on the inbound manifest). Or change the type to return
|
|
// `VerifierManifest` (value), not `*VerifierManifest`.
|
|
func TestF81_VerifierRegistry_LookupReturnsDefensiveCopy(t *testing.T) {
|
|
r := NewVerifierManifestRegistry(noopLog())
|
|
original := &VerifierManifest{
|
|
VerifierID: config.VerifierP3QSTARKFRISHA3PQ,
|
|
BackendID: config.ProofBackendP3QSTARKFRISHA3,
|
|
Version: "1.0.0",
|
|
BuildProfile: "production",
|
|
ProofFormatID: config.ProofFormatP3QBinaryV1,
|
|
SupportsPolicyIDs: []config.ProofPolicyID{config.ProofPolicySTARKFRISHA3PQ},
|
|
}
|
|
if err := r.Register(original); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
m1, ok := r.Lookup(config.VerifierP3QSTARKFRISHA3PQ)
|
|
if !ok {
|
|
t.Fatal("Lookup failed")
|
|
}
|
|
|
|
// ATTACK: caller mutates the stored manifest's policy support.
|
|
m1.SupportsPolicyIDs = append(m1.SupportsPolicyIDs, config.ProofPolicyGroth16BN254Forbid)
|
|
|
|
m2, ok := r.Lookup(config.VerifierP3QSTARKFRISHA3PQ)
|
|
if !ok {
|
|
t.Fatal("second Lookup failed")
|
|
}
|
|
|
|
// If Lookup returned a defensive copy, m2's policy list is still the
|
|
// original. If not, the attacker mutation has been persisted.
|
|
if m2.SupportsPolicy(config.ProofPolicyGroth16BN254Forbid) {
|
|
t.Fatalf("VerifierManifestRegistry persisted external mutation of SupportsPolicyIDs; "+
|
|
"Groth16-classical now appears to be supported by %s; finding F81 unfixed",
|
|
m2.VerifierID.String())
|
|
}
|
|
}
|
|
|
|
// F81b: Same attack on VerifierKeyHash. If the verifier-key hash can be
|
|
// mutated after Register, an attacker can match the manifest hash to
|
|
// an arbitrary envelope's claimed hash and bypass the drift-refusal.
|
|
func TestF81b_VerifierRegistry_LookupMutationOnHashFields(t *testing.T) {
|
|
r := NewVerifierManifestRegistry(noopLog())
|
|
original := &VerifierManifest{
|
|
VerifierID: config.VerifierP3QSTARKFRISHA3PQ,
|
|
BackendID: config.ProofBackendP3QSTARKFRISHA3,
|
|
Version: "1.0.0",
|
|
BuildProfile: "production",
|
|
ProofFormatID: config.ProofFormatP3QBinaryV1,
|
|
SupportsPolicyIDs: []config.ProofPolicyID{config.ProofPolicySTARKFRISHA3PQ},
|
|
ProgramOrAirHash: fill48Z(0xAA),
|
|
VerifierKeyHash: fill48Z(0xBB),
|
|
}
|
|
if err := r.Register(original); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
m1, _ := r.Lookup(config.VerifierP3QSTARKFRISHA3PQ)
|
|
// ATTACK: rewrite the stored key hash to match an arbitrary target.
|
|
target := fill48Z(0xCC)
|
|
m1.VerifierKeyHash = target
|
|
|
|
m2, _ := r.Lookup(config.VerifierP3QSTARKFRISHA3PQ)
|
|
if m2.VerifierKeyHash == target {
|
|
t.Fatalf("VerifierManifestRegistry persisted external mutation of VerifierKeyHash; finding F81 unfixed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F82 — Register checks length(SupportsPolicyIDs) > 0 but does NOT
|
|
// check policy validity; a manifest can claim SupportsPolicyIDs =
|
|
// [ProofPolicyGroth16BN254Forbid].
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: high
|
|
//
|
|
// verifier_manifest.go:170 refuses an empty SupportsPolicyIDs list.
|
|
// It does NOT refuse a list containing IsForbiddenInPQMode() policies.
|
|
// A genesis-loader or boot-config bug that registers a manifest
|
|
// claiming to support Groth16-BN254 will succeed. Downstream the
|
|
// profile may refuse the policy on the envelope — but the manifest
|
|
// claim is recorded as "this verifier supports classical Groth16,"
|
|
// which is wrong as a documentation artefact and dangerous if any
|
|
// future call site consults manifest.SupportsPolicy() before consulting
|
|
// profile.AllowedFormats / IsPostQuantum.
|
|
//
|
|
// FIX: in Register, refuse any SupportsPolicyIDs element with
|
|
// IsForbiddenInPQMode()=true. The dev-build can bypass via a separate
|
|
// RegisterUnsafe entry-point (off in production).
|
|
func TestF82_VerifierRegistry_RejectsForbiddenPolicyInSupportsList(t *testing.T) {
|
|
r := NewVerifierManifestRegistry(noopLog())
|
|
m := &VerifierManifest{
|
|
VerifierID: config.VerifierP3QSTARKFRISHA3PQ,
|
|
BackendID: config.ProofBackendP3QSTARKFRISHA3,
|
|
Version: "1.0.0",
|
|
BuildProfile: "production",
|
|
ProofFormatID: config.ProofFormatP3QBinaryV1,
|
|
SupportsPolicyIDs: []config.ProofPolicyID{
|
|
config.ProofPolicySTARKFRISHA3PQ,
|
|
config.ProofPolicyGroth16BN254Forbid, // forbidden marker
|
|
},
|
|
}
|
|
err := r.Register(m)
|
|
if err == nil {
|
|
t.Fatalf("Register accepted manifest with ProofPolicyGroth16BN254Forbid in SupportsPolicyIDs; finding F82 unfixed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F83 — Register does not check ProofFormatID matches BackendID.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: medium
|
|
//
|
|
// A manifest can claim BackendID = SP1CompressedSTARK and
|
|
// ProofFormatID = RISC0BinaryV1 (cross-backend mismatch). Register
|
|
// accepts this. Downstream, VerifyZProofUnderProfile (when it exists)
|
|
// would compare envelope.ProofFormatID against manifest.ProofFormatID
|
|
// and route to the SP1 verifier even though the envelope's format is
|
|
// labelled RISC0. The mismatch slips through.
|
|
//
|
|
// FIX: add a small matrix at Register time that asserts
|
|
// (backend, format) is in the set of allowed pairs. Pair table:
|
|
//
|
|
// SP1CompressedSTARK → STARKFRIBinaryV1, SP1BinaryV1
|
|
// RISC0SuccinctSTARK → STARKFRIBinaryV1, RISC0BinaryV1
|
|
// P3QSTARKFRISHA3 → STARKFRIBinaryV1, P3QBinaryV1
|
|
// StoneCairoSTARK → STARKFRIBinaryV1, StoneCairoBinaryV1
|
|
// StwoCircleSTARK → STARKFRIBinaryV1, StwoCircleBinaryV1
|
|
func TestF83_VerifierRegistry_RejectsBackendFormatMismatch(t *testing.T) {
|
|
r := NewVerifierManifestRegistry(noopLog())
|
|
m := &VerifierManifest{
|
|
VerifierID: config.VerifierSP1CompressedSTARKPQ,
|
|
BackendID: config.ProofBackendSP1CompressedSTARK, // SP1
|
|
ProofFormatID: config.ProofFormatRISC0BinaryV1, // RISC0 format — MISMATCH
|
|
Version: "1.0.0",
|
|
BuildProfile: "production",
|
|
SupportsPolicyIDs: []config.ProofPolicyID{config.ProofPolicySTARKFRISHA3PQ},
|
|
}
|
|
err := r.Register(m)
|
|
if err == nil {
|
|
t.Fatalf("Register accepted SP1 backend with RISC0 format; finding F83 unfixed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F84 — HashZPublicInputs binds every field per the test below, but
|
|
// the nil-receiver path returns a well-defined hash that an attacker
|
|
// could collide a real input against.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: high
|
|
//
|
|
// HashZPublicInputs(nil) returns
|
|
// `tupleHash48([][]byte{[]byte(publicInputsProtocolTag)}, publicInputsCustomization)`.
|
|
// This is a deterministic 48-byte constant. An attacker who can submit
|
|
// an envelope whose PublicInputsHash equals this constant has just
|
|
// claimed to be proving over zero-input. If the downstream verifier
|
|
// re-derives the input from chain state and that state happens to also
|
|
// canonicalise to the protocol-tag-only encoding (because every field
|
|
// is "the zero value" with a nil-aware encoder), the binding check
|
|
// passes. This is the constant-collision attack class.
|
|
//
|
|
// FIX: HashZPublicInputs(nil) MUST panic or return an error. Refusing
|
|
// nil input is the right call — there is no legitimate way to prove
|
|
// over "no public inputs."
|
|
func TestF84_HashZPublicInputs_NilInputMustNotReturnConstant(t *testing.T) {
|
|
// The current implementation returns a deterministic 48-byte
|
|
// constant. A safe implementation panics or refuses nil.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Log("HashZPublicInputs(nil) panicked (expected post-fix):", r)
|
|
return
|
|
}
|
|
t.Errorf("HashZPublicInputs(nil) returned without error; finding F84 unfixed")
|
|
}()
|
|
_ = HashZPublicInputs(nil)
|
|
}
|
|
|
|
// F84b: Two empty (all-zero) ZPublicInputs values must NOT canonicalise
|
|
// to the same bytes as HashZPublicInputs(nil). Otherwise a producer
|
|
// who passes an all-zero struct gets the same hash as a producer who
|
|
// passed nil.
|
|
func TestF84b_HashZPublicInputs_EmptyVsNil_MustDiffer(t *testing.T) {
|
|
hp, panicked := func() (h [48]byte, panicked bool) {
|
|
defer func() {
|
|
if recover() != nil {
|
|
panicked = true
|
|
}
|
|
}()
|
|
h = HashZPublicInputs(nil)
|
|
return
|
|
}()
|
|
if panicked {
|
|
t.Log("HashZPublicInputs(nil) panicked (expected post-fix)")
|
|
return
|
|
}
|
|
empty := &ZPublicInputs{}
|
|
eh := HashZPublicInputs(empty)
|
|
if hp == eh {
|
|
t.Errorf("HashZPublicInputs(nil) == HashZPublicInputs(&ZPublicInputs{}) — finding F84b unfixed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F85 — HashZPublicInputs MUST bind every field. We assert this by
|
|
// mutating each field and checking the hash changes.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: high (defence-in-depth; one missed field = a malleability
|
|
// vector)
|
|
//
|
|
// The implementation in proof_envelope.go HashZPublicInputs (line 406)
|
|
// lists 22 parts — including the protocol tag. We exhaustively test
|
|
// that each non-tag field flip changes the hash.
|
|
func TestF85_HashZPublicInputs_BindsEveryField(t *testing.T) {
|
|
base := &ZPublicInputs{
|
|
Version: 0x0001,
|
|
ProfileID: 0xC0DE0001,
|
|
NetworkID: 96369,
|
|
ChainID: 1,
|
|
Epoch: 100,
|
|
PreviousZStateRoot: fill48Z(0x10),
|
|
NewZStateRoot: fill48Z(0x11),
|
|
TxBatchHash: fill48Z(0x12),
|
|
IdentityRoot: fill48Z(0x13),
|
|
ValidatorRegistryRoot: fill48Z(0x14),
|
|
RevocationRoot: fill48Z(0x15),
|
|
StakeWeightRoot: fill48Z(0x16),
|
|
CommitteeRoot: fill48Z(0x17),
|
|
DKGTranscriptRoot: fill48Z(0x18),
|
|
GroupPublicKeyHash: fill48Z(0x19),
|
|
QChainTipHash: fill48Z(0x1A),
|
|
EpochCommitmentHash: fill48Z(0x1B),
|
|
HashSuiteID: config.HashSuiteSHA3NIST,
|
|
IdentitySchemeID: config.SigSchemeMLDSA65,
|
|
FinalitySchemeID: config.SigSchemePulsar65,
|
|
ProofPolicyID: config.ProofPolicySTARKFRISHA3PQ,
|
|
}
|
|
baseHash := HashZPublicInputs(base)
|
|
|
|
cases := []struct {
|
|
name string
|
|
mut func(in *ZPublicInputs)
|
|
}{
|
|
{"Version", func(in *ZPublicInputs) { in.Version++ }},
|
|
{"ProfileID", func(in *ZPublicInputs) { in.ProfileID ^= 1 }},
|
|
{"NetworkID", func(in *ZPublicInputs) { in.NetworkID++ }},
|
|
{"ChainID", func(in *ZPublicInputs) { in.ChainID++ }},
|
|
{"Epoch", func(in *ZPublicInputs) { in.Epoch++ }},
|
|
{"PreviousZStateRoot", func(in *ZPublicInputs) { in.PreviousZStateRoot[0] ^= 1 }},
|
|
{"NewZStateRoot", func(in *ZPublicInputs) { in.NewZStateRoot[0] ^= 1 }},
|
|
{"TxBatchHash", func(in *ZPublicInputs) { in.TxBatchHash[0] ^= 1 }},
|
|
{"IdentityRoot", func(in *ZPublicInputs) { in.IdentityRoot[0] ^= 1 }},
|
|
{"ValidatorRegistryRoot", func(in *ZPublicInputs) { in.ValidatorRegistryRoot[0] ^= 1 }},
|
|
{"RevocationRoot", func(in *ZPublicInputs) { in.RevocationRoot[0] ^= 1 }},
|
|
{"StakeWeightRoot", func(in *ZPublicInputs) { in.StakeWeightRoot[0] ^= 1 }},
|
|
{"CommitteeRoot", func(in *ZPublicInputs) { in.CommitteeRoot[0] ^= 1 }},
|
|
{"DKGTranscriptRoot", func(in *ZPublicInputs) { in.DKGTranscriptRoot[0] ^= 1 }},
|
|
{"GroupPublicKeyHash", func(in *ZPublicInputs) { in.GroupPublicKeyHash[0] ^= 1 }},
|
|
{"QChainTipHash", func(in *ZPublicInputs) { in.QChainTipHash[0] ^= 1 }},
|
|
{"EpochCommitmentHash", func(in *ZPublicInputs) { in.EpochCommitmentHash[0] ^= 1 }},
|
|
{"HashSuiteID", func(in *ZPublicInputs) { in.HashSuiteID = config.HashSuiteBLAKE3Legacy }},
|
|
{"IdentitySchemeID", func(in *ZPublicInputs) { in.IdentitySchemeID = config.SigSchemeMLDSA87 }},
|
|
{"FinalitySchemeID", func(in *ZPublicInputs) { in.FinalitySchemeID = config.SigSchemePulsar87 }},
|
|
{"ProofPolicyID", func(in *ZPublicInputs) { in.ProofPolicyID = config.ProofPolicySTARKFRIKeccak }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cc := *base
|
|
tc.mut(&cc)
|
|
if HashZPublicInputs(&cc) == baseHash {
|
|
t.Errorf("flipping %s did not change HashZPublicInputs; field is not bound", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// F85b: Last-field tail also flips. Catches an "iterate up to N-1" bug
|
|
// in the parts loop.
|
|
func TestF85b_HashZPublicInputs_LastFieldTailFlip(t *testing.T) {
|
|
base := &ZPublicInputs{
|
|
EpochCommitmentHash: fill48Z(0xAA),
|
|
}
|
|
baseHash := HashZPublicInputs(base)
|
|
cc := *base
|
|
cc.EpochCommitmentHash[47] ^= 1 // very last byte
|
|
if HashZPublicInputs(&cc) == baseHash {
|
|
t.Errorf("flipping last byte of last [48]byte field did not change hash; tail-encoding bug")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F86 — VerifierID field width in ZProofEnvelope is uint16 (per the
|
|
// security_profile.go side of the duplicate-type collision) but the
|
|
// architecture spec demands a 16-byte (UUID-shaped) identifier so that
|
|
// distinct verifiers in distinct clusters don't clash on a 16-bit
|
|
// keyspace.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: info (architectural pin)
|
|
//
|
|
// F86 was originally framed as "VerifierID width is too small." After
|
|
// architecture review (HIP-0078, user task spec, security_profile.go),
|
|
// the design 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 finding was based on
|
|
// the assumption that VerifierID was the unique-per-program key —
|
|
// in this architecture it is the unique-per-kind enum, and the
|
|
// per-program key lives in ProgramOrAirID.
|
|
//
|
|
// The test asserts the architectural shape: VerifierID stays uint16,
|
|
// ProgramOrAirID is [16]byte.
|
|
func TestF86_ZProofEnvelope_VerifierIDFieldWidth(t *testing.T) {
|
|
var e ZProofEnvelope
|
|
rt := reflect.TypeOf(e)
|
|
vid, ok := rt.FieldByName("VerifierID")
|
|
if !ok {
|
|
t.Fatalf("ZProofEnvelope.VerifierID field missing")
|
|
}
|
|
if vid.Type.Kind() != reflect.Uint16 {
|
|
t.Errorf("ZProofEnvelope.VerifierID kind = %s, want uint16 (enum-shaped per HIP-0078)", vid.Type.Kind())
|
|
}
|
|
pid, ok := rt.FieldByName("ProgramOrAirID")
|
|
if !ok {
|
|
t.Fatalf("ZProofEnvelope.ProgramOrAirID field missing")
|
|
}
|
|
if pid.Type.Kind() != reflect.Array || pid.Type.Len() != 16 {
|
|
t.Errorf("ZProofEnvelope.ProgramOrAirID type = %v, want [16]byte", pid.Type)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F87 — Backend-self-declared `UsesPairings / UsesKZG / UsesTrustedSetup /
|
|
// UsesClassicalSNARKWrapper` are encoded into the envelope verbatim
|
|
// without any attestation. A malicious backend can set them all to
|
|
// `false` even when the verifier internally uses KZG.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: high
|
|
//
|
|
// proof_envelope.go:107-117 says these flags are "NOT trusted — the
|
|
// profile enforces the ban via Require / Forbid fields." Good in
|
|
// principle, but the only "ban enforcement" is in the (missing-today,
|
|
// F80) VerifyZProofUnderProfile function. Until that lands, the flags
|
|
// are just self-attestations.
|
|
//
|
|
// Worse: the manifest also holds these flags (verifier_manifest.go:95).
|
|
// The check that "envelope.UsesKZG == manifest.UsesKZG" is a comparison
|
|
// of two self-attested values. A malicious cluster that produces both
|
|
// the envelope AND the manifest can keep both lying in lock-step.
|
|
//
|
|
// FIX: replace self-attestation with attestation by a separate
|
|
// (offline, audit-time) signing path. Each VerifierManifest has a
|
|
// detached audit signature (e.g. a release-engineering ML-DSA-65
|
|
// signature over the manifest's hash). Boot-time, Register checks
|
|
// the audit signature against a hard-coded audit pubkey. Without that
|
|
// signature, no manifest enters the registry.
|
|
//
|
|
// Below: a property test asserting Register refuses a manifest whose
|
|
// flags claim "Uses=false" but whose BackendID is one we KNOW uses
|
|
// trusted setup (Groth16WrapForbid). The point of the test is to
|
|
// show the lack of cross-check.
|
|
func TestF87_VerifierManifest_FlagsAreSelfAttested(t *testing.T) {
|
|
r := NewVerifierManifestRegistry(noopLog())
|
|
m := &VerifierManifest{
|
|
VerifierID: config.VerifierP3QSTARKFRISHA3PQ,
|
|
BackendID: config.ProofBackendGroth16WrapForbid, // KNOWN classical wrapper
|
|
Version: "1.0.0",
|
|
BuildProfile: "production",
|
|
ProofFormatID: config.ProofFormatGroth16WrappedForbid,
|
|
SupportsPolicyIDs: []config.ProofPolicyID{config.ProofPolicySTARKFRISHA3PQ},
|
|
UsesPairings: false, // self-attested LIE
|
|
UsesKZG: false, // self-attested LIE
|
|
UsesTrustedSetup: false, // self-attested LIE
|
|
UsesClassicalSNARKWrapper: false, // self-attested LIE
|
|
}
|
|
err := r.Register(m)
|
|
// Register today accepts the lying flags. The finding is that the
|
|
// register accepts a manifest whose BackendID is a classical
|
|
// forbidden marker AND whose self-flags pretend to be safe.
|
|
if err == nil {
|
|
t.Errorf("Register accepted manifest with Groth16-classical backend; finding F87 (and F82 mirror) unfixed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F88 — VerifierManifest.BuildProfile is a free-form string; "production"
|
|
// vs "Production" vs "PRODUCTION" all accepted; no canonicalisation.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: medium
|
|
//
|
|
// Register checks `m.BuildProfile == ""` but does not check the value
|
|
// is one of a known set (production / dev / audit). A future check
|
|
// site that wants to refuse "debug" builds would need to do its own
|
|
// canonicalisation and lookups; case-sensitivity bugs become likely.
|
|
//
|
|
// FIX: introduce a typed enum BuildProfile (Production, Dev, Audit)
|
|
// with a Parse function and a String. Refuse unknown values at
|
|
// Register time.
|
|
func TestF88_VerifierManifest_BuildProfileShouldBeTyped(t *testing.T) {
|
|
r := NewVerifierManifestRegistry(noopLog())
|
|
m := &VerifierManifest{
|
|
VerifierID: config.VerifierP3QSTARKFRISHA3PQ,
|
|
BackendID: config.ProofBackendP3QSTARKFRISHA3,
|
|
Version: "1.0.0",
|
|
BuildProfile: "TOTALLY-FAKE", // accepted today
|
|
ProofFormatID: config.ProofFormatP3QBinaryV1,
|
|
SupportsPolicyIDs: []config.ProofPolicyID{config.ProofPolicySTARKFRISHA3PQ},
|
|
}
|
|
err := r.Register(m)
|
|
if err == nil {
|
|
t.Errorf("Register accepted manifest with garbage BuildProfile; finding F88 unfixed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F89 — ZProofEnvelope.Marshal returns nil on a nil receiver instead of
|
|
// panicking; downstream UnmarshalZProofEnvelope(nil) succeeds at length
|
|
// check then fails at codec, giving a less-clear error than panic-on-misuse.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: info
|
|
//
|
|
// proof_envelope.go:217 — comments say "the empty buffer fails every
|
|
// downstream check" but the side effect is that a programmer error
|
|
// (calling Marshal on a nil *ZProofEnvelope) produces a silent empty
|
|
// byte slice. The right behaviour for an internal-callers-only struct
|
|
// is panic-on-misuse; that catches the bug at the caller site, not
|
|
// three layers down the codec.
|
|
//
|
|
// FIX: panic("zchain: Marshal of nil envelope") on nil receiver. Or
|
|
// return an error and remove the nil-receiver path entirely (force
|
|
// callers to construct before calling).
|
|
func TestF89_ZProofEnvelope_MarshalNilReceiver(t *testing.T) {
|
|
var e *ZProofEnvelope
|
|
defer func() {
|
|
if recover() != nil {
|
|
t.Log("Marshal(nil) panicked (expected post-fix)")
|
|
return
|
|
}
|
|
out := e.Marshal()
|
|
if out == nil {
|
|
t.Errorf("Marshal(nil) returned nil silently; finding F89 unfixed")
|
|
}
|
|
}()
|
|
_ = e.Marshal()
|
|
}
|
|
|
|
// =============================================================================
|
|
// F90 — UnmarshalZProofEnvelope does not refuse zero-value
|
|
// HashSuiteID / ProofPolicyID / ProofBackendID / VerifierID. A
|
|
// zero-init envelope round-trips cleanly.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: high
|
|
//
|
|
// Construct an envelope with every field zero, Marshal, then
|
|
// Unmarshal: the unmarshalled envelope is byte-equal to the original.
|
|
// The codec does not refuse the zero-init payload. The verifier
|
|
// (F80, missing) is the only layer that could refuse it, and that
|
|
// puts the entire zero-value-is-invalid burden on a function that
|
|
// does not yet exist.
|
|
//
|
|
// FIX: at the codec layer, refuse zero values for the security-relevant
|
|
// enums (HashSuiteID, ProofPolicyID, ProofBackendID, ProofFormatID,
|
|
// VerifierID, IdentitySchemeID, FinalitySchemeID). This is the "fail
|
|
// at the boundary" principle.
|
|
func TestF90_UnmarshalZProofEnvelope_RefusesZeroEnums(t *testing.T) {
|
|
e := &ZProofEnvelope{
|
|
Version: 0x0001,
|
|
ProfileID: 1, // non-zero, otherwise refused
|
|
ChainID: 1,
|
|
NetworkID: 1,
|
|
Epoch: 1,
|
|
// All enum fields LEFT ZERO.
|
|
}
|
|
data := e.Marshal()
|
|
got, err := UnmarshalZProofEnvelope(data)
|
|
if err != nil {
|
|
t.Logf("Unmarshal rejected zero enums: %v (expected post-fix)", err)
|
|
return
|
|
}
|
|
// The codec accepted zero-init. The finding is that it should have
|
|
// refused.
|
|
if got.HashSuiteID == config.HashSuiteNone &&
|
|
got.ProofPolicyID == config.ProofPolicyNone &&
|
|
got.ProofBackendID == config.ProofBackendNone &&
|
|
got.ProofFormatID == config.ProofFormatNone &&
|
|
got.IdentitySchemeID == config.SigSchemeNone &&
|
|
got.FinalitySchemeID == config.SigSchemeNone {
|
|
t.Errorf("UnmarshalZProofEnvelope round-tripped a zero-enum envelope; finding F90 unfixed")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F91 — TranscriptHash binds the canonical parts list exactly.
|
|
// =============================================================================
|
|
//
|
|
// SEVERITY: medium (regression catch)
|
|
//
|
|
// The codec exposes a single canonical layout — there is no wire-version
|
|
// axis to downgrade across. The transcript identity lives in the
|
|
// customization tag (cSHAKE256 customization) plus the in-band protocol
|
|
// tag (first TupleHash part). Any future change to the parts list, the
|
|
// customization tag, or the protocol tag produces a digest stream that
|
|
// does not collide with prior digests, by TupleHash injectivity.
|
|
//
|
|
// This test re-derives the digest with the test's own parts list and
|
|
// asserts byte equality with TranscriptHash(). Any diff that reorders,
|
|
// drops, or adds a part will break this test in lockstep.
|
|
func TestF91_TranscriptHash_BindsCanonicalPartsList(t *testing.T) {
|
|
e := &ZProofEnvelope{
|
|
Version: 0x0001,
|
|
ProfileID: 1,
|
|
ChainID: 1,
|
|
NetworkID: 1,
|
|
Epoch: 1,
|
|
HashSuiteID: config.HashSuiteSHA3NIST,
|
|
ProofPolicyID: config.ProofPolicySTARKFRISHA3PQ,
|
|
ProofBackendID: config.ProofBackendP3QSTARKFRISHA3,
|
|
ProofFormatID: config.ProofFormatP3QBinaryV1,
|
|
VerifierID: config.VerifierP3QSTARKFRISHA3PQ,
|
|
IdentitySchemeID: config.SigSchemeMLDSA65,
|
|
FinalitySchemeID: config.SigSchemePulsar65,
|
|
}
|
|
parts := [][]byte{
|
|
[]byte(envelopeProtocolTag),
|
|
u16BE(e.Version),
|
|
u32BE(e.ProfileID),
|
|
u32BE(e.ChainID),
|
|
u32BE(e.NetworkID),
|
|
u64BE(e.Epoch),
|
|
{byte(e.ProofPolicyID)},
|
|
{byte(e.ProofBackendID)},
|
|
{byte(e.ProofFormatID)},
|
|
u16BE(uint16(e.VerifierID)),
|
|
{byte(e.HashSuiteID)},
|
|
{byte(e.IdentitySchemeID)},
|
|
{byte(e.FinalitySchemeID)},
|
|
e.PublicInputsHash[:],
|
|
e.VerifierKeyHash[:],
|
|
e.ProgramOrAirID[:],
|
|
e.ProgramOrAirHash[:],
|
|
u16BE(e.SoundnessBitsClaimed),
|
|
u16BE(e.HashOutputBits),
|
|
{boolByte(e.TransparentSetup)},
|
|
{boolByte(e.UsesPairings)},
|
|
{boolByte(e.UsesKZG)},
|
|
{boolByte(e.UsesTrustedSetup)},
|
|
{boolByte(e.UsesClassicalSNARKWrapper)},
|
|
e.ProofBytes,
|
|
}
|
|
want := tupleHash48(parts, envelopeTranscriptCustomization)
|
|
if want != e.TranscriptHash() {
|
|
t.Errorf("re-derive does NOT match TranscriptHash; parts list / customization drift detected")
|
|
}
|
|
|
|
// Drift catch: a flipped customization byte produces a different digest.
|
|
drift := tupleHash48(parts, envelopeTranscriptCustomization+"X")
|
|
if drift == e.TranscriptHash() {
|
|
t.Errorf("customization-tag drift produces colliding digest; cSHAKE customization not bound")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers + stubs
|
|
// =============================================================================
|
|
|
|
func fill48Z(v byte) [48]byte {
|
|
var a [48]byte
|
|
for i := range a {
|
|
a[i] = v
|
|
}
|
|
return a
|
|
}
|
|
|
|
// callVerifyZProofUnderProfile dispatches to the real entry point. The
|
|
// function now exists; F80's finding is therefore RESOLVED — but the
|
|
// test that checks for it should still verify the function exists and
|
|
// behaves correctly under nil inputs.
|
|
func callVerifyZProofUnderProfile(
|
|
profile *config.ChainSecurityProfile,
|
|
registry *VerifierManifestRegistry,
|
|
input *ZPublicInputs,
|
|
proof *ZProofEnvelope,
|
|
) error {
|
|
return VerifyZProofUnderProfile(profile, registry, input, proof)
|
|
}
|
|
|
|
var errVerifyZProofNotImplemented = errors.New("red-team test: VerifyZProofUnderProfile not yet implemented")
|
|
|
|
// Convenience: ensure bytes.Equal is available to compile-shape tests.
|
|
var _ = bytes.Equal
|