Files
zk/proof_envelope.go
zeekay 3fde8e4eb7 zk: initial split from luxfi/consensus/protocol/zchain
Extracts the Z-Chain proof envelope, verifier dispatch, verifier
manifest, backend registry, hash helper, and account-ID/key-record
registry subpackage into github.com/luxfi/zk.

Consensus repo is now primitives-only; chain-side proof code lives
here. Zero consensus .go imports needed rewriting — the only
references were doc comments, confirming the modules were already
decomplected.

Coverage:
  138 test runs across github.com/luxfi/zk and github.com/luxfi/zk/registry
  138 PASS / 0 FAIL

Cross-module: registry/parity_with_auth_test.go imports
luxfi/consensus/protocol/auth to confirm DeriveAccountID byte-equal
parity across the auth/registry packages. Resolves via a local
replace github.com/luxfi/consensus => ../consensus until the
matching consensus tag drops the deleted protocol/zchain dir.
2026-05-11 19:12:15 -07:00

544 lines
18 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zk
import (
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/luxfi/consensus/config"
)
// proof_envelope.go — the on-the-wire ZProofEnvelope plus the canonical
// public inputs ABI shared across every PQ proof backend.
//
// One envelope, every backend. SP1, RISC0, P3Q, Stone, and Stwo all map
// into this struct; the only thing that differs across backends is the
// VerifierID + the opaque ProofBytes layout (which the registered
// VerifierManifest knows how to dispatch into a BackendVerifier).
//
// Every byte that determines what the proof asserts is bound into
// TranscriptHash() via TupleHash256, so a post-sign mutation breaks
// signature verification — not just envelope equality.
// envelopeTranscriptCustomization is the SP 800-185 cSHAKE256
// customization tag for the envelope transcript. The tag is the schema
// identity; changing it produces a transcript stream that does not
// collide with any prior digest.
const envelopeTranscriptCustomization = "ZCHAIN-PROOF-ENVELOPE"
// envelopeProtocolTag is the in-band redundant protocol tag for the
// transcript. Defence-in-depth so a cross-customization-collision
// attacker also has to forge the leading TupleHash part.
const envelopeProtocolTag = "Z-Chain/ProofEnvelope"
// publicInputsCustomization is the SP 800-185 cSHAKE256 customization
// tag for HashZPublicInputs. Distinct from the envelope customization
// so the digest a backend computes over public inputs cannot collide
// with a digest over the envelope itself.
const publicInputsCustomization = "ZCHAIN-PUBLIC-INPUTS"
// publicInputsProtocolTag is the in-band redundant protocol tag for
// the public-inputs digest.
const publicInputsProtocolTag = "Z-Chain/PublicInputs"
// ZProofEnvelope is the canonical on-the-wire proof envelope that every
// PQ proof backend on Z-Chain produces. The struct is the union of every
// piece of policy / format / identity metadata the verifier needs BEFORE
// it touches ProofBytes — so cheap structural checks can refuse a proof
// without paying any backend cost.
//
// All fixed-width fields are 48 bytes wide: the HIP-0078 canonical hash
// width for Z-Chain transcripts and state roots (SHA3-384 / cSHAKE256
// squeezed to 384 bits). ProgramOrAirID is the only 16-byte field —
// it's a UUID-shaped opaque program / AIR identifier.
type ZProofEnvelope struct {
Version uint16
ProfileID uint32 // wider than config.ProfileID so future profiles aren't squeezed
ChainID uint32
NetworkID uint32
Epoch uint64
ProofPolicyID config.ProofPolicyID
ProofBackendID config.ProofBackendID
ProofFormatID config.ProofFormatID
VerifierID config.VerifierID
HashSuiteID config.HashSuiteID
IdentitySchemeID config.SigSchemeID
FinalitySchemeID config.SigSchemeID
// PublicInputsHash is HashZPublicInputs(input) computed by the
// producer over the same canonical inputs the backend proves about.
// VerifyZProofUnderProfile re-hashes the verifier-side inputs and
// refuses any mismatch — the binding closes the "wrong public
// inputs, same proof bytes" attack.
PublicInputsHash [48]byte
// VerifierKeyHash is the manifest-pinned 48-byte hash of the
// verifier key the backend was set up against. The registry holds
// the same value; mismatch is a config-drift refusal.
VerifierKeyHash [48]byte
// ProgramOrAirID is a 16-byte opaque program / AIR identifier
// (typically a UUID or the first 16 bytes of a content hash). One
// program → one identifier across versions; bump only when the
// program semantics change.
ProgramOrAirID [16]byte
// ProgramOrAirHash is the full 48-byte content hash of the program
// / AIR bytes the backend proved against. The registry holds the
// authoritative value; mismatch is a config-drift refusal.
ProgramOrAirHash [48]byte
SoundnessBitsClaimed uint16
HashOutputBits uint16
// Backend-self-declared properties. These are NOT trusted — the
// profile enforces the ban via Require / Forbid fields, and the
// verifier refuses anything that contradicts the profile policy.
// They appear in the envelope so audit tooling can name a
// misconfiguration precisely without having to inspect the
// backend implementation.
TransparentSetup bool
UsesPairings bool
UsesKZG bool
UsesTrustedSetup bool
UsesClassicalSNARKWrapper bool
// ProofBytes is the backend-native proof payload. The
// VerifierManifest registered under proof.VerifierID knows how to
// parse this slice. The envelope does not look inside.
ProofBytes []byte
}
// TranscriptHash returns the 48-byte digest the producer's identity /
// finality signature signs over for this envelope. Bound via
// TupleHash256 (customization "ZCHAIN-PROOF-ENVELOPE") so any byte
// flip breaks signature verification — not just envelope-equality.
//
// The hash family is fixed at cSHAKE256 / TupleHash256 here, independent
// of HashSuiteID. HashSuiteID is bound as DATA into the transcript, not
// as the hash family of the transcript itself. This avoids a circular
// dependency on suite selection and matches the Pulsar-SHA3 canonical
// profile.
func (e *ZProofEnvelope) TranscriptHash() [48]byte {
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,
}
return tupleHash48(parts, envelopeTranscriptCustomization)
}
// =============================================================================
// Wire codec
// =============================================================================
//
// Layout (deterministic, big-endian):
//
// version uint16 BE
// profile_id uint32 BE
// chain_id uint32 BE
// network_id uint32 BE
// epoch uint64 BE
// proof_policy_id uint8
// proof_backend_id uint8
// proof_format_id uint8
// verifier_id uint16 BE
// hash_suite_id uint8
// identity_scheme_id uint8
// finality_scheme_id uint8
// public_inputs_hash [48]byte
// verifier_key_hash [48]byte
// program_or_air_id [16]byte
// program_or_air_hash [48]byte
// soundness_bits_claimed uint16 BE
// hash_output_bits uint16 BE
// transparent_setup uint8 (0/1)
// uses_pairings uint8 (0/1)
// uses_kzg uint8 (0/1)
// uses_trusted_setup uint8 (0/1)
// uses_classical_snark_wrapper uint8 (0/1)
// proof_bytes_len uint32 BE
// proof_bytes []byte
// Typed codec errors. Each names exactly which invariant the decoder
// caught so the caller can route the failure without parsing a string.
var (
ErrEnvelopeNil = errors.New("zchain: nil envelope")
ErrEnvelopeTruncated = errors.New("zchain: envelope truncated")
ErrEnvelopeProofBytesTooLong = errors.New("zchain: declared proof_bytes_len exceeds input")
ErrEnvelopeTrailingBytes = errors.New("zchain: trailing bytes after envelope decode")
ErrEnvelopeBoolByteOutOfRange = errors.New("zchain: bool byte must be 0 or 1")
ErrEnvelopeZeroEnum = errors.New("zchain: envelope has zero-value enum where a real value is required")
)
// Marshal returns the deterministic byte encoding of e. Panics on a
// nil receiver — Marshal is internal-caller-only and a nil pointer here
// is a programmer error that MUST surface at the call site, not three
// codec layers down.
func (e *ZProofEnvelope) Marshal() []byte {
if e == nil {
panic("zchain: Marshal called on nil *ZProofEnvelope")
}
// Fixed prefix size: 2 (ver) + 4 (profile) + 4 (chain) + 4 (network)
// + 8 (epoch) + 8 (policy/backend/format/verifier(2)/suite/id/fin)
// + 48 + 48 + 16 + 48 (hashes) + 2 + 2 (bits) + 5 (bool bytes) +
// 4 (proof len) = 153 bytes + len(proof).
size := 2 + 4 + 4 + 4 + 8 + 8 + 48 + 48 + 16 + 48 + 2 + 2 + 5 + 4 + len(e.ProofBytes)
buf := make([]byte, 0, size)
buf = appendU16(buf, e.Version)
buf = appendU32(buf, e.ProfileID)
buf = appendU32(buf, e.ChainID)
buf = appendU32(buf, e.NetworkID)
buf = appendU64(buf, e.Epoch)
buf = append(buf, byte(e.ProofPolicyID))
buf = append(buf, byte(e.ProofBackendID))
buf = append(buf, byte(e.ProofFormatID))
buf = appendU16(buf, uint16(e.VerifierID))
buf = append(buf, byte(e.HashSuiteID))
buf = append(buf, byte(e.IdentitySchemeID))
buf = append(buf, byte(e.FinalitySchemeID))
buf = append(buf, e.PublicInputsHash[:]...)
buf = append(buf, e.VerifierKeyHash[:]...)
buf = append(buf, e.ProgramOrAirID[:]...)
buf = append(buf, e.ProgramOrAirHash[:]...)
buf = appendU16(buf, e.SoundnessBitsClaimed)
buf = appendU16(buf, e.HashOutputBits)
buf = append(buf, boolByte(e.TransparentSetup))
buf = append(buf, boolByte(e.UsesPairings))
buf = append(buf, boolByte(e.UsesKZG))
buf = append(buf, boolByte(e.UsesTrustedSetup))
buf = append(buf, boolByte(e.UsesClassicalSNARKWrapper))
buf = appendU32(buf, uint32(len(e.ProofBytes)))
buf = append(buf, e.ProofBytes...)
return buf
}
// UnmarshalZProofEnvelope is the round-trip inverse of Marshal. Returns
// a typed error from the ErrEnvelope* set on any framing failure so
// callers can distinguish truncation from boundary-violation without
// parsing strings.
func UnmarshalZProofEnvelope(data []byte) (*ZProofEnvelope, error) {
r := &envelopeReader{buf: data}
e := &ZProofEnvelope{}
var err error
if e.Version, err = r.u16(); err != nil {
return nil, err
}
if e.ProfileID, err = r.u32(); err != nil {
return nil, err
}
if e.ChainID, err = r.u32(); err != nil {
return nil, err
}
if e.NetworkID, err = r.u32(); err != nil {
return nil, err
}
if e.Epoch, err = r.u64(); err != nil {
return nil, err
}
v, err := r.u8()
if err != nil {
return nil, err
}
e.ProofPolicyID = config.ProofPolicyID(v)
if v, err = r.u8(); err != nil {
return nil, err
}
e.ProofBackendID = config.ProofBackendID(v)
if v, err = r.u8(); err != nil {
return nil, err
}
e.ProofFormatID = config.ProofFormatID(v)
vid, err := r.u16()
if err != nil {
return nil, err
}
e.VerifierID = config.VerifierID(vid)
if v, err = r.u8(); err != nil {
return nil, err
}
e.HashSuiteID = config.HashSuiteID(v)
if v, err = r.u8(); err != nil {
return nil, err
}
e.IdentitySchemeID = config.SigSchemeID(v)
if v, err = r.u8(); err != nil {
return nil, err
}
e.FinalitySchemeID = config.SigSchemeID(v)
if err = r.read48(&e.PublicInputsHash); err != nil {
return nil, err
}
if err = r.read48(&e.VerifierKeyHash); err != nil {
return nil, err
}
if err = r.read16(&e.ProgramOrAirID); err != nil {
return nil, err
}
if err = r.read48(&e.ProgramOrAirHash); err != nil {
return nil, err
}
if e.SoundnessBitsClaimed, err = r.u16(); err != nil {
return nil, err
}
if e.HashOutputBits, err = r.u16(); err != nil {
return nil, err
}
if e.TransparentSetup, err = r.boolByte(); err != nil {
return nil, err
}
if e.UsesPairings, err = r.boolByte(); err != nil {
return nil, err
}
if e.UsesKZG, err = r.boolByte(); err != nil {
return nil, err
}
if e.UsesTrustedSetup, err = r.boolByte(); err != nil {
return nil, err
}
if e.UsesClassicalSNARKWrapper, err = r.boolByte(); err != nil {
return nil, err
}
if e.ProofBytes, err = r.lenPrefixed(); err != nil {
return nil, err
}
if len(r.buf) != 0 {
return nil, fmt.Errorf("%w: %d bytes", ErrEnvelopeTrailingBytes, len(r.buf))
}
// Refuse zero values for the security-relevant enums. A zero-init
// envelope is never a legitimate wire payload; making the codec
// refuse it removes one degree of freedom an attacker has when
// fuzzing a verifier whose downstream profile-check might miss a
// path. See F90.
switch {
case e.HashSuiteID == config.HashSuiteNone:
return nil, fmt.Errorf("%w: HashSuiteID", ErrEnvelopeZeroEnum)
case e.ProofPolicyID == config.ProofPolicyNone:
return nil, fmt.Errorf("%w: ProofPolicyID", ErrEnvelopeZeroEnum)
case e.ProofBackendID == config.ProofBackendNone:
return nil, fmt.Errorf("%w: ProofBackendID", ErrEnvelopeZeroEnum)
case e.ProofFormatID == config.ProofFormatNone:
return nil, fmt.Errorf("%w: ProofFormatID", ErrEnvelopeZeroEnum)
case e.VerifierID == config.VerifierNone:
return nil, fmt.Errorf("%w: VerifierID", ErrEnvelopeZeroEnum)
case e.IdentitySchemeID == config.SigSchemeNone:
return nil, fmt.Errorf("%w: IdentitySchemeID", ErrEnvelopeZeroEnum)
case e.FinalitySchemeID == config.SigSchemeNone:
return nil, fmt.Errorf("%w: FinalitySchemeID", ErrEnvelopeZeroEnum)
}
return e, nil
}
// =============================================================================
// ZPublicInputs — the stable ABI shared across backends.
// =============================================================================
// ZPublicInputs is the canonical public inputs every Z-Chain proof
// commits to. The wire layout is fixed: HashZPublicInputs(in) is the
// single 48-byte commitment the envelope binds, and every backend MUST
// derive its own public-inputs digest by the same canonical encoding —
// otherwise the binding check in VerifyZProofUnderProfile rejects.
//
// A change to this layout is a hard fork: the TupleHash injectivity
// means any change to the parts list produces a digest stream that
// does not collide with any prior digest, and downstream signature
// verification breaks. There is no compatibility window.
type ZPublicInputs struct {
Version uint16
ProfileID uint32
NetworkID uint32
ChainID uint32
Epoch uint64
PreviousZStateRoot [48]byte
NewZStateRoot [48]byte
TxBatchHash [48]byte
IdentityRoot [48]byte
ValidatorRegistryRoot [48]byte
RevocationRoot [48]byte
StakeWeightRoot [48]byte
CommitteeRoot [48]byte
DKGTranscriptRoot [48]byte
GroupPublicKeyHash [48]byte
QChainTipHash [48]byte
EpochCommitmentHash [48]byte
HashSuiteID config.HashSuiteID
IdentitySchemeID config.SigSchemeID
FinalitySchemeID config.SigSchemeID
ProofPolicyID config.ProofPolicyID
}
// HashZPublicInputs returns the 48-byte canonical digest over in. Every
// field is bound exactly once; the customization tag pins the layout.
// Mutating any field MUST change the digest (see the
// TestHashZPublicInputs_FieldMutationChangesHash test).
//
// Panics on a nil input. A nil ZPublicInputs is a programmer error;
// there is no legitimate "prove over no public inputs" path, and
// returning a well-defined constant for nil opens a constant-collision
// attack class (see F84). Verifier callers MUST construct an explicit
// ZPublicInputs from chain state before invoking this function.
func HashZPublicInputs(in *ZPublicInputs) [48]byte {
if in == nil {
panic("zchain: HashZPublicInputs called with nil *ZPublicInputs")
}
parts := [][]byte{
[]byte(publicInputsProtocolTag),
u16BE(in.Version),
u32BE(in.ProfileID),
u32BE(in.NetworkID),
u32BE(in.ChainID),
u64BE(in.Epoch),
in.PreviousZStateRoot[:],
in.NewZStateRoot[:],
in.TxBatchHash[:],
in.IdentityRoot[:],
in.ValidatorRegistryRoot[:],
in.RevocationRoot[:],
in.StakeWeightRoot[:],
in.CommitteeRoot[:],
in.DKGTranscriptRoot[:],
in.GroupPublicKeyHash[:],
in.QChainTipHash[:],
in.EpochCommitmentHash[:],
{byte(in.HashSuiteID)},
{byte(in.IdentitySchemeID)},
{byte(in.FinalitySchemeID)},
{byte(in.ProofPolicyID)},
}
return tupleHash48(parts, publicInputsCustomization)
}
// =============================================================================
// Reader helpers (private to this file).
// =============================================================================
type envelopeReader struct {
buf []byte
}
func (r *envelopeReader) need(n int) error {
if len(r.buf) < n {
return fmt.Errorf("%w: need %d bytes, have %d",
io.ErrUnexpectedEOF, n, len(r.buf))
}
return nil
}
func (r *envelopeReader) u8() (uint8, error) {
if err := r.need(1); err != nil {
return 0, ErrEnvelopeTruncated
}
v := r.buf[0]
r.buf = r.buf[1:]
return v, nil
}
func (r *envelopeReader) u16() (uint16, error) {
if err := r.need(2); err != nil {
return 0, ErrEnvelopeTruncated
}
v := binary.BigEndian.Uint16(r.buf[:2])
r.buf = r.buf[2:]
return v, nil
}
func (r *envelopeReader) u32() (uint32, error) {
if err := r.need(4); err != nil {
return 0, ErrEnvelopeTruncated
}
v := binary.BigEndian.Uint32(r.buf[:4])
r.buf = r.buf[4:]
return v, nil
}
func (r *envelopeReader) u64() (uint64, error) {
if err := r.need(8); err != nil {
return 0, ErrEnvelopeTruncated
}
v := binary.BigEndian.Uint64(r.buf[:8])
r.buf = r.buf[8:]
return v, nil
}
func (r *envelopeReader) boolByte() (bool, error) {
v, err := r.u8()
if err != nil {
return false, err
}
switch v {
case 0:
return false, nil
case 1:
return true, nil
default:
return false, fmt.Errorf("%w: got 0x%02x", ErrEnvelopeBoolByteOutOfRange, v)
}
}
func (r *envelopeReader) read16(dst *[16]byte) error {
if err := r.need(16); err != nil {
return ErrEnvelopeTruncated
}
copy(dst[:], r.buf[:16])
r.buf = r.buf[16:]
return nil
}
func (r *envelopeReader) read48(dst *[48]byte) error {
if err := r.need(48); err != nil {
return ErrEnvelopeTruncated
}
copy(dst[:], r.buf[:48])
r.buf = r.buf[48:]
return nil
}
func (r *envelopeReader) lenPrefixed() ([]byte, error) {
n, err := r.u32()
if err != nil {
return nil, err
}
if uint64(n) > uint64(len(r.buf)) {
return nil, fmt.Errorf("%w: declared=%d remaining=%d",
ErrEnvelopeProofBytesTooLong, n, len(r.buf))
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = r.buf[n:]
return out, nil
}