Skip to content

Fix/zk verifier hardening - #175

Merged
nol4lej merged 5 commits into
mainfrom
fix/zk-verifier-hardening
Oct 21, 2026
Merged

nol4lej merged 5 commits into
mainfrom
fix/zk-verifier-hardening

Conversation

@nol4lej

@nol4lej nol4lej commented Oct 21, 2026

Copy link
Copy Markdown
Member

Summary

Hardens how circuit verifying keys are managed, so a Root mistake can no longer reopen an unsound circuit or install a key anyone can forge proofs with, and pins the frozen Groth16 host function to golden vectors. Ships in runtime spec 20 with pallet-zk-verifier 0.19.0 and orbinum-zk-verifier 3.4.0.

Why

  • A spend chooses its circuit version, and any registered, non-retired version verifies; the active version is only the clients' default. Transfer and unshield v1/v2 let k + i·l spending-key aliases give one note several nullifiers (fixed in v3), so retiring them was the only guard. Nothing enforced it: unretire_version checked arity alone, and the same key could be registered again under another version number.
  • Registration accepted a key whose delta is ±gamma or ± the G2 generator — what snarkjs leaves when a circuit's phase 2 never ran. With such a key, (α, β, −L(x)) verifies any statement using public key data only.
  • A removed version could be registered again with another key, silently changing what that version number means for any client that cached it.
  • The host function bn254_groth16_verify (v1) is frozen — executed blocks and syncing nodes depend on it answering the same — but its result depended on crate constants and arkworks' serialized layout with nothing pinning them.

Changes

pallet-zk-verifier 0.19.0

  • DeniedVkHashes: on-chain, append-only record of unsound keys. A denied key is refused by register_verification_key, batch_register_verification_keys, set_active_version and unretire_version (DeniedVerificationKey), and a stored one verifies nothing, active or not. No call removes an entry, remove_verification_key and purge_circuit included.
  • deny_version(circuit_id, version) (Root, call index 8): retires the version and records its key's hash (VerificationKeyDenied). Never the active version; an already-recorded hash is refused (KeyAlreadyDenied).
  • GenesisConfig::denied_vk_hashes (Vec<H256>, 0x… in the chain spec): the record a new chain starts with, written before any genesis key.
  • RemovedVkHashes: remove_verification_key records the removed key's hash, and the version can then only take that same key again (VersionReused). Append-only; purge_circuit leaves it.
  • Weights: one extra read per verification (denied check), one per registered key, one read and one write on removal; deny_version estimated from retire_version until re-benchmarked.

orbinum-zk-verifier 3.4.0

  • VerifyingKey::to_ark_vk refuses delta ∈ {gamma, −gamma, G2, −G2}. Every path that validates a raw key (registration, prepared_bytes, Groth16Verifier, set_active/unretire arity checks) refuses it with InvalidVerifyingKey. Keys already stored in prepared form are not re-checked; the seven on testnet have an independent delta.
  • tests/host_function_v1.rs: golden vectors — the production unshield v3 key, its prepared form as stored on chain, and a real proof. They pin that the host function and verify_prepared agree and accept it, refuse tampered, non-canonical and malformed input, that preparing the key still yields the stored bytes, and the constants v1 depends on. A failure means a change alters what v1 answers: add a #[version(N)] instead of updating the fixtures.

After the upgrade (testnet)

Root records the unsound versions, all already retired:

deny_version(1, 1), deny_version(1, 2), deny_version(2, 1), deny_version(2, 2)

Testing

  • pallet-zk-verifier: 173 passed. orbinum-zk-verifier: 94 unit, 10 real-proof integration, 9 golden. pallet-shielded-pool: 384. Runtime: 85. Benchmarks (runtime-benchmarks): 9, including deny_version. clippy and cargo fmt --check clean; release build OK.
  • Mutation checks on the golden suite: dropping the canonical-input check and changing the prepared layout both make it fail.
  • Dev node started from a chain spec with deniedVkHashes, using real proofs from the production circuit artifacts:
    • Key lifecycle (36/36): a genesis-denied key cannot be registered. With unshield v2 active, real shields and a v2 unshield are included. After rotating to v3, v2 spends are refused and v3 spends included. Reproduced the gap: Root unretires v2 and a v2 spend works again. After deny_version, the v2 spend is refused and every way back — unretire, register as another version, register after removal, batch — fails. v3 transfer and unshields keep working.
    • Keys without phase 2 (26/26): all four degenerate deltas are refused by register and batch. A forged unshield that snarkjs confirms valid for such a key is refused on chain, with no funds moved and the nullifier unspent.
    • Control: with the delta check disabled, the same forged unshield was included and paid 5 ORB with no note and no secret.
    • Removed versions (14/14): another key under a removed version is refused, the original key can return, and the record survives a second removal.
    • Golden vector on chain: verify_proof against the active unshield v3 gives ProofVerified; a tampered proof or a changed input gives VerificationFailed.

…s (deny_version, genesis) — a denied key is refused on register, activate and unretire and never verifies
…2 (a snarkjs key without phase 2), with which anyone could forge proofs
…y take that key again — a client that cached what it means is never served another key under it
… vectors — a production key, its stored prepared form and a real proof, plus the refusals and constants v1 depends on
…verifier 3.4.0 — on-chain denied keys, removed versions keep their key, keys without phase 2 refused, host-function golden vectors (spec 20)
@nol4lej
nol4lej merged commit b65ed2a into main Oct 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant