Skip to main content

qp_wormhole_verifier/
lib.rs

1//! Verifier logic for the Wormhole circuit.
2//!
3//! This module provides the [`WormholeVerifier`] type, which allows for the verification of
4//! zero-knowledge proofs generated by the Wormhole circuit.
5//!
6//! The typical usage flow involves:
7//! 1. Initializing the verifier from pre-built circuit data via [`WormholeVerifier::new_from_bytes()`].
8//! 2. Deserializing a [`ProofWithPublicInputs`].
9//! 3. Verifying the proof using [`WormholeVerifier::verify`].
10//!
11//! # Example
12//!
13//! ```ignore
14//! use qp_wormhole_verifier::{WormholeVerifier, ProofWithPublicInputs, C, D, F};
15//!
16//! // Load verifier from pre-serialized bytes
17//! let verifier = WormholeVerifier::new_from_bytes(verifier_bytes, common_bytes)?;
18//!
19//! // Deserialize the proof
20//! let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(proof_bytes, &verifier.circuit_data.common)?;
21//!
22//! // Verify
23//! verifier.verify(proof)?;
24//! ```
25#![cfg_attr(not(feature = "std"), no_std)]
26
27#[cfg(not(feature = "std"))]
28extern crate alloc;
29
30#[cfg(not(feature = "std"))]
31use alloc::vec::Vec;
32#[cfg(feature = "std")]
33use std::vec::Vec;
34
35use anyhow::anyhow;
36#[cfg(feature = "std")]
37use std::path::Path;
38
39// Re-export types from qp-plonky2-verifier
40pub use qp_plonky2_verifier::{
41    CircuitConfig, CommonCircuitData, ProofWithPublicInputs, VerifierCircuitData,
42    VerifierOnlyCircuitData, C, D, F,
43};
44
45use qp_plonky2_verifier::field::types::PrimeField64;
46use qp_plonky2_verifier::util::serialization::DefaultGateSerializer;
47use tiny_keccak::{Hasher, Keccak};
48
49// Re-export input types from qp-wormhole-inputs
50pub use qp_wormhole_inputs::{
51    BlockData, BytesDigest, PrivateBatchPublicInputs, PublicBatchPublicInputs, PublicCircuitInputs,
52    PublicInputsByAccount, MIN_LEAF_SECURITY_BITS, PUBLIC_INPUTS_FELTS_LEN,
53};
54
55/// Parse public inputs from a proof.
56pub fn parse_public_inputs(
57    proof: &ProofWithPublicInputs<F, C, D>,
58) -> anyhow::Result<PublicCircuitInputs> {
59    let u64s: Vec<u64> = proof
60        .public_inputs
61        .iter()
62        .map(|f| f.to_canonical_u64())
63        .collect();
64    PublicCircuitInputs::try_from_u64_slice(&u64s)
65}
66
67/// Parse aggregated public inputs from a proof.
68pub fn parse_private_batch_public_inputs(
69    proof: &ProofWithPublicInputs<F, C, D>,
70) -> anyhow::Result<PrivateBatchPublicInputs> {
71    let u64s: Vec<u64> = proof
72        .public_inputs
73        .iter()
74        .map(|f| f.to_canonical_u64())
75        .collect();
76    PrivateBatchPublicInputs::try_from_u64_slice(&u64s)
77}
78
79/// Parse public-batch public inputs from a proof.
80pub fn parse_public_batch_public_inputs(
81    proof: &ProofWithPublicInputs<F, C, D>,
82    num_private_batch_proofs: usize,
83    num_leaf_proofs: usize,
84) -> anyhow::Result<PublicBatchPublicInputs> {
85    let u64s: Vec<u64> = proof
86        .public_inputs
87        .iter()
88        .map(|f| f.to_canonical_u64())
89        .collect();
90    PublicBatchPublicInputs::try_from_u64_slice(&u64s, num_private_batch_proofs, num_leaf_proofs)
91}
92
93/// Verifier for Wormhole circuit proofs.
94///
95/// This struct wraps the circuit verification data and provides methods to verify proofs.
96#[derive(Debug)]
97pub struct WormholeVerifier {
98    pub circuit_data: VerifierCircuitData<F, C, D>,
99}
100
101// Keccak-256 commitments to the byte-exact canonical leaf artifacts produced by
102// `WormholeCircuit::new(CircuitConfig::standard_recursion_config())` with
103// qp-plonky2 1.5.1. The integration tests rebuild the circuit and fail if a
104// deliberate circuit change requires these commitments to be updated.
105const CANONICAL_LEAF_VERIFIER_KECCAK256: [u8; 32] = [
106    0x21, 0x86, 0xb2, 0x0f, 0x20, 0x02, 0x7d, 0xae, 0x1e, 0x16, 0x23, 0xa0, 0x26, 0xe9, 0x0f, 0xf5,
107    0x50, 0x04, 0x2b, 0x33, 0x02, 0xfb, 0x1a, 0x01, 0x4e, 0x9a, 0xd5, 0x7b, 0x8a, 0x5b, 0x22, 0xb2,
108];
109const CANONICAL_LEAF_COMMON_KECCAK256: [u8; 32] = [
110    0x84, 0x5d, 0x8f, 0xdf, 0x8f, 0xfe, 0x9f, 0xd5, 0xef, 0x3f, 0x8b, 0xe8, 0x74, 0x11, 0x4b, 0xf1,
111    0x57, 0x5d, 0xcf, 0xe2, 0xb0, 0x1d, 0xb4, 0x46, 0x5f, 0x3d, 0x9a, 0x34, 0xf6, 0xb7, 0xb7, 0xe1,
112];
113
114/// Maximum size accepted for a serialized verifier artifact, whether loaded
115/// from a file or passed as bytes.
116///
117/// The keccak256 canonicality pin already rejects everything but the
118/// byte-exact canonical leaf artifacts, so this cap's only job is to bound
119/// the work done BEFORE the pin can fire: the file read's allocation in
120/// [`WormholeVerifier::new_from_files`] and the keccak pass over
121/// caller-supplied slices in [`WormholeVerifier::new_from_bytes`]. That work
122/// is proportional to the cap, so the cap must be tight for the "rejected in
123/// O(1)" property to mean anything. The canonical artifacts are a few KB;
124/// 1 MiB is ~2–3 orders of magnitude of headroom while keeping the worst
125/// pre-pin allocation and hash trivially cheap. (The aggregator crate keeps
126/// its own, larger bound for its much larger circuit artifacts.)
127pub const MAX_VERIFIER_ARTIFACT_BYTES: u64 = 1024 * 1024;
128
129fn keccak256(input: &[u8]) -> [u8; 32] {
130    let mut output = [0u8; 32];
131    let mut hasher = Keccak::v256();
132    hasher.update(input);
133    hasher.finalize(&mut output);
134    output
135}
136
137/// Read a verifier-artifact file, refusing anything larger than
138/// [`MAX_VERIFIER_ARTIFACT_BYTES`] before allocating for its contents.
139///
140/// Mirrors the aggregator's size-capped reader. Artifact paths are assumed
141/// to come from attested CI builds (`wormhole/THREAT_MODEL.md`).
142#[cfg(feature = "std")]
143fn read_artifact_file(path: &Path) -> anyhow::Result<Vec<u8>> {
144    use anyhow::Context as _;
145
146    let metadata = std::fs::metadata(path)
147        .with_context(|| format!("failed to stat artifact file {}", path.display()))?;
148    let claimed_len = metadata.len();
149    if claimed_len > MAX_VERIFIER_ARTIFACT_BYTES {
150        return Err(anyhow!(
151            "artifact file {} is {} bytes, which exceeds the {} byte limit for \
152             verifier artifacts; refusing to load it",
153            path.display(),
154            claimed_len,
155            MAX_VERIFIER_ARTIFACT_BYTES
156        ));
157    }
158    std::fs::read(path).with_context(|| format!("failed to read artifact file {}", path.display()))
159}
160
161impl WormholeVerifier {
162    /// Creates a new [`WormholeVerifier`] from verifier and common data bytes.
163    ///
164    /// Inputs larger than [`MAX_VERIFIER_ARTIFACT_BYTES`] are rejected before any
165    /// hashing, so the size bound is a property of the loading contract
166    /// itself, not just of the file-based wrapper. Within that bound, both
167    /// serialized inputs must match the byte-exact canonical Wormhole leaf
168    /// artifacts (keccak256 pin). The decoded config and public-input shape
169    /// are checked again as defense in depth.
170    pub fn new_from_bytes(verifier_bytes: &[u8], common_bytes: &[u8]) -> anyhow::Result<Self> {
171        for (label, bytes) in [("verifier-only", verifier_bytes), ("common", common_bytes)] {
172            if bytes.len() as u64 > MAX_VERIFIER_ARTIFACT_BYTES {
173                return Err(anyhow!(
174                    "{} artifact is {} bytes, which exceeds the {} byte limit for \
175                     verifier artifacts; refusing to load it",
176                    label,
177                    bytes.len(),
178                    MAX_VERIFIER_ARTIFACT_BYTES
179                ));
180            }
181        }
182
183        let verifier_hash = keccak256(verifier_bytes);
184        if verifier_hash != CANONICAL_LEAF_VERIFIER_KECCAK256 {
185            return Err(anyhow!(
186                "loaded verifier-only artifact does not match the canonical Wormhole leaf circuit (keccak256={:02x?})",
187                verifier_hash
188            ));
189        }
190
191        let common_hash = keccak256(common_bytes);
192        if common_hash != CANONICAL_LEAF_COMMON_KECCAK256 {
193            return Err(anyhow!(
194                "loaded common artifact does not match the canonical Wormhole leaf circuit (keccak256={:02x?})",
195                common_hash
196            ));
197        }
198
199        let verifier_only = VerifierOnlyCircuitData::from_bytes(verifier_bytes.to_vec())
200            .map_err(|e| anyhow!("failed to deserialize verifier data: {}", e))?;
201
202        let common = CommonCircuitData::from_bytes(common_bytes.to_vec(), &DefaultGateSerializer)
203            .map_err(|e| anyhow!("failed to deserialize common circuit data: {}", e))?;
204
205        Self::ensure_loaded_matches_canonical_leaf_profile(&common)?;
206
207        let circuit_data = VerifierCircuitData {
208            verifier_only,
209            common,
210        };
211
212        Ok(Self { circuit_data })
213    }
214
215    fn ensure_loaded_matches_canonical_leaf_profile(
216        common: &CommonCircuitData<F, D>,
217    ) -> anyhow::Result<()> {
218        let expected = CircuitConfig::standard_recursion_config();
219
220        if expected.security_bits < MIN_LEAF_SECURITY_BITS {
221            return Err(anyhow!(
222                "canonical recursion config provides only {} security bits, below the required minimum of {}",
223                expected.security_bits,
224                MIN_LEAF_SECURITY_BITS
225            ));
226        }
227
228        if common.config != expected {
229            return Err(anyhow!(
230                "loaded verifier circuit config does not match the canonical Wormhole leaf config \
231                 (security_bits loaded={}, expected={})",
232                common.config.security_bits,
233                expected.security_bits
234            ));
235        }
236
237        if common.num_public_inputs != PUBLIC_INPUTS_FELTS_LEN {
238            return Err(anyhow!(
239                "loaded verifier common data has {} public inputs, expected {} for the canonical Wormhole leaf circuit",
240                common.num_public_inputs,
241                PUBLIC_INPUTS_FELTS_LEN
242            ));
243        }
244
245        Ok(())
246    }
247
248    /// Creates a new [`WormholeVerifier`] from a verifier and common data files.
249    ///
250    /// Both files are read through [`read_artifact_file`] (size-capped). The
251    /// keccak256 canonicality pin in [`Self::new_from_bytes`] then rejects any
252    /// non-canonical contents. Paths are assumed to come from attested CI
253    /// builds (`wormhole/THREAT_MODEL.md`).
254    #[cfg(feature = "std")]
255    pub fn new_from_files(
256        verifier_data_path: &Path,
257        common_data_path: &Path,
258    ) -> anyhow::Result<Self> {
259        let verifier_bytes = read_artifact_file(verifier_data_path)?;
260        let common_bytes = read_artifact_file(common_data_path)?;
261
262        Self::new_from_bytes(&verifier_bytes, &common_bytes)
263    }
264
265    /// Verify a [`ProofWithPublicInputs`].
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the proof is not valid.
270    pub fn verify_ref(&self, proof: &ProofWithPublicInputs<F, C, D>) -> anyhow::Result<()> {
271        self.circuit_data
272            .verify(proof.clone())
273            .map_err(|e| anyhow!("proof verification failed: {}", e))
274    }
275
276    /// Verify a [`ProofWithPublicInputs`].
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if the proof is not valid.
281    pub fn verify(&self, proof: ProofWithPublicInputs<F, C, D>) -> anyhow::Result<()> {
282        self.verify_ref(&proof)
283    }
284}
285
286#[cfg(all(test, feature = "std"))]
287mod tests {
288    use super::*;
289
290    fn temp_dir(tag: &str) -> std::path::PathBuf {
291        let dir = std::env::temp_dir().join(format!(
292            "qp-verifier-artifact-test-{}-{}",
293            tag,
294            std::process::id()
295        ));
296        let _ = std::fs::remove_dir_all(&dir);
297        std::fs::create_dir_all(&dir).unwrap();
298        dir
299    }
300
301    /// `new_from_bytes` is the public loading contract, so the size bound
302    /// must be enforced there — not only in the file wrapper — otherwise a
303    /// caller-supplied oversized slice is keccak-hashed in full before the
304    /// canonicality pin rejects it.
305    #[test]
306    fn new_from_bytes_rejects_oversized_slices_before_hashing() {
307        let oversized = vec![0u8; MAX_VERIFIER_ARTIFACT_BYTES as usize + 1];
308
309        let err = WormholeVerifier::new_from_bytes(&oversized, b"irrelevant").unwrap_err();
310        assert!(
311            format!("{err:#}").contains("exceeds the"),
312            "oversized verifier bytes must be rejected by the size cap, got: {err:#}"
313        );
314
315        let err = WormholeVerifier::new_from_bytes(b"irrelevant", &oversized).unwrap_err();
316        assert!(
317            format!("{err:#}").contains("exceeds the"),
318            "oversized common bytes must be rejected by the size cap, got: {err:#}"
319        );
320    }
321
322    /// An oversized artifact file must be rejected by a size bound BEFORE its
323    /// contents are buffered, not after: `std::fs::read` would otherwise
324    /// allocate and read the whole file just so the keccak pin can reject it,
325    /// letting an attacker-planted sparse file memory-starve verifier startup
326    /// (audit finding: unbounded trusted file reads).
327    #[test]
328    fn new_from_files_rejects_oversized_artifact_before_reading() {
329        let dir = temp_dir("oversized");
330
331        let verifier_path = dir.join("verifier.bin");
332        let common_path = dir.join("common.bin");
333        // A sparse file claims an enormous length without costing disk space,
334        // exactly like an attacker-planted artifact would.
335        std::fs::File::create(&verifier_path)
336            .unwrap()
337            .set_len(MAX_VERIFIER_ARTIFACT_BYTES + 1)
338            .unwrap();
339        std::fs::write(&common_path, b"irrelevant").unwrap();
340
341        let err = WormholeVerifier::new_from_files(&verifier_path, &common_path).unwrap_err();
342        assert!(
343            format!("{err:#}").contains("exceeds the"),
344            "oversized artifact must be rejected by the size cap, got: {err:#}"
345        );
346
347        std::fs::remove_dir_all(&dir).unwrap();
348    }
349}