diff --git a/Cargo.toml b/Cargo.toml index 6f239923..e10b09e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/vole-core", "crates/wasm-bench", "crates/poly-proof-core", + "crates/perm-proof-core", ] exclude = ["crates/lpn-estimator"] resolver = "2" @@ -120,6 +121,7 @@ ark-serialize = "0.4" serde = "1.0" serde_yaml = "0.9" serde_arrays = "0.1" +bcs = "0.1.5" bincode = "1.3.3" bytes = "1" yamux = "0.10" diff --git a/crates/perm-proof-core/Cargo.toml b/crates/perm-proof-core/Cargo.toml new file mode 100644 index 00000000..340dbfb7 --- /dev/null +++ b/crates/perm-proof-core/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "perm-proof-core" +version = "0.1.0" +edition = "2024" + +[lib] +name = "perm_proof_core" + +[lints] +workspace = true + +[features] +test-utils = [] + +[dependencies] +bcs = { workspace = true } +blake3 = { workspace = true } +hybrid-array = { workspace = true } +mpz-circuits-new = { workspace = true } +mpz-common = { workspace = true, features = ["future"] } +mpz-fields = { workspace = true } +mpz-vole-core = { workspace = true } +mpz-poly-proof-core = { workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +zerocopy = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true } + +[[test]] +name = "api" +path = "tests/api.rs" +required-features = ["test-utils"] + +[[bench]] +name = "prover" +path = "benches/prover.rs" +harness = false +required-features = ["test-utils"] + +[[bench]] +name = "verifier" +path = "benches/verifier.rs" +harness = false +required-features = ["test-utils"] diff --git a/crates/perm-proof-core/benches/prover.rs b/crates/perm-proof-core/benches/prover.rs new file mode 100644 index 00000000..a0156573 --- /dev/null +++ b/crates/perm-proof-core/benches/prover.rs @@ -0,0 +1,164 @@ +//! Prover-side benchmark for the permutation-proof protocol. +//! +//! Uses the [`VoleZkProverBackend`] with the ideal RVOLE / RVOPE +//! functionalities, so the numbers measure protocol overhead in +//! isolation from any concrete VOLE construction. + +use blake3::Hasher; +use criterion::{ + BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, +}; +use mpz_fields::gf2_64::Gf2_64; +use mpz_vole_core::ideal::{ + rvole::{IdealRVOLEReceiver, IdealRVOLESender, ideal_rvole}, + rvope::{IdealRVOPEReceiver, IdealRVOPESender, ideal_rvope}, +}; +use perm_proof_core::{ + Prover, + backend::vole_zk::VoleZkProverBackend, + test_utils::{ + Committed, commit_values, vole_zk_rvole_pregenerate_count, + vole_zk_rvope_pregenerate_degree, + }, +}; +use rand::{Rng, SeedableRng, seq::SliceRandom}; +use rand_chacha::ChaCha8Rng; + +/// Fan-in of the product circuit. +const EPS: usize = 16; + +/// Criterion reports per-element throughput via `Throughput::Elements(n)`. +const INPUT_SIZES: &[usize] = &[10_000, 100_000, 200_000]; + +/// Seed for the driver RNG. Fixed so input distributions and ideal +/// correlations are reproducible run-to-run. +const BENCH_SEED: u64 = 0x5E1F_B0FB_1F15_D00D; + +type ProverBackend = VoleZkProverBackend< + Gf2_64, + Gf2_64, + IdealRVOLEReceiver, + IdealRVOPEReceiver, +>; + +/// Per-`(n, L)` data that's immutable across bench iterations: the +/// shared MAC secret, the input permutation, its authenticated wires, +/// and the transcript state from the ideal-VOLE commit. +struct Fixture { + delta: Gf2_64, + x_values: Vec>, + x_macs: Vec>, + y_values: Vec>, + y_macs: Vec>, + transcript: Hasher, +} + +/// One-shot: pick `delta`, generate the input permutation, authenticate +/// both vectors via the ideal-VOLE `commit_values` helper, and bundle +/// the result. Called once per `(n, L)` bench point. +fn build_fixture(rng: &mut ChaCha8Rng, n: usize) -> Fixture { + let delta: Gf2_64 = rng.random(); + + let x_values: Vec> = (0..n) + .map(|_| (0..L).map(|_| rng.random()).collect()) + .collect(); + let mut perm_indices: Vec = (0..n).collect(); + perm_indices.shuffle(rng); + let y_values: Vec> = perm_indices.iter().map(|&i| x_values[i].clone()).collect(); + + let Committed { + macs: [x_macs, y_macs], + keys: _, + transcript, + } = commit_values([&x_values[..], &y_values[..]], delta, rng); + + Fixture { + delta, + x_values, + x_macs, + y_values, + y_macs, + transcript, + } +} + +/// Per-iter: build a fresh ideal RVOLE / RVOPE pair under the fixture's +/// `delta`, pregenerate enough correlations for exactly one prover run, +/// and wire a new `Prover` to the receiver halves. +fn build_correlations_and_prover( + rng: &mut ChaCha8Rng, + delta: Gf2_64, + n: usize, +) -> Prover { + let rvole_seed: u64 = rng.random(); + let rvope_seed: u64 = rng.random(); + + let rvole_count = vole_zk_rvole_pregenerate_count(n, EPS); + let (mut rvole_s, mut rvole_r): (IdealRVOLESender, _) = + ideal_rvole::(rvole_seed, delta); + rvole_s.pregenerate(rvole_count); + rvole_r + .pregenerate(rvole_count, delta) + .expect("ideal RVOLE receiver pregenerate"); + + let rvope_degree = vole_zk_rvope_pregenerate_degree::(EPS); + let (mut rvope_s, mut rvope_r): (IdealRVOPESender, _) = + ideal_rvope::(rvope_seed, delta); + rvope_s.pregenerate(1, rvope_degree); + rvope_r.pregenerate(1, rvope_degree); + + // Keep the senders alive to the end of setup so they materialize + // the receiver-side pool via shared seed; drop once done. + drop(rvole_s); + drop(rvope_s); + + let mut prover = Prover::new( + VoleZkProverBackend::::new(EPS, rvole_r, rvope_r).unwrap(), + ); + prover.alloc(n).expect("prover alloc must succeed"); + prover +} + +/// Bench `n` inputs of tuple width `L`. +fn bench_prove(c: &mut Criterion) { + fn case( + group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>, + n: usize, + ) { + let id = BenchmarkId::new(format!("L={L}"), n); + group.bench_with_input(id, &n, |b, &n| { + let mut rng = ChaCha8Rng::seed_from_u64(BENCH_SEED ^ (n as u64) ^ (L as u64)); + let fixture = build_fixture::(&mut rng, n); + b.iter_batched( + || { + let prover = build_correlations_and_prover(&mut rng, fixture.delta, n); + (prover, fixture.transcript.clone()) + }, + |(prover, transcript)| { + let (_preparation, prover) = prover + .prepare( + transcript, + (&fixture.x_values, &fixture.x_macs), + (&fixture.y_values, &fixture.y_macs), + ) + .expect("prepare must succeed"); + let proof = prover.prove().expect("prove must succeed"); + black_box(proof); + }, + criterion::BatchSize::PerIteration, + ); + }); + } + + let mut group = c.benchmark_group("prove"); + group.sample_size(10); + for &n in INPUT_SIZES { + group.throughput(Throughput::Elements(n as u64)); + case::<1>(&mut group, n); + case::<2>(&mut group, n); + } + group.finish(); +} + +criterion_group!(benches, bench_prove); +criterion_main!(benches); diff --git a/crates/perm-proof-core/benches/verifier.rs b/crates/perm-proof-core/benches/verifier.rs new file mode 100644 index 00000000..840deb09 --- /dev/null +++ b/crates/perm-proof-core/benches/verifier.rs @@ -0,0 +1,217 @@ +//! Verifier-side benchmark for the permutation-proof protocol. +//! +//! Uses the [`VoleZkVerifierBackend`] with the ideal RVOLE / RVOPE +//! functionalities, so the numbers measure protocol overhead in +//! isolation from any concrete VOLE construction. + +use blake3::Hasher; +use criterion::{ + BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, +}; +use mpz_fields::gf2_64::Gf2_64; +use mpz_vole_core::ideal::{ + rvole::{IdealRVOLESender, ideal_rvole}, + rvope::{IdealRVOPESender, ideal_rvope}, +}; +use perm_proof_core::{ + Proof as Envelope, Prover, Verifier, + backend::vole_zk::{ + Preparation, Proof as BackendProof, VoleZkProverBackend, VoleZkVerifierBackend, + }, + test_utils::{ + Committed, commit_values, vole_zk_rvole_pregenerate_count, + vole_zk_rvope_pregenerate_degree, + }, +}; +use rand::{Rng, SeedableRng, seq::SliceRandom}; +use rand_chacha::ChaCha8Rng; + +const EPS: usize = 16; + +const INPUT_SIZES: &[usize] = &[10_000, 100_000, 200_000]; + +const BENCH_SEED: u64 = 0x5E1F_B0FB_1F15_D00D; + +type VerifierBackendT = VoleZkVerifierBackend< + Gf2_64, + Gf2_64, + IdealRVOLESender, + IdealRVOPESender, +>; + +type FullProof = Envelope>; + +/// Per-`(n, L)` data that's immutable across iterations: the verifier +/// inputs (keys + transcript), the prover-produced DTOs the verifier +/// consumes, and the RVOLE/RVOPE seeds so per-iter setup can rebuild +/// matching senders. Generated once per bench point. +struct Fixture { + delta: Gf2_64, + x_keys: Vec>, + y_keys: Vec>, + transcript: Hasher, + preparation: Preparation, + proof: FullProof, + rvole_seed: u64, + rvope_seed: u64, +} + +/// One-shot: generate inputs, authenticate them via `commit_values`, +/// then run an honest prover through `prepare` + `prove` to produce the +/// `preparation` and `Proof` the verifier will consume. +fn build_fixture(rng: &mut ChaCha8Rng, n: usize) -> Fixture { + let delta: Gf2_64 = rng.random(); + + let x_values: Vec> = (0..n) + .map(|_| (0..L).map(|_| rng.random()).collect()) + .collect(); + let mut perm_indices: Vec = (0..n).collect(); + perm_indices.shuffle(rng); + let y_values: Vec> = perm_indices.iter().map(|&i| x_values[i].clone()).collect(); + + let Committed { + macs: [x_macs, y_macs], + keys: [x_keys, y_keys], + transcript, + } = commit_values([&x_values[..], &y_values[..]], delta, rng); + + let rvole_seed: u64 = rng.random(); + let rvope_seed: u64 = rng.random(); + + // Build the prover's correlation receivers (senders are only kept + // alive long enough to materialize the matching pool via shared + // seed, then dropped — their per-iter counterpart is rebuilt in + // `build_verifier` below). + let rvole_count = vole_zk_rvole_pregenerate_count(n, EPS); + let (mut rvole_s, mut rvole_r): (IdealRVOLESender, _) = + ideal_rvole::(rvole_seed, delta); + rvole_s.pregenerate(rvole_count); + rvole_r + .pregenerate(rvole_count, delta) + .expect("ideal RVOLE receiver pregenerate"); + + let rvope_degree = vole_zk_rvope_pregenerate_degree::(EPS); + let (mut rvope_s, mut rvope_r): (IdealRVOPESender, _) = + ideal_rvope::(rvope_seed, delta); + rvope_s.pregenerate(1, rvope_degree); + rvope_r.pregenerate(1, rvope_degree); + + drop(rvole_s); + drop(rvope_s); + + let mut prover = Prover::new( + VoleZkProverBackend::::new(EPS, rvole_r, rvope_r).unwrap(), + ); + prover.alloc(n).expect("prover alloc must succeed"); + + let (preparation, prover) = prover + .prepare( + transcript.clone(), + (&x_values, &x_macs), + (&y_values, &y_macs), + ) + .expect("prover prepare must succeed"); + let proof = prover.prove().expect("prover prove must succeed"); + + Fixture { + delta, + x_keys, + y_keys, + transcript, + preparation, + proof, + rvole_seed, + rvope_seed, + } +} + +/// Per-iter: rebuild the verifier side under the fixture's `delta` and +/// seeds. The sender pool pregenerated here matches the receiver pool +/// the prover consumed in `build_fixture` (shared `(seed, delta)` → +/// identical correlations), so the adjustments in the prover's +/// `preparation` apply correctly. +/// +/// The receiver halves are pregenerated but immediately dropped; the +/// prover bench's comment applies here inverted: keep both halves alive +/// through pregenerate so the pair materializes consistently, then +/// discard the one we don't need. +fn build_verifier( + delta: Gf2_64, + rvole_seed: u64, + rvope_seed: u64, + n: usize, +) -> Verifier { + let rvole_count = vole_zk_rvole_pregenerate_count(n, EPS); + let (mut rvole_s, mut rvole_r): (IdealRVOLESender, _) = + ideal_rvole::(rvole_seed, delta); + rvole_s.pregenerate(rvole_count); + rvole_r + .pregenerate(rvole_count, delta) + .expect("ideal RVOLE receiver pregenerate"); + + let rvope_degree = vole_zk_rvope_pregenerate_degree::(EPS); + let (mut rvope_s, mut rvope_r): (IdealRVOPESender, _) = + ideal_rvope::(rvope_seed, delta); + rvope_s.pregenerate(1, rvope_degree); + rvope_r.pregenerate(1, rvope_degree); + + drop(rvole_r); + drop(rvope_r); + + let mut verifier = Verifier::new( + VoleZkVerifierBackend::::new(EPS, delta, rvole_s, rvope_s) + .unwrap(), + ); + verifier.alloc(n).expect("verifier alloc must succeed"); + verifier +} + +/// Bench `n` inputs of tuple width `L`. +fn bench_verify(c: &mut Criterion) { + fn case( + group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>, + n: usize, + ) { + let id = BenchmarkId::new(format!("L={L}"), n); + group.bench_with_input(id, &n, |b, &n| { + let mut rng = ChaCha8Rng::seed_from_u64(BENCH_SEED ^ (n as u64) ^ (L as u64)); + let fixture = build_fixture::(&mut rng, n); + b.iter_batched( + || { + let verifier = build_verifier( + fixture.delta, + fixture.rvole_seed, + fixture.rvope_seed, + n, + ); + ( + verifier, + fixture.transcript.clone(), + fixture.preparation.clone(), + fixture.proof.clone(), + ) + }, + |(verifier, transcript, preparation, proof)| { + let verifier = verifier + .prepare(transcript, &fixture.x_keys, &fixture.y_keys, preparation) + .expect("verifier prepare must succeed"); + verifier.verify(proof).expect("verify must succeed"); + black_box(()); + }, + criterion::BatchSize::PerIteration, + ); + }); + } + + let mut group = c.benchmark_group("verify"); + group.sample_size(10); + for &n in INPUT_SIZES { + group.throughput(Throughput::Elements(n as u64)); + case::<1>(&mut group, n); + case::<2>(&mut group, n); + } + group.finish(); +} + +criterion_group!(benches, bench_verify); +criterion_main!(benches); diff --git a/crates/perm-proof-core/src/backend/mock/mod.rs b/crates/perm-proof-core/src/backend/mock/mod.rs new file mode 100644 index 00000000..e070b079 --- /dev/null +++ b/crates/perm-proof-core/src/backend/mock/mod.rs @@ -0,0 +1,35 @@ +//! Mock backend. + +use mpz_fields::Field; +use serde::{Deserialize, Serialize}; + +pub mod prover; +pub mod verifier; + +pub use prover::MockProverBackend; +pub use verifier::MockVerifierBackend; + +/// Preparation DTO for the mock backend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Preparation { + /// Verifier-side IT-MAC keys for product wires, declared by the + /// prover (legal here because the mock prover knows `Δ`), one per + /// `product` call, in emission order. + pub prod_keys: Vec, +} + +/// Error produced by the mock prover / verifier. +#[derive(Debug, thiserror::Error)] +pub enum MockError { + /// The verifier ran out of buffered prod_keys. + #[error("ran out of prod_keys at product")] + ProdKeyUnderflow, +} + +/// Build a paired mock prover and verifier sharing `delta`. +pub fn mock_pair(delta: E) -> (MockProverBackend, MockVerifierBackend) { + ( + MockProverBackend::new(delta), + MockVerifierBackend::new(delta), + ) +} diff --git a/crates/perm-proof-core/src/backend/mock/prover.rs b/crates/perm-proof-core/src/backend/mock/prover.rs new file mode 100644 index 00000000..2c975f11 --- /dev/null +++ b/crates/perm-proof-core/src/backend/mock/prover.rs @@ -0,0 +1,75 @@ +//! Mock prover backend. + +use std::marker::PhantomData; + +use blake3::Hasher; +use mpz_fields::{ExtensionField, Field}; + +use super::{MockError, Preparation}; +use crate::backend::{Backend, ProverBackend}; + +/// Mock prover backend. +pub struct MockProverBackend { + /// Global key. Must match the verifier's. + delta: E, + + /// Buffered product-wire keys emitted by + /// [`product`](ProverBackend::product). + prod_keys: Vec, + + _phantom: PhantomData, +} + +impl MockProverBackend { + /// Build a new mock prover holding `delta`. + pub fn new(delta: E) -> Self { + Self { + delta, + prod_keys: Vec::new(), + _phantom: PhantomData, + } + } +} + +impl> Backend for MockProverBackend { + type Error = MockError; + type Preparation = Preparation; + type BackendProof = (); +} + +impl> ProverBackend for MockProverBackend { + fn drain_preparation(&mut self) -> Result { + Ok(Preparation { + prod_keys: std::mem::take(&mut self.prod_keys), + }) + } + + fn prove(self) -> Result { + // Mock contributes no supplementary proof. + Ok(()) + } + + fn product( + &mut self, + _transcript: &mut Hasher, + factor_values: &[E], + factor_macs: &[E], + ) -> Result<(E, E), Self::Error> { + assert_eq!( + factor_values.len(), + factor_macs.len(), + "factor_values and factor_macs must be equal length" + ); + + // Cleartext product. + let prod_value = factor_values.iter().copied().fold(E::one(), |a, b| a * b); + + // Any prod_mac satisfies the IT-MAC invariant. + let prod_mac = E::rand(&mut rand::rng()); + + let prod_key = prod_mac - self.delta * prod_value; + + self.prod_keys.push(prod_key); + Ok((prod_value, prod_mac)) + } +} diff --git a/crates/perm-proof-core/src/backend/mock/verifier.rs b/crates/perm-proof-core/src/backend/mock/verifier.rs new file mode 100644 index 00000000..0fa664dc --- /dev/null +++ b/crates/perm-proof-core/src/backend/mock/verifier.rs @@ -0,0 +1,63 @@ +//! Mock verifier backend. + +use std::{collections::VecDeque, marker::PhantomData}; + +use blake3::Hasher; +use mpz_fields::{ExtensionField, Field}; + +use super::{MockError, Preparation}; +use crate::backend::{Backend, VerifierBackend}; + +/// Mock verifier backend. +pub struct MockVerifierBackend { + /// Global key. Must match the prover's. + delta: E, + + /// Product-wire keys loaded from a [`Preparation`], drained FIFO by + /// [`product`](VerifierBackend::product). + prod_keys: VecDeque, + + _phantom: PhantomData, +} + +impl MockVerifierBackend { + /// Build a new mock verifier holding `delta`. + pub fn new(delta: E) -> Self { + Self { + delta, + prod_keys: VecDeque::new(), + _phantom: PhantomData, + } + } +} + +impl> Backend for MockVerifierBackend { + type Error = MockError; + type Preparation = Preparation; + type BackendProof = (); +} + +impl> VerifierBackend for MockVerifierBackend { + fn delta(&self) -> E { + self.delta + } + + fn load_preparation(&mut self, preparation: Self::Preparation) { + self.prod_keys = preparation.prod_keys.into(); + } + + fn verify(self, _proof: Self::BackendProof) -> Result<(), Self::Error> { + // Mock contributes no supplementary check. + Ok(()) + } + + fn product( + &mut self, + _transcript: &mut Hasher, + _factor_keys: &[E], + ) -> Result { + self.prod_keys + .pop_front() + .ok_or(MockError::ProdKeyUnderflow) + } +} diff --git a/crates/perm-proof-core/src/backend/mod.rs b/crates/perm-proof-core/src/backend/mod.rs new file mode 100644 index 00000000..8497ece6 --- /dev/null +++ b/crates/perm-proof-core/src/backend/mod.rs @@ -0,0 +1,108 @@ +//! Backend traits for the permutation proof. + +use blake3::Hasher; +use mpz_fields::{ExtensionField, Field}; + +#[cfg(any(test, feature = "test-utils"))] +pub mod mock; +pub mod vole_zk; + +/// Types shared by a paired prover/verifier backend. +pub trait Backend> { + /// Error type produced by fallible backend operations. + type Error: std::error::Error + Send + Sync + 'static; + + /// Preparation DTO: data the verifier can start processing the + /// moment the prover's `product` calls are done. + type Preparation; + + /// Backend-specific supplementary proof. + type BackendProof; +} + +/// Prover backend for the permutation proof. +pub trait ProverBackend>: Backend + Sized { + /// Allocate capacity for proving a permutation of size `n`. + /// + /// May be called multiple times: each call allocates additional + /// capacity on top of prior calls. + /// + /// # Arguments + /// + /// * `n` - Size of the permutation to prove. + fn alloc(&mut self, _n: usize) -> Result<(), Self::Error> { + Ok(()) + } + + /// Authenticated product of `n` factors. Returns the + /// `(value, MAC)` of the product wire. + /// + /// On entry, `transcript` is guaranteed to have absorbed + /// `factor_values` and `factor_macs`. On return, the + /// implementation must have absorbed any on-wire bytes it + /// emitted during the call into `transcript`. + /// + /// # Arguments + /// + /// * `transcript` - Shared session transcript. + /// * `factor_values` - Cleartext values of the factor wires. + /// * `factor_macs` - Per-position MACs matching `factor_values`. + fn product( + &mut self, + transcript: &mut Hasher, + factor_values: &[E], + factor_macs: &[E], + ) -> Result<(E, E), Self::Error>; + + /// Drain the buffered preparation DTO. + fn drain_preparation(&mut self) -> Result; + + /// Produce the backend-specific proof. + fn prove(self) -> Result; +} + +/// Verifier backend for the permutation proof. +pub trait VerifierBackend>: Backend + Sized { + /// Verifier's global key `Δ`. + fn delta(&self) -> E; + + /// Allocate capacity for verifying a permutation of size `n`. + /// + /// May be called multiple times: each call allocates additional + /// capacity on top of prior calls. + /// + /// # Arguments + /// + /// * `n` - Size of the permutation to verify. + fn alloc(&mut self, _n: usize) -> Result<(), Self::Error> { + Ok(()) + } + + /// Consumes the keys for `n` authenticated factors and returns + /// the key for the product wire. + /// + /// On entry, `transcript` is guaranteed to have absorbed + /// `factor_keys`. On return, the implementation must have + /// absorbed any on-wire bytes it received during the call into + /// `transcript`. + /// + /// # Arguments + /// + /// * `transcript` - Shared session transcript. + /// * `factor_keys` - Per-position keys for the factor wires. + fn product(&mut self, transcript: &mut Hasher, factor_keys: &[E]) -> Result; + + /// Install the preparation DTO. + /// + /// # Arguments + /// + /// * `preparation` - The prover-emitted preparation DTO. + fn load_preparation(&mut self, preparation: Self::Preparation); + + /// Verify the backend-specific proof. + /// + /// # Arguments + /// + /// * `proof` - The prover-emitted backend proof DTO. + fn verify(self, proof: Self::BackendProof) -> Result<(), Self::Error>; +} diff --git a/crates/perm-proof-core/src/backend/vole_zk/mod.rs b/crates/perm-proof-core/src/backend/vole_zk/mod.rs new file mode 100644 index 00000000..5277ac13 --- /dev/null +++ b/crates/perm-proof-core/src/backend/vole_zk/mod.rs @@ -0,0 +1,240 @@ +//! VOLE-ZK backend (prover and verifier) built on top of VOLE-ZK +//! authentication with a QuickSilver polynomial proof for fan-in +//! multiplications. + +use mpz_circuits_new::Context; +use mpz_fields::Field; +use mpz_poly_proof_core::{ConstraintId, Constraints}; + +pub mod prover; +pub mod verifier; + +pub use prover::{Preparation, Proof, VoleZkProverBackend, VoleZkProverError}; +pub use verifier::{VoleZkVerifierBackend, VoleZkVerifierError}; + +/// Internal-node count of a fan-in-`d` tree over `n` leaves — +/// `⌈(n−1)/(d−1)⌉`, since each merge takes `d` items into 1 and +/// reducing `n` to `1` requires `n−1` removals. +pub(crate) fn fan_in_tree_internal_nodes(n: usize, d: usize) -> usize { + n.saturating_sub(1).div_ceil(d - 1) +} + +/// Split `[0, n)` into `d`-sized chunks plus the trailing leftover. +pub(crate) fn chunk_ranges_and_leftover(n: usize, d: usize) -> (Vec<(usize, usize)>, Vec) { + let full = n / d; + let chunks: Vec<(usize, usize)> = (0..full).map(|i| (i * d, (i + 1) * d)).collect(); + let leftover: Vec = (full * d..n).collect(); + (chunks, leftover) +} + +/// Build a `Constraints` set holding the single fan-in-product +/// constraint `(x_0 · x_1 · … · x_{n-1}) − prod = 0`. +/// +/// Variable layout: `var(0)…var(n−1)` are the factors, `var(n)` is +/// `prod`. Returns the set alongside the constraint's id. +pub(crate) fn build_product_constraints( + factor_count: usize, +) -> (Constraints, ConstraintId) { + assert!(factor_count >= 1); + let mut b = Constraints::::builder(); + let id = b + .add_dynamic(factor_count + 1, |ctx, vars| { + // `add_dynamic(factor_count + 1, …)` allocates exactly that + // many wires, so the indices below are always in bounds. + let mut product = vars[0]; + for &f in &vars[1..factor_count] { + product = ctx.mul(product, f); + } + ctx.assert_eq(product, vars[factor_count]) + }) + .expect("product constraint shape is well-formed"); + (b.build(), id) +} + +#[cfg(test)] +mod tests { + use super::*; + + use mpz_fields::gf2_128::Gf2_128; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; + + use crate::{ + backend::{ProverBackend, VerifierBackend}, + test_utils::{Committed, commit_values}, + }; + use mpz_vole_core::{ + RVOLEReceiver, RVOLESender, RVOPEReceiver, RVOPESender, + ideal::{ + rvole::{IdealRVOLEReceiver, IdealRVOLESender, ideal_rvole}, + rvope::{IdealRVOPEReceiver, IdealRVOPESender, ideal_rvope}, + }, + }; + + /// Build both VOLE-ZK backends wired to pre-filled ideal correlation + /// providers, sized for a proof of running size `n` with fan-in `eps`. + fn build_pair( + rng_seed: u64, + n: usize, + eps: usize, + ) -> ( + Gf2_128, + VoleZkProverBackend< + Gf2_128, + Gf2_128, + IdealRVOLEReceiver, + IdealRVOPEReceiver, + >, + VoleZkVerifierBackend< + Gf2_128, + Gf2_128, + IdealRVOLESender, + IdealRVOPESender, + >, + ) { + let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); + let delta: Gf2_128 = rng.random(); + let rvole_seed: u64 = rng.random(); + let rvope_seed: u64 = rng.random(); + + // Match what the backend's own `alloc` reserves. + let rvole_count = 2 * fan_in_tree_internal_nodes(n, eps); + let (mut rvole_s, mut rvole_r) = ideal_rvole::(rvole_seed, delta); + <_ as RVOLESender>::alloc(&mut rvole_s, rvole_count).unwrap(); + <_ as RVOLEReceiver>::alloc(&mut rvole_r, rvole_count).unwrap(); + if let Some(msg) = rvole_s.flush() { + rvole_r.flush(msg).unwrap(); + } + + // Pre-fill RVOPE — query a throwaway QS prover. + let (constraints, _) = build_product_constraints::(eps); + let tmp_qs = mpz_poly_proof_core::prover::Prover::::new(&constraints); + let required_vopes = tmp_qs.required_vopes(); + + let (mut rvope_s, mut rvope_r) = ideal_rvope::(rvope_seed, delta); + <_ as RVOPESender>::alloc(&mut rvope_s, 1, required_vopes).unwrap(); + <_ as RVOPEReceiver>::alloc(&mut rvope_r, 1, required_vopes).unwrap(); + for msg in rvope_s.flush() { + rvope_r.flush(msg).unwrap(); + } + + let prover = + VoleZkProverBackend::::new(eps, rvole_r, rvope_r).unwrap(); + let verifier = + VoleZkVerifierBackend::::new(eps, delta, rvole_s, rvope_s) + .unwrap(); + + (delta, prover, verifier) + } + + /// Mode toggle for [`run_pair`]. + #[derive(Clone, Copy)] + enum Mode { + /// Honest prover. + Honest, + /// Dishonest prover. + Dishonest, + } + + /// Drive a prover/verifier pair through the full backend lifecycle + /// against `n` factors with fan-in `eps`. + fn run_pair( + rng_seed: u64, + n: usize, + eps: usize, + mode: Mode, + ) -> Result<(), VoleZkVerifierError> { + let (delta, mut prover, mut verifier) = build_pair(rng_seed, n, eps); + + let mut rng = ChaCha8Rng::seed_from_u64(rng_seed ^ 0xABCD_EF01); + // Width-1 tuples: each position is a singleton Vec. + let mut values: Vec> = (0..n).map(|_| vec![rng.random()]).collect(); + let Committed { + macs: [macs], + keys: [keys], + transcript, + } = commit_values([&values[..]], delta, &mut rng); + + // Tamper AFTER authentication. + if matches!(mode, Mode::Dishonest) { + values[0][0] = values[0][0] + Gf2_128::one(); + } + + prover.alloc(n).unwrap(); + verifier.alloc(n).unwrap(); + + // Transcript is bound to committed values. + let mut tp = transcript.clone(); + let mut tv = transcript; + + // Flatten width-1 tuples into plain slices for the backend. + let flat_values: Vec = values.iter().flatten().copied().collect(); + let flat_macs: Vec = macs.iter().flatten().copied().collect(); + let flat_keys: Vec = keys.iter().flatten().copied().collect(); + + let (prod_value, prod_mac) = prover.product(&mut tp, &flat_values, &flat_macs).unwrap(); + let prep = prover.drain_preparation().unwrap(); + verifier.load_preparation(prep); + let prod_key = verifier.product(&mut tv, &flat_keys).unwrap(); + + if matches!(mode, Mode::Honest) { + assert_eq!( + prod_mac, + prod_key + delta * prod_value, + "IT-MAC invariant violated at fan-in-product root" + ); + } + + let proof = prover.prove().unwrap(); + verifier.verify(proof) + } + + /// Honest prover: each shape exercises a different path through the + /// fan-in tree. + #[test] + fn accepts() { + // Multi-level walk with leftover passthroughs and a terminal short chunk. + run_pair(0xA1, 100, 8, Mode::Honest).expect("honest must accept"); + // ε=2: tightest tree, many small levels. + run_pair(0xC3, 9, 2, Mode::Honest).expect("honest must accept"); + // n == ε: one-level tree, single chunk, no leftover. + run_pair(0xD4, 4, 4, Mode::Honest).expect("honest must accept"); + } + + /// Dishonest prover: tampered input is rejected. + #[test] + fn rejects_tampered_input() { + let err = + run_pair(0xA1, 100, 8, Mode::Dishonest).expect_err("tampered input must be rejected"); + assert!( + matches!(err, VoleZkVerifierError::QsVerify(_)), + "expected QsVerify, got {err:?}" + ); + } + + #[test] + fn test_fan_in_tree_internal_nodes() { + assert_eq!(fan_in_tree_internal_nodes(1, 2), 0); // single leaf, no merge + assert_eq!(fan_in_tree_internal_nodes(8, 2), 7); // 8-leaf binary tree + assert_eq!(fan_in_tree_internal_nodes(8, 8), 1); // one merge of all 8 + assert_eq!(fan_in_tree_internal_nodes(9, 8), 2); // 8 merge + 2-merge + } + + #[test] + fn test_chunk_ranges_and_leftover() { + // Clean split: n is a multiple of d. + assert_eq!( + chunk_ranges_and_leftover(8, 4), + (vec![(0, 4), (4, 8)], vec![]) + ); + // Two full chunks + trailing leftover. + assert_eq!( + chunk_ranges_and_leftover(10, 4), + (vec![(0, 4), (4, 8)], vec![8, 9]) + ); + // n < d: no full chunk fits, everything is leftover. + assert_eq!(chunk_ranges_and_leftover(3, 4), (vec![], vec![0, 1, 2])); + // Single index → single leftover. + assert_eq!(chunk_ranges_and_leftover(1, 4), (vec![], vec![0])); + } +} diff --git a/crates/perm-proof-core/src/backend/vole_zk/prover.rs b/crates/perm-proof-core/src/backend/vole_zk/prover.rs new file mode 100644 index 00000000..a27551f5 --- /dev/null +++ b/crates/perm-proof-core/src/backend/vole_zk/prover.rs @@ -0,0 +1,547 @@ +//! VOLE-ZK prover backend. + +use std::{borrow::Cow, marker::PhantomData}; + +use mpz_common::future::Output; +use mpz_fields::{ExtensionField, Field}; +use mpz_poly_proof_core::ConstraintId; +use serde::{Deserialize, Serialize}; + +use super::{build_product_constraints, chunk_ranges_and_leftover, fan_in_tree_internal_nodes}; +use crate::backend::{Backend, ProverBackend}; +use mpz_vole_core::{ + DerandVOLEReceiver, RVOLEReceiver, RVOPEReceiver, VOLEReceiver, VoleAdjustment, +}; + +/// VOLE-ZK prover backend. +pub struct VoleZkProverBackend +where + W: Field, + E: ExtensionField + ExtensionField, + VL: RVOLEReceiver, + VP: RVOPEReceiver, +{ + /// Fan-in of the product tree: each tree level + /// merges `fan_in_degree` factors into one product wire. + fan_in_degree: usize, + + /// Derandomized full-field VOLE receiver for committing the + /// intermediate product wires. + rvole: DerandVOLEReceiver, + + /// RVOPE receiver for the mask(s) consumed at finalization. + rvope: VP, + + /// QuickSilver polynomial-proof prover. + qs: mpz_poly_proof_core::prover::Prover, + + /// Id of the single product constraint registered with `qs`. + product_constraint: ConstraintId, + + /// Running size of the permutation vector this backend will prove + /// over. + total_count: usize, + + /// Total RVOLE correlations the backend will consume across its + /// lifecycle. + rvoles_alloced: usize, + + /// VoleAdjustments emitted by the tree walk, one per level, in + /// the order the verifier expects. + pending_adjustments: Vec>, + + /// QuickSilver `accumulate` arguments, one per tree level, + /// replayed at finalize. + pending_qs_accumulate: Vec>, + + _phantom: PhantomData, +} + +impl VoleZkProverBackend +where + W: Field, + E: ExtensionField + ExtensionField, + VL: RVOLEReceiver, + VP: RVOPEReceiver, +{ + /// Build a new prover backend. + /// + /// # Arguments + /// + /// * `fan_in_degree` - Fan-in of the product tree (must be ≥ 1). + /// * `rvole` - Random VOLE receiver for committing intermediate + /// product wires. + /// * `rvope` - Random VOPE receiver for the mask consumed at QS + /// finalize time. + pub fn new(fan_in_degree: usize, rvole: VL, mut rvope: VP) -> Result { + if fan_in_degree < 2 { + return Err(VoleZkProverError::InvalidFanIn(fan_in_degree)); + } + let (constraints, product_constraint) = build_product_constraints::(fan_in_degree); + let qs = mpz_poly_proof_core::prover::Prover::new(&constraints); + + rvope + .alloc(1, qs.required_vopes()) + .map_err(|e| VoleZkProverError::RvopeAlloc(Box::new(e)))?; + + Ok(Self { + fan_in_degree, + rvole: DerandVOLEReceiver::new(rvole), + rvope, + qs, + product_constraint, + total_count: 0, + rvoles_alloced: 0, + pending_adjustments: Vec::new(), + pending_qs_accumulate: Vec::new(), + _phantom: PhantomData, + }) + } + + /// Configured fan-in degree. + pub fn fan_in_degree(&self) -> usize { + self.fan_in_degree + } + + /// Running total of RVOLE correlations. + #[cfg(test)] + pub(super) fn rvoles_alloced(&self) -> usize { + self.rvoles_alloced + } +} + +/// Preparation message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Preparation { + /// Per-level VoleAdjustment DTOs, in emission order. + pub adjustments: Vec>, +} + +/// Proof message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Proof { + /// QuickSilver polynomial-proof message. + pub qs_proof: mpz_poly_proof_core::ProofMessage, +} + +impl Backend for VoleZkProverBackend +where + W: Field, + E: ExtensionField + ExtensionField, + VL: RVOLEReceiver, + VP: RVOPEReceiver, +{ + type Error = VoleZkProverError; + type Preparation = Preparation; + type BackendProof = Proof; +} + +impl ProverBackend for VoleZkProverBackend +where + W: Field, + E: ExtensionField + + ExtensionField + + Serialize + + zerocopy::IntoBytes + + zerocopy::FromBytes, + VL: RVOLEReceiver, + VP: RVOPEReceiver, +{ + fn drain_preparation(&mut self) -> Result { + Ok(Preparation { + adjustments: std::mem::take(&mut self.pending_adjustments), + }) + } + + fn prove(mut self) -> Result { + // Step 1: replay the QS accumulate calls that `product` + // buffered earlier. + for DeferredAccumulate { seed, evaluations } in + std::mem::take(&mut self.pending_qs_accumulate) + { + let refs: Vec<(ConstraintId, &[E], &[E])> = evaluations + .iter() + .map(|(id, m, v)| (*id, m.as_slice(), v.as_slice())) + .collect(); + // W = E here: tree-walk wires are already extension-field + // elements (post-`prepare` collapse), so the `accumulate` + // generic uses the trivial `E: ExtensionField` impl. + self.qs + .accumulate::(&refs, seed) + .map_err(|e| VoleZkProverError::QsAccumulate(Box::new(e)))?; + } + + // Step 2: consume the single RVOPE correlation. + let rvope_out = self + .rvope + .try_recv_vope(1) + .map_err(|e| VoleZkProverError::RvopeConsume(Box::new(e)))?; + // Move the coefficients out. + let coeffs = rvope_out + .polynomials + .into_iter() + .next() + .expect("RVOPE try_recv_vope(1) should return exactly one polynomial"); + let vope = mpz_poly_proof_core::ProverVope { coeffs }; + + // Step 3: run QS finalize. + let qs_proof = self + .qs + .finalize(&vope) + .map_err(|e| VoleZkProverError::QsFinalize(Box::new(e)))?; + + Ok(Proof { qs_proof }) + } + + fn alloc(&mut self, n: usize) -> Result<(), Self::Error> { + self.total_count = self.total_count.saturating_add(n); + // 2× because the product is computed for each of the two + // permutation vectors. + let target = 2 * fan_in_tree_internal_nodes(self.total_count, self.fan_in_degree); + let delta = target - self.rvoles_alloced; + if delta > 0 { + self.rvole + .alloc(delta) + .map_err(|e| VoleZkProverError::RvoleAlloc(Box::new(e)))?; + self.rvoles_alloced = target; + } + Ok(()) + } + + fn product( + &mut self, + transcript: &mut blake3::Hasher, + factor_values: &[E], + factor_macs: &[E], + ) -> Result<(E, E), Self::Error> { + if factor_values.len() != factor_macs.len() { + return Err(VoleZkProverError::FactorLengthMismatch { + values: factor_values.len(), + macs: factor_macs.len(), + }); + } + + let n = factor_values.len(); + if n == 0 { + return Err(VoleZkProverError::EmptyInput); + } + if n == 1 { + // Singleton: no chunk to commit, just pass the wire through. + return Ok((factor_values[0], factor_macs[0])); + } + + // Working set for the current tree level. Borrowed from the + // input on iter 0, replaced with an owned Vec at the end of + // every iter — so no upfront copy of the leaves. + let mut current_values: Cow<'_, [E]> = Cow::Borrowed(factor_values); + let mut current_macs: Cow<'_, [E]> = Cow::Borrowed(factor_macs); + let eps = self.fan_in_degree; + + // Walk the tree bottom-up until a single root wire remains. + while current_values.len() > 1 { + let level_size = current_values.len(); + let (mut chunk_ranges, mut passthroughs) = chunk_ranges_and_leftover(level_size, eps); + // Terminal level (`level_size < eps`): no next iteration to + // defer leftover to, so commit it here as a single short + // chunk. + if chunk_ranges.is_empty() { + chunk_ranges = vec![(0, level_size)]; + passthroughs = Vec::new(); + } + + // Compute each chunk's cleartext product. Pre-sized to fit + // the trailing passthroughs we'll append at end-of-iter, + // so the next-level assembly is realloc-free. + let mut chunk_products: Vec = + Vec::with_capacity(chunk_ranges.len() + passthroughs.len()); + chunk_products.extend(chunk_ranges.iter().map(|&(start, end)| { + current_values[start..end] + .iter() + .copied() + .fold(E::one(), |acc, x| acc * x) + })); + + // Commit products. + let mut batch_fut = self + .rvole + .queue_recv_vole(&chunk_products) + .map_err(|e| VoleZkProverError::RvoleAlloc(Box::new(e)))?; + + let adjustment = self + .rvole + .adjust() + .map_err(|e| VoleZkProverError::RvoleAlloc(Box::new(e)))?; + + let mut prod_macs: Vec = batch_fut + .try_recv() + .expect("VOLE adjust() should not cancel queued futures") + .expect("VOLE future should resolve after adjust() returns") + .macs; + + transcript.update(b"permutation-proof::vole-adjustment"); + transcript.update(&bcs::to_bytes(&adjustment).expect("serialize")); + + // Buffer the DTO for transport. + self.pending_adjustments.push(adjustment); + + // Build the QS accumulate inputs. For each chunk, assemble + // one evaluation with `ε + 1` variables: ε factor slots + // followed by the prod slot. + let mut chunk_macs_store: Vec> = Vec::with_capacity(chunk_ranges.len()); + let mut chunk_values_store: Vec> = Vec::with_capacity(chunk_ranges.len()); + for (i, &(start, end)) in chunk_ranges.iter().enumerate() { + let real_count = end - start; + let mut macs = Vec::with_capacity(eps + 1); + let mut values = Vec::with_capacity(eps + 1); + macs.extend_from_slice(¤t_macs[start..end]); + values.extend_from_slice(¤t_values[start..end]); + // Pad short chunks up to ε with the public-coefficient + // 1: value = 1 leaves the circuit's product unchanged + // (1 is the multiplicative identity); MAC = 0 is the + // IT-MAC encoding, pinned by `key = -Δ` on the verifier + // side. Only ever fires for the last chunk — full + // chunks have `real_count == ε` and the loop is empty. + for _ in real_count..eps { + macs.push(::zero()); + values.push(::one()); + } + macs.push(prod_macs[i]); + values.push(chunk_products[i]); + chunk_macs_store.push(macs); + chunk_values_store.push(values); + } + + // Draw a fresh PRG seed for this tree-walk level. + let seed = crate::draw_seed(transcript, b"permutation-proof::qs-seed"); + let evaluations: Vec<(ConstraintId, Vec, Vec)> = chunk_macs_store + .into_iter() + .zip(chunk_values_store) + .map(|(m, v)| (self.product_constraint, m, v)) + .collect(); + self.pending_qs_accumulate + .push(DeferredAccumulate { seed, evaluations }); + + // Assemble the next level. + chunk_products.extend(passthroughs.iter().map(|&idx| current_values[idx])); + prod_macs.extend(passthroughs.iter().map(|&idx| current_macs[idx])); + + current_values = Cow::Owned(chunk_products); + current_macs = Cow::Owned(prod_macs); + } + + Ok((current_values[0], current_macs[0])) + } +} + +/// One tree-walk level's deferred QuickSilver accumulate call. +struct DeferredAccumulate { + /// PRG seed drawn from the transcript. + seed: [u8; 32], + /// Per-chunk `(constraint_id, macs, values)` tuples. + evaluations: Vec<(ConstraintId, Vec, Vec)>, +} + +/// Errors produced by [`VoleZkProverBackend`]. +#[derive(Debug, thiserror::Error)] +pub enum VoleZkProverError { + /// The underlying RVOLE provider's `alloc` rejected the request. + #[error("RVOLE alloc failed: {0}")] + RvoleAlloc(#[source] Box), + + /// The underlying RVOPE provider's `alloc` rejected the request. + #[error("RVOPE alloc failed: {0}")] + RvopeAlloc(#[source] Box), + + /// The QuickSilver prover rejected an `accumulate` call. + #[error("QuickSilver accumulate failed: {0}")] + QsAccumulate(#[source] Box), + + /// The RVOPE provider failed to deliver the pre-allocated correlation + /// when QS finalize tried to consume it. + #[error("RVOPE consume failed: {0}")] + RvopeConsume(#[source] Box), + + /// The QuickSilver prover rejected the `finalize` call. + #[error("QuickSilver finalize failed: {0}")] + QsFinalize(#[source] Box), + + /// `fan_in_degree` was less than the minimum supported value (2). + #[error("fan_in_degree must be at least 2; got {0}")] + InvalidFanIn(usize), + + /// `factor_values` and `factor_macs` lengths disagree at `product`. + #[error("factor_values and factor_macs lengths disagree: values={values}, macs={macs}")] + FactorLengthMismatch { + /// Length of the `factor_values` slice. + values: usize, + /// Length of the `factor_macs` slice. + macs: usize, + }, + + /// `product` was called with empty factor slices. + #[error("product called with empty factor slices")] + EmptyInput, +} + +#[cfg(test)] +mod tests { + use super::*; + + use mpz_fields::gf2_128::Gf2_128; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; + + use crate::backend::ProverBackend; + use mpz_vole_core::{ + RVOLEReceiver, RVOLESender, + ideal::{ + rvole::{IdealRVOLEReceiver, ideal_rvole}, + rvope::IdealRVOPEReceiver, + }, + }; + + /// Build a prover-only backend with no pre-filled correlations. + /// For tests that exercise bookkeeping. + fn build_prover_only( + rng_seed: u64, + eps: usize, + ) -> VoleZkProverBackend< + Gf2_128, + Gf2_128, + IdealRVOLEReceiver, + IdealRVOPEReceiver, + > { + let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); + let delta: Gf2_128 = rng.random(); + let rvole_seed: u64 = rng.random(); + let rvope_seed: u64 = rng.random(); + let (_rvole_s, rvole_r) = ideal_rvole::(rvole_seed, delta); + let rvope_r = IdealRVOPEReceiver::::new(rvope_seed); + VoleZkProverBackend::::new(eps, rvole_r, rvope_r).unwrap() + } + + /// Build a prover-only backend with RVOLE pre-filled. + fn build_prover_with_correlations( + rng_seed: u64, + n: usize, + eps: usize, + ) -> VoleZkProverBackend< + Gf2_128, + Gf2_128, + IdealRVOLEReceiver, + IdealRVOPEReceiver, + > { + let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); + let delta: Gf2_128 = rng.random(); + let rvole_seed: u64 = rng.random(); + let rvope_seed: u64 = rng.random(); + + let rvole_count = 2 * fan_in_tree_internal_nodes(n, eps); + let (mut rvole_s, mut rvole_r) = ideal_rvole::(rvole_seed, delta); + <_ as RVOLESender>::alloc(&mut rvole_s, rvole_count).unwrap(); + <_ as RVOLEReceiver>::alloc(&mut rvole_r, rvole_count).unwrap(); + if let Some(msg) = rvole_s.flush() { + rvole_r.flush(msg).unwrap(); + } + + let rvope_r = IdealRVOPEReceiver::::new(rvope_seed); + VoleZkProverBackend::::new(eps, rvole_r, rvope_r).unwrap() + } + + /// Test cumulative-alloc contract. + #[test] + fn alloc_forwards_cumulative_deltas() { + let mut prover = build_prover_only(0xE5, 8); + assert_eq!(prover.rvoles_alloced(), 0); + + // running n=5 → internal_nodes(5,8)=1, target=2, delta=+2 + prover.alloc(5).unwrap(); + assert_eq!(prover.rvoles_alloced(), 2); + + // running n=12 → internal_nodes(12,8)=2, target=4, delta=+2 + prover.alloc(7).unwrap(); + assert_eq!(prover.rvoles_alloced(), 4); + + // running n=16 → internal_nodes(16,8)=3, target=6, delta=+2 + prover.alloc(4).unwrap(); + assert_eq!(prover.rvoles_alloced(), 6); + } + + /// Empty-input short-circuit. + #[test] + fn product_rejects_empty_input() { + let mut prover = build_prover_only(0xE6, 8); + let mut transcript = blake3::Hasher::new(); + let err = prover + .product(&mut transcript, &[], &[]) + .expect_err("empty input must surface an error"); + assert!( + matches!(err, VoleZkProverError::EmptyInput), + "expected EmptyInput, got {err:?}" + ); + } + + /// Singleton short-circuit. + #[test] + fn product_passes_through_singleton() { + let mut prover = build_prover_only(0xE7, 8); + let mut rng = ChaCha8Rng::seed_from_u64(0xE7_1234); + let v: Gf2_128 = rng.random(); + let m: Gf2_128 = rng.random(); + + let mut transcript = blake3::Hasher::new(); + let (ret_v, ret_m) = prover + .product(&mut transcript, &[v], &[m]) + .expect("singleton passthrough must succeed"); + assert_eq!(ret_v, v); + assert_eq!(ret_m, m); + + let prep = prover.drain_preparation().unwrap(); + assert!( + prep.adjustments.is_empty(), + "singleton passthrough must not buffer any adjustments" + ); + } + + /// `product`'s root value must equal the total plaintext + /// product of its input factors. + #[test] + fn product_computes_correct_root() { + let cases = [ + (2, 2), // minimal tree + (10, 2), // multi-level tight tree + (10, 3), // leftover=1 at leaves + (10, 4), // leftover=2 at leaves, padded + (27, 3), // clean power-of-3 tree, every level splits exactly + (100, 8), // moderate size matching a pair-test shape + ]; + + for (n, eps) in cases { + let mut prover = build_prover_with_correlations( + 0xF00D_u64.wrapping_add((n as u64) * 31 + eps as u64), + n, + eps, + ); + + let mut rng = + ChaCha8Rng::seed_from_u64(0xBEEF_u64.wrapping_add((n as u64) * 17 + eps as u64)); + let values: Vec = (0..n).map(|_| rng.random()).collect(); + let macs: Vec = (0..n).map(|_| rng.random()).collect(); + + prover.alloc(n).unwrap(); + let mut transcript = blake3::Hasher::new(); + let (prod_value, _prod_mac) = prover + .product(&mut transcript, &values, &macs) + .unwrap_or_else(|e| panic!("(n={n}, eps={eps}): {e:?}")); + + let expected = values + .iter() + .copied() + .fold(Gf2_128::one(), |acc, x| acc * x); + assert_eq!( + prod_value, expected, + "(n={n}, eps={eps}): root value must equal the total product" + ); + } + } +} diff --git a/crates/perm-proof-core/src/backend/vole_zk/verifier.rs b/crates/perm-proof-core/src/backend/vole_zk/verifier.rs new file mode 100644 index 00000000..9140ab3c --- /dev/null +++ b/crates/perm-proof-core/src/backend/vole_zk/verifier.rs @@ -0,0 +1,484 @@ +//! VOLE-ZK verifier backend. + +use std::{borrow::Cow, collections::VecDeque, marker::PhantomData}; + +use mpz_fields::{ExtensionField, Field}; +use mpz_poly_proof_core::ConstraintId; +use serde::Serialize; + +use super::{ + Preparation, Proof, build_product_constraints, chunk_ranges_and_leftover, + fan_in_tree_internal_nodes, +}; +use crate::backend::{Backend, VerifierBackend}; +use mpz_vole_core::{RVOLESender, RVOPESender, VoleAdjustment}; + +/// VOLE-ZK verifier backend. +pub struct VoleZkVerifierBackend +where + W: Field, + E: ExtensionField + ExtensionField, + VL: RVOLESender, + VP: RVOPESender, +{ + /// Fan-in of the product tree: each tree level + /// merges `fan_in_degree` factors into one product wire. + /// + /// Must match the prover's setting or the proof will fail. + fan_in_degree: usize, + + /// Verifier's global key. + delta: E, + + /// RVOLE sender. + rvole: VL, + + /// RVOPE sender for the mask(s) consumed at finalization. + rvope: VP, + + /// QuickSilver polynomial-proof verifier. + qs: mpz_poly_proof_core::verifier::Verifier, + + /// Id of the single product constraint registered with `qs`. + product_constraint: ConstraintId, + + /// Running size of the permutation vector this backend will verify + /// over. + total_count: usize, + + /// Total RVOLE correlations the backend will consume across its + /// lifecycle. + rvoles_alloced: usize, + + /// VoleAdjustments loaded from the prover's [`Preparation`]. + adjustments: VecDeque>, + + _phantom: PhantomData, +} + +impl VoleZkVerifierBackend +where + W: Field, + E: ExtensionField + ExtensionField, + VL: RVOLESender, + VP: RVOPESender, +{ + /// Build a new verifier backend. + /// + /// # Arguments + /// + /// * `fan_in_degree` - Fan-in of the product tree (must be ≥ 1). + /// * `delta` - Verifier's global key. + /// * `rvole` - Random VOLE sender for committing intermediate + /// product wires. + /// * `rvope` - Random VOPE sender for the mask consumed at QS + /// finalize time. + pub fn new( + fan_in_degree: usize, + delta: E, + rvole: VL, + mut rvope: VP, + ) -> Result { + if fan_in_degree < 2 { + return Err(VoleZkVerifierError::InvalidFanIn(fan_in_degree)); + } + let (constraints, product_constraint) = build_product_constraints::(fan_in_degree); + let qs = mpz_poly_proof_core::verifier::Verifier::new(delta, &constraints); + + rvope + .alloc(1, qs.required_vopes()) + .map_err(|e| VoleZkVerifierError::RvopeAlloc(Box::new(e)))?; + + Ok(Self { + fan_in_degree, + delta, + rvole, + rvope, + qs, + product_constraint, + total_count: 0, + rvoles_alloced: 0, + adjustments: VecDeque::new(), + _phantom: PhantomData, + }) + } + + /// Configured fan-in degree. + pub fn fan_in_degree(&self) -> usize { + self.fan_in_degree + } + + /// Verifier's `Δ`. + pub fn delta_value(&self) -> E { + self.delta + } + + /// Running total of RVOLE correlations. + #[cfg(test)] + pub(super) fn rvoles_alloced(&self) -> usize { + self.rvoles_alloced + } +} + +impl Backend for VoleZkVerifierBackend +where + W: Field, + E: ExtensionField + ExtensionField, + VL: RVOLESender, + VP: RVOPESender, +{ + type Error = VoleZkVerifierError; + type Preparation = Preparation; + type BackendProof = Proof; +} + +impl VerifierBackend for VoleZkVerifierBackend +where + W: Field, + E: ExtensionField + + ExtensionField + + Serialize + + zerocopy::IntoBytes + + zerocopy::FromBytes, + VL: RVOLESender, + VP: RVOPESender, +{ + fn delta(&self) -> E { + self.delta + } + + fn load_preparation(&mut self, preparation: Self::Preparation) { + self.adjustments = preparation.adjustments.into(); + } + + fn verify(mut self, proof: Self::BackendProof) -> Result<(), Self::Error> { + let Proof { qs_proof } = proof; + + // Step 1: consume the single pre-alloc'd RVOPE correlation. + let rvope_out = self + .rvope + .try_send_vope(1) + .map_err(|e| VoleZkVerifierError::RvopeConsume(Box::new(e)))?; + let sum = rvope_out + .evaluations + .into_iter() + .next() + .expect("RVOPE try_send_vope(1) should return exactly one evaluation"); + let vope = mpz_poly_proof_core::VerifierVope { sum }; + + // Step 2: run QS finalize. + self.qs + .finalize(&qs_proof, &vope) + .map_err(|e| VoleZkVerifierError::QsVerify(Box::new(e))) + } + + fn alloc(&mut self, n: usize) -> Result<(), Self::Error> { + self.total_count = self.total_count.saturating_add(n); + // 2× because the product is computed for each of the two + // permutation vectors. + let target = 2 * fan_in_tree_internal_nodes(self.total_count, self.fan_in_degree); + let delta = target - self.rvoles_alloced; + if delta > 0 { + self.rvole + .alloc(delta) + .map_err(|e| VoleZkVerifierError::RvoleAlloc(Box::new(e)))?; + self.rvoles_alloced = target; + } + Ok(()) + } + + fn product( + &mut self, + transcript: &mut blake3::Hasher, + factor_keys: &[E], + ) -> Result { + let n = factor_keys.len(); + if n == 0 { + return Err(VoleZkVerifierError::EmptyInput); + } + if n == 1 { + return Ok(factor_keys[0]); + } + + // Working set for the current tree level. Borrowed from the + // input on iter 0, replaced with an owned Vec at the end of + // every iter — so no upfront copy of the leaves. + let mut current_keys: Cow<'_, [E]> = Cow::Borrowed(factor_keys); + let eps = self.fan_in_degree; + + while current_keys.len() > 1 { + let level_size = current_keys.len(); + let (mut chunk_ranges, mut passthroughs) = chunk_ranges_and_leftover(level_size, eps); + // Terminal level (`level_size < eps`): no next iteration to + // defer leftover to, so commit it here as a single short + // chunk. + if chunk_ranges.is_empty() { + chunk_ranges = vec![(0, level_size)]; + passthroughs = Vec::new(); + } + let n_chunks = chunk_ranges.len(); + + // Consume the prover's adjustment for this level. + let adjustment = self + .adjustments + .pop_front() + .ok_or(VoleZkVerifierError::AdjustmentUnderflow)?; + if adjustment.diffs.len() != n_chunks { + return Err(VoleZkVerifierError::AdjustmentShapeMismatch { + expected: n_chunks, + actual: adjustment.diffs.len(), + }); + } + + // Absorb into the transcript. + transcript.update(b"permutation-proof::vole-adjustment"); + transcript.update(&bcs::to_bytes(&adjustment).expect("serialize")); + + // Consume and derandomize random VOLEs. + let rvole_out = self + .rvole + .try_send_vole(n_chunks) + .map_err(|e| VoleZkVerifierError::RvoleConsume(Box::new(e)))?; + + // Pre-sized to fit the trailing passthroughs we'll append at + // end-of-iter, so the next-level assembly is realloc-free. + let mut prod_keys: Vec = Vec::with_capacity(n_chunks + passthroughs.len()); + prod_keys.extend( + rvole_out + .keys + .iter() + .zip(&adjustment.diffs) + .map(|(k, d)| *k - self.delta * *d), + ); + + // Build the QS accumulate inputs for this level, one + // evaluation per chunk with ε+1 keys. + let neg_delta = -self.delta; + let mut chunk_keys_store: Vec> = Vec::with_capacity(n_chunks); + for (i, &(start, end)) in chunk_ranges.iter().enumerate() { + let real_count = end - start; + let mut keys = Vec::with_capacity(eps + 1); + keys.extend_from_slice(¤t_keys[start..end]); + for _ in real_count..eps { + // Padding convention: the prover uses (value=1, mac=0). + // Under the invariant `mac = key + Δ·value`, that forces + // the matching key to `mac − Δ·value = −Δ·1 = −Δ`. + // Only ever fires for the last chunk — full + // chunks have `real_count == ε` and the loop is empty. + keys.push(neg_delta); + } + keys.push(prod_keys[i]); + chunk_keys_store.push(keys); + } + + // Draw a fresh PRG seed for this tree-walk level. + let seed = crate::draw_seed(transcript, b"permutation-proof::qs-seed"); + let evaluations: Vec<(ConstraintId, &[E])> = chunk_keys_store + .iter() + .map(|k| (self.product_constraint, k.as_slice())) + .collect(); + self.qs + .accumulate(&evaluations, seed) + .map_err(|e| VoleZkVerifierError::QsAccumulate(Box::new(e)))?; + + // Assemble the next level. + prod_keys.extend(passthroughs.iter().map(|&idx| current_keys[idx])); + + current_keys = Cow::Owned(prod_keys); + } + + Ok(current_keys[0]) + } +} + +/// Errors produced by [`VoleZkVerifierBackend`]. +#[derive(Debug, thiserror::Error)] +pub enum VoleZkVerifierError { + /// The underlying RVOLE provider's `alloc` rejected the request. + #[error("RVOLE alloc failed: {0}")] + RvoleAlloc(#[source] Box), + + /// The underlying RVOPE provider's `alloc` rejected the request. + #[error("RVOPE alloc failed: {0}")] + RvopeAlloc(#[source] Box), + + /// The QuickSilver verifier rejected an `accumulate` call. + #[error("QuickSilver accumulate failed: {0}")] + QsAccumulate(#[source] Box), + + /// The RVOPE provider failed to deliver the pre-allocated correlation. + #[error("RVOPE consume failed: {0}")] + RvopeConsume(#[source] Box), + + /// The underlying RVOLE provider failed to produce preprocessed + /// correlations. + #[error("RVOLE consume failed: {0}")] + RvoleConsume(#[source] Box), + + /// The QuickSilver polynomial-proof verifier rejected the `finalize` + /// call. + #[error("QuickSilver verify failed: {0}")] + QsVerify(#[source] Box), + + /// The verifier's tree walk needed another VoleAdjustment but the + /// loaded proof had none left. + #[error("ran out of VoleAdjustments while walking the fan-in tree")] + AdjustmentUnderflow, + + /// A consumed VoleAdjustment's `diffs` length disagreed with the + /// number of chunks the verifier expected at this tree level. + #[error( + "VoleAdjustment shape mismatch: expected {expected} diffs for this level, got {actual}" + )] + AdjustmentShapeMismatch { + /// Number of chunks the verifier expected at this level. + expected: usize, + /// Number of diffs actually present in the adjustment. + actual: usize, + }, + + /// `fan_in_degree` was less than the minimum supported value (2). + #[error("fan_in_degree must be at least 2; got {0}")] + InvalidFanIn(usize), + + /// `product` was called with an empty factor-keys slice. + #[error("product called with empty factor-keys slice")] + EmptyInput, +} + +#[cfg(test)] +mod tests { + use super::*; + + use mpz_fields::gf2_128::Gf2_128; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; + + use crate::backend::VerifierBackend; + use mpz_vole_core::ideal::{ + rvole::{IdealRVOLESender, ideal_rvole}, + rvope::IdealRVOPESender, + }; + + /// Build a verifier-only backend with no pre-filled correlations. + /// For tests that exercise bookkeeping. + fn build_verifier_only( + rng_seed: u64, + eps: usize, + ) -> VoleZkVerifierBackend, IdealRVOPESender> + { + let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); + let delta: Gf2_128 = rng.random(); + let rvole_seed: u64 = rng.random(); + let rvope_seed: u64 = rng.random(); + let (rvole_s, _rvole_r) = ideal_rvole::(rvole_seed, delta); + let rvope_s = IdealRVOPESender::::new(rvope_seed, delta); + VoleZkVerifierBackend::::new(eps, delta, rvole_s, rvope_s).unwrap() + } + + /// Test cumulative-alloc contract. + #[test] + fn alloc_forwards_cumulative_deltas() { + let mut verifier = build_verifier_only(0xE5, 8); + assert_eq!(verifier.rvoles_alloced(), 0); + + // running n=5 → internal_nodes(5,8)=1, target=2, delta=+2 + verifier.alloc(5).unwrap(); + assert_eq!(verifier.rvoles_alloced(), 2); + + // running n=12 → internal_nodes(12,8)=2, target=4, delta=+2 + verifier.alloc(7).unwrap(); + assert_eq!(verifier.rvoles_alloced(), 4); + + // running n=16 → internal_nodes(16,8)=3, target=6, delta=+2 + verifier.alloc(4).unwrap(); + assert_eq!(verifier.rvoles_alloced(), 6); + } + + /// Empty-input short-circuit. + #[test] + fn product_rejects_empty_input() { + let mut verifier = build_verifier_only(0xE6, 8); + let mut transcript = blake3::Hasher::new(); + let err = verifier + .product(&mut transcript, &[]) + .expect_err("empty input must surface an error"); + assert!( + matches!(err, VoleZkVerifierError::EmptyInput), + "expected EmptyInput, got {err:?}" + ); + } + + /// Singleton short-circuit. + #[test] + fn product_passes_through_singleton() { + let mut verifier = build_verifier_only(0xE7, 8); + let mut rng = ChaCha8Rng::seed_from_u64(0xE7_1234); + let k: Gf2_128 = rng.random(); + + // Load an empty `Preparation` — the singleton path must not + // pop any adjustments off the queue. + verifier.load_preparation(Preparation { + adjustments: vec![], + }); + + let mut transcript = blake3::Hasher::new(); + let ret_k = verifier + .product(&mut transcript, &[k]) + .expect("singleton passthrough must succeed"); + assert_eq!(ret_k, k); + } + + /// Test `VoleAdjustment` underflow. + #[test] + fn product_underflow_on_short_preparation() { + let mut verifier = build_verifier_only(0xE8, 4); + // `n = 4, eps = 4`: tree walks exactly one level (one chunk). + // Loading an empty Preparation means the first `pop_front` at + // level 0 returns `None` → AdjustmentUnderflow. + verifier.load_preparation(Preparation { + adjustments: vec![], + }); + + let mut rng = ChaCha8Rng::seed_from_u64(0xE8_1234); + let keys: Vec = (0..4).map(|_| rng.random()).collect(); + + let mut transcript = blake3::Hasher::new(); + let err = verifier + .product(&mut transcript, &keys) + .expect_err("short Preparation must surface an error"); + assert!( + matches!(err, VoleZkVerifierError::AdjustmentUnderflow), + "expected AdjustmentUnderflow, got {err:?}" + ); + } + + /// Per-level shape check. + #[test] + fn product_shape_mismatch_on_wrong_diffs_length() { + let mut verifier = build_verifier_only(0xE9, 4); + // `n = 4, eps = 4`: level 0 has exactly one chunk, so the + // matching adjustment must carry `diffs.len() == 1`. Load one + // with 2 diffs to trip the shape check. + verifier.load_preparation(Preparation { + adjustments: vec![mpz_vole_core::VoleAdjustment { + diffs: vec![Gf2_128::one(), Gf2_128::one()], + }], + }); + + let mut rng = ChaCha8Rng::seed_from_u64(0xE9_1234); + let keys: Vec = (0..4).map(|_| rng.random()).collect(); + + let mut transcript = blake3::Hasher::new(); + let err = verifier + .product(&mut transcript, &keys) + .expect_err("wrong-shape adjustment must surface an error"); + match err { + VoleZkVerifierError::AdjustmentShapeMismatch { expected, actual } => { + assert_eq!(expected, 1, "expected 1 chunk at level 0"); + assert_eq!(actual, 2, "loaded adjustment had 2 diffs"); + } + other => panic!("expected AdjustmentShapeMismatch, got {other:?}"), + } + } +} diff --git a/crates/perm-proof-core/src/lib.rs b/crates/perm-proof-core/src/lib.rs new file mode 100644 index 00000000..db96f0f8 --- /dev/null +++ b/crates/perm-proof-core/src/lib.rs @@ -0,0 +1,119 @@ +//! Permutation proof protocol. +//! +//! Given two vectors `x, y ∈ Eⁿ` of authenticated wires, this crate +//! proves `x ~ y` (i.e. that one is a permutation of the other) using +//! the standard polynomial identity test over a random challenge drawn +//! from the verifier. + +#![deny(missing_docs)] + +use blake3::Hasher; +use hybrid_array::Array; +use mpz_fields::Field; +use serde::{Deserialize, Serialize}; + +pub mod backend; +pub mod prover; +pub mod verifier; + +/// Test-support utilities. +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; + +pub use backend::{ + ProverBackend, VerifierBackend, + vole_zk::{VoleZkProverBackend, VoleZkProverError, VoleZkVerifierBackend, VoleZkVerifierError}, +}; +pub use mpz_vole_core::{ + DerandVOLEReceiver, DerandVOLEReceiverError, RVOLEReceiver, RVOLEReceiverOutput, RVOLESender, + RVOLESenderOutput, RVOPEReceiver, RVOPEReceiverOutput, RVOPESender, RVOPESenderOutput, + VOLEReceiver, VOLEReceiverOutput, VoleAdjustment, +}; +pub use prover::{ProveError, Prover}; +pub use verifier::{Verifier, VerifyError}; + +/// Bundle of proof data the prover ships to the verifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Proof { + /// MAC opening for the (single) zero-check the protocol runs over + /// the difference of the two authenticated product wires. + pub zero_proof: E, + /// Backend-specific supplementary proof. + pub backend_proof: BackendProof, +} + +/// Draw a uniform extension-field element from the transcript under a +/// domain-separation label. +pub(crate) fn draw_field(transcript: &mut Hasher, label: &[u8]) -> E { + transcript.update(label); + let mut buf: Array = Array::default(); + transcript.finalize_xof().fill(buf.as_mut_slice()); + E::try_from(buf).expect("uniform bytes are a field element") +} + +/// Draw a 32-byte PRG seed from the transcript under a +/// domain-separation label. +pub(crate) fn draw_seed(transcript: &mut Hasher, label: &[u8]) -> [u8; 32] { + transcript.update(label); + let mut buf = [0u8; 32]; + transcript.finalize_xof().fill(&mut buf); + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + use mpz_fields::gf2_128::Gf2_128; + + /// Determinism: two hashers in identical state, drawn with the + /// same label, must produce the same field element. + #[test] + fn draw_field_is_deterministic_on_identical_transcripts() { + let mut a = Hasher::new(); + let mut b = Hasher::new(); + a.update(b"shared-prefix"); + b.update(b"shared-prefix"); + + let x: Gf2_128 = draw_field(&mut a, b"label"); + let y: Gf2_128 = draw_field(&mut b, b"label"); + assert_eq!( + x, y, + "same transcript state + same label must yield same draw" + ); + } + + /// Domain separation: two draws from identical starting state but + /// under different labels must produce different field elements + /// with overwhelming probability. + #[test] + fn draw_field_separates_domains_by_label() { + let mut a = Hasher::new(); + let mut b = Hasher::new(); + a.update(b"shared-prefix"); + b.update(b"shared-prefix"); + + let x: Gf2_128 = draw_field(&mut a, b"label-one"); + let y: Gf2_128 = draw_field(&mut b, b"label-two"); + assert_ne!( + x, y, + "different labels on same state must yield different draws" + ); + } + + /// Sequential uniqueness: two consecutive draws with the *same* + /// label on the same transcript must produce different field + /// elements. + #[test] + fn draw_field_is_sequentially_unique_under_same_label() { + let mut transcript = Hasher::new(); + transcript.update(b"shared-prefix"); + + let x: Gf2_128 = draw_field(&mut transcript, b"label"); + let y: Gf2_128 = draw_field(&mut transcript, b"label"); + assert_ne!( + x, y, + "consecutive draws under same label must still diverge" + ); + } +} diff --git a/crates/perm-proof-core/src/prover.rs b/crates/perm-proof-core/src/prover.rs new file mode 100644 index 00000000..80ff49d0 --- /dev/null +++ b/crates/perm-proof-core/src/prover.rs @@ -0,0 +1,444 @@ +//! Permutation protocol prover. + +use blake3::Hasher; +use mpz_fields::{ExtensionField, Field}; +use serde::Serialize; + +use crate::{Proof, backend::ProverBackend, draw_field}; + +/// Permutation protocol prover. +pub struct Prover +where + W: Field, + E: ExtensionField, + B: ProverBackend, + S: prover_state::State, +{ + backend: B, + state: S, + _phantom: std::marker::PhantomData<(W, E)>, +} + +impl Prover +where + W: Field, + E: ExtensionField, + B: ProverBackend, +{ + /// Build a new prover around `backend`. + pub fn new(backend: B) -> Self { + Self { + backend, + state: prover_state::Initialized, + _phantom: std::marker::PhantomData, + } + } + + /// Announce that a permutation proof of size `n` will run through + /// this prover. + /// + /// Multiple calls accumulate. + pub fn alloc(&mut self, n: usize) -> Result<(), B::Error> { + self.backend.alloc(n) + } + + /// Compute the preparation message for phase 1 of the protocol. + /// + /// # Arguments + /// + /// * `transcript` - Shared session transcript. + /// * `x` - Pair `(values, macs)` for the first input vector. + /// * `y` - Pair `(values, macs)` for the second input vector. + /// + /// # Security + /// + /// It is crucial that `transcript` has absorbed the input vectors + /// `x` and `y` before this method is invoked. The protocol's + /// soundness depends on this binding. + pub fn prepare( + mut self, + mut transcript: Hasher, + x: (&[Vec], &[Vec]), + y: (&[Vec], &[Vec]), + ) -> Result<(B::Preparation, Prover>), ProveError> + { + let (x_values, x_macs) = x; + let (y_values, y_macs) = y; + + let xv = x_values.len(); + let xm = x_macs.len(); + let yv = y_values.len(); + let ym = y_macs.len(); + if xv != xm || yv != ym || xv != yv { + return Err(ProveError::LengthMismatch { xv, xm, yv, ym }); + } + let n = xv; + if n == 0 { + return Err(ProveError::EmptyInputs); + } + + // Tuple width: read from the first input tuple. + let tuple_width = x_values[0].len(); + if tuple_width == 0 { + return Err(ProveError::EmptyInputs); + } + + // Uniformity: every tuple (across both x and y, values and + // macs) must have the same width. + let all_uniform = x_values.iter().all(|v| v.len() == tuple_width) + && x_macs.iter().all(|m| m.len() == tuple_width) + && y_values.iter().all(|v| v.len() == tuple_width) + && y_macs.iter().all(|m| m.len() == tuple_width); + if !all_uniform { + return Err(ProveError::TupleWidthMismatch); + } + + // Draw the random challenge `r`. + let r = draw_field::(&mut transcript, b"permutation-proof::challenge_r"); + + // Draw the tuple-collapse challenge `s ∈ E^tuple_width`. + let s: Vec = (0..tuple_width) + .map(|_| draw_field::(&mut transcript, b"permutation-proof::challenge_s")) + .collect(); + + // Compute per-position collapsed factors. + // + // z_val = Σ_j s[j] · values[i][j].embed() (in E) + // z_mac = Σ_j s[j] · macs[i][j] (in E) + // factor_value = r − z_val + // factor_mac = −z_mac // r contributes no MAC; the `−` carries through. + let mut fx_values: Vec = Vec::with_capacity(n); + let mut fx_macs: Vec = Vec::with_capacity(n); + let mut fy_values: Vec = Vec::with_capacity(n); + let mut fy_macs: Vec = Vec::with_capacity(n); + + for i in 0..n { + let (zx_val, zx_mac) = collapse_tuple(&s, &x_values[i], &x_macs[i]); + let (zy_val, zy_mac) = collapse_tuple(&s, &y_values[i], &y_macs[i]); + fx_values.push(r - zx_val); + fx_macs.push(-zx_mac); + fy_values.push(r - zy_val); + fy_macs.push(-zy_mac); + } + + // Commit the authenticated product of each vectors's factors. + let (_, px_m) = self + .backend + .product(&mut transcript, &fx_values, &fx_macs) + .map_err(ProveError::Backend)?; + let (_, py_m) = self + .backend + .product(&mut transcript, &fy_values, &fy_macs) + .map_err(ProveError::Backend)?; + + // Drain the preparation message now so the caller can ship it + // to the verifier immediately. + let preparation = self + .backend + .drain_preparation() + .map_err(ProveError::Backend)?; + + Ok(( + preparation, + Prover { + backend: self.backend, + state: prover_state::Prepared { + transcript, + px_m, + py_m, + }, + _phantom: std::marker::PhantomData, + }, + )) + } +} + +impl Prover> +where + W: Field, + E: ExtensionField, + B: ProverBackend, +{ + /// Return the proof message for phase 2 of the protocol. + pub fn prove(self) -> Result, ProveError> + where + E: Serialize, + B::BackendProof: Serialize, + { + let Prover { backend, state, .. } = self; + let prover_state::Prepared { + mut transcript, + px_m, + py_m, + } = state; + + // Materialize the zero-check: the difference MAC is what the + // verifier checks against its own diff_k. Under an honest + // permutation, the underlying value diff is zero. + let zero_proof = px_m - py_m; + + // Absorb the proof into the transcript so any subsequent proof + // sharing this transcript stays bound to this one. + let backend_proof = backend.prove().map_err(ProveError::Backend)?; + let proof = Proof { + zero_proof, + backend_proof, + }; + transcript.update(b"permutation-proof::proof"); + transcript.update(&bcs::to_bytes(&proof).expect("serialize")); + + Ok(proof) + } +} + +/// Type-state markers for [`Prover`]'s phase. +pub mod prover_state { + use mpz_fields::Field; + + mod sealed { + pub trait Sealed {} + } + + /// Marker trait implemented by every legal [`Prover`](super::Prover) + /// phase. Sealed: external crates cannot add new phases. + pub trait State: sealed::Sealed {} + + /// Phase right after [`Prover::new`](super::Prover::new): `alloc` + /// and `prepare` are callable; `prove` is not. + pub struct Initialized; + + /// Phase right after a successful + /// [`prepare`](super::Prover::<_, _, _, Initialized>::prepare): + /// `prove` is callable. + pub struct Prepared { + pub(super) transcript: blake3::Hasher, + pub(super) px_m: E, + pub(super) py_m: E, + } + + impl sealed::Sealed for Initialized {} + impl State for Initialized {} + impl sealed::Sealed for Prepared {} + impl State for Prepared {} +} + +/// Collapse one tuple of authenticated wires into a single +/// `(value, MAC)` pair via the inner product with `s`. +/// +/// `s.len()`, `values.len()`, and `macs.len()` are expected to be +/// equal; the inner product extends only as far as the shortest slice. +pub(crate) fn collapse_tuple(s: &[E], values: &[W], macs: &[E]) -> (E, E) +where + W: Field, + E: ExtensionField, +{ + let embedded: Vec = values.iter().map(|v| E::embed(*v)).collect(); + (E::inner_product(s, &embedded), E::inner_product(s, macs)) +} + +/// Error produced by protocol prover. +#[derive(Debug, thiserror::Error)] +pub enum ProveError { + /// The four input slices did not all have the same length. + #[error("length mismatch: x_values={xv}, x_macs={xm}, y_values={yv}, y_macs={ym}")] + LengthMismatch { + /// Length of `x.0` (values). + xv: usize, + /// Length of `x.1` (macs). + xm: usize, + /// Length of `y.0` (values). + yv: usize, + /// Length of `y.1` (macs). + ym: usize, + }, + + /// Input vectors had length zero, or the tuple width was zero. + #[error("empty inputs: permutation proof requires at least one wire per side")] + EmptyInputs, + + /// Not all input tuples had the same width. + #[error("tuple width mismatch across input vectors")] + TupleWidthMismatch, + + /// The backend reported an error. + #[error("backend error: {0}")] + Backend(#[source] E), +} + +#[cfg(test)] +mod tests { + use super::*; + + use mpz_fields::gf2_128::Gf2_128; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; + + use crate::backend::mock::MockProverBackend; + + /// Build a mock-backed prover. + fn build_mock_prover() -> Prover> { + let delta = Gf2_128::one(); + Prover::new(MockProverBackend::::new(delta)) + } + + /// Construct a uniform-width tuple Vec for tests. + fn ones(n: usize, width: usize) -> Vec> { + (0..n).map(|_| vec![Gf2_128::one(); width]).collect() + } + + /// Mismatched `x_values.len()` vs `x_macs.len()` must surface as + /// `LengthMismatch`. + #[test] + fn prepare_rejects_x_values_x_macs_length_mismatch() { + let prover = build_mock_prover(); + let transcript = Hasher::new(); + let x_values = ones(3, 1); + let x_macs = ones(2, 1); // short by 1 + let y_values = ones(3, 1); + let y_macs = ones(3, 1); + + let err = prover + .prepare(transcript, (&x_values, &x_macs), (&y_values, &y_macs)) + .err() + .expect("x-side length mismatch must surface an error"); + match err { + ProveError::LengthMismatch { xv, xm, yv, ym } => { + assert_eq!((xv, xm, yv, ym), (3, 2, 3, 3)); + } + other => panic!("expected LengthMismatch, got {other:?}"), + } + } + + /// Mismatched `y_values.len()` vs `y_macs.len()` trips the second + /// disjunct of the length check. + #[test] + fn prepare_rejects_y_values_y_macs_length_mismatch() { + let prover = build_mock_prover(); + let transcript = Hasher::new(); + let x_values = ones(4, 1); + let x_macs = ones(4, 1); + let y_values = ones(4, 1); + let y_macs = ones(3, 1); // short by 1 + + let err = prover + .prepare(transcript, (&x_values, &x_macs), (&y_values, &y_macs)) + .err() + .expect("y-side length mismatch must surface an error"); + match err { + ProveError::LengthMismatch { xv, xm, yv, ym } => { + assert_eq!((xv, xm, yv, ym), (4, 4, 4, 3)); + } + other => panic!("expected LengthMismatch, got {other:?}"), + } + } + + /// With each side internally consistent but `x.len() != y.len()`, + /// the third disjunct of the length check trips. + #[test] + fn prepare_rejects_x_vs_y_length_mismatch() { + let prover = build_mock_prover(); + let transcript = Hasher::new(); + let x_values = ones(3, 1); + let x_macs = ones(3, 1); + let y_values = ones(5, 1); + let y_macs = ones(5, 1); + + let err = prover + .prepare(transcript, (&x_values, &x_macs), (&y_values, &y_macs)) + .err() + .expect("x-vs-y length mismatch must surface an error"); + match err { + ProveError::LengthMismatch { xv, xm, yv, ym } => { + assert_eq!((xv, xm, yv, ym), (3, 3, 5, 5)); + } + other => panic!("expected LengthMismatch, got {other:?}"), + } + } + + /// A permutation proof over zero positions is vacuous. + #[test] + fn prepare_rejects_empty_vectors() { + let prover = build_mock_prover(); + let transcript = Hasher::new(); + let empty: Vec> = Vec::new(); + + let err = prover + .prepare(transcript, (&empty, &empty), (&empty, &empty)) + .err() + .expect("empty inputs must surface an error"); + assert!( + matches!(err, ProveError::EmptyInputs), + "expected EmptyInputs, got {err:?}" + ); + } + + /// Zero-width tuples rejected. + #[test] + fn prepare_rejects_zero_width_tuples() { + let prover = build_mock_prover(); + let transcript = Hasher::new(); + // n = 1, tuple_width = 0. + let x_values = ones(1, 0); + let x_macs = ones(1, 0); + let y_values = ones(1, 0); + let y_macs = ones(1, 0); + + let err = prover + .prepare(transcript, (&x_values, &x_macs), (&y_values, &y_macs)) + .err() + .expect("zero-width tuples must surface an error"); + assert!( + matches!(err, ProveError::EmptyInputs), + "expected EmptyInputs, got {err:?}" + ); + } + + /// Non-uniform tuple widths rejected. + #[test] + fn prepare_rejects_tuple_width_mismatch() { + let prover = build_mock_prover(); + let transcript = Hasher::new(); + // n = 2: first tuple width 2, second tuple width 3. + let x_values: Vec> = vec![vec![Gf2_128::one(); 2], vec![Gf2_128::one(); 3]]; + let x_macs: Vec> = vec![vec![Gf2_128::one(); 2], vec![Gf2_128::one(); 3]]; + let y_values = x_values.clone(); + let y_macs = x_macs.clone(); + + let err = prover + .prepare(transcript, (&x_values, &x_macs), (&y_values, &y_macs)) + .err() + .expect("non-uniform tuple width must surface an error"); + assert!( + matches!(err, ProveError::TupleWidthMismatch), + "expected TupleWidthMismatch, got {err:?}" + ); + } + + /// `prove` materializes the zero-check opening as + /// `zero_proof = px_m − py_m`. + #[test] + fn prove_materializes_zero_proof_as_px_minus_py() { + let mut rng = ChaCha8Rng::seed_from_u64(0xF00F); + let delta: Gf2_128 = rng.random(); + let px_m: Gf2_128 = rng.random(); + let py_m: Gf2_128 = rng.random(); + + // Construct the Prepared state directly — bypasses `prepare` + // so this test pins `prove` in isolation from the rest of the + // lifecycle. `pub(super)` fields on `Prepared` make this + // accessible from inside `prover.rs`'s test submodule. + let prover = Prover { + backend: MockProverBackend::::new(delta), + state: prover_state::Prepared { + transcript: Hasher::new(), + px_m, + py_m, + }, + _phantom: std::marker::PhantomData, + }; + + let proof = prover.prove().expect("mock prove must succeed"); + assert_eq!(proof.zero_proof, px_m - py_m); + assert_eq!(proof.backend_proof, ()); + } +} diff --git a/crates/perm-proof-core/src/test_utils.rs b/crates/perm-proof-core/src/test_utils.rs new file mode 100644 index 00000000..356d8fac --- /dev/null +++ b/crates/perm-proof-core/src/test_utils.rs @@ -0,0 +1,139 @@ +//! Test utilities for the permutation-proof protocol. + +use mpz_common::future::Output; +use mpz_fields::{ExtensionField, Field}; +use rand::Rng; + +use mpz_vole_core::{ + RVOLESender, VOLEReceiver, + ideal::vole::{FlushMsg, ideal_vole}, +}; + +/// Tight bundle of what [`commit_values`] produces: per-vector +/// prover-side MAC tuples and verifier-side key tuples plus the +/// transcript carrying a binding to all of them. Each inner `Vec` +/// is one tuple-position; outer `Vec` is one entry per input vector. +#[derive(Debug)] +pub struct Committed { + /// Prover-side MAC tuples, one `Vec` per input vector, in + /// submission order. + pub macs: [Vec>; N], + /// Verifier-side key tuples, one `Vec` per input vector, in + /// submission order. + pub keys: [Vec>; N], + /// Transcript with the on-wire setup message absorbed under a + /// fixed label. + pub transcript: blake3::Hasher, +} + +/// Commit `vectors` of runtime-width tuples as authenticated wires +/// via a single ideal chosen-VOLE session. +/// +/// Each input `&[Vec]` is a slice of tuples; every tuple within a +/// given input must have the same width. Across inputs, tuple widths +/// may differ — the per-input width is read from the first tuple. +pub fn commit_values( + vectors: [&[Vec]; N], + delta: E, + rng: &mut impl Rng, +) -> Committed +where + W: Field, + E: ExtensionField + serde::Serialize, +{ + let seed: u64 = rng.random(); + + // Per-input tuple widths and flat counts. + let widths: [usize; N] = std::array::from_fn(|i| vectors[i].first().map_or(0, |t| t.len())); + for (i, v) in vectors.iter().enumerate() { + assert!( + v.iter().all(|t| t.len() == widths[i]), + "commit_values: input {i} has non-uniform tuple widths", + ); + } + let total_scalars: usize = vectors.iter().enumerate().map(|(i, v)| v.len() * widths[i]).sum(); + + let (mut sender, mut receiver) = ideal_vole::(seed, delta); + <_ as RVOLESender>::alloc(&mut sender, total_scalars).expect("sender alloc"); + <_ as VOLEReceiver>::alloc(&mut receiver, total_scalars).expect("receiver alloc"); + + let flat_inputs: [Vec; N] = + std::array::from_fn(|i| vectors[i].iter().flatten().copied().collect()); + let mut futs: [_; N] = std::array::from_fn(|i| { + Some( + receiver + .queue_recv_vole(&flat_inputs[i]) + .expect("queue"), + ) + }); + + // Single flush covers every queued wire. + let flush = sender.flush().expect("flush must produce a message"); + let mut transcript = blake3::Hasher::new(); + absorb_vole_flush(&mut transcript, &flush); + receiver.flush(flush).expect("receiver flush"); + + // Re-bundle the returned flat MACs / keys into per-vector tuple + // shapes matching the input. + let macs: [Vec>; N] = std::array::from_fn(|i| { + let flat = futs[i] + .take() + .expect("future slot populated") + .try_recv() + .expect("future must not cancel") + .expect("future must resolve after flush") + .macs; + chunk_into_vecs(flat, widths[i]) + }); + let keys: [Vec>; N] = std::array::from_fn(|i| { + let flat = sender + .try_send_vole(vectors[i].len() * widths[i]) + .expect("sender keys") + .keys; + chunk_into_vecs(flat, widths[i]) + }); + + Committed { + macs, + keys, + transcript, + } +} + +/// Reshape a flat `Vec` into a `Vec>` where each inner vec +/// holds `width` consecutive elements. With `width == 0` returns an +/// empty outer vec. +fn chunk_into_vecs(flat: Vec, width: usize) -> Vec> { + if width == 0 { + return Vec::new(); + } + assert!( + flat.len() % width == 0, + "flat length {} not divisible by tuple width {}", + flat.len(), + width + ); + flat.chunks_exact(width).map(|chunk| chunk.to_vec()).collect() +} + +/// Absorb the bytes of an ideal-VOLE [`FlushMsg`] into a transcript. +fn absorb_vole_flush( + transcript: &mut blake3::Hasher, + msg: &FlushMsg, +) { + transcript.update(b"permutation-proof::test::ideal-vole-flush"); + transcript.update(&bcs::to_bytes(msg).expect("serialize")); +} + +/// Number of RVOLE correlations the `vole_zk` backend consumes across +/// one prover/verifier pair running on `n` inputs with fan-in `eps`. +pub fn vole_zk_rvole_pregenerate_count(n: usize, eps: usize) -> usize { + 2 * crate::backend::vole_zk::fan_in_tree_internal_nodes(n, eps) +} + +/// Polynomial degree the `vole_zk` backend's QS finalize expects from +/// its single RVOPE correlation, for fan-in `eps` over field `E`. +pub fn vole_zk_rvope_pregenerate_degree(eps: usize) -> usize { + let (constraints, _) = crate::backend::vole_zk::build_product_constraints::(eps); + mpz_poly_proof_core::prover::Prover::::new(&constraints).required_vopes() +} diff --git a/crates/perm-proof-core/src/verifier.rs b/crates/perm-proof-core/src/verifier.rs new file mode 100644 index 00000000..9d87789e --- /dev/null +++ b/crates/perm-proof-core/src/verifier.rs @@ -0,0 +1,405 @@ +//! Permutation protocol verifier. + +use blake3::Hasher; +use mpz_fields::{ExtensionField, Field}; +use serde::Serialize; + +use crate::{Proof, backend::VerifierBackend, draw_field}; + +/// Permutation protocol verifier. +pub struct Verifier +where + W: Field, + E: ExtensionField, + B: VerifierBackend, + S: verifier_state::State, +{ + backend: B, + state: S, + _phantom: std::marker::PhantomData<(W, E)>, +} + +impl Verifier +where + W: Field, + E: ExtensionField, + B: VerifierBackend, +{ + /// Build a new verifier around `backend`. + pub fn new(backend: B) -> Self { + Self { + backend, + state: verifier_state::Initialized, + _phantom: std::marker::PhantomData, + } + } + + /// Announce that a permutation proof of size `n` will run through + /// this verifier. + /// + /// Multiple calls accumulate. + pub fn alloc(&mut self, n: usize) -> Result<(), B::Error> { + self.backend.alloc(n) + } + + /// Compute the preparation phase of the protocol. + /// + /// # Arguments + /// + /// * `transcript` - Shared session transcript. + /// * `x_keys` - Verifier-side keys for the first input vector. + /// * `y_keys` - Verifier-side keys for the second input vector. + /// * `preparation` - The prover-emitted preparation DTO. + /// + /// # Security + /// + /// It is crucial that `transcript` has absorbed `x_keys` and + /// `y_keys` before this method is invoked. The protocol's soundness + /// depends on this binding. + pub fn prepare( + mut self, + mut transcript: Hasher, + x_keys: &[Vec], + y_keys: &[Vec], + preparation: B::Preparation, + ) -> Result>, VerifyError> { + let xn = x_keys.len(); + let yn = y_keys.len(); + if xn != yn { + return Err(VerifyError::LengthMismatch { xn, yn }); + } + if xn == 0 { + return Err(VerifyError::EmptyInputs); + } + + // Tuple width: read from the first input tuple. + let tuple_width = x_keys[0].len(); + if tuple_width == 0 { + return Err(VerifyError::EmptyInputs); + } + + // Uniformity: every tuple (across both x and y) must have the + // same width. + let all_uniform = x_keys.iter().all(|k| k.len() == tuple_width) + && y_keys.iter().all(|k| k.len() == tuple_width); + if !all_uniform { + return Err(VerifyError::TupleWidthMismatch); + } + + // Draw the random challenge `r`. + let r = draw_field::(&mut transcript, b"permutation-proof::challenge_r"); + + // Draw the tuple-collapse challenge `s ∈ E^tuple_width`. + let s: Vec = (0..tuple_width) + .map(|_| draw_field::(&mut transcript, b"permutation-proof::challenge_s")) + .collect(); + + // Compute per-position collapsed factors. + // + // z_key = Σ_j s[j] · keys[i][j] (in E) + // factor_key = −Δ·r − z_key // r's key is −Δ·r; the `−` carries through. + let delta = self.backend.delta(); + let minus_delta_r = -(delta * r); + let fx_keys: Vec = x_keys + .iter() + .map(|k| minus_delta_r - E::inner_product(&s, k)) + .collect(); + let fy_keys: Vec = y_keys + .iter() + .map(|k| minus_delta_r - E::inner_product(&s, k)) + .collect(); + + // Install the preparation DTOs. + self.backend.load_preparation(preparation); + + let px_k = self + .backend + .product(&mut transcript, &fx_keys) + .map_err(VerifyError::Backend)?; + let py_k = self + .backend + .product(&mut transcript, &fy_keys) + .map_err(VerifyError::Backend)?; + + Ok(Verifier { + backend: self.backend, + state: verifier_state::Prepared { + transcript, + px_k, + py_k, + }, + _phantom: std::marker::PhantomData, + }) + } +} + +impl Verifier> +where + W: Field, + E: ExtensionField, + B: VerifierBackend, +{ + /// Verify the proof. + /// + /// # Arguments + /// + /// * `proof` - The prover-emitted proof. + pub fn verify(self, proof: Proof) -> Result<(), VerifyError> + where + E: Serialize, + B::BackendProof: Serialize, + { + let Verifier { backend, state, .. } = self; + let verifier_state::Prepared { + mut transcript, + px_k, + py_k, + } = state; + + transcript.update(b"permutation-proof::proof"); + transcript.update(&bcs::to_bytes(&proof).expect("serialize")); + + let Proof { + zero_proof, + backend_proof, + } = proof; + + // Zero-check: diff_k is the key for the difference wire. Under + // `mac = key + Δ · value`, value-zero forces mac == key, so + // the prover's opened mac (zero_proof) must equal this + // locally-computed key. + let diff_k = px_k - py_k; + if zero_proof != diff_k { + return Err(VerifyError::ZeroCheckFailed); + } + + // Backend's supplementary check. + backend.verify(backend_proof).map_err(VerifyError::Backend) + } +} + +/// Error produced by protocol verifier. +#[derive(Debug, thiserror::Error)] +pub enum VerifyError { + /// The two key slices did not have the same length. + #[error("length mismatch: x_keys={xn}, y_keys={yn}")] + LengthMismatch { + /// Length of `x_keys`. + xn: usize, + /// Length of `y_keys`. + yn: usize, + }, + + /// Input key slices had length zero, or the tuple width was zero. + #[error("empty inputs: permutation proof requires at least one wire per side")] + EmptyInputs, + + /// Not all input tuples had the same width. + #[error("tuple width mismatch across input vectors")] + TupleWidthMismatch, + + /// The prover's opened MAC disagreed with the verifier's + /// locally-computed key. + #[error("zero-check rejected: zero_proof does not match verifier's diff key")] + ZeroCheckFailed, + + /// The backend reported an error. + #[error("backend error: {0}")] + Backend(#[source] E), +} + +/// Type-state markers for [`Verifier`]'s phase. +pub mod verifier_state { + use mpz_fields::Field; + + mod sealed { + pub trait Sealed {} + } + + /// Marker trait implemented by every legal + /// [`Verifier`](super::Verifier) phase. Sealed: external crates + /// cannot add new phases. + pub trait State: sealed::Sealed {} + + /// Phase right after [`Verifier::new`](super::Verifier::new): + /// `alloc` and `prepare` are callable; `verify` is not. + pub struct Initialized; + + /// Phase right after a successful + /// [`prepare`](super::Verifier::<_, _, _, Initialized>::prepare): + /// `verify` is callable. + pub struct Prepared { + pub(super) transcript: blake3::Hasher, + pub(super) px_k: E, + pub(super) py_k: E, + } + + impl sealed::Sealed for Initialized {} + impl State for Initialized {} + impl sealed::Sealed for Prepared {} + impl State for Prepared {} +} + +#[cfg(test)] +mod tests { + use super::*; + + use mpz_fields::gf2_128::Gf2_128; + use rand::{Rng, SeedableRng}; + use rand_chacha::ChaCha8Rng; + + use crate::backend::mock::{MockVerifierBackend, Preparation}; + + /// Build a mock-backed verifier. + fn build_mock_verifier() -> Verifier> { + let delta = Gf2_128::one(); + Verifier::new(MockVerifierBackend::::new(delta)) + } + + /// Construct a uniform-width key Vec for tests. + fn key_ones(n: usize, width: usize) -> Vec> { + (0..n).map(|_| vec![Gf2_128::one(); width]).collect() + } + + /// Mismatched `x_keys.len()` vs `y_keys.len()` must surface as + /// `LengthMismatch`. + #[test] + fn prepare_rejects_length_mismatch() { + let verifier = build_mock_verifier(); + let transcript = Hasher::new(); + let x_keys = key_ones(3, 1); + let y_keys = key_ones(5, 1); + let preparation = Preparation { prod_keys: vec![] }; + + let err = verifier + .prepare(transcript, &x_keys, &y_keys, preparation) + .err() + .expect("length mismatch must surface an error"); + match err { + VerifyError::LengthMismatch { xn, yn } => { + assert_eq!((xn, yn), (3, 5)); + } + other => panic!("expected LengthMismatch, got {other:?}"), + } + } + + /// A permutation proof over zero positions is vacuous. + #[test] + fn prepare_rejects_empty_vectors() { + let verifier = build_mock_verifier(); + let transcript = Hasher::new(); + let empty: Vec> = Vec::new(); + let preparation = Preparation { prod_keys: vec![] }; + + let err = verifier + .prepare(transcript, &empty, &empty, preparation) + .err() + .expect("empty inputs must surface an error"); + assert!( + matches!(err, VerifyError::EmptyInputs), + "expected EmptyInputs, got {err:?}" + ); + } + + /// Zero-width tuples are rejected. + #[test] + fn prepare_rejects_zero_width_tuples() { + let verifier = build_mock_verifier(); + let transcript = Hasher::new(); + let x_keys = key_ones(1, 0); + let y_keys = key_ones(1, 0); + let preparation = Preparation { prod_keys: vec![] }; + + let err = verifier + .prepare(transcript, &x_keys, &y_keys, preparation) + .err() + .expect("zero-width tuples must surface an error"); + assert!( + matches!(err, VerifyError::EmptyInputs), + "expected EmptyInputs, got {err:?}" + ); + } + + /// Non-uniform tuple widths rejected. + #[test] + fn prepare_rejects_tuple_width_mismatch() { + let verifier = build_mock_verifier(); + let transcript = Hasher::new(); + let x_keys: Vec> = vec![ + vec![Gf2_128::one(); 2], + vec![Gf2_128::one(); 3], + ]; + let y_keys = x_keys.clone(); + let preparation = Preparation { prod_keys: vec![] }; + + let err = verifier + .prepare(transcript, &x_keys, &y_keys, preparation) + .err() + .expect("non-uniform tuple width must surface an error"); + assert!( + matches!(err, VerifyError::TupleWidthMismatch), + "expected TupleWidthMismatch, got {err:?}" + ); + } + + /// `verify` accepts iff `zero_proof == px_k − py_k`. + #[test] + fn verify_accepts_matching_zero_proof() { + let mut rng = ChaCha8Rng::seed_from_u64(0xDEAD); + let delta: Gf2_128 = rng.random(); + let px_k: Gf2_128 = rng.random(); + let py_k: Gf2_128 = rng.random(); + + let verifier = Verifier { + backend: MockVerifierBackend::::new(delta), + state: verifier_state::Prepared { + transcript: Hasher::new(), + px_k, + py_k, + }, + _phantom: std::marker::PhantomData, + }; + + let proof = Proof { + zero_proof: px_k - py_k, + backend_proof: (), + }; + + verifier + .verify(proof) + .expect("matching zero_proof must be accepted"); + } + + /// `verify` rejects when `zero_proof != px_k − py_k`. + #[test] + fn verify_rejects_mismatched_zero_proof() { + let mut rng = ChaCha8Rng::seed_from_u64(0xBEEF); + let delta: Gf2_128 = rng.random(); + let px_k: Gf2_128 = rng.random(); + let py_k: Gf2_128 = rng.random(); + + let verifier = Verifier { + backend: MockVerifierBackend::::new(delta), + state: verifier_state::Prepared { + transcript: Hasher::new(), + px_k, + py_k, + }, + _phantom: std::marker::PhantomData, + }; + + let tampered = Proof { + zero_proof: (px_k - py_k) + Gf2_128::one(), + backend_proof: (), + }; + + let err = verifier + .verify(tampered) + .err() + .expect("tampered zero_proof must be rejected"); + assert!( + matches!(err, VerifyError::ZeroCheckFailed), + "expected ZeroCheckFailed, got {err:?}" + ); + } +} diff --git a/crates/perm-proof-core/tests/api.rs b/crates/perm-proof-core/tests/api.rs new file mode 100644 index 00000000..759b4cf6 --- /dev/null +++ b/crates/perm-proof-core/tests/api.rs @@ -0,0 +1,106 @@ +//! End-to-end tests for the permutation proof protocol public API. +//! +//! These tests drive the full `Prover` → `Verifier` lifecycle against +//! the non-cryptographic [`mock`](perm_proof_core::backend::mock) +//! backend. + +use mpz_fields::{Field, gf2_128::Gf2_128}; +use perm_proof_core::{ + Prover, Verifier, + backend::mock::{MockError, mock_pair}, + test_utils::{Committed, commit_values}, + verifier::VerifyError, +}; +use rand::{Rng, SeedableRng, seq::SliceRandom}; +use rand_chacha::ChaCha8Rng; + +/// Mode toggle for the end-to-end runners. +#[derive(Clone, Copy)] +enum Mode { + /// Honest prover. + Honest, + /// Dishonest prover. + Dishonest, +} + +/// Drive the full two-round Prover → Verifier lifecycle over +/// `n = 100` tuples of width `L` and return the verifier's result. +fn run_end_to_end(mode: Mode) -> Result<(), VerifyError> { + let mut rng = ChaCha8Rng::seed_from_u64(0xA1); + let delta: Gf2_128 = rng.random(); + + // Input tuples: `n` positions, each an `L`-wide tuple of random + // field elements. `y` is a random permutation of `x`. + let mut x_values: Vec> = (0..100) + .map(|_| (0..L).map(|_| rng.random()).collect()) + .collect(); + // Fisher-Yates permutation on index vector; apply to build y. + let mut perm_indices: Vec = (0..x_values.len()).collect(); + perm_indices.shuffle(&mut rng); + let y_values: Vec> = perm_indices.iter().map(|&i| x_values[i].clone()).collect(); + + // Commit both vectors as authenticated tuple-wires in a single + // VOLE session. + let Committed { + macs: [x_macs, y_macs], + keys: [x_keys, y_keys], + transcript, + } = commit_values([&x_values[..], &y_values[..]], delta, &mut rng); + let tp = transcript.clone(); + let tv = transcript; + + if matches!(mode, Mode::Dishonest) { + // Tamper AFTER authentication. + x_values[0][0] = x_values[0][0] + Gf2_128::one(); + } + + let (pb, vb) = mock_pair::(delta); + let prover = Prover::new(pb); + let verifier = Verifier::new(vb); + + // --- Round 1: prover -> verifier --- + let (preparation, prover) = prover + .prepare(tp, (&x_values, &x_macs), (&y_values, &y_macs)) + .expect("prover prepare must succeed"); + let verifier = verifier + .prepare(tv, &x_keys, &y_keys, preparation) + .expect("verifier prepare must succeed"); + + // --- Round 2: prover -> verifier --- + let proof = prover.prove().expect("prover prove must succeed"); + verifier.verify(proof) +} + +/// Honest end-to-end with scalar inputs (`L = 1`). +#[test] +fn accepts_honest_permutation_scalar_n100() { + run_end_to_end::<1>(Mode::Honest).expect("honest case must accept"); +} + +/// Dishonest end-to-end with scalar inputs. +#[test] +fn rejects_non_permutation_scalar_n100() { + let err = + run_end_to_end::<1>(Mode::Dishonest).expect_err("dishonest scalar case must be rejected"); + assert!( + matches!(err, VerifyError::ZeroCheckFailed), + "expected ZeroCheckFailed, got {err:?}" + ); +} + +/// Honest end-to-end with 3-tuple inputs. +#[test] +fn accepts_honest_permutation_tuples3_n100() { + run_end_to_end::<3>(Mode::Honest).expect("honest tuple case must accept"); +} + +/// Dishonest end-to-end with 3-tuple inputs. +#[test] +fn rejects_non_permutation_tuples3_n100() { + let err = + run_end_to_end::<3>(Mode::Dishonest).expect_err("dishonest tuple case must be rejected"); + assert!( + matches!(err, VerifyError::ZeroCheckFailed), + "expected ZeroCheckFailed, got {err:?}" + ); +}