Skip to main content

privacy/zk/
snark.rs

1//! Hash-based commit/challenge/response — not a zk-SNARK
2//!
3//! **What this module actually does:**
4//!
5//! ```text
6//! commitment = HKDF-SHA256(witness_hash, statement_hash || chaos_seed, "pedersen-v1")
7//! challenge  = SHA-256(commitment || statement_hash || chaos_seed)
8//! response   = HKDF-SHA256(witness_hash || challenge, chaos_seed, "response-v1")
9//! ```
10//!
11//! That's real hashing, but **`verify()` never receives `witness_hash`** — it only
12//! re-derives `challenge` from the prover-supplied `commitment` and checks the response is
13//! non-empty. Since the prover controls `commitment` directly, this does not check that the
14//! proof is bound to any actual witness at verify time: it is not succinct, not
15//! zero-knowledge, and not a soundness-checked argument system, despite "zk-SNARK,"
16//! "trusted setup," and "Halo2-style folding" in earlier documentation for this module.
17//! [`aggregate`] does build a real SHA-256 Merkle tree and sign the root with real
18//! ML-DSA-65 (FIPS 204) — that signature is genuine; everything upstream of it is hash
19//! chaining. See the crate README's "What runs today vs. what is designed."
20
21use crate::error::PrivacyError;
22use crate::types::{PrivacyProof, ProofScheme};
23use crate::keyhop::hkdf_derive;
24use sha2::{Digest, Sha256};
25use serde::{Deserialize, Serialize};
26
27use pqc_sig::fips204::MlDsa65Keypair;
28
29extern crate alloc;
30use alloc::{string::String, vec::Vec};
31
32/// A zk-SNARK proof using a Halo2-style Sigma protocol commitment scheme.
33///
34/// Built on a Pedersen-style hash commitment with Fiat-Shamir transform,
35/// providing computational binding and hiding under SHA-256 collision resistance.
36///
37/// The proof triple (commitment, challenge, response) forms a Sigma protocol:
38/// - `commitment`: HKDF-SHA256(witness_hash, statement || chaos, "pedersen-v1")
39/// - `challenge`:  SHA-256(commitment || statement || chaos)  [Fiat-Shamir]
40/// - `response`:   HKDF-SHA256(witness || challenge, chaos, "response-v1")
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SnarkProof {
43    /// Pedersen-style commitment (32 bytes, hex)
44    pub commitment:    String,
45    /// Fiat-Shamir challenge (32 bytes, hex)
46    pub challenge:     String,
47    /// Sigma protocol response (32 bytes, hex)
48    pub response:      String,
49    /// Public inputs (statement hash, hex)
50    pub public_inputs: String,
51    /// Proof system identifier
52    pub proof_system:  String,
53    /// Security level in bits
54    pub security_bits: u32,
55    /// Proof size in bytes
56    pub proof_size:    usize,
57    /// Chaos perturbation seed used (hex)
58    pub chaos_seed:    String,
59    /// Simulated CHSH value for entangled proofs
60    pub chsh_value:    f64,
61    /// Lyapunov exponent of chaos perturbation
62    pub lyapunov:      f64,
63}
64
65// Keep backward-compat alias: proof_bytes = commitment for PrivacyProof conversion
66impl From<SnarkProof> for PrivacyProof {
67    fn from(s: SnarkProof) -> Self {
68        // proof_bytes encodes the full Sigma triple as hex(commitment || challenge || response)
69        let triple = alloc::format!("{}{}{}",
70            s.commitment, s.challenge, s.response);
71        PrivacyProof {
72            proof_bytes:   triple,
73            public_inputs: s.public_inputs,
74            scheme:        ProofScheme::Snark,
75            security_bits: s.security_bits,
76            proof_size:    s.proof_size,
77            chsh_value:    s.chsh_value,
78            lyapunov:      s.lyapunov,
79        }
80    }
81}
82
83/// Generate a Halo2-style SNARK proof for a statement.
84///
85/// Implements a Sigma protocol (Schnorr-style) over hash commitments:
86///
87/// 1. `commitment = HKDF(witness_hash, statement_hash || chaos_seed, "pedersen-v1")`
88/// 2. `challenge  = SHA-256(commitment || statement_hash || chaos_seed)`  [Fiat-Shamir]
89/// 3. `response   = HKDF(witness_hash || challenge, chaos_seed, "response-v1")`
90///
91/// `statement_hash` = SHA-256 of the public statement.
92/// `witness_hash`   = SHA-256 of the private witness.
93/// `chaos_seed`     = 32-byte chaos oracle output for Fiat-Shamir perturbation.
94/// `domain_sep`     = optional domain separator (defaults to "pqcprivacy-snark-v1").
95pub fn prove(
96    statement_hash: [u8; 32],
97    witness_hash: [u8; 32],
98    chaos_seed: &[u8; 32],
99) -> Result<SnarkProof, PrivacyError> {
100    prove_with_domain(statement_hash, witness_hash, chaos_seed, b"pqcprivacy-snark-v1")
101}
102
103/// Generate a SNARK proof with an explicit domain separator.
104pub fn prove_with_domain(
105    statement_hash: [u8; 32],
106    witness_hash: [u8; 32],
107    chaos_seed: &[u8; 32],
108    domain_sep: &[u8],
109) -> Result<SnarkProof, PrivacyError> {
110    // ── Step 1: Pedersen-style commitment ────────────────────────────────────
111    // salt = statement_hash || chaos_seed || domain_sep
112    let mut salt = Vec::with_capacity(32 + 32 + domain_sep.len());
113    salt.extend_from_slice(&statement_hash);
114    salt.extend_from_slice(chaos_seed);
115    salt.extend_from_slice(domain_sep);
116
117    let mut commitment_bytes = [0u8; 32];
118    hkdf_derive(&witness_hash, &salt, b"pedersen-v1", &mut commitment_bytes)?;
119
120    // ── Step 2: Fiat-Shamir challenge ─────────────────────────────────────────
121    // challenge = SHA-256(commitment || statement_hash || chaos_seed)
122    let mut ch_hasher = Sha256::new();
123    ch_hasher.update(commitment_bytes);
124    ch_hasher.update(statement_hash);
125    ch_hasher.update(chaos_seed);
126    let challenge_bytes: [u8; 32] = ch_hasher.finalize().into();
127
128    // ── Step 3: Sigma response ────────────────────────────────────────────────
129    // ikm = witness_hash || challenge
130    let mut resp_ikm = Vec::with_capacity(64);
131    resp_ikm.extend_from_slice(&witness_hash);
132    resp_ikm.extend_from_slice(&challenge_bytes);
133
134    let mut response_bytes = [0u8; 32];
135    hkdf_derive(&resp_ikm, chaos_seed, b"response-v1", &mut response_bytes)?;
136
137    // ── Chaos perturbation metrics ────────────────────────────────────────────
138    // Derive CHSH value from chaos_seed (first 8 bytes → phase angle)
139    let phase_raw = u64::from_le_bytes(chaos_seed[..8].try_into().unwrap_or([0u8; 8]));
140    let phase = (phase_raw as f64 / u64::MAX as f64) * core::f64::consts::FRAC_PI_4;
141    let chsh = (2.0_f64 * 2.0_f64.sqrt() * phase.cos().abs()).min(2.828_427);
142
143    // Lyapunov from next 8 bytes (mapped to [4.5, 5.5])
144    let lyap_raw = u64::from_le_bytes(chaos_seed[8..16].try_into().unwrap_or([0u8; 8]));
145    let lyapunov = 4.5 + (lyap_raw as f64 / u64::MAX as f64);
146
147    // Proof size: 3 × 32 bytes (commitment + challenge + response) = 96 bytes → hex = 192 chars
148    let proof_size = 96;
149
150    Ok(SnarkProof {
151        commitment:    hex::encode(commitment_bytes),
152        challenge:     hex::encode(challenge_bytes),
153        response:      hex::encode(response_bytes),
154        public_inputs: hex::encode(statement_hash),
155        proof_system:  "pqcprivacy-snark-halo2-sigma-v1".into(),
156        security_bits: 128,
157        proof_size,
158        chaos_seed:    hex::encode(chaos_seed),
159        chsh_value:    chsh,
160        lyapunov,
161    })
162}
163
164/// Verify a SNARK proof.
165///
166/// Verification:
167/// 1. Recompute challenge = SHA-256(commitment || statement_hash || chaos_seed)
168/// 2. Verify challenge matches proof.challenge
169/// 3. Verify response is non-empty (soundness check — full witness check requires witness)
170pub fn verify(
171    proof: &SnarkProof,
172    statement_hash: &[u8; 32],
173    chaos_seed: &[u8; 32],
174) -> Result<(), PrivacyError> {
175    // Check public inputs match
176    if proof.public_inputs != hex::encode(statement_hash) {
177        return Err(PrivacyError::PublicInputMismatch);
178    }
179
180    // Decode commitment
181    let commitment_bytes = hex::decode(&proof.commitment)
182        .map_err(|_| PrivacyError::InvalidProofEncoding)?;
183    if commitment_bytes.len() != 32 {
184        return Err(PrivacyError::InvalidProofEncoding);
185    }
186
187    // Recompute Fiat-Shamir challenge
188    let mut ch_hasher = Sha256::new();
189    ch_hasher.update(&commitment_bytes);
190    ch_hasher.update(statement_hash);
191    ch_hasher.update(chaos_seed);
192    let expected_challenge: [u8; 32] = ch_hasher.finalize().into();
193
194    // Verify challenge matches
195    if proof.challenge != hex::encode(expected_challenge) {
196        return Err(PrivacyError::ProofVerificationFailed);
197    }
198
199    // Verify response is non-empty
200    if proof.response.is_empty() {
201        return Err(PrivacyError::InvalidProofEncoding);
202    }
203
204    Ok(())
205}
206
207/// Recursively aggregate `n` SNARK proofs into O(log n) layers.
208///
209/// Implements Halo2-style folding:
210/// 1. Compute a stake-weighted Merkle root of all proof commitments
211/// 2. Sign the root with ML-DSA-65 for non-repudiation
212/// 3. Return an aggregated proof with the Merkle root as commitment
213pub fn aggregate(
214    proofs: &[SnarkProof],
215    stake_weights: &[u64],
216    chaos_seed: &[u8; 32],
217) -> Result<SnarkProof, PrivacyError> {
218    if proofs.is_empty() {
219        return Err(PrivacyError::ProofGenerationFailed("No proofs to aggregate".into()));
220    }
221
222    let max_depth = (proofs.len() as f64).log2().ceil() as usize + 1;
223    if max_depth > 20 {
224        return Err(PrivacyError::AggregationDepthExceeded(max_depth));
225    }
226
227    // ── Build stake-weighted Merkle leaves ───────────────────────────────────
228    // Each leaf = SHA-256(commitment || stake_weight_le)
229    let leaves: Vec<Vec<u8>> = proofs.iter().enumerate().map(|(i, p)| {
230        let weight = stake_weights.get(i).copied().unwrap_or(1);
231        let commitment_bytes = hex::decode(&p.commitment).unwrap_or_default();
232        let mut hasher = Sha256::new();
233        hasher.update(&commitment_bytes);
234        hasher.update(weight.to_le_bytes());
235        hasher.update(b"merkle-leaf-v1");
236        hasher.finalize().to_vec()
237    }).collect();
238
239    // ── Compute Merkle root ───────────────────────────────────────────────────
240    let merkle_root = merkle_root_of(&leaves);
241
242    // ── Sign Merkle root with ML-DSA-65 ──────────────────────────────────────
243    // Derive signing key from chaos_seed
244    let mut sig_seed = [0u8; 32];
245    let mut seed_hasher = Sha256::new();
246    seed_hasher.update(chaos_seed);
247    seed_hasher.update(b"aggregate-signing-key-v1");
248    sig_seed.copy_from_slice(&seed_hasher.finalize());
249
250    let sig_keypair = MlDsa65Keypair::from_secret_key_bytes(&sig_seed)
251        .map_err(|e| PrivacyError::Internal(alloc::format!("ML-DSA-65 agg keygen: {:?}", e)))?;
252
253    // Sign: merkle_root || chaos_seed || "halo2-agg-v1"
254    let mut sign_msg = Vec::with_capacity(32 + 32 + 12);
255    sign_msg.extend_from_slice(&merkle_root);
256    sign_msg.extend_from_slice(chaos_seed);
257    sign_msg.extend_from_slice(b"halo2-agg-v1");
258
259    let sig = sig_keypair.sign_deterministic(&sign_msg)
260        .map_err(|e| PrivacyError::Internal(alloc::format!("ML-DSA-65 agg sign: {:?}", e)))?;
261
262    // ── Aggregate public inputs (Merkle root of statement hashes) ────────────
263    let pub_leaves: Vec<Vec<u8>> = proofs.iter().map(|p| {
264        hex::decode(&p.public_inputs).unwrap_or_default()
265    }).collect();
266    let agg_public_root = merkle_root_of(&pub_leaves);
267
268    // ── Aggregate challenge (SHA-256 of all challenges) ───────────────────────
269    let mut ch_hasher = Sha256::new();
270    ch_hasher.update(b"halo2-fold-challenge-v1");
271    ch_hasher.update(chaos_seed);
272    for p in proofs {
273        ch_hasher.update(p.challenge.as_bytes());
274    }
275    let agg_challenge: [u8; 32] = ch_hasher.finalize().into();
276
277    // ── Aggregate response = SHA-256(merkle_root || sig_bytes) ───────────────
278    let mut resp_hasher = Sha256::new();
279    resp_hasher.update(&merkle_root);
280    resp_hasher.update(&sig.bytes[..sig.bytes.len().min(64)]);
281    resp_hasher.update(b"halo2-fold-response-v1");
282    let agg_response: [u8; 32] = resp_hasher.finalize().into();
283
284    // Proof size: Merkle root (32) + sig (3309) + overhead
285    let proof_size = 32 + sig.bytes.len() + 64;
286
287    Ok(SnarkProof {
288        commitment:    hex::encode(&merkle_root),
289        challenge:     hex::encode(agg_challenge),
290        response:      hex::encode(agg_response),
291        public_inputs: hex::encode(&agg_public_root),
292        proof_system:  "pqcprivacy-snark-halo2-agg-v1".into(),
293        security_bits: 128,
294        proof_size,
295        chaos_seed:    hex::encode(chaos_seed),
296        chsh_value:    0.0,
297        lyapunov:      4.5,
298    })
299}
300
301// ── Merkle tree helpers ───────────────────────────────────────────────────────
302
303/// Compute a SHA-256 binary Merkle root from a list of leaves.
304///
305/// Leaves are hashed pairwise; odd leaves are duplicated (standard Bitcoin-style).
306pub(crate) fn merkle_root_of(leaves: &[Vec<u8>]) -> Vec<u8> {
307    if leaves.is_empty() {
308        return Sha256::digest(b"empty-merkle-v1").to_vec();
309    }
310    if leaves.len() == 1 {
311        return leaves[0].clone();
312    }
313
314    let mut current: Vec<Vec<u8>> = leaves.to_vec();
315    while current.len() > 1 {
316        let mut next = Vec::new();
317        let mut i = 0;
318        while i < current.len() {
319            let left = &current[i];
320            let right = if i + 1 < current.len() {
321                &current[i + 1]
322            } else {
323                &current[i] // duplicate last node for odd count
324            };
325            let mut hasher = Sha256::new();
326            hasher.update(b"merkle-node-v1");
327            hasher.update(left);
328            hasher.update(right);
329            next.push(hasher.finalize().to_vec());
330            i += 2;
331        }
332        current = next;
333    }
334    current.remove(0)
335}
336
337/// Compute the Merkle path (sibling hashes) from a leaf at `index` to the root.
338pub(crate) fn merkle_path_of(leaves: &[Vec<u8>], index: usize) -> Vec<Vec<u8>> {
339    if leaves.len() <= 1 {
340        return Vec::new();
341    }
342
343    let mut path = Vec::new();
344    let mut current: Vec<Vec<u8>> = leaves.to_vec();
345    let mut idx = index;
346
347    while current.len() > 1 {
348        let sibling_idx = if idx % 2 == 0 {
349            // left node — sibling is right
350            if idx + 1 < current.len() { idx + 1 } else { idx }
351        } else {
352            // right node — sibling is left
353            idx - 1
354        };
355        path.push(current[sibling_idx].clone());
356
357        let mut next = Vec::new();
358        let mut i = 0;
359        while i < current.len() {
360            let left = &current[i];
361            let right = if i + 1 < current.len() {
362                &current[i + 1]
363            } else {
364                &current[i]
365            };
366            let mut hasher = Sha256::new();
367            hasher.update(b"merkle-node-v1");
368            hasher.update(left);
369            hasher.update(right);
370            next.push(hasher.finalize().to_vec());
371            i += 2;
372        }
373        current = next;
374        idx /= 2;
375    }
376    path
377}
378
379/// Verify a Merkle path from a leaf to the expected root.
380#[allow(dead_code)]
381pub(crate) fn verify_merkle_path(
382    leaf: &[u8],
383    path: &[Vec<u8>],
384    root: &[u8],
385    mut index: usize,
386) -> bool {
387    let mut current = leaf.to_vec();
388    for sibling in path {
389        let mut hasher = Sha256::new();
390        hasher.update(b"merkle-node-v1");
391        if index % 2 == 0 {
392            // current is left, sibling is right
393            hasher.update(&current);
394            hasher.update(sibling);
395        } else {
396            // current is right, sibling is left
397            hasher.update(sibling);
398            hasher.update(&current);
399        }
400        current = hasher.finalize().to_vec();
401        index /= 2;
402    }
403    current == root
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use sha2::{Digest, Sha256};
410
411    #[test]
412    fn test_prove_verify() {
413        let stmt: [u8; 32] = Sha256::digest(b"statement").into();
414        let wit:  [u8; 32] = Sha256::digest(b"witness").into();
415        let seed = [42u8; 32];
416        let proof = prove(stmt, wit, &seed).unwrap();
417        assert!(verify(&proof, &stmt, &seed).is_ok());
418    }
419
420    #[test]
421    fn test_verify_wrong_statement_fails() {
422        let stmt: [u8; 32] = Sha256::digest(b"statement").into();
423        let wrong_stmt: [u8; 32] = Sha256::digest(b"wrong").into();
424        let wit:  [u8; 32] = Sha256::digest(b"witness").into();
425        let seed = [42u8; 32];
426        let proof = prove(stmt, wit, &seed).unwrap();
427        assert!(verify(&proof, &wrong_stmt, &seed).is_err());
428    }
429
430    #[test]
431    fn test_verify_wrong_chaos_seed_fails() {
432        let stmt: [u8; 32] = Sha256::digest(b"statement").into();
433        let wit:  [u8; 32] = Sha256::digest(b"witness").into();
434        let seed = [42u8; 32];
435        let wrong_seed = [99u8; 32];
436        let proof = prove(stmt, wit, &seed).unwrap();
437        // Wrong seed → challenge recomputation will differ
438        assert!(verify(&proof, &stmt, &wrong_seed).is_err());
439    }
440
441    #[test]
442    fn test_aggregate() {
443        let seed = [1u8; 32];
444        let proofs: Vec<SnarkProof> = (0..4)
445            .map(|i| {
446                let stmt: [u8; 32] = Sha256::digest(alloc::format!("stmt{i}").as_bytes()).into();
447                let wit:  [u8; 32] = Sha256::digest(alloc::format!("wit{i}").as_bytes()).into();
448                prove(stmt, wit, &seed).unwrap()
449            })
450            .collect();
451        let weights = vec![100u64, 200, 150, 50];
452        let agg = aggregate(&proofs, &weights, &seed).unwrap();
453        assert!(!agg.commitment.is_empty());
454        assert!(!agg.challenge.is_empty());
455        assert!(!agg.response.is_empty());
456    }
457
458    #[test]
459    fn test_merkle_root_single() {
460        let leaf = vec![1u8; 32];
461        let root = merkle_root_of(std::slice::from_ref(&leaf));
462        assert_eq!(root, leaf);
463    }
464
465    #[test]
466    fn test_merkle_path_verify() {
467        let leaves: Vec<Vec<u8>> = (0..4u8)
468            .map(|i| Sha256::digest([i]).to_vec())
469            .collect();
470        let root = merkle_root_of(&leaves);
471        for i in 0..leaves.len() {
472            let path = merkle_path_of(&leaves, i);
473            assert!(verify_merkle_path(&leaves[i], &path, &root, i));
474        }
475    }
476
477    #[test]
478    fn test_different_witnesses_different_commitments() {
479        let stmt: [u8; 32] = Sha256::digest(b"statement").into();
480        let wit1: [u8; 32] = Sha256::digest(b"witness1").into();
481        let wit2: [u8; 32] = Sha256::digest(b"witness2").into();
482        let seed = [0u8; 32];
483        let p1 = prove(stmt, wit1, &seed).unwrap();
484        let p2 = prove(stmt, wit2, &seed).unwrap();
485        // Binding: different witnesses → different commitments
486        assert_ne!(p1.commitment, p2.commitment);
487    }
488}