Proof System

Browser Proving Engine

Hardware detection, memory budgets, and parallel worker management.

The previous sections described the circuits — what gets proven and how proofs are aggregated. This section explains where all of that runs: entirely in the user's browser, using WebAssembly and web workers.

Apertrue generates ZK proofs client-side. No image data or private metadata leaves the browser during proof generation. The proving engine detects the device's hardware capabilities, allocates workers within a memory budget, loads the correct circuit variants, and orchestrates the entire pipeline from split proofs through aggregation — all inside a browser tab.

Hardware detection

Before allocating any workers, the engine reads two browser APIs to understand the device:

APIWhat it providesLimitations
navigator.hardwareConcurrencyLogical CPU core countAlways available; falls back to 1
navigator.deviceMemoryApproximate RAM in GB (bucketed: 0.25, 0.5, 1, 2, 4, 8)Chrome/Edge only. Caps at 8 GB. Absent in Firefox and Safari.
SharedArrayBufferMulti-threaded WASM supportRequires COOP/COEP headers
crossOriginIsolatedWhether COOP/COEP headers are activeRequired for SharedArrayBuffer

Because deviceMemory caps at 8 GB and is absent in Firefox/Safari, the system uses hardwareConcurrency as a proxy for RAM: 16+ cores implies 32 GB, 12+ implies 16 GB, 8+ implies 8 GB. The system takes whichever signal gives the higher tier, preventing false-low estimates.

Memory tiers and worker allocation

Based on the detected hardware, the engine assigns a memory budget and allocates workers:

TierBudgetSplit workersAgg workersMax batchPipelining
High (16+ cores)3000 MB44 (2 overlap)10 imagesYes
Mid (8+ cores)2000 MB236 imagesNo
Low (<8 cores)1200 MB114 imagesNo

Each split worker costs ~400 MB of physical memory (SRS + WASM linear memory + circuit data). Each aggregation worker costs ~350 MB. The budget ensures the total physical memory used by all workers stays within what the device can handle without crashing the browser tab.

Note
WASM virtual memory allocation is much higher than physical usage — each worker reports ~2,500 MB of virtual address space. But modern operating systems only back pages when written, so the actual physical footprint is ~400 MB. The budget system tracks estimated physical cost, not virtual allocation.

Web workers

All proving runs in web workers — background threads that don't block the UI. The workers are esbuild bundles (not native ES modules) served from /wasm/. Bundling with esbuild makes the entire dependency tree available offline and avoids cross-origin issues with worker script loading.

Worker types

  • Split worker (zk-worker-split.js) — generates ProofA and ProofB for a single image. Holds one Barretenberg instance with two UltraHonk backends.
  • ProofB worker (zk-worker-proof-b.js) — dedicated ProofB worker for dual-worker mode on high-end devices. Receives ProofB inputs from a split worker that only generates ProofA.
  • Aggregator worker — generates image aggregation and tree aggregation proofs. Holds its own Barretenberg instance with both aggregation circuit backends.

Message protocol

Workers communicate with the main thread via structured messages:

MessageDirectionPurpose
initializeMain → WorkerStrategy, thread count, algorithm hints
initialisedWorker → MainSuccess/failure, memory usage
prove-splitMain → WorkerImage data, manifest, privacy settings, recursive flag
progressWorker → MainStage, percentage, message, memory usage
prove-split-completeWorker → MainProof result with optional recursive artefacts
prove-split-errorWorker → MainError message
load-circuitMain → WorkerHot-load a different circuit variant

Worker initialisation

When a worker receives the initialize message, it goes through these steps:

  1. Module imports. Lazily imports Noir, Barretenberg, the C2PA extractor, circuit input builder, and oracle client via dynamic import().
  2. ACVM and ABI init. Initialises the Noir WASM modules for witness generation.
  3. Circuit fetch. Fetches one or two circuit JSON files from /circuits/ based on algorithm hints. Specialised variants are selected when the algorithm is known in advance.
  4. Barretenberg instantiation. Creates the WASM proving engine with the configured thread count. Multi-threading requires SharedArrayBuffer.
  5. SRS loading. Loads the Structured Reference String — 221 BN254 curve points (~200 MB). This is the slowest step on first load. Subsequent loads serve from IndexedDB cache (~3 seconds vs ~30 seconds).
  6. Backend creation. Two UltraHonk backends (one per circuit) sharing the same Barretenberg instance and SRS.
  7. VK pre-warming. After reporting success, the worker generates and caches verification keys in IndexedDB in the background. This saves 4-10 seconds on subsequent proof jobs.

The SRS

The Structured Reference String is a set of elliptic curve points on BN254, required by the UltraHonk proving system. It's the largest single resource the engine needs — roughly 200 MB for 2,097,152 points.

  • Source: Fetched from the Aztec CRS CDN, proxied through a Next.js API route at /api/crs/ to comply with COEP headers.
  • Caching: Stored in IndexedDB under the keyval-store database. After the first fetch, subsequent loads are near-instant.
  • Sharing: Within a single worker, the SRS is loaded once and shared between both UltraHonk backends (ProofA and ProofB).
  • Range requests: The proxy forwards HTTP Range headers, allowing Barretenberg to fetch only the portion of the SRS needed for the current circuit size.
Why a CRS proxy?
The browser's Cross-Origin-Embedder-Policy (credentialless) blocks cross-origin fetches that don't opt in with Cross-Origin-Resource-Policy headers. The Aztec CDN doesn't set these headers. The proxy fetches server-side and streams the response back as same-origin, bypassing the restriction. Worker bundles are patched at build time to replace CDN URLs with /api/crs/.

Circuit variant selection

Rather than loading a single large circuit that handles all algorithms, the engine selects the smallest specialised variant that matches the image's cryptographic algorithm. This reduces constraint counts by 40-66% and proportionally reduces proving time.

The circuit registry maps algorithm types to circuit files:

  • ProofA variant — selected by the intermediate certificate's key type: ECDSA P-256 (~120K constraints), ECDSA P-384 (~150K), RSA-2048 (~180K), RSA-4096 (~220K), Skip (~80K for 2-cert chains), or Unified fallback (~350K).
  • ProofB variant — selected by the COSE signature algorithm: ES256 (~100K) or PS256 (~160K), with Unified fallback (~200K).

If a worker was initialised for one algorithm and receives an image with a different algorithm, it hot-reloads the correct circuit variant — recreating the UltraHonk backend while reusing the existing Barretenberg instance and SRS. If the specialised variant fails to load (network error, 404), the worker falls back to the unified circuit.

The proving flow

End-to-end proving flow: Upload images, C2PA extraction (parse JUMBF, extract manifest, determine trust tier), privacy config (user selects disclosure levels), split proofs (parallel ProofA + ProofB per image), release workers, image aggregation, tree aggregation to root, backend verify, optional Aztec TX for on-chain record.
  1. Extraction. Each image's C2PA manifest is parsed, the trust tier is determined, and Tier 4 images are blocked. The algorithm type is detected for circuit variant selection.
  2. Privacy configuration. The user selects disclosure levels for time (hidden, year, month, exact) and location (hidden, country, city, exact). These become circuit inputs for the range proofs.
  3. Split proofs. The prover pool distributes images across split workers. Each worker generates ProofA and ProofB in parallel. Progress updates flow from workers to the UI via the message protocol.
  4. Worker release. After all split proofs complete, split workers are terminated, freeing memory for aggregation.
  5. Aggregation. The aggregation orchestrator runs image aggregation (one per image) then tree aggregation (binary tree) to produce a single root commitment and proof.
  6. Backend submission. One API call sends the aggregated proof, root commitment, content hashes, and nullifiers. The backend verifies and stores atomically.
  7. On-chain record. If blockchain verification is enabled, the browser submits an Aztec private transaction with the root commitment.

Progress reporting

The UI shows real-time progress at three levels:

  • Initialisation progress — a 0-100% bar during SRS loading and circuit fetching, shown with a pulsing shield icon.
  • Per-image progress — each image shows its current stage (extracting, verifying, proving, uploading, complete) with an animated progress bar.
  • Aggregation progress — stage label (aggregating images, aggregating tree), current level, and completion count within the level.

Error recovery

The engine handles failures at multiple levels:

  • Worker crash. If a worker's onerror fires, the current job is marked as failed and remaining jobs continue on other workers.
  • Circuit mismatch. If the loaded circuit doesn't match the image's algorithm, the worker hot-reloads the correct variant. If that fails, it falls back to the unified circuit.
  • Aggregation failure. If any aggregation step throws, the system falls back to submitting individual proofs without aggregation. The backend accepts both aggregated and individual proof formats.
  • Pipeline timeout. Pipelined image aggregation (high-end overlap mode) has a 60-second timeout. If it expires, the system falls back to batch aggregation.

WASM requirements

The proving engine requires specific browser capabilities:

  • SharedArrayBuffer — required for multi-threaded WASM. Without it, Barretenberg falls back to single-threaded mode (significantly slower).
  • Cross-Origin Isolation — the server must set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless. These headers are configured in next.config.js and applied to all routes.
  • IndexedDB — used for SRS caching and verification key caching. If unavailable (private browsing in some browsers), every session re-downloads the SRS.
  • WebAssembly — the proving system is compiled to WASM from C++ (via Barretenberg) and Rust (via Noir's ACVM). All modern browsers support WASM.
Key Insight
COEP: credentialless is used rather than require-corp specifically to allow cross-origin resource loading (fonts, analytics) while still enabling SharedArrayBuffer. The credentialless policy permits cross-origin fetches without the Cross-Origin-Resource-Policy header, as long as credentials are omitted.

The next section covers the privacy model — how the proving engine's range proofs, selective disclosure, and data stripping work together to protect user privacy while maintaining verifiability.