diff --git a/experiments/conoir-spike/ONE-PAGER.md b/experiments/conoir-spike/ONE-PAGER.md new file mode 100644 index 0000000..d316ec1 --- /dev/null +++ b/experiments/conoir-spike/ONE-PAGER.md @@ -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.* + +> 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. diff --git a/experiments/conoir-spike/README.md b/experiments/conoir-spike/README.md new file mode 100644 index 0000000..6033abf --- /dev/null +++ b/experiments/conoir-spike/README.md @@ -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). diff --git a/experiments/conoir-spike/bench_imt_scale.sh b/experiments/conoir-spike/bench_imt_scale.sh new file mode 100755 index 0000000..d328833 --- /dev/null +++ b/experiments/conoir-spike/bench_imt_scale.sh @@ -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 +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" diff --git a/experiments/conoir-spike/binding_wrapper/.gitignore b/experiments/conoir-spike/binding_wrapper/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/experiments/conoir-spike/binding_wrapper/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/experiments/conoir-spike/binding_wrapper/Nargo.toml b/experiments/conoir-spike/binding_wrapper/Nargo.toml new file mode 100644 index 0000000..b7dc80b --- /dev/null +++ b/experiments/conoir-spike/binding_wrapper/Nargo.toml @@ -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" } diff --git a/experiments/conoir-spike/binding_wrapper/src/main.nr b/experiments/conoir-spike/binding_wrapper/src/main.nr new file mode 100644 index 0000000..da2e36c --- /dev/null +++ b/experiments/conoir-spike/binding_wrapper/src/main.nr @@ -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]; + + // 3. Emit the hiding commitment to the authentic nullifier. + Poseidon2::hash([nullifier, blind], 2) +} diff --git a/experiments/conoir-spike/bound_nm/Nargo.toml b/experiments/conoir-spike/bound_nm/Nargo.toml new file mode 100644 index 0000000..fe48de0 --- /dev/null +++ b/experiments/conoir-spike/bound_nm/Nargo.toml @@ -0,0 +1,7 @@ +[package] +name = "bound_nm" +type = "bin" +compiler_version = ">=1.0.0" +authors = ["Apertrue"] + +[dependencies] diff --git a/experiments/conoir-spike/bound_nm/Prover.toml b/experiments/conoir-spike/bound_nm/Prover.toml new file mode 100644 index 0000000..9abcf50 --- /dev/null +++ b/experiments/conoir-spike/bound_nm/Prover.toml @@ -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"] diff --git a/experiments/conoir-spike/bound_nm/src/main.nr b/experiments/conoir-spike/bound_nm/src/main.nr new file mode 100644 index 0000000..5f18ad5 --- /dev/null +++ b/experiments/conoir-spike/bound_nm/src/main.nr @@ -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] +} + +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 +} diff --git a/experiments/conoir-spike/imt_nm_mini/Nargo.toml b/experiments/conoir-spike/imt_nm_mini/Nargo.toml new file mode 100644 index 0000000..6031eb5 --- /dev/null +++ b/experiments/conoir-spike/imt_nm_mini/Nargo.toml @@ -0,0 +1,7 @@ +[package] +name = "imt_nm_mini" +type = "bin" +compiler_version = ">=1.0.0" +authors = ["Apertrue"] + +[dependencies] diff --git a/experiments/conoir-spike/imt_nm_mini/Prover.toml b/experiments/conoir-spike/imt_nm_mini/Prover.toml new file mode 100644 index 0000000..cf0b51b --- /dev/null +++ b/experiments/conoir-spike/imt_nm_mini/Prover.toml @@ -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"] diff --git a/experiments/conoir-spike/imt_nm_mini/src/main.nr b/experiments/conoir-spike/imt_nm_mini/src/main.nr new file mode 100644 index 0000000..9cbd31b --- /dev/null +++ b/experiments/conoir-spike/imt_nm_mini/src/main.nr @@ -0,0 +1,41 @@ +// Minimal IMT non-membership primitive test for coNoir MPC. +// +// Isolates the two operations Colofon's check_non_membership needs that fall +// in coNoir's risk zone (bit-decomposition based): +// 1. Field::lt -> the low-leaf range check (low_key < key < next_key) +// 2. to_le_bits -> left/right selection while hashing the Merkle path +// Plus Poseidon2 (already proven supported). If this runs under MPC and +// verifies, the full Colofon IMT non-membership circuit will too. + +global DEPTH: u32 = 8; + +fn merkle_hash(l: Field, r: Field) -> Field { + std::hash::poseidon2_permutation::<4>([l, r, 0, 0])[0] +} + +fn main( + key: Field, // candidate (query) key, the non-membership target + low_key: Field, // low leaf's key + next_key: Field, // low leaf's next key + low_leaf_index: Field, // low leaf's index in the tree + sibling_path: [Field; DEPTH], // non-membership Merkle opening for the low leaf +) -> pub Field { // returns the computed IMT root (public) + // RISK OP 1: comparisons. Non-membership requires the key to sit strictly + // inside the low leaf's gap. lt() lowers to bit decomposition. + let in_range = low_key.lt(key) & key.lt(next_key); + assert(in_range, "key not strictly within low-leaf gap"); + + // RISK OP 2: to_le_bits drives left/right selection up the tree; Poseidon2 hashes. + let low_leaf = merkle_hash(low_key, next_key); + let mut node = low_leaf; + let bits: [bool; DEPTH] = low_leaf_index.to_le_bits(); + for i in 0..DEPTH { + let (a, b) = if bits[i] { + (sibling_path[i], node) + } else { + (node, sibling_path[i]) + }; + node = merkle_hash(a, b); + } + node +} diff --git a/experiments/conoir-spike/imt_nm_scale/Nargo.toml b/experiments/conoir-spike/imt_nm_scale/Nargo.toml new file mode 100644 index 0000000..6708e35 --- /dev/null +++ b/experiments/conoir-spike/imt_nm_scale/Nargo.toml @@ -0,0 +1,7 @@ +[package] +name = "imt_nm_scale" +type = "bin" +compiler_version = ">=1.0.0" +authors = ["Apertrue"] + +[dependencies] diff --git a/experiments/conoir-spike/imt_nm_scale/src/main.nr b/experiments/conoir-spike/imt_nm_scale/src/main.nr new file mode 100644 index 0000000..3d9562d --- /dev/null +++ b/experiments/conoir-spike/imt_nm_scale/src/main.nr @@ -0,0 +1,49 @@ +// Depth-32 IMT non-membership at Colofon scale, for the coNoir MPC perf number. +// +// Mirrors the dominant cost of Colofon's sbom_non_membership per component: +// - leaf-preimage hash (value, next_value, next_index) -> Poseidon2 +// - low-leaf range check (low < key < next) -> Field::lt +// - 32-level Merkle path to the CVE-tree root -> to_le_bits + Poseidon2 +// batched over CHECKS components (Colofon MAX_SBOM_COMPONENTS = 50). +// +// CHECKS is patched by the run harness to sweep N=1 and N=50. + +global DEPTH: u32 = 32; +global CHECKS: u32 = 50; + +fn merkle_hash(l: Field, r: Field) -> Field { + std::hash::poseidon2_permutation::<4>([l, r, 0, 0])[0] +} + +fn main( + key: [Field; CHECKS], // query keys (non-membership targets) + low_key: [Field; CHECKS], // low leaf key + next_key: [Field; CHECKS], // low leaf next key + next_index: [Field; CHECKS], // low leaf next index (part of preimage) + low_leaf_index: [Field; CHECKS], // low leaf position in the tree + sibling_path: [[Field; DEPTH]; CHECKS], // per-check non-membership opening +) -> pub Field { // accumulated roots (keeps work live) + let mut acc: Field = 0; + for c in 0..CHECKS { + // low-leaf range check + let in_range = low_key[c].lt(key[c]) & key[c].lt(next_key[c]); + assert(in_range, "key not within low-leaf gap"); + + // leaf preimage hash: Poseidon2(Poseidon2(low_key, next_key), next_index) + let low_leaf = merkle_hash(merkle_hash(low_key[c], next_key[c]), next_index[c]); + + // 32-level Merkle path to the root + let mut node = low_leaf; + let bits: [bool; DEPTH] = low_leaf_index[c].to_le_bits(); + for i in 0..DEPTH { + let (a, b) = if bits[i] { + (sibling_path[c][i], node) + } else { + (node, sibling_path[c][i]) + }; + node = merkle_hash(a, b); + } + acc = acc + node; + } + acc +} diff --git a/experiments/conoir-spike/imt_real/Nargo.toml b/experiments/conoir-spike/imt_real/Nargo.toml new file mode 100644 index 0000000..3df94fb --- /dev/null +++ b/experiments/conoir-spike/imt_real/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "imt_real" +type = "bin" +compiler_version = ">=1.0.0" +authors = ["Apertrue"] + +[dependencies] +colofon_imt = { path = "../colofon_imt_lib" } diff --git a/experiments/conoir-spike/imt_real/Prover.toml b/experiments/conoir-spike/imt_real/Prover.toml new file mode 100644 index 0000000..6bb1da1 --- /dev/null +++ b/experiments/conoir-spike/imt_real/Prover.toml @@ -0,0 +1,7 @@ +key="20" +low_key="10" +next_key="30" +next_index="0" +low_leaf_index="0" +sibling_path=["1","2","3","4","5","6","7","8"] +tree_root="0x28618b6eec45a079d66c8e06817c3274f12a40e18551f92f607fa825787150c0" diff --git a/experiments/conoir-spike/imt_real/src/main.nr b/experiments/conoir-spike/imt_real/src/main.nr new file mode 100644 index 0000000..4d66e4a --- /dev/null +++ b/experiments/conoir-spike/imt_real/src/main.nr @@ -0,0 +1,22 @@ +// Faithful port test: the REAL colofon_imt lib (check_non_membership + +// CveLeafPreimage) compiled under nargo beta.20 and run under coNoir MPC. + +use colofon_imt::cve_leaf_preimage::CveLeafPreimage; +use colofon_imt::membership::{check_non_membership, MembershipWitness}; + +global TREE_HEIGHT: u32 = 8; + +fn main( + key: Field, // non-membership target + low_key: Field, // low leaf key + next_key: Field, // low leaf next key + next_index: Field, // low leaf next index + low_leaf_index: Field, // low leaf position + sibling_path: [Field; TREE_HEIGHT], // membership opening for the low leaf + tree_root: pub Field, // public IMT root +) -> pub bool { + let low_leaf = CveLeafPreimage::new(low_key, next_key, next_index); + let witness = MembershipWitness { leaf_index: low_leaf_index, sibling_path }; + let (non_existence, _valid, _exists) = check_non_membership(key, low_leaf, witness, tree_root); + non_existence +} diff --git a/experiments/conoir-spike/nullifier_check/Nargo.toml b/experiments/conoir-spike/nullifier_check/Nargo.toml new file mode 100644 index 0000000..d3d6cf4 --- /dev/null +++ b/experiments/conoir-spike/nullifier_check/Nargo.toml @@ -0,0 +1,7 @@ +[package] +name = "nullifier_check" +type = "bin" +compiler_version = ">=1.0.0" +authors = ["Apertrue"] + +[dependencies] diff --git a/experiments/conoir-spike/nullifier_check/Prover.toml b/experiments/conoir-spike/nullifier_check/Prover.toml new file mode 100644 index 0000000..40221a6 --- /dev/null +++ b/experiments/conoir-spike/nullifier_check/Prover.toml @@ -0,0 +1,3 @@ +candidate_content = "1" +scope = "2" +existing = ["10", "0x299bfccd7daf3c917e51291383929049ec0eaed800af245056cbf135f7dea636", "12", "13"] diff --git a/experiments/conoir-spike/nullifier_check/src/main.nr b/experiments/conoir-spike/nullifier_check/src/main.nr new file mode 100644 index 0000000..252f6a0 --- /dev/null +++ b/experiments/conoir-spike/nullifier_check/src/main.nr @@ -0,0 +1,31 @@ +// Minimal cross-party double-dip check (coNoir spike). +// +// Mirrors the apertrue x coNoir primitive at its smallest: +// - each party's authentic content yields a nullifier n = Poseidon2([content, scope]) +// - the consortium holds a private set of existing nullifiers (secret-shared in MPC) +// - we reveal ONLY whether n is already present (a double-dip) +// +// Uses Poseidon2 + field equality only -> inside coNoir's supported lane. + +global SET_SIZE: u32 = 4; + +fn main( + // private: this party's authenticated content fingerprint (e.g. apertrue content_hash) + candidate_content: Field, + // context binding (consortium / epoch); kept private here, can be made public later + scope: Field, + // private: the consortium's existing nullifiers (secret-shared across the MPC parties) + existing: [Field; SET_SIZE], +) -> pub bool { + // derive the nullifier with the Poseidon2 blackbox (permutation form, supported in MPC) + let nullifier: Field = std::hash::poseidon2_permutation::<4>([candidate_content, scope, 0, 0])[0]; + + // membership scan: collision == true means the item is already used somewhere + let mut collision: bool = false; + for i in 0..SET_SIZE { + collision = collision | (existing[i] == nullifier); + } + + // the ONLY thing revealed: the double-dip bit + collision +} diff --git a/experiments/conoir-spike/quorum/.gitignore b/experiments/conoir-spike/quorum/.gitignore new file mode 100644 index 0000000..c69cf43 --- /dev/null +++ b/experiments/conoir-spike/quorum/.gitignore @@ -0,0 +1,9 @@ +# Noir build artifacts +**/target/ + +# Python caches +**/__pycache__/ + +# Stand-in keys + regenerable crypto material — never commit private keys. +# (regenerated by adapters/fattura_adapter.py / run_fattura_pipeline.sh) +adapters/keys/ diff --git a/experiments/conoir-spike/quorum/ARCHITECTURE.md b/experiments/conoir-spike/quorum/ARCHITECTURE.md new file mode 100644 index 0000000..341e63e --- /dev/null +++ b/experiments/conoir-spike/quorum/ARCHITECTURE.md @@ -0,0 +1,216 @@ +# Apertrue Quorum — Architecture Design + +Consolidated from the authenticity analysis (C2PA vs PAdES/e-invoice/clearance, +canonicalisation, the two-signer model). This is the target production +architecture; it is deliberately **envelope-agnostic** with **pluggable input +adapters**, so we never fork the system per industry. + +--- + +## 0. The one-line shape + +``` +source document → [INPUT ADAPTER] → {canonical fields, signatures, certs} + → [CANONICALISATION] → canonical_id, anchor + → [PROOF A, per party, in-circuit] → role-stamped anchor (+ proof) + → [ENGINE, joint via coNoir] → one bit (+ portable proof) +``` + +The **core** (canonicalisation → Proof A → engine → coNoir) is shared across +every vertical. Only the **input adapter** changes per document format. + +--- + +## 1. Invariants (do not break these) + +1. **The core is envelope-agnostic.** It consumes `{canonical fields, signatures, + trust anchors}` — never a specific document format. (Confirmed: the circuits + already operate on canonical fields, never on C2PA.) +2. **Two guarantees, married:** authenticity (a non-gameable signature) **and** + private joint computation (coNoir). Neither alone is enough. +3. **The trust is the *signer*, not the envelope.** C2PA/PAdES/e-invoice are + transport; the trust comes from *who signed* the canonical fields. +4. **Per-vertical authenticity:** ride native rails where they exist + (structured), use C2PA where they don't (unstructured/media). Never force a + format onto producers. +5. **No operator ever holds the union.** Each party keeps its own book; only + roots/commitments are public; only one bit leaves. + +--- + +## 2. The core (shared) — mostly built + +- `canonical_id = Poseidon2([canonical fields])` +- `anchor = Poseidon2([canonical_id, salt, signer_role])` +- **Three engines**, pick per use case: + - **Membership** (duplication / double-X) — `bound_receivables`, IMT non-membership. + - **Aggregate** (sum vs a signed limit) — `bound_aggregate`. + - **Relational** (distance / time / order) — `bound_geotime`. +- **Status:** built and proven under real 3-party REP3 MPC. + +--- + +## 3. Proof A — the authenticity binding **(PRIORITY BUILD — the soundness fix)** + +**Today (stand-in):** the obligor signature is verified *off-circuit* (openssl in +admission); the circuit only consumes the role-stamped `anchor` and checks +`signer_role ∈ accepted_roles`. So the trustless proof does **not** itself attest +that a real obligor signed — a fabricated anchor claiming `role = obligor` would +pass the MPC. + +**Target (sound):** Proof A runs **in-circuit**, per party, and: +1. verifies the **obligor's** ES256 signature **over `canonical_id`**, +2. verifies the **issuer/clearance** signature, +3. checks **both certs ∈ trust-list Merkle root**, +4. binds to the **role-stamped anchor**. + +So the one-bit answer provably rests on a genuine, obligor-signed document. + +**Reuse:** apertrue's `proof_a_ecdsa_p256` already does in-circuit ES256 +verification + Merkle membership + nullifier binding. This is integration, not +new crypto. + +**Two-signer model:** +- **issuer / clearance** signs → the invoice is *genuine* (catches fabricated documents). +- **obligor** signs → the debt is *owed* (catches fabricated debts; the non-gameable signer). +- Roles are bound into the anchor. + +--- + +## 4. Input adapters (off-circuit, per vertical) — build at pilot + +**Adapter contract (the interface every adapter satisfies):** + +``` +adapter(source_document) -> { + canonical_fields, // the fields the canonicaliser hashes + issuer: { signature, cert_chain }, // signer #1 (genuine) + obligor: { signature, cert_chain }, // signer #2 (owed) — over canonical_id +} +``` + +Because every adapter emits this same shape, **Proof A and the engines never +change per vertical.** + +- **Native-rail adapter** (structured: FatturaPA / UBL / CII / EDI): parse the + e-invoice → canonical fields; ride the **clearance / AdES (XAdES/PAdES)** + signature as *issuer*; source the **obligor** confirmation from a buyer + acceptance / **approved-payables** confirmation. Lean on EN 16931 field + definitions + existing parsers (e.g. Mustangproject) — map a few syntaxes to + the canonical schema, not one per country. +- **C2PA adapter** (unstructured docs + media): read the signed **custom + assertion** carrying canonical fields + obligor signature; verify the COSE + manifest signature; bind via `c2pa.hash.data`. This is where C2PA is + load-bearing (no native rail to ride). + +**Do not pre-build adapters.** Build the one the first pilot's documents need. + +--- + +## 5. Canonicalisation — the make-or-break + +- A **deterministic spec**: exactly how each field normalises + (VAT, `amount → cents`, `date → YYYYMMDD`, currency, ...) → `canonical_id`. + Two parties must derive byte-identical ids from the same source. +- **Key move:** the **non-gameable signer emits *and* signs the canonical form** + (`canonical_id`). Then verifying parties don't re-canonicalise — they verify the + signature over the signed `canonical_id`. This solves determinism **and** + binding at once. (The run-through already does this: the obligor signs + `canonical_id`.) +- Where the signer only signs the native document, the adapter recomputes + `canonical_id` from it under the shared spec and binds by matching hashes. +- Per-vertical field set; ride EN 16931 fields where available. + +--- + +## 6. The signer — the partner-dependent crux (GTM, not crypto) + +- **issuer/clearance:** a tax platform (SdI), an e-invoice clearance, or a + verified platform. +- **obligor:** the customer who owes — sourced from a **buyer acceptance** or, + strongest and already in production, an **approved-payables / supply-chain- + finance confirmation**, where the anchor buyer already confirms invoices. +- **Today:** a self-made CA stand-in. A real non-gameable signer is the + **critical path** — and likely lives in **approved-payables finance**, where + *both* signers (cleared invoice + buyer confirmation) already exist. + +--- + +## 7. Deployment & trust model + +- coNoir runs on a **committee of independent, non-colluding operators** (REP3 / + proof delegation, e.g. TACEO:Proof). No single operator — and not Apertrue — + ever holds the union; each only sees a secret share. +- **Registry-of-roots:** each party publishes a committed root; the cross-party + check is **non-membership against published roots** (indexed Merkle tree). +- **Freshness:** two financings in the same window before roots refresh can both + read "not financed" — stated as an explicit **settlement-window** bound. +- **Liveness:** the always-on committee stands in for offline parties. + +--- + +## 8. Honest status + +| Component | State | +|---|---| +| Core engines (membership / aggregate / relational) | **built**, MPC-proven (3-party) | +| Canonicalisation (FatturaPA fields) | **real** for the demo | +| C2PA packaging (c2pie) | **real** (media-first tooling; PDF support nascent) | +| **Proof A — in-circuit signature binding** | **BUILT + e2e-proven** (`proof_a_receivable`; `run_proof_a.sh`) | +| **Obligor signature verified in-circuit** | **BUILT + e2e-proven** (ECDSA-P256 over `canonical_id`, role bound in trust-list leaf) | +| Adapter interface + FatturaPA reference adapter | **BUILT + e2e-proven** on the real invoice (`adapters/`); other verticals pilot-driven | +| Real non-gameable signer | **STAND-IN** (self-made CA; partner-dependent) | +| Committee / roots deployment | **DESIGN** (TACEO:Proof available) | + +--- + +## 9. Build sequence + +1. **In-circuit Proof A** — verify obligor + issuer signatures over + `canonical_id`, signer ∈ trust root, role bound in leaf, emit role-stamped + anchor. ✓ **DONE** — `proof_a_receivable` proves end-to-end, negative tests + pass (bad sig, unauthorised role), anchor hands off to `bound_receivables` + (`run_proof_a.sh`). Note: ECDSA needs low-s normalisation for Noir's verifier. +2. **Adapter interface** + the **first pilot's** adapter only (native-rail *or* + C2PA, per the pilot vertical). ✓ **DONE (reference)** — interface formalised + (`adapters/ADAPTER.md`) + FatturaPA adapter (`adapters/fattura_adapter.py`) + proves the full pipeline on the real invoice (`run_fattura_pipeline.sh`). + Other verticals' adapters remain pilot-driven. +3. **Canonicalisation spec** for that vertical, signer-emits-canonical-form. +4. Wire to the chosen **engine** + the **committee/roots** deployment for the pilot. +5. **Do not** pre-build other adapters/engines/verticals. + +> The engine and pattern are proven; the priority is the in-circuit signature +> binding, and the bottleneck remains a real partner + a real non-gameable signer. + +--- + +## 10. Open decisions (resolve before this is "production-correct") + +Load-bearing and deliberately unresolved; some are pilot/scale-dependent. + +1. **Proof A composition — local vs joint, and the recursion.** Signature + verification belongs in a cheap **per-party local Proof A** (single-prover), + recursively bound into the joint engine — **not** inside the joint MPC + (in-MPC P-256 is prohibitive). Decide the binding: recursive in-circuit + verification of each party's Proof A, vs. separately-checked proofs the + relying party also validates. Drives both soundness and cost. + **→ RESOLVED:** per-party *local* Proof A → emits the role-stamped `anchor` + as a public output → fed to `bound_receivables` as `candidate_anchor` (the + `binding_wrapper` "verify, then emit a public anchor" pattern). Signature + verification stays out of the MPC. Recursion-into-one-proof is an optional + later step. Built: `quorum/proof_a_receivable` (compiles, beta.20). +2. **Canonicalisation determinism across independently-received copies.** The + "signer signs `canonical_id`" move requires the *same* signed `canonical_id` + to reach every party who finances the invoice. Specify how it travels with + the document so two lenders derive byte-identical ids. +3. **Decentralised registry-of-roots is research-adjacent.** Private + non-membership against a published root *without the other party live* is + key-transparency / accumulator territory. Near-term = the **committee model**; + the roots model is a research track, not yet production. +4. **Freshness / simultaneity** is a bounded settlement-window limit, not solved. + Choose: near-real-time roots, a mandatory pre-advance live check, or an + openly-stated window. +5. **Toolchain reconciliation.** The coNoir spike uses nargo beta.20; apertrue's + `proof_a` is beta.18. Reconcile versions before reusing `proof_a` in the + Quorum flow. diff --git a/experiments/conoir-spike/quorum/README.md b/experiments/conoir-spike/quorum/README.md new file mode 100644 index 0000000..4b3c64c --- /dev/null +++ b/experiments/conoir-spike/quorum/README.md @@ -0,0 +1,73 @@ +# Apertrue Quorum — P0/P1: double-financing check, two signer roles, under MPC + +Runnable proof-of-life for the receivables product (`apertrue-quorum-plan.md`). Answers the +First Brands question — *has this exact receivable already been financed anywhere in the +network?* — under 3-party REP3 MPC, revealing **only one bit + which guarantee the anchor +carries**. Demonstrates the **two-signer thesis**: the signer is configurable per market, +and *which fraud you catch depends on who signs*. + +## The First Brands correction (why two signers, not one) +First Brands was **not only** double-pledging of real invoices — it was **also** fabricated +and inflated invoices (invented sales; amounts inflated up to ~10×). So: +- **Clearance (SdI) alone** would have caught the double-pledge slice and **missed the + fabrication slice** — a large part of the actual fraud. +- The **obligor (debtor)** signature catches fabrication/inflation, because the debtor will + not sign a lie. +- **Together** they are the honest answer to "what would have caught First Brands." + Clearance is *not* sufficient on its own; don't let any single-signer framing imply it is. + +## Two signer roles → two different guarantees +| Role | Signer | Guarantee the proof carries | Catches | +|---|---|---|---| +| 1 | SdI clearance (Agenzia delle Entrate) | "uniquely identified, cleared, **not** financed elsewhere" — **NOT** proof the debt is real | double-pledging | +| 2 | Obligor / debtor confirmation | "the debt is **genuinely owed**" (stronger) | double-pledging **and** fabrication/inflation | + +The role is **bound into the anchor** (`anchor = Poseidon2([canonical_id, salt, role])`), so +an SdI anchor and an obligor anchor for the same receivable are distinct and cannot be +swapped, and the role is never silently upgraded. + +## Layers +- `commit_receivable/` — Layer 1: `canonical_id = Poseidon2([uuid,debtor,amount,date])`, + role-bound `anchor`. +- `p1_provenance/admission/admission.sh` — Layer 2 **acceptable-anchor allow-list** + (off-MPC). Real openssl cert chains; admits a submission only if the inner signature + verifies **over the canonical_id** (so the signer vouches for exactly debtor∥amount∥id) + **and** the signer chains to a trusted root; the root fixes the role. `p1_provenance/` + also has a real C2PA-signed invoice PDF (via c2pie). +- `bound_receivables/` — Layer 3 (MPC): binds secret-shared fields to the role-bound anchor, + enforces the role is in the allow-list, scans the network's financed fingerprints, reveals + `(already_financed, role)`. + +## Admission decisions (Layer 2, real cert chains) +``` +obligor: true receivable -> ADMITTED role=2 the debt is GENUINELY OWED +SdI: true receivable -> ADMITTED role=1 cleared (NOT proof of debt) +SdI: INFLATED (seller cleared) -> ADMITTED role=1 cleared (NOT proof of debt) <- the gap +obligor: INFLATED (fabrication) -> REJECTED no obligor signature over the lie <- fabrication caught +seller self-signed -> REJECTED signer not in allow-list <- fraudster can't self-vouch +``` + +## MPC results (Layer 3, full 3-party REP3, proof verified) +| # | Scenario | Output | Verified | +|---|----------|--------|----------| +| 1 | obligor, true, not financed | `already_financed=0`, role 2 — **owed** | ✅ | +| 2 | obligor, true, double-financed | `already_financed=1`, role 2 — **owed** | ✅ | +| 3 | SdI, **inflated** (seller cleared), not financed | `already_financed=0`, role 1 — **cleared, NOT owed** (the gap) | ✅ | +| 4 | seller self-signed (role 3) | — | ❌ rejected by MPC allow-list (defense in depth) | + +Where each fraud is caught: **fabrication/inflation → at admission** (no obligor signature +over the lie). **Seller-as-signer → at admission *and* in-MPC allow-list** (defense in +depth). **Double-pledging → in-MPC membership** (bit = 1). The MPC binding alone proves +"these fields open this anchor"; it does **not** prove the signer was acceptable — that is +the admission/allow-list job, which is why both layers exist. + +## Reproduce +``` +cd p1_provenance/admission && ./admission.sh # Layer 2 allow-list decisions +cd ../.. && ./run_quorum_scenarios.sh # Layer 3 MPC, all four scenarios +``` + +## Still stand-in (needs a design partner / deeper C2PA build) +- Clearance + obligor certs are self-made CAs — real SdI / debtor certs need a partner. +- Asset is a PDF rendering (c2pie); raw-XML C2PA needs the data-hash sidecar API. +- Receivable fields carried as CreativeWork; custom assertion = c2pie programmatic API (P1.next). diff --git a/experiments/conoir-spike/quorum/_mkroot/Nargo.toml b/experiments/conoir-spike/quorum/_mkroot/Nargo.toml new file mode 100644 index 0000000..c0e2bac --- /dev/null +++ b/experiments/conoir-spike/quorum/_mkroot/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "_mkroot" +type = "bin" +authors = ["Apertrue"] +compiler_version = ">=1.0.0" + +[dependencies] +poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } diff --git a/experiments/conoir-spike/quorum/_mkroot/Prover.toml b/experiments/conoir-spike/quorum/_mkroot/Prover.toml new file mode 100644 index 0000000..d00039f --- /dev/null +++ b/experiments/conoir-spike/quorum/_mkroot/Prover.toml @@ -0,0 +1,5 @@ +signer_pubkey_x = [118, 30, 224, 102, 155, 108, 46, 96, 35, 198, 166, 178, 25, 180, 27, 216, 240, 64, 196, 153, 145, 140, 214, 216, 98, 231, 196, 128, 219, 233, 217, 102] +signer_pubkey_y = [90, 109, 25, 193, 124, 211, 44, 180, 7, 38, 154, 58, 185, 254, 127, 175, 20, 153, 246, 236, 171, 71, 33, 56, 255, 210, 243, 76, 125, 122, 173, 32] +signer_role = "2" +merkle_path = ["0","0","0","0","0","0","0","0"] +merkle_indices = ["0","0","0","0","0","0","0","0"] diff --git a/experiments/conoir-spike/quorum/_mkroot/src/main.nr b/experiments/conoir-spike/quorum/_mkroot/src/main.nr new file mode 100644 index 0000000..e290895 --- /dev/null +++ b/experiments/conoir-spike/quorum/_mkroot/src/main.nr @@ -0,0 +1,34 @@ +use poseidon::poseidon2::Poseidon2; +global MERKLE_DEPTH: u32 = 8; + +fn be_bytes_to_field(b: [u8; 32]) -> Field { + let mut acc: Field = 0; + for i in 0..32 { + acc = acc * 256 + b[i] as Field; + } + acc +} + +fn compute_merkle_root(leaf: Field, path: [Field; MERKLE_DEPTH], indices: [Field; MERKLE_DEPTH]) -> Field { + let mut current = leaf; + for i in 0..MERKLE_DEPTH { + let is_left = indices[i] == 0; + let left = if is_left { current } else { path[i] }; + let right = if is_left { path[i] } else { current }; + current = Poseidon2::hash([left, right], 2); + } + current +} + +fn main( + signer_pubkey_x: [u8; 32], + signer_pubkey_y: [u8; 32], + signer_role: Field, + merkle_path: [Field; MERKLE_DEPTH], + merkle_indices: [Field; MERKLE_DEPTH], +) -> pub Field { + let x_f = be_bytes_to_field(signer_pubkey_x); + let y_f = be_bytes_to_field(signer_pubkey_y); + let leaf = Poseidon2::hash([x_f, y_f, signer_role], 3); + compute_merkle_root(leaf, merkle_path, merkle_indices) +} diff --git a/experiments/conoir-spike/quorum/adapters/ADAPTER.md b/experiments/conoir-spike/quorum/adapters/ADAPTER.md new file mode 100644 index 0000000..d293b10 --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/ADAPTER.md @@ -0,0 +1,168 @@ +# Quorum Adapter Interface + +A Quorum **adapter** turns a real-world receivable document (an Italian FatturaPA +e-invoice, an Indian IRP invoice, a UBL/PEPPOL bill, …) into one canonical object that +the in-circuit **Proof A** (`proof_a_receivable`) consumes. Proof A then proves, in +zero-knowledge, that an *authorised, role-bound* signer put an ECDSA P-256 signature on +this receivable's `canonical_id`, and emits the role-stamped authenticity **anchor** that +the joint `bound_receivables` MPC circuit binds to. + +The adapter is the only document-format-specific code in the pipeline. Everything +downstream (`proof_a_receivable`, `commit_receivable`, `bound_receivables`) speaks only the +canonical object below, so a new document type = a new adapter, nothing else. + +## 1. The interface contract (the object every adapter MUST emit) + +```jsonc +{ + "canonical_fields": { + "invoice_uuid": , // globally-unique receivable id (issuer-scoped) + "debtor_id": , // who owes the money + "amount": , // minor units (cents) + "issue_date": // YYYYMMDD + }, + "canonical_id": "0x..", // Poseidon2([uuid, debtor, amount, date], 4) + "canonical_id_bytes": [; 32], // big-endian encoding of canonical_id (the SIGNED message) + "obligor": { + "pubkey_x": [; 32], // signer P-256 public key X (big-endian) + "pubkey_y": [; 32], // signer P-256 public key Y (big-endian) + "signature": [; 64], // r||s, LOW-S normalised + "signer_role": 2 // 1 = SdI/clearance, 2 = obligor/debtor confirmation + } +} +``` + +These map 1:1 onto Proof A's witness (`proof_a_receivable/src/main.nr`): +`signer_pubkey_x/y`, `signature`, `canonical_id_bytes`, the four canonical fields, plus +`signer_role` (public). The only inputs the adapter does **not** supply are the +trust-list witness (`merkle_path`, `merkle_indices`, `trust_list_root`) and the privacy +`salt` — those belong to the trust-list operator and the prover, not the document. + +### Field semantics +- `canonical_id = Poseidon2([invoice_uuid, debtor_id, amount, issue_date], 4)` — the value + the obligor vouches for and the value the network's double-financing check keys on. +- `anchor = Poseidon2([canonical_id, salt, signer_role], 3)` — Proof A's **public output**. + Role-bound, so a clearance anchor and an obligor anchor for the same receivable are + distinct values and cannot be swapped. + +## 2. Signing spec (the Quorum standard) + +**Raw ECDSA P-256 over the 32-byte big-endian `canonical_id`, used directly as the +digest. No SHA-256 wrapper.** `canonical_id` is already a Poseidon2 commitment, so a second +hash adds nothing; Proof A calls `std::ecdsa_secp256r1::verify_signature(x, y, sig, +canonical_id_bytes)` with the 32-byte message as the digest. Signatures MUST be +**low-S normalised** (`s = min(s, n-s)`) — Noir's verifier rejects high-S. The 64-byte +`signature` is `r||s`, each zero-left-padded to 32 bytes. + +Practical note: `openssl pkeyutl -sign` (not `openssl dgst -sha256 -sign`) treats its input +bytes as the digest, which is exactly the raw-ECDSA form Proof A expects. The legacy +`admission.sh` produced its `*.sig` with `openssl dgst -sha256`, so those signatures do +**not** verify under this spec — the adapter (re-)signs `canonical_id` raw. + +## 3. How the FatturaPA adapter satisfies the contract + +`fattura_adapter.py ` (driven end-to-end by `run_fattura_pipeline.sh`): + +1. **Canonicalisation** (namespace-agnostic XML walk, identical to `p1_provenance`): + - `invoice_uuid = CedentePrestatore/…/IdFiscaleIVA/IdCodice (01234567890) * 1e6 + DatiGeneraliDocumento/Numero (123)` → `1234567890000123` + - `debtor_id = CessionarioCommittente/…/CodiceFiscale (09876543210)` with leading zeros dropped → `9876543210` + - `amount = DatiRiepilogo/ImponibileImporto (5.00 EUR) * 100` → `500` + - `issue_date = DatiGeneraliDocumento/Data (2014-12-18)` → `20141218` +2. **canonical_id** — computed by running the `commit_receivable` circuit (proof_a's + Poseidon2 scheme) so the adapter and the circuits cannot drift. For this invoice: + `0x06cc60c66ce6389ea53783b55dc5ff1b480e9a43ecf4c942262f5d73d7a87280`, which equals the + committed `admission/certs/obligor_true.cid` — confirming the real document reproduces + the expected fingerprint. +3. **obligor signature** — raw-ECDSA-signs `canonical_id` (see §2), extracts `x`/`y` from + the uncompressed public-key point, normalises the DER signature to low-S `r||s`. +4. Emits the JSON object above (`fattura_adapter_object.json`). + +`run_fattura_pipeline.sh` then builds the depth-8 trust root from the obligor `(key, role)` +leaf via `_mkroot`, runs **Proof A** (in-circuit ECDSA verify + trust-list membership → +anchor), checks the proven anchor equals the `commit_receivable` anchor for the same +`(salt=42, role=2)`, and hands the anchor to `bound_receivables` (clean → `false`, +double-financed → `true`). + +## 3b. C2PA-document adapter (warehouse receipt) — the *carrier* path + +The FatturaPA adapter reads canonical fields from a **native structured rail** (the +e-invoice XML). Many collateral documents have **no such rail** — a warehouse receipt, +a bill of lading, a deposit slip. For these, **C2PA is the load-bearing CARRIER**: the +signed canonical fields, the signer's public key, and the signer's raw-ECDSA signature +over `canonical_id` are embedded in a C2PA **custom assertion** and **hash-bound** to the +document, then read back out of the manifest. C2PA transports + binds; the *trust* is the +**signature**, verified in-circuit by Proof A — exactly as in the native-rail path. + +`warehouse_adapter.py` (driven end-to-end by `run_warehouse_pipeline.sh`), modes `wrap` / +`read` / `both`: + +1. **Canonical fields** (warehouse receipt, four fields, same structure as receivables; + names are adapter-local, mapped onto the generic 4-field circuit slots): + - `receipt_uuid = 7001234000042` (operator licence 7001234 · 1e6 + receipt no 42) → slot 1 + - `commodity_id = 74031100` (HS code, copper cathode grade A) → slot 2 + - `quantity = 25000` (250.00 metric tonnes, centi-tonnes) → slot 3 + - `deposit_date = 20240315` (2024-03-15) → slot 4 + - `canonical_id = 0x20adcccdd6e2e94b4a78a2cba482c287b37c3c840204f9412d5baa1cdb11a423` + (computed by `commit_receivable`, salt=42, signer_role=2). +2. **WRAP (C2PA is genuinely created).** Renders a warehouse-receipt PDF (`cupsfilter`), + then uses **c2pie** (the same tool as `p1_provenance/p1_next.py`) to embed a custom + assertion **`org.apertrue.quorum.collateral`** carrying the canonical fields, the + operator's `pubkey_x/y` (hex), and the **raw ECDSA P-256 signature over `canonical_id`** + (low-s, `r‖s` hex; §2 signing spec), plus a `c2pa.hash.data` hard binding over the whole + PDF. The manifest is signed with a stand-in RSA leaf→CA chain (PS256). Output: + `warehouse_receipt_c2pa.pdf`. +3. **READ (C2PA is genuinely parsed).** Parses the manifest back with **c2patool**, recovers + `{canonical_fields, pubkey_x, pubkey_y, signature, signer_role}`, **re-derives** + `canonical_id` from the *recovered* fields and asserts it equals the embedded value, and + emits `warehouse_adapter_object.json` (the **same interface shape** as fattura). +4. **Trust root + Proof A.** `_mkroot` builds the depth-8 Poseidon2 root from the operator + `(x, y, role=2)` leaf; `proof_a_receivable` does the **in-circuit ECDSA verify + trust-list + membership** over the C2PA-carried fields and emits the anchor; the proven anchor equals the + `commit_receivable` anchor `0x2f458e5a…7744`. +5. **Handoff.** The anchor feeds `bound_receivables` (accepted_roles `[1,2]`): `financed` + without the cid → `(false, 0x02)`, then with the cid → `(true, 0x02)` — anchor opens cleanly. + +The exact carrier commands (proving C2PA is exercised, not faked): +- **wrap**: `c2pie_GenerateManifest([CustomJsonAssertion("org.apertrue.quorum.collateral", …), + c2pie_GenerateHashDataAssertion(…)], rsa_leaf.key, rsa_chain.pem)` → + `c2pie_EmplaceManifest(C2PA_ContentTypes.pdf, …)` (in `warehouse_adapter.py wrap`). +- **read**: `c2patool warehouse_receipt_c2pa.pdf` → JSON → pull the + `org.apertrue.quorum.collateral` assertion (in `warehouse_adapter.py read`). + +### Caveats specific to this adapter +- The **warehouse-operator P-256 key is a STAND-IN** (`adapters/keys/operator_standin.key`); + the real operator/custodian key is the partner crux (same security note as §4.1). +- The **C2PA manifest signer is a stand-in RSA leaf→CA chain** (`adapters/keys/c2pa_leaf.*`, + PS256); c2pie is the carrier tool. A production manifest would chain to a real C2PA CA. +- Proof A trusts the operator **leaf key directly** via the trust list (Poseidon2 `(x,y,role)` + leaf → root), not an in-circuit X.509 chain — the same §4.2 follow-up applies. + +## 4. Honest caveats (follow-ups for a design partner) + +1. **The obligor key is a STAND-IN.** The real obligor private key (an Agenzia delle + Entrate / SdI *ricevuta*, or an Indian IRP IRN authority) is not in the repo — only + `admission/certs/obligor_leaf.pem/.pub` exist, without the private key. The adapter + therefore mints a stand-in P-256 key+cert (`adapters/keys/obligor_standin.key`) and + signs with it. This proves the *mechanism* end-to-end; the non-gameable signer is the + partner crux. The security claim ("the debtor will not sign a lie, so fabrication/ + inflation cannot be admitted as role 2") only holds once the signer is a genuine + authority. +2. **Proof A trusts the signer's LEAF key directly** via the trust list (a Poseidon2 + `(x, y, role)` leaf → root membership). It does **not** verify an X.509 certificate + chain to a CA root in-circuit. Full production parity (cf. apertrue's `proof_a`, which + performs in-circuit chain verification) would verify the obligor leaf cert chains to a + trusted Quorum root inside the circuit, rather than trusting an enrolled leaf key. Noted + follow-up. + +## 5. Files + +- `adapters/fattura_adapter.py` — the FatturaPA adapter (native XML → interface object). +- `adapters/run_fattura_pipeline.sh` — full end-to-end runner (idempotent). +- `adapters/fattura_adapter_object.json` — the emitted interface object for this invoice. +- `adapters/keys/obligor_standin.*` — minted stand-in obligor key+cert (gitignored). +- `adapters/warehouse_adapter.py` — the C2PA warehouse-receipt adapter (C2PA assertion → interface object); modes wrap/read/both. Needs a python with `c2pie`+`pypdf`. +- `adapters/run_warehouse_pipeline.sh` — full end-to-end runner (idempotent); auto-detects a c2pie python (or set `C2PIE_PY`). +- `adapters/warehouse_adapter_object.json` — the emitted interface object (recovered from the C2PA manifest). +- `adapters/warehouse_receipt.pdf` / `warehouse_receipt_c2pa.pdf` — the rendered receipt and its C2PA-signed form. +- `adapters/keys/operator_standin.*` — minted stand-in warehouse-operator P-256 key+cert (gitignored). +- `adapters/keys/c2pa_leaf.* / c2pa_ca.*` — minted stand-in RSA chain that signs the C2PA manifest (gitignored). diff --git a/experiments/conoir-spike/quorum/adapters/fattura_adapter.py b/experiments/conoir-spike/quorum/adapters/fattura_adapter.py new file mode 100755 index 0000000..5404422 --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/fattura_adapter.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +fattura_adapter.py -- Apertrue Quorum FatturaPA adapter. + +Turns a REAL Italian e-invoice (FatturaPA XML) into the Quorum +ADAPTER-INTERFACE object that `proof_a_receivable` consumes: + + { canonical_fields: {invoice_uuid, debtor_id, amount, issue_date}, + canonical_id, canonical_id_bytes, + obligor: { pubkey_x:[32], pubkey_y:[32], signature:[64] r||s low-s, signer_role:2 } } + +Canonicalisation (identical to p1_provenance, see README/admission.sh): + invoice_uuid = supplierVAT(CedentePrestatore IdCodice) * 1e6 + Numero + debtor_id = buyer CodiceFiscale (CessionarioCommittente), leading zeros dropped + amount = ImponibileImporto in cents (5.00 EUR -> 500) + issue_date = DatiGeneraliDocumento/Data -> YYYYMMDD integer + +canonical_id is the Poseidon2 commitment of those four fields, computed by the +`commit_receivable` Noir circuit (proof_a's scheme). + +Signing spec (Quorum standard): RAW ECDSA P-256 over the 32-byte big-endian +canonical_id used DIRECTLY as the digest -- NO sha256 wrapper. This matches +`proof_a_receivable`'s in-circuit `verify_signature(...)`. + +Obligor key: the real obligor PRIVATE key is not present (only obligor_leaf.pem/.pub +exist in admission/certs). So this adapter MINTS a stand-in obligor P-256 key+cert +(reused idempotently) and raw-signs canonical_id with it. The output flags this. +The real, non-gameable obligor signer is the partner crux (SdI ricevuta / IRP IRN). +""" +import argparse, binascii, json, os, subprocess, sys, xml.etree.ElementTree as ET + +HERE = os.path.dirname(os.path.abspath(__file__)) +QUORUM = os.path.dirname(HERE) +NARGO = os.environ.get("NARGO", os.path.expanduser("~/.nargo/bin/nargo")) +KEYDIR = os.path.join(HERE, "keys") +N_P256 = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 + +def _local(tag): + return tag.split('}', 1)[-1] # strip XML namespace + +def _find(root, *path): + """Walk by LOCAL element names (namespace-agnostic), return first text match.""" + nodes = [root] + for name in path: + nxt = [] + for n in nodes: + for c in n: + if _local(c.tag) == name: + nxt.append(c) + nodes = nxt + if not nodes: + return None + return nodes[0].text.strip() if nodes[0].text else None + +def parse_canonical_fields(xml_path): + root = ET.parse(xml_path).getroot() + hdr = next(c for c in root if _local(c.tag) == "FatturaElettronicaHeader") + body = next(c for c in root if _local(c.tag) == "FatturaElettronicaBody") + + supplier_vat = _find(hdr, "CedentePrestatore", "DatiAnagrafici", "IdFiscaleIVA", "IdCodice") + debtor_cf = _find(hdr, "CessionarioCommittente", "DatiAnagrafici", "CodiceFiscale") + numero = _find(body, "DatiGenerali", "DatiGeneraliDocumento", "Numero") + data = _find(body, "DatiGenerali", "DatiGeneraliDocumento", "Data") + imponibile = _find(body, "DatiBeniServizi", "DatiRiepilogo", "ImponibileImporto") + divisa = _find(body, "DatiGenerali", "DatiGeneraliDocumento", "Divisa") + + invoice_uuid = int(supplier_vat) * 1_000_000 + int(numero) + debtor_id = int(debtor_cf) # int() drops leading zeros + amount = round(float(imponibile) * 100) # EUR -> cents + issue_date = int(data.replace("-", "")) # 2014-12-18 -> 20141218 + + return { + "invoice_uuid": invoice_uuid, + "debtor_id": debtor_id, + "amount": amount, + "issue_date": issue_date, + "_meta": {"supplier_vat": supplier_vat, "debtor_cf": debtor_cf, + "numero": numero, "issue_date_raw": data, "currency": divisa, + "imponibile_eur": imponibile}, + } + +def compute_canonical_id(fields, salt, signer_role): + """Run commit_receivable to get [canonical_id, anchor]. canonical_id is field 0.""" + toml = (f'invoice_uuid = "{fields["invoice_uuid"]}"\n' + f'debtor_id = "{fields["debtor_id"]}"\n' + f'amount = "{fields["amount"]}"\n' + f'issue_date = "{fields["issue_date"]}"\n' + f'salt = "{salt}"\n' + f'signer_role = "{signer_role}"\n') + cr = os.path.join(QUORUM, "commit_receivable") + open(os.path.join(cr, "Prover.toml"), "w").write(toml) + out = subprocess.run([NARGO, "execute", "--program-dir", cr], + capture_output=True, text=True).stdout + line = next(l for l in out.splitlines() if "Circuit output" in l) + inner = line.split("Circuit output:", 1)[1].split("[", 1)[1].rsplit("]", 1)[0] + cid, anchor = [t.strip() for t in inner.split(",")] + return cid, anchor + +def mint_standin_obligor(): + """Idempotently mint a stand-in obligor P-256 key + self-signed cert.""" + key = os.path.join(KEYDIR, "obligor_standin.key") + pem = os.path.join(KEYDIR, "obligor_standin.pem") + os.makedirs(KEYDIR, exist_ok=True) + if not os.path.exists(key): + subprocess.run(["openssl", "ecparam", "-name", "prime256v1", "-genkey", + "-noout", "-out", key], check=True, capture_output=True) + subprocess.run(["openssl", "req", "-new", "-x509", "-key", key, "-out", pem, + "-days", "3650", + "-subj", "/CN=obligor_standin/O=Apertrue Quorum STANDIN"], + check=True, capture_output=True) + return key + +def raw_sign_and_extract(key_path, cid_hex, workdir): + """Raw-ECDSA sign the 32-byte canonical_id; return (x[32], y[32], sig r||s low-s [64]).""" + cid_bin = os.path.join(workdir, "cid.bin") + sig_der = os.path.join(workdir, "sig.der") + pub_der = os.path.join(workdir, "pub.der") + open(cid_bin, "wb").write(binascii.unhexlify(cid_hex)) + # pkeyutl -sign treats the EC input bytes AS the digest -> raw ECDSA, no sha256 wrapper + subprocess.run(["openssl", "pkeyutl", "-sign", "-inkey", key_path, + "-in", cid_bin, "-out", sig_der], check=True, capture_output=True) + subprocess.run(["openssl", "ec", "-in", key_path, "-pubout", + "-conv_form", "uncompressed", "-outform", "DER", "-out", pub_der], + check=True, capture_output=True) + + pub = open(pub_der, "rb").read() + point = pub[-65:] + assert point[0] == 4, "public key is not an uncompressed point" + x = list(point[1:33]); y = list(point[33:65]) + + der = open(sig_der, "rb").read() + assert der[0] == 0x30 + def read_int(b, i): + assert b[i] == 0x02 + ln = b[i + 1] + return b[i + 2:i + 2 + ln], i + 2 + ln + r, i = read_int(der, 2) + s, _ = read_int(der, i) + sv = int.from_bytes(s, "big") + if sv > N_P256 // 2: # Noir verify_signature requires LOW-S (canonical) + sv = N_P256 - sv + s = sv.to_bytes(32, "big") + r = r.lstrip(b"\x00").rjust(32, b"\x00") + sig = list(r) + list(s) + return x, y, sig + +def main(): + ap = argparse.ArgumentParser(description="FatturaPA -> Quorum adapter-interface object") + ap.add_argument("xml", nargs="?", + default=os.path.join(QUORUM, "p1_provenance", "IT01234567890_FPR01.xml")) + ap.add_argument("--salt", default="42") + ap.add_argument("--signer-role", default="2") + ap.add_argument("--out", default=os.path.join(HERE, "fattura_adapter_object.json")) + args = ap.parse_args() + + fields = parse_canonical_fields(args.xml) + canonical_id, anchor = compute_canonical_id(fields, args.salt, args.signer_role) + cid_hex = canonical_id[2:].rjust(64, "0") + cid_bytes = list(binascii.unhexlify(cid_hex)) + + key_path = mint_standin_obligor() + workdir = os.path.join(HERE, "keys") + x, y, sig = raw_sign_and_extract(key_path, cid_hex, workdir) + + obj = { + "scheme": "FatturaPA", + "source_document": os.path.basename(args.xml), + "canonical_fields": { + "invoice_uuid": fields["invoice_uuid"], + "debtor_id": fields["debtor_id"], + "amount": fields["amount"], + "issue_date": fields["issue_date"], + }, + "canonical_id": canonical_id, + "canonical_id_bytes": cid_bytes, + "obligor": { + "pubkey_x": x, + "pubkey_y": y, + "signature": sig, # 64 bytes r||s, low-s normalised + "signer_role": int(args.signer_role), + }, + "_role_bound_anchor_at_salt": {"salt": int(args.salt), "anchor": anchor}, + "_notes": { + "signing_spec": "raw ECDSA P-256 over be32(canonical_id) used directly as digest (no sha256)", + "obligor_key": "STAND-IN: minted P-256 key (adapters/keys/obligor_standin.key); " + "real obligor key is the partner crux (SdI ricevuta / IRP IRN), not present in repo", + "field_derivation": fields["_meta"], + }, + } + open(args.out, "w").write(json.dumps(obj, indent=2)) + print(json.dumps(obj, indent=2)) + print(f"\n# wrote {args.out}", file=sys.stderr) + +if __name__ == "__main__": + main() diff --git a/experiments/conoir-spike/quorum/adapters/fattura_adapter_object.json b/experiments/conoir-spike/quorum/adapters/fattura_adapter_object.json new file mode 100644 index 0000000..f639364 --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/fattura_adapter_object.json @@ -0,0 +1,198 @@ +{ + "scheme": "FatturaPA", + "source_document": "IT01234567890_FPR01.xml", + "canonical_fields": { + "invoice_uuid": 1234567890000123, + "debtor_id": 9876543210, + "amount": 500, + "issue_date": 20141218 + }, + "canonical_id": "0x06cc60c66ce6389ea53783b55dc5ff1b480e9a43ecf4c942262f5d73d7a87280", + "canonical_id_bytes": [ + 6, + 204, + 96, + 198, + 108, + 230, + 56, + 158, + 165, + 55, + 131, + 181, + 93, + 197, + 255, + 27, + 72, + 14, + 154, + 67, + 236, + 244, + 201, + 66, + 38, + 47, + 93, + 115, + 215, + 168, + 114, + 128 + ], + "obligor": { + "pubkey_x": [ + 184, + 196, + 60, + 29, + 156, + 89, + 169, + 92, + 112, + 56, + 23, + 82, + 52, + 132, + 157, + 216, + 26, + 175, + 28, + 175, + 154, + 107, + 100, + 91, + 224, + 231, + 205, + 20, + 85, + 95, + 222, + 100 + ], + "pubkey_y": [ + 252, + 213, + 234, + 45, + 227, + 108, + 134, + 93, + 214, + 236, + 225, + 34, + 82, + 254, + 169, + 210, + 219, + 200, + 134, + 59, + 103, + 33, + 15, + 47, + 118, + 153, + 65, + 29, + 251, + 84, + 126, + 248 + ], + "signature": [ + 7, + 37, + 93, + 31, + 67, + 230, + 53, + 149, + 219, + 170, + 249, + 240, + 195, + 233, + 219, + 71, + 90, + 67, + 107, + 87, + 62, + 241, + 161, + 56, + 110, + 203, + 223, + 223, + 220, + 59, + 5, + 231, + 22, + 206, + 41, + 160, + 113, + 75, + 198, + 70, + 214, + 51, + 9, + 208, + 151, + 254, + 19, + 12, + 109, + 140, + 73, + 102, + 198, + 240, + 222, + 13, + 199, + 200, + 40, + 223, + 82, + 158, + 214, + 251 + ], + "signer_role": 2 + }, + "_role_bound_anchor_at_salt": { + "salt": 42, + "anchor": "0x108137c71e47833c5655288aea2160955230af60e82c0d0fd4f9d8297c912d0b" + }, + "_notes": { + "signing_spec": "raw ECDSA P-256 over be32(canonical_id) used directly as digest (no sha256)", + "obligor_key": "STAND-IN: minted P-256 key (adapters/keys/obligor_standin.key); real obligor key is the partner crux (SdI ricevuta / IRP IRN), not present in repo", + "field_derivation": { + "supplier_vat": "01234567890", + "debtor_cf": "09876543210", + "numero": "123", + "issue_date_raw": "2014-12-18", + "currency": "EUR", + "imponibile_eur": "5.00" + } + } +} \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/adapters/run_fattura_pipeline.sh b/experiments/conoir-spike/quorum/adapters/run_fattura_pipeline.sh new file mode 100755 index 0000000..d9c7c04 --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/run_fattura_pipeline.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# run_fattura_pipeline.sh -- FULL Quorum pipeline on the REAL FatturaPA invoice. +# +# real FatturaPA XML +# -> fattura_adapter.py (adapter-interface object: fields, canonical_id, obligor sig) +# -> _mkroot (Poseidon2 depth-8 trust-list root for the obligor (key,role)) +# -> proof_a_receivable (in-circuit: ECDSA verify + trust-list membership -> anchor) +# -> commit_receivable (expected anchor for the same fields/salt/role) == proven anchor? +# -> bound_receivables (double-financing one-bit answer: clean=false, double=true) +# +# Idempotent: re-running reuses the stand-in obligor key and recomputes everything. +# Requires: nargo (1.0.0-beta.20) at ~/.nargo/bin/nargo, openssl, python3. +set -euo pipefail + +NARGO="${NARGO:-$HOME/.nargo/bin/nargo}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .../quorum/adapters +QUORUM="$(cd "$HERE/.." && pwd)" +XML="${1:-$QUORUM/p1_provenance/IT01234567890_FPR01.xml}" +SALT="${SALT:-42}" +SIGNER_ROLE="${SIGNER_ROLE:-2}" +OBJ="$HERE/fattura_adapter_object.json" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +hr() { printf '\n==== %s ====\n' "$*"; } + +# --------------------------------------------------------------------------- +hr "STEP 1: adapter -- FatturaPA XML -> adapter-interface object" +python3 "$HERE/fattura_adapter.py" "$XML" --salt "$SALT" --signer-role "$SIGNER_ROLE" --out "$OBJ" >/dev/null +# pull values out of the JSON into shell +read -r INVOICE_UUID DEBTOR_ID AMOUNT ISSUE_DATE CANONICAL_ID < <(python3 -c " +import json;o=json.load(open('$OBJ'));f=o['canonical_fields'] +print(f['invoice_uuid'],f['debtor_id'],f['amount'],f['issue_date'],o['canonical_id'])") +X=$(python3 -c "import json;print(json.load(open('$OBJ'))['obligor']['pubkey_x'])") +Y=$(python3 -c "import json;print(json.load(open('$OBJ'))['obligor']['pubkey_y'])") +SIG=$(python3 -c "import json;print(json.load(open('$OBJ'))['obligor']['signature'])") +CIDB=$(python3 -c "import json;print(json.load(open('$OBJ'))['canonical_id_bytes'])") +echo "invoice_uuid = $INVOICE_UUID" +echo "debtor_id = $DEBTOR_ID" +echo "amount = $AMOUNT" +echo "issue_date = $ISSUE_DATE" +echo "canonical_id = $CANONICAL_ID" + +# confirm canonical_id matches the committed obligor_true.cid +EXPECT_CID="0x$(xxd -p "$QUORUM/p1_provenance/admission/certs/obligor_true.cid" | tr -d '\n')" +if [ "$CANONICAL_ID" = "$EXPECT_CID" ]; then + green "PASS: canonical_id == obligor_true.cid ($EXPECT_CID)" +else + red "FAIL: canonical_id $CANONICAL_ID != obligor_true.cid $EXPECT_CID"; exit 1 +fi + +# --------------------------------------------------------------------------- +hr "STEP 2: commit_receivable -> expected role-bound anchor (same salt, role)" +cat > "$QUORUM/commit_receivable/Prover.toml" <&1 | grep "Circuit output") +EXPECTED_ANCHOR=$(echo "$CR_OUT" | sed -E 's/.*\[(0x[0-9a-f]+), (0x[0-9a-f]+)\].*/\2/') +echo "expected anchor = $EXPECTED_ANCHOR" + +# --------------------------------------------------------------------------- +hr "STEP 3: _mkroot -> trust_list_root for obligor (key, role) leaf" +cat > "$QUORUM/_mkroot/Prover.toml" <&1 | grep "Circuit output" | sed -E 's/.*(0x[0-9a-f]+).*/\1/') +echo "trust_list_root = $ROOT" + +# --------------------------------------------------------------------------- +hr "STEP 4: proof_a_receivable -- in-circuit ECDSA verify + membership -> anchor" +cat > "$QUORUM/proof_a_receivable/Prover.toml" <&1 | grep "Circuit output" | sed -E 's/.*(0x[0-9a-f]+).*/\1/') +echo "proven anchor = $PROVEN_ANCHOR" +if [ "$PROVEN_ANCHOR" = "$EXPECTED_ANCHOR" ]; then + green "PASS: proof_a anchor == commit_receivable anchor" +else + red "FAIL: anchor mismatch (proven $PROVEN_ANCHOR vs expected $EXPECTED_ANCHOR)"; exit 1 +fi + +# --------------------------------------------------------------------------- +hr "STEP 5: bound_receivables -- double-financing one-bit answer" +br_run () { # $1 = financed array contents, $2 = label, $3 = expected bool + cat > "$QUORUM/bound_receivables/Prover.toml" <&1 | grep "Circuit output") + echo " $2 -> $OUT" + echo "$OUT" | grep -q "($3," && green " PASS: already_financed=$3 (anchor opened cleanly)" \ + || { red " FAIL: expected already_financed=$3"; exit 1; } +} +br_run '"0x01","0x02","0x03","0x04","0x05","0x06"' "clean (cid NOT in financed book)" "false" +br_run "\"0x01\",\"0x02\",\"$CANONICAL_ID\",\"0x04\",\"0x05\",\"0x06\"" "double (cid IS in financed book)" "true" + +hr "ALL CHECKS PASSED -- real FatturaPA invoice proven end-to-end" diff --git a/experiments/conoir-spike/quorum/adapters/run_warehouse_pipeline.sh b/experiments/conoir-spike/quorum/adapters/run_warehouse_pipeline.sh new file mode 100755 index 0000000..7d71794 --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/run_warehouse_pipeline.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# run_warehouse_pipeline.sh -- FULL Quorum pipeline on a C2PA WAREHOUSE RECEIPT. +# +# Parallel to run_fattura_pipeline.sh, but the canonical fields are NOT read from a +# native structured rail -- they are CARRIED BY C2PA. The adapter creates a warehouse +# receipt PDF, embeds the signed canonical fields + operator key + raw-ECDSA signature +# in a C2PA custom assertion (org.apertrue.quorum.collateral) hash-bound to the file, +# then READS them back out of the manifest. C2PA is the load-bearing carrier; the +# trust is the operator's signature, verified IN-CIRCUIT by Proof A. +# +# warehouse receipt PDF + C2PA manifest (c2pie wrap) +# -> warehouse_adapter.py read (parse C2PA -> adapter-interface object) +# -> commit_receivable (expected role-bound anchor for the same fields/salt/role) +# -> _mkroot (Poseidon2 depth-8 trust-list root for operator (key,role)) +# -> proof_a_receivable (in-circuit: ECDSA verify + trust-list membership -> anchor) +# -> commit anchor == proven anchor? +# -> bound_receivables (double-financing one-bit answer: clean=false, double=true) +# +# Idempotent: reuses the stand-in operator P-256 key + C2PA RSA chain, recomputes the rest. +# Requires: nargo (1.0.0-beta.20) at ~/.nargo/bin/nargo, openssl, c2patool, cupsfilter, +# and a python with `c2pie` + `pypdf` importable (set C2PIE_PY, else auto-detected). +set -euo pipefail + +NARGO="${NARGO:-$HOME/.nargo/bin/nargo}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .../quorum/adapters +QUORUM="$(cd "$HERE/.." && pwd)" +SALT="${SALT:-42}" +SIGNER_ROLE="${SIGNER_ROLE:-2}" +OBJ="$HERE/warehouse_adapter_object.json" +C2PA_PDF="$HERE/warehouse_receipt_c2pa.pdf" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +hr() { printf '\n==== %s ====\n' "$*"; } + +# ---- locate a python that can import c2pie (the C2PA carrier tool) ---- +find_c2pie_py() { + local cand + for cand in "${C2PIE_PY:-}" \ + "/private/tmp/claude-501/-Users-jamienewton/1c6e4848-1e0b-4678-af70-eb7bad88f949/scratchpad/c2pie_venv/bin/python3" \ + "$HERE/.c2pie_venv/bin/python3" \ + "$QUORUM/p1_provenance/venv/bin/python3" \ + "python3"; do + [ -z "$cand" ] && continue + if command -v "$cand" >/dev/null 2>&1 || [ -x "$cand" ]; then + if "$cand" -c "import c2pie, pypdf" >/dev/null 2>&1; then echo "$cand"; return 0; fi + fi + done + # last resort: build a local venv (needs network) + local v="$HERE/.c2pie_venv" + python3 -m venv "$v" >/dev/null 2>&1 || true + "$v/bin/pip" install -q c2pie pypdf >/dev/null 2>&1 || true + if "$v/bin/python3" -c "import c2pie, pypdf" >/dev/null 2>&1; then echo "$v/bin/python3"; return 0; fi + return 1 +} + +C2PIE_PY="$(find_c2pie_py)" || { red "FATAL: no python with c2pie+pypdf. Set C2PIE_PY=/path/to/venv/python3"; exit 1; } +echo "c2pie python: $C2PIE_PY" + +# --------------------------------------------------------------------------- +hr "STEP 1: WRAP -- build warehouse receipt PDF + embed signed fields in C2PA" +"$C2PIE_PY" "$HERE/warehouse_adapter.py" --mode wrap --salt "$SALT" --signer-role "$SIGNER_ROLE" +green "wrote C2PA-signed PDF: $C2PA_PDF" + +# prove C2PA is genuinely exercised: show the embedded assertion labels via c2patool +echo "-- c2patool assertion labels (read from the signed PDF) --" +c2patool "$C2PA_PDF" 2>&1 | "$C2PIE_PY" -c \ + "import sys,json;d=json.load(sys.stdin);m=d['manifests'][d['active_manifest']];print(' ',[a['label'] for a in m['assertions']])" + +# --------------------------------------------------------------------------- +hr "STEP 2: READ -- parse C2PA manifest back -> adapter-interface object" +"$C2PIE_PY" "$HERE/warehouse_adapter.py" --mode read --salt "$SALT" --signer-role "$SIGNER_ROLE" \ + --pdf "$C2PA_PDF" --out "$OBJ" >/dev/null +read -r RECEIPT_UUID COMMODITY_ID QUANTITY DEPOSIT_DATE CANONICAL_ID < <(python3 -c " +import json;o=json.load(open('$OBJ'));f=o['canonical_fields'] +print(f['receipt_uuid'],f['commodity_id'],f['quantity'],f['deposit_date'],o['canonical_id'])") +X=$(python3 -c "import json;print(json.load(open('$OBJ'))['obligor']['pubkey_x'])") +Y=$(python3 -c "import json;print(json.load(open('$OBJ'))['obligor']['pubkey_y'])") +SIG=$(python3 -c "import json;print(json.load(open('$OBJ'))['obligor']['signature'])") +CIDB=$(python3 -c "import json;print(json.load(open('$OBJ'))['canonical_id_bytes'])") +echo "receipt_uuid = $RECEIPT_UUID (recovered from C2PA assertion)" +echo "commodity_id = $COMMODITY_ID" +echo "quantity = $QUANTITY" +echo "deposit_date = $DEPOSIT_DATE" +echo "canonical_id = $CANONICAL_ID (re-derived from recovered fields, matches embedded)" + +# --------------------------------------------------------------------------- +hr "STEP 3: commit_receivable -> expected role-bound anchor (same salt, role)" +cat > "$QUORUM/commit_receivable/Prover.toml" <&1 | grep "Circuit output") +EXPECTED_ANCHOR=$(echo "$CR_OUT" | sed -E 's/.*\[(0x[0-9a-f]+), (0x[0-9a-f]+)\].*/\2/') +echo "expected anchor = $EXPECTED_ANCHOR" + +# --------------------------------------------------------------------------- +hr "STEP 4: _mkroot -> trust_list_root for operator (key, role) leaf" +cat > "$QUORUM/_mkroot/Prover.toml" <&1 | grep "Circuit output" | sed -E 's/.*(0x[0-9a-f]+).*/\1/') +echo "trust_list_root = $ROOT" + +# --------------------------------------------------------------------------- +hr "STEP 5: proof_a_receivable -- in-circuit ECDSA verify + membership -> anchor" +cat > "$QUORUM/proof_a_receivable/Prover.toml" <&1 | grep "Circuit output" | sed -E 's/.*(0x[0-9a-f]+).*/\1/') +echo "proven anchor = $PROVEN_ANCHOR" +if [ "$PROVEN_ANCHOR" = "$EXPECTED_ANCHOR" ]; then + green "PASS: proof_a anchor == commit_receivable anchor (in-circuit ECDSA over C2PA-carried fields)" +else + red "FAIL: anchor mismatch (proven $PROVEN_ANCHOR vs expected $EXPECTED_ANCHOR)"; exit 1 +fi + +# --------------------------------------------------------------------------- +hr "STEP 6: bound_receivables -- double-financing one-bit answer (accepted_roles incl. 2)" +br_run () { # $1 = financed array contents, $2 = label, $3 = expected bool + cat > "$QUORUM/bound_receivables/Prover.toml" <&1 | grep "Circuit output") + echo " $2 -> $OUT" + echo "$OUT" | grep -q "($3," && green " PASS: already_financed=$3 (anchor opened cleanly)" \ + || { red " FAIL: expected already_financed=$3"; exit 1; } +} +br_run '"0x01","0x02","0x03","0x04","0x05","0x06"' "clean (cid NOT in financed book)" "false" +br_run "\"0x01\",\"0x02\",\"$CANONICAL_ID\",\"0x04\",\"0x05\",\"0x06\"" "double (cid IS in financed book)" "true" + +hr "ALL CHECKS PASSED -- C2PA warehouse receipt proven end-to-end (C2PA carried the fields)" diff --git a/experiments/conoir-spike/quorum/adapters/warehouse_adapter.py b/experiments/conoir-spike/quorum/adapters/warehouse_adapter.py new file mode 100644 index 0000000..5933b1d --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/warehouse_adapter.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +warehouse_adapter.py -- Apertrue Quorum C2PA WAREHOUSE-RECEIPT adapter. + +Parallel to fattura_adapter.py, but for an UNSTRUCTURED collateral document (a +warehouse receipt) that has NO native clearance/structured rail to ride. So here +**C2PA is the load-bearing CARRIER**: the signed canonical fields + the operator's +key + the operator's raw-ECDSA signature over canonical_id are embedded in a C2PA +custom assertion and hash-bound to the document, then READ BACK out of the manifest. + +It emits the SAME Quorum adapter-interface object that `proof_a_receivable` consumes: + + { canonical_fields: {receipt_uuid, commodity_id, quantity, deposit_date}, + canonical_id, canonical_id_bytes, + obligor: { pubkey_x:[32], pubkey_y:[32], signature:[64] r||s low-s, signer_role:2 } } + +Field mapping onto the (generic, 4-field) commit_receivable / proof_a circuits: + receipt_uuid -> slot 1 (invoice_uuid) + commodity_id -> slot 2 (debtor_id) + quantity -> slot 3 (amount) + deposit_date -> slot 4 (issue_date) +The circuit hashes four Fields generically, so the *names* are adapter-local. + +Signing spec (Quorum standard, identical to fattura): RAW ECDSA P-256 over the +32-byte big-endian canonical_id used DIRECTLY as the digest (NO sha256 wrapper), +low-S normalised. signer_role = 2 (the non-gameable custodian/operator attestation; +reuses role 2 so bound_receivables' accepted_roles works unchanged). + +Modes: + wrap -- mint keys, sign canonical_id, build the warehouse-receipt PDF, embed a + C2PA `org.apertrue.quorum.collateral` custom assertion + c2pa.hash.data + hard binding (REQUIRES the c2pie venv python). + read -- parse the C2PA manifest back out (c2patool), recover {fields, pubkey, + signature, signer_role}, re-derive canonical_id from the RECOVERED fields + and assert it matches the embedded one, emit warehouse_adapter_object.json. + both -- wrap then read (default). + +STAND-IN caveats (see ADAPTER.md): the warehouse-operator P-256 key is a minted +stand-in (real operator key is the partner crux); the C2PA manifest is signed by a +minted RSA leaf->CA chain (PS256). c2pie is the carrier tool. Proof A trusts the +operator LEAF key directly via the trust list, not an in-circuit X.509 chain. +""" +import argparse, base64, binascii, json, os, subprocess, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +QUORUM = os.path.dirname(HERE) +NARGO = os.environ.get("NARGO", os.path.expanduser("~/.nargo/bin/nargo")) +KEYDIR = os.path.join(HERE, "keys") +N_P256 = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 + +# --- concrete warehouse-receipt canonical fields (Meridian Bonded Storage, receipt 42) --- +DEFAULT_FIELDS = { + "receipt_uuid": 7001234000042, # operator licence 7001234 * 1e6 + receipt no 42 + "commodity_id": 74031100, # HS code: copper cathode, grade A + "quantity": 25000, # 250.00 metric tonnes, in centi-tonnes + "deposit_date": 20240315, # 2024-03-15 +} +RECEIPT_PDF = os.path.join(HERE, "warehouse_receipt.pdf") +C2PA_PDF = os.path.join(HERE, "warehouse_receipt_c2pa.pdf") +COLLATERAL_LABEL = "org.apertrue.quorum.collateral" + + +# --------------------------------------------------------------------------- # +# circuit helpers (shared with fattura adapter) +# --------------------------------------------------------------------------- # +def compute_canonical_id(fields, salt, signer_role): + """Run commit_receivable to get [canonical_id, anchor]. canonical_id is field 0.""" + toml = (f'invoice_uuid = "{fields["receipt_uuid"]}"\n' + f'debtor_id = "{fields["commodity_id"]}"\n' + f'amount = "{fields["quantity"]}"\n' + f'issue_date = "{fields["deposit_date"]}"\n' + f'salt = "{salt}"\n' + f'signer_role = "{signer_role}"\n') + cr = os.path.join(QUORUM, "commit_receivable") + open(os.path.join(cr, "Prover.toml"), "w").write(toml) + out = subprocess.run([NARGO, "execute", "--program-dir", cr], + capture_output=True, text=True).stdout + line = next(l for l in out.splitlines() if "Circuit output" in l) + inner = line.split("Circuit output:", 1)[1].split("[", 1)[1].rsplit("]", 1)[0] + cid, anchor = [t.strip() for t in inner.split(",")] + return cid, anchor + + +def mint_standin_operator(): + """Idempotently mint a stand-in warehouse-operator P-256 key + self-signed cert.""" + key = os.path.join(KEYDIR, "operator_standin.key") + pem = os.path.join(KEYDIR, "operator_standin.pem") + os.makedirs(KEYDIR, exist_ok=True) + if not os.path.exists(key): + subprocess.run(["openssl", "ecparam", "-name", "prime256v1", "-genkey", + "-noout", "-out", key], check=True, capture_output=True) + subprocess.run(["openssl", "req", "-new", "-x509", "-key", key, "-out", pem, + "-days", "3650", + "-subj", "/CN=warehouse_operator_standin/O=Apertrue Quorum STANDIN"], + check=True, capture_output=True) + return key + + +def mint_c2pa_rsa_chain(): + """Idempotently mint an RSA CA->leaf chain (PS256, emailProtection EKU) for the + C2PA manifest signer. Returns (leaf_pkcs8_key_path, chain_pem_path).""" + os.makedirs(KEYDIR, exist_ok=True) + ca_key = os.path.join(KEYDIR, "c2pa_ca.key") + ca_pem = os.path.join(KEYDIR, "c2pa_ca.pem") + leaf_t = os.path.join(KEYDIR, "c2pa_leaf_trad.key") + leaf_k = os.path.join(KEYDIR, "c2pa_leaf.key") # PKCS#8 (what c2pie wants) + leaf_p = os.path.join(KEYDIR, "c2pa_leaf.pem") # = chain (leaf only, no root) + csr = os.path.join(KEYDIR, "c2pa_leaf.csr") + ext = os.path.join(KEYDIR, "c2pa_leaf.ext") + if not os.path.exists(leaf_k): + run = lambda *a: subprocess.run(a, check=True, capture_output=True) + run("openssl", "genrsa", "-out", ca_key, "3072") + run("openssl", "req", "-new", "-x509", "-key", ca_key, "-out", ca_pem, "-days", "3650", + "-subj", "/CN=apertrue_quorum_c2pa_ca/O=Apertrue Quorum STANDIN", + "-addext", "basicConstraints=critical,CA:TRUE") + run("openssl", "genrsa", "-out", leaf_t, "3072") + run("openssl", "pkcs8", "-topk8", "-nocrypt", "-in", leaf_t, "-out", leaf_k) + run("openssl", "req", "-new", "-key", leaf_k, "-out", csr, + "-subj", "/CN=apertrue_quorum_c2pa_leaf/O=Apertrue Quorum STANDIN") + open(ext, "w").write("keyUsage=critical,digitalSignature\nextendedKeyUsage=emailProtection\n") + run("openssl", "x509", "-req", "-in", csr, "-CA", ca_pem, "-CAkey", ca_key, + "-CAcreateserial", "-out", leaf_p, "-days", "3650", "-extfile", ext) + return leaf_k, leaf_p + + +def raw_sign_and_extract(key_path, cid_hex, workdir): + """Raw-ECDSA sign the 32-byte canonical_id; return (x[32], y[32], sig r||s low-s [64]).""" + cid_bin = os.path.join(workdir, "wh_cid.bin") + sig_der = os.path.join(workdir, "wh_sig.der") + pub_der = os.path.join(workdir, "wh_pub.der") + open(cid_bin, "wb").write(binascii.unhexlify(cid_hex)) + # pkeyutl -sign treats the EC input bytes AS the digest -> raw ECDSA, no sha256 wrapper + subprocess.run(["openssl", "pkeyutl", "-sign", "-inkey", key_path, + "-in", cid_bin, "-out", sig_der], check=True, capture_output=True) + subprocess.run(["openssl", "ec", "-in", key_path, "-pubout", + "-conv_form", "uncompressed", "-outform", "DER", "-out", pub_der], + check=True, capture_output=True) + pub = open(pub_der, "rb").read() + point = pub[-65:] + assert point[0] == 4, "public key is not an uncompressed point" + x = list(point[1:33]); y = list(point[33:65]) + der = open(sig_der, "rb").read() + assert der[0] == 0x30 + def read_int(b, i): + assert b[i] == 0x02 + ln = b[i + 1] + return b[i + 2:i + 2 + ln], i + 2 + ln + r, i = read_int(der, 2) + s, _ = read_int(der, i) + sv = int.from_bytes(s, "big") + if sv > N_P256 // 2: # Noir verify_signature requires LOW-S (canonical) + sv = N_P256 - sv + s = sv.to_bytes(32, "big") + r = r.lstrip(b"\x00").rjust(32, b"\x00") + sig = list(r) + list(s) + return x, y, sig + + +# --------------------------------------------------------------------------- # +# the warehouse-receipt document +# --------------------------------------------------------------------------- # +def build_receipt_pdf(fields, out_pdf): + """Render a simple warehouse-receipt text -> PDF via cupsfilter (idempotent).""" + qty = fields["quantity"] / 100.0 + d = str(fields["deposit_date"]) + txt = f"""WAREHOUSE RECEIPT (NON-NEGOTIABLE) + +Operator : Meridian Bonded Storage Ltd (licence 7001234) +Receipt UUID : {fields['receipt_uuid']} +Commodity : Copper Cathode, Grade A (HS {fields['commodity_id']}) +Quantity : {qty:.2f} metric tonnes +Deposit date : {d[0:4]}-{d[4:6]}-{d[6:8]} +Location : Bonded Warehouse 7, Rotterdam + +This receipt evidences collateral held on deposit and is the carrier for the +Apertrue Quorum custodian attestation embedded as a C2PA assertion in this file. +""" + txt_path = os.path.join(HERE, "warehouse_receipt.txt") + open(txt_path, "w").write(txt) + raw = subprocess.run(["/usr/sbin/cupsfilter", txt_path], + check=True, capture_output=True).stdout + open(out_pdf, "wb").write(raw) + return out_pdf + + +# --------------------------------------------------------------------------- # +# WRAP: embed the signed canonical fields into a C2PA custom assertion +# --------------------------------------------------------------------------- # +def wrap(fields, salt, signer_role): + import hashlib + from c2pie.interface import (c2pie_GenerateHashDataAssertion, + c2pie_GenerateManifest, c2pie_EmplaceManifest) + from c2pie.utils.assertion_schemas import json_to_bytes, C2PA_AssertionTypes + from c2pie.utils.content_types import C2PA_ContentTypes, jumbf_content_types + from c2pie.jumbf_boxes.super_box import SuperBox + from c2pie.jumbf_boxes.content_box import ContentBox + + class CustomJsonAssertion(SuperBox): + """C2PA assertion with an arbitrary label + JSON payload (c2pie ships only 3 enum types).""" + def __init__(self, label, schema): + self.type = C2PA_AssertionTypes.creative_work # any value != data_hash + self.schema = schema + cb = ContentBox(box_type=b"json".hex(), payload=json_to_bytes(schema)) + super().__init__(content_type=jumbf_content_types["json"], label=label, + content_boxes=[cb]) + def get_data_for_signing(self): + return self.description_box.serialize() + self.serialize_content_boxes() + + canonical_id, anchor = compute_canonical_id(fields, salt, signer_role) + cid_hex = canonical_id[2:].rjust(64, "0") + + op_key = mint_standin_operator() + x, y, sig = raw_sign_and_extract(op_key, cid_hex, KEYDIR) + + build_receipt_pdf(fields, RECEIPT_PDF) + raw = open(RECEIPT_PDF, "rb").read() + + # The collateral assertion IS the rail: it carries the signed canonical fields, + # the operator's pubkey (x,y) and the raw-ECDSA signature over canonical_id. + collateral = { + "scheme": "WarehouseReceipt", + "signer_role": signer_role, + "signer_role_name": "warehouse-operator / custodian attestation", + "guarantee": "the collateral is genuinely on deposit", + "non_gameable_signer": "Meridian Bonded Storage Ltd (operator) [STAND-IN CERT]", + "canonical_fields": { + "receipt_uuid": fields["receipt_uuid"], + "commodity_id": fields["commodity_id"], + "quantity": fields["quantity"], + "deposit_date": fields["deposit_date"], + }, + "canonical_id": canonical_id, + "anchor": anchor, + "salt": salt, + # the operator's P-256 attestation over canonical_id (the load-bearing TRUST) + "operator_pubkey_x_hex": "".join(f"{b:02x}" for b in x), + "operator_pubkey_y_hex": "".join(f"{b:02x}" for b in y), + "signature_rs_low_s_hex": "".join(f"{b:02x}" for b in sig), + "signing_spec": "raw ECDSA P-256 over be32(canonical_id) used directly as digest (no sha256), low-s", + "source_document": "warehouse_receipt.pdf (this file)", + } + collateral_assertion = CustomJsonAssertion(COLLATERAL_LABEL, collateral) + + # hard binding over the WHOLE pdf; manifest appended at EOF + cai_offset = len(raw) + hash_data = c2pie_GenerateHashDataAssertion(cai_offset=cai_offset, + hashed_data=hashlib.sha256(raw).digest()) + manifest = c2pie_GenerateManifest( + assertions=[collateral_assertion, hash_data], + private_key=open(mint_c2pa_rsa_chain()[0], "rb").read(), + certificate_chain=open(mint_c2pa_rsa_chain()[1], "rb").read(), + ) + signed = c2pie_EmplaceManifest(C2PA_ContentTypes.pdf, raw, cai_offset, manifest) + open(C2PA_PDF, "wb").write(signed) + print(f"# wrap: signed C2PA warehouse receipt -> {C2PA_PDF} ({len(signed)} bytes)", + file=sys.stderr) + return C2PA_PDF + + +# --------------------------------------------------------------------------- # +# READ: pull the assertion back out of the C2PA manifest (c2patool) +# --------------------------------------------------------------------------- # +def read_manifest(pdf_path): + """Parse the C2PA manifest via c2patool, return (collateral_assertion_dict, manifest_label).""" + out = subprocess.run(["c2patool", pdf_path], check=True, capture_output=True, text=True).stdout + rep = json.loads(out) + label = rep["active_manifest"] + man = rep["manifests"][label] + for a in man["assertions"]: + if a["label"] == COLLATERAL_LABEL: + return a["data"], label + raise SystemExit(f"FAIL: {COLLATERAL_LABEL} assertion not found in {pdf_path}") + + +def read_and_emit(pdf_path, salt, signer_role, out_path): + coll, manifest_label = read_manifest(pdf_path) + f = coll["canonical_fields"] + fields = { + "receipt_uuid": int(f["receipt_uuid"]), + "commodity_id": int(f["commodity_id"]), + "quantity": int(f["quantity"]), + "deposit_date": int(f["deposit_date"]), + } + # Re-derive canonical_id from the RECOVERED fields -> must match the embedded value. + cid_recomputed, anchor_recomputed = compute_canonical_id(fields, salt, signer_role) + if cid_recomputed != coll["canonical_id"]: + raise SystemExit(f"FAIL: recomputed canonical_id {cid_recomputed} != " + f"embedded {coll['canonical_id']}") + print(f"# read: canonical_id from C2PA matches recompute ({cid_recomputed})", file=sys.stderr) + + cid_hex = cid_recomputed[2:].rjust(64, "0") + cid_bytes = list(binascii.unhexlify(cid_hex)) + x = list(binascii.unhexlify(coll["operator_pubkey_x_hex"])) + y = list(binascii.unhexlify(coll["operator_pubkey_y_hex"])) + sig = list(binascii.unhexlify(coll["signature_rs_low_s_hex"])) + assert len(x) == 32 and len(y) == 32 and len(sig) == 64, "bad recovered key/sig lengths" + + obj = { + "scheme": "WarehouseReceipt", + "source_document": os.path.basename(pdf_path), + "carrier": "C2PA custom assertion (org.apertrue.quorum.collateral), hash-bound to the PDF", + "c2pa_manifest_label": manifest_label, + "canonical_fields": fields, + "canonical_id": cid_recomputed, + "canonical_id_bytes": cid_bytes, + "obligor": { + "pubkey_x": x, + "pubkey_y": y, + "signature": sig, # 64 bytes r||s, low-s normalised + "signer_role": int(signer_role), + }, + "_role_bound_anchor_at_salt": {"salt": int(salt), "anchor": anchor_recomputed}, + "_notes": { + "signing_spec": "raw ECDSA P-256 over be32(canonical_id) used directly as digest (no sha256)", + "carrier_note": "C2PA is the load-bearing CARRIER: the operator key+signature are " + "transported in a C2PA custom assertion and hash-bound to the file; " + "trust is the SIGNATURE, verified in-circuit by Proof A.", + "operator_key": "STAND-IN: minted P-256 key (adapters/keys/operator_standin.key); " + "real warehouse-operator key is the partner crux, not present in repo", + "c2pa_manifest_signer": "STAND-IN RSA leaf->CA chain (adapters/keys/c2pa_leaf.*), PS256", + "field_mapping": "receipt_uuid->slot1, commodity_id->slot2, quantity->slot3, deposit_date->slot4", + }, + } + open(out_path, "w").write(json.dumps(obj, indent=2)) + print(json.dumps(obj, indent=2)) + print(f"\n# wrote {out_path}", file=sys.stderr) + return obj + + +def main(): + ap = argparse.ArgumentParser(description="C2PA warehouse receipt -> Quorum adapter-interface object") + ap.add_argument("--mode", choices=["wrap", "read", "both"], default="both") + ap.add_argument("--salt", default="42") + ap.add_argument("--signer-role", default="2") + ap.add_argument("--pdf", default=C2PA_PDF, help="C2PA pdf to read (read mode)") + ap.add_argument("--out", default=os.path.join(HERE, "warehouse_adapter_object.json")) + args = ap.parse_args() + fields = dict(DEFAULT_FIELDS) + + if args.mode in ("wrap", "both"): + wrap(fields, args.salt, args.signer_role) + if args.mode in ("read", "both"): + read_and_emit(args.pdf, args.salt, args.signer_role, args.out) + + +if __name__ == "__main__": + main() diff --git a/experiments/conoir-spike/quorum/adapters/warehouse_adapter_object.json b/experiments/conoir-spike/quorum/adapters/warehouse_adapter_object.json new file mode 100644 index 0000000..e0e2ab3 --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/warehouse_adapter_object.json @@ -0,0 +1,195 @@ +{ + "scheme": "WarehouseReceipt", + "source_document": "warehouse_receipt_c2pa.pdf", + "carrier": "C2PA custom assertion (org.apertrue.quorum.collateral), hash-bound to the PDF", + "c2pa_manifest_label": "urn:uuid:78be8d074dc94d639d9b1e957f711ffe", + "canonical_fields": { + "receipt_uuid": 7001234000042, + "commodity_id": 74031100, + "quantity": 25000, + "deposit_date": 20240315 + }, + "canonical_id": "0x20adcccdd6e2e94b4a78a2cba482c287b37c3c840204f9412d5baa1cdb11a423", + "canonical_id_bytes": [ + 32, + 173, + 204, + 205, + 214, + 226, + 233, + 75, + 74, + 120, + 162, + 203, + 164, + 130, + 194, + 135, + 179, + 124, + 60, + 132, + 2, + 4, + 249, + 65, + 45, + 91, + 170, + 28, + 219, + 17, + 164, + 35 + ], + "obligor": { + "pubkey_x": [ + 118, + 30, + 224, + 102, + 155, + 108, + 46, + 96, + 35, + 198, + 166, + 178, + 25, + 180, + 27, + 216, + 240, + 64, + 196, + 153, + 145, + 140, + 214, + 216, + 98, + 231, + 196, + 128, + 219, + 233, + 217, + 102 + ], + "pubkey_y": [ + 90, + 109, + 25, + 193, + 124, + 211, + 44, + 180, + 7, + 38, + 154, + 58, + 185, + 254, + 127, + 175, + 20, + 153, + 246, + 236, + 171, + 71, + 33, + 56, + 255, + 210, + 243, + 76, + 125, + 122, + 173, + 32 + ], + "signature": [ + 61, + 17, + 95, + 117, + 81, + 75, + 66, + 152, + 32, + 140, + 81, + 150, + 199, + 115, + 60, + 143, + 170, + 225, + 36, + 183, + 118, + 223, + 194, + 4, + 226, + 161, + 77, + 154, + 35, + 179, + 239, + 76, + 114, + 64, + 251, + 219, + 186, + 150, + 60, + 34, + 51, + 101, + 172, + 8, + 103, + 207, + 149, + 84, + 50, + 142, + 0, + 255, + 216, + 243, + 180, + 9, + 119, + 63, + 175, + 221, + 207, + 177, + 200, + 156 + ], + "signer_role": 2 + }, + "_role_bound_anchor_at_salt": { + "salt": 42, + "anchor": "0x2f458e5aee3d011eebd93c81e310de32f13892134be3f27c7fccac4d1cef7744" + }, + "_notes": { + "signing_spec": "raw ECDSA P-256 over be32(canonical_id) used directly as digest (no sha256)", + "carrier_note": "C2PA is the load-bearing CARRIER: the operator key+signature are transported in a C2PA custom assertion and hash-bound to the file; trust is the SIGNATURE, verified in-circuit by Proof A.", + "operator_key": "STAND-IN: minted P-256 key (adapters/keys/operator_standin.key); real warehouse-operator key is the partner crux, not present in repo", + "c2pa_manifest_signer": "STAND-IN RSA leaf->CA chain (adapters/keys/c2pa_leaf.*), PS256", + "field_mapping": "receipt_uuid->slot1, commodity_id->slot2, quantity->slot3, deposit_date->slot4" + } +} \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/adapters/warehouse_receipt.pdf b/experiments/conoir-spike/quorum/adapters/warehouse_receipt.pdf new file mode 100644 index 0000000..58e436f Binary files /dev/null and b/experiments/conoir-spike/quorum/adapters/warehouse_receipt.pdf differ diff --git a/experiments/conoir-spike/quorum/adapters/warehouse_receipt.txt b/experiments/conoir-spike/quorum/adapters/warehouse_receipt.txt new file mode 100644 index 0000000..9d8d354 --- /dev/null +++ b/experiments/conoir-spike/quorum/adapters/warehouse_receipt.txt @@ -0,0 +1,11 @@ +WAREHOUSE RECEIPT (NON-NEGOTIABLE) + +Operator : Meridian Bonded Storage Ltd (licence 7001234) +Receipt UUID : 7001234000042 +Commodity : Copper Cathode, Grade A (HS 74031100) +Quantity : 250.00 metric tonnes +Deposit date : 2024-03-15 +Location : Bonded Warehouse 7, Rotterdam + +This receipt evidences collateral held on deposit and is the carrier for the +Apertrue Quorum custodian attestation embedded as a C2PA assertion in this file. diff --git a/experiments/conoir-spike/quorum/adapters/warehouse_receipt_c2pa.pdf b/experiments/conoir-spike/quorum/adapters/warehouse_receipt_c2pa.pdf new file mode 100644 index 0000000..a0a1c0a Binary files /dev/null and b/experiments/conoir-spike/quorum/adapters/warehouse_receipt_c2pa.pdf differ diff --git a/experiments/conoir-spike/quorum/aggregate-README.md b/experiments/conoir-spike/quorum/aggregate-README.md new file mode 100644 index 0000000..80cb615 --- /dev/null +++ b/experiments/conoir-spike/quorum/aggregate-README.md @@ -0,0 +1,41 @@ +# Apertrue Quorum — aggregate engine: marine over-insurance, under MPC (2026-06) + +Runnable proof-of-life for the second fraud shape in `apertrue-quorum-plan.md`: not "has this +been seen before?" (membership) but "do separate, legitimate pieces sum past a limit?". The +case is a hull insured by several insurers past its agreed value, the point at which a ship is +worth more sunk than afloat. Answered under 3-party REP3 MPC, revealing **only one bit**. + +## Circuits +- `commit_aggregate/` — Layer 1 (off-MPC). Role-stamped anchors `Poseidon2([value, salt, + role])` for each insurer line (role 3) and the valuer-signed agreed value (role 4). +- `bound_aggregate/` — Layer 3 (MPC). Binds each secret-shared line to its insurer anchor and + the secret-shared agreed value to the valuer anchor, checks both roles against the + allow-list, sums the lines, and reveals only whether the sum exceeds the agreed value. + +## Authenticity (two signers, adapted) +- Each **line** is signed by the **insurer** that wrote it (role 3): honest, free, it vouches + for its own exposure. +- The **agreed value** is signed by a **valuer** (role 4), never the owner, who could inflate + it. An inflated value does not open the valuer's anchor and is rejected. + +## Result (full 3-party MPC, proof verified) +| Case | Setup | Output | Verified | +|------|-------|--------|----------| +| over-insured | lines 20M + 25M + 10M = 55M, value 50M | `over_insured = 1` | ✅ | +| within the limit | same lines, value 60M | `over_insured = 0` | ✅ | +| fabricated line | a line inflated to 30M, breaks its insurer anchor | — | ❌ rejected | +| wrong signer role | a line carrying the valuer role instead of an insurer's | — | ❌ rejected by the allow-list | + +No insurer's line and no agreed value is revealed; only the bit crosses between the parties. +The fabricated and wrong-role cases fail at verification, exactly as in the membership engine. + +## Scope +Same as the rest of the spike: the engine, the binding and the allow-list are real and run. +The certificates (insurer, valuer) are stand-in; the lines and value are sample figures. + +## Reproduce +``` +cd commit_aggregate && ~/.nargo/bin/nargo execute # prints the four anchors +cd .. && ./run_bound_aggregate.sh # over-insured case, full MPC +./run_bound_aggregate.sh /path/to/variant_Prover.toml # other cases +``` diff --git a/experiments/conoir-spike/quorum/bound_aggregate/Nargo.toml b/experiments/conoir-spike/quorum/bound_aggregate/Nargo.toml new file mode 100644 index 0000000..4cfdcce --- /dev/null +++ b/experiments/conoir-spike/quorum/bound_aggregate/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bound_aggregate" +type = "bin" +authors = ["Apertrue"] +compiler_version = ">=1.0.0" +description = "Apertrue Quorum aggregate engine: sum secret-shared insurer lines against a valuer-signed agreed value under MPC. Reveals only the over-insured bit." + +[dependencies] +poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } diff --git a/experiments/conoir-spike/quorum/bound_aggregate/Prover.toml b/experiments/conoir-spike/quorum/bound_aggregate/Prover.toml new file mode 100644 index 0000000..146fc99 --- /dev/null +++ b/experiments/conoir-spike/quorum/bound_aggregate/Prover.toml @@ -0,0 +1,17 @@ +# PUBLIC anchors (from commit_aggregate): three insurer lines + the valuer-signed value +line_anchor = [ + "0x162ae25928e5b1c2372c7b47384f715830e9e5612c6e5c343edb434457f3a412", + "0x15a0acd5bdada247adb810cc90b356d1be9f9a9fc00e57341821401f0871407c", + "0x23cff3f18aaf298b6d50f1559a48e48bcdb15aa018098cf50847bdaab7474a91", +] +value_anchor = "0x2a5aa4535b0dfa2c0922bf221b329388d5e02b68dc190893ae02ae0190da0389" +accepted_line_role = "3" +accepted_value_role = "4" + +# PRIVATE (secret-shared): the lines and the agreed value +line = ["20000000", "25000000", "10000000"] # sum = 55,000,000 +line_salt = ["0xa1a1a1a1a1a1a1a1", "0xa2a2a2a2a2a2a2a2", "0xa3a3a3a3a3a3a3a3"] +line_role = ["3", "3", "3"] +agreed_value = "50000000" # 55M > 50M -> over-insured +value_salt = "0xb1b1b1b1b1b1b1b1" +value_role = "4" diff --git a/experiments/conoir-spike/quorum/bound_aggregate/src/main.nr b/experiments/conoir-spike/quorum/bound_aggregate/src/main.nr new file mode 100644 index 0000000..ee69c92 --- /dev/null +++ b/experiments/conoir-spike/quorum/bound_aggregate/src/main.nr @@ -0,0 +1,56 @@ +// bound_aggregate -- Apertrue Quorum aggregate engine, under MPC (marine over-insurance). +// +// "Do the separate, legitimate lines of cover on this hull sum past its agreed value?" +// -> reveal ONE bit, nothing else. No insurer sees another's line; the agreed value and +// every line stay secret-shared; only over-insured / not crosses between them. +// +// Authenticity (the two-signer idea, adapted): +// - each line is signed by the INSURER that wrote it (role 3): honest, free authenticity, +// it vouches for its own exposure. +// - the agreed value is signed by a VALUER (role 4), never the owner, who could inflate it. +// Both are bound to public, role-stamped anchors; a line signed with the wrong role, or an +// inflated value that does not open the valuer's anchor, is rejected. +// +// Same skeleton as the membership engine: bind authenticated inputs, run a predicate +// (here a sum against a threshold), reveal one bit. Simpler maths, no Merkle tree. + +use poseidon::poseidon2::Poseidon2; + +global N_LINES: u32 = 3; + +fn anchor(value: Field, salt: Field, signer_role: Field) -> Field { + Poseidon2::hash([value, salt, signer_role], 3) +} + +fn main( + // ---- PUBLIC ---- + line_anchor: pub [Field; N_LINES], // each insurer's signed line anchor + value_anchor: pub Field, // the valuer-signed agreed-value anchor + accepted_line_role: pub Field, // allow-list: role an insurer line must carry (3) + accepted_value_role: pub Field, // allow-list: role the valuer must carry (4) + // ---- PRIVATE (secret-shared) ---- + line: [Field; N_LINES], // each insurer's cover amount + line_salt: [Field; N_LINES], + line_role: [Field; N_LINES], + agreed_value: Field, // the valuer-attested hull value (never revealed) + value_salt: Field, + value_role: Field, +) -> pub bool { + // bind each line to its insurer anchor, check the role, and total it up + let mut total: Field = 0; + for i in 0..N_LINES { + assert( + anchor(line[i], line_salt[i], line_role[i]) == line_anchor[i], + "line anchor opening failed", + ); + assert(line_role[i] == accepted_line_role, "line signer role not accepted"); + total = total + line[i]; + } + + // bind the agreed value to the valuer's anchor and check the role + assert(anchor(agreed_value, value_salt, value_role) == value_anchor, "value anchor opening failed"); + assert(value_role == accepted_value_role, "value signer role not accepted"); + + // over-insured if the lines sum past the agreed value + (total as u64) > (agreed_value as u64) +} diff --git a/experiments/conoir-spike/quorum/bound_receivables/Nargo.toml b/experiments/conoir-spike/quorum/bound_receivables/Nargo.toml new file mode 100644 index 0000000..8f00d50 --- /dev/null +++ b/experiments/conoir-spike/quorum/bound_receivables/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bound_receivables" +type = "bin" +authors = ["Apertrue"] +compiler_version = ">=1.0.0" +description = "Apertrue Quorum: authenticated double-financing check across lenders' books under MPC. Reveals only the already-financed bit." + +[dependencies] +poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } diff --git a/experiments/conoir-spike/quorum/bound_receivables/Prover.toml b/experiments/conoir-spike/quorum/bound_receivables/Prover.toml new file mode 100644 index 0000000..d7c9fe7 --- /dev/null +++ b/experiments/conoir-spike/quorum/bound_receivables/Prover.toml @@ -0,0 +1,9 @@ +candidate_anchor = "0x2f458e5aee3d011eebd93c81e310de32f13892134be3f27c7fccac4d1cef7744" +signer_role = "2" +accepted_roles = ["1", "2"] +invoice_uuid = "7001234000042" +debtor_id = "74031100" +amount = "25000" +issue_date = "20240315" +salt = "42" +financed = ["0x01","0x02","0x20adcccdd6e2e94b4a78a2cba482c287b37c3c840204f9412d5baa1cdb11a423","0x04","0x05","0x06"] diff --git a/experiments/conoir-spike/quorum/bound_receivables/Prover_sdi.toml b/experiments/conoir-spike/quorum/bound_receivables/Prover_sdi.toml new file mode 100644 index 0000000..d7c4663 --- /dev/null +++ b/experiments/conoir-spike/quorum/bound_receivables/Prover_sdi.toml @@ -0,0 +1,14 @@ +candidate_anchor = "0x015dac177e4952c70e1161e5478ab76817250fa1d521e258e505cfea9c1648a3" +invoice_uuid = "1234567890000123" +debtor_id = "9876543210" +amount = "500" +issue_date = "20141218" +salt = "0x7777777777777777" +financed = [ + "0x01a1111111111111111111111111111111111111111111111111111111111111", + "0x02b2222222222222222222222222222222222222222222222222222222222222", + "0x03c3333333333333333333333333333333333333333333333333333333333333", + "0x04d4444444444444444444444444444444444444444444444444444444444444", + "0x05e5555555555555555555555555555555555555555555555555555555555555", + "0x06f6666666666666666666666666666666666666666666666666666666666666", +] diff --git a/experiments/conoir-spike/quorum/bound_receivables/src/main.nr b/experiments/conoir-spike/quorum/bound_receivables/src/main.nr new file mode 100644 index 0000000..cfcbc0e --- /dev/null +++ b/experiments/conoir-spike/quorum/bound_receivables/src/main.nr @@ -0,0 +1,69 @@ +// bound_receivables -- Apertrue Quorum double-financing check, under MPC, with the +// acceptable-anchor allow-list and per-signer guarantee. +// +// "Before a lender advances cash, does this EXACT receivable already appear in any lender's +// book across the network?" -> reveal ONE bit, plus WHICH guarantee the anchor carries. +// +// Two distinct signer roles defend different frauds (the spine of the thesis): +// role 1 SdI clearance -> proves "uniquely identified, cleared, not financed elsewhere". +// Catches DOUBLE-PLEDGING of real invoices. Does NOT prove the +// debt is real -- a seller can clear a fabricated/inflated invoice. +// role 2 obligor/debtor -> proves "the debt is genuinely owed". Also catches FABRICATION +// and INFLATION, because the debtor will not sign a lie. +// (First Brands was BOTH double-pledging AND fabricated/inflated invoices. Clearance alone +// catches the former; the obligor catches the latter. Together = the honest answer.) +// +// Bindings enforced here: +// 1. the secret-shared fields open the public, ROLE-BOUND anchor (authenticated query); +// 2. the signer_role is in the acceptable-anchor ALLOW-LIST (a seller's own signature, +// role 3, is rejected here -- the fraudster cannot be the trusted signer). +// The inner signature verification + role assignment happen OFF-MPC in admission (Layer 2), +// against the trust list; this circuit binds to its role-stamped output. + +use poseidon::poseidon2::Poseidon2; + +global BOOK_UNION: u32 = 6; // already-financed fingerprints across the network +global ALLOW_LIST: u32 = 2; // size of the acceptable-anchor allow-list + +fn canonical_id(uuid: Field, debtor: Field, amount: Field, date: Field) -> Field { + Poseidon2::hash([uuid, debtor, amount, date], 4) +} + +fn anchor(cid: Field, salt: Field, signer_role: Field) -> Field { + Poseidon2::hash([cid, salt, signer_role], 3) +} + +fn main( + // ---- PUBLIC ---- + candidate_anchor: pub Field, // role-bound authenticity anchor (from admission) + signer_role: pub Field, // 1 = SdI clearance, 2 = obligor + accepted_roles: pub [Field; ALLOW_LIST], // the acceptable-anchor allow-list + // ---- PRIVATE (secret-shared) ---- + invoice_uuid: Field, + debtor_id: Field, + amount: Field, + issue_date: Field, + salt: Field, + financed: [Field; BOOK_UNION], // network's already-financed fingerprints +) -> pub (bool, Field) { + let cid = canonical_id(invoice_uuid, debtor_id, amount, issue_date); + + // 1. AUTHENTICATED QUERY: fields must open the role-bound anchor. + assert(anchor(cid, salt, signer_role) == candidate_anchor, "candidate anchor opening failed"); + + // 2. ALLOW-LIST: the signer role must be acceptable (rejects e.g. a seller self-signature). + let mut role_ok: bool = false; + for i in 0..ALLOW_LIST { + role_ok = role_ok | (accepted_roles[i] == signer_role); + } + assert(role_ok, "signer role not in acceptable-anchor allow-list"); + + // 3. DOUBLE-DIP: is this exact receivable already financed anywhere in the network? + let mut already_financed: bool = false; + for i in 0..BOOK_UNION { + already_financed = already_financed | (financed[i] == cid); + } + + // reveal the bit + the role (which fixes the guarantee the proof carries) + (already_financed, signer_role) +} diff --git a/experiments/conoir-spike/quorum/commit_aggregate/Nargo.toml b/experiments/conoir-spike/quorum/commit_aggregate/Nargo.toml new file mode 100644 index 0000000..8773348 --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_aggregate/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "commit_aggregate" +type = "bin" +authors = ["Apertrue"] +compiler_version = ">=1.0.0" +description = "Quorum aggregate engine: produce role-stamped anchors for insurer lines and the valuer-signed agreed value, off-MPC." + +[dependencies] +poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } diff --git a/experiments/conoir-spike/quorum/commit_aggregate/Prover.toml b/experiments/conoir-spike/quorum/commit_aggregate/Prover.toml new file mode 100644 index 0000000..72dfb86 --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_aggregate/Prover.toml @@ -0,0 +1,7 @@ +# marine over-insurance: three insurer lines of cover + the valuer's agreed hull value (USD) +# line A = $20,000,000 line B = $25,000,000 line C = $10,000,000 (sum = $55,000,000) +# agreed value = $50,000,000 -> sum exceeds value -> over-insured +# roles: 3 = insurer line, 4 = valuer +values = ["20000000", "25000000", "10000000", "50000000"] +salts = ["0xa1a1a1a1a1a1a1a1", "0xa2a2a2a2a2a2a2a2", "0xa3a3a3a3a3a3a3a3", "0xb1b1b1b1b1b1b1b1"] +roles = ["3", "3", "3", "4"] diff --git a/experiments/conoir-spike/quorum/commit_aggregate/src/main.nr b/experiments/conoir-spike/quorum/commit_aggregate/src/main.nr new file mode 100644 index 0000000..1b3db16 --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_aggregate/src/main.nr @@ -0,0 +1,24 @@ +// commit_aggregate -- off-MPC anchor generator for the aggregate engine (marine over-insurance). +// +// Each authentic quantity (an insurer's line of cover, or the valuer's agreed value) is +// committed with a role-stamped anchor, exactly as in the membership engine: +// anchor = Poseidon2([value, salt, signer_role], 3) +// Roles here: 3 = insurer line (the insurer signs its own exposure, free authenticity), +// 4 = valuer (the agreed value is signed by a valuer, never the owner). +// +// Run with `nargo execute` to obtain the four public anchors (3 lines + 1 agreed value). + +use poseidon::poseidon2::Poseidon2; + +fn anchor(value: Field, salt: Field, signer_role: Field) -> Field { + Poseidon2::hash([value, salt, signer_role], 3) +} + +fn main(values: [Field; 4], salts: [Field; 4], roles: [Field; 4]) -> pub [Field; 4] { + [ + anchor(values[0], salts[0], roles[0]), + anchor(values[1], salts[1], roles[1]), + anchor(values[2], salts[2], roles[2]), + anchor(values[3], salts[3], roles[3]), + ] +} diff --git a/experiments/conoir-spike/quorum/commit_receivable/Nargo.toml b/experiments/conoir-spike/quorum/commit_receivable/Nargo.toml new file mode 100644 index 0000000..a705baa --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_receivable/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "commit_receivable" +type = "bin" +authors = ["Apertrue"] +compiler_version = ">=1.0.0" +description = "Quorum Layer 1: produce (canonical_id, authenticity anchor) for a receivable, off-MPC." + +[dependencies] +poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } diff --git a/experiments/conoir-spike/quorum/commit_receivable/Prover.toml b/experiments/conoir-spike/quorum/commit_receivable/Prover.toml new file mode 100644 index 0000000..cfe065b --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_receivable/Prover.toml @@ -0,0 +1,6 @@ +invoice_uuid = "7001234000042" +debtor_id = "74031100" +amount = "25000" +issue_date = "20240315" +salt = "42" +signer_role = "2" diff --git a/experiments/conoir-spike/quorum/commit_receivable/Prover_a.toml b/experiments/conoir-spike/quorum/commit_receivable/Prover_a.toml new file mode 100644 index 0000000..4ced4a5 --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_receivable/Prover_a.toml @@ -0,0 +1,6 @@ +invoice_uuid = "1234567890000123" +debtor_id = "9876543210" +amount = "500" +issue_date = "20141218" +salt = "42" +signer_role = "2" diff --git a/experiments/conoir-spike/quorum/commit_receivable/Prover_sdi.toml b/experiments/conoir-spike/quorum/commit_receivable/Prover_sdi.toml new file mode 100644 index 0000000..76306d9 --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_receivable/Prover_sdi.toml @@ -0,0 +1,5 @@ +invoice_uuid = "1234567890000123" +debtor_id = "9876543210" +amount = "500" +issue_date = "20141218" +salt = "0x7777777777777777" diff --git a/experiments/conoir-spike/quorum/commit_receivable/src/main.nr b/experiments/conoir-spike/quorum/commit_receivable/src/main.nr new file mode 100644 index 0000000..2f56cef --- /dev/null +++ b/experiments/conoir-spike/quorum/commit_receivable/src/main.nr @@ -0,0 +1,37 @@ +// commit_receivable -- off-MPC anchor generator for Apertrue Quorum (receivables). +// +// Mirrors Layer 1: a non-fraudster signs the receivable value over the CANONICAL FIELDS, +// C2PA carries it, and the local proof emits a PUBLIC, role-bound authenticity anchor: +// +// canonical_id = Poseidon2([invoice_uuid, debtor_id, amount, issue_date], 4) +// anchor = Poseidon2([canonical_id, salt, signer_role], 3) +// +// signer_role binds WHO attested into the anchor itself, so an SdI-clearance anchor and an +// obligor (debtor-confirmation) anchor for the same receivable are DISTINCT values and +// cannot be swapped. The role determines the GUARANTEE the proof carries: +// role 1 (SdI clearance) -> "uniquely-identified, cleared, not financed elsewhere" +// role 2 (obligor/debtor) -> "the debt is genuinely owed" (the stronger claim) +// +// Run headless with `nargo execute` to obtain (canonical_id, anchor). + +use poseidon::poseidon2::Poseidon2; + +fn canonical_id(uuid: Field, debtor: Field, amount: Field, date: Field) -> Field { + Poseidon2::hash([uuid, debtor, amount, date], 4) +} + +fn anchor(cid: Field, salt: Field, signer_role: Field) -> Field { + Poseidon2::hash([cid, salt, signer_role], 3) +} + +fn main( + invoice_uuid: Field, + debtor_id: Field, + amount: Field, + issue_date: Field, + salt: Field, + signer_role: Field, // 1 = SdI clearance, 2 = obligor/debtor confirmation +) -> pub [Field; 2] { + let cid = canonical_id(invoice_uuid, debtor_id, amount, issue_date); + [cid, anchor(cid, salt, signer_role)] +} diff --git a/experiments/conoir-spike/quorum/p1_provenance/IT01234567890_FPR01.xml b/experiments/conoir-spike/quorum/p1_provenance/IT01234567890_FPR01.xml new file mode 100644 index 0000000..c82c1bc --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/IT01234567890_FPR01.xml @@ -0,0 +1,110 @@ + + + + + + IT + 01234567890 + + 00001 + FPR12 + ABC1234 + + + + + + IT + 01234567890 + + + SOCIETA' ALPHA SRL + + RF19 + + + VIALE ROMA 543 + 07100 + SASSARI + SS + IT + + + + + 09876543210 + + DITTA BETA + + + + VIA TORINO 38-B + 00145 + ROMA + RM + IT + + + + + + + TD01 + EUR + 2014-12-18 + 123 + LA FATTURA FA RIFERIMENTO AD UNA OPERAZIONE AAAA BBBBBBBBBBBBBBBBBB CCC DDDDDDDDDDDDDDD E FFFFFFFFFFFFFFFFFFFF GGGGGGGGGG HHHHHHH II LLLLLLLLLLLLLLLLL MMM NNNNN OO PPPPPPPPPPP QQQQ RRRR SSSSSSSSSSSSSS + SEGUE DESCRIZIONE CAUSALE NEL CASO IN CUI NON SIANO STATI SUFFICIENTI 200 CARATTERI AAAAAAAAAAA BBBBBBBBBBBBBBBBB + + + 1 + 66685 + 1 + + + 1 + 123 + 2012-09-01 + 5 + 123abc + 456def + + + + + IT + 24681012141 + + + Trasporto spa + + + 2012-10-22T16:46:12.000+02:00 + + + + + 1 + DESCRIZIONE DELLA FORNITURA + 5.00 + 1.00 + 5.00 + 22.00 + + + 22.00 + 5.00 + 1.10 + I + + + + TP01 + + MP01 + 2015-01-30 + 6.10 + + + + diff --git a/experiments/conoir-spike/quorum/p1_provenance/README.md b/experiments/conoir-spike/quorum/p1_provenance/README.md new file mode 100644 index 0000000..033b329 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/README.md @@ -0,0 +1,87 @@ +# Quorum P1 — real provenance adapter (FatturaPA → C2PA → anchor → MPC) + +End-to-end with a **real Italian e-invoice**: parse it, render to PDF, bind a real C2PA +manifest, derive the canonical fingerprint + authenticity anchor, and feed that anchor into +the `bound_receivables` MPC proof. Closes the loop from a real document to the one-bit +double-financing answer. + +## The real chain (what actually ran) +1. **Real FatturaPA invoice** — `IT01234567890_FPR01.xml` (SOCIETA' ALPHA SRL → DITTA BETA, + no. 123, 2014-12-18, EUR; public sample, simevo/fattura-elettronica-json). +2. **Canonicalisation** (deterministic, the make-or-break field): + - `invoice_uuid = supplierVAT(01234567890) * 1e6 + numero(123) = 1234567890000123` + - `debtor_id = buyer CF 09876543210 → 9876543210` + - `amount = 5.00 EUR → 500` (cents) · `issue_date = 2014-12-18 → 20141218` +3. **Anchor** (`commit_receivable`, proof_a's scheme): + - `canonical_id = Poseidon2([uuid, debtor, amount, date]) = 0x06cc60c6…87280` + - `anchor = Poseidon2([canonical_id, salt]) = 0x015dac17…48a3` +4. **Real C2PA-signed PDF** — `invoice_c2pa.pdf`, produced by **c2pie** (PS256, RSA cert + chain). Contains `c2pa.claim`, `c2pa.hash.data` (hard binding to the PDF bytes), + `c2pa.signature` (COSE), the CreativeWork assertion, in an embedded `manifest.c2pa` + (`/AFRelationship /C2PA_Manifest`). Verified embedded via JUMBF markers. +5. **MPC** — anchor fed to `bound_receivables`, full 3-party REP3: + - clean → `already_financed = 0` (verified) · double-financed → `1` (verified). + +## What is REAL vs STAND-IN +| Element | State | +|---|---| +| FatturaPA invoice + fields + canonicalisation | **real** | +| canonical_id / anchor (proof_a Poseidon2 scheme) | **real** | +| C2PA manifest on a PDF, hard data-hash binding, COSE sig, cert chain | **real** (via c2pie) | +| MPC double-financing proof from this anchor | **real** (verified) | +| Clearance signer cert (Agenzia delle Entrate / SdI) | **stand-in** self-made CA — real cert needs a partner; validation_state untrusted until the CA is a known anchor | +| Asset format | PDF **rendering** of the invoice (+ could embed the XML as attachment); raw-XML C2PA needs the data-hash sidecar API (see tooling note) | + +## Tooling notes +- Homebrew **c2patool 0.26.29 is media-only** — it maps `.xml`→SVG and reports PDF/generic + "type is unsupported" (PDF/data-hash are non-default c2pa-rs features). So it cannot bind + to a raw e-invoice XML or PDF. +- **c2pie** (TourmalineCore) fills the gap: Python, embeds C2PA into **PDF** (and JPG), + PS256 + cert chains, with a hard `c2pa.hash.data` binding. No raw-XML / sidecar. +- For raw-**XML** C2PA (data-hash sidecar) the spec supports it; needs a c2pa-rs build with + the data-hash feature, or the CAWG identity-assertion path. Tracked for P1.next. + +## Reproduce +``` +# render + sign a C2PA PDF (needs: c2pie in a venv, openssl) +python3 -m venv venv && . venv/bin/activate && pip install c2pie +cupsfilter invoice.txt > invoice.pdf +# generate an RSA CA->leaf chain (emailProtection EKU), then: +python3 -c "from c2pie.signing import sign_file; \ + sign_file('invoice.pdf','invoice_c2pa.pdf','rsa_leaf.key','rsa_chain.pem','c2pie_schema.json')" +# derive anchor + run MPC +cd ../commit_receivable && ~/.nargo/bin/nargo execute --prover-name Prover_sdi +cd ../ && ./run_bound_receivables.sh bound_receivables/Prover_sdi.toml +``` + +## P1.next (done) — `p1_next.py` → `invoice_quorum.pdf` +Two of the three items are now real (the third needs a partner cert): + +- ✅ **Custom C2PA assertion** `org.apertrue.quorum.receivable` (via c2pie's programmatic + `interface` API, not just CreativeWork) carrying the canonical fields, the role-bound + anchor, the signer role/guarantee, and the **inner obligor ES256 signature over the + canonical_id**. Referenced twice in the file (assertion box + claim url) → bound by the + claim signature. +- ✅ **Authoritative XML travels with the C2PA.** Embedded two ways: + (a) inside the PDF (under the `c2pa.hash.data` hard binding — the XML bytes are physically + present and hashed), and (b) — the robust path — inside the signed manifest itself as + `org.apertrue.quorum.source_document` (base64 + sha256). Extracted back out of the + manifest it **round-trips bit-perfectly** (4315 bytes == source). + Note: c2pie's PDF incremental update clobbers the PDF *attachment* names tree (only + `manifest.c2pa` remains navigable), which is why the manifest-embedded copy (b) is the + load-bearing one. +- ⬜ **Real clearance/obligor cert** (genuine SdI *ricevuta* / India IRP IRN): still + stand-in; needs a design partner. The inner-signature **verification** path exists + (admission, openssl) and a proof_a-style in-circuit verify is the remaining step. + +Note: anchors here are now **role-bound** (`Poseidon2([canonical_id, salt, role])`); the +obligor anchor is `0x276218a3…`, superseding the earlier pre-role `0x015dac17…`. + +## Reproduce P1.next +``` +. venv/bin/activate # c2pie + pypdf +cd admission && ./admission.sh # regenerates certs + obligor signature +# regenerate an RSA CA->leaf chain (rsa_leaf.key + rsa_chain.pem), then: +python3 ../p1_next.py rsa_leaf.key rsa_chain.pem +``` +(Private keys are not committed — the scripts regenerate them.) diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/admission.sh b/experiments/conoir-spike/quorum/p1_provenance/admission/admission.sh new file mode 100755 index 0000000..2feac2a --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/admission.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Apertrue Quorum -- Layer 2 ADMISSION (off-MPC acceptable-anchor allow-list). +# +# Decides whether a receivable's authenticity signature is acceptable, and if so assigns a +# SIGNER ROLE and emits the role-bound anchor that the MPC circuit binds to. +# +# trust list (allow-list of roots): SdI root -> role 1 (clearance) +# Obligor root -> role 2 (debtor confirmation) +# NOT trusted: Seller root -> rejected (the fraudster cannot self-vouch) +# +# A submission is admitted only if: (a) the inner signature verifies over the CANONICAL_ID +# (so the signer vouched for exactly debtor|amount|id, the value the membership check keys +# on), AND (b) the signer's cert chains to a trusted root. The role comes from which root. +# +# The fabrication catch lives HERE: the obligor signs only the TRUE canonical_id; there is +# no obligor signature over an inflated/fictitious canonical_id, so an inflated invoice can +# never be admitted as role 2. (Clearance, role 1, will admit it -- it only proves "cleared".) +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +CERTS="$DIR/certs"; mkdir -p "$CERTS" +COMMIT="$HOME/apertrue/circuits/experiments/conoir-spike/quorum/commit_receivable" +NARGO="$HOME/.nargo/bin/nargo" + +CID_TRUE=06cc60c66ce6389ea53783b55dc5ff1b480e9a43ecf4c942262f5d73d7a87280 # amount 500 +CID_INFL=015e98c898078299bf9ed776c7855548a0207ec782c4b245d222fbdcd358bb03 # amount 5000 (inflated) + +mkroot() { # name + openssl ecparam -name prime256v1 -genkey -noout -out "$CERTS/$1.key" 2>/dev/null + openssl req -new -x509 -key "$CERTS/$1.key" -out "$CERTS/$1.pem" -days 3650 \ + -subj "/CN=$1/O=Apertrue Quorum STANDIN" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null +} +mkleaf() { # name rootname + openssl ecparam -name prime256v1 -genkey -noout -out "$CERTS/$1.key" 2>/dev/null + openssl req -new -key "$CERTS/$1.key" -out "$CERTS/$1.csr" -subj "/CN=$1/O=Apertrue Quorum STANDIN" 2>/dev/null + printf "keyUsage=critical,digitalSignature\nextendedKeyUsage=emailProtection\n" > "$CERTS/$1.ext" + openssl x509 -req -in "$CERTS/$1.csr" -CA "$CERTS/$2.pem" -CAkey "$CERTS/$2.key" -CAcreateserial \ + -out "$CERTS/$1.pem" -days 3650 -extfile "$CERTS/$1.ext" 2>/dev/null + openssl x509 -in "$CERTS/$1.pem" -pubkey -noout > "$CERTS/$1.pub" 2>/dev/null +} +sign_cid() { # leafname cidhex outname -- signer vouches for the canonical_id + printf "$2" | xxd -r -p > "$CERTS/$3.cid" + openssl dgst -sha256 -sign "$CERTS/$1.key" -out "$CERTS/$3.sig" "$CERTS/$3.cid" 2>/dev/null +} + +echo "### generate trust roots + signer leaves (one-time) ###" +mkroot sdi_root; mkroot obligor_root; mkroot seller_root +mkleaf sdi_leaf sdi_root +mkleaf obligor_leaf obligor_root +mkleaf seller_leaf seller_root +cat "$CERTS/sdi_root.pem" "$CERTS/obligor_root.pem" > "$CERTS/trust_store.pem" # allow-list + +echo "### produce signatures (who vouches for what) ###" +sign_cid sdi_leaf $CID_TRUE sdi_true # SdI clears the true invoice +sign_cid sdi_leaf $CID_INFL sdi_infl # SdI also clears the INFLATED invoice (it only clears) +sign_cid obligor_leaf $CID_TRUE obligor_true # debtor confirms ONLY the true debt +sign_cid seller_leaf $CID_TRUE seller_true # seller self-attests (not acceptable) +# NOTE: there is deliberately NO obligor signature over CID_INFL -- the debtor won't sign a lie. + +SDI_SUBJ=$(openssl x509 -in "$CERTS/sdi_root.pem" -noout -subject 2>/dev/null | sed 's/^subject=//') +OBL_SUBJ=$(openssl x509 -in "$CERTS/obligor_root.pem" -noout -subject 2>/dev/null | sed 's/^subject=//') + +admit() { # label leafname cidhex tag + local label="$1" leaf="$2" cid="$3" tag="$4" + printf "$cid" | xxd -r -p > "$CERTS/_chk.cid" + # (a) inner signature over the canonical_id? + if ! openssl dgst -sha256 -verify "$CERTS/$leaf.pub" -signature "$CERTS/$tag.sig" "$CERTS/_chk.cid" >/dev/null 2>&1; then + printf "%-34s -> REJECTED (no valid signature over this canonical_id)\n" "$label"; return; fi + # (b) does the signer chain to a trusted root (allow-list)? + if ! openssl verify -CAfile "$CERTS/trust_store.pem" "$CERTS/$leaf.pem" >/dev/null 2>&1; then + printf "%-34s -> REJECTED (signer not in acceptable-anchor allow-list)\n" "$label"; return; fi + # role from issuer + local iss role guar + iss=$(openssl x509 -in "$CERTS/$leaf.pem" -noout -issuer 2>/dev/null | sed 's/^issuer=//') + if [ "$iss" = "$SDI_SUBJ" ]; then role=1; guar='cleared + uniquely identified (NOT proof of debt)'; + elif [ "$iss" = "$OBL_SUBJ" ]; then role=2; guar='the debt is GENUINELY OWED'; + else printf "%-34s -> REJECTED (unknown issuer)\n" "$label"; return; fi + # emit role-bound anchor + printf "%-34s -> ADMITTED role=%s guarantee: %s\n" "$label" "$role" "$guar" +} + +echo +echo "### ADMISSION DECISIONS ###" +admit "obligor: true receivable" obligor_leaf $CID_TRUE obligor_true +admit "SdI: true receivable" sdi_leaf $CID_TRUE sdi_true +admit "SdI: INFLATED (seller cleared)" sdi_leaf $CID_INFL sdi_infl +admit "obligor: INFLATED (fabrication)" obligor_leaf $CID_INFL sdi_infl # no obligor sig exists -> rejected +admit "seller self-signed" seller_leaf $CID_TRUE seller_true diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/_chk.cid b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/_chk.cid new file mode 100644 index 0000000..c2cf81e --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/_chk.cid @@ -0,0 +1 @@ +`l87]HCB&/]sרr \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.csr b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.csr new file mode 100644 index 0000000..d4c462e --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.csr @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIHzMIGbAgEAMDkxFTATBgNVBAMMDG9ibGlnb3JfbGVhZjEgMB4GA1UECgwXQXBl +cnRydWUgUXVvcnVtIFNUQU5ESU4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARj +DLk/Hg5vuz0+rAwQKZmsr6DOMG40MyJst4UhJrbXcV3HF/9qg2ixpaTp/hUki6kC +UOQzDsmVxUmTRewSZ9BKoAAwCgYIKoZIzj0EAwIDRwAwRAIgFiLb3riRirsURKJk +/sHr21SzYE1EwLgA7CkEy7AdJ20CIF5DhwcEMe+mTodcYSijm/BUiPBCdk/+tE1q +PqFy9skz +-----END CERTIFICATE REQUEST----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.ext b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.ext new file mode 100644 index 0000000..7e63ec8 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.ext @@ -0,0 +1,2 @@ +keyUsage=critical,digitalSignature +extendedKeyUsage=emailProtection diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.pem b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.pem new file mode 100644 index 0000000..4bcae4a --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB2jCCAYGgAwIBAgIUHdw6reRhOMG+k99eAgAEPuIDSnkwCgYIKoZIzj0EAwIw +OTEVMBMGA1UEAwwMb2JsaWdvcl9yb290MSAwHgYDVQQKDBdBcGVydHJ1ZSBRdW9y +dW0gU1RBTkRJTjAeFw0yNjA2MjYxMjQ4MzVaFw0zNjA2MjMxMjQ4MzVaMDkxFTAT +BgNVBAMMDG9ibGlnb3JfbGVhZjEgMB4GA1UECgwXQXBlcnRydWUgUXVvcnVtIFNU +QU5ESU4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARjDLk/Hg5vuz0+rAwQKZms +r6DOMG40MyJst4UhJrbXcV3HF/9qg2ixpaTp/hUki6kCUOQzDsmVxUmTRewSZ9BK +o2cwZTAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwQwHQYDVR0O +BBYEFPOqHt9D0Y2Qwt2DIw4wgZ3qDaTBMB8GA1UdIwQYMBaAFII8PIvRZxlKJNqH +h4KkBkr1cRfSMAoGCCqGSM49BAMCA0cAMEQCIB46ITJPe3VyK3NHIJYHQ4XT6/mR +/y0owHp0fAJB7IUxAiBu8ONTfzaEFFBEOpHIqN335owvlOZopR3zqa6qBFOipQ== +-----END CERTIFICATE----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.pub b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.pub new file mode 100644 index 0000000..8cd97af --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_leaf.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYwy5Px4Ob7s9PqwMECmZrK+gzjBu +NDMibLeFISa213Fdxxf/aoNosaWk6f4VJIupAlDkMw7JlcVJk0XsEmfQSg== +-----END PUBLIC KEY----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_root.pem b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_root.pem new file mode 100644 index 0000000..ae4373e --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_root.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBxzCCAW2gAwIBAgIUWMjz/lFVcuZvGGuD8lN4wHzWXAcwCgYIKoZIzj0EAwIw +OTEVMBMGA1UEAwwMb2JsaWdvcl9yb290MSAwHgYDVQQKDBdBcGVydHJ1ZSBRdW9y +dW0gU1RBTkRJTjAeFw0yNjA2MjYxMjQ4MzVaFw0zNjA2MjMxMjQ4MzVaMDkxFTAT +BgNVBAMMDG9ibGlnb3Jfcm9vdDEgMB4GA1UECgwXQXBlcnRydWUgUXVvcnVtIFNU +QU5ESU4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARqfNSS+oszgwodij9T3A6X +ujK62/Wrzhy/ct/vojGoOs7lsPUcdFY3F/iy6iQwnyIpwllnSwn7a699v1b1UDL4 +o1MwUTAdBgNVHQ4EFgQUgjw8i9FnGUok2oeHgqQGSvVxF9IwHwYDVR0jBBgwFoAU +gjw8i9FnGUok2oeHgqQGSvVxF9IwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiAJCYaGfG6qHoeZfT6EsDNH7elJH05xKQpttP2lXYqlVgIhALHb26jq +72af9ilhPSp+ZE6RskbXweGPFTFSB+mP+Mb5 +-----END CERTIFICATE----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_root.srl b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_root.srl new file mode 100644 index 0000000..0f8d957 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_root.srl @@ -0,0 +1 @@ +1DDC3AADE46138C1BE93DF5E0200043EE2034A79 diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_true.cid b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_true.cid new file mode 100644 index 0000000..c2cf81e --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_true.cid @@ -0,0 +1 @@ +`l87]HCB&/]sרr \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_true.sig b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_true.sig new file mode 100644 index 0000000..f3c20ea Binary files /dev/null and b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/obligor_true.sig differ diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_infl.cid b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_infl.cid new file mode 100644 index 0000000..d56f628 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_infl.cid @@ -0,0 +1 @@ +^ȘvDžUH ~ǂIJE"X \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_infl.sig b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_infl.sig new file mode 100644 index 0000000..b321eb7 Binary files /dev/null and b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_infl.sig differ diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.csr b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.csr new file mode 100644 index 0000000..7a8c409 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.csr @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIHxMIGXAgEAMDUxETAPBgNVBAMMCHNkaV9sZWFmMSAwHgYDVQQKDBdBcGVydHJ1 +ZSBRdW9ydW0gU1RBTkRJTjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBY0uER0 +qq89DaUEVKdqKCm87RRj6YkT1u8wyY8oEMm9GDbLdNBWHxbeJciNX8DG9iIFePfu +yl8CJ/OIsIe2t2+gADAKBggqhkjOPQQDAgNJADBGAiEA7VWIZGZHE+WkjJp6OyP4 +bBc7DZKzWzeVhzTL2zTDzi8CIQCI6by3W+2v9alfvh7xwP0FdEu60kFIP7b0CwcO +HxQ01Q== +-----END CERTIFICATE REQUEST----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.ext b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.ext new file mode 100644 index 0000000..7e63ec8 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.ext @@ -0,0 +1,2 @@ +keyUsage=critical,digitalSignature +extendedKeyUsage=emailProtection diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.pem b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.pem new file mode 100644 index 0000000..d86507e --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB0jCCAXmgAwIBAgIUQKNhLaYdWrRk/s1O/q0b9qaysVQwCgYIKoZIzj0EAwIw +NTERMA8GA1UEAwwIc2RpX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBT +VEFORElOMB4XDTI2MDYyNjEyNDgzNVoXDTM2MDYyMzEyNDgzNVowNTERMA8GA1UE +AwwIc2RpX2xlYWYxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBTVEFORElOMFkw +EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFjS4RHSqrz0NpQRUp2ooKbztFGPpiRPW +7zDJjygQyb0YNst00FYfFt4lyI1fwMb2IgV49+7KXwIn84iwh7a3b6NnMGUwDgYD +VR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMEMB0GA1UdDgQWBBTqwTY2 +uGDbxLELHaVxrWSbWax4ijAfBgNVHSMEGDAWgBTahpYT+n+kRGxOc1oVcMEGAe+Q +FDAKBggqhkjOPQQDAgNHADBEAiBSfwyXdUlMvnTzzU7oTeplIAUqKIWtPZTboIau +cEfQ4wIgcDRdOjf/1wkScqiv2NsqLgHgkI4fh479R/m+bhJH/TE= +-----END CERTIFICATE----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.pub b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.pub new file mode 100644 index 0000000..61218fe --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_leaf.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFjS4RHSqrz0NpQRUp2ooKbztFGPp +iRPW7zDJjygQyb0YNst00FYfFt4lyI1fwMb2IgV49+7KXwIn84iwh7a3bw== +-----END PUBLIC KEY----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_root.pem b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_root.pem new file mode 100644 index 0000000..03e6a42 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_root.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBwDCCAWWgAwIBAgIUfSsdGSwwq0KnsBKeBPaH3gSYv40wCgYIKoZIzj0EAwIw +NTERMA8GA1UEAwwIc2RpX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBT +VEFORElOMB4XDTI2MDYyNjEyNDgzNVoXDTM2MDYyMzEyNDgzNVowNTERMA8GA1UE +AwwIc2RpX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBTVEFORElOMFkw +EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPseog0XuTPtf1+xBI5yiZdBn2sxkkJAN +qyQ/RFpNSqUkE4E/vaIU3Czj+oVF4eC7JU4go0iIuEZonoQGk7F3EaNTMFEwHQYD +VR0OBBYEFNqGlhP6f6REbE5zWhVwwQYB75AUMB8GA1UdIwQYMBaAFNqGlhP6f6RE +bE5zWhVwwQYB75AUMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIh +ALYzjT8Us6Yos9B1smyT9thw841JJ0uKLh/KYZTOXBOdAiEAynGOtBM0LsbcZeG+ +na6kCKISUZmLc7JdcP1Em0lUxg4= +-----END CERTIFICATE----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_root.srl b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_root.srl new file mode 100644 index 0000000..27fffaf --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_root.srl @@ -0,0 +1 @@ +40A3612DA61D5AB464FECD4EFEAD1BF6A6B2B154 diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_true.cid b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_true.cid new file mode 100644 index 0000000..c2cf81e --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_true.cid @@ -0,0 +1 @@ +`l87]HCB&/]sרr \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_true.sig b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_true.sig new file mode 100644 index 0000000..44e444a Binary files /dev/null and b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/sdi_true.sig differ diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.csr b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.csr new file mode 100644 index 0000000..5547969 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.csr @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIHzMIGaAgEAMDgxFDASBgNVBAMMC3NlbGxlcl9sZWFmMSAwHgYDVQQKDBdBcGVy +dHJ1ZSBRdW9ydW0gU1RBTkRJTjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDOL +9SuSKmzUDjQaxgSv7/o8mi+n41isENHnZSCOCY++UFvjM8rH37H9eV6aQpcKSskA +/Lau65jhtAftRMe6JtKgADAKBggqhkjOPQQDAgNIADBFAiAM02E3gygmJEczFWW7 +Gk9pg7JFObk1fHMb6sN0Lh+yYwIhAPQZ5M0kUxxp4pUpgND9+Sg7J1KGwnBU1iFG +IbDjqc+3 +-----END CERTIFICATE REQUEST----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.ext b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.ext new file mode 100644 index 0000000..7e63ec8 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.ext @@ -0,0 +1,2 @@ +keyUsage=critical,digitalSignature +extendedKeyUsage=emailProtection diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.pem b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.pem new file mode 100644 index 0000000..b3d9f79 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB2DCCAX+gAwIBAgIUJhkOG2L9EPCmUPW85BrQkD9zqSMwCgYIKoZIzj0EAwIw +ODEUMBIGA1UEAwwLc2VsbGVyX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1 +bSBTVEFORElOMB4XDTI2MDYyNjEyNDgzNVoXDTM2MDYyMzEyNDgzNVowODEUMBIG +A1UEAwwLc2VsbGVyX2xlYWYxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBTVEFO +RElOMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEM4v1K5IqbNQONBrGBK/v+jya +L6fjWKwQ0edlII4Jj75QW+Mzysffsf15XppClwpKyQD8tq7rmOG0B+1Ex7om0qNn +MGUwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMEMB0GA1UdDgQW +BBRv0hMh7DF93mlaJkdhKmxyrBRDHDAfBgNVHSMEGDAWgBR2hfzxTv/os7vWxjxC +F0yAku5+bTAKBggqhkjOPQQDAgNHADBEAiAZmeU/eAHID45DlIkxgJl6TcDg5ueW +XSFpQbAPgmYKGgIgHqD7HmEXCZVeKcG4NXGCqvaAVKVDb39vJGEeRExa/BA= +-----END CERTIFICATE----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.pub b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.pub new file mode 100644 index 0000000..01d455b --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_leaf.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEM4v1K5IqbNQONBrGBK/v+jyaL6fj +WKwQ0edlII4Jj75QW+Mzysffsf15XppClwpKyQD8tq7rmOG0B+1Ex7om0g== +-----END PUBLIC KEY----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_root.pem b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_root.pem new file mode 100644 index 0000000..d94db26 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_root.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBxTCCAWugAwIBAgIUPydNM/JdeWq9llFGhM0IUwyvR2kwCgYIKoZIzj0EAwIw +ODEUMBIGA1UEAwwLc2VsbGVyX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1 +bSBTVEFORElOMB4XDTI2MDYyNjEyNDgzNVoXDTM2MDYyMzEyNDgzNVowODEUMBIG +A1UEAwwLc2VsbGVyX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBTVEFO +RElOMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECJ5WxfVBeTbWM34wAyo+0eM9 +WEOLm48xsCq4dPY/6FWkli8y5VhbotuNyYNsYthM3felPcR0W3yX+R64HKaSY6NT +MFEwHQYDVR0OBBYEFHaF/PFO/+izu9bGPEIXTICS7n5tMB8GA1UdIwQYMBaAFHaF +/PFO/+izu9bGPEIXTICS7n5tMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwID +SAAwRQIhALpUBbOc0dooGxDwaRdDF2hQDRkEobwpT54RTLotiQGzAiAjEn/6MTM0 +WNf1YJIKE7TmGBrsHa1wRKCOymQ229sFUw== +-----END CERTIFICATE----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_root.srl b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_root.srl new file mode 100644 index 0000000..8b49687 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_root.srl @@ -0,0 +1 @@ +26190E1B62FD10F0A650F5BCE41AD0903F73A923 diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_true.cid b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_true.cid new file mode 100644 index 0000000..c2cf81e --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_true.cid @@ -0,0 +1 @@ +`l87]HCB&/]sרr \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_true.sig b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_true.sig new file mode 100644 index 0000000..42c15b7 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/seller_true.sig @@ -0,0 +1 @@ +0D ~,˲TVW&`rFVm+ SCLj 5EɲY“PϤpKXڛ:n{ \ No newline at end of file diff --git a/experiments/conoir-spike/quorum/p1_provenance/admission/certs/trust_store.pem b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/trust_store.pem new file mode 100644 index 0000000..19910f8 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/admission/certs/trust_store.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIBwDCCAWWgAwIBAgIUfSsdGSwwq0KnsBKeBPaH3gSYv40wCgYIKoZIzj0EAwIw +NTERMA8GA1UEAwwIc2RpX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBT +VEFORElOMB4XDTI2MDYyNjEyNDgzNVoXDTM2MDYyMzEyNDgzNVowNTERMA8GA1UE +AwwIc2RpX3Jvb3QxIDAeBgNVBAoMF0FwZXJ0cnVlIFF1b3J1bSBTVEFORElOMFkw +EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPseog0XuTPtf1+xBI5yiZdBn2sxkkJAN +qyQ/RFpNSqUkE4E/vaIU3Czj+oVF4eC7JU4go0iIuEZonoQGk7F3EaNTMFEwHQYD +VR0OBBYEFNqGlhP6f6REbE5zWhVwwQYB75AUMB8GA1UdIwQYMBaAFNqGlhP6f6RE +bE5zWhVwwQYB75AUMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIh +ALYzjT8Us6Yos9B1smyT9thw841JJ0uKLh/KYZTOXBOdAiEAynGOtBM0LsbcZeG+ +na6kCKISUZmLc7JdcP1Em0lUxg4= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBxzCCAW2gAwIBAgIUWMjz/lFVcuZvGGuD8lN4wHzWXAcwCgYIKoZIzj0EAwIw +OTEVMBMGA1UEAwwMb2JsaWdvcl9yb290MSAwHgYDVQQKDBdBcGVydHJ1ZSBRdW9y +dW0gU1RBTkRJTjAeFw0yNjA2MjYxMjQ4MzVaFw0zNjA2MjMxMjQ4MzVaMDkxFTAT +BgNVBAMMDG9ibGlnb3Jfcm9vdDEgMB4GA1UECgwXQXBlcnRydWUgUXVvcnVtIFNU +QU5ESU4wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARqfNSS+oszgwodij9T3A6X +ujK62/Wrzhy/ct/vojGoOs7lsPUcdFY3F/iy6iQwnyIpwllnSwn7a699v1b1UDL4 +o1MwUTAdBgNVHQ4EFgQUgjw8i9FnGUok2oeHgqQGSvVxF9IwHwYDVR0jBBgwFoAU +gjw8i9FnGUok2oeHgqQGSvVxF9IwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiAJCYaGfG6qHoeZfT6EsDNH7elJH05xKQpttP2lXYqlVgIhALHb26jq +72af9ilhPSp+ZE6RskbXweGPFTFSB+mP+Mb5 +-----END CERTIFICATE----- diff --git a/experiments/conoir-spike/quorum/p1_provenance/c2pie_schema.json b/experiments/conoir-spike/quorum/p1_provenance/c2pie_schema.json new file mode 100644 index 0000000..1ebc85a --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/c2pie_schema.json @@ -0,0 +1,7 @@ +{ + "@context": "https://schema.org", + "@type": "CreativeWork", + "author": [{"@type": "Organization", "name": "IT-SdI (Agenzia delle Entrate) STAND-IN"}], + "copyrightYear": "2014", + "copyrightHolder": "SOCIETA' ALPHA SRL / FatturaPA no.123" +} diff --git a/experiments/conoir-spike/quorum/p1_provenance/invoice.pdf b/experiments/conoir-spike/quorum/p1_provenance/invoice.pdf new file mode 100644 index 0000000..5b26f01 Binary files /dev/null and b/experiments/conoir-spike/quorum/p1_provenance/invoice.pdf differ diff --git a/experiments/conoir-spike/quorum/p1_provenance/invoice_c2pa.pdf b/experiments/conoir-spike/quorum/p1_provenance/invoice_c2pa.pdf new file mode 100644 index 0000000..a5fce95 Binary files /dev/null and b/experiments/conoir-spike/quorum/p1_provenance/invoice_c2pa.pdf differ diff --git a/experiments/conoir-spike/quorum/p1_provenance/invoice_quorum.pdf b/experiments/conoir-spike/quorum/p1_provenance/invoice_quorum.pdf new file mode 100644 index 0000000..2093599 Binary files /dev/null and b/experiments/conoir-spike/quorum/p1_provenance/invoice_quorum.pdf differ diff --git a/experiments/conoir-spike/quorum/p1_provenance/invoice_with_xml.pdf b/experiments/conoir-spike/quorum/p1_provenance/invoice_with_xml.pdf new file mode 100644 index 0000000..9a3acec Binary files /dev/null and b/experiments/conoir-spike/quorum/p1_provenance/invoice_with_xml.pdf differ diff --git a/experiments/conoir-spike/quorum/p1_provenance/manifest_receivable.c2patool.json b/experiments/conoir-spike/quorum/p1_provenance/manifest_receivable.c2patool.json new file mode 100644 index 0000000..a85feec --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/manifest_receivable.c2patool.json @@ -0,0 +1,23 @@ +{ + "alg": "es256", + "claim_generator": "Apertrue_Quorum/0.1", + "title": "FatturaPA IT01234567890 no.123 (SdI-cleared)", + "assertions": [ + { + "label": "org.apertrue.quorum.receivable", + "data": { + "scheme": "FatturaPA", + "clearance_authority": "IT-SdI (Agenzia delle Entrate) [STAND-IN CERT]", + "supplier_vat": "IT01234567890", + "debtor_cf": "09876543210", + "invoice_number": "123", + "issue_date": "2014-12-18", + "amount_cents": 500, + "currency": "EUR", + "canonical_id": "0x06cc60c66ce6389ea53783b55dc5ff1b480e9a43ecf4c942262f5d73d7a87280", + "anchor": "0x015dac177e4952c70e1161e5478ab76817250fa1d521e258e505cfea9c1648a3", + "clearance_signature_es256_b64": "MEUCIE8Lt17Ckbq46L+W+P8tGxedIAyw1XUHHTttK7klv3BhAiEAuWe80/n/W8ON91DUrwCdKnCkqoswXneGKrPnZrHJHkg=" + } + } + ] +} diff --git a/experiments/conoir-spike/quorum/p1_provenance/p1_next.py b/experiments/conoir-spike/quorum/p1_provenance/p1_next.py new file mode 100644 index 0000000..820e704 --- /dev/null +++ b/experiments/conoir-spike/quorum/p1_provenance/p1_next.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Apertrue Quorum P1.next -- a REAL C2PA-signed invoice PDF that: + 1. embeds the authoritative FatturaPA XML inside the PDF (so it travels under the binding), + 2. carries a custom `org.apertrue.quorum.receivable` assertion with the canonical fields, + the role-bound anchor, and the inner non-fraudster (obligor) signature, and + 3. has a c2pa.hash.data hard binding over the whole PDF (including the embedded XML). + +Run inside the c2pie venv. Inputs (real, from earlier steps): + - invoice.pdf (rendered invoice) + - IT01234567890_FPR01.xml (real FatturaPA source) + - admission/certs/obligor_true.sig (real ES256 obligor signature over canonical_id) + - rsa_leaf.key / rsa_chain.pem (C2PA manifest signer; stand-in) +""" +import base64, hashlib, json, os, sys +from pypdf import PdfReader, PdfWriter + +from c2pie.interface import ( + c2pie_GenerateAssertion, c2pie_GenerateHashDataAssertion, + c2pie_GenerateManifest, c2pie_EmplaceManifest, +) +from c2pie.utils.assertion_schemas import C2PA_AssertionTypes, json_to_bytes +from c2pie.utils.content_types import C2PA_ContentTypes, jumbf_content_types +from c2pie.jumbf_boxes.super_box import SuperBox +from c2pie.jumbf_boxes.content_box import ContentBox + +HERE = os.path.dirname(os.path.abspath(__file__)) +SC = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(HERE))))) # unused +ADM = os.path.join(HERE, "admission", "certs") + +# real values from earlier steps (obligor variant, true receivable) +CANONICAL_ID = "0x06cc60c66ce6389ea53783b55dc5ff1b480e9a43ecf4c942262f5d73d7a87280" +ANCHOR = "0x276218a3095f1f2a85ee95ceeece1963fb83fbbc4da888208d85b2b3013822e5" +SIGNER_ROLE = 2 # obligor / debtor confirmation + +class CustomJsonAssertion(SuperBox): + """A C2PA assertion with an arbitrary label + JSON payload (c2pie only ships 3 enum types).""" + def __init__(self, label: str, schema: dict): + self.type = C2PA_AssertionTypes.creative_work # any value != data_hash + self.schema = schema + content_box = ContentBox(box_type=b"json".hex(), payload=json_to_bytes(schema)) + super().__init__(content_type=jumbf_content_types["json"], label=label, content_boxes=[content_box]) + def get_data_for_signing(self) -> bytes: + return self.description_box.serialize() + self.serialize_content_boxes() + +def main(): + invoice_pdf = os.path.join(HERE, "invoice.pdf") + xml_path = os.path.join(HERE, "IT01234567890_FPR01.xml") + key_path = sys.argv[1] # rsa_leaf.key + chain_path = sys.argv[2] # rsa_chain.pem + out_path = os.path.join(HERE, "invoice_quorum.pdf") + + # --- 1) embed the authoritative FatturaPA XML INSIDE the PDF --- + xml_bytes = open(xml_path, "rb").read() + r = PdfReader(invoice_pdf); w = PdfWriter(); w.append(r) + w.add_attachment("IT01234567890_FPR01.xml", xml_bytes) + tmp = os.path.join(HERE, "invoice_with_xml.pdf") + with open(tmp, "wb") as f: w.write(f) + raw = open(tmp, "rb").read() + print("embedded XML -> PDF: %d bytes (orig invoice %d, xml %d)" % (len(raw), os.path.getsize(invoice_pdf), len(xml_bytes))) + + # --- 2) custom receivable assertion carrying fields + role-bound anchor + inner signature --- + obligor_sig_b64 = base64.b64encode(open(os.path.join(ADM, "obligor_true.sig"), "rb").read()).decode() + receivable = { + "scheme": "FatturaPA", + "signer_role": SIGNER_ROLE, + "signer_role_name": "obligor/debtor confirmation", + "guarantee": "the debt is genuinely owed", + "non_fraudster_signer": "DITTA BETA (debtor) [STAND-IN CERT]", + "supplier_vat": "IT01234567890", + "debtor_cf": "09876543210", + "invoice_number": "123", + "issue_date": "2014-12-18", + "amount_cents": 500, + "currency": "EUR", + "canonical_id": CANONICAL_ID, + "anchor": ANCHOR, + "inner_signature_es256_over_canonical_id_b64": obligor_sig_b64, + "source_document": "IT01234567890_FPR01.xml (embedded in this PDF)", + } + receivable_assertion = CustomJsonAssertion("org.apertrue.quorum.receivable", receivable) + + # carry the AUTHORITATIVE source XML inside the signed manifest itself, so it travels with + # the C2PA (bound by the claim signature, extractable regardless of PDF attachment quirks). + source_doc = { + "format": "application/xml (FatturaPA)", + "filename": "IT01234567890_FPR01.xml", + "sha256": hashlib.sha256(xml_bytes).hexdigest(), + "bytes_b64": base64.b64encode(xml_bytes).decode(), + } + source_assertion = CustomJsonAssertion("org.apertrue.quorum.source_document", source_doc) + creative_work = c2pie_GenerateAssertion(C2PA_AssertionTypes.creative_work, { + "@context": "https://schema.org", "@type": "CreativeWork", + "author": [{"@type": "Organization", "name": "IT-SdI / debtor confirmation STAND-IN"}], + "copyrightYear": "2014", "copyrightHolder": "FatturaPA no.123", + }) + + # --- 3) hard binding over the WHOLE pdf (incl. embedded XML); manifest appended at EOF --- + cai_offset = len(raw) + hash_data = c2pie_GenerateHashDataAssertion(cai_offset=cai_offset, hashed_data=hashlib.sha256(raw).digest()) + + manifest = c2pie_GenerateManifest( + assertions=[receivable_assertion, source_assertion, creative_work, hash_data], + private_key=open(key_path, "rb").read(), + certificate_chain=open(chain_path, "rb").read(), + ) + signed = c2pie_EmplaceManifest(C2PA_ContentTypes.pdf, raw, cai_offset, manifest) + with open(out_path, "wb") as f: f.write(signed) + print("signed C2PA PDF -> %s (%d bytes)" % (out_path, len(signed))) + +if __name__ == "__main__": + main() diff --git a/experiments/conoir-spike/quorum/proof_a_receivable/Nargo.toml b/experiments/conoir-spike/quorum/proof_a_receivable/Nargo.toml new file mode 100644 index 0000000..942c493 --- /dev/null +++ b/experiments/conoir-spike/quorum/proof_a_receivable/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "proof_a_receivable" +type = "bin" +authors = ["Apertrue"] +compiler_version = ">=1.0.0" +description = "Quorum Proof A: in-circuit verification that a trusted, role-bound signer signed canonical_id; emits the role-stamped authenticity anchor." + +[dependencies] +poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } diff --git a/experiments/conoir-spike/quorum/proof_a_receivable/Prover.toml b/experiments/conoir-spike/quorum/proof_a_receivable/Prover.toml new file mode 100644 index 0000000..af561e9 --- /dev/null +++ b/experiments/conoir-spike/quorum/proof_a_receivable/Prover.toml @@ -0,0 +1,13 @@ +trust_list_root = "0x13eb3c8373e7337dd54fcd50935dde6b5a2269b9b1d0de5b277174a3d7c1fed1" +signer_role = "2" +invoice_uuid = "7001234000042" +debtor_id = "74031100" +amount = "25000" +issue_date = "20240315" +salt = "42" +signer_pubkey_x = [118, 30, 224, 102, 155, 108, 46, 96, 35, 198, 166, 178, 25, 180, 27, 216, 240, 64, 196, 153, 145, 140, 214, 216, 98, 231, 196, 128, 219, 233, 217, 102] +signer_pubkey_y = [90, 109, 25, 193, 124, 211, 44, 180, 7, 38, 154, 58, 185, 254, 127, 175, 20, 153, 246, 236, 171, 71, 33, 56, 255, 210, 243, 76, 125, 122, 173, 32] +signature = [61, 17, 95, 117, 81, 75, 66, 152, 32, 140, 81, 150, 199, 115, 60, 143, 170, 225, 36, 183, 118, 223, 194, 4, 226, 161, 77, 154, 35, 179, 239, 76, 114, 64, 251, 219, 186, 150, 60, 34, 51, 101, 172, 8, 103, 207, 149, 84, 50, 142, 0, 255, 216, 243, 180, 9, 119, 63, 175, 221, 207, 177, 200, 156] +canonical_id_bytes = [32, 173, 204, 205, 214, 226, 233, 75, 74, 120, 162, 203, 164, 130, 194, 135, 179, 124, 60, 132, 2, 4, 249, 65, 45, 91, 170, 28, 219, 17, 164, 35] +merkle_path = ["0","0","0","0","0","0","0","0"] +merkle_indices = ["0","0","0","0","0","0","0","0"] diff --git a/experiments/conoir-spike/quorum/proof_a_receivable/run_proof_a.sh b/experiments/conoir-spike/quorum/proof_a_receivable/run_proof_a.sh new file mode 100755 index 0000000..1d1e3bf --- /dev/null +++ b/experiments/conoir-spike/quorum/proof_a_receivable/run_proof_a.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# run_proof_a.sh -- end-to-end test harness for the `proof_a_receivable` Noir circuit. +# +# Proves Proof A END-TO-END with a real OpenSSL ECDSA P-256 signature, runs two +# negative tests, and demonstrates that Proof A's anchor output feeds `bound_receivables`. +# +# Idempotent: regenerates a fresh P-256 key and recomputes the trust-list root each run. +# (The anchor is independent of the key -- it depends only on canonical_id, salt, role -- +# so the "executed anchor == expected anchor" check is stable across runs.) +# +# Requires: nargo (1.0.0-beta.20) at ~/.nargo/bin/nargo, openssl, python3. +set -euo pipefail + +NARGO="${NARGO:-$HOME/.nargo/bin/nargo}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .../quorum/proof_a_receivable +QUORUM="$(cd "$HERE/.." && pwd)" +WD="$(mktemp -d)" +trap 'rm -rf "$WD"' EXIT + +# Fixed receivable + role used throughout. +INVOICE_UUID=1234567890000123 +DEBTOR_ID=9876543210 +AMOUNT=500 +ISSUE_DATE=20141218 +SALT=42 +SIGNER_ROLE=2 + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +hr() { printf '\n==== %s ====\n' "$*"; } + +# --------------------------------------------------------------------------- +hr "STEP 1/2: commit_receivable -> canonical_id, expected anchor" +cat > "$QUORUM/commit_receivable/Prover.toml" <&1 | grep "Circuit output") +# Circuit output: [0x..canonical_id.., 0x..anchor..] +CANONICAL_ID=$(echo "$CR_OUT" | sed -E 's/.*\[(0x[0-9a-f]+), (0x[0-9a-f]+)\].*/\1/') +EXPECTED_ANCHOR=$(echo "$CR_OUT" | sed -E 's/.*\[(0x[0-9a-f]+), (0x[0-9a-f]+)\].*/\2/') +echo "canonical_id = $CANONICAL_ID" +echo "expected anchor = $EXPECTED_ANCHOR" + +# --------------------------------------------------------------------------- +hr "STEP 3/4: P-256 key, pubkey x/y, raw-ECDSA sign of canonical_id digest" +# canonical_id -> 32 big-endian bytes (the signed digest) +CIDHEX=${CANONICAL_ID#0x} +CIDHEX=$(printf '%064s' "$CIDHEX" | tr ' ' '0') +python3 -c "import binascii;open('$WD/cid.bin','wb').write(binascii.unhexlify('$CIDHEX'))" + +openssl ecparam -name prime256v1 -genkey -noout -out "$WD/key.pem" +# raw ECDSA over the 32-byte digest (pkeyutl -sign treats EC input as the digest) +openssl pkeyutl -sign -inkey "$WD/key.pem" -in "$WD/cid.bin" -out "$WD/sig.der" +openssl ec -in "$WD/key.pem" -pubout -conv_form uncompressed -outform DER -out "$WD/pub.der" 2>/dev/null + +python3 - "$WD" <<'PY' +import sys +WD=sys.argv[1] +pub=open(WD+'/pub.der','rb').read() +point=pub[-65:]; assert point[0]==4, "not uncompressed point" +x=point[1:33]; y=point[33:65] +der=open(WD+'/sig.der','rb').read(); assert der[0]==0x30 +def read_int(b,i): + assert b[i]==0x02; ln=b[i+1]; return b[i+2:i+2+ln], i+2+ln +r,i=read_int(der,2); s,_=read_int(der,i) +# Noir's ecdsa_secp256r1::verify_signature requires LOW-S (canonical) form; +# OpenSSL does not enforce it, so normalise s -> min(s, n-s). +N=0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 +sv=int.from_bytes(s,'big') +if sv > N//2: sv = N - sv +s=sv.to_bytes(32,'big') +r=r.lstrip(b'\x00').rjust(32,b'\x00'); s=s.rjust(32,b'\x00') +sig=r+s +arr=lambda bb:"["+", ".join(str(c) for c in bb)+"]" +open(WD+'/x.txt','w').write(arr(x)) +open(WD+'/y.txt','w').write(arr(y)) +open(WD+'/sig.txt','w').write(arr(sig)) +open(WD+'/cidbytes.txt','w').write(arr(open(WD+'/cid.bin','rb').read())) +PY +X=$(cat "$WD/x.txt"); Y=$(cat "$WD/y.txt"); SIG=$(cat "$WD/sig.txt"); CIDB=$(cat "$WD/cidbytes.txt") + +# --------------------------------------------------------------------------- +hr "STEP 5: _mkroot -> trust_list_root (siblings=0, indices=0, leftmost leaf)" +cat > "$QUORUM/_mkroot/Prover.toml" <&1 | grep "Circuit output" | sed -E 's/.*(0x[0-9a-f]+).*/\1/') +echo "trust_list_root = $ROOT" + +# --------------------------------------------------------------------------- +hr "STEP 6: proof_a_receivable Prover.toml + execute" +cat > "$HERE/Prover.toml" <&1 | grep "Circuit output" | sed -E 's/.*(0x[0-9a-f]+).*/\1/') +echo "proven anchor = $PROVEN_ANCHOR" +if [ "$PROVEN_ANCHOR" = "$EXPECTED_ANCHOR" ]; then + green "PASS: proven anchor == expected anchor" +else + red "FAIL: anchor mismatch"; exit 1 +fi + +cp "$HERE/Prover.toml" "$WD/good.toml" + +# --------------------------------------------------------------------------- +hr "STEP 7a: NEGATIVE -- flip one signature byte (expect ECDSA failure)" +SIGBAD=$(echo "$SIG" | sed -E 's/^\[([0-9]+),/["bad",/' ; true) +# bump first signature byte by 1 (mod 256) to keep it a valid u8 +FIRST=$(echo "$SIG" | sed -E 's/^\[([0-9]+),.*/\1/') +NEWFIRST=$(( (FIRST + 1) % 256 )) +sed -E "s/^signature = \[$FIRST,/signature = [$NEWFIRST,/" "$WD/good.toml" > "$HERE/Prover.toml" +if OUT=$("$NARGO" execute --program-dir "$HERE" 2>&1); then + red "FAIL: expected ECDSA assertion failure but execute succeeded"; exit 1 +else + echo "$OUT" | grep -iE "Assertion failed: ECDSA" && green "PASS: ECDSA signature assertion fired" +fi + +# --------------------------------------------------------------------------- +hr "STEP 7b: NEGATIVE -- signer_role=1 (leaf built for role 2; expect trust-list failure)" +sed -E 's/^signer_role = "2"/signer_role = "1"/' "$WD/good.toml" > "$HERE/Prover.toml" +if OUT=$("$NARGO" execute --program-dir "$HERE" 2>&1); then + red "FAIL: expected trust-list assertion failure but execute succeeded"; exit 1 +else + echo "$OUT" | grep -iE "Assertion failed: signer \(key, role\) not in trust list" \ + && green "PASS: trust-list (key,role) assertion fired" +fi + +# restore the good Prover.toml +cp "$WD/good.toml" "$HERE/Prover.toml" + +# --------------------------------------------------------------------------- +hr "STEP 8: HANDOFF -- proven anchor feeds bound_receivables" +br_run () { # $1 = financed array contents, $2 = label, $3 = expected bool + cat > "$QUORUM/bound_receivables/Prover.toml" <&1 | grep "Circuit output") + echo " $2 -> $OUT" + echo "$OUT" | grep -q "($3," && green " PASS: already_financed=$3 (anchor opened)" \ + || { red " FAIL: expected already_financed=$3"; exit 1; } +} +br_run '"0x01","0x02","0x03","0x04","0x05","0x06"' "clean (cid NOT financed)" "false" +br_run "\"0x01\",\"0x02\",\"$CANONICAL_ID\",\"0x04\",\"0x05\",\"0x06\"" "double (cid IS financed) " "true" + +hr "ALL CHECKS PASSED" diff --git a/experiments/conoir-spike/quorum/proof_a_receivable/src/main.nr b/experiments/conoir-spike/quorum/proof_a_receivable/src/main.nr new file mode 100644 index 0000000..1bd8145 --- /dev/null +++ b/experiments/conoir-spike/quorum/proof_a_receivable/src/main.nr @@ -0,0 +1,98 @@ +// proof_a_receivable -- Apertrue Quorum "Proof A" (per-party, single-prover, LOCAL). +// +// Replaces the off-circuit OpenSSL admission step with an in-circuit binding: +// it proves that an AUTHORISED, ROLE-BOUND signer put an ES256 signature on the +// receivable's canonical_id, and emits the role-stamped authenticity anchor as a +// PUBLIC output. That anchor becomes `candidate_anchor` for the joint +// `bound_receivables` MPC circuit -- so the one-bit answer provably rests on a +// real signer, not an asserted role. +// +// canonical_id = Poseidon2([invoice_uuid, debtor_id, amount, issue_date], 4) +// anchor = Poseidon2([canonical_id, salt, signer_role], 3) +// +// The trust list binds (signer_key, role): a leaf is Poseidon2([x, y, role]), so a +// signer can only claim the role its authorised entry encodes (SdI root -> role 1, +// obligor root -> role 2). This closes "claim role 2 without being the obligor". +// +// Signature: ECDSA P-256 over the 32-byte big-endian encoding of canonical_id used +// directly as the digest (raw ECDSA; canonical_id is already a Poseidon2 commitment, +// so no extra SHA-256 wrapper is needed). The signer produces a raw ECDSA signature +// over be32(canonical_id) (e.g. `openssl pkeyutl -sign` over the 32-byte value). + +use poseidon::poseidon2::Poseidon2; +use std::ecdsa_secp256r1::verify_signature; + +global MERKLE_DEPTH: u32 = 8; + +fn canonical_id(uuid: Field, debtor: Field, amount: Field, date: Field) -> Field { + Poseidon2::hash([uuid, debtor, amount, date], 4) +} + +fn anchor(cid: Field, salt: Field, signer_role: Field) -> Field { + Poseidon2::hash([cid, salt, signer_role], 3) +} + +// Interpret 32 big-endian bytes as a Field (reduces mod the BN254 prime, like proof_a). +fn be_bytes_to_field(b: [u8; 32]) -> Field { + let mut acc: Field = 0; + for i in 0..32 { + acc = acc * 256 + b[i] as Field; + } + acc +} + +// Depth-8 Poseidon2 Merkle root. indices[i] == 0 => current node is the LEFT child. +fn compute_merkle_root( + leaf: Field, + path: [Field; MERKLE_DEPTH], + indices: [Field; MERKLE_DEPTH], +) -> Field { + let mut current = leaf; + for i in 0..MERKLE_DEPTH { + let is_left = indices[i] == 0; + let left = if is_left { current } else { path[i] }; + let right = if is_left { path[i] } else { current }; + current = Poseidon2::hash([left, right], 2); + } + current +} + +fn main( + // --- public --- + trust_list_root: pub Field, // Quorum trust list (authorised signer (key,role) leaves) + signer_role: pub Field, // 1 = SdI clearance, 2 = obligor/debtor confirmation + // --- private: the receivable --- + invoice_uuid: Field, + debtor_id: Field, + amount: Field, + issue_date: Field, + salt: Field, + // --- private: the signer's ES256 signature over canonical_id --- + signer_pubkey_x: [u8; 32], + signer_pubkey_y: [u8; 32], + signature: [u8; 64], + canonical_id_bytes: [u8; 32], // big-endian encoding of canonical_id (the signed message) + // --- private: trust-list membership of (key, role) --- + merkle_path: [Field; MERKLE_DEPTH], + merkle_indices: [Field; MERKLE_DEPTH], +) -> pub Field { + // 1. recompute canonical_id from the receivable fields + let cid = canonical_id(invoice_uuid, debtor_id, amount, issue_date); + + // 2. the witnessed bytes must encode exactly that canonical_id (big-endian) + assert(be_bytes_to_field(canonical_id_bytes) == cid, "canonical_id bytes != canonical_id"); + + // 3. verify the signer's ECDSA P-256 signature over canonical_id (used as the digest) + let sig_ok = verify_signature(signer_pubkey_x, signer_pubkey_y, signature, canonical_id_bytes); + assert(sig_ok, "ECDSA P-256 signature over canonical_id failed"); + + // 4. the (signer_key, role) pair must be an authorised leaf -> binds role to signer + let x_f = be_bytes_to_field(signer_pubkey_x); + let y_f = be_bytes_to_field(signer_pubkey_y); + let leaf = Poseidon2::hash([x_f, y_f, signer_role], 3); + let root = compute_merkle_root(leaf, merkle_path, merkle_indices); + assert(root == trust_list_root, "signer (key, role) not in trust list"); + + // 5. emit the role-stamped anchor -> becomes candidate_anchor for bound_receivables + anchor(cid, salt, signer_role) +} diff --git a/experiments/conoir-spike/quorum/run_bound_aggregate.sh b/experiments/conoir-spike/quorum/run_bound_aggregate.sh new file mode 100755 index 0000000..ec73b3f --- /dev/null +++ b/experiments/conoir-spike/quorum/run_bound_aggregate.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Apertrue Quorum -- aggregate engine (marine over-insurance) through full 3-party REP3 MPC. +set -e +ROOT="$HOME/co-snarks-spike"; CO="$ROOT/target/release/co-noir" +EX="$ROOT/co-noir/co-noir/examples"; CFG="$EX/configs" +CRS1="$ROOT/co-noir/co-noir-common/src/crs/bn254_g1.dat" +CRS2="$ROOT/co-noir/co-noir-common/src/crs/bn254_g2.dat" +SRC="$HOME/apertrue/circuits/experiments/conoir-spike/quorum/bound_aggregate" +TV="$EX/test_vectors/bound_aggregate"; J="$TV/target/bound_aggregate.json" +INPUT="${1:-$SRC/Prover.toml}" + +rm -rf "$TV"; mkdir -p "$TV/src" +cp "$SRC/Nargo.toml" "$TV/"; cp "$SRC/src/main.nr" "$TV/src/"; cp "$INPUT" "$TV/Prover.toml" +( cd "$TV" && PATH="$HOME/.nargo/bin:$PATH" nargo compile ) + +cd "$TV" +"$CO" split-input --circuit "$J" --input "$TV/Prover.toml" --protocol REP3 --out-dir "$TV" +for i in 0 1 2; do "$CO" generate-witness --input "$TV/Prover.toml.$i.shared" --circuit "$J" --protocol REP3 --config "$CFG/party$((i+1)).toml" --out "$TV/witness.$i.shared" & done; wait +for i in 0 1 2; do "$CO" build-proving-key --witness "$TV/witness.$i.shared" --circuit "$J" --protocol REP3 --config "$CFG/party$((i+1)).toml" --out "$TV/pk.$i.shared" --crs "$CRS1" & done; wait +"$CO" create-vk --circuit "$J" --crs "$CRS1" --hasher keccak --vk "$TV/vk" +"$CO" generate-proof --proving-key "$TV/pk.0.shared" --protocol REP3 --hasher keccak --config "$CFG/party1.toml" --crs "$CRS1" --out "$TV/proof.0.proof" --vk "$TV/vk" --public-input "$TV/public_input" & +for i in 1 2; do "$CO" generate-proof --proving-key "$TV/pk.$i.shared" --protocol REP3 --hasher keccak --config "$CFG/party$((i+1)).toml" --crs "$CRS1" --out "$TV/proof.$i.proof" --vk "$TV/vk" & done; wait +"$CO" verify --proof "$TV/proof.0.proof" --public-input "$TV/public_input" --vk "$TV/vk" --hasher keccak --crs "$CRS2" +PUB="$TV/public_input" python3 - <<'PY' +import os +b=open(os.environ["PUB"],"rb").read(); n=len(b)//32 +out=int.from_bytes(b[(n-1)*32:n*32],"big") +print(" => over_insured =", out) +PY diff --git a/experiments/conoir-spike/quorum/run_bound_receivables.sh b/experiments/conoir-spike/quorum/run_bound_receivables.sh new file mode 100755 index 0000000..c722b4d --- /dev/null +++ b/experiments/conoir-spike/quorum/run_bound_receivables.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Apertrue Quorum: run bound_receivables through the full 3-party REP3 MPC pipeline. +# Answers "is this receivable already financed anywhere in the network?" revealing one bit. +# Usage: ./run_bound_receivables.sh [Prover.toml] (default: the clean case) +set -e +ROOT="$HOME/co-snarks-spike"; CO="$ROOT/target/release/co-noir" +EX="$ROOT/co-noir/co-noir/examples"; CFG="$EX/configs" +CRS1="$ROOT/co-noir/co-noir-common/src/crs/bn254_g1.dat" +CRS2="$ROOT/co-noir/co-noir-common/src/crs/bn254_g2.dat" +SRC="$HOME/apertrue/circuits/experiments/conoir-spike/quorum/bound_receivables" +TV="$EX/test_vectors/bound_receivables" +J="$TV/target/bound_receivables.json" +INPUT="${1:-$SRC/Prover.toml}" + +# stage circuit into the co-snarks clone + compile (beta.20) +rm -rf "$TV"; mkdir -p "$TV/src" +cp "$SRC/Nargo.toml" "$TV/"; cp "$SRC/src/main.nr" "$TV/src/"; cp "$INPUT" "$TV/Prover.toml" +( cd "$TV" && PATH="$HOME/.nargo/bin:$PATH" nargo compile ) + +cd "$TV" +echo "### 1. split-input ###" +"$CO" split-input --circuit "$J" --input "$TV/Prover.toml" --protocol REP3 --out-dir "$TV" +echo "### 2. generate-witness (3 parties) ###" +for i in 0 1 2; do "$CO" generate-witness --input "$TV/Prover.toml.$i.shared" --circuit "$J" --protocol REP3 --config "$CFG/party$((i+1)).toml" --out "$TV/witness.$i.shared" & done; wait +echo "### 3. build-proving-key (3 parties) ###" +for i in 0 1 2; do "$CO" build-proving-key --witness "$TV/witness.$i.shared" --circuit "$J" --protocol REP3 --config "$CFG/party$((i+1)).toml" --out "$TV/pk.$i.shared" --crs "$CRS1" & done; wait +echo "### 4. create-vk ###" +"$CO" create-vk --circuit "$J" --crs "$CRS1" --hasher keccak --vk "$TV/vk" +echo "### 5. generate-proof (3 parties) ###" +"$CO" generate-proof --proving-key "$TV/pk.0.shared" --protocol REP3 --hasher keccak --config "$CFG/party1.toml" --crs "$CRS1" --out "$TV/proof.0.proof" --vk "$TV/vk" --public-input "$TV/public_input" & +for i in 1 2; do "$CO" generate-proof --proving-key "$TV/pk.$i.shared" --protocol REP3 --hasher keccak --config "$CFG/party$((i+1)).toml" --crs "$CRS1" --out "$TV/proof.$i.proof" --vk "$TV/vk" & done; wait +echo "### 6. verify ###" +"$CO" verify --proof "$TV/proof.0.proof" --public-input "$TV/public_input" --vk "$TV/vk" --hasher keccak --crs "$CRS2" +echo "--- public output (anchor, role, allow-list, bit, guarantee) ---" +PUB="$TV/public_input" python3 - <<'PY' +import os +b=open(os.environ["PUB"],"rb").read() +n=len(b)//32 +vals=[int.from_bytes(b[i*32:(i+1)*32],"big") for i in range(n)] +# layout: [candidate_anchor, signer_role, accepted_roles[0..1], already_financed, signer_role_out] +def hexs(v): return str(v) if v<100000 else "0x"+("%064x"%v).lstrip("0") +labels=["candidate_anchor","IN signer_role","accepted_role[0]","accepted_role[1]","OUT already_financed","OUT signer_role"] +for i,v in enumerate(vals): + print(" %-22s = %s" % (labels[i] if i already_financed=%d | role=%d | guarantee: %s" % (fin, role, guar)) +PY diff --git a/experiments/conoir-spike/quorum/run_quorum_scenarios.sh b/experiments/conoir-spike/quorum/run_quorum_scenarios.sh new file mode 100755 index 0000000..219cac6 --- /dev/null +++ b/experiments/conoir-spike/quorum/run_quorum_scenarios.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Apertrue Quorum -- the two-signer thesis, end to end under MPC. +# Anchors come from Layer-2 admission (p1_provenance/admission/admission.sh). +Q="$HOME/apertrue/circuits/experiments/conoir-spike/quorum" +cd "$Q" + +OBL=0x276218a3095f1f2a85ee95ceeece1963fb83fbbc4da888208d85b2b3013822e5 # role 2, true (amount 500) +SDI_INFL=0x29b3212006ae841a7ad864218a471c73a7813f28140377223877cb26beef1180 # role 1, INFLATED (amount 5000) +SELLER=0x25bb81fa6fd90d294abd6719560ae8d74c88b559fee18245d9337ca23b54984e # role 3, true (not in allow-list) +CID_TRUE='"0x06cc60c66ce6389ea53783b55dc5ff1b480e9a43ecf4c942262f5d73d7a87280"' +# exactly BOOK_UNION=6 entries; CLEAN5 = first 5 (leave a slot for the double-finance hit) +CLEAN5='"0x01a1111111111111111111111111111111111111111111111111111111111111","0x02b2222222222222222222222222222222222222222222222222222222222222","0x03c3333333333333333333333333333333333333333333333333333333333333","0x04d4444444444444444444444444444444444444444444444444444444444444","0x05e5555555555555555555555555555555555555555555555555555555555555"' +CLEAN="$CLEAN5,\"0x06f6666666666666666666666666666666666666666666666666666666666666\"" + +prover() { # anchor role amount financed_csv -> /tmp/q.toml + cat > /tmp/q.toml <&1 | grep -iE "verified|verification failed|=> already_financed|allow-list|opening failed" || true; } + +echo "########## 1) OBLIGOR, true receivable, not financed (expect 0, owed) ##########" +prover "$OBL" 2 500 "$CLEAN"; run +echo "########## 2) OBLIGOR, true receivable, DOUBLE-FINANCED (expect 1, owed) ##########" +prover "$OBL" 2 500 "$CLEAN5,$CID_TRUE"; run +echo "########## 3) SdI, INFLATED invoice seller cleared, not financed (expect 0, cleared-NOT-owed = THE GAP) ##########" +prover "$SDI_INFL" 1 5000 "$CLEAN"; run +echo "########## 4) SELLER self-signed (role 3) -> MPC allow-list rejects (defense in depth) ##########" +prover "$SELLER" 3 500 "$CLEAN"; run +rm -f /tmp/q.toml \ No newline at end of file diff --git a/experiments/conoir-spike/run_imt_nm_mini.sh b/experiments/conoir-spike/run_imt_nm_mini.sh new file mode 100755 index 0000000..8204f54 --- /dev/null +++ b/experiments/conoir-spike/run_imt_nm_mini.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# coNoir spike: run the imt_nm_mini circuit through the full 3-party MPC pipeline. +# Mirrors run_all_steps_poseidon.sh, adapted for test_vectors/imt_nm_mini. +set -e +cd "$(dirname "$(realpath "$0")")" + +CO=../../../target/release/co-noir # built binary (faster than cargo run) +TV=test_vectors/imt_nm_mini +CRS1=../../co-noir-common/src/crs/bn254_g1.dat +CRS2=../../co-noir-common/src/crs/bn254_g2.dat + +echo "### 1. split-input (REP3 secret-share the Prover.toml inputs) ###" +"$CO" split-input --circuit "$TV/target/imt_nm_mini.json" --input "$TV/Prover.toml" --protocol REP3 --out-dir "$TV" + +echo "### 2. generate-witness in MPC (3 parties) ###" +"$CO" generate-witness --input "$TV/Prover.toml.0.shared" --circuit "$TV/target/imt_nm_mini.json" --protocol REP3 --config configs/party1.toml --out "$TV/witness.0.shared" & +"$CO" generate-witness --input "$TV/Prover.toml.1.shared" --circuit "$TV/target/imt_nm_mini.json" --protocol REP3 --config configs/party2.toml --out "$TV/witness.1.shared" & +"$CO" generate-witness --input "$TV/Prover.toml.2.shared" --circuit "$TV/target/imt_nm_mini.json" --protocol REP3 --config configs/party3.toml --out "$TV/witness.2.shared" +wait $(jobs -p) + +echo "### 3. build-proving-key in MPC (3 parties) ###" +"$CO" build-proving-key --witness "$TV/witness.0.shared" --circuit "$TV/target/imt_nm_mini.json" --protocol REP3 --config configs/party1.toml --out "$TV/pk.0.shared" --crs "$CRS1" & +"$CO" build-proving-key --witness "$TV/witness.1.shared" --circuit "$TV/target/imt_nm_mini.json" --protocol REP3 --config configs/party2.toml --out "$TV/pk.1.shared" --crs "$CRS1" & +"$CO" build-proving-key --witness "$TV/witness.2.shared" --circuit "$TV/target/imt_nm_mini.json" --protocol REP3 --config configs/party3.toml --out "$TV/pk.2.shared" --crs "$CRS1" +wait $(jobs -p) + +echo "### 4. create verification key ###" +"$CO" create-vk --circuit "$TV/target/imt_nm_mini.json" --crs "$CRS1" --hasher keccak --vk "$TV/vk" + +echo "### 5. generate-proof in MPC (3 parties) ###" +"$CO" generate-proof --proving-key "$TV/pk.0.shared" --protocol REP3 --hasher keccak --config configs/party1.toml --crs "$CRS1" --out "$TV/proof.0.proof" --vk "$TV/vk" --public-input "$TV/public_input" & +"$CO" generate-proof --proving-key "$TV/pk.1.shared" --protocol REP3 --hasher keccak --config configs/party2.toml --crs "$CRS1" --out "$TV/proof.1.proof" --vk "$TV/vk" & +"$CO" generate-proof --proving-key "$TV/pk.2.shared" --protocol REP3 --hasher keccak --config configs/party3.toml --crs "$CRS1" --out "$TV/proof.2.proof" --vk "$TV/vk" +wait $(jobs -p) + +echo "### 6. verify the proof + show public output ###" +"$CO" verify --proof "$TV/proof.0.proof" --public-input "$TV/public_input" --vk "$TV/vk" --hasher keccak --crs "$CRS2" +echo "--- public_input (the revealed collision bit) ---" +cat "$TV/public_input" 2>/dev/null || echo "(no public_input file)" diff --git a/experiments/conoir-spike/run_nullifier_check.sh b/experiments/conoir-spike/run_nullifier_check.sh new file mode 100755 index 0000000..ef1feb7 --- /dev/null +++ b/experiments/conoir-spike/run_nullifier_check.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# coNoir spike: run the nullifier_check circuit through the full 3-party MPC pipeline. +# Mirrors run_all_steps_poseidon.sh, adapted for test_vectors/nullifier_check. +set -e +cd "$(dirname "$(realpath "$0")")" + +CO=../../../target/release/co-noir # built binary (faster than cargo run) +TV=test_vectors/nullifier_check +CRS1=../../co-noir-common/src/crs/bn254_g1.dat +CRS2=../../co-noir-common/src/crs/bn254_g2.dat + +echo "### 1. split-input (REP3 secret-share the Prover.toml inputs) ###" +"$CO" split-input --circuit "$TV/target/nullifier_check.json" --input "$TV/Prover.toml" --protocol REP3 --out-dir "$TV" + +echo "### 2. generate-witness in MPC (3 parties) ###" +"$CO" generate-witness --input "$TV/Prover.toml.0.shared" --circuit "$TV/target/nullifier_check.json" --protocol REP3 --config configs/party1.toml --out "$TV/witness.0.shared" & +"$CO" generate-witness --input "$TV/Prover.toml.1.shared" --circuit "$TV/target/nullifier_check.json" --protocol REP3 --config configs/party2.toml --out "$TV/witness.1.shared" & +"$CO" generate-witness --input "$TV/Prover.toml.2.shared" --circuit "$TV/target/nullifier_check.json" --protocol REP3 --config configs/party3.toml --out "$TV/witness.2.shared" +wait $(jobs -p) + +echo "### 3. build-proving-key in MPC (3 parties) ###" +"$CO" build-proving-key --witness "$TV/witness.0.shared" --circuit "$TV/target/nullifier_check.json" --protocol REP3 --config configs/party1.toml --out "$TV/pk.0.shared" --crs "$CRS1" & +"$CO" build-proving-key --witness "$TV/witness.1.shared" --circuit "$TV/target/nullifier_check.json" --protocol REP3 --config configs/party2.toml --out "$TV/pk.1.shared" --crs "$CRS1" & +"$CO" build-proving-key --witness "$TV/witness.2.shared" --circuit "$TV/target/nullifier_check.json" --protocol REP3 --config configs/party3.toml --out "$TV/pk.2.shared" --crs "$CRS1" +wait $(jobs -p) + +echo "### 4. create verification key ###" +"$CO" create-vk --circuit "$TV/target/nullifier_check.json" --crs "$CRS1" --hasher keccak --vk "$TV/vk" + +echo "### 5. generate-proof in MPC (3 parties) ###" +"$CO" generate-proof --proving-key "$TV/pk.0.shared" --protocol REP3 --hasher keccak --config configs/party1.toml --crs "$CRS1" --out "$TV/proof.0.proof" --vk "$TV/vk" --public-input "$TV/public_input" & +"$CO" generate-proof --proving-key "$TV/pk.1.shared" --protocol REP3 --hasher keccak --config configs/party2.toml --crs "$CRS1" --out "$TV/proof.1.proof" --vk "$TV/vk" & +"$CO" generate-proof --proving-key "$TV/pk.2.shared" --protocol REP3 --hasher keccak --config configs/party3.toml --crs "$CRS1" --out "$TV/proof.2.proof" --vk "$TV/vk" +wait $(jobs -p) + +echo "### 6. verify the proof + show public output ###" +"$CO" verify --proof "$TV/proof.0.proof" --public-input "$TV/public_input" --vk "$TV/vk" --hasher keccak --crs "$CRS2" +echo "--- public_input (the revealed collision bit) ---" +cat "$TV/public_input" 2>/dev/null || echo "(no public_input file)"