Skip to content
Open
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
34 changes: 34 additions & 0 deletions experiments/conoir-spike/ONE-PAGER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Apertrue — Authenticated Private Computation
### *Let distrusting organizations check each other's data for fraud — without sharing it, and without anyone being able to lie about it.*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix markdown heading hierarchy violation.

Line 2 is marked as h3 (###) but should be h2 (##) to comply with markdown heading-level incrementing rules (no skipping levels). This is flagged by markdownlint MD001.

🔧 Proposed fix
- ### *Let distrusting organizations check each other's data for fraud — without sharing it, and without anyone being able to lie about it.*
+ ## *Let distrusting organizations check each other's data for fraud — without sharing it, and without anyone being able to lie about it.*
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### *Let distrusting organizations check each other's data for fraud — without sharing it, and without anyone being able to lie about it.*
## *Let distrusting organizations check each other's data for fraud — without sharing it, and without anyone being able to lie about it.*
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 2-2: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/conoir-spike/ONE-PAGER.md` at line 2, The heading "Let
distrusting organizations check each other's data for fraud — without sharing
it, and without anyone being able to lie about it." is currently marked as h3
with three hash symbols (###) but should be marked as h2 with two hash symbols
(##) to follow proper markdown heading hierarchy and comply with the
markdownlint MD001 rule that prohibits skipping heading levels. Change the three
hash symbols to two hash symbols at the beginning of this line.

Source: Linters/SAST tools


> Working title / positioning. Vertical-agnostic. Claims are factual to the built prototype.

## The problem
Organizations constantly need to check data **against each other** to catch duplication and fraud — the same invoice financed by two lenders, the same claim paid by two insurers, the same asset pledged twice. But they **can't share the underlying data**: it's competitively sensitive, contractually restricted, and often regulated personal data.

So today they face a false choice: **don't check** (and eat the fraud), or **pool the data** (legally and commercially impossible). And even the privacy-preserving approaches that exist have a deeper flaw — they can prove you *computed correctly*, but not that your *inputs were real*.

## The breakthrough: authenticity + privacy, together
Apertrue combines two things that have never been combined:

- **Cryptographic privacy** — multi-party computation lets organizations jointly compute over secret-shared data. No party ever sees another's inputs.
- **Cryptographic authenticity** — apertrue's content-provenance layer (built on the C2PA standard + zero-knowledge proofs) guarantees each input is **genuine, unaltered, and hardware-attested** before it enters the computation.

The result is **verifiable private computation over provably-authentic data**: no one has to reveal their inputs, *and* no one can fabricate them. That combination — solving MPC's "garbage-in" problem with hardware-grade authenticity — is the defensible core. The ingredients are public; **the working combination is not.**

## What it does, plainly
Multiple organizations can answer *"has this item already been used by anyone in our network?"* — and get a trustworthy **yes/no** — while revealing **nothing**: not their data, not their volumes, not even the item being checked. Fabricated entries are rejected by the authenticity layer; the privacy is cryptographic, not "trust us."

## Proof — this is built, not a whitepaper
We have a working, benchmarked end-to-end prototype:
- **Runs under real 3-party secure computation** — proofs generate and verify.
- **Full-registry scale**: proving is a **batch operation measured in minutes**; verification is **instant (milliseconds)** and constant regardless of scale.
- **The authenticity binding holds**: a party that tries to substitute a fabricated input is **provably rejected**.
- Built on **production-grade ZK** — the same recursive-proof machinery apertrue already runs client-side.

## Why it's defensible
- **Network effect** — the registry becomes more valuable as participants join, and the cryptography makes it the *only* way they can participate without exposing data. The network *is* the moat.
- **Standards position** — apertrue's authenticity layer sits on the open C2PA / CAWG standards apertrue is helping shape, giving a credibility and distribution advantage.

## Where we are / the ask
Working prototype, feasibility and performance validated, architecture locked. **Seeking a first design partner** to run a pilot over real (privately-held) data — and **non-dilutive funding** to harden the system for production.
125 changes: 125 additions & 0 deletions experiments/conoir-spike/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# coNoir spike — authenticated-nullifier double-dip check under MPC

Proof-of-life for the **apertrue × coNoir** primitive: prove whether an authenticated
nullifier already exists in a consortium's private set, revealing **only** a yes/no bit,
with the candidate and the set kept secret-shared across the MPC parties.

## Result (2026-06-23)
Ran the full 3-party REP3 MPC pipeline (split-input → witness → proving-key → vk → proof → verify)
on `nullifier_check`:

| Case | Public output | Verified |
|------|---------------|----------|
| candidate not in set | `0` (false) | ✅ |
| candidate in set (double-dip) | `1` (true) | ✅ |

Whole pipeline ~1s for this tiny circuit (proving ~77ms, verify ~2ms). Only the bit was revealed.

## Circuit
`nullifier_check/src/main.nr` — derives `n = Poseidon2([content, scope])`, scans a private
set for membership, returns the collision bit. Poseidon2 + field equality only → inside coNoir's
supported MPC lane (no RSA/ECDSA, which stay in apertrue's local C2PA proof).

## Versions
- co-noir **0.7.0** (built from `github.com/TaceoLabs/co-snarks`).
- nargo **1.0.0-beta.20** (coNoir's target; apertrue is on beta.18 — small gap).
- bn254 CRS from the co-snarks repo.

## Reproduce
`run_nullifier_check.sh` is preserved **as a reference**. Its relative paths assume it sits in
`co-noir/co-noir/examples/` of a co-snarks clone (it points at `../../../target/release/co-noir`
and `../../co-noir-common/src/crs/*`). To re-run: clone co-snarks, `cargo build --release --bin
co-noir`, drop `nullifier_check/` into `examples/test_vectors/`, place this script in `examples/`,
and run it. Compile the circuit with nargo beta.20 first (`nargo execute`).

## IMT non-membership primitive (2026-06-23)
`imt_nm_mini` isolates the two operations Colofon's `check_non_membership` needs that fall in
coNoir's risk zone (bit-decomposition based): `Field::lt` (the low-leaf range check) and
`to_le_bits` (Merkle-path left/right selection), plus Poseidon2. Depth-8, single check,
221 ACIR opcodes / 3845 gates.

Ran under 3-party REP3 MPC -> **proof verified**. So coNoir's co-brillig/co-acvm handle those ops;
the full Colofon IMT non-membership will port. Perf: build-proving-key ~2.0s (dominant, scales with
circuit size), generate-proof ~0.6s, verify ~3ms, ~4s total.

beta.20 migration notes for the real port: `u1` is removed (use `bool`); `to_le_bits` returns
`[bool; N]` (Colofon's `root.nr` still uses the old `[u1; N]`).

## Depth-32 scale perf (2026-06-23)
`imt_nm_scale` mirrors Colofon's per-component non-membership shape (leaf-preimage hash + low-leaf
`lt` + 32-level Merkle path), batched over N checks. `bench_imt_scale.sh N` patches `CHECKS`,
generates the witness, compiles, counts gates, and runs the timed 3-party MPC pipeline.

| N (checks) | gates | witness | proving-key | proof | verify | total |
|------------|-------|---------|-------------|-------|--------|-------|
| 1 | 5,798 | 0.6s | 2.3s | 1.2s | 29ms | ~4.1s |
| 50 (full) | 153,843 | 23s | 107s | 30s | 29ms | ~160s (~2.7 min) |

~3k gates per added depth-32 check; proving-key dominant + slightly super-linear; **verify constant
29ms regardless of scale.** Full-scale double-dip ≈ 2.7 min to prove, instant to verify — fine for a
batch/async fraud check. Caveat: all 3 MPC nodes co-located, so NO network latency in these numbers.

## Binding / authenticated-MPC seam (2026-06-23)
`bound_nm` demonstrates the apertrue <-> coNoir binding. A public `commitment = Commit(nullifier,
blind)` is the authenticity anchor (in the real system apertrue's C2PA proof attests it off-MPC;
it is hiding so it leaks nothing). The party secret-shares `(nullifier, blind)`; a lightweight
in-MPC opening check binds them to the commitment; non-membership runs on the deterministic
secret-shared nullifier; only the collision bit is revealed. No recursive proof verification in MPC.

Ran under 3-party MPC:
- no-collision -> bit 0 (verified)
- collision (set contains the nullifier) -> bit 1 (verified)
- binding failure (substitute a nullifier that does NOT open the commitment) -> rejected,
"Assertion failed: commitment opening failed"

The binding-failure case is the security property: a party cannot substitute an arbitrary nullifier;
it must open the authenticated commitment.

### Threat-model notes (from design review)
- MPC protects the registry AT REST (1 REP3 node can't read shares). The real attack is the
membership ORACLE (guess a low-entropy ID, query, read the bit) -> require a C2PA-authenticated
query too (can only test items you authentically hold).
- coNoir is **semi-honest** only (`mpc-core/src/lib.rs`): secure vs passive nodes, NOT vs actively
deviating ones. For competing institutions, either run nodes under reputable/independent/audited
operators (governance) or wait for malicious-secure REP3 (not yet implemented).
- Privacy rests on: honest-but-curious operators + no 2-of-3 collusion.

## Literal colofon_imt port (2026-06-23)
`imt_real` calls the REAL `colofon_imt` lib (`check_non_membership` + `CveLeafPreimage`), not a
reimplementation. It compiles under nargo beta.20 and runs under 3-party coNoir MPC: `non_existence`
proven true, verified, ~3.5s at depth-8. Confirms faithfulness end to end.

The port needs exactly two migrations to the lib (against beta.20):
1. `root.nr`: `[u1; N]` -> `[bool; N]` (and `if indices[i] == 1` -> `if indices[i]`) -- `u1` removed.
2. `Nargo.toml`: bump the `poseidon` dep `v0.1.1` -> `v0.3.0` (v0.1.1 fails under beta.20:
"Comptime global RATE used in non-comptime code" + a Poseidon2::hash arity error).
`imt_real` here depends on a beta.20-migrated copy of `colofon_imt` (lib copy not committed; the two
migrations above are the whole delta).

## Binding wrapper -- Option A (2026-06-23)
`binding_wrapper` is the CLIENT-SIDE, off-MPC step that produces the commitment without touching
production. It recursively verifies an apertrue proof (one branch of the aggregator, real
`bb_proof_verification` lib, beta.18 toolchain) and emits `C = Poseidon2(public_values[2], blind)` --
a commitment to the VERIFIED nullifier (index 2), not a free witness.

Gate count: **773,025** (one recursive verify + commit). For context, apertrue's `image_aggregator`
does 2x verify (~1.5M gates) client-side in PRODUCTION today, and proving capacity is
`initSRSChonk(2^21)` ~2M. So the wrapper is smaller than what apertrue already proves client-side --
recursion overhead is in-budget, not a new cost category. Compilation + gate count confirmed; full
end-to-end prove not run (needs a real apertrue inner proof; apertrue already runs equivalent
recursion, so it will work).

### Locked architecture (CTO decision)
coNoir double-dip is a **containerised downstream sidecar** that consumes apertrue's existing proof +
deterministic nullifier N. It derives `C` via `binding_wrapper` (composition, NOT duplicating the
C2PA circuit), secret-shares N into the MPC, and runs the bound non-membership. N is never blinded
(load-bearing for dedup/SD/verification/on-chain). Production proof_a/proof_b are untouched.
Rejected: duplicating proof_a (Option B, anti-pattern). Deferred: folding C into proof_a (Option C,
post-lighthouse only, behind a flag -- it is a breaking format change rippling to proof_b/aggregators/
all VKs/on-chain verifier/backend parser/client).

## Remaining (non-code / deferred)
- Networked 3-machine latency: needs real infra (3 machines / WAN). All numbers here are co-located.
- Production graft of C into proof_a: deferred to post-lighthouse (Option C above).
- The gating risk is now PRODUCT, not engineering: lighthouse use case + first design partner +
node-operator governance (which makes coNoir's semi-honest model acceptable).
63 changes: 63 additions & 0 deletions experiments/conoir-spike/bench_imt_scale.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Benchmark imt_nm_scale at a given number of checks N.
# Usage: bench_imt_scale.sh N
set -e
cd "$(dirname "$(realpath "$0")")"
N="${1:-1}"
NARGO=~/.nargo/bin/nargo
CO=../../../target/release/co-noir
TV=test_vectors/imt_nm_scale
CRS1=../../co-noir-common/src/crs/bn254_g1.dat
CRS2=../../co-noir-common/src/crs/bn254_g2.dat
Comment on lines +8 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix benchmark path constants to match this package layout.

Line 9 targets test_vectors/imt_nm_scale, but this PR adds the circuit at experiments/conoir-spike/imt_nm_scale; Line 35 then executes in that target path. As written, the benchmark can fail before proving.

💡 Suggested patch
-NARGO=~/.nargo/bin/nargo
-CO=../../../target/release/co-noir
-TV=test_vectors/imt_nm_scale
+NARGO=~/.nargo/bin/nargo
+CO=../../target/release/co-noir
+TV=imt_nm_scale

Also applies to: 17-17, 35-35

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/conoir-spike/bench_imt_scale.sh` around lines 8 - 11, The TV
variable pointing to test_vectors/imt_nm_scale does not match the actual
location of the circuit files in the current package layout. Update the TV path
constant to reference the correct location at
experiments/conoir-spike/imt_nm_scale so that when the benchmark script executes
in that target path (as performed on line 35), it can properly locate the
necessary circuit files and prevent the benchmark from failing before proving.

now(){ python3 -c 'import time;print(int(time.time()*1000))'; }

echo "########## N = $N ##########"

# 1. patch CHECKS in the circuit
sed -i '' "s/^global CHECKS: u32 = .*/global CHECKS: u32 = $N;/" "$TV/src/main.nr"

# 2. generate Prover.toml (N checks, depth 32; low_key < key < next_key)
node -e '
const N=+process.argv[1], D=32;
const arr=(f)=>"["+Array.from({length:N},(_,c)=>`"${f(c)}"`).join(", ")+"]";
let s="";
s+="key = "+arr(c=>c*1000+20)+"\n";
s+="low_key = "+arr(c=>c*1000+10)+"\n";
s+="next_key = "+arr(c=>c*1000+30)+"\n";
s+="next_index = "+arr(c=>c)+"\n";
s+="low_leaf_index = "+arr(c=>c)+"\n";
const paths=Array.from({length:N},(_,c)=>"["+Array.from({length:D},(_,i)=>`"${c*100+i+1}"`).join(", ")+"]");
s+="sibling_path = ["+paths.join(", ")+"]\n";
process.stdout.write(s);
' "$N" > "$TV/Prover.toml"

# 3. compile + gate count
( cd "$TV" && "$NARGO" execute >/dev/null 2>&1 )
GATES=$(bb gates -b "$TV/target/imt_nm_scale.json" 2>/dev/null | grep -oE '"circuit_size": *[0-9]+' | grep -oE '[0-9]+' | head -1)
echo "gates(circuit_size) = ${GATES:-unknown}"

# 4. timed MPC pipeline
t0=$(now)
"$CO" split-input --circuit "$TV/target/imt_nm_scale.json" --input "$TV/Prover.toml" --protocol REP3 --out-dir "$TV" >/dev/null 2>&1
for p in 0 1 2; do
"$CO" generate-witness --input "$TV/Prover.toml.$p.shared" --circuit "$TV/target/imt_nm_scale.json" --protocol REP3 --config "configs/party$((p+1)).toml" --out "$TV/w.$p.shared" >/dev/null 2>&1 &
done; wait
tw=$(now)
for p in 0 1 2; do
"$CO" build-proving-key --witness "$TV/w.$p.shared" --circuit "$TV/target/imt_nm_scale.json" --protocol REP3 --config "configs/party$((p+1)).toml" --out "$TV/pk.$p.shared" --crs "$CRS1" >/dev/null 2>&1 &
done; wait
tpk=$(now)
"$CO" create-vk --circuit "$TV/target/imt_nm_scale.json" --crs "$CRS1" --hasher keccak --vk "$TV/vk" >/dev/null 2>&1
for p in 0 1 2; do
extra=""; [ "$p" = "0" ] && extra="--public-input $TV/public_input"
"$CO" generate-proof --proving-key "$TV/pk.$p.shared" --protocol REP3 --hasher keccak --config "configs/party$((p+1)).toml" --crs "$CRS1" --out "$TV/proof.$p.proof" --vk "$TV/vk" $extra >/dev/null 2>&1 &
done; wait
tpf=$(now)
"$CO" verify --proof "$TV/proof.0.proof" --public-input "$TV/public_input" --vk "$TV/vk" --hasher keccak --crs "$CRS2" >/dev/null 2>&1 && echo "VERIFIED ok"
tv=$(now)

echo "witness: $((tw-t0)) ms"
echo "proving-key: $((tpk-tw)) ms"
echo "proof: $((tpf-tpk)) ms (incl vk)"
echo "verify: $((tv-tpf)) ms"
echo "TOTAL: $((tv-t0)) ms"
1 change: 1 addition & 0 deletions experiments/conoir-spike/binding_wrapper/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target/
10 changes: 10 additions & 0 deletions experiments/conoir-spike/binding_wrapper/Nargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "binding_wrapper"
type = "bin"
authors = ["Apertrue"]
compiler_version = ">=1.0.0"
description = "Option A: recursively verify an apertrue proof, emit C = Commit(nullifier, blind). Client-side, off-MPC. Production proof_a/proof_b untouched."

[dependencies]
bb_proof_verification = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.3", directory = "barretenberg/noir/bb_proof_verification" }
poseidon = { tag = "v0.1.1", git = "https://github.com/noir-lang/poseidon" }
Comment on lines +5 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Cross-check declared versions vs documented compatibility notes.
rg -n 'compiler_version|poseidon|beta\.18|beta\.20|v0\.1\.1|v0\.3\.0' \
  experiments/conoir-spike/binding_wrapper/Nargo.toml \
  experiments/conoir-spike/bound_nm/Nargo.toml \
  experiments/conoir-spike/README.md

Repository: Apertrue/circuits

Length of output: 1465


🏁 Script executed:

cat -n experiments/conoir-spike/README.md | sed -n '20,100p'

Repository: Apertrue/circuits

Length of output: 5786


🏁 Script executed:

cat -n experiments/conoir-spike/README.md | sed -n '99,120p'

Repository: Apertrue/circuits

Length of output: 1908


🏁 Script executed:

cat experiments/conoir-spike/binding_wrapper/Nargo.toml

Repository: Apertrue/circuits

Length of output: 680


🏁 Script executed:

cat -n experiments/conoir-spike/README.md | sed -n '85,105p'

Repository: Apertrue/circuits

Length of output: 1681


Pin compiler_version to the documented beta.18 toolchain.

The manifest declares compiler_version = ">=1.0.0" but README line 102 explicitly documents that binding_wrapper targets the beta.18 toolchain (using bb_proof_verification's beta.18 version). Pin to "1.0.0-beta.18" to match the tested configuration. The current poseidon v0.1.1 is compatible with beta.18 (the v0.1.1→v0.3.0 migration mentioned in the README applies to the beta.20 port of imt_real, not this module).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/conoir-spike/binding_wrapper/Nargo.toml` around lines 5 - 10,
Update the compiler_version field in the Nargo.toml file from the permissive
constraint ">=1.0.0" to the specific pinned version "1.0.0-beta.18" to match the
documented and tested configuration for the binding_wrapper module. This ensures
consistency with the bb_proof_verification dependency and the README
documentation which explicitly targets the beta.18 toolchain.

30 changes: 30 additions & 0 deletions experiments/conoir-spike/binding_wrapper/src/main.nr
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Option A -- the coNoir binding wrapper (CLIENT-SIDE, off-MPC).
//
// Recursively verifies an apertrue proof (mirroring one branch of the
// aggregators), then emits a blinded commitment to the VERIFIED nullifier.
// Production proof_a/proof_b are untouched; this layers on top of their output.
//
// - verify_honk_proof binds `public_values` to the authentic apertrue proof.
// - the nullifier is public_values[2] (a verified output, NOT a free witness).
// - output C = Poseidon2(nullifier, blind) is the public authenticity anchor
// that the MPC opening check later binds the secret-shared nullifier to.

use dep::bb_proof_verification::{UltraHonkZKProof, UltraHonkVerificationKey, verify_honk_proof};
use poseidon::poseidon2::Poseidon2;

fn main(
proof: UltraHonkZKProof, // private: the apertrue proof (stand-in)
vk: UltraHonkVerificationKey, // private
public_values: [Field; 12], // private: inner proof's public outputs
vk_hash: Field, // private
blind: Field, // private: fresh random blind
) -> pub Field { // public: C = Commit(nullifier, blind)
// 1. Recursively verify the apertrue proof; binds public_values to it.
verify_honk_proof(vk, proof, public_values, vk_hash);

// 2. The nullifier is the VERIFIED public output at index 2.
let nullifier = public_values[2];

Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare wrapper vs upstream verifier pattern for VK hash binding.
rg -n 'verify_honk_proof|vk_hash|VK hash mismatch|assert\(vk_hash' \
  experiments/conoir-spike/binding_wrapper/src/main.nr \
  aztec_verifier/src/main.nr

Repository: Apertrue/circuits

Length of output: 2307


🏁 Script executed:

# Read the full main.nr file from binding_wrapper to understand current implementation
cat -n experiments/conoir-spike/binding_wrapper/src/main.nr

Repository: Apertrue/circuits

Length of output: 1901


🏁 Script executed:

# Check the aztec_verifier function signature to understand the allowlisted vk_hash pattern
sed -n '155,210p' aztec_verifier/src/main.nr

Repository: Apertrue/circuits

Length of output: 3377


Constrain vk_hash to an allowlisted key before extracting the nullifier.

The verify_honk_proof call at line 23 verifies internal consistency of (vk, proof, public_values, vk_hash) but does not validate that vk_hash corresponds to the authentic apertrue circuit. An attacker can provide a valid proof and verification key for a different circuit, and verify_honk_proof will succeed with that alternative VK while the wrapper extracts public_values[2] from the wrong circuit's output.

Compare this to aztec_verifier/src/main.nr lines 202-203, which validates vk_hash == stored_vk_hash before calling verify_honk_proof. The binding_wrapper should follow the same pattern:

Proposed hardening
 fn main(
     proof: UltraHonkZKProof,
     vk: UltraHonkVerificationKey,
     public_values: [Field; 12],
     vk_hash: Field,
+    expected_vk_hash: pub Field,
     blind: Field,
 ) -> pub Field {
+    assert(vk_hash == expected_vk_hash, "VK hash mismatch");
     verify_honk_proof(vk, proof, public_values, vk_hash);

     let nullifier = public_values[2];
     Poseidon2::hash([nullifier, blind], 2)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/conoir-spike/binding_wrapper/src/main.nr` around lines 23 - 27,
The verify_honk_proof call does not validate that vk_hash corresponds to an
authentic or allowlisted verification key before extracting the nullifier from
public_values. Add a constraint check before calling verify_honk_proof that
compares the provided vk_hash against a stored or allowlisted authentic vk_hash
value (similar to the pattern used in aztec_verifier/src/main.nr lines 202-203).
This constraint must be enforced before the verify_honk_proof invocation to
ensure only trusted circuits can have their public outputs used, preventing an
attacker from providing a valid proof from an alternative circuit while the
wrapper extracts values from the wrong circuit's output.

// 3. Emit the hiding commitment to the authentic nullifier.
Poseidon2::hash([nullifier, blind], 2)
}
7 changes: 7 additions & 0 deletions experiments/conoir-spike/bound_nm/Nargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "bound_nm"
type = "bin"
compiler_version = ">=1.0.0"
authors = ["Apertrue"]

[dependencies]
7 changes: 7 additions & 0 deletions experiments/conoir-spike/bound_nm/Prover.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Bound double-dip, no-collision case. nullifier 42 opens the commitment.
# Collision: add "42" to existing. Binding-failure: change nullifier to a value
# that does not open the commitment -> "commitment opening failed".
nullifier = "42"
blind = "12345"
commitment = "0x03f0661cae8bd60726876aa42536ef1d790c866dc3312872471385c22d1d44ff"
existing = ["10", "11", "12", "13"]
37 changes: 37 additions & 0 deletions experiments/conoir-spike/bound_nm/src/main.nr
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Bound double-dip check (coNoir spike) -- the apertrue <-> coNoir seam.
//
// Demonstrates the binding that makes this "authenticated MPC":
// - commitment = Commit(nullifier, blind) is a PUBLIC authenticity anchor.
// In the real system apertrue's C2PA proof attests this commitment off-MPC
// (verifying it reveals only the commitment, which is hiding -> leaks nothing).
// - The party secret-shares (nullifier, blind) into the MPC.
// - A lightweight in-MPC OPENING check binds the secret-shared values to the
// public commitment -- so the nullifier being checked is provably the
// authenticated one, without any node ever seeing it.
// - Non-membership (double-dip) runs on the deterministic secret-shared
// nullifier; only the collision bit is revealed.
//
// No recursive proof verification in MPC -- just a Poseidon2 opening check.

global SET_SIZE: u32 = 4;

fn commit(value: Field, blind: Field) -> Field {
std::hash::poseidon2_permutation::<4>([value, blind, 0, 0])[0]
}
Comment on lines +18 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the two commitment constructions used by the seam.
rg -n 'poseidon2_permutation|Poseidon2::hash|fn commit' \
  experiments/conoir-spike/bound_nm/src/main.nr \
  experiments/conoir-spike/binding_wrapper/src/main.nr \
  tree_aggregator/src/main.nr

# Expected: one canonical construction everywhere, or explicit documented/tested equivalence.

Repository: Apertrue/circuits

Length of output: 1047


🏁 Script executed:

# Check imports and understand what Poseidon2::hash and poseidon2_permutation are
cat -n experiments/conoir-spike/bound_nm/src/main.nr | head -30

Repository: Apertrue/circuits

Length of output: 1852


🏁 Script executed:

# Check binding_wrapper to understand line 29 in full context
cat -n experiments/conoir-spike/binding_wrapper/src/main.nr | head -35

Repository: Apertrue/circuits

Length of output: 1901


🏁 Script executed:

# Search for any tests or documentation about Poseidon2 commitment equivalence
fd -t f '.*test.*\|.*spec.*' experiments/conoir-spike/ | head -20

Repository: Apertrue/circuits

Length of output: 155


🏁 Script executed:

# Look for Poseidon2 imports and their origins
rg -n 'use.*Poseidon2|use.*poseidon2_permutation' experiments/conoir-spike/

Repository: Apertrue/circuits

Length of output: 371


🏁 Script executed:

# Search for any test files that test commitments
fd -t f '\.(test|spec)\.' --type f . | xargs grep -l 'commit\|Poseidon2' 2>/dev/null | head -20

Repository: Apertrue/circuits

Length of output: 155


🏁 Script executed:

# Check if there are any comments or documentation about the equivalence
rg -n 'poseidon2_permutation|equivalence|commitment.*hash' experiments/conoir-spike/ --type nr -B 2 -A 2

Repository: Apertrue/circuits

Length of output: 182


🏁 Script executed:

# Check the README for any notes about this issue
cat experiments/conoir-spike/README.md

Repository: Apertrue/circuits

Length of output: 7911


🏁 Script executed:

# Search for any tests in the entire repository that might test this
find . -name '*.nr' -type f | xargs grep -l 'fn.*test\|#\[test\]' 2>/dev/null | head -10

Repository: Apertrue/circuits

Length of output: 577


Unify the commitment primitive across bound_nm and binding_wrapper.

bound_nm computes commitments with std::hash::poseidon2_permutation::<4>([value, blind, 0, 0])[0], while binding_wrapper emits Poseidon2::hash([nullifier, blind], 2). These are different APIs (raw permutation vs. sponge-based hash) and must produce identical commitments for the binding to remain secure. The architecture depends on this equivalence, but uses two distinct implementations without explicit cross-module verification. If these diverge—whether immediately or across future Noir/coNoir versions—valid openings will fail silently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/conoir-spike/bound_nm/src/main.nr` around lines 18 - 20, The
commit function in bound_nm and the commitment computation in binding_wrapper
use different Poseidon2 APIs that may not produce identical results, risking
silent failures if they diverge. Unify the commitment primitive by ensuring both
modules use the same implementation and API for computing commitments from a
value and blind parameter. Either update the commit function to match the
sponge-based Poseidon2::hash approach used in binding_wrapper or update
binding_wrapper to use the same raw permutation approach, ensuring both produce
identical commitments for the same inputs.


fn main(
nullifier: Field, // private, secret-shared
blind: Field, // private, secret-shared
commitment: pub Field, // public authenticity anchor (hiding)
existing: [Field; SET_SIZE], // private, secret-shared registry
) -> pub bool {
// BINDING: the secret-shared (nullifier, blind) must open the public anchor.
assert(commit(nullifier, blind) == commitment, "commitment opening failed");

// DOUBLE-DIP: is this authenticated nullifier already present?
let mut collision: bool = false;
for i in 0..SET_SIZE {
collision = collision | (existing[i] == nullifier);
}
collision
}
7 changes: 7 additions & 0 deletions experiments/conoir-spike/imt_nm_mini/Nargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "imt_nm_mini"
type = "bin"
compiler_version = ">=1.0.0"
authors = ["Apertrue"]

[dependencies]
6 changes: 6 additions & 0 deletions experiments/conoir-spike/imt_nm_mini/Prover.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Non-membership case: low_key < key < next_key (10 < 20 < 30) -> in_range holds.
key = "20"
low_key = "10"
next_key = "30"
low_leaf_index = "5"
sibling_path = ["111", "222", "333", "444", "555", "666", "777", "888"]
Loading
Loading