Privacy & Blockchain

On-Chain Private Verification

Aztec private state, trustless in-circuit verification, and encrypted notes.

The previous section described how Apertrue protects user privacy. This section explains how proof commitments are stored on the Aztec blockchain — enabling trustless verification without relying on a centralized backend.

Aztec is a privacy-first Layer 2 rollup on Ethereum. Unlike public blockchains where all data is visible, Aztec supports encrypted private state — data that exists on-chain but can only be read by its owner. Apertrue uses this to store verified media records as encrypted notes, giving users a tamper-proof, self-sovereign verification history.

The ApertrueVerifier contract

The core smart contract is ApertrueVerifier, written in Noir and deployed to the Aztec network. It has a single primary function: accept a ZK proof, verify it cryptographically, and store the result as an encrypted note.

Storage layout

FieldTypePurpose
adminPublicImmutableContract administrator address, set once at deployment
vk_hashPublicImmutableTree aggregator verification key hash, checked during proof verification
verified_notesOwned<PrivateSet>Per-owner encrypted notes storing verified media records
identity_notesOwned<PrivateSet>ZK-verified organisation identity notes
passport_identity_notesOwned<PrivateSet>ZKPassport-verified personhood notes
verification_rootPublicMutablePublic Merkle root for transparency lookups
tree_sizePublicMutableVerification index size (monotonically increasing)

The vk_hash is stored as PublicImmutable — set once at deployment and never changed. This prevents backdoor attacks: no one can swap the verification key after deployment, even the admin. Private functions can read it via a historical read (zero-cost, no public call needed).

In-circuit proof verification

The contract's verify_and_store function performs trustless cryptographic verification — it does not trust the backend or any external party. The verification happens inside the Aztec circuit itself:

  1. VK hash check. Reads the stored verification key hash from PublicImmutable and asserts it matches the submitted VK hash. This ensures the proof was generated with the expected circuit — preventing circuit substitution attacks.
  2. Proof verification. Calls verify_honk_proof(vk, proof, public_values, vk_hash) — Barretenberg's UltraHonk verifier running inside the Aztec circuit. This performs full cryptographic verification of the aggregated proof.
  3. Public values validation. Asserts that public_values[0..9] == 0 (padding) and public_values[9] != 0 (root commitment exists). The tree aggregator outputs 12 public values — 9 unused padding fields, the root commitment, and two VK hashes for allowlist checking.
  4. Note creation. Creates an encrypted VerifiedMediaNote containing the root commitment, image count, trust list root, and epoch week. The note is encrypted to the owner's address.
  5. Nullifier push. Derives poseidon2([root_commitment, contract_address]) and pushes it to the nullifier tree. If this nullifier already exists, the transaction fails — preventing the same batch from being verified twice.
verify_and_store (simplified)
fn verify_and_store(
    proof: [Field; 500],        // UltraHonk ZK proof
    vk: [Field; 115],           // Verification key
    vk_hash: Field,             // Poseidon2 hash of VK
    public_values: [Field; 12], // [9 zero padding, root_commitment, vk_hash_left, vk_hash_right]
    image_count: Field,
    trust_list_root: Field,
    epoch_week: Field,
    owner: AztecAddress
) {
    // 1. Check VK hash matches stored value
    let stored_vk_hash = storage.vk_hash.read();
    assert(vk_hash == stored_vk_hash);

    // 2. Cryptographically verify the aggregated proof
    verify_honk_proof(vk, proof, public_values, vk_hash);

    // 3. Extract root commitment
    let root_commitment = public_values[9];
    assert(root_commitment != 0);

    // 4. Create encrypted note
    let note = VerifiedMediaNote {
        root_commitment,
        image_count,
        trust_list_root,
        epoch_week
};
    storage.verified_notes.at(owner).insert(note);

    // 5. Push nullifier (replay prevention)
    let nullifier = poseidon2_hash([root_commitment, this_address()]);
    push_nullifier(nullifier);
}
Key Insight
The backend is never involved in on-chain verification. The browser submits the proof directly to the Aztec contract, which verifies it cryptographically. This is trustless — even if the backend is compromised, on-chain verification records cannot be forged.

The VerifiedMediaNote

Each verified batch produces one encrypted note stored in the owner's private set:

FieldTypeContents
root_commitmentFieldMerkle root covering all images in the batch
image_countFieldNumber of images verified in this batch
trust_list_rootFieldOracle trust list root used during proving
epoch_weekFieldISO week number (coarse timestamp for the verification)

The Aztec framework automatically manages additional metadata: owner (derived from the recipient address), randomness (generated by the framework), and a note header. The owner field determines who can decrypt the note — only the holder of the corresponding secret key.

Notes are delivered via UNCONSTRAINED_OFFCHAIN — the most private delivery mode. The note exists on-chain as encrypted ciphertext. No public side effects are created, meaning an external observer watching the chain sees only that some private function was called, not what data was stored.

Transaction flow

End-to-end transaction flow: Browser (Aggregated proof + VK → PXE simulates verify_and_store() → Kernel proofs created → Session key signs TX) → Aztec Node (Validates TX proof → Commits to rollup) → Confirmation (Poll for confirmation → TX confirmed). PXE runs entirely in the browser. SponsoredFPC pays all fees.
  1. Load verification key. The browser fetches the tree aggregator's verification key and VK hash from the sidecar service. These are public parameters — the same for every verification.
  2. Build transaction. The browser constructs a call to verify_and_store with the 500-field proof, 115-field VK, VK hash, 12 public values, image count, trust list root, epoch week, and a diversified owner address.
  3. PXE simulation. The browser's PXE (Private eXecution Environment) simulates the private function locally. This executes verify_honk_proof in WASM, creates the encrypted note, and generates kernel proofs — all in the browser.
  4. Transaction signing. The user's WebAuthn account contract signs the transaction. If a session key is active, it signs with the ephemeral Schnorr key (no biometric prompt). Otherwise, the browser triggers a WebAuthn assertion (biometric).
  5. Submission. The fully proven transaction is sent directly to the Aztec node. The SponsoredFPC pays the transaction fee — the user never needs fee tokens.
  6. Confirmation. The browser polls the Aztec node via PXE. Once the transaction is committed to the rollup, the VerifiedMediaNote is permanently stored on-chain.

PXE — the browser proving environment

The PXE (Private eXecution Environment) runs entirely in the browser. It is the client-side component that makes Aztec's privacy model work — private functions execute locally, and only the proven result is sent to the network.

  • Storage. PXE state is persisted in IndexedDB — encrypted notes, registered contracts, and account data survive page reloads.
  • Note discovery. PXE syncs encrypted notes from the chain and decrypts those belonging to registered accounts. Only accounts whose secret keys are registered with PXE can decrypt their notes.
  • Sandbox restart detection. PXE stores the Aztec node's chain ID and block tip in localStorage. If these change (network restart), PXE clears stale IndexedDB data to prevent "Block hash not found" errors.
  • Diversified key registration. After page reload, PXE re-registers derived keys for each batch nonce so it can decrypt notes from previous batches.

Fee payment

Every Aztec transaction requires a fee payer. Apertrue uses a SponsoredFPC (Fee Payment Contract) that pays on behalf of users — users never need to hold or spend fee tokens.

TransactionFee payerWhen
Account deploymentSponsoredFPCOnce, during wallet initialisation
Session key authorisationSponsoredFPCOnce per session (30 min default)
verify_and_store()SponsoredFPCPer batch submission

The SponsoredFPC is a pre-deployed contract that holds a balance of fee juice (Aztec's gas token). When a transaction is submitted with the SponsoredFPC as the payment method, the Aztec kernel automatically calls the FPC's pay_fees function, which deducts fees from its balance. The user's account is never charged.

Session keys

Every Aztec transaction normally requires a WebAuthn assertion (biometric prompt). For proof submission, this is impractical — a batch upload would require multiple prompts. Session keys solve this: the user performs one biometric to authorize an ephemeral Schnorr/Grumpkin keypair that signs transactions for 30 minutes. Authorisation starts in parallel with ZK proof generation, so by the time proofs complete, the session key is ready and verify_and_store submits without any additional prompt. The full session key lifecycle is detailed in Authentication & Wallet.

Verification lookup

There are two ways to verify that a photo batch was proven on-chain, serving different trust models:

Private lookup (owner only)

The owner queries their own encrypted notes via PXE. Only they hold the decryption key, so only they can see their verification history. This is the primary flow for a user checking their own uploads.

Public verification index

For third-party verification (journalists proving authenticity to editors, courts verifying evidence), the contract maintains a public Merkle tree of all root commitments. The admin periodically updates this tree. Anyone can:

  1. Query the public Merkle root from the contract.
  2. Fetch a Merkle inclusion path for a specific root commitment from the backend API.
  3. Verify the path locally — proving the batch was verified on-chain.

The backend also exposes RFC 6962 consistency proofs, allowing external auditors to verify that the tree only grows (no entries are ever removed or modified).

Lookup typeWho can use itWhat it provesPrivacy cost
Private notesOwner only (needs passkey)Full verification detailsNone — encrypted end-to-end
Public indexAnyoneBatch was verified on-chainReveals that a root_commitment exists (not who created it)

Trust model

The on-chain verification path is designed so that no single party needs to be trusted:

  • The backend cannot forge proofs. The contract verifies the ZK proof cryptographically. A fake proof would fail verify_honk_proof.
  • The backend cannot change the VK. The verification key hash is PublicImmutable — set at deployment and frozen forever. Swapping the VK would require deploying an entirely new contract.
  • The backend cannot replay proofs. The nullifier derived from each root commitment is unique. Submitting the same batch twice fails at the nullifier tree.
  • The user controls their data. Encrypted notes can only be read by the owner. The backend, contract admin, and Aztec node operators cannot decrypt them.
  • The user doesn't need funds. The SponsoredFPC eliminates the requirement for users to acquire and manage cryptocurrency tokens.

The next section covers authentication and wallet management — how WebAuthn passkeys, PRF-derived secrets, and the Aztec account system work together to give users self-sovereign identity without managing seed phrases.