From 21fa6a124cff31c2cfa22df5f920765596f7bba8 Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 20 May 2026 14:34:09 -0300 Subject: [PATCH 1/5] Cache the bitwise preprocessed commitment --- Cargo.lock | 107 +++++++++++++++++++++++++++++++++ prover/Cargo.toml | 2 + prover/src/lib.rs | 53 ++++++++++++++-- prover/src/tests/mod.rs | 2 + prover/src/tests/vkey_tests.rs | 81 +++++++++++++++++++++++++ prover/src/vkey.rs | 73 ++++++++++++++++++++++ 6 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 prover/src/tests/vkey_tests.rs create mode 100644 prover/src/vkey.rs diff --git a/Cargo.lock b/Cargo.lock index 70b4071e8..d4057252a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,6 +258,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atty" version = "0.2.14" @@ -575,6 +584,15 @@ dependencies = [ "tikv-jemallocator", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -706,6 +724,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam" version = "0.8.4" @@ -988,6 +1012,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1547,6 +1583,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1574,6 +1619,20 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1959,8 +2018,10 @@ dependencies = [ "executor", "log", "math", + "postcard", "rayon", "serde", + "sha3", "stark", "sysinfo", "tikv-jemalloc-ctl", @@ -2049,6 +2110,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -2518,6 +2588,19 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2889,6 +2972,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -2968,6 +3060,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -2982,6 +3080,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -3207,6 +3311,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" diff --git a/prover/Cargo.toml b/prover/Cargo.toml index 82ca8bfe0..599714c32 100644 --- a/prover/Cargo.toml +++ b/prover/Cargo.toml @@ -19,6 +19,8 @@ serde = { version = "1.0", features = ["derive"] } rayon = { version = "1.8.0", optional = true } sysinfo = { version = "0.31", default-features = false, features = ["system"] } log = "0.4" +sha3 = "0.10.8" +postcard = { version = "1.0", features = ["alloc"] } [dev-dependencies] env_logger = "*" diff --git a/prover/src/lib.rs b/prover/src/lib.rs index dbe13d20b..ba7644ab2 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -21,6 +21,9 @@ pub mod tables; pub mod test_utils; #[cfg(test)] pub mod tests; +pub mod vkey; + +pub use vkey::VmVerifyingKey; use std::fmt; @@ -336,6 +339,28 @@ impl VmAirs { minimal_bitwise: bool, page_configs: &[crate::tables::page::PageConfig], table_counts: &TableCounts, + ) -> Self { + Self::new_with_vkey( + elf, + proof_options, + minimal_bitwise, + page_configs, + table_counts, + None, + ) + } + + /// Same as [`Self::new`] but accepts a precomputed [`VmVerifyingKey`]. + /// When `vkey` is `Some`, the bitwise preprocessed commitment is taken + /// from it instead of being recomputed from `proof_options` — that + /// recomputation is ~87% of verifier cycles inside the recursion guest. + pub fn new_with_vkey( + elf: &Elf, + proof_options: &ProofOptions, + minimal_bitwise: bool, + page_configs: &[crate::tables::page::PageConfig], + table_counts: &TableCounts, + vkey: Option<&VmVerifyingKey>, ) -> Self { let cpus: Vec<_> = (0..table_counts.cpu) .map(|i| create_cpu_air(proof_options).with_name(&format!("CPU[{}]", i))) @@ -343,10 +368,12 @@ impl VmAirs { let bitwise = if minimal_bitwise { create_bitwise_air(proof_options) } else { - create_bitwise_air(proof_options).with_preprocessed( - bitwise::preprocessed_commitment(proof_options), - bitwise::NUM_PRECOMPUTED_COLS, - ) + let commitment = match vkey { + Some(vk) => vk.bitwise, + None => bitwise::preprocessed_commitment(proof_options), + }; + create_bitwise_air(proof_options) + .with_preprocessed(commitment, bitwise::NUM_PRECOMPUTED_COLS) }; let lts: Vec<_> = (0..table_counts.lt) .map(|i| create_lt_air(proof_options).with_name(&format!("LT[{}]", i))) @@ -708,6 +735,21 @@ pub fn verify_with_options( vm_proof: &VmProof, elf_bytes: &[u8], proof_options: &ProofOptions, +) -> Result { + verify_with_options_with_vkey(vm_proof, elf_bytes, proof_options, None) +} + +/// Same as [`verify_with_options`] but accepts a precomputed +/// [`VmVerifyingKey`]. When `vkey` is `Some`, the bitwise preprocessed +/// commitment is taken from it instead of being recomputed inside +/// `VmAirs::new`. A tampered vkey is caught by Fiat-Shamir: the verifier +/// feeds the supplied commitment into the transcript, derives different +/// challenges from what the prover used, and the openings stop matching. +pub fn verify_with_options_with_vkey( + vm_proof: &VmProof, + elf_bytes: &[u8], + proof_options: &ProofOptions, + vkey: Option<&VmVerifyingKey>, ) -> Result { // Validate table_counts before constructing AIRs. // A malicious prover could set counts to 0, removing entire constraint sets. @@ -747,12 +789,13 @@ pub fn verify_with_options( ))); } - let airs = VmAirs::new( + let airs = VmAirs::new_with_vkey( &program, proof_options, false, &page_configs, &vm_proof.table_counts, + vkey, ); // Recompute the COMMIT output bus offset from VmProof.public_output. diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index dc5f3fe22..0afe2bbc1 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -30,3 +30,5 @@ pub mod mul_tests; pub mod prove_elfs_tests; #[cfg(test)] pub mod trace_builder_tests; +#[cfg(test)] +pub mod vkey_tests; diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs new file mode 100644 index 000000000..3838b94f7 --- /dev/null +++ b/prover/src/tests/vkey_tests.rs @@ -0,0 +1,81 @@ +//! Tests for [`crate::VmVerifyingKey`] and the vkey-aware verify path. + +use executor::elf::Elf; +use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; + +use crate::VmVerifyingKey; +use crate::test_utils::asm_elf_bytes; +use crate::vkey::VKEY_VERSION; + +fn default_options() -> ProofOptions { + GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid") +} + +#[test] +fn test_vkey_roundtrip() { + let elf_bytes = asm_elf_bytes("sub"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options); + assert_eq!(vkey.version, VKEY_VERSION, "version field must be set"); + let digest_before = vkey.compute_digest(); + + // Two host derivations on the same inputs must produce the same vkey; + // the BITWISE_COMMITMENT cache should not change between calls. + let vkey_again = VmVerifyingKey::from_elf_and_options(&elf, &options); + assert_eq!(vkey, vkey_again, "vkey derivation must be deterministic"); + + // postcard round-trip preserves every field. + let encoded = postcard::to_allocvec(&vkey).expect("postcard encode"); + let decoded: VmVerifyingKey = postcard::from_bytes(&encoded).expect("postcard decode"); + assert_eq!(vkey, decoded, "postcard round-trip must preserve the vkey"); + assert_eq!( + decoded.compute_digest(), + digest_before, + "digest must be stable across serialization" + ); +} + +#[test] +fn test_vkey_verify_equivalence() { + // Prove a tiny program once with the full (non-minimal) bitwise table, + // then verify it both ways: with and without a precomputed vkey. + // Both paths must accept the proof. This is the core correctness + // guarantee — the vkey shortcut produces identical results to the + // recompute-from-scratch path. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = crate::prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options); + + let baseline = crate::verify_with_options(&vm_proof, &elf_bytes, &options) + .expect("baseline verify errored"); + assert!(baseline, "baseline verify must accept the proof"); + + let with_vkey = + crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("vkey verify errored"); + assert!(with_vkey, "vkey verify must accept the same proof"); +} + +#[test] +fn test_vkey_mismatch_rejects() { + // Tamper with vkey.bitwise. Without an explicit `vk_digest` field on + // VmProof (deferred to a later PR), rejection comes from Fiat-Shamir: + // the verifier feeds the tampered commitment into the transcript, + // derives different challenges from what the prover used, and the + // proof's openings stop matching. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = crate::prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options); + + vkey.bitwise[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered bitwise commitment must cause rejection"); +} diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs new file mode 100644 index 000000000..4b314e375 --- /dev/null +++ b/prover/src/vkey.rs @@ -0,0 +1,73 @@ +//! Verifying key for the lambda-vm STARK verifier. +//! +//! Caches preprocessed-table Merkle commitments that the verifier would +//! otherwise recompute on every call. Mirrors the SP1 `MachineVerifyingKey` +//! pattern (preprocessed commitments derived once at setup, never recomputed +//! per-proof) and the prover-side companion in +//! (which caches the +//! same data on the prover side). +//! +//! ## Current scope +//! +//! Only the BITWISE preprocessed commitment is cached here. The other four +//! preprocessed tables (DECODE, KECCAK_RC, REGISTER, PAGE) are still +//! recomputed inside `VmAirs::new`; follow-up PRs will move them into this +//! struct one at a time. The `version` field exists so a vkey serialized +//! today does not accidentally validate against a future shape. +//! +//! ## Security +//! +//! For this PR the verifying key is only a performance shortcut. The +//! verifier still relies on Fiat-Shamir: the bitwise commitment the prover +//! used is bound into the proof's challenges, so a verifier that consumes a +//! tampered `vkey.bitwise` derives different challenges, the openings stop +//! matching, and verification fails. A future PR will additionally embed +//! `vkey.compute_digest()` in `VmProof` so vkey substitution surfaces as an +//! explicit error before any STARK work runs. + +use executor::elf::Elf; +use sha3::{Digest, Keccak256}; +use stark::config::Commitment; +use stark::proof::options::ProofOptions; + +use crate::tables::bitwise; + +/// Current `VmVerifyingKey` layout version. Bump whenever fields are added, +/// removed, or reordered so that vkeys serialized against an older layout +/// produce a different `compute_digest()` and stop validating. +pub const VKEY_VERSION: u32 = 1; + +/// Cached preprocessed-table commitments the verifier would otherwise +/// recompute on every call. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VmVerifyingKey { + /// Layout version. See [`VKEY_VERSION`]. + pub version: u32, + /// Merkle root over the LDE of the bitwise preprocessed columns. + /// Program-independent; depends only on `ProofOptions`. + pub bitwise: Commitment, +} + +impl VmVerifyingKey { + /// Derive the verifying key on the host. + /// + /// `elf` is unused for now (bitwise is program-independent) but stays in + /// the signature so callers do not need to change when follow-up PRs + /// fold in DECODE, REGISTER, and PAGE — which all depend on the ELF. + pub fn from_elf_and_options(_elf: &Elf, options: &ProofOptions) -> Self { + Self { + version: VKEY_VERSION, + bitwise: bitwise::preprocessed_commitment(options), + } + } + + /// Keccak256 fingerprint of the postcard-serialized vkey. Stable as long + /// as the field layout (and [`VKEY_VERSION`]) does not change. + pub fn compute_digest(&self) -> [u8; 32] { + let bytes = postcard::to_allocvec(self) + .expect("postcard serialization of VmVerifyingKey must succeed"); + let mut hasher = Keccak256::new(); + hasher.update(&bytes); + hasher.finalize().into() + } +} From 8bf406721d4ffe9d6bf67efbe2295f727021a5fe Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 20 May 2026 15:54:18 -0300 Subject: [PATCH 2/5] Cache page-table preprocessed commitments --- prover/src/lib.rs | 21 +++++++---- prover/src/tests/vkey_tests.rs | 62 ++++++++++++++++++++++++++++---- prover/src/vkey.rs | 64 ++++++++++++++++++++++++++-------- 3 files changed, 120 insertions(+), 27 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index ba7644ab2..d729eea2c 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -418,7 +418,8 @@ impl VmAirs { ); let pages: Vec<_> = page_configs .iter() - .map(|config| { + .enumerate() + .map(|(i, config)| { if config.is_private_input { // Private-input pages: all columns are main trace (not preprocessed). // The verifier doesn't see the init values; correctness is enforced @@ -426,11 +427,19 @@ impl VmAirs { create_page_air(proof_options, config.page_base) } else { // ELF and zero-init pages: OFFSET + INIT are preprocessed. - // The verifier independently recomputes the commitment from public data. - create_page_air(proof_options, config.page_base).with_preprocessed( - page::precomputed_commitment_cached(config, proof_options), - page::NUM_PREPROCESSED_COLS, - ) + // Prefer the vkey-supplied commitment when present (cached on host, + // saves the FFT + Merkle pipeline inside the verifier). If the vkey + // is absent or shorter than expected, fall back to recomputing — the + // length mismatch path is defensive only; Fiat-Shamir would catch a + // genuine mismatch downstream anyway. + let commitment = + vkey.and_then(|vk| vk.pages.get(i)) + .copied() + .unwrap_or_else(|| { + page::precomputed_commitment_cached(config, proof_options) + }); + create_page_air(proof_options, config.page_base) + .with_preprocessed(commitment, page::NUM_PREPROCESSED_COLS) } }) .collect(); diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs index 3838b94f7..095d970a0 100644 --- a/prover/src/tests/vkey_tests.rs +++ b/prover/src/tests/vkey_tests.rs @@ -4,26 +4,48 @@ use executor::elf::Elf; use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; use crate::VmVerifyingKey; +use crate::tables::page::PageConfig; +use crate::tables::trace_builder::Traces; use crate::test_utils::asm_elf_bytes; use crate::vkey::VKEY_VERSION; +use crate::{VmProof, prove}; fn default_options() -> ProofOptions { GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid") } +/// Derive the same `page_configs` slice the verifier would reconstruct from +/// `vm_proof`. This is exactly what `verify_with_options_with_vkey` does +/// internally, lifted into the test so the test-side and verifier-side +/// `vkey.pages` indexing line up. +fn page_configs_from_proof(elf: &Elf, vm_proof: &VmProof) -> Vec { + Traces::page_configs_from_elf_and_runtime( + elf, + &vm_proof.runtime_page_ranges, + vm_proof.num_private_input_pages, + ) +} + #[test] fn test_vkey_roundtrip() { let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); let elf = Elf::load(&elf_bytes).expect("ELF load failed"); let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); - let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options); + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); assert_eq!(vkey.version, VKEY_VERSION, "version field must be set"); + assert_eq!( + vkey.pages.len(), + page_configs.len(), + "vkey.pages must have one entry per page config", + ); let digest_before = vkey.compute_digest(); // Two host derivations on the same inputs must produce the same vkey; - // the BITWISE_COMMITMENT cache should not change between calls. - let vkey_again = VmVerifyingKey::from_elf_and_options(&elf, &options); + // the per-table commitment caches should not change between calls. + let vkey_again = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); assert_eq!(vkey, vkey_again, "vkey derivation must be deterministic"); // postcard round-trip preserves every field. @@ -45,10 +67,11 @@ fn test_vkey_verify_equivalence() { // guarantee — the vkey shortcut produces identical results to the // recompute-from-scratch path. let elf_bytes = asm_elf_bytes("sub"); - let vm_proof = crate::prove(&elf_bytes).expect("inner prove should succeed"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); let elf = Elf::load(&elf_bytes).expect("ELF load failed"); let options = default_options(); - let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); let baseline = crate::verify_with_options(&vm_proof, &elf_bytes, &options) .expect("baseline verify errored"); @@ -68,10 +91,11 @@ fn test_vkey_mismatch_rejects() { // derives different challenges from what the prover used, and the // proof's openings stop matching. let elf_bytes = asm_elf_bytes("sub"); - let vm_proof = crate::prove(&elf_bytes).expect("inner prove should succeed"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); let elf = Elf::load(&elf_bytes).expect("ELF load failed"); let options = default_options(); - let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); vkey.bitwise[0] ^= 0xFF; @@ -79,3 +103,27 @@ fn test_vkey_mismatch_rejects() { .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); assert!(!result, "tampered bitwise commitment must cause rejection"); } + +#[test] +fn test_vkey_page_mismatch_rejects() { + // Same shape as `test_vkey_mismatch_rejects`, but tampers with the page + // table that gets it first non-private-input slot. Fiat-Shamir rejects + // the same way: the page commitment is in the verifier's transcript + // exactly like the bitwise one. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + let target = page_configs + .iter() + .position(|c| !c.is_private_input) + .expect("test ELF must produce at least one non-private-input page"); + vkey.pages[target][0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered page commitment must cause rejection"); +} diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs index 4b314e375..c9f050571 100644 --- a/prover/src/vkey.rs +++ b/prover/src/vkey.rs @@ -9,21 +9,22 @@ //! //! ## Current scope //! -//! Only the BITWISE preprocessed commitment is cached here. The other four -//! preprocessed tables (DECODE, KECCAK_RC, REGISTER, PAGE) are still +//! BITWISE and PAGE preprocessed commitments are cached here. The remaining +//! three preprocessed tables (DECODE, KECCAK_RC, REGISTER) are still //! recomputed inside `VmAirs::new`; follow-up PRs will move them into this //! struct one at a time. The `version` field exists so a vkey serialized -//! today does not accidentally validate against a future shape. +//! against an older layout produces a different `compute_digest()` and stops +//! validating. //! //! ## Security //! //! For this PR the verifying key is only a performance shortcut. The -//! verifier still relies on Fiat-Shamir: the bitwise commitment the prover -//! used is bound into the proof's challenges, so a verifier that consumes a -//! tampered `vkey.bitwise` derives different challenges, the openings stop -//! matching, and verification fails. A future PR will additionally embed -//! `vkey.compute_digest()` in `VmProof` so vkey substitution surfaces as an -//! explicit error before any STARK work runs. +//! verifier still relies on Fiat-Shamir: every preprocessed commitment the +//! prover used is bound into the proof's challenges, so a verifier that +//! consumes a tampered `vkey` field derives different challenges, the +//! openings stop matching, and verification fails. A future PR will +//! additionally embed `vkey.compute_digest()` in `VmProof` so vkey +//! substitution surfaces as an explicit error before any STARK work runs. use executor::elf::Elf; use sha3::{Digest, Keccak256}; @@ -31,11 +32,18 @@ use stark::config::Commitment; use stark::proof::options::ProofOptions; use crate::tables::bitwise; +use crate::tables::page::{self, PageConfig}; /// Current `VmVerifyingKey` layout version. Bump whenever fields are added, /// removed, or reordered so that vkeys serialized against an older layout /// produce a different `compute_digest()` and stop validating. -pub const VKEY_VERSION: u32 = 1; +pub const VKEY_VERSION: u32 = 2; + +/// Placeholder commitment stored in [`VmVerifyingKey::pages`] for +/// private-input page slots, where there is no preprocessed commitment to +/// cache. The verifier never reads these slots (private-input pages have no +/// `with_preprocessed(...)` call in `VmAirs::new`). +const PRIVATE_INPUT_PAGE_PLACEHOLDER: Commitment = [0u8; 32]; /// Cached preprocessed-table commitments the verifier would otherwise /// recompute on every call. @@ -46,18 +54,46 @@ pub struct VmVerifyingKey { /// Merkle root over the LDE of the bitwise preprocessed columns. /// Program-independent; depends only on `ProofOptions`. pub bitwise: Commitment, + /// Per-page preprocessed Merkle roots, indexed parallel to the + /// `page_configs` slice the verifier reconstructs from the proof via + /// [`crate::tables::trace_builder::Traces::page_configs_from_elf_and_runtime`]. + /// Private-input slots hold a zero placeholder and are never read by the + /// verifier — they exist only to keep the index aligned with + /// `page_configs`, which interleaves preprocessed and private-input pages. + pub pages: Vec, } impl VmVerifyingKey { /// Derive the verifying key on the host. /// - /// `elf` is unused for now (bitwise is program-independent) but stays in - /// the signature so callers do not need to change when follow-up PRs - /// fold in DECODE, REGISTER, and PAGE — which all depend on the ELF. - pub fn from_elf_and_options(_elf: &Elf, options: &ProofOptions) -> Self { + /// `page_configs` must match exactly what the verifier will reconstruct + /// from the proof — i.e. the output of + /// `Traces::page_configs_from_elf_and_runtime(elf, runtime_page_ranges, + /// num_private_input_pages)`. The host can call that helper with the + /// values it already has after producing the inner proof. + /// + /// `elf` is unused at the moment but kept in the signature so callers + /// stay stable when follow-up PRs fold in DECODE, REGISTER, and the + /// other ELF-dependent preprocessed tables. + pub fn from_elf_and_options( + _elf: &Elf, + options: &ProofOptions, + page_configs: &[PageConfig], + ) -> Self { + let pages = page_configs + .iter() + .map(|config| { + if config.is_private_input { + PRIVATE_INPUT_PAGE_PLACEHOLDER + } else { + page::precomputed_commitment_cached(config, options) + } + }) + .collect(); Self { version: VKEY_VERSION, bitwise: bitwise::preprocessed_commitment(options), + pages, } } From 311d79f54ec797e149cef55c35a1194bd5e10588 Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 20 May 2026 16:41:00 -0300 Subject: [PATCH 3/5] Cache preprocessed commitments for decode, register, keccak_rc --- prover/src/lib.rs | 23 +++++++++------ prover/src/tests/vkey_tests.rs | 51 ++++++++++++++++++++++++++++++++++ prover/src/vkey.rs | 37 ++++++++++++++++-------- 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index d729eea2c..881d00968 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -390,11 +390,12 @@ impl VmAirs { let loads: Vec<_> = (0..table_counts.load) .map(|i| create_load_air(proof_options).with_name(&format!("LOAD[{}]", i))) .collect(); - let decode = create_decode_air(proof_options).with_preprocessed( + let decode_commitment = vkey.map(|vk| vk.decode).unwrap_or_else(|| { decode::commitment_from_elf(elf, proof_options) - .expect("Failed to compute decode commitment"), - decode::NUM_PRECOMPUTED_COLS, - ); + .expect("Failed to compute decode commitment") + }); + let decode = create_decode_air(proof_options) + .with_preprocessed(decode_commitment, decode::NUM_PRECOMPUTED_COLS); let muls: Vec<_> = (0..table_counts.mul) .map(|i| create_mul_air(proof_options).with_name(&format!("MUL[{}]", i))) .collect(); @@ -408,14 +409,18 @@ impl VmAirs { let commit = create_commit_air(proof_options); let keccak = create_keccak_air(proof_options); let keccak_rnd = create_keccak_rnd_air(proof_options); + let keccak_rc_commitment = vkey + .map(|vk| vk.keccak_rc) + .unwrap_or_else(|| tables::keccak_rc::preprocessed_commitment(proof_options)); let keccak_rc = create_keccak_rc_air(proof_options).with_preprocessed( - tables::keccak_rc::preprocessed_commitment(proof_options), + keccak_rc_commitment, tables::keccak_rc::NUM_PRECOMPUTED_COLS, ); - let register = create_register_air(proof_options).with_preprocessed( - register::preprocessed_commitment(proof_options, elf.entry_point), - register::NUM_PREPROCESSED_COLS, - ); + let register_commitment = vkey + .map(|vk| vk.register) + .unwrap_or_else(|| register::preprocessed_commitment(proof_options, elf.entry_point)); + let register = create_register_air(proof_options) + .with_preprocessed(register_commitment, register::NUM_PREPROCESSED_COLS); let pages: Vec<_> = page_configs .iter() .enumerate() diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs index 095d970a0..498a8baad 100644 --- a/prover/src/tests/vkey_tests.rs +++ b/prover/src/tests/vkey_tests.rs @@ -127,3 +127,54 @@ fn test_vkey_page_mismatch_rejects() { .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); assert!(!result, "tampered page commitment must cause rejection"); } + +#[test] +fn test_vkey_decode_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.decode[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered decode commitment must cause rejection"); +} + +#[test] +fn test_vkey_register_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.register[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered register commitment must cause rejection"); +} + +#[test] +fn test_vkey_keccak_rc_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.keccak_rc[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!( + !result, + "tampered keccak_rc commitment must cause rejection" + ); +} diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs index c9f050571..aa382a507 100644 --- a/prover/src/vkey.rs +++ b/prover/src/vkey.rs @@ -9,11 +9,11 @@ //! //! ## Current scope //! -//! BITWISE and PAGE preprocessed commitments are cached here. The remaining -//! three preprocessed tables (DECODE, KECCAK_RC, REGISTER) are still -//! recomputed inside `VmAirs::new`; follow-up PRs will move them into this -//! struct one at a time. The `version` field exists so a vkey serialized -//! against an older layout produces a different `compute_digest()` and stops +//! All five preprocessed tables — BITWISE, DECODE, REGISTER, KECCAK_RC, and +//! every non-private-input PAGE — are cached here. `VmAirs::new_with_vkey` +//! prefers the vkey-supplied commitment over recomputing when a vkey is +//! provided. The `version` field exists so a vkey serialized against an +//! older layout produces a different `compute_digest()` and stops //! validating. //! //! ## Security @@ -32,12 +32,15 @@ use stark::config::Commitment; use stark::proof::options::ProofOptions; use crate::tables::bitwise; +use crate::tables::decode; +use crate::tables::keccak_rc; use crate::tables::page::{self, PageConfig}; +use crate::tables::register; /// Current `VmVerifyingKey` layout version. Bump whenever fields are added, /// removed, or reordered so that vkeys serialized against an older layout /// produce a different `compute_digest()` and stop validating. -pub const VKEY_VERSION: u32 = 2; +pub const VKEY_VERSION: u32 = 3; /// Placeholder commitment stored in [`VmVerifyingKey::pages`] for /// private-input page slots, where there is no preprocessed commitment to @@ -54,6 +57,15 @@ pub struct VmVerifyingKey { /// Merkle root over the LDE of the bitwise preprocessed columns. /// Program-independent; depends only on `ProofOptions`. pub bitwise: Commitment, + /// Merkle root over the LDE of the decode preprocessed columns. + /// Program-dependent: derived from the inner ELF's instruction stream. + pub decode: Commitment, + /// Merkle root over the LDE of the register preprocessed columns. + /// Program-dependent via the ELF's entry point. + pub register: Commitment, + /// Merkle root over the LDE of the keccak round-constants preprocessed + /// columns. Program-independent; depends only on `ProofOptions`. + pub keccak_rc: Commitment, /// Per-page preprocessed Merkle roots, indexed parallel to the /// `page_configs` slice the verifier reconstructs from the proof via /// [`crate::tables::trace_builder::Traces::page_configs_from_elf_and_runtime`]. @@ -66,17 +78,16 @@ pub struct VmVerifyingKey { impl VmVerifyingKey { /// Derive the verifying key on the host. /// + /// `elf` is read to derive the program-dependent commitments (DECODE + /// from the instruction stream, REGISTER from `elf.entry_point`). + /// /// `page_configs` must match exactly what the verifier will reconstruct /// from the proof — i.e. the output of /// `Traces::page_configs_from_elf_and_runtime(elf, runtime_page_ranges, /// num_private_input_pages)`. The host can call that helper with the /// values it already has after producing the inner proof. - /// - /// `elf` is unused at the moment but kept in the signature so callers - /// stay stable when follow-up PRs fold in DECODE, REGISTER, and the - /// other ELF-dependent preprocessed tables. pub fn from_elf_and_options( - _elf: &Elf, + elf: &Elf, options: &ProofOptions, page_configs: &[PageConfig], ) -> Self { @@ -93,6 +104,10 @@ impl VmVerifyingKey { Self { version: VKEY_VERSION, bitwise: bitwise::preprocessed_commitment(options), + decode: decode::commitment_from_elf(elf, options) + .expect("decode commitment must compute"), + register: register::preprocessed_commitment(options, elf.entry_point), + keccak_rc: keccak_rc::preprocessed_commitment(options), pages, } } From 26603cdd55484159ade747b6cbc2e44021136512 Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 21 May 2026 12:12:39 -0300 Subject: [PATCH 4/5] Update doc, normalize patter for bitwise, add tests --- prover/src/lib.rs | 18 ++++--- prover/src/tests/vkey_tests.rs | 95 ++++++++++++++++++++++++++++++++-- prover/src/vkey.rs | 8 ++- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 881d00968..ef70da247 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -351,9 +351,12 @@ impl VmAirs { } /// Same as [`Self::new`] but accepts a precomputed [`VmVerifyingKey`]. - /// When `vkey` is `Some`, the bitwise preprocessed commitment is taken - /// from it instead of being recomputed from `proof_options` — that - /// recomputation is ~87% of verifier cycles inside the recursion guest. + /// When `vkey` is `Some`, every preprocessed-table commitment (bitwise, + /// decode, register, keccak_rc, and the per-page commitments) is taken + /// from it instead of being recomputed inside `VmAirs::new`. In the + /// recursion guest this skips the FFT + Merkle pipeline for all 5 + /// preprocessed tables, dropping the in-VM verifier from ~40.5 B cycles + /// to ~67 M (609×). pub fn new_with_vkey( elf: &Elf, proof_options: &ProofOptions, @@ -368,10 +371,9 @@ impl VmAirs { let bitwise = if minimal_bitwise { create_bitwise_air(proof_options) } else { - let commitment = match vkey { - Some(vk) => vk.bitwise, - None => bitwise::preprocessed_commitment(proof_options), - }; + let commitment = vkey + .map(|vk| vk.bitwise) + .unwrap_or_else(|| bitwise::preprocessed_commitment(proof_options)); create_bitwise_air(proof_options) .with_preprocessed(commitment, bitwise::NUM_PRECOMPUTED_COLS) }; @@ -754,7 +756,7 @@ pub fn verify_with_options( } /// Same as [`verify_with_options`] but accepts a precomputed -/// [`VmVerifyingKey`]. When `vkey` is `Some`, the bitwise preprocessed +/// [`VmVerifyingKey`]. When `vkey` is `Some`, every preprocessed-table /// commitment is taken from it instead of being recomputed inside /// `VmAirs::new`. A tampered vkey is caught by Fiat-Shamir: the verifier /// feeds the supplied commitment into the transcript, derives different diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs index 498a8baad..22da7b1fa 100644 --- a/prover/src/tests/vkey_tests.rs +++ b/prover/src/tests/vkey_tests.rs @@ -2,13 +2,14 @@ use executor::elf::Elf; use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; +use stark::traits::AIR; use crate::VmVerifyingKey; use crate::tables::page::PageConfig; use crate::tables::trace_builder::Traces; use crate::test_utils::asm_elf_bytes; use crate::vkey::VKEY_VERSION; -use crate::{VmProof, prove}; +use crate::{VmAirs, VmProof, prove}; fn default_options() -> ProofOptions { GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid") @@ -106,8 +107,8 @@ fn test_vkey_mismatch_rejects() { #[test] fn test_vkey_page_mismatch_rejects() { - // Same shape as `test_vkey_mismatch_rejects`, but tampers with the page - // table that gets it first non-private-input slot. Fiat-Shamir rejects + // Same shape as `test_vkey_mismatch_rejects`, but tampers with the + // first non-private-input slot of the page table. Fiat-Shamir rejects // the same way: the page commitment is in the verifier's transcript // exactly like the bitwise one. let elf_bytes = asm_elf_bytes("sub"); @@ -178,3 +179,91 @@ fn test_vkey_keccak_rc_mismatch_rejects() { "tampered keccak_rc commitment must cause rejection" ); } + +#[test] +fn test_vkey_version_mismatch_today_accepts() { + // Today `version` is advisory only — the verifier never reads it, so + // mutating it should not affect verification. This test pins that + // behavior so the deferred `vk_digest` PR (which will start digesting + // `version` into the digest and enforcing it at verify time) has to + // flip this assertion as a conscious choice. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.version = vkey.version.wrapping_add(7); + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err"); + assert!( + result, + "today `version` is advisory — once vk_digest lands this assertion must flip" + ); +} + +#[test] +fn test_vkey_empty_pages_falls_back_to_recompute() { + // Pin the silent fallback in `VmAirs::new_with_vkey`'s page loop: when + // `vkey.pages` is shorter than `page_configs`, the loop recomputes the + // missing slots instead of panicking. A future tightening to return + // `Err` would be a conscious break of this test. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.pages.clear(); + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err"); + assert!( + result, + "empty vkey.pages must fall back to recomputing page commitments and still accept the proof" + ); +} + +#[test] +fn test_vkey_fields_match_air_commitments() { + // Sharper version of `test_vkey_verify_equivalence`: directly assert + // each cached commitment matches what `VmAirs::new` constructs from the + // same elf + options. Catches "host helper diverges from AIR + // construction" bugs explicitly rather than via Fiat-Shamir failure. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + let airs = VmAirs::new(&elf, &options, false, &page_configs, &vm_proof.table_counts); + + assert!( + airs.bitwise.is_preprocessed(), + "bitwise AIR should be preprocessed" + ); + assert_eq!(vkey.bitwise, airs.bitwise.precomputed_commitment()); + assert_eq!(vkey.decode, airs.decode.precomputed_commitment()); + assert_eq!(vkey.register, airs.register.precomputed_commitment()); + assert_eq!(vkey.keccak_rc, airs.keccak_rc.precomputed_commitment()); + + assert_eq!( + vkey.pages.len(), + airs.pages.len(), + "vkey.pages and airs.pages must have the same length", + ); + for (i, (page_config, page_air)) in page_configs.iter().zip(airs.pages.iter()).enumerate() { + if page_config.is_private_input { + continue; + } + assert_eq!( + vkey.pages[i], + page_air.precomputed_commitment(), + "vkey.pages[{i}] must match AIR commitment for non-private-input page", + ); + } +} diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs index aa382a507..3532be4de 100644 --- a/prover/src/vkey.rs +++ b/prover/src/vkey.rs @@ -45,7 +45,13 @@ pub const VKEY_VERSION: u32 = 3; /// Placeholder commitment stored in [`VmVerifyingKey::pages`] for /// private-input page slots, where there is no preprocessed commitment to /// cache. The verifier never reads these slots (private-input pages have no -/// `with_preprocessed(...)` call in `VmAirs::new`). +/// `with_preprocessed(...)` call in `VmAirs::new_with_vkey`). +/// +/// NOTE: once the deferred `vk_digest` PR lands and `compute_digest()` +/// becomes verifier-checked, these slots will need to be canonicalized +/// (e.g. by hashing only non-private-input slots, or by asserting these +/// slots equal `[0u8; 32]` before hashing) so a malicious supplier cannot +/// produce two functionally-equivalent vkeys with different digests. const PRIVATE_INPUT_PAGE_PLACEHOLDER: Commitment = [0u8; 32]; /// Cached preprocessed-table commitments the verifier would otherwise From 5d8f91ad030fb5ce5a45de0f7f532df9f5422643 Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 21 May 2026 17:08:01 -0300 Subject: [PATCH 5/5] Doc and a log warning --- prover/src/lib.rs | 20 ++++++++++++++++++++ prover/src/vkey.rs | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index ef70da247..95d5789c1 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -423,6 +423,16 @@ impl VmAirs { .unwrap_or_else(|| register::preprocessed_commitment(proof_options, elf.entry_point)); let register = create_register_air(proof_options) .with_preprocessed(register_commitment, register::NUM_PREPROCESSED_COLS); + if let Some(vk) = vkey + && vk.pages.len() != page_configs.len() + { + log::warn!( + "vkey.pages length ({}) does not match page_configs length ({}); \ + recomputing the missing/extra slots — likely a caller bug", + vk.pages.len(), + page_configs.len() + ); + } let pages: Vec<_> = page_configs .iter() .enumerate() @@ -761,6 +771,16 @@ pub fn verify_with_options( /// `VmAirs::new`. A tampered vkey is caught by Fiat-Shamir: the verifier /// feeds the supplied commitment into the transcript, derives different /// challenges from what the prover used, and the openings stop matching. +/// +/// IMPORTANT: when `vkey` is `Some(...)`, the `elf_bytes` argument no longer +/// authenticates the proof against the program. The `decode`, `register`, +/// and page commitments come from the vkey, not from `elf_bytes`, so a +/// caller who passes a vkey derived from program A together with +/// `elf_bytes` of program B will get `Ok(true)` for any proof of program A. +/// The caller is responsible for ensuring the vkey was derived from the +/// same `elf_bytes` they intend to verify against. A future PR will bind +/// `vkey.compute_digest()` into [`VmProof`] so this trust assumption is +/// enforced cryptographically. pub fn verify_with_options_with_vkey( vm_proof: &VmProof, elf_bytes: &[u8], diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs index 3532be4de..cfc49a1eb 100644 --- a/prover/src/vkey.rs +++ b/prover/src/vkey.rs @@ -39,7 +39,10 @@ use crate::tables::register; /// Current `VmVerifyingKey` layout version. Bump whenever fields are added, /// removed, or reordered so that vkeys serialized against an older layout -/// produce a different `compute_digest()` and stop validating. +/// produce a different `compute_digest()`. Today the verifier does not read +/// `version` directly; the deferred `vk_digest` PR will bind +/// `compute_digest()` into the proof, at which point a stale `version` will +/// cause verification to fail as a side effect of the digest mismatch. pub const VKEY_VERSION: u32 = 3; /// Placeholder commitment stored in [`VmVerifyingKey::pages`] for