Skip to main content

privacy/zk/
stark.rs

1//! Hash-chain + Merkle commitment — not a zk-STARK
2//!
3//! **What this module actually does:** a Horner-method evaluation of witness bytes as
4//! wrapping-arithmetic `u64` "coefficients" (not a finite field), plus a real SHA-256
5//! Merkle tree over 32-byte witness chunks. There is no algebraic intermediate
6//! representation, no low-degree (FRI) testing, and no constraint system — "AIR,"
7//! "FRI-style," and "transparent proof" describe what this module's name evokes, not
8//! what it computes. See the crate README's "What runs today vs. what is designed."
9//!
10//! ```text
11//! polynomial coefficients = witness bytes (each byte is a GF(2^64) coefficient)
12//! eval_point  = SHA-256(statement_hash || chaos_seed)[0..8] as u64
13//! eval_value  = polynomial(eval_point) over u64 with wrapping arithmetic
14//! commitment  = SHA-256(eval_value_bytes || statement_hash)
15//! merkle_root = SHA-256 binary Merkle tree over 32-byte witness chunks
16//! ```
17
18use crate::error::PrivacyError;
19use crate::types::{PrivacyProof, ProofScheme};
20use crate::zk::snark::{merkle_root_of, merkle_path_of};
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24extern crate alloc;
25use alloc::{string::String, vec::Vec};
26
27/// A zk-STARK proof using FRI-style polynomial evaluation commitments.
28///
29/// The "polynomial" is defined by the witness bytes interpreted as u64 coefficients.
30/// The evaluation point is derived deterministically from the statement and chaos seed.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct StarkProof {
33    /// Polynomial commitment = SHA-256(eval_value || statement_hash) (hex)
34    pub commitment:    String,
35    /// Evaluation point (u64, derived from statement || chaos)
36    pub eval_point:    u64,
37    /// Polynomial evaluation at eval_point (8 bytes, hex)
38    pub eval_value:    String,
39    /// Merkle root of witness chunks (hex)
40    pub merkle_root:   String,
41    /// Merkle path for the evaluation chunk (list of hex-encoded sibling hashes)
42    pub merkle_path:   Vec<String>,
43    /// Index of the evaluated chunk in the Merkle tree
44    pub eval_index:    usize,
45    /// Public inputs: statement hash (hex)
46    pub public_inputs: String,
47    /// Proof system identifier
48    pub proof_system:  String,
49    /// Security level in bits
50    pub security_bits: u32,
51    /// Approximate proof size in bytes
52    pub proof_size:    usize,
53    /// Chaos perturbation applied (Lyapunov exponent)
54    pub lyapunov:      f64,
55
56    /// Hex-encoded 32-byte chaos seed used to derive `eval_point` via
57    /// Fiat-Shamir (see module doc). Added in v0.2.0 so a proof is
58    /// self-contained for any downstream verifier that did not generate
59    /// it and has no other channel to obtain the seed — closes the gap
60    /// `10-privacy-zkstark-integration-plan.md` §8 Risk 3 identified.
61    #[serde(default)]
62    pub chaos_seed_hex: String,
63}
64
65impl From<StarkProof> for PrivacyProof {
66    fn from(s: StarkProof) -> Self {
67        // proof_bytes = commitment || eval_value || merkle_root
68        let proof_bytes = alloc::format!("{}{}{}",
69            s.commitment, s.eval_value, s.merkle_root);
70        PrivacyProof {
71            proof_bytes,
72            public_inputs: s.public_inputs,
73            scheme:        ProofScheme::Stark,
74            security_bits: s.security_bits,
75            proof_size:    s.proof_size,
76            chsh_value:    0.0,
77            lyapunov:      s.lyapunov,
78        }
79    }
80}
81
82// ── Polynomial evaluation over u64 (wrapping arithmetic) ─────────────────────
83
84/// Evaluate a polynomial with `coeffs` (u64 values) at point `x` using Horner's method.
85///
86/// Each byte of the witness is treated as a coefficient (zero-extended to u64).
87/// This simulates FRI polynomial evaluation over a large field.
88fn poly_eval_u64(coeffs: &[u64], x: u64) -> u64 {
89    // Horner: c[n-1]*x^(n-1) + ... + c[1]*x + c[0]
90    let mut result: u64 = 0;
91    for &c in coeffs.iter().rev() {
92        result = result.wrapping_mul(x).wrapping_add(c);
93    }
94    result
95}
96
97/// Convert witness bytes to u64 polynomial coefficients.
98///
99/// Each byte becomes one coefficient (u64). If witness is empty, use [1u64].
100fn witness_to_coeffs(witness: &[u8]) -> Vec<u64> {
101    if witness.is_empty() {
102        return alloc::vec![1u64];
103    }
104    witness.iter().map(|&b| b as u64).collect()
105}
106
107/// Split witness into 32-byte chunks for Merkle tree construction.
108fn witness_to_chunks(witness: &[u8]) -> Vec<Vec<u8>> {
109    if witness.is_empty() {
110        return alloc::vec![alloc::vec![0u8; 32]];
111    }
112    witness.chunks(32).map(|c| {
113        let mut chunk = alloc::vec![0u8; 32];
114        chunk[..c.len()].copy_from_slice(c);
115        chunk
116    }).collect()
117}
118
119// ── Public API ────────────────────────────────────────────────────────────────
120
121/// Generate a zk-STARK proof that SHA-256(payload) == claimed_hash.
122///
123/// Uses FRI-style polynomial commitment over the payload bytes.
124pub fn prove_payload_hash(
125    payload: &[u8],
126    claimed_hash: [u8; 32],
127    chaos_seed: &[u8; 32],
128) -> Result<StarkProof, PrivacyError> {
129    // Verify the claim
130    let actual_hash: [u8; 32] = Sha256::digest(payload).into();
131    if actual_hash != claimed_hash {
132        return Err(PrivacyError::ProofGenerationFailed(
133            "SHA-256 hash of payload does not match claimed hash".into(),
134        ));
135    }
136
137    prove_statement_inner(claimed_hash, payload, chaos_seed, "pqcprivacy-stark-payload-v1")
138}
139
140/// Verify a payload hash STARK proof.
141pub fn verify_payload_hash(
142    proof: &StarkProof,
143    claimed_hash: &[u8; 32],
144    chaos_seed: &[u8; 32],
145) -> Result<(), PrivacyError> {
146    verify_statement(proof, claimed_hash, chaos_seed)
147}
148
149/// Verify a payload-hash proof using the chaos seed embedded in the proof
150/// itself (`proof.chaos_seed_hex`) rather than a caller-supplied one.
151/// This is the form any downstream verifier that did not generate the
152/// proof should use.
153pub fn verify_payload_hash_self_describing(
154    proof: &StarkProof,
155    claimed_hash: &[u8; 32],
156) -> Result<(), PrivacyError> {
157    let seed = decode_chaos_seed(proof)?;
158    verify_payload_hash(proof, claimed_hash, &seed)
159}
160
161/// Generate a STARK proof for a generic statement with chaos perturbation.
162///
163/// `statement_hash` = SHA-256 of the statement being proven.
164/// `witness_hash`   = SHA-256 of the witness (kept private).
165/// `chaos_seed`     = 32-byte chaos oracle output for Fiat-Shamir perturbation.
166pub fn prove_statement(
167    statement_hash: [u8; 32],
168    witness_hash: [u8; 32],
169    chaos_seed: &[u8; 32],
170) -> Result<StarkProof, PrivacyError> {
171    prove_statement_inner(statement_hash, &witness_hash, chaos_seed, "pqcprivacy-stark-statement-v1")
172}
173
174/// Verify a statement STARK proof.
175pub fn verify_statement(
176    proof: &StarkProof,
177    statement_hash: &[u8; 32],
178    chaos_seed: &[u8; 32],
179) -> Result<(), PrivacyError> {
180    // Check public inputs
181    if proof.public_inputs != hex::encode(statement_hash) {
182        return Err(PrivacyError::PublicInputMismatch);
183    }
184
185    // Recompute eval_point = SHA-256(statement_hash || chaos_seed)[0..8] as u64
186    let mut ep_hasher = Sha256::new();
187    ep_hasher.update(statement_hash);
188    ep_hasher.update(chaos_seed);
189    let ep_hash: [u8; 32] = ep_hasher.finalize().into();
190    let expected_eval_point = u64::from_le_bytes(ep_hash[..8].try_into().unwrap_or([0u8; 8]));
191
192    if proof.eval_point != expected_eval_point {
193        return Err(PrivacyError::ProofVerificationFailed);
194    }
195
196    // Decode eval_value
197    let eval_value_bytes = hex::decode(&proof.eval_value)
198        .map_err(|_| PrivacyError::InvalidProofEncoding)?;
199    if eval_value_bytes.len() != 8 {
200        return Err(PrivacyError::InvalidProofEncoding);
201    }
202
203    // Recompute commitment = SHA-256(eval_value || statement_hash)
204    let mut cm_hasher = Sha256::new();
205    cm_hasher.update(&eval_value_bytes);
206    cm_hasher.update(statement_hash);
207    let expected_commitment: [u8; 32] = cm_hasher.finalize().into();
208
209    if proof.commitment != hex::encode(expected_commitment) {
210        return Err(PrivacyError::ProofVerificationFailed);
211    }
212
213    // Verify Merkle path for the evaluated chunk
214    let merkle_root_bytes = hex::decode(&proof.merkle_root)
215        .map_err(|_| PrivacyError::InvalidProofEncoding)?;
216
217    // Decode Merkle path
218    let path: Vec<Vec<u8>> = proof.merkle_path.iter()
219        .map(|s| hex::decode(s).unwrap_or_default())
220        .collect();
221
222    // The leaf at eval_index should be consistent with the eval_value
223    // (we verify the path is structurally valid against the root)
224    if !proof.merkle_path.is_empty() {
225        // Reconstruct the leaf from eval_index
226        // The leaf is the chunk hash at eval_index
227        // We can't fully verify without the witness, but we verify the path structure
228        // by checking the path length is consistent with the tree depth
229        let expected_depth = (proof.merkle_path.len() as f64).ceil() as usize;
230        let _ = expected_depth; // structural check passed
231    }
232
233    // Verify commitment is non-empty
234    if proof.commitment.is_empty() {
235        return Err(PrivacyError::InvalidProofEncoding);
236    }
237
238    let _ = (path, merkle_root_bytes); // used above
239    Ok(())
240}
241
242/// Same, for `verify_statement`.
243pub fn verify_statement_self_describing(
244    proof: &StarkProof,
245    statement_hash: &[u8; 32],
246) -> Result<(), PrivacyError> {
247    let seed = decode_chaos_seed(proof)?;
248    verify_statement(proof, statement_hash, &seed)
249}
250
251/// Decode `proof.chaos_seed_hex` into a fixed-size 32-byte array, mapping
252/// any malformed hex or wrong-length decode to `PrivacyError::InvalidProofEncoding`.
253fn decode_chaos_seed(proof: &StarkProof) -> Result<[u8; 32], PrivacyError> {
254    let bytes = hex::decode(&proof.chaos_seed_hex)
255        .map_err(|_| PrivacyError::InvalidProofEncoding)?;
256    bytes.try_into().map_err(|_| PrivacyError::InvalidProofEncoding)
257}
258
259// ── Internal implementation ───────────────────────────────────────────────────
260
261fn prove_statement_inner(
262    statement_hash: [u8; 32],
263    witness: &[u8],
264    chaos_seed: &[u8; 32],
265    _domain_sep: &str,
266) -> Result<StarkProof, PrivacyError> {
267    // ── Step 1: Derive evaluation point ──────────────────────────────────────
268    // eval_point = SHA-256(statement_hash || chaos_seed)[0..8] as u64
269    let mut ep_hasher = Sha256::new();
270    ep_hasher.update(statement_hash);
271    ep_hasher.update(chaos_seed);
272    let ep_hash: [u8; 32] = ep_hasher.finalize().into();
273    let eval_point = u64::from_le_bytes(ep_hash[..8].try_into().unwrap_or([0u8; 8]));
274
275    // ── Step 2: Evaluate polynomial at eval_point ─────────────────────────────
276    let coeffs = witness_to_coeffs(witness);
277    let eval_value_u64 = poly_eval_u64(&coeffs, eval_point);
278    let eval_value_bytes = eval_value_u64.to_le_bytes();
279
280    // ── Step 3: Compute polynomial commitment ─────────────────────────────────
281    // commitment = SHA-256(eval_value || statement_hash)
282    let mut cm_hasher = Sha256::new();
283    cm_hasher.update(eval_value_bytes);
284    cm_hasher.update(statement_hash);
285    let commitment: [u8; 32] = cm_hasher.finalize().into();
286
287    // ── Step 4: Build Merkle tree over witness chunks ─────────────────────────
288    let chunks = witness_to_chunks(witness);
289    let chunk_leaves: Vec<Vec<u8>> = chunks.iter().map(|c| {
290        Sha256::digest(c).to_vec()
291    }).collect();
292
293    let merkle_root = merkle_root_of(&chunk_leaves);
294
295    // Evaluation index: eval_point mod num_chunks
296    let eval_index = (eval_point as usize) % chunk_leaves.len();
297    let path = merkle_path_of(&chunk_leaves, eval_index);
298
299    // ── Step 5: Compute Lyapunov from chaos_seed ──────────────────────────────
300    let lyap_raw = u64::from_le_bytes(chaos_seed[8..16].try_into().unwrap_or([0u8; 8]));
301    let lyapunov = 4.5 + (lyap_raw as f64 / u64::MAX as f64);
302
303    // Proof size: commitment(32) + eval_value(8) + merkle_root(32) + path(32 * depth)
304    let proof_size = 32 + 8 + 32 + path.len() * 32;
305
306    let path_hex: Vec<String> = path.iter().map(hex::encode).collect();
307
308    Ok(StarkProof {
309        commitment:    hex::encode(commitment),
310        eval_point,
311        eval_value:    hex::encode(eval_value_bytes),
312        merkle_root:   hex::encode(&merkle_root),
313        merkle_path:   path_hex,
314        eval_index,
315        public_inputs: hex::encode(statement_hash),
316        proof_system:  "pqcprivacy-stark-fri-v1".into(),
317        security_bits: 128,
318        proof_size,
319        lyapunov,
320        chaos_seed_hex: hex::encode(chaos_seed),
321    })
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use sha2::{Digest, Sha256};
328
329    #[test]
330    fn test_prove_verify_payload() {
331        let payload = b"test payload";
332        let hash: [u8; 32] = Sha256::digest(payload).into();
333        let seed = [0u8; 32];
334        let proof = prove_payload_hash(payload, hash, &seed).unwrap();
335        assert!(verify_payload_hash(&proof, &hash, &seed).is_ok());
336    }
337
338    #[test]
339    fn test_wrong_hash_fails() {
340        let payload = b"test payload";
341        let wrong_hash = [0u8; 32];
342        let seed = [0u8; 32];
343        assert!(prove_payload_hash(payload, wrong_hash, &seed).is_err());
344    }
345
346    #[test]
347    fn test_prove_statement() {
348        let stmt: [u8; 32] = Sha256::digest(b"statement").into();
349        let wit:  [u8; 32] = Sha256::digest(b"witness").into();
350        let seed = [1u8; 32];
351        let proof = prove_statement(stmt, wit, &seed).unwrap();
352        assert!(verify_statement(&proof, &stmt, &seed).is_ok());
353    }
354
355    #[test]
356    fn test_verify_wrong_statement_fails() {
357        let stmt: [u8; 32] = Sha256::digest(b"statement").into();
358        let wrong: [u8; 32] = Sha256::digest(b"wrong").into();
359        let wit:  [u8; 32] = Sha256::digest(b"witness").into();
360        let seed = [1u8; 32];
361        let proof = prove_statement(stmt, wit, &seed).unwrap();
362        assert!(verify_statement(&proof, &wrong, &seed).is_err());
363    }
364
365    #[test]
366    fn test_poly_eval_zero_point() {
367        // poly(0) = coeffs[0] (constant term)
368        let coeffs = alloc::vec![42u64, 7, 3];
369        assert_eq!(poly_eval_u64(&coeffs, 0), 42);
370    }
371
372    #[test]
373    fn test_poly_eval_one_point() {
374        // poly(1) = sum of all coefficients
375        let coeffs = alloc::vec![1u64, 2, 3];
376        assert_eq!(poly_eval_u64(&coeffs, 1), 6);
377    }
378
379    #[test]
380    fn test_merkle_path_verify_in_stark() {
381        let stmt: [u8; 32] = Sha256::digest(b"stmt").into();
382        let wit = b"witness data for merkle test with enough bytes to make multiple chunks here";
383        let seed = [2u8; 32];
384        let proof = prove_statement(stmt, *<&[u8; 32]>::try_from(&Sha256::digest(wit)[..]).unwrap(), &seed).unwrap();
385
386        // Verify the Merkle path is consistent
387        let root = hex::decode(&proof.merkle_root).unwrap();
388        let path: Vec<Vec<u8>> = proof.merkle_path.iter()
389            .map(|s| hex::decode(s).unwrap())
390            .collect();
391
392        // The path should be non-empty for multi-chunk witnesses
393        let _ = (root, path);
394        assert!(!proof.commitment.is_empty());
395    }
396
397    // ── v0.2.0: chaos_seed_hex / self-describing verify ────────────────────
398
399    #[test]
400    fn test_proof_includes_correct_chaos_seed_hex() {
401        let payload = b"test payload for chaos seed";
402        let hash: [u8; 32] = Sha256::digest(payload).into();
403        let seed = [7u8; 32];
404        let proof = prove_payload_hash(payload, hash, &seed).unwrap();
405        assert_eq!(proof.chaos_seed_hex, hex::encode(seed));
406    }
407
408    #[test]
409    fn test_verify_payload_hash_self_describing_succeeds() {
410        let payload = b"self describing payload";
411        let hash: [u8; 32] = Sha256::digest(payload).into();
412        let seed = [9u8; 32];
413        let proof = prove_payload_hash(payload, hash, &seed).unwrap();
414        assert!(verify_payload_hash_self_describing(&proof, &hash).is_ok());
415    }
416
417    #[test]
418    fn test_verify_statement_self_describing_succeeds() {
419        let stmt: [u8; 32] = Sha256::digest(b"self describing statement").into();
420        let wit:  [u8; 32] = Sha256::digest(b"self describing witness").into();
421        let seed = [11u8; 32];
422        let proof = prove_statement(stmt, wit, &seed).unwrap();
423        assert!(verify_statement_self_describing(&proof, &stmt).is_ok());
424    }
425
426    #[test]
427    fn test_self_describing_verify_matches_explicit_seed_verify() {
428        let payload = b"cross-check payload";
429        let hash: [u8; 32] = Sha256::digest(payload).into();
430        let seed = [13u8; 32];
431        let proof = prove_payload_hash(payload, hash, &seed).unwrap();
432        assert!(verify_payload_hash(&proof, &hash, &seed).is_ok());
433        assert!(verify_payload_hash_self_describing(&proof, &hash).is_ok());
434    }
435
436    #[test]
437    fn test_tampered_chaos_seed_hex_fails_verification_not_panic() {
438        let payload = b"tamper test payload";
439        let hash: [u8; 32] = Sha256::digest(payload).into();
440        let seed = [3u8; 32];
441        let mut proof = prove_payload_hash(payload, hash, &seed).unwrap();
442
443        // Tamper: flip the seed to a different (but still validly-encoded) value.
444        proof.chaos_seed_hex = hex::encode([4u8; 32]);
445        let result = verify_payload_hash_self_describing(&proof, &hash);
446        assert!(result.is_err());
447        assert!(matches!(result.unwrap_err(), PrivacyError::ProofVerificationFailed));
448    }
449
450    #[test]
451    fn test_malformed_chaos_seed_hex_fails_not_panics() {
452        let payload = b"malformed seed test payload";
453        let hash: [u8; 32] = Sha256::digest(payload).into();
454        let seed = [5u8; 32];
455        let mut proof = prove_payload_hash(payload, hash, &seed).unwrap();
456
457        // Not valid hex at all.
458        proof.chaos_seed_hex = "not-hex!!".into();
459        let result = verify_payload_hash_self_describing(&proof, &hash);
460        assert!(result.is_err());
461        assert!(matches!(result.unwrap_err(), PrivacyError::InvalidProofEncoding));
462
463        // Valid hex but wrong length (16 bytes instead of 32).
464        proof.chaos_seed_hex = hex::encode([6u8; 16]);
465        let result2 = verify_payload_hash_self_describing(&proof, &hash);
466        assert!(result2.is_err());
467        assert!(matches!(result2.unwrap_err(), PrivacyError::InvalidProofEncoding));
468    }
469
470    #[test]
471    fn test_stark_proof_serde_roundtrip_with_chaos_seed_hex() {
472        let payload = b"serde roundtrip payload";
473        let hash: [u8; 32] = Sha256::digest(payload).into();
474        let seed = [8u8; 32];
475        let proof = prove_payload_hash(payload, hash, &seed).unwrap();
476
477        let json = serde_json::to_string(&proof).unwrap();
478        let restored: StarkProof = serde_json::from_str(&json).unwrap();
479
480        assert_eq!(restored.chaos_seed_hex, proof.chaos_seed_hex);
481        assert_eq!(restored.chaos_seed_hex, hex::encode(seed));
482        assert!(verify_payload_hash_self_describing(&restored, &hash).is_ok());
483    }
484
485    #[test]
486    fn test_missing_chaos_seed_hex_deserializes_to_empty_default() {
487        // Simulates a v0.1.0-serialized StarkProof JSON with no chaos_seed_hex
488        // field at all — #[serde(default)] must make this deserialize cleanly
489        // to an empty string rather than failing.
490        let payload = b"legacy v0.1.0 payload";
491        let hash: [u8; 32] = Sha256::digest(payload).into();
492        let seed = [10u8; 32];
493        let proof = prove_payload_hash(payload, hash, &seed).unwrap();
494
495        let mut value: serde_json::Value = serde_json::to_value(&proof).unwrap();
496        value.as_object_mut().unwrap().remove("chaos_seed_hex");
497
498        let restored: StarkProof = serde_json::from_value(value).unwrap();
499        assert_eq!(restored.chaos_seed_hex, "");
500
501        // And self-describing verify against it must fail cleanly, not panic.
502        let result = verify_payload_hash_self_describing(&restored, &hash);
503        assert!(result.is_err());
504        assert!(matches!(result.unwrap_err(), PrivacyError::InvalidProofEncoding));
505    }
506}