Skip to content

tomiet/zkauth

Repository files navigation

Proof-Carrying Authorization (PCA)

This repo contains:

  • A short write-up of the PCA model: paper/pca.md.
  • Python validation helpers + property tests: pca_validation/ and tests/.
  • A Pinocchio-ready Rust library for on-chain verification: pinocchio-pca/.
  • A demo Solana program showcasing private withdrawals: programs/private_payments/.
  • A demo Solana program showcasing private allowlist access: programs/private_allowlist/.

Math Summary (as implemented here)

PCA turns authorization into a proof about a statement rather than an identity. You prove there exists a witness w such that a policy predicate holds for (action, context, public_inputs), and the verifier checks the proof and a nullifier for replay resistance.

Key encodings in this repo:

Statement encoding (conceptual):

stmt = enc(action) || enc(context) || enc(public_inputs)

Scope hash (Rust on-chain, pinocchio-pca/src/hash.rs):

scope = SHA256(DST_SCOPE || action || context || SHA256(p_pieces))

Domain hash (Rust on-chain, pinocchio-pca/src/hash.rs):

domain = SHA256(DST_DOMAIN || protocol_id || policy_id || verifier_id)

Nullifier (defined in the paper and Python helpers, computed off-chain):

nf = SHA256(DST_NF || domain || spend_key || scope)

Notes:

  • The Rust library does not compute nf; it expects the proof to expose it as a public input and enforces uniqueness via a PDA. The nullifier derivation lives in your circuit and off-chain tooling.
  • context in this implementation is a slot window (issued_at_slot, valid_until_slot) serialized to 16 bytes and included in the scope hash. If you need a stable/fresh split, ensure your circuit uses the same bytes that PcaContext::to_le_bytes() produces.
  • On-chain SHA-256 for scope/domain/action hashing uses the sol_sha256 syscall to reduce compute; host builds still use sha2 for deterministic tests.

Public Input Layout (Rust binding)

Pca::authorize expects at least 6 public inputs. The first 6 elements are reserved and must be ordered as:

0: scope_hi
1: scope_lo
2: domain_hi
3: domain_lo
4: nf_hi
5: nf_lo

Each *_hi/*_lo is produced by splitting a 32-byte digest into two 16-byte limbs, then left-padding to 32 bytes (big-endian 128-bit encoding):

hi = 0x0000..00 || digest[0..16]
lo = 0x0000..00 || digest[16..32]

Your circuit must compute scope, domain, and nf with the same hash functions and pack them into public inputs using this encoding.

Pinocchio Usage

Library entrypoint:

  • Pca::authorize validates the domain, slot window, public input prefix, proof verification, and consumes the nullifier PDA.
  • You pass a verifier that implements ProofVerifier (for example, Groth16).

Typical flow inside a program:

use pinocchio_pca::{Pca, PcaContext, PcaDomain};

let pca = Pca::new(PcaDomain::for_program(policy_id, program_id));
let context = PcaContext { issued_at_slot, valid_until_slot };

// p_for_scope is the exact set of bytes your circuit hashed for `scope`.
let p_for_scope = [&public_inputs[0][..], &public_inputs[1][..], ...];

pca.authorize::<N, _>(
    program_id,
    payer,
    nullifier_record,
    rent_sysvar,
    action_bytes,
    context,
    &p_for_scope,
    &nf,
    &proof_a,
    &proof_b,
    &proof_c,
    &public_inputs,
    &verifier,
)?;

On-chain nullifier PDA:

  • Seed prefix: b"pca_nf" plus the 32-byte nf.
  • Record layout (112 bytes): discriminator(8) | nf(32) | scope(32) | domain(32) | created_slot(8).

Example programs:

  • pinocchio-pca/examples/pca_program.rs shows how to parse an instruction and call Pca::authorize. Replace the placeholder verifier with a real one.
  • programs/pca_multisig shows a minimal 2-of-2 multisig that calls Pca::authorize once per signer and enforces shared scope inputs.

Compute Units (program-test snapshot)

Measured via solana-program-test transaction metadata:

  • private_payments WithdrawPrivate: ~198,512 CU (includes proof verification + nullifier PDA + token transfer)
  • private_allowlist AccessPrivate: ~179,535 CU (includes proof verification + nullifier PDA)

Notes:

  • Numbers vary with toolchain/runtime and compute budget settings.
  • Capture commands:
    • cargo test -p private-payments --tests -- --nocapture
    • cargo test -p private-allowlist --tests -- --nocapture

Why not Anchor alone

Anchor can call syscalls, but its default account validation, deserialization, and larger dependency surface add compute and program size. These flows already consume ~180-200k CU for Groth16 verification, hashing, and PDA creation, so framework overhead can push you over budget or force compute budget increases.

To stay in this CU range you still need:

  • syscall-based hashing (sol_sha256) and Groth16 verification (alt-bn128 syscalls)
  • minimal account handling and manual checks

At that point you are effectively outside "Anchor alone", which is why this repo uses Pinocchio for the on-chain core.

Testing

Python (paper-level invariants and crypto games):

uv run pytest

Tests live in tests/ and rely on helpers in pca_validation/.

Rust (library invariants and encodings):

cd pinocchio-pca
cargo test

Optional:

cd pinocchio-pca
cargo check --examples

If you enable Groth16 verification:

cd pinocchio-pca
cargo test --features groth16

About

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors