Proof System

Split-Proof Architecture

ProofA + ProofB: parallel certificate and signature verification.

The previous sections described what Apertrue verifies: the hash chain, certificate chain, trust list membership, and COSE signature. Verifying all of this in a single ZK circuit would work, but it would be slow — a monolithic circuit with both RSA signature verification and ECDSA signature verification has hundreds of thousands of constraints and runs sequentially.

Apertrue splits the verification into two independent circuits — ProofA and ProofB — that run in parallel. This is the split-proof architecture, and it delivers a ~40-50% speedup over the monolithic approach.

Why split

The bottleneck in ZK proof generation is the number of constraints in the circuit. More constraints means more computation for the prover. The two most expensive operations in C2PA verification are:

  • Certificate signature verification (ProofA) — verifying that the intermediate CA signed the leaf certificate. RSA-4096 alone requires ~220,000 constraints.
  • COSE signature verification (ProofB) — verifying that the leaf key signed the C2PA manifest payload. RSA PSS adds ~160,000 constraints.

These operations have no data dependency between them — the certificate chain verification and the content signature verification use different inputs and different algorithms. By putting them in separate circuits, they can execute as concurrent JavaScript promises in the same web worker, roughly halving the wall-clock proving time.

Split-proof parallel execution: ProofA (cert chain + hash chain + trust list) runs from T=0 to T=4s, ProofB (COSE signature) runs from T=0 to T=3s, both start simultaneously. At T=4s, link commitment check verifies ProofA.output equals ProofB.output. Compared with monolithic approach at T=0 to T=7s. Approximately 40-50% speedup from parallelism.

ProofA — certificate chain and hash chain

ProofA is the larger of the two circuits. It verifies that a signing certificate is valid, trusted, and bound to specific content. Its verification steps, in order:

  1. Certificate signature. Verifies the intermediate CA's signature over the leaf certificate's TBS (To-Be-Signed) bytes. Supports five algorithm paths: ECDSA P-256, ECDSA P-384, RSA-2048 SHA-256, RSA-2048 SHA-384, and RSA-4096.
  2. Leaf key hash. Computes a Pedersen hash of the leaf certificate's public key for nullifier derivation.
  3. Nullifier. Computes pedersen([content_hash, leaf_key_hash]) — a unique identifier binding this proof to a specific image and device. Prevents proof replay.
  4. Link commitment. Computes pedersen([intermediate_leaf, claim_hash_field, link_blind]) — the cryptographic binding to ProofB (explained below).
  5. Intermediate leaf verification. Recomputes the intermediate CA's key hash from its public key material and verifies it matches the leaf used in the Merkle proof. Prevents self-signing attacks.
  6. Trust list membership. Verifies a depth-8 Merkle proof showing the intermediate CA is in the Oracle's promoted trust list.
  7. Certificate validity. Checks that the proof timestamp falls within the certificate's validity period.
  8. Time range proof. Proves the capture timestamp is within a public range without revealing the exact time.
  9. Location range proof. Proves GPS coordinates are within a public privacy circle without revealing the exact position.
  10. Hash chain. Verifies SHA256(claim_bytes) == claim_hash, checks the content hash binding, and verifies assertion hashes (EXIF and actions).

ProofA outputs four public values: the link commitment, a location commitment, a time commitment, and the actions assertion hash (32 bytes). These carry forward into aggregation.

ProofB — COSE signature

ProofB verifies the actual content signature — the COSE_Sign1 envelope that proves a specific device signed a specific C2PA manifest. Its steps:

  1. Claim hash commitment. Verifies the Poseidon2 commitment of the claim hash matches ProofA's commitment — ensuring both proofs reference the same claim.
  2. COSE signature. Verifies the leaf key's signature over the COSE message hash. Supports ES256 (ECDSA P-256) and PS256 (RSA-2048 PSS).
  3. Sig_structure hash. Verifies SHA256(sig_structure_bytes) == cose_message_hash. The Sig_structure is the CBOR envelope that wraps the claim — the signature must cover this exact structure.
  4. Claim embedding. Verifies the claim bytes appear at the correct offset within the Sig_structure. This binds the signed payload to the claim.
  5. Claim hash. Independently verifies SHA256(claim_bytes) == claim_hash, same as ProofA.
  6. Trust list membership. Independently verifies the Merkle proof, same as ProofA.
  7. Link commitment. Independently computes the same link commitment as ProofA.

ProofB outputs a single public value: the link commitment. The aggregation circuit checks that ProofA's link commitment equals ProofB's link commitment.

The link commitment

ProofA and ProofB run independently — they could be generated by different workers, at different times, with different intermediate results. The link commitment is what binds them together:

Link commitment (computed by both circuits)
let claim_hash_field = bytes_to_field(claim_hash);
let link_commit = pedersen_hash([
    intermediate_leaf,   // binds to the same certificate chain
    claim_hash_field,    // binds to the same C2PA claim
    link_blind           // random blinding for zero-knowledge
]);

Both circuits compute this independently from their private inputs. The aggregation circuit then asserts:

Aggregation binding check
assert(proof_a_outputs.link_commit == proof_b_outputs.link_commit);

If an attacker tries to mix ProofA from one image with ProofB from another, the claim hash field changes, the link commitment changes, and the check fails. If they try to use a different certificate chain, the intermediate leaf changes. The blinding factor ensures the verifier cannot learn the intermediate leaf or claim hash from the commitment alone.

Circuit variants

The split architecture goes further than just ProofA/ProofB — each proof has specialised variants optimised for specific cryptographic algorithms. When a monolithic circuit supports multiple algorithms via if/else branches, the ZK prover still pays the constraint cost of all branches. Specialised variants eliminate unused branches entirely.

ProofA variants

VariantConstraintsUse case
ECDSA P-256~120,000Truepic/ChatGPT, ProofMode — ES256 certificates
ECDSA P-384~150,000Google Pixel cameras — P-384 intermediate, P-256 leaf
RSA-2048~180,000Standard RSA-2048 SHA-256 certificates
RSA-4096~220,000Adobe Photoshop, Lightroom, enterprise HSMs
Skip (2-cert)~80,000ProofMode self-signed chains — no intermediate signature to check
Unified (fallback)~350,000Handles all 5 algorithms — used when the correct variant fails to load

ProofB variants

VariantConstraintsUse case
ES256 (ECDSA)~100,000ECDSA COSE signatures (ChatGPT, ProofMode, most devices)
PS256 (RSA PSS)~160,000RSA PSS COSE signatures (Adobe tools)
Unified (fallback)~200,000Handles both ES256 and PS256
Key Insight
The specialised ECDSA P-256 ProofA (~120,000 constraints) is ~66% smaller than the unified variant (~350,000). For a ChatGPT image, the worker loads the ECDSA P-256 ProofA and ES256 ProofB — the smallest possible combination — while an Adobe Photoshop image loads RSA-4096 ProofA and PS256 ProofB.

Algorithm detection

When the split worker receives an image, it inspects the extracted certificate data to select the correct variants:

  • ProofA variant — determined by the intermediate certificate's key type and signature algorithm: RSA modulus size distinguishes 2048 from 4096, ECDSA public key length distinguishes P-256 from P-384, and 2-certificate chains use the skip variant.
  • ProofB variant — determined by the COSE signature algorithm in the manifest header: algorithm ID -7 selects ES256 (ECDSA), -37 selects PS256 (RSA PSS).

If the worker was initialised with the wrong variant (e.g., loaded ECDSA but the image uses RSA), it hot-reloads the correct specialised circuit or falls back to the unified variant.

Execution strategies

The split worker supports three execution strategies, selected based on hardware capability:

StrategyHardwareBehaviour
SequentialLow-end (2 cores, <4GB RAM)ProofA then ProofB in sequence — safest for memory-constrained devices
ParallelMid-range (default)Both proofs run as concurrent promises in a single worker with shared SRS
Dual-workerHigh-end (12+ cores, 16GB+ RAM)ProofA in one worker, ProofB inputs passed to a dedicated second worker

The parallel strategy is the default and most common. A single web worker holds one Barretenberg instance (with its ~200MB SRS) and two UltraHonk backends — one per circuit. The Promise.allSettled call runs both proofs concurrently, and the slower proof determines the total wall time.

Public inputs and outputs

The two circuits share several public inputs (to ensure they're verifying the same content) and produce independent outputs:

Shared public inputs

  • trust_list_root — the Oracle Merkle root (same tree for both)
  • content_hash — SHA-256 of the image bytes
  • nullifier — unique identifier preventing proof replay
  • proof_timestamp — when the proof was generated
  • cert_not_before / cert_not_after — certificate validity window
  • claim_hash_commitment — Poseidon2 commitment of the claim hash (binds both proofs to the same claim)

ProofA-only inputs

  • Time range bounds (time_min, time_max)
  • Location privacy circle (centre coordinates, radius)
  • TBS hash commitment (certificate To-Be-Signed data)

Outputs

CircuitPublic outputsPurpose
ProofAlink_commitBinding to ProofB
ProofAlocation_commitmentPoseidon2 hash of exact GPS for optional future disclosure
ProofAtime_commitmentPoseidon2 hash of exact timestamp for optional future disclosure
ProofAactions_assertion_hash (32 bytes)Hash of the C2PA actions assertion — carries edit history into aggregation
ProofBlink_commitMust match ProofA's output

Feeding into aggregation

When the proofs are generated with the recursive flag, the worker produces additional artefacts for aggregation:

  • The proof bytes split into 500 field elements (UltraHonk ZK proof format)
  • The verification key for each circuit
  • All public values (inputs + outputs) for each proof

These feed into the image aggregator circuit, which recursively verifies both proofs inside a new circuit, checks the link commitment match, and produces a single image_commitment — one field element that represents a fully verified image. The aggregation pipeline is covered in the next section.

PSS salt length compatibility
Adobe Photoshop uses a non-standard RSA PSS salt length of 20 bytes (the legacy RFC 4055 default), while the standard is 32 bytes (SHA-256 output length). The PS256 ProofB circuit accepts the salt length as a private input, allowing it to handle both standards without separate circuit variants.
Open-source foundations
The proof circuits depend on several open-source Noir libraries: noir_rsa (ZKPassport) for RSA signature verification, noir-bignum for large integer arithmetic, sha256 and poseidon for cryptographic hashing, and Aztec's Barretenberg proving backend. Apertrue's contributions are the circuit logic itself — the split-proof architecture, link commitment scheme, algorithm-specific variants, and the integration with the C2PA extraction pipeline.

The next section explains how individual image proofs (each consisting of a verified ProofA + ProofB pair) are aggregated into batch proofs covering multiple images in a single upload.