Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Security Model & Known Limitations

This document describes the trust assumptions and known limitations of Apertrue's ZK circuits. It is intended for auditors, security researchers, and anyone evaluating the soundness of the proof system.

## Trust Assumptions

### 1. Metadata values are client-extracted, not parsed in-circuit

GPS coordinates (`exact_lat_scaled`, `exact_lon_scaled`) and timestamps (`exact_timestamp`) are provided as private witness values. The circuit verifies that the EXIF assertion bytes are authentic (via the hash chain binding to the signed claim), but does **not** parse CBOR/EXIF to extract these values in-circuit.

**Why**: CBOR and EXIF parsing in Noir would add tens of thousands of constraints and is not feasible with current tooling.

**Mitigation**: The hash chain cryptographically binds the assertion bytes to the C2PA signature. A malicious prover cannot alter the assertion bytes, but could theoretically provide different extracted values from those bytes. The commitment scheme (Poseidon2 hash of exact values + salt) ensures that any disclosed values in selective disclosure must match what was originally committed.

### 2. Certificate validity timestamps are witness-provided

`cert_not_before` and `cert_not_after` are public inputs, not derived from X.509 certificate DER data in-circuit.

**Why**: X.509 DER/ASN.1 parsing in Noir is not feasible.

**Mitigation**: These are public inputs visible to verifiers. The backend and on-chain verifier should independently validate these timestamps against known certificate metadata. The image aggregator cross-checks that ProofA and ProofB agree on these values.

### 3. `bytes_to_field` can overflow BN254

SHA-256 outputs (256 bits) and P-384 coordinates (384 bits) exceed the BN254 scalar field (~254 bits). The `bytes_to_field` helper silently reduces modulo p, creating a theoretical collision space.

**Impact**: For SHA-256 hashes (256 bits → ~254-bit field), approximately 3 out of every 4 field elements have a second preimage under reduction. For P-384 coordinates (384 bits), the collision space is proportionally larger. However, finding a meaningful collision — two valid certificates or hashes that map to the same field element — remains computationally infeasible, as it requires finding valid structured data (not arbitrary byte strings) that collide.

**Future fix**: Split 32-byte values into two 128-bit field elements. This is a breaking change to the commitment scheme and requires coordinated migration.

### 4. Two-certificate chains (cert_algorithm == 5)

For 2-cert chains (e.g., ProofMode), the `intermediate_leaf` witness is not derived from certificate key material in-circuit. It is accepted as-is and checked against the trust list Merkle tree.

**Why**: 2-cert chains have a different structure (self-signed root → leaf) that doesn't fit the 3-cert chain verification logic. The intermediate identity needs to be derived from the issuer DN, which requires byte packing and hashing that is currently done client-side.

**Mitigation**: The trust list is curated and signed. An attacker would need to know a valid leaf in the trust list to exploit this. The `skip_content_hash_binding` and `skip_assertion_hash_verification` flags are now public inputs, so verifiers can see when these checks are skipped and apply appropriate trust policy.

### 5. Selective disclosure location range cannot cross equator or prime meridian

The location range proof uses magnitude + sign representation. A single bounding box cannot span the equator (latitude sign change) or prime meridian (longitude sign change).

**Impact**: A verifier requesting proof that a photo was taken in a region crossing these boundaries must use two separate range proofs.

## Security Fixes Applied

The following hardening measures were added based on internal audit:

- **Merkle index binary constraints**: All `merkle_indices[i]` values are asserted to be 0 or 1
- **Field-to-u64 range checks**: All Field values cast to u64 for comparisons include roundtrip assertions (`val as u64 as Field == val`)
- **Boolean sign flag constraints**: `exact_lat_is_negative` and `exact_lon_is_negative` are constrained to 0 or 1
- **Buffer length bounds checks**: `claim_length`, `assertion_length`, `sig_structure_length` are checked against their maximum buffer sizes
- **P-384 ECDSA r/s non-zero**: Signature components are asserted non-zero before verification
- **Independent cert/leaf key types**: `cert_algorithm` (intermediate signature algorithm) and `leaf_key_type` (leaf key algorithm) are intentionally independent — a CA can sign with RSA but issue ECDSA leaf certs (e.g., Truepic: RSA-2048-SHA384 intermediate, ECDSA P-256 leaf)
- **VK hash transparency**: Verification key hashes are exposed as public outputs from aggregator circuits for external allowlist checking
- **Skip flag transparency**: Content hash binding and assertion hash skip flags are public inputs, constrained to binary (0 or 1)
- **Cross-proof consistency**: Certificate validity period and content hash offset are cross-checked between ProofA and ProofB in the image aggregator
- **Link commitment non-zero**: Prevents vacuous binding between split proofs
- **Path depth bounds**: Selective disclosure path depth is range-checked and bounded by MAX_TREE_DEPTH
- **JWT email domain extraction**: Takes first `@` occurrence only, preventing domain spoofing via crafted multi-`@` emails
- **Merkle root monotonicity**: `update_merkle_root` requires strictly increasing tree size with Field-to-u64 range check

## Audit Status

These circuits have **not** been formally audited by a third party. The security hardening above was applied based on internal review. A formal audit is recommended before production use with high-stakes verification (legal evidence, insurance claims, etc.).
14 changes: 9 additions & 5 deletions aztec_verifier/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ pub contract ApertrueVerifier {
// proof - 500-field UltraHonkZK proof from tree aggregator
// vk - 115-field verification key for tree aggregator circuit
// vk_hash - Hash of VK (checked against stored immutable hash)
// public_values - 10 public values from tree aggregator:
// [0, 0, 0, 0, 0, 0, 0, 0, 0, root_commitment]
// public_values - 12 public values from tree aggregator:
// [0, 0, 0, 0, 0, 0, 0, 0, 0, root_commitment, vk_hash_left, vk_hash_right]
// image_count - Number of images in the batch
// trust_list_root - Trust list root used during proving
// epoch_week - ISO week number for coarse timing
Expand All @@ -169,7 +169,7 @@ pub contract ApertrueVerifier {
proof: UltraHonkZKProof,
vk: UltraHonkVerificationKey,
vk_hash: Field,
public_values: [Field; 10],
public_values: [Field; 12],
image_count: Field,
trust_list_root: Field,
epoch_week: Field,
Expand All @@ -193,6 +193,8 @@ pub contract ApertrueVerifier {
assert(public_values[7] == 0, "public_values padding [7] must be zero");
assert(public_values[8] == 0, "public_values padding [8] must be zero");
assert(public_values[9] != 0, "root_commitment must not be zero");
// public_values[10] = vk_hash_left (tree aggregator output, for external VK allowlist checking)
// public_values[11] = vk_hash_right (tree aggregator output, for external VK allowlist checking)

// ============================================================
// VK HASH CHECK (private historical read of PublicImmutable)
Expand All @@ -208,7 +210,7 @@ pub contract ApertrueVerifier {

// ============================================================
// STEP 2: Extract root_commitment from public values
// Tree aggregator output: [0, 0, 0, 0, 0, 0, 0, 0, 0, root_commitment]
// Tree aggregator output: [0, 0, 0, 0, 0, 0, 0, 0, 0, root_commitment, vk_hash_left, vk_hash_right]
// ============================================================
let root_commitment = public_values[9];

Expand Down Expand Up @@ -361,8 +363,10 @@ pub contract ApertrueVerifier {
assert(sender == admin, "Only admin can update Merkle root");

let current_size = self.storage.tree_size.read();
assert(current_size as u64 as Field == current_size, "stored tree_size exceeds u64 range");
assert(new_tree_size as u64 as Field == new_tree_size, "new_tree_size exceeds u64 range");
assert(
new_tree_size as u64 >= current_size as u64,
new_tree_size as u64 > current_size as u64,
"Tree size cannot decrease",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

Expand Down
71 changes: 46 additions & 25 deletions image_aggregator/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
// - Preserves nullifier for replay prevention
//
// IMPORTANT: verify_honk_proof requires ALL public inputs+outputs from the inner proof.
// ProofA has 53 public values (17 inputs + 36 outputs), ProofB has 9 public values (8 inputs + 1 output).
// ProofA has 55 public values (19 inputs + 36 outputs), ProofB has 9 public values (8 inputs + 1 output).
//
// Public values layout (10 total = 4 inputs + 6 outputs):
// Public values layout (12 total = 4 inputs + 8 outputs):
// [0] trust_list_root (input)
// [1] content_hash (input)
// [2] nullifier (input)
Expand All @@ -27,6 +27,8 @@
// [7] link_commit (output)
// [8] actions_hash_field (output)
// [9] image_commitment (output)
// [10] vk_a_hash (output)
// [11] vk_b_hash (output)
//
// Estimated constraints: ~100k (2x verify_proof + Poseidon hash)

Expand All @@ -38,9 +40,9 @@ use dep::bb_proof_verification::{
use dep::poseidon::poseidon2::Poseidon2;

// Number of public values for each proof (inputs + outputs)
// ProofA: 17 inputs + 36 outputs = 53 (after public input packing + edit time)
// ProofA: 19 inputs + 36 outputs = 55 (after public input packing + edit time + skip flags)
// ProofB: 8 inputs + 1 output = 9 (added claim_hash_commitment)
global PROOF_A_PUBLIC_VALUES: u32 = 53;
global PROOF_A_PUBLIC_VALUES: u32 = 55;
global PROOF_B_PUBLIC_VALUES: u32 = 9;

// Main entry point for ImageAggregator
Expand Down Expand Up @@ -80,10 +82,10 @@ fn main(
vk_a_hash: Field,
vk_b_hash: Field,

// === PRIVATE INPUTS: ALL PROOF_A PUBLIC VALUES (53 total) ===
// === PRIVATE INPUTS: ALL PROOF_A PUBLIC VALUES (55 total) ===
// These must be passed to verify_honk_proof in exact order
//
// ProofA PUBLIC INPUTS (17 total, after packing optimization + edit time):
// ProofA PUBLIC INPUTS (19 total, after packing optimization + edit time + skip flags):
// [0] trust_list_root: Field
// [1] content_hash: Field
// [2] nullifier: Field
Expand All @@ -101,14 +103,16 @@ fn main(
// [14] tbs_hash_commitment: Field (Poseidon2 of TBS hash bytes)
// [15] claim_hash_commitment: Field (Poseidon2 of claim hash bytes)
// [16] content_hash_offset: Field (u32 as Field)
// [17] skip_flag_a: Field
// [18] skip_flag_b: Field
//
// ProofA PUBLIC OUTPUTS (36 total):
// [17] link_commit: Field
// [18] location_commitment: Field
// [19] time_commitment: Field
// [20] edit_time_commitment: Field
// [21..53] actions_assertion_hash: [Field; 32] (u8 as Field)
proof_a_public_values: [Field; 53],
// [19] link_commit: Field
// [20] location_commitment: Field
// [21] time_commitment: Field
// [22] edit_time_commitment: Field
// [23..55] actions_assertion_hash: [Field; 32] (u8 as Field)
proof_a_public_values: [Field; 55],

// === PRIVATE INPUTS: ALL PROOF_B PUBLIC VALUES (9 total) ===
//
Expand All @@ -125,11 +129,11 @@ fn main(
// ProofB PUBLIC OUTPUT (1 total):
// [8] link_commit: Field
proof_b_public_values: [Field; 9],
) -> pub (Field, Field, Field, Field, Field, Field) {
) -> pub (Field, Field, Field, Field, Field, Field, Field, Field) {
// ================================================================
// STEP 1: Verify ProofA (certificate chain verification)
// ================================================================
// Pass ALL 50 public values to verify_honk_proof
// Pass ALL 55 public values to verify_honk_proof
verify_honk_proof(
vk_a,
proof_a,
Expand Down Expand Up @@ -179,6 +183,22 @@ fn main(
"claim_hash_commitment mismatch between ProofA and ProofB"
);

// SECURITY: Verify cert validity period matches
assert(
proof_a_public_values[4] == proof_b_public_values[4],
"cert_not_before mismatch between ProofA and ProofB"
);
assert(
proof_a_public_values[5] == proof_b_public_values[5],
"cert_not_after mismatch between ProofA and ProofB"
);

// SECURITY: Verify content_hash_offset matches
assert(
proof_a_public_values[16] == proof_b_public_values[7],
"content_hash_offset mismatch between ProofA and ProofB"
);

// Also verify these match our own public inputs
assert(
proof_a_public_values[0] == trust_list_root,
Expand All @@ -203,31 +223,32 @@ fn main(
// SECURITY: This prevents proof substitution attacks where an attacker
// could mix ProofA from image A with ProofB from image B.
//
// ProofA link_commit is at index 17 (first output after 17 inputs)
// ProofA link_commit is at index 19 (first output after 19 inputs)
// ProofB link_commit is at index 8 (first output after 8 inputs)
let link_commit_a = proof_a_public_values[17];
let link_commit_a = proof_a_public_values[19];
let link_commit_b = proof_b_public_values[8];

assert(
link_commit_a == link_commit_b,
"Link commitment mismatch: ProofA and ProofB were not generated from the same image"
);
assert(link_commit_a != 0, "link_commit must not be zero");

// ================================================================
// STEP 5: Extract ProofA commitment outputs
// ================================================================
// ProofA outputs at indices 17-20 are commitment fields:
// [17] link_commit, [18] location_commitment, [19] time_commitment, [20] edit_time_commitment
let link_commit = proof_a_public_values[17];
let location_commitment = proof_a_public_values[18];
let time_commitment = proof_a_public_values[19];
let edit_time_commitment = proof_a_public_values[20];
// ProofA outputs at indices 19-22 are commitment fields:
// [19] link_commit, [20] location_commitment, [21] time_commitment, [22] edit_time_commitment
let link_commit = proof_a_public_values[19];
let location_commitment = proof_a_public_values[20];
let time_commitment = proof_a_public_values[21];
let edit_time_commitment = proof_a_public_values[22];

// ProofA outputs at indices 21-52 are actions_assertion_hash [u8; 32] as Fields.
// ProofA outputs at indices 23-54 are actions_assertion_hash [u8; 32] as Fields.
// Reduce to a single Field via Poseidon2 for inclusion in image_commitment.
let mut actions_bytes: [Field; 32] = [0; 32];
for i in 0..32 {
actions_bytes[i] = proof_a_public_values[21 + i];
actions_bytes[i] = proof_a_public_values[23 + i];
}
let actions_hash_field = Poseidon2::hash(actions_bytes, 32);

Expand All @@ -245,7 +266,7 @@ fn main(
9,
);

(location_commitment, time_commitment, edit_time_commitment, link_commit, actions_hash_field, image_commitment)
(location_commitment, time_commitment, edit_time_commitment, link_commit, actions_hash_field, image_commitment, vk_a_hash, vk_b_hash)
}

// ====================================================================
Expand Down
2 changes: 1 addition & 1 deletion jwt_identity/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ fn main(
let mut found_at = false;
for i in 0..MAX_EMAIL_LENGTH {
if i < email.len() {
if email.get(i) == 64 {
if (email.get(i) == 64) & (!found_at) {
at_pos = i + 1;
found_at = true;
}
Expand Down
5 changes: 5 additions & 0 deletions proof_a/src/crypto_utils.nr
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ pub fn verify_ecdsa_p384(
let pk_y: Secp384r1_Fq = Secp384r1_Fq::from_be_bytes(pubkey_y);
let r: Secp384r1_Fr = Secp384r1_Fr::from_be_bytes(sig_r);
let s: Secp384r1_Fr = Secp384r1_Fr::from_be_bytes(sig_s);

// Security: reject zero r or s (invalid per SEC1/FIPS 186-5)
assert(r != Secp384r1_Fr::zero(), "ECDSA P-384 r must not be zero");
assert(s != Secp384r1_Fr::zero(), "ECDSA P-384 s must not be zero");

let e: Secp384r1_Fr = Secp384r1_Fr::from_be_bytes(message_hash);

// Compute s^-1 mod n (unconstrained, then verify)
Expand Down
9 changes: 9 additions & 0 deletions proof_a/src/hash_chain.nr
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ pub fn verify_claim_hash(
claim_length: u32,
expected_claim_hash: [u8; 32]
) {
// Bounds check: claim_length must not exceed buffer size
assert(claim_length <= MAX_CLAIM_SIZE, "claim_length exceeds MAX_CLAIM_SIZE");

// Compute SHA256 of the ACTUAL claim bytes (not padded)
// sha256_var hashes only the first claim_length bytes
let computed_hash: [u8; 32] = dep::sha256::sha256_var(claim_bytes, claim_length as u32);
Expand Down Expand Up @@ -136,6 +139,9 @@ pub fn verify_assertion_hash(
assertion_length: u32,
expected_hash: [u8; 32]
) {
// Bounds check: assertion_length must not exceed buffer size
assert(assertion_length <= MAX_EXIF_ASSERTION_SIZE, "assertion_length exceeds buffer");

// Compute SHA256 of the ACTUAL assertion bytes (not padded)
// sha256_var hashes only the first assertion_length bytes
let computed_hash: [u8; 32] = dep::sha256::sha256_var(assertion_bytes, assertion_length as u32);
Expand Down Expand Up @@ -436,6 +442,9 @@ pub fn verify_actions_assertion_hash(
assertion_length: u32,
expected_hash: [u8; 32]
) {
// Bounds check: assertion_length must not exceed buffer size
assert(assertion_length <= MAX_ACTIONS_ASSERTION_SIZE, "actions_assertion_length exceeds buffer");

// Compute SHA256 of the ACTUAL assertion bytes (not padded)
// sha256_var hashes only the first assertion_length bytes
let computed_hash: [u8; 32] = dep::sha256::sha256_var(assertion_bytes, assertion_length as u32);
Expand Down
Loading
Loading