mirror of
https://github.com/luxfi/zk.git
synced 2026-10-30 15:11:33 +00:00
187 lines
7.3 KiB
Go
187 lines
7.3 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package registry
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// auth_proof.go — execution hot-path verification for transactions whose
|
|
// signature was already proven in a Z-Chain tx_auth batch.
|
|
//
|
|
// The big win HIP-0078 §"Batched auth proof" names: ML-DSA-65 signatures
|
|
// are ~3309 bytes; verifying one on every transaction is wasteful when
|
|
// the rollup has already verified them in bulk. VerifyAuthPassed replaces
|
|
// the per-tx ML-DSA verify with a 48-byte Merkle inclusion check against
|
|
// the rollup's accepted TxAuthRoot.
|
|
//
|
|
// Soundness: the inclusion proof is sound iff the rollup's commitment
|
|
// is sound. The Z-Chain STARK proves that every (account_id, tx_digest)
|
|
// pair below TxAuthRoot was verified under a valid ML-DSA signature
|
|
// against the corresponding registry record. So a successful inclusion
|
|
// check is equivalent to "the chain accepted this signature."
|
|
//
|
|
// Caller is responsible for:
|
|
//
|
|
// 1. Resolving the current TxAuthRoot from the chain's EpochCommitment.
|
|
// 2. Constructing the inclusion proof at batch-acceptance time and
|
|
// handing it to the executor with the transaction.
|
|
// 3. Refusing any tx that does not carry an inclusion proof (the
|
|
// executor can fall back to direct ML-DSA verify if the chain's
|
|
// policy permits, but a strict-PQ chain SHOULD refuse).
|
|
|
|
// MerkleProof is the inclusion witness for a leaf in a 48-byte-wide
|
|
// binary Merkle tree. The verifier walks Siblings from leaf to root,
|
|
// combining at each level according to LeafIndex's bits.
|
|
//
|
|
// Hash family is fixed at cSHAKE256 / TupleHash256 with customization
|
|
// "LUX_MERKLE_NODE_V1" — distinct from any other digest customization
|
|
// in this package so a leaf digest cannot collide with a node digest.
|
|
//
|
|
// Empty Siblings means "leaf is the root" (single-element tree).
|
|
type MerkleProof struct {
|
|
// LeafIndex is the leaf's position in the tree (0-indexed, left
|
|
// to right). Bit i selects the combine direction at level i:
|
|
// 0 = sibling-on-right, 1 = sibling-on-left.
|
|
LeafIndex uint64
|
|
|
|
// LeafHash is the canonical 48-byte leaf digest. Caller computes
|
|
// it the same way the rollup did when committing the batch.
|
|
LeafHash [48]byte
|
|
|
|
// Siblings is the path from leaf to root. Length determines the
|
|
// tree depth; an empty slice means LeafHash IS the root.
|
|
Siblings [][48]byte
|
|
}
|
|
|
|
// merkleNodeCustomization is the cSHAKE256 customization for internal
|
|
// Merkle nodes. Distinct from "LUX_MERKLE_LEAF_V1" (the leaf customization
|
|
// the rollup uses to encode (account_id || tx_digest)) so a leaf digest
|
|
// cannot collide with an internal-node digest.
|
|
const merkleNodeCustomization = "LUX_MERKLE_NODE_V1"
|
|
|
|
// merkleLeafCustomization is the cSHAKE256 customization the rollup
|
|
// uses when committing each (account_id, tx_digest) tuple as a leaf.
|
|
// Exported (not unexported) so the rollup and the verifier share one
|
|
// string literal at one place in the codebase.
|
|
const merkleLeafCustomization = "LUX_MERKLE_LEAF_V1"
|
|
|
|
// LeafDigest returns the canonical 48-byte leaf digest for a
|
|
// (account_id, tx_digest) pair. The rollup computes it the same way
|
|
// when building TxAuthRoot; VerifyAuthPassed recomputes it before
|
|
// the inclusion walk.
|
|
//
|
|
// Bound via TupleHash256 so account_id || tx_digest is unambiguously
|
|
// framed. A flipped byte in either input changes the digest.
|
|
func LeafDigest(accountID [48]byte, txDigest [48]byte) [48]byte {
|
|
parts := [][]byte{
|
|
[]byte("Lux/MerkleLeaf/AuthBatch/v1"),
|
|
accountID[:],
|
|
txDigest[:],
|
|
}
|
|
return tupleHash48(parts, merkleLeafCustomization)
|
|
}
|
|
|
|
// nodeDigest combines two child digests into a parent.
|
|
func nodeDigest(left, right [48]byte) [48]byte {
|
|
parts := [][]byte{
|
|
[]byte("Lux/MerkleNode/v1"),
|
|
left[:],
|
|
right[:],
|
|
}
|
|
return tupleHash48(parts, merkleNodeCustomization)
|
|
}
|
|
|
|
// VerifyAuthPassed returns nil iff (accountID, txDigest) was included
|
|
// in an accepted Z-Chain tx_auth batch whose root equals txAuthRoot.
|
|
// The Merkle inclusion walk is the entire hot-path cost — no ML-DSA
|
|
// verify happens here because the Z-Chain STARK already proved every
|
|
// signature in the batch.
|
|
//
|
|
// Checks (fail-closed, in order):
|
|
//
|
|
// 1. txAuthRoot is non-zero.
|
|
// 2. proof.LeafHash equals LeafDigest(accountID, txDigest).
|
|
// Closes the "wrong leaf with a valid inclusion path" attack.
|
|
// 3. proof.LeafIndex fits within the proof depth (a 2^d-leaf tree
|
|
// admits LeafIndex < 2^d). Refuses an out-of-range index.
|
|
// 4. Walk Siblings from leaf to root combining via nodeDigest; the
|
|
// final hash MUST equal txAuthRoot.
|
|
//
|
|
// Returns ErrAuthProofStaleEpoch when the caller-supplied txAuthRoot
|
|
// is the all-zero sentinel — by convention the registry uses zero to
|
|
// mean "no batch accepted yet at this epoch" so a stale-epoch lookup
|
|
// surfaces an immediate refusal rather than a silently-passing all-zero
|
|
// inclusion check.
|
|
func VerifyAuthPassed(
|
|
txAuthRoot [48]byte,
|
|
accountID [48]byte,
|
|
txDigest [48]byte,
|
|
inclusionProof MerkleProof,
|
|
) error {
|
|
if isZero48(txAuthRoot) {
|
|
return ErrAuthProofStaleEpoch
|
|
}
|
|
expectedLeaf := LeafDigest(accountID, txDigest)
|
|
if inclusionProof.LeafHash != expectedLeaf {
|
|
return fmt.Errorf("%w: leaf digest does not match (account_id, tx_digest)", ErrAuthProofLeafMismatch)
|
|
}
|
|
depth := len(inclusionProof.Siblings)
|
|
if depth >= 64 {
|
|
// A 2^64-leaf tree is implausible; refuse before integer overflow.
|
|
return ErrAuthProofDepthExceeded
|
|
}
|
|
// LeafIndex must fit within the proof depth.
|
|
if depth < 64 {
|
|
maxIndex := uint64(1) << depth
|
|
if inclusionProof.LeafIndex >= maxIndex {
|
|
return fmt.Errorf("%w: leaf_index=%d depth=%d",
|
|
ErrAuthProofIndexOutOfRange, inclusionProof.LeafIndex, depth)
|
|
}
|
|
}
|
|
// Walk leaf -> root.
|
|
cur := inclusionProof.LeafHash
|
|
for level, sibling := range inclusionProof.Siblings {
|
|
bit := (inclusionProof.LeafIndex >> uint(level)) & 1
|
|
if bit == 0 {
|
|
// Leaf path bit = 0 → current is left child, sibling is right.
|
|
cur = nodeDigest(cur, sibling)
|
|
} else {
|
|
// Leaf path bit = 1 → current is right child, sibling is left.
|
|
cur = nodeDigest(sibling, cur)
|
|
}
|
|
}
|
|
if cur != txAuthRoot {
|
|
return ErrAuthProofWrongRoot
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Typed errors
|
|
// =============================================================================
|
|
|
|
var (
|
|
// ErrAuthProofStaleEpoch — txAuthRoot was the all-zero sentinel,
|
|
// meaning no batch has been accepted yet at this epoch.
|
|
ErrAuthProofStaleEpoch = errors.New("registry: tx_auth_root is zero (stale or pre-batch epoch)")
|
|
|
|
// ErrAuthProofLeafMismatch — proof.LeafHash did not match
|
|
// LeafDigest(account_id, tx_digest). Wrong leaf for the claimed
|
|
// (account, tx).
|
|
ErrAuthProofLeafMismatch = errors.New("registry: inclusion proof leaf digest mismatch")
|
|
|
|
// ErrAuthProofIndexOutOfRange — proof.LeafIndex was >= 2^depth.
|
|
ErrAuthProofIndexOutOfRange = errors.New("registry: inclusion proof leaf index out of range")
|
|
|
|
// ErrAuthProofDepthExceeded — proof depth >= 64; implausibly large
|
|
// tree. Refused before integer-overflow risk.
|
|
ErrAuthProofDepthExceeded = errors.New("registry: inclusion proof depth >= 64")
|
|
|
|
// ErrAuthProofWrongRoot — the walked root did not equal txAuthRoot.
|
|
// Wrong inclusion path for the claimed (account, tx) pair.
|
|
ErrAuthProofWrongRoot = errors.New("registry: inclusion proof root does not match tx_auth_root")
|
|
)
|