From 6b8a7d18a79486620dcb3631fbacb104f01b31e6 Mon Sep 17 00:00:00 2001 From: sphere <101384151+spherel@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:09:33 -0700 Subject: [PATCH] fix(soundness): bind verifying-key digest into proof transcript (#356) Compute a Poseidon digest of the verifying key in a single bincode + DefaultChallenger pass, and absorb the squeezed extension elements into the protocol transcript before any proof-derived data. The prover, the native verifier and the recursion verifier all absorb the same felts. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + Cargo.lock | 1 + ceno_recursion/src/zkvm_verifier/verifier.rs | 10 +- ceno_zkvm/Cargo.toml | 1 + ceno_zkvm/src/scheme/prover.rs | 7 +- ceno_zkvm/src/scheme/verifier.rs | 3 + ceno_zkvm/src/structs.rs | 97 ++++++++++++++++++++ gkr_iop/src/circuit_builder.rs | 1 + 8 files changed, 119 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 267346272..dc0f44f02 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target +.tmp/ .vscode .idea *.log diff --git a/Cargo.lock b/Cargo.lock index 0945d22db..5285d14c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,6 +1230,7 @@ dependencies = [ "once_cell", "p3", "parse-size", + "poseidon", "pprof2", "prettytable-rs", "proptest", diff --git a/ceno_recursion/src/zkvm_verifier/verifier.rs b/ceno_recursion/src/zkvm_verifier/verifier.rs index eb13c53e5..d504d1e51 100644 --- a/ceno_recursion/src/zkvm_verifier/verifier.rs +++ b/ceno_recursion/src/zkvm_verifier/verifier.rs @@ -113,7 +113,7 @@ fn challenger_observe_exts( } } -pub fn verify_zkvm_proof>( +pub fn verify_zkvm_proof>( builder: &mut Builder, zkvm_proof_input: ZKVMProofInputVariable, vk: &ZKVMVerifyingKey, @@ -125,6 +125,14 @@ pub fn verify_zkvm_proof>( let prod_w: Ext = builder.constant(C::EF::ONE); let logup_sum: Ext = builder.constant(C::EF::ZERO); + let vk_digest = vk.compute_digest(); + let vk_digest_array: Array> = builder.dyn_array(vk_digest.len()); + for (i, digest_element) in vk_digest.iter().enumerate() { + let baked: Ext = builder.constant(*digest_element); + builder.set_value(&vk_digest_array, i, baked); + } + challenger_observe_exts(builder, &mut challenger, &vk_digest_array); + for (_, circuit_vk) in vk.circuit_vks.iter() { for instance_value in circuit_vk.get_cs().zkvm_v1_css.instance.iter() { let raw = builder.get(&zkvm_proof_input.pi, instance_value.0); diff --git a/ceno_zkvm/Cargo.toml b/ceno_zkvm/Cargo.toml index e0f9dc23e..d9e170f40 100644 --- a/ceno_zkvm/Cargo.toml +++ b/ceno_zkvm/Cargo.toml @@ -23,6 +23,7 @@ mpcs = { workspace = true, features = ["whir"] } multilinear_extensions.workspace = true once_cell.workspace = true p3.workspace = true +poseidon.workspace = true rayon.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/ceno_zkvm/src/scheme/prover.rs b/ceno_zkvm/src/scheme/prover.rs index fe048bd37..363b2327a 100644 --- a/ceno_zkvm/src/scheme/prover.rs +++ b/ceno_zkvm/src/scheme/prover.rs @@ -34,7 +34,7 @@ use crate::{ hal::{DeviceProvingKey, ProofInput}, utils::build_main_witness, }, - structs::{TowerProofs, ZKVMProvingKey, ZKVMWitnesses}, + structs::{RV32imMemStateConfig, TowerProofs, ZKVMProvingKey, ZKVMWitnesses}, }; type CreateTableProof<'a, PB> = ( @@ -317,6 +317,11 @@ impl< shard_id = shard_ctx.shard_id ) .in_scope(|| { + let digest_span = entered_span!("commit_to_vk_digest", profiling_1 = true); + let vk_digest = self.pk.compute_vk_digest::(); + transcript.append_field_element_exts(&vk_digest); + exit_span!(digest_span); + let span = entered_span!("commit_to_pi", profiling_1 = true); // Include transcript-visible public values in canonical circuit order. // The order must match verifier and recursion verifier exactly. diff --git a/ceno_zkvm/src/scheme/verifier.rs b/ceno_zkvm/src/scheme/verifier.rs index 968c9bddc..e2e4b9244 100644 --- a/ceno_zkvm/src/scheme/verifier.rs +++ b/ceno_zkvm/src/scheme/verifier.rs @@ -491,6 +491,9 @@ impl> } } + let vk_digest = self.vk.compute_digest(); + transcript.append_field_element_exts(&vk_digest); + // Include transcript-visible public values in canonical circuit order. // This must match prover and recursion verifier exactly. for (_, circuit_vk) in self.vk.circuit_vks.iter() { diff --git a/ceno_zkvm/src/structs.rs b/ceno_zkvm/src/structs.rs index f205333ca..b83efffce 100644 --- a/ceno_zkvm/src/structs.rs +++ b/ceno_zkvm/src/structs.rs @@ -19,6 +19,8 @@ use gkr_iop::{ use itertools::Itertools; use mpcs::{Point, PolynomialCommitmentScheme}; use multilinear_extensions::Instance; +use p3::field::FieldAlgebra; +use poseidon::challenger::{CanObserve, DefaultChallenger, FieldChallenger}; use rayon::{ iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}, prelude::ParallelSlice, @@ -965,6 +967,101 @@ pub struct ZKVMVerifyingKey< pub fixed_no_omc_init_commit: Option<>::Commitment>, // circuit index -> circuit name // mainly used for debugging + #[serde(skip)] pub circuit_index_to_name: BTreeMap, pub mem_state_verifier: M, } + +/// Number of extension-field elements squeezed from the digest sponge. +pub const VK_DIGEST_LEN: usize = 2; + +/// Convert bytes into base-field elements using ≤ 3-byte chunks. +fn bytes_to_felts_safe(bytes: &[u8]) -> Vec { + bytes + .chunks(3) + .map(|chunk| { + let mut arr = [0u8; 4]; + arr[..chunk.len()].copy_from_slice(chunk); + F::from_canonical_u32(u32::from_le_bytes(arr)) + }) + .collect() +} + +/// Hash the supplied VK preimage into `VK_DIGEST_LEN` extension elements. +/// `circuits` must iterate in `BTreeMap` lex order on `name`. +fn compute_vk_digest_inner<'a, E, PCS, M>( + vp: &PCS::VerifierParam, + entry_pc: u32, + fixed_commit: &Option<>::Commitment>, + fixed_no_omc_init_commit: &Option<>::Commitment>, + mem_state_verifier: &M, + circuits: impl IntoIterator)>, +) -> [E; VK_DIGEST_LEN] +where + E: ExtensionField, + PCS: PolynomialCommitmentScheme, + M: Serialize, +{ + let circuit_view: Vec<(&str, &ComposedConstrainSystem)> = circuits + .into_iter() + .map(|(name, vk)| (name.as_str(), &vk.cs)) + .collect(); + let bytes = bincode::serialize(&( + vp, + entry_pc, + fixed_commit, + fixed_no_omc_init_commit, + mem_state_verifier, + &circuit_view, + )) + .expect("vk digest serialization is infallible for a valid VK"); + + let mut sponge = DefaultChallenger::::new_poseidon_default(); + sponge.observe_slice(&bytes_to_felts_safe::(&bytes)); + std::array::from_fn(|_| sponge.sample_ext_element()) +} + +impl ZKVMVerifyingKey +where + E: ExtensionField, + PCS: PolynomialCommitmentScheme, + M: Clone + Default + Serialize + DeserializeOwned, +{ + /// Poseidon-sponge digest of the verifying key's soundness-bound fields. + pub fn compute_digest(&self) -> [E; VK_DIGEST_LEN] { + compute_vk_digest_inner::( + &self.vp, + self.entry_pc, + &self.fixed_commit, + &self.fixed_no_omc_init_commit, + &self.mem_state_verifier, + self.circuit_vks.iter(), + ) + } +} + +impl ZKVMProvingKey +where + E: ExtensionField, + PCS: PolynomialCommitmentScheme, +{ + /// Prover-side mirror of [`ZKVMVerifyingKey::compute_digest`] for `M`. + pub fn compute_vk_digest(&self) -> [E; VK_DIGEST_LEN] + where + M: Clone + Default + From + Serialize, + { + let mem_state_verifier: M = self + .program_ctx + .as_ref() + .map(|ctx| M::from(ctx.platform.clone())) + .unwrap_or_default(); + compute_vk_digest_inner::( + &self.vp, + self.entry_pc, + &self.fixed_commit, + &self.fixed_no_omc_init_commit, + &mem_state_verifier, + self.circuit_pks.iter().map(|(n, pk)| (n, &pk.vk)), + ) + } +} diff --git a/gkr_iop/src/circuit_builder.rs b/gkr_iop/src/circuit_builder.rs index f211c8dbe..0ad3054eb 100644 --- a/gkr_iop/src/circuit_builder.rs +++ b/gkr_iop/src/circuit_builder.rs @@ -169,6 +169,7 @@ pub struct ConstraintSystem { pub chip_record_alpha: Expression, pub chip_record_beta: Expression, + #[serde(skip)] pub debug_map: HashMap>>, pub(crate) phantom: PhantomData,