Zero-Knowledge Validity Proofs
This document specifies the ZK validity-proof layer for the ENC protocol: a succinct proof that an SMT state transition produced by the node is valid under the RBAC rules, verifiable by a client in O(1) without replaying the event log or trusting the node.
It complements the Merkle proofs in proof.md (which prove inclusion against a root the client already trusts) by proving the root transition itself is well-formed.
Table of Contents
- Motivation
- Proving System
- SMT Hash: Poseidon2 over BN254
- The Transition Circuit
- Multi-Leaf Transitions
- Folding (Incremental Verifiable Computation)
- Out of Circuit
- Verification Flow
- Status
Motivation
In the base protocol the node is an untrusted sequencer: it orders events, applies them to the
Enclave State SMT (see smt.md), and publishes roots committed via Certificate Transparency.
A client that wants to trust a new state_hash otherwise has to either re-execute every event or trust the node.
A ZK validity proof removes that choice. For each state-changing AC event the node (acting as prover) emits a proof that
old_root --[valid RBAC transition for this event]--> new_rootThe client (acting as verifier) checks the proof against the public roots and is convinced the transition obeys every authorization rule — semiring evaluation, rank, state preconditions, leaf deletion — without seeing the witness or recomputing the tree. This makes ENC a validity rollup for RBAC state.
The same transition relation is proven in two complementary forms: a bounded Groth16 proof of one transition (or a fixed-size batch), and a single folded proof over the entire transition history (Nova IVC — see Folding). The folded form gives O(1) verification of an unbounded chain, which the bounded form cannot.
| Role | Who | Sees |
|---|---|---|
| Sequencer | Node | Orders + applies events (untrusted) |
| Prover | Node (or a delegated prover) | Full witness: pre-image leaves, siblings, schema |
| Verifier | Client | Public inputs + proof only; O(1) |
Proving System
The validity relation old_root → new_root MUST be proven in two complementary modes, both over BN254; implementations MUST use the choices in the following table:
| Bounded | Unbounded (folding) | |
|---|---|---|
| Proof system | Groth16 over R1CS | Nova IVC (folding) + Spartan compression |
| Scope | one transition, or a fixed batch chained through intermediate roots | an incremental, unbounded chain — the enclave's whole transition history |
| Prover | one circuit, size O(N) in batch length | O(1) work per step, constant memory |
| Verifier | O(1) (≈3 pairings) | succinct: O(log n)-size compressed proof on the transparent (IPA) path, O(1) with the optional KZG decider |
| Curve(s) | BN254 (alt-bn128) | BN254 + Grumpkin (the second curve of the cycle) |
Both modes use the BN254 scalar field Fr
(p = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001) as the field over which the
SMT's Poseidon2 hash is defined, so SMT membership is proven natively in-circuit.
The bounded Groth16 verifier is pairing-based and on-chain-friendly (EIP-196/197); the unbounded path folds each transition with Nova and compresses the final instance to one succinct proof.
Trusted setup: Groth16 (and the optional KZG-based folding decider) require a per-circuit / universal setup; the transparent folding path (IPA/Pedersen commitments) requires none.
Verifying keys are published and pinned by clients; a proof is valid only against the pinned key.
SMT Hash: Poseidon2 over BN254
The Enclave State SMT leaf and node hashing use Poseidon2 over BN254 (a SNARK-native hash), so the prover can prove SMT membership inside the circuit cheaply.
The canonical instance is normative:
| Parameter | Value |
|---|---|
| State width | t = 3 (rate 2, capacity 1) — clean 2-to-1 compression |
| S-box | x^5 (d = 5) |
| Full rounds | R_F = 8 (4 + 4 external) |
| Partial rounds | R_P = 56 (internal) |
External matrix M_ext | MDS circulant [[2,1,1],[1,2,1],[1,1,2]] |
Internal matrix M_int | [[2,1,1],[1,2,1],[1,1,3]] (1 + diag[1,1,2] ones-form) |
| Round constants | NUMS: `RC_g = Fr::from_be_bytes_mod_order(sha256("ENCv1-Poseidon2-BN254-t3" |
The round structure is the Poseidon2 form (distinct from Poseidon v1): an initial external linear
layer, R_F/2 external rounds, R_P internal rounds, R_F/2 external rounds.
R_F, R_P, d, and M_int match the official Poseidon2 BN256 reference (HorizenLabs/poseidon2).
The round constants are an intentional NUMS deviation from the reference Grain-LFSR — a nothing-up-my-sleeve choice for trivial cross-implementation reproducibility; security rests on the (matching) round structure and matrices, not on the specific constants.
The full instance, including the materialized constants, is the cross-language contract and MUST be embedded, never re-derived per implementation.
Hash modes
Implementations MUST compute each hash mode per the following equations:
compress(cap, l, r) = Poseidon2_permute([cap, l, r])[0]
leaf_hash(key, value) = compress(DOMAIN_LEAF, F(key), F(value))
node_hash(left, right) = compress(DOMAIN_NODE, F(left), F(right))Where F(b) interprets a 32-byte big-endian string as an Fr (reduced mod p), and the capacity
element carries the domain constant.
This replaces the SHA-256 prefix-byte domain separation of smt.md:
| Constant | Value | Replaces |
|---|---|---|
DOMAIN_LEAF | Fr(0x20) | SMT-leaf prefix 0x20 |
DOMAIN_NODE | Fr(0x21) | SMT-node prefix 0x21 |
Unchanged from smt.md
-
Tree depth stays 168 bits, MSB-first traversal, namespaced fixed-depth sparse tree.
-
Key derivation stays
namespace || sha256(raw)[0:160 bits]— SHA-256, not Poseidon2.The in-circuit identity→key derivation (
derive_key(ns, id) = poseidon2(...)) is deliberately not adopted: the circuit takes the SMT key as a public input and the binding to identity is enforced out-of-circuit (see Identity Binding), which is sound and avoids a second SMT migration plus ~30k constraints of in-circuit SHA-256. -
Empty sentinel stays
sha256("")(reduced intoFrfor hashing). Empty subtree hashes are precomputed per level. -
Leaf value encoding: a 32-byte SMT leaf value is interpreted as a single
FrviaF(b) = Fr::from_be_bytes_mod_order(b)(32-byte big-endian → Fr, reduced modulop).RBAC bitmasks (State bits 0-7, trait bits 8+) fit directly in
Fr(< 2^253) and the reduction is a no-op for them.Arbitrary 32-byte values (event-status ids, KV content hashes, SMT leaf hash outputs from a SHA-256 path) MAY exceed
pby a small margin (2^254 ≤ x < 2^256); the modular reduction is sound by reduction to discrete-log hardness of the underlying 32-byte source — the collision probability between two SHA-256 outputsa, bwitha ≡ b (mod p)is~2^-254, indistinguishable from collision in SHA-256 itself.
The Transition Circuit
The circuit proves a single state-changing AC event drives old_root → new_root validly.
There is one circuit family parameterized by event shape; the single-leaf circuit is the core, and multi-leaf events chain it (see Multi-Leaf Transitions).
Public Inputs
A single-leaf transition exposes exactly these field elements as public inputs:
| Input | Meaning |
|---|---|
old_root | SMT root before the event |
new_root | SMT root after the event |
author_key | 21-byte SMT key of the event author (operator), as Fr |
target_key | 21-byte SMT key of the target identity, as Fr |
Everything else — the author's and target's leaf values (bitmasks), the Merkle siblings and presence
bitmaps for both leaves, the action schema, the operator rank, the Move from-state, the Grant scope
— is a private witness. The verifier learns only that some valid witness exists.
Enforced Constraints
The circuit enforces the complete node authorization pipeline (see rbac/events.md):
-
Membership binding (old).
fold_membership(target_key, old_target_leaf, siblings, bitmap)recomputes toold_root. Same for the author leaf. This binds the witnessed pre-image leaves to the publicold_root. -
Authorization (semiring). The RBAC semiring verdict (deny-override via
_oprows, Public/Any/ wildcard/Self columns) over the operator's traits MUST beallowfor this event type and operation. -
Rank. The operator's best rank MUST be strictly less than the target's (skipped for the no-traits and self cases per the spec rule).
-
Event-specific preconditions.
-
Move:
current_state == from(else the transition is invalid), set the State enum and clear trait flags unlesspreserve. -
Grant: the trait being granted MUST be in-scope for the current State.
-
Revoke / Transfer: clear/move the trait bit.
-
-
Mutation + leaf deletion. The new target leaf is the mutated bitmask, except a bitmask of
0removes the leaf (substitutes the empty sentinel) — an identity with no roles is not in the tree. -
Membership binding (new). Re-folding the mutated target leaf through the same siblings MUST recompute to
new_root. (Single-leaf updates share siblings, so this is sound without a divergence-detection multiproof.)
A proof exists iff all of the above hold. A forged new_root (or any rule violation) is
unsatisfiable.
Emptiness handling (bounded vs folding). This bounded circuit takes the per-level sibling-presence bitmap as a private witness (pinned by the old-root membership binding) and covers present-leaf update and deletion (bitmask→0). Insert from a non-membership old state, and the empty-subtree short-circuit generally, are provided by the folding step (see Folding), which derives emptiness in-circuit from the sibling values rather than from a witnessed flag.
Identity Binding
author_key and target_key are public 21-byte SMT keys, not raw identities. The verifier closes the
identity binding out-of-circuit by checking
key == namespace || sha256(id_pub)[0:160 bits]against the id_pub it already authenticated (e.g. from the Schnorr-signed commit). This is sound —
the key→identity map is a single trivial SHA-256 the verifier already computes — and keeps the
expensive SHA-256 out of R1CS. is_self is derived in-circuit from author_key == target_key, so the
Self authorization path cannot be forged.
Multi-Leaf Transitions
Events that touch more than one leaf MUST be proven as chains of single-leaf transitions through intermediate roots, rather than with a single multi-leaf multiproof, with each multi-leaf event constructed per the following table:
| Event | Construction |
|---|---|
| Transfer | Two chained single-leaf updates (clear operator trait, set target trait) through one intermediate root mid_root |
| AC_Bundle | N chained single-leaf updates through N−1 intermediate roots |
| Manifest | Genesis: chained inserts from init entries (insert support via the folding step) |
For a bundle, old_root and every intermediate root are public inputs; the final root is the
published new_root. Each link is the single-leaf circuit above, so atomicity is proven as "the whole
chain verifies or none does." This avoids an in-circuit divergence-detection gadget: because each link
is an independent single-leaf update with its own membership binding against the previous root, the
chain is sound.
Folding (Incremental Verifiable Computation)
The bounded forms above prove a fixed-size batch: the circuit grows O(N) in the number of
transitions, so there is a ceiling. But the Enclave State SMT also holds arbitrary application state
(KV namespace 0x02, see smt.md), so the number of state transitions scales with app
activity and is unbounded over an enclave's lifetime — no fixed batch can cover the whole history.
Folding (Incrementally Verifiable Computation, via the Nova folding scheme) closes this: each step folds one transition into a running instance, so the prover does O(1) work per step with constant memory, and a final compression SNARK yields one O(1)-verifiable proof of the entire chain.
Step relation. The IVC state is the two field elements z = [smt_root, schema_commitment]. The
first moves each step (the SMT root); the second is constant across the chain — a commitment to the
enclave's manifest schema, carried unchanged.
Each fold step proves
the full per-transition authorization relation — membership of the old leaf in z[0], the RBAC
semiring + rank rule on the author (with is_self derived in-circuit), the Move from-state
precondition and Grant state-scope (Enforced Constraints item 4), the apply_op mutation, and the new
root (including insert of a leaf into an empty slot and delete when a bitmask reaches 0) — and
outputs the next root.
Universal step + witnessed op/schema. One circuit proves any single-leaf transition (RBAC, KV,
or event-status), with is_ac (whether RBAC authorization applies) derived in-circuit from the target
key's namespace byte — so one IVC chain covers an enclave's whole state across all namespaces. To make
the constraint matrices identical for every transition (a precondition for a single shared
PublicParams), both the event op (Grant/Revoke/Move of any trait) and the action schema
(allow/deny flags + ranks, with trait positions fixed at bits 8+k) are witnessed circuit inputs
rather than baked constants. So one PublicParams folds arbitrary ops under any enclave schema.
Schema binding. Because the schema is witnessed, the step also binds it: it hashes the witnessed
schema in-circuit to schema_commitment = Poseidon2(0x53, flags_packed, ranks_packed) and enforces it
equals z[1]. The verifier sets z[1] from the published manifest (the same hash), so a prover
cannot fold under a forged, more-permissive schema — the proof is valid only for the manifest's
schema. (This is stronger than the bounded circuit, where the schema is a trusted private witness.)
Folding N steps attests every transition in the chain from a trusted r_0 to a final r_n,
with no replay; binding r_n to the node's published state_hash is the verifier's job (see
Verification below).
Construction. The folding construction MUST follow the choices in the table below:
| Aspect | Choice |
|---|---|
| Folding scheme | Nova (the canonical microsoft/Nova nova-snark), over a BN254/Grumpkin 2-curve cycle |
| IVC state | z = [smt_root, schema_commitment] — the root moves; the commitment is constant (carried) |
| Step hash | the same Poseidon2-over-BN254 instance as the node and the bounded circuit (byte-identical) |
| Op + schema | witnessed circuit inputs (not baked) → one PublicParams folds arbitrary ops under any schema |
| Schema binding | witnessed schema hashed in-circuit to Poseidon2(0x53, flags, ranks) and enforced == z[1] |
| Commitments | IPA/Pedersen — transparent, no trusted setup (default); KZG optional for constant-size proofs |
| Final proof | a Spartan SNARK compressing the folded instance to one succinct proof |
The empty-subtree short-circuit in the folding step is derived in-circuit from the sibling values (a parent of two empty children stays the sentinel) rather than taking a witnessed "sibling present" flag, so the emptiness check is bound to the actual sibling value (no free witness) — the basis for non-membership, insert, and delete, pending the external review noted under Status.
Because the step hash is the identical Poseidon2 instance, a folded root commits to state exactly as
the node's root does, and the folding step enforces the same authorization relation as the bounded
circuit. The one remaining difference is identity binding: the bounded circuit exposes the
author/target keys as public inputs (so the verifier can bind them to signed events out-of-circuit),
whereas the folding step witnesses the keys and derives is_self — the IVC state is [smt_root, schema_commitment], no per-step keys. So a folded proof attests the same per-transition validity,
without exposing the per-step identities; the anchoring binds roots to the signed log (which is
exactly what the folding path certifies: every transition between two log-anchored roots was valid).
Verification. Recompute schema_commitment from the published manifest. Obtain r_0 from a trusted
genesis/checkpoint (or anchor it via CT + STH as in the bounded flow), then verify the compressed
folding proof against z = [r_0, schema_commitment], checking the attested final state is [r_n, schema_commitment]. Success means every one of the (unboundedly many) intervening transitions was
valid and folded under the manifest's schema — in O(1), with no replay. A reference implementation
of this anchored verification (STH + CT inclusion + schema recompute + proof) lives in the impl-zk
verifier/ crate.
Out of Circuit
The following checks MUST be performed out-of-circuit by the client with ordinary cryptography, and linked to the proof only by equality of the public field elements (the roots and keys). Putting SHA-256/CBOR/secp256k1 in-circuit would cost millions of constraints per event for no soundness gain.
| Check | Where | How it links |
|---|---|---|
| Commit signature (Schnorr / ECDSA / cosign cert) | Out-of-circuit | Authenticates commit.from (the parent id_pub) → author_key. Sub-key-authored commits validate the cosign cert per spec.md §Delegated Sub-keys; the SMT key still derives from the parent id_pub, never the sub-key. |
| CBOR event/commit hash recompute | Out-of-circuit | Standard event verification |
| CT inclusion / consistency | Out-of-circuit (proof.md) | Binds state_hash to the log |
| STH signature | Out-of-circuit (proof.md) | Anchors the published root |
| Identity → SMT key | Out-of-circuit (verifier SHA-256) | Binds public *_key to identity |
Verification Flow
To accept a node-claimed state transition for an AC event:
-
Authenticate the event out-of-circuit: verify the commit signature per
commit.alg(spec.md§Signature Schemes — Schnorr for the default path; ECDSA for thealg: "ecdsa"path; cosign cert validation for sub-key-authored commits perspec.md§Delegated Sub-keys). Recompute the CBOR event hash. In all cases,id_pub_author = commit.fromandid_pub_target = content.target(when present); RBAC and the SMT key derivation are keyed to the parent identity, not the sub-key, so a cert-authorized commit produces the sameauthor_keyas a direct-signed commit by the same parent. -
Derive the expected SMT keys
namespace || sha256(id_pub)[0:160 bits]and check they equal the proof's publicauthor_key/target_key. The hash input is the 32-byte x-onlyid_pub(the field-name used inspec.md§reg_identity — notid_pub_fullorsub_pub). Sub-keys are not in the SMT and are never used as SMT-key material. -
Establish that
old_rootis trusted — typically via a prior accepted transition, or via a CT inclusion proof + STH anchoringold_rootto the signed log (proof.md). -
Verify the proof against the pinned verifying key. Bounded mode: a Groth16 proof over public inputs
(old_root, new_root, author_key, target_key)(plus any intermediate roots for multi-leaf events). Unbounded mode: the compressed folding (Spartan) proof overz = [r_0, schema_commitment]→[r_n, schema_commitment], whereschema_commitmentis recomputed from the published manifest — see Folding. -
On success,
new_root(bounded) /r_n(folded) is accepted as a valid successor reached by valid transition(s) — without replaying the SMT.
The proof certifies validity of the transition; the CT/STH layer certifies which transition the log
commits to. Together a client trusts a fresh state_hash end-to-end with O(1) work.
Production Readiness
This layer is implemented and reference-tested but not yet production-locked:
- Bounded (Groth16). The validity proof verifies over a live node's real Poseidon2 SMT roots (real signed RBAC transition → Groth16 proof → client verify; a forged root is rejected). The single-leaf circuit is ~127k constraints; Transfer/AC_Bundle chain it; the Move precondition, Grant state-scope, rank rule, and leaf deletion are enforced.
- Unbounded (folding). The Nova IVC layer is implemented on the canonical Nova implementation:
single-leaf membership-and-update, the full in-circuit RBAC authorization relation (semiring +
rank +
apply_op+ Move from-state precondition + Grant state-scope), and structure-changing insert/delete are each folded and recursively verified. The universal step proves any single-leaf transition (RBAC/KV/event-status), and the op and schema are witnessed inputs — so one sharedPublicParamsfolds arbitrary ops under any enclave schema, with the witnessed schema bound to a manifest commitment inz[1](a prover cannot fold under a forged schema). A Spartan compression to one succinct proof is demonstrated end-to-end on the Poseidon2 fold chain. The folding step's Poseidon2 is gated byte-identical to the node (shared golden vectors). The full out-of-circuit verifier (impl-zkverifier/crate) is implemented and run end-to-end against a livewrangler devnode: a real signed Grant is folded through the prover-service, and the client verifies the node's real STH (Schnorr) + CT inclusion proofs anchoring bothr_0andr_nto the signed log, then the compressed folding proof against[r_0, schema_commitment]— acceptingr_n, and rejecting any tampering. It runs ceremony-free (transparent IPA commitments). The bounded circuit additionally exposes the keys as public inputs for identity binding; the folding step witnesses them (a by-design IVC difference). Each fold step is sizable (~1.5·10⁵ constraints); "O(1) per step" is asymptotic in chain length, not small. - Poseidon2 is byte-identical across the JS node, the Rust node logic, the bounded circuit, and the
folding step; the instance is validated against the official BN256 reference for
R_F/R_P/d/M_int(round constants an intentional NUMS deviation, above). - Trusted setup. Groth16 (and the optional KZG folding decider) need a real setup ceremony before production; the transparent IPA folding path does not. Verifying keys MUST be published and pinned.
The reference implementation lives in the impl-zk circuit crate (bounded) and its folding/ crate (IVC).