From 27d64c3b5a77c0477258085913c8734065aef54e Mon Sep 17 00:00:00 2001 From: Jamie Newton <33573418+newtsjamie@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:53:53 +0900 Subject: [PATCH 1/5] security: comprehensive circuit hardening from internal audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 — Constraint additions (no layout changes): - Add merkle index binary constraints (all circuits with Merkle proofs) - Add Field-to-u64 range checks for all timestamp/coordinate comparisons - Add boolean constraints on lat/lon sign flags - Add path_depth range check in selective_disclosure - Add claim_length/assertion_length/sig_structure_length bounds checks - Add leaf_key_type consistency with cert_algorithm in proof_a - Add P-384 ECDSA r/s non-zero checks - Add cert_not_before/cert_not_after cross-check in image_aggregator - Add content_hash_offset cross-check in image_aggregator - Add link_commit non-zero check in image_aggregator Tier 2 — Public input layout changes: - Make skip_content_hash_binding and skip_assertion_hash_verification public inputs in all ProofA variants (PROOF_A_PUBLIC_VALUES: 53 → 55) - Expose vk_a_hash and vk_b_hash as public outputs from image_aggregator (IMAGE_AGG_PUBLIC_VALUES: 10 → 12) - Expose vk_hash_left and vk_hash_right as public outputs from tree_aggregator (maintains 12 total public values) Tier 3 — Document known limitations in SECURITY.md: - Metadata values client-extracted, not parsed in-circuit - Certificate timestamps witness-provided - bytes_to_field BN254 overflow (future breaking fix) - 2-cert chain intermediate_leaf unconstrained - Cross-equator bounding box limitation All 12 modified circuits compile and pass tests. Co-Authored-By: Claude Opus 4.6 --- SECURITY.md | 63 +++++++++++++++++++++++ image_aggregator/src/main.nr | 71 +++++++++++++++++--------- proof_a/src/crypto_utils.nr | 5 ++ proof_a/src/hash_chain.nr | 9 ++++ proof_a/src/main.nr | 36 ++++++++++++- proof_a_ecdsa_p256/src/hash_chain.nr | 9 ++++ proof_a_ecdsa_p256/src/main.nr | 24 ++++++++- proof_a_ecdsa_p384/src/crypto_utils.nr | 5 ++ proof_a_ecdsa_p384/src/hash_chain.nr | 9 ++++ proof_a_ecdsa_p384/src/main.nr | 24 ++++++++- proof_a_rsa_2048/src/hash_chain.nr | 9 ++++ proof_a_rsa_2048/src/main.nr | 24 ++++++++- proof_a_rsa_4096/src/hash_chain.nr | 9 ++++ proof_a_rsa_4096/src/main.nr | 24 ++++++++- proof_a_skip/src/hash_chain.nr | 9 ++++ proof_a_skip/src/main.nr | 26 +++++++++- proof_b/src/main.nr | 6 +++ proof_b_es256/src/main.nr | 6 +++ proof_b_ps256/src/main.nr | 6 +++ selective_disclosure/src/main.nr | 21 ++++++++ tree_aggregator/src/main.nr | 58 +++++++++++---------- 21 files changed, 390 insertions(+), 63 deletions(-) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..aca5afd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# 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, approximately 1 in 2^2 values have a collision partner. For P-384 coordinates, the collision space is larger. Finding a meaningful collision (two valid certificates or hashes that map to the same field element) remains computationally infeasible. + +**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 +- **Leaf key type consistency**: `leaf_key_type` is constrained to match `cert_algorithm` +- **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 +- **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 + +## 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.). diff --git a/image_aggregator/src/main.nr b/image_aggregator/src/main.nr index 7e311fa..379b1f7 100644 --- a/image_aggregator/src/main.nr +++ b/image_aggregator/src/main.nr @@ -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) @@ -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) @@ -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 @@ -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 @@ -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) === // @@ -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, @@ -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, @@ -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); @@ -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) } // ==================================================================== diff --git a/proof_a/src/crypto_utils.nr b/proof_a/src/crypto_utils.nr index a60aed4..379b70b 100644 --- a/proof_a/src/crypto_utils.nr +++ b/proof_a/src/crypto_utils.nr @@ -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) diff --git a/proof_a/src/hash_chain.nr b/proof_a/src/hash_chain.nr index 4e6ff85..d7fe6d5 100644 --- a/proof_a/src/hash_chain.nr +++ b/proof_a/src/hash_chain.nr @@ -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); @@ -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); @@ -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); diff --git a/proof_a/src/main.nr b/proof_a/src/main.nr index 330aaf1..494a04a 100644 --- a/proof_a/src/main.nr +++ b/proof_a/src/main.nr @@ -91,10 +91,10 @@ fn main( // Skip content hash binding verification for C2PA manifests where content hash // is in a separate assertion (c2pa.hash.data) rather than directly in the claim. // The hash chain is still verified via assertion_hash -> content_hash. - skip_content_hash_binding: u8, // 0 = verify (default), 1 = skip + skip_content_hash_binding: pub u8, // 0 = verify (default), 1 = skip // Skip assertion hash verification for manifests where assertion data isn't available // or parseable (e.g., some ProofMode 2-cert chain configurations). - skip_assertion_hash_verification: u8, // 0 = verify (default), 1 = skip + skip_assertion_hash_verification: pub u8, // 0 = verify (default), 1 = skip // === PRIVATE: LEAF KEY (for nullifier binding) === // ECDSA leaf key @@ -280,6 +280,14 @@ fn main( assert(false, f"Invalid certificate algorithm: {cert_algorithm}"); } + // SECURITY: Verify leaf_key_type consistency with cert_algorithm + if (cert_algorithm == 0) | (cert_algorithm == 4) { + assert(leaf_key_type == 0, "leaf_key_type must be ECDSA for ECDSA cert algorithms"); + } else if (cert_algorithm == 1) | (cert_algorithm == 2) | (cert_algorithm == 3) { + assert(leaf_key_type != 0, "leaf_key_type must be RSA for RSA cert algorithms"); + } + // cert_algorithm == 5 (skip) allows either leaf_key_type + // ================================================================ // STEP 2: Derive Leaf Key Hash // ================================================================ @@ -392,6 +400,11 @@ fn main( // ================================================================ // STEP 5: Certificate Validity (Time) // ================================================================ + // SECURITY: Ensure Field values fit in u64 before comparison + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); + let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -412,6 +425,10 @@ fn main( // // This protects journalists/activists by hiding precise capture times // while still proving the photo has a valid timestamp within the range. + assert(exact_timestamp as u64 as Field == exact_timestamp, "exact_timestamp exceeds u64"); + assert(time_min as u64 as Field == time_min, "time_min exceeds u64"); + assert(time_max as u64 as Field == time_max, "time_max exceeds u64"); + let exact_ts = exact_timestamp as u64; let min_ts = time_min as u64; let max_ts = time_max as u64; @@ -425,6 +442,10 @@ fn main( // ================================================================ // When a photo is edited (capture -> export), exact_edit_timestamp is the // signing/export time. For direct camera captures, it's 0 (sentinel). + assert(exact_edit_timestamp as u64 as Field == exact_edit_timestamp, "exact_edit_timestamp exceeds u64"); + assert(edit_time_min as u64 as Field == edit_time_min, "edit_time_min exceeds u64"); + assert(edit_time_max as u64 as Field == edit_time_max, "edit_time_max exceeds u64"); + if exact_edit_timestamp != 0 { let edit_ts = exact_edit_timestamp as u64; let edit_min = edit_time_min as u64; @@ -462,11 +483,19 @@ fn main( // Now we ALWAYS verify location. For radius=0 (exact disclosure), we verify // center == exact coordinates. For radius>0, we verify distance <= radius. + assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); + assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + // Unpack location_flags: bit 0 = lat negative, bit 1 = lon negative assert(location_flags <= 3, "location_flags has invalid high bits set (must be 0-3)"); let center_lat_is_negative = location_flags & 1; let center_lon_is_negative = (location_flags >> 1) & 1; + assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); + assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); + assert(exact_lon_scaled as u64 as Field == exact_lon_scaled, "exact_lon_scaled exceeds u64"); + assert(center_lon_scaled as u64 as Field == center_lon_scaled, "center_lon_scaled exceeds u64"); + { // Calculate latitude difference (accounting for hemisphere) // If signs differ (one N, one S), we add the absolute values @@ -505,6 +534,8 @@ fn main( let distance_squared = d_lat * d_lat + d_lon * d_lon; // Verify exact location is within privacy circle + assert(distance_squared as u64 as Field == distance_squared, "distance_squared exceeds u64"); + assert(radius_squared_scaled as u64 as Field == radius_squared_scaled, "radius_squared_scaled exceeds u64"); assert( distance_squared as u64 <= radius_squared_scaled as u64, "Exact GPS location is outside disclosed privacy circle" @@ -762,6 +793,7 @@ fn compute_merkle_root( let mut current = leaf; for i in 0..8 { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; diff --git a/proof_a_ecdsa_p256/src/hash_chain.nr b/proof_a_ecdsa_p256/src/hash_chain.nr index 4e6ff85..d7fe6d5 100644 --- a/proof_a_ecdsa_p256/src/hash_chain.nr +++ b/proof_a_ecdsa_p256/src/hash_chain.nr @@ -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); @@ -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); @@ -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); diff --git a/proof_a_ecdsa_p256/src/main.nr b/proof_a_ecdsa_p256/src/main.nr index f196ec3..7f723e3 100644 --- a/proof_a_ecdsa_p256/src/main.nr +++ b/proof_a_ecdsa_p256/src/main.nr @@ -85,8 +85,8 @@ fn main( claim_hash: [u8; 32], // === PRIVATE: SKIP FLAGS === - skip_content_hash_binding: u8, - skip_assertion_hash_verification: u8, + skip_content_hash_binding: pub u8, + skip_assertion_hash_verification: pub u8, // === PRIVATE: ECDSA P-256 LEAF KEY (for nullifier binding) === leaf_pubkey_x: [u8; 32], @@ -200,6 +200,9 @@ fn main( // ================================================================ // STEP 5: Certificate Validity (Time) // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -209,6 +212,9 @@ fn main( // ================================================================ // STEP 6: Time Range Proof (Graduated Time Disclosure) // ================================================================ + assert(exact_timestamp as u64 as Field == exact_timestamp, "exact_timestamp exceeds u64"); + assert(time_min as u64 as Field == time_min, "time_min exceeds u64"); + assert(time_max as u64 as Field == time_max, "time_max exceeds u64"); let exact_ts = exact_timestamp as u64; let min_ts = time_min as u64; let max_ts = time_max as u64; @@ -222,6 +228,9 @@ fn main( // When a photo is edited (capture -> export), exact_edit_timestamp is the // signing/export time. For direct camera captures, it's 0 (sentinel). if exact_edit_timestamp != 0 { + assert(exact_edit_timestamp as u64 as Field == exact_edit_timestamp, "exact_edit_timestamp exceeds u64"); + assert(edit_time_min as u64 as Field == edit_time_min, "edit_time_min exceeds u64"); + assert(edit_time_max as u64 as Field == edit_time_max, "edit_time_max exceeds u64"); let edit_ts = exact_edit_timestamp as u64; let edit_min = edit_time_min as u64; let edit_max = edit_time_max as u64; @@ -241,6 +250,14 @@ fn main( let center_lat_is_negative = location_flags & 1; let center_lon_is_negative = (location_flags >> 1) & 1; + assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); + assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + + assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); + assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); + assert(exact_lon_scaled as u64 as Field == exact_lon_scaled, "exact_lon_scaled exceeds u64"); + assert(center_lon_scaled as u64 as Field == center_lon_scaled, "center_lon_scaled exceeds u64"); + { let lat_same_sign = (exact_lat_is_negative == center_lat_is_negative); let d_lat = if lat_same_sign { @@ -270,6 +287,8 @@ fn main( // by using the longitude conversion at the user's latitude, ensuring the // privacy circle covers at least the requested radius in ALL directions. let distance_squared = d_lat * d_lat + d_lon * d_lon; + assert(distance_squared as u64 as Field == distance_squared, "distance_squared exceeds u64"); + assert(radius_squared_scaled as u64 as Field == radius_squared_scaled, "radius_squared_scaled exceeds u64"); assert( distance_squared as u64 <= radius_squared_scaled as u64, @@ -391,6 +410,7 @@ fn compute_merkle_root( let mut current = leaf; for i in 0..8 { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/proof_a_ecdsa_p384/src/crypto_utils.nr b/proof_a_ecdsa_p384/src/crypto_utils.nr index 5ef4d19..9e0ff83 100644 --- a/proof_a_ecdsa_p384/src/crypto_utils.nr +++ b/proof_a_ecdsa_p384/src/crypto_utils.nr @@ -19,6 +19,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) diff --git a/proof_a_ecdsa_p384/src/hash_chain.nr b/proof_a_ecdsa_p384/src/hash_chain.nr index 4e6ff85..d7fe6d5 100644 --- a/proof_a_ecdsa_p384/src/hash_chain.nr +++ b/proof_a_ecdsa_p384/src/hash_chain.nr @@ -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); @@ -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); @@ -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); diff --git a/proof_a_ecdsa_p384/src/main.nr b/proof_a_ecdsa_p384/src/main.nr index 83f0ffe..ff6aaa8 100644 --- a/proof_a_ecdsa_p384/src/main.nr +++ b/proof_a_ecdsa_p384/src/main.nr @@ -78,8 +78,8 @@ fn main( claim_hash: [u8; 32], // === PRIVATE: SKIP FLAGS === - skip_content_hash_binding: u8, - skip_assertion_hash_verification: u8, + skip_content_hash_binding: pub u8, + skip_assertion_hash_verification: pub u8, // === PRIVATE: ECDSA LEAF KEY (for nullifier binding) === // NOTE: Leaf key is P-256 (32 bytes) even when intermediate is P-384. @@ -197,6 +197,9 @@ fn main( // ================================================================ // STEP 5: Certificate Validity // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -206,6 +209,9 @@ fn main( // ================================================================ // STEP 6: Time Range Proof // ================================================================ + assert(exact_timestamp as u64 as Field == exact_timestamp, "exact_timestamp exceeds u64"); + assert(time_min as u64 as Field == time_min, "time_min exceeds u64"); + assert(time_max as u64 as Field == time_max, "time_max exceeds u64"); let exact_ts = exact_timestamp as u64; let min_ts = time_min as u64; let max_ts = time_max as u64; @@ -219,6 +225,9 @@ fn main( // When a photo is edited (capture -> export), exact_edit_timestamp is the // signing/export time. For direct camera captures, it's 0 (sentinel). if exact_edit_timestamp != 0 { + assert(exact_edit_timestamp as u64 as Field == exact_edit_timestamp, "exact_edit_timestamp exceeds u64"); + assert(edit_time_min as u64 as Field == edit_time_min, "edit_time_min exceeds u64"); + assert(edit_time_max as u64 as Field == edit_time_max, "edit_time_max exceeds u64"); let edit_ts = exact_edit_timestamp as u64; let edit_min = edit_time_min as u64; let edit_max = edit_time_max as u64; @@ -238,6 +247,14 @@ fn main( let center_lat_is_negative = location_flags & 1; let center_lon_is_negative = (location_flags >> 1) & 1; + assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); + assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + + assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); + assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); + assert(exact_lon_scaled as u64 as Field == exact_lon_scaled, "exact_lon_scaled exceeds u64"); + assert(center_lon_scaled as u64 as Field == center_lon_scaled, "center_lon_scaled exceeds u64"); + { let lat_same_sign = (exact_lat_is_negative == center_lat_is_negative); let d_lat = if lat_same_sign { @@ -262,6 +279,8 @@ fn main( }; let distance_squared = d_lat * d_lat + d_lon * d_lon; + assert(distance_squared as u64 as Field == distance_squared, "distance_squared exceeds u64"); + assert(radius_squared_scaled as u64 as Field == radius_squared_scaled, "radius_squared_scaled exceeds u64"); assert( distance_squared as u64 <= radius_squared_scaled as u64, "Location outside privacy circle" @@ -381,6 +400,7 @@ fn verify_content_hash_binding( fn compute_merkle_root(leaf: Field, path: [Field; 8], indices: [Field; 8]) -> Field { let mut current = leaf; for i in 0..8 { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/proof_a_rsa_2048/src/hash_chain.nr b/proof_a_rsa_2048/src/hash_chain.nr index 4e6ff85..d7fe6d5 100644 --- a/proof_a_rsa_2048/src/hash_chain.nr +++ b/proof_a_rsa_2048/src/hash_chain.nr @@ -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); @@ -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); @@ -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); diff --git a/proof_a_rsa_2048/src/main.nr b/proof_a_rsa_2048/src/main.nr index 045d4ec..b82bb4d 100644 --- a/proof_a_rsa_2048/src/main.nr +++ b/proof_a_rsa_2048/src/main.nr @@ -79,8 +79,8 @@ fn main( claim_hash: [u8; 32], // === PRIVATE: SKIP FLAGS === - skip_content_hash_binding: u8, - skip_assertion_hash_verification: u8, + skip_content_hash_binding: pub u8, + skip_assertion_hash_verification: pub u8, // === PRIVATE: RSA-2048 LEAF KEY (first 8 limbs for nullifier binding) === leaf_rsa_modulus_limbs: [u128; 8], @@ -204,6 +204,9 @@ fn main( // ================================================================ // STEP 5: Certificate Validity // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -213,6 +216,9 @@ fn main( // ================================================================ // STEP 6: Time Range Proof // ================================================================ + assert(exact_timestamp as u64 as Field == exact_timestamp, "exact_timestamp exceeds u64"); + assert(time_min as u64 as Field == time_min, "time_min exceeds u64"); + assert(time_max as u64 as Field == time_max, "time_max exceeds u64"); let exact_ts = exact_timestamp as u64; let min_ts = time_min as u64; let max_ts = time_max as u64; @@ -226,6 +232,9 @@ fn main( // When a photo is edited (capture -> export), exact_edit_timestamp is the // signing/export time. For direct camera captures, it's 0 (sentinel). if exact_edit_timestamp != 0 { + assert(exact_edit_timestamp as u64 as Field == exact_edit_timestamp, "exact_edit_timestamp exceeds u64"); + assert(edit_time_min as u64 as Field == edit_time_min, "edit_time_min exceeds u64"); + assert(edit_time_max as u64 as Field == edit_time_max, "edit_time_max exceeds u64"); let edit_ts = exact_edit_timestamp as u64; let edit_min = edit_time_min as u64; let edit_max = edit_time_max as u64; @@ -244,6 +253,14 @@ fn main( let center_lat_is_negative = location_flags & 1; let center_lon_is_negative = (location_flags >> 1) & 1; + assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); + assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + + assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); + assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); + assert(exact_lon_scaled as u64 as Field == exact_lon_scaled, "exact_lon_scaled exceeds u64"); + assert(center_lon_scaled as u64 as Field == center_lon_scaled, "center_lon_scaled exceeds u64"); + { let lat_same_sign = (exact_lat_is_negative == center_lat_is_negative); let d_lat = if lat_same_sign { @@ -268,6 +285,8 @@ fn main( }; let distance_squared = d_lat * d_lat + d_lon * d_lon; + assert(distance_squared as u64 as Field == distance_squared, "distance_squared exceeds u64"); + assert(radius_squared_scaled as u64 as Field == radius_squared_scaled, "radius_squared_scaled exceeds u64"); assert( distance_squared as u64 <= radius_squared_scaled as u64, "Location outside privacy circle" @@ -362,6 +381,7 @@ fn verify_content_hash_binding( fn compute_merkle_root(leaf: Field, path: [Field; 8], indices: [Field; 8]) -> Field { let mut current = leaf; for i in 0..8 { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/proof_a_rsa_4096/src/hash_chain.nr b/proof_a_rsa_4096/src/hash_chain.nr index 4e6ff85..d7fe6d5 100644 --- a/proof_a_rsa_4096/src/hash_chain.nr +++ b/proof_a_rsa_4096/src/hash_chain.nr @@ -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); @@ -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); @@ -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); diff --git a/proof_a_rsa_4096/src/main.nr b/proof_a_rsa_4096/src/main.nr index a582f36..1e14ce3 100644 --- a/proof_a_rsa_4096/src/main.nr +++ b/proof_a_rsa_4096/src/main.nr @@ -89,8 +89,8 @@ fn main( claim_hash: [u8; 32], // === PRIVATE: SKIP FLAGS === - skip_content_hash_binding: u8, - skip_assertion_hash_verification: u8, + skip_content_hash_binding: pub u8, + skip_assertion_hash_verification: pub u8, // === PRIVATE: RSA-4096 LEAF KEY (first 8 limbs for nullifier binding) === leaf_rsa_modulus_limbs: [u128; 8], @@ -215,6 +215,9 @@ fn main( // ================================================================ // STEP 5: Certificate Validity (Time) // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -224,6 +227,9 @@ fn main( // ================================================================ // STEP 6: Time Range Proof (Graduated Time Disclosure) // ================================================================ + assert(exact_timestamp as u64 as Field == exact_timestamp, "exact_timestamp exceeds u64"); + assert(time_min as u64 as Field == time_min, "time_min exceeds u64"); + assert(time_max as u64 as Field == time_max, "time_max exceeds u64"); let exact_ts = exact_timestamp as u64; let min_ts = time_min as u64; let max_ts = time_max as u64; @@ -237,6 +243,9 @@ fn main( // When a photo is edited (capture -> export), exact_edit_timestamp is the // signing/export time. For direct camera captures, it's 0 (sentinel). if exact_edit_timestamp != 0 { + assert(exact_edit_timestamp as u64 as Field == exact_edit_timestamp, "exact_edit_timestamp exceeds u64"); + assert(edit_time_min as u64 as Field == edit_time_min, "edit_time_min exceeds u64"); + assert(edit_time_max as u64 as Field == edit_time_max, "edit_time_max exceeds u64"); let edit_ts = exact_edit_timestamp as u64; let edit_min = edit_time_min as u64; let edit_max = edit_time_max as u64; @@ -255,6 +264,14 @@ fn main( let center_lat_is_negative = location_flags & 1; let center_lon_is_negative = (location_flags >> 1) & 1; + assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); + assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + + assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); + assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); + assert(exact_lon_scaled as u64 as Field == exact_lon_scaled, "exact_lon_scaled exceeds u64"); + assert(center_lon_scaled as u64 as Field == center_lon_scaled, "center_lon_scaled exceeds u64"); + { let lat_same_sign = (exact_lat_is_negative == center_lat_is_negative); let d_lat = if lat_same_sign { @@ -279,6 +296,8 @@ fn main( }; let distance_squared = d_lat * d_lat + d_lon * d_lon; + assert(distance_squared as u64 as Field == distance_squared, "distance_squared exceeds u64"); + assert(radius_squared_scaled as u64 as Field == radius_squared_scaled, "radius_squared_scaled exceeds u64"); assert( distance_squared as u64 <= radius_squared_scaled as u64, @@ -397,6 +416,7 @@ fn compute_merkle_root( let mut current = leaf; for i in 0..8 { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/proof_a_skip/src/hash_chain.nr b/proof_a_skip/src/hash_chain.nr index 4e6ff85..d7fe6d5 100644 --- a/proof_a_skip/src/hash_chain.nr +++ b/proof_a_skip/src/hash_chain.nr @@ -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); @@ -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); @@ -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); diff --git a/proof_a_skip/src/main.nr b/proof_a_skip/src/main.nr index eac711f..bc176dd 100644 --- a/proof_a_skip/src/main.nr +++ b/proof_a_skip/src/main.nr @@ -74,8 +74,8 @@ fn main( claim_hash: [u8; 32], // === PRIVATE: SKIP FLAGS === - skip_content_hash_binding: u8, - skip_assertion_hash_verification: u8, + skip_content_hash_binding: pub u8, + skip_assertion_hash_verification: pub u8, // === PRIVATE: LEAF KEY TYPE === // 0 = ECDSA, 1 = RSA @@ -136,6 +136,8 @@ fn main( let computed_claim_commitment = Poseidon2::hash(bytes32_to_field_array(claim_hash), 2); assert(computed_claim_commitment == claim_hash_commitment, "Claim hash commitment mismatch"); + assert((leaf_key_type == 0) | (leaf_key_type == 1), "leaf_key_type must be 0 (ECDSA) or 1 (RSA)"); + // ================================================================ // STEP 1: SKIP Certificate Signature Verification // ================================================================ @@ -194,6 +196,9 @@ fn main( // ================================================================ // STEP 5: Certificate Validity // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -203,6 +208,9 @@ fn main( // ================================================================ // STEP 6: Time Range Proof // ================================================================ + assert(exact_timestamp as u64 as Field == exact_timestamp, "exact_timestamp exceeds u64"); + assert(time_min as u64 as Field == time_min, "time_min exceeds u64"); + assert(time_max as u64 as Field == time_max, "time_max exceeds u64"); let exact_ts = exact_timestamp as u64; let min_ts = time_min as u64; let max_ts = time_max as u64; @@ -216,6 +224,9 @@ fn main( // When a photo is edited (capture -> export), exact_edit_timestamp is the // signing/export time. For direct camera captures, it's 0 (sentinel). if exact_edit_timestamp != 0 { + assert(exact_edit_timestamp as u64 as Field == exact_edit_timestamp, "exact_edit_timestamp exceeds u64"); + assert(edit_time_min as u64 as Field == edit_time_min, "edit_time_min exceeds u64"); + assert(edit_time_max as u64 as Field == edit_time_max, "edit_time_max exceeds u64"); let edit_ts = exact_edit_timestamp as u64; let edit_min = edit_time_min as u64; let edit_max = edit_time_max as u64; @@ -234,6 +245,14 @@ fn main( let center_lat_is_negative = location_flags & 1; let center_lon_is_negative = (location_flags >> 1) & 1; + assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); + assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + + assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); + assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); + assert(exact_lon_scaled as u64 as Field == exact_lon_scaled, "exact_lon_scaled exceeds u64"); + assert(center_lon_scaled as u64 as Field == center_lon_scaled, "center_lon_scaled exceeds u64"); + { let lat_same_sign = (exact_lat_is_negative == center_lat_is_negative); let d_lat = if lat_same_sign { @@ -258,6 +277,8 @@ fn main( }; let distance_squared = d_lat * d_lat + d_lon * d_lon; + assert(distance_squared as u64 as Field == distance_squared, "distance_squared exceeds u64"); + assert(radius_squared_scaled as u64 as Field == radius_squared_scaled, "radius_squared_scaled exceeds u64"); assert( distance_squared as u64 <= radius_squared_scaled as u64, "Location outside privacy circle" @@ -352,6 +373,7 @@ fn verify_content_hash_binding( fn compute_merkle_root(leaf: Field, path: [Field; 8], indices: [Field; 8]) -> Field { let mut current = leaf; for i in 0..8 { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/proof_b/src/main.nr b/proof_b/src/main.nr index 12d371a..19d6dd0 100644 --- a/proof_b/src/main.nr +++ b/proof_b/src/main.nr @@ -171,6 +171,7 @@ fn main( // ================================================================ // The COSE signature is over SHA256(Sig_structure) // We verify that the provided sig_structure_bytes actually hashes to cose_message_hash + assert(sig_structure_length as u32 <= MAX_SIG_STRUCTURE_SIZE as u32, "sig_structure_length exceeds buffer"); let computed_sig_hash = sha256_var(sig_structure_bytes, sig_structure_length as u32); assert(computed_sig_hash == cose_message_hash, "Sig_structure hash does not match COSE message hash"); @@ -192,6 +193,7 @@ fn main( // STEP 4: Verify claim_hash matches SHA256(claim_bytes) // (SECURITY FIX HIGH-3: Binds to ProofA via link_commit) // ================================================================ + assert(claim_length as u32 <= MAX_CLAIM_SIZE as u32, "claim_length exceeds buffer"); let computed_claim_hash = sha256_var(claim_bytes, claim_length as u32); assert(computed_claim_hash == claim_hash, "Claim hash mismatch - claim_bytes integrity violated"); @@ -259,6 +261,9 @@ fn main( // ================================================================ // STEP 8: Verify certificate validity at proof time // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -305,6 +310,7 @@ fn compute_merkle_root( let mut current = leaf; for i in 0..MERKLE_TREE_DEPTH { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/proof_b_es256/src/main.nr b/proof_b_es256/src/main.nr index fa22750..814284f 100644 --- a/proof_b_es256/src/main.nr +++ b/proof_b_es256/src/main.nr @@ -109,6 +109,7 @@ fn main( // ================================================================ // STEP 2: Verify sig_structure hash matches cose_message_hash // ================================================================ + assert(sig_structure_length as u32 <= MAX_SIG_STRUCTURE_SIZE as u32, "sig_structure_length exceeds buffer"); let computed_sig_hash = sha256_var(sig_structure_bytes, sig_structure_length as u32); assert(computed_sig_hash == cose_message_hash, "Sig_structure hash does not match COSE message hash"); @@ -126,6 +127,7 @@ fn main( // ================================================================ // STEP 4: Verify claim_hash matches SHA256(claim_bytes) // ================================================================ + assert(claim_length as u32 <= MAX_CLAIM_SIZE as u32, "claim_length exceeds buffer"); let computed_claim_hash = sha256_var(claim_bytes, claim_length as u32); assert(computed_claim_hash == claim_hash, "Claim hash mismatch - claim_bytes integrity violated"); @@ -173,6 +175,9 @@ fn main( // ================================================================ // STEP 8: Verify certificate validity at proof time // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -214,6 +219,7 @@ fn compute_merkle_root( let mut current = leaf; for i in 0..MERKLE_TREE_DEPTH { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/proof_b_ps256/src/main.nr b/proof_b_ps256/src/main.nr index 1791f66..f882783 100644 --- a/proof_b_ps256/src/main.nr +++ b/proof_b_ps256/src/main.nr @@ -119,6 +119,7 @@ fn main( // ================================================================ // STEP 2: Verify sig_structure hash matches cose_message_hash // ================================================================ + assert(sig_structure_length as u32 <= MAX_SIG_STRUCTURE_SIZE as u32, "sig_structure_length exceeds buffer"); let computed_sig_hash = sha256_var(sig_structure_bytes, sig_structure_length as u32); assert(computed_sig_hash == cose_message_hash, "Sig_structure hash does not match COSE message hash"); @@ -136,6 +137,7 @@ fn main( // ================================================================ // STEP 4: Verify claim_hash matches SHA256(claim_bytes) // ================================================================ + assert(claim_length as u32 <= MAX_CLAIM_SIZE as u32, "claim_length exceeds buffer"); let computed_claim_hash = sha256_var(claim_bytes, claim_length as u32); assert(computed_claim_hash == claim_hash, "Claim hash mismatch - claim_bytes integrity violated"); @@ -191,6 +193,9 @@ fn main( // ================================================================ // STEP 8: Verify certificate validity at proof time // ================================================================ + assert(proof_timestamp as u64 as Field == proof_timestamp, "proof_timestamp exceeds u64"); + assert(cert_not_before as u64 as Field == cert_not_before, "cert_not_before exceeds u64"); + assert(cert_not_after as u64 as Field == cert_not_after, "cert_not_after exceeds u64"); let ts = proof_timestamp as u64; let not_before = cert_not_before as u64; let not_after = cert_not_after as u64; @@ -232,6 +237,7 @@ fn compute_merkle_root( let mut current = leaf; for i in 0..MERKLE_TREE_DEPTH { + assert((indices[i] == 0) | (indices[i] == 1), "Merkle index must be 0 or 1"); let path_element = path[i]; let is_right = indices[i]; let left = if is_right == 0 { current } else { path_element }; diff --git a/selective_disclosure/src/main.nr b/selective_disclosure/src/main.nr index 0b818aa..556ad0f 100644 --- a/selective_disclosure/src/main.nr +++ b/selective_disclosure/src/main.nr @@ -106,6 +106,8 @@ fn main( // For single-image batches (path_depth == 0), the leaf IS the root. let mut current = image_commitment; let depth = path_depth as u32; + assert(path_depth as u32 as Field == path_depth, "path_depth does not fit in u32"); + assert(depth <= MAX_TREE_DEPTH, "path_depth exceeds MAX_TREE_DEPTH"); for i in 0..MAX_TREE_DEPTH { if i < depth { @@ -172,6 +174,10 @@ fn main( "disclosed_proof_timestamp mismatch", ); + // SECURITY: Constrain sign flags to boolean + assert((exact_lat_is_negative == 0) | (exact_lat_is_negative == 1), "exact_lat_is_negative must be 0 or 1"); + assert((exact_lon_is_negative == 0) | (exact_lon_is_negative == 1), "exact_lon_is_negative must be 0 or 1"); + // --- 6. Verify location_commitment preimage --- // location_commitment = Poseidon2([lat, lon, lat_sign, lon_sign, salt], 5) let computed_location = Poseidon2::hash( @@ -207,6 +213,13 @@ fn main( // Sign bits in disclosed_location_flags encode whether bounds are negative. // All comparisons done via u64 cast (coordinates are scaled positive integers). if disclose_location_range == 1 { + assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); + assert(disclosed_lat_min as u64 as Field == disclosed_lat_min, "disclosed_lat_min exceeds u64"); + assert(disclosed_lat_max as u64 as Field == disclosed_lat_max, "disclosed_lat_max exceeds u64"); + assert(exact_lon_scaled as u64 as Field == exact_lon_scaled, "exact_lon_scaled exceeds u64"); + assert(disclosed_lon_min as u64 as Field == disclosed_lon_min, "disclosed_lon_min exceeds u64"); + assert(disclosed_lon_max as u64 as Field == disclosed_lon_max, "disclosed_lon_max exceeds u64"); + let lat = exact_lat_scaled as u64; let lat_min = disclosed_lat_min as u64; let lat_max = disclosed_lat_max as u64; @@ -240,6 +253,10 @@ fn main( // --- 9. Time range proof --- // If disclosing time range: verify exact timestamp falls within disclosed bounds. if disclose_time_range == 1 { + assert(exact_timestamp as u64 as Field == exact_timestamp, "exact_timestamp exceeds u64"); + assert(disclosed_time_min as u64 as Field == disclosed_time_min, "disclosed_time_min exceeds u64"); + assert(disclosed_time_max as u64 as Field == disclosed_time_max, "disclosed_time_max exceeds u64"); + let ts = exact_timestamp as u64; let ts_min = disclosed_time_min as u64; let ts_max = disclosed_time_max as u64; @@ -253,6 +270,10 @@ fn main( // --- 9b. Edit time range proof --- if disclose_edit_time_range == 1 { assert(exact_edit_timestamp != 0, "Cannot disclose edit time with no edit"); + assert(exact_edit_timestamp as u64 as Field == exact_edit_timestamp, "exact_edit_timestamp exceeds u64"); + assert(disclosed_edit_time_min as u64 as Field == disclosed_edit_time_min, "disclosed_edit_time_min exceeds u64"); + assert(disclosed_edit_time_max as u64 as Field == disclosed_edit_time_max, "disclosed_edit_time_max exceeds u64"); + let ts = exact_edit_timestamp as u64; let ts_min = disclosed_edit_time_min as u64; let ts_max = disclosed_edit_time_max as u64; diff --git a/tree_aggregator/src/main.nr b/tree_aggregator/src/main.nr index c5dca6f..2c2edb8 100644 --- a/tree_aggregator/src/main.nr +++ b/tree_aggregator/src/main.nr @@ -18,21 +18,23 @@ // | // Level 3: [FINAL_PROOF] // -// IMPORTANT: At level 0, we verify ImageAggregator proofs which have 10 public values: +// IMPORTANT: At level 0, we verify ImageAggregator proofs which have 12 public values: // [trust_list_root, content_hash, nullifier, proof_timestamp, // location_commitment, time_commitment, edit_time_commitment, -// link_commit, actions_hash_field, image_commitment] -// At level 1+, we verify TreeAggregator proofs which also have 10 public values: -// [0, 0, 0, 0, 0, 0, 0, 0, 0, combined_commitment] (padded to match ImageAggregator layout) +// link_commit, actions_hash_field, image_commitment, vk_a_hash, vk_b_hash] +// At level 1+, we verify TreeAggregator proofs which also have 12 public values: +// [0, 0, 0, 0, 0, 0, 0, 0, 0, combined_commitment, vk_hash_left, vk_hash_right] (padded to match ImageAggregator layout) // // At each level, TreeAggregator: // 1. Verifies left child proof // 2. Verifies right child proof // 3. Combines their commitments into a new combined_commitment // -// Public values layout (10 total = 9 padding inputs + 1 output): +// Public values layout (12 total = 9 padding inputs + 3 outputs): // [0..8] padding (must be zero) -- matches ImageAggregator's 4 inputs + 5 commitment outputs // [9] combined_commitment (output) +// [10] vk_hash_left (output) +// [11] vk_hash_right (output) // // Estimated constraints: ~50k (2x verify_proof + Poseidon hash) @@ -43,29 +45,29 @@ use dep::bb_proof_verification::{ }; use dep::poseidon::poseidon2::Poseidon2; -// Number of public values for ImageAggregator (4 inputs + 6 outputs) -global IMAGE_AGG_PUBLIC_VALUES: u32 = 10; +// Number of public values for ImageAggregator (4 inputs + 8 outputs) +global IMAGE_AGG_PUBLIC_VALUES: u32 = 12; // Main entry point for TreeAggregator // // PUBLIC INPUTS (9 padding fields, forced to zero): -// - pad_0..pad_8: Must be zero. These exist so TreeAggregator has 10 public -// values (9 inputs + 1 return) matching ImageAggregator's layout. This +// - pad_0..pad_8: Must be zero. These exist so TreeAggregator has 12 public +// values (9 inputs + 3 returns) matching ImageAggregator's layout. This // ensures verify_honk_proof works identically at every tree level. // // PRIVATE INPUTS: // - combined_commitment: The expected Poseidon2([commitment_left, commitment_right]) -// Made private to keep it at index 8 of public values (as the return value). +// Made private to keep it at index 9 of public values (as the first return value). // - proof_left: Left child proof (ImageProof or previous TreeProof) // - proof_right: Right child proof (ImageProof or previous TreeProof) -// - vk: Verification key (same for both - they're from the same circuit type) -// - vk_hash: Hash of the VK for recursive verification -// - public_values_left: All public values from left child proof (9 values) -// - public_values_right: All public values from right child proof (9 values) +// - vk_left/vk_right: Verification keys for left/right child proofs +// - vk_hash_left/vk_hash_right: Hash of the VKs for recursive verification +// - public_values_left: All public values from left child proof (12 values) +// - public_values_right: All public values from right child proof (12 values) // -// PUBLIC OUTPUT: -// - Returns the combined_commitment at index 9 -// Public values layout: [0, 0, 0, 0, 0, 0, 0, 0, 0, combined_commitment] +// PUBLIC OUTPUTS: +// - Returns (combined_commitment, vk_hash_left, vk_hash_right) +// Public values layout: [0, 0, 0, 0, 0, 0, 0, 0, 0, combined_commitment, vk_hash_left, vk_hash_right] fn main( // === PUBLIC INPUTS (padding - must be zero) === pad_0: pub Field, @@ -94,7 +96,7 @@ fn main( vk_hash_right: Field, // === PRIVATE INPUTS: ALL PUBLIC VALUES FROM CHILD PROOFS === - // For ImageAggregator proofs (level 0): 10 values each + // For ImageAggregator proofs (level 0): 12 values each // [0] trust_list_root // [1] content_hash // [2] nullifier @@ -105,11 +107,13 @@ fn main( // [7] link_commit // [8] actions_hash_field // [9] image_commitment (the output we care about) + // [10] vk_a_hash + // [11] vk_b_hash // - // For TreeAggregator proofs (level 1+): [0, 0, 0, 0, 0, 0, 0, 0, 0, combined_commitment] - public_values_left: [Field; 10], - public_values_right: [Field; 10], -) -> pub Field { + // For TreeAggregator proofs (level 1+): [0, 0, 0, 0, 0, 0, 0, 0, 0, combined_commitment, vk_hash_left, vk_hash_right] + public_values_left: [Field; 12], + public_values_right: [Field; 12], +) -> pub (Field, Field, Field) { // ================================================================ // STEP 0: Enforce padding fields are zero // ================================================================ @@ -148,7 +152,7 @@ fn main( // ================================================================ // STEP 3: Compute and verify combined commitment // ================================================================ - // The commitment is always the LAST value in the public values array + // The commitment is at index 9 in the public values array (first output) // For ImageAggregator: index 9 (image_commitment) // For TreeAggregator: index 9 (combined_commitment, padded to position 9) let commitment_left = public_values_left[9]; @@ -169,9 +173,9 @@ fn main( "Combined commitment mismatch: tree structure invalid" ); - // Return the combined commitment as public output + // Return the combined commitment and VK hashes as public outputs // This allows the next level of aggregation to verify it - combined_commitment + (combined_commitment, vk_hash_left, vk_hash_right) } // ==================================================================== @@ -218,7 +222,7 @@ fn test_different_children_different_commitment() { #[test] fn test_commitment_index_location() { // Verify that commitment is correctly extracted from index 9 - let public_values: [Field; 10] = [ + let public_values: [Field; 12] = [ 111, // trust_list_root 222, // content_hash 333, // nullifier @@ -229,6 +233,8 @@ fn test_commitment_index_location() { 888, // link_commit 999, // actions_hash_field 1010, // image_commitment (this is what we use) + 1111, // vk_a_hash + 1212, // vk_b_hash ]; let commitment = public_values[9]; From 0592180f039cad0e4bde3c65c18a4c11eaa82934 Mon Sep 17 00:00:00 2001 From: Jamie Newton <33573418+newtsjamie@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:31:26 +0900 Subject: [PATCH 2/5] security: skip flag binary constraints, JWT domain fix, Merkle root monotonicity - Add <= 1 constraints on skip_content_hash_binding and skip_assertion_hash_verification across all 6 proof_a circuits, consistent with other boolean flag hardening - Fix JWT email domain extraction to take first @ occurrence, preventing theoretical domain spoofing via multi-@ emails - Enforce strictly increasing tree size in aztec_verifier update_merkle_root (>= changed to >) with Field-to-u64 range check - Reword SECURITY.md BN254 collision description for accuracy (CodeRabbit review feedback) Co-Authored-By: Claude Opus 4.6 --- SECURITY.md | 6 ++++-- aztec_verifier/src/main.nr | 3 ++- jwt_identity/src/main.nr | 2 +- proof_a/src/main.nr | 2 ++ proof_a_ecdsa_p256/src/main.nr | 2 ++ proof_a_ecdsa_p384/src/main.nr | 2 ++ proof_a_rsa_2048/src/main.nr | 2 ++ proof_a_rsa_4096/src/main.nr | 2 ++ proof_a_skip/src/main.nr | 2 ++ 9 files changed, 19 insertions(+), 4 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index aca5afd..443e5cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,7 +24,7 @@ GPS coordinates (`exact_lat_scaled`, `exact_lon_scaled`) and timestamps (`exact_ 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, approximately 1 in 2^2 values have a collision partner. For P-384 coordinates, the collision space is larger. Finding a meaningful collision (two valid certificates or hashes that map to the same field element) remains computationally infeasible. +**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. @@ -53,10 +53,12 @@ The following hardening measures were added based on internal audit: - **P-384 ECDSA r/s non-zero**: Signature components are asserted non-zero before verification - **Leaf key type consistency**: `leaf_key_type` is constrained to match `cert_algorithm` - **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 +- **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 diff --git a/aztec_verifier/src/main.nr b/aztec_verifier/src/main.nr index 11fe06d..74304be 100644 --- a/aztec_verifier/src/main.nr +++ b/aztec_verifier/src/main.nr @@ -361,8 +361,9 @@ pub contract ApertrueVerifier { assert(sender == admin, "Only admin can update Merkle root"); let current_size = self.storage.tree_size.read(); + 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", ); diff --git a/jwt_identity/src/main.nr b/jwt_identity/src/main.nr index cabed90..9d246d5 100644 --- a/jwt_identity/src/main.nr +++ b/jwt_identity/src/main.nr @@ -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; } diff --git a/proof_a/src/main.nr b/proof_a/src/main.nr index 494a04a..05bb582 100644 --- a/proof_a/src/main.nr +++ b/proof_a/src/main.nr @@ -485,6 +485,8 @@ fn main( assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + assert(skip_content_hash_binding <= 1, "skip_content_hash_binding must be 0 or 1"); + assert(skip_assertion_hash_verification <= 1, "skip_assertion_hash_verification must be 0 or 1"); // Unpack location_flags: bit 0 = lat negative, bit 1 = lon negative assert(location_flags <= 3, "location_flags has invalid high bits set (must be 0-3)"); diff --git a/proof_a_ecdsa_p256/src/main.nr b/proof_a_ecdsa_p256/src/main.nr index 7f723e3..6fce3e3 100644 --- a/proof_a_ecdsa_p256/src/main.nr +++ b/proof_a_ecdsa_p256/src/main.nr @@ -252,6 +252,8 @@ fn main( assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + assert(skip_content_hash_binding <= 1, "skip_content_hash_binding must be 0 or 1"); + assert(skip_assertion_hash_verification <= 1, "skip_assertion_hash_verification must be 0 or 1"); assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); diff --git a/proof_a_ecdsa_p384/src/main.nr b/proof_a_ecdsa_p384/src/main.nr index ff6aaa8..943c667 100644 --- a/proof_a_ecdsa_p384/src/main.nr +++ b/proof_a_ecdsa_p384/src/main.nr @@ -249,6 +249,8 @@ fn main( assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + assert(skip_content_hash_binding <= 1, "skip_content_hash_binding must be 0 or 1"); + assert(skip_assertion_hash_verification <= 1, "skip_assertion_hash_verification must be 0 or 1"); assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); diff --git a/proof_a_rsa_2048/src/main.nr b/proof_a_rsa_2048/src/main.nr index b82bb4d..c6eea6e 100644 --- a/proof_a_rsa_2048/src/main.nr +++ b/proof_a_rsa_2048/src/main.nr @@ -255,6 +255,8 @@ fn main( assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + assert(skip_content_hash_binding <= 1, "skip_content_hash_binding must be 0 or 1"); + assert(skip_assertion_hash_verification <= 1, "skip_assertion_hash_verification must be 0 or 1"); assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); diff --git a/proof_a_rsa_4096/src/main.nr b/proof_a_rsa_4096/src/main.nr index 1e14ce3..17db180 100644 --- a/proof_a_rsa_4096/src/main.nr +++ b/proof_a_rsa_4096/src/main.nr @@ -266,6 +266,8 @@ fn main( assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + assert(skip_content_hash_binding <= 1, "skip_content_hash_binding must be 0 or 1"); + assert(skip_assertion_hash_verification <= 1, "skip_assertion_hash_verification must be 0 or 1"); assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); diff --git a/proof_a_skip/src/main.nr b/proof_a_skip/src/main.nr index bc176dd..2a777c4 100644 --- a/proof_a_skip/src/main.nr +++ b/proof_a_skip/src/main.nr @@ -247,6 +247,8 @@ fn main( assert(exact_lat_is_negative <= 1, "exact_lat_is_negative must be 0 or 1"); assert(exact_lon_is_negative <= 1, "exact_lon_is_negative must be 0 or 1"); + assert(skip_content_hash_binding <= 1, "skip_content_hash_binding must be 0 or 1"); + assert(skip_assertion_hash_verification <= 1, "skip_assertion_hash_verification must be 0 or 1"); assert(exact_lat_scaled as u64 as Field == exact_lat_scaled, "exact_lat_scaled exceeds u64"); assert(center_lat_scaled as u64 as Field == center_lat_scaled, "center_lat_scaled exceeds u64"); From 1809514ade8109ab0d6d6c0c7eddfbbe49362c2e Mon Sep 17 00:00:00 2001 From: Jamie Newton <33573418+newtsjamie@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:41:22 +0900 Subject: [PATCH 3/5] fix: update aztec_verifier public_values to [Field; 12] for VK hash transparency Tree aggregator now outputs 12 public values (was 10): 9 padding zeros, root_commitment, vk_hash_left, vk_hash_right. The contract must accept the full array or verify_honk_proof will hard fail on field count mismatch. Co-Authored-By: Claude Opus 4.6 --- aztec_verifier/src/main.nr | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/aztec_verifier/src/main.nr b/aztec_verifier/src/main.nr index 74304be..26a4147 100644 --- a/aztec_verifier/src/main.nr +++ b/aztec_verifier/src/main.nr @@ -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 @@ -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, @@ -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) @@ -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]; From 0f8de21b4383e56bb11b90366c7600274b8ebd12 Mon Sep 17 00:00:00 2001 From: Jamie Newton <33573418+newtsjamie@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:45:21 +0900 Subject: [PATCH 4/5] fix: range-check stored tree_size before u64 cast in update_merkle_root Defense-in-depth: if stored tree_size were ever corrupted or exceeded u64 range, the monotonicity comparison would silently truncate. Add roundtrip assert matching the pattern already used for new_tree_size. Co-Authored-By: Claude Opus 4.6 --- aztec_verifier/src/main.nr | 1 + 1 file changed, 1 insertion(+) diff --git a/aztec_verifier/src/main.nr b/aztec_verifier/src/main.nr index 26a4147..afcad6d 100644 --- a/aztec_verifier/src/main.nr +++ b/aztec_verifier/src/main.nr @@ -363,6 +363,7 @@ 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, From 5ae4dba58dde7dcfe9c8285b34db4f39c38db657 Mon Sep 17 00:00:00 2001 From: Jamie Newton <33573418+newtsjamie@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:44:51 +0900 Subject: [PATCH 5/5] fix: remove invalid leaf_key_type/cert_algorithm cross-check assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit added an assertion requiring cert_algorithm and leaf_key_type to match (RSA cert = RSA leaf, ECDSA cert = ECDSA leaf). This is incorrect: cert_algorithm describes how the intermediate cert signature is verified, while leaf_key_type describes the leaf certificate's key type. These are independent — Truepic uses RSA-2048-SHA384 for intermediate signing but issues ECDSA P-256 leaf certs. The assertion broke all ChatGPT/Truepic image proving. Co-Authored-By: Claude Opus 4.6 --- SECURITY.md | 2 +- proof_a/src/main.nr | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 443e5cb..b8ce613 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,7 +51,7 @@ The following hardening measures were added based on internal audit: - **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 -- **Leaf key type consistency**: `leaf_key_type` is constrained to match `cert_algorithm` +- **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 diff --git a/proof_a/src/main.nr b/proof_a/src/main.nr index 05bb582..8c1261c 100644 --- a/proof_a/src/main.nr +++ b/proof_a/src/main.nr @@ -280,13 +280,11 @@ fn main( assert(false, f"Invalid certificate algorithm: {cert_algorithm}"); } - // SECURITY: Verify leaf_key_type consistency with cert_algorithm - if (cert_algorithm == 0) | (cert_algorithm == 4) { - assert(leaf_key_type == 0, "leaf_key_type must be ECDSA for ECDSA cert algorithms"); - } else if (cert_algorithm == 1) | (cert_algorithm == 2) | (cert_algorithm == 3) { - assert(leaf_key_type != 0, "leaf_key_type must be RSA for RSA cert algorithms"); - } - // cert_algorithm == 5 (skip) allows either leaf_key_type + // NOTE: cert_algorithm describes how the INTERMEDIATE cert signature is verified + // (e.g., RSA-2048-SHA384), while leaf_key_type describes the LEAF certificate's key type + // (e.g., ECDSA P-256). These are independent -- a CA can sign with RSA but issue ECDSA + // leaf certs (e.g., Truepic: cert_algorithm=3/RSA-2048-SHA384, leaf_key_type=0/ECDSA). + // No cross-check is valid here. // ================================================================ // STEP 2: Derive Leaf Key Hash