From f3bf84443ffa9b6c422fb271d4124e2947bc0728 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 26 Jun 2026 18:03:52 -0300 Subject: [PATCH 1/7] feat: cache verifying key + commitments (recursion opt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `VmVerifyingKey` (prover/src/vkey.rs): host-derived cache of the five preprocessed-table Merkle commitments (BITWISE, DECODE, REGISTER, KECCAK_RC, per-PAGE). `VmAirs::new_with_vkey` / `verify_with_options_with_vkey` take the cached commitments instead of recomputing them — recomputation is ~87% of verifier cycles inside the recursion guest. Soundness is preserved by Fiat-Shamir. The recursion and deserialize-only guests and the smoke test now encode the vkey into the postcard blob `(VmProof, elf, opts, vkey)`. --- bench_vs/lambda/recursion/Cargo.lock | 1 + bench_vs/lambda/recursion/src/main.rs | 15 +- prover/Cargo.toml | 1 + prover/src/lib.rs | 98 +++++++++- prover/src/tables/page.rs | 20 ++ prover/src/tests/mod.rs | 2 + prover/src/tests/recursion_smoke_test.rs | 16 +- prover/src/tests/vkey_tests.rs | 221 +++++++++++++++++++++++ prover/src/vkey.rs | 128 +++++++++++++ 9 files changed, 484 insertions(+), 18 deletions(-) create mode 100644 prover/src/tests/vkey_tests.rs create mode 100644 prover/src/vkey.rs diff --git a/bench_vs/lambda/recursion/Cargo.lock b/bench_vs/lambda/recursion/Cargo.lock index 66048ba81..88a9fb605 100644 --- a/bench_vs/lambda/recursion/Cargo.lock +++ b/bench_vs/lambda/recursion/Cargo.lock @@ -412,6 +412,7 @@ dependencies = [ "executor", "log", "math", + "postcard", "serde", "sha3", "stark", diff --git a/bench_vs/lambda/recursion/src/main.rs b/bench_vs/lambda/recursion/src/main.rs index f19271aac..4ea592b78 100644 --- a/bench_vs/lambda/recursion/src/main.rs +++ b/bench_vs/lambda/recursion/src/main.rs @@ -14,7 +14,7 @@ #![no_main] -use lambda_vm_prover::{ProofOptions, VmProof}; +use lambda_vm_prover::{ProofOptions, VmProof, VmVerifyingKey}; #[unsafe(export_name = "main")] pub fn main() -> ! { @@ -29,14 +29,21 @@ pub fn main() -> ! { })); let blob = lambda_vm_syscalls::syscalls::get_private_input(); - let (vm_proof, inner_elf, options): (VmProof, Vec, ProofOptions) = + let (vm_proof, inner_elf, options, vkey): (VmProof, Vec, ProofOptions, VmVerifyingKey) = postcard::from_bytes(&blob).expect("failed to deserialize recursion input"); lambda_vm_prover::profile_markers::step_marker::< { lambda_vm_prover::profile_markers::STEP_DECODE_DONE }, >(); - let ok = lambda_vm_prover::verify_with_options(&vm_proof, &inner_elf, &options, None, None) - .expect("verify errored"); + let ok = lambda_vm_prover::verify_with_options_with_vkey( + &vm_proof, + &inner_elf, + &options, + None, + None, + Some(&vkey), + ) + .expect("verify errored"); assert!(ok, "inner proof failed verification"); lambda_vm_syscalls::syscalls::commit(&[1u8]); diff --git a/prover/Cargo.toml b/prover/Cargo.toml index 3695689d6..887d9c1c1 100644 --- a/prover/Cargo.toml +++ b/prover/Cargo.toml @@ -25,6 +25,7 @@ rayon = { version = "1.8.0", optional = true } sysinfo = { version = "0.31", default-features = false, features = ["system"] } log = "0.4" sha3 = { version = "0.10.8", default-features = false } +postcard = { version = "1.0", default-features = false, features = ["alloc"] } [dev-dependencies] env_logger = "*" diff --git a/prover/src/lib.rs b/prover/src/lib.rs index ea791d212..c327003f0 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -25,6 +25,9 @@ pub mod tables; pub mod test_utils; #[cfg(test)] pub mod tests; +pub mod vkey; + +pub use vkey::VmVerifyingKey; use std::fmt; @@ -459,6 +462,38 @@ impl VmAirs { register_init: Option<&[u32]>, page_commitments: Option<&[(u64, Commitment)]>, register_preprocessed: Option<(Commitment, usize)>, + ) -> Self { + Self::new_with_vkey( + elf, + proof_options, + minimal_bitwise, + page_configs, + table_counts, + decode_commitment, + include_halt, + register_init, + page_commitments, + register_preprocessed, + 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, + decode_commitment: Option, + include_halt: bool, + register_init: Option<&[u32]>, + page_commitments: Option<&[(u64, Commitment)]>, + register_preprocessed: Option<(Commitment, usize)>, + vkey: Option<&VmVerifyingKey>, ) -> Self { let cpus: Vec<_> = (0..table_counts.cpu) .map(|i| { @@ -468,8 +503,12 @@ impl VmAirs { let bitwise: VmAir = if minimal_bitwise { Box::new(create_bitwise_air(proof_options)) } else { + let commitment = match vkey { + Some(vk) => vk.bitwise, + None => bitwise::preprocessed_commitment(proof_options), + }; Box::new(create_bitwise_air(proof_options).with_preprocessed( - bitwise::preprocessed_commitment(proof_options), + commitment, bitwise::NUM_PRECOMPUTED_COLS, )) }; @@ -501,10 +540,12 @@ impl VmAirs { Box::new(create_load_air(proof_options).with_name(&format!("LOAD[{}]", i))) as VmAir }) .collect(); - let decode_root = decode_commitment.unwrap_or_else(|| { - decode::commitment_from_elf(elf, proof_options) - .expect("Failed to compute decode commitment") - }); + let decode_root = decode_commitment + .or_else(|| vkey.map(|vk| vk.decode)) + .unwrap_or_else(|| { + decode::commitment_from_elf(elf, proof_options) + .expect("Failed to compute decode commitment") + }); let decode: VmAir = Box::new( create_decode_air(proof_options) .with_preprocessed(decode_root, decode::NUM_PRECOMPUTED_COLS), @@ -529,8 +570,11 @@ impl VmAirs { let commit: VmAir = Box::new(create_commit_air(proof_options)); let keccak: VmAir = Box::new(create_keccak_air(proof_options)); let keccak_rnd: VmAir = Box::new(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: VmAir = Box::new(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 ecsm: VmAir = Box::new(create_ecsm_air(proof_options)); @@ -546,8 +590,11 @@ impl VmAirs { let register_init = register_init .map(<[u32]>::to_vec) .unwrap_or_else(|| register::register_init_from_entry_point(elf.entry_point)); + let register_commitment = vkey.map(|vk| vk.register).unwrap_or_else(|| { + register::preprocessed_commitment(proof_options, ®ister_init) + }); Box::new(create_register_air(proof_options).with_preprocessed( - register::preprocessed_commitment(proof_options, ®ister_init), + register_commitment, register::NUM_PREPROCESSED_COLS, )) }; @@ -561,7 +608,8 @@ impl VmAirs { let pages: Vec = page_configs .iter() - .map(|config| -> VmAir { + .enumerate() + .map(|(index, config)| -> VmAir { let air = create_page_air(proof_options, config.page_base); if config.is_private_input { // Private-input pages: all columns are main trace (not preprocessed). @@ -570,18 +618,23 @@ impl VmAirs { Box::new(air) } else if config.init_values.is_none() { // Zero-init pages: the shared commitment computed once above. + // `vkey.pages` caches the same static value for these slots, + // so the local lookup is equivalent and equally cheap. Box::new( air.with_preprocessed(zero_init_commitment, page::NUM_PREPROCESSED_COLS), ) } else { // ELF data pages: INIT is program-specific, so the commitment is // per-page. Prefer a caller-supplied `(page_base, commitment)` - // (recursion guest); otherwise recompute from the ELF. + // (recursion guest), then the vkey's cached per-page root + // (indexed parallel to `page_configs`); otherwise recompute + // from the ELF. let commitment = page_commitments .unwrap_or(&[]) .iter() .find(|(pb, _)| *pb == config.page_base) .map(|(_, c)| *c) + .or_else(|| vkey.map(|vk| vk.pages[index])) .unwrap_or_else(|| { page::compute_precomputed_commitment(config, proof_options) }); @@ -1035,6 +1088,30 @@ pub fn verify_with_options( proof_options: &ProofOptions, decode_commitment: Option, page_commitments: Option<&[(u64, Commitment)]>, +) -> Result { + verify_with_options_with_vkey( + vm_proof, + elf_bytes, + proof_options, + decode_commitment, + page_commitments, + 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, + decode_commitment: Option, + page_commitments: Option<&[(u64, Commitment)]>, + vkey: Option<&VmVerifyingKey>, ) -> Result { // Validate table_counts before constructing AIRs. // A malicious prover could set counts to 0, removing entire constraint sets. @@ -1073,7 +1150,7 @@ pub fn verify_with_options( ))); } - let airs = VmAirs::new( + let airs = VmAirs::new_with_vkey( &program, proof_options, false, @@ -1084,6 +1161,7 @@ pub fn verify_with_options( None, page_commitments, None, + vkey, ); // Recompute the COMMIT output bus offset from VmProof.public_output. diff --git a/prover/src/tables/page.rs b/prover/src/tables/page.rs index 5cba30435..7e0f03c35 100644 --- a/prover/src/tables/page.rs +++ b/prover/src/tables/page.rs @@ -399,6 +399,26 @@ pub fn compute_precomputed_commitment(config: &PageConfig, options: &ProofOption root } +/// Returns a page's preprocessed commitment, preferring the cheap path. +/// +/// Zero-init pages (INIT is all-zero) share a single commitment that depends +/// only on `(blowup, coset)`, so they resolve to the static lookup in +/// [`zero_init_preprocessed_commitment`] instead of rebuilding the FFT + +/// Merkle tree. ELF data pages have program-specific INIT and fall through +/// to [`compute_precomputed_commitment`]. This mirrors the per-page choice +/// made in `VmAirs::new_with_vkey`, so a vkey built from this function caches +/// exactly the commitments the verifier expects. +/// +/// Private-input pages have no preprocessed commitment; callers must skip +/// them before calling this. +pub fn precomputed_commitment_cached(config: &PageConfig, options: &ProofOptions) -> Commitment { + if config.init_values.is_none() { + zero_init_preprocessed_commitment(options) + } else { + compute_precomputed_commitment(config, options) + } +} + /// Returns the zero-init PAGE preprocessed commitment. /// /// Looks up `blowup_factor` in [`static_zero_page_commitment`] when diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 2c06d7fcf..199e00730 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -86,3 +86,5 @@ pub mod templates_tests; pub mod trace_builder_tests; #[cfg(test)] pub mod trace_test_helpers; +#[cfg(test)] +pub mod vkey_tests; diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index a32d44c7c..e2aae52ff 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -38,8 +38,8 @@ const MIN_PROOF_OPTIONS: stark::proof::options::ProofOptions = grinding_factor: 1, }; -/// Prove `inner_elf` under `opts` and postcard-encode `(proof, elf, opts)` into -/// the guest's private-input blob. Returns the proof and the blob. +/// Prove `inner_elf` under `opts` and postcard-encode `(proof, elf, opts, vkey)` +/// into the guest's private-input blob. Returns the proof and the blob. fn prove_inner_and_encode_blob( tag: &str, inner_elf: &[u8], @@ -58,8 +58,16 @@ fn prove_inner_and_encode_blob( ) .expect("inner prove should succeed"); - let blob = - postcard::to_allocvec(&(&inner_proof, &inner_elf, opts)).expect("postcard encode failed"); + let elf_for_vkey = executor::elf::Elf::load(inner_elf).expect("ELF load failed"); + let page_configs = crate::tables::trace_builder::Traces::page_configs_from_elf_and_runtime( + &elf_for_vkey, + &inner_proof.runtime_page_ranges, + inner_proof.num_private_input_pages, + ); + let vkey = + crate::VmVerifyingKey::from_elf_and_options(&elf_for_vkey, opts, None, &page_configs); + let blob = postcard::to_allocvec(&(&inner_proof, &inner_elf, opts, &vkey)) + .expect("postcard encode failed"); eprintln!("[{tag}] postcard blob: {} bytes", blob.len()); (inner_proof, blob) } diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs new file mode 100644 index 000000000..fc8a0c0e0 --- /dev/null +++ b/prover/src/tests/vkey_tests.rs @@ -0,0 +1,221 @@ +//! 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::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, None, &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 per-table commitment caches should not change between calls. + let vkey_again = VmVerifyingKey::from_elf_and_options(&elf, &options, None, &page_configs); + 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 = 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, None, &page_configs); + + let baseline = crate::verify_with_options(&vm_proof, &elf_bytes, &options, None, None) + .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, + None, + None, + 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 = 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, None, &page_configs); + + vkey.bitwise[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey( + &vm_proof, + &elf_bytes, + &options, + None, + None, + Some(&vkey), + ) + .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, None, &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, + None, + None, + Some(&vkey), + ) + .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, None, &page_configs); + + vkey.decode[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey( + &vm_proof, + &elf_bytes, + &options, + None, + None, + 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, None, &page_configs); + + vkey.register[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey( + &vm_proof, + &elf_bytes, + &options, + None, + None, + 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, None, &page_configs); + + vkey.keccak_rc[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey( + &vm_proof, + &elf_bytes, + &options, + None, + None, + 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 new file mode 100644 index 000000000..1d85d1d28 --- /dev/null +++ b/prover/src/vkey.rs @@ -0,0 +1,128 @@ +//! 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 +//! +//! 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 +//! +//! For this PR the verifying key is only a performance shortcut. The +//! 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}; +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 = 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`). +const PRIVATE_INPUT_PAGE_PLACEHOLDER: Commitment = [0u8; 32]; + +/// 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, + /// 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`]. + /// 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 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. + pub fn from_elf_and_options( + elf: &Elf, + options: &ProofOptions, + register_init: Option<&[u32]>, + 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(); + let register_init = register_init + .map(<[u32]>::to_vec) + .unwrap_or_else(|| register::register_init_from_entry_point(elf.entry_point)); + 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, ®ister_init), + keccak_rc: keccak_rc::preprocessed_commitment(options), + pages, + } + } + + /// 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 ccc8068d13e2db099a9fb9f769ab75d3922ebf61 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Thu, 2 Jul 2026 15:57:38 -0300 Subject: [PATCH 2/7] clippy allow --- prover/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index c327003f0..acd4587fb 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -482,6 +482,7 @@ impl VmAirs { /// 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. + #[allow(clippy::too_many_arguments)] pub fn new_with_vkey( elf: &Elf, proof_options: &ProofOptions, From 11f55e6fb3e725c2cf10f20c0009880faa64f745 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Thu, 2 Jul 2026 17:26:37 -0300 Subject: [PATCH 3/7] feat: bind vk_digest into proof statement and guest output A prover-supplied vkey defeats the preprocessed-commitment check: Fiat-Shamir only catches post-hoc tampering, not a coordinated prover committing to a forged table with a matching vkey. Bind keccak(vkey) as vk_digest: embed ProofOptions in the vkey (query count and grinding factor pin no commitment), stamp the digest into VmProof and the statement transcript (V3), check it in verify before STARK work, and commit vk_digest || inner output from the recursion guest so the outer verifier can compare against a digest derived from the trusted inner ELF. Also validate vkey version/page-count instead of panicking on short pages, and reject on options mismatch. --- bench_vs/lambda/recursion/src/main.rs | 12 +- crypto/stark/src/proof/options.rs | 2 +- prover/src/lib.rs | 81 ++++++++-- prover/src/statement.rs | 24 ++- prover/src/tests/prove_elfs_tests.rs | 3 + prover/src/tests/recursion_smoke_test.rs | 13 +- prover/src/tests/statement_tests.rs | 28 +++- prover/src/tests/vkey_tests.rs | 182 +++++++++-------------- prover/src/vkey.rs | 34 +++-- 9 files changed, 230 insertions(+), 149 deletions(-) diff --git a/bench_vs/lambda/recursion/src/main.rs b/bench_vs/lambda/recursion/src/main.rs index 4ea592b78..00aac3f03 100644 --- a/bench_vs/lambda/recursion/src/main.rs +++ b/bench_vs/lambda/recursion/src/main.rs @@ -1,9 +1,12 @@ //! Naive recursion guest: verifies an inner lambda-vm proof inside the VM. //! //! Private input layout (postcard-encoded): -//! `(VmProof, Vec, ProofOptions)` +//! `(VmProof, Vec, ProofOptions, VmVerifyingKey)` //! where the `Vec` holds the inner program's ELF bytes and `ProofOptions` -//! specifies the parameters the inner prover used. Commits `[1]` on success. +//! specifies the parameters the inner prover used. Commits +//! `vk_digest ‖ inner public output` on success: every input here is +//! prover-supplied, so soundness comes from the outer verifier checking +//! the committed digest against one derived from the trusted inner ELF. //! //! Not `no_std` (std/alloc are available — `build-std` provides them, and the //! prover links as a normal std crate; its prove-side code is dead-code @@ -46,6 +49,9 @@ pub fn main() -> ! { .expect("verify errored"); assert!(ok, "inner proof failed verification"); - lambda_vm_syscalls::syscalls::commit(&[1u8]); + let mut output = Vec::with_capacity(32 + vm_proof.public_output.len()); + output.extend_from_slice(&vkey.compute_digest()); + output.extend_from_slice(&vm_proof.public_output); + lambda_vm_syscalls::syscalls::commit(&output); lambda_vm_syscalls::syscalls::sys_halt(); } diff --git a/crypto/stark/src/proof/options.rs b/crypto/stark/src/proof/options.rs index 70976b993..2c91ef00c 100644 --- a/crypto/stark/src/proof/options.rs +++ b/crypto/stark/src/proof/options.rs @@ -39,7 +39,7 @@ impl fmt::Display for ProofOptionsError { /// - `coset_offset`: the offset for the coset /// - `grinding_factor`: the number of leading zeros that we want for the Hash(hash || nonce) #[cfg_attr(feature = "wasm", wasm_bindgen)] -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ProofOptions { pub blowup_factor: u8, pub fri_number_of_queries: usize, diff --git a/prover/src/lib.rs b/prover/src/lib.rs index acd4587fb..5bc24fae6 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -175,6 +175,10 @@ pub struct VmProof { /// These pages are NOT preprocessed — the verifier reconstructs them /// as non-preprocessed tables starting at `PRIVATE_INPUT_START_INDEX`. pub num_private_input_pages: usize, + /// Digest of the [`VmVerifyingKey`] the proof was made against. Bound + /// into the Fiat-Shamir statement and checked by the verifier against + /// its own vkey's digest before any STARK work. + pub vk_digest: [u8; 32], } /// Error type for the prover crate. @@ -192,6 +196,8 @@ pub enum Error { Prover(String), /// Proof contains invalid table_counts (e.g. zero for a required table) InvalidTableCounts(String), + /// Supplied verifying key is malformed (wrong version or page count) + InvalidVerifyingKey(String), /// Continuation epoch size exponent is invalid. InvalidContinuationEpochSize(String), /// Continuation proof construction hit an internal invariant failure. @@ -215,6 +221,7 @@ impl fmt::Display for Error { Error::Execution(msg) => write!(f, "execution error: {msg}"), Error::Prover(msg) => write!(f, "proving error: {msg}"), Error::InvalidTableCounts(msg) => write!(f, "invalid table_counts: {msg}"), + Error::InvalidVerifyingKey(msg) => write!(f, "invalid verifying key: {msg}"), Error::InvalidContinuationEpochSize(msg) => { write!(f, "invalid continuation epoch size: {msg}") } @@ -635,7 +642,7 @@ impl VmAirs { .iter() .find(|(pb, _)| *pb == config.page_base) .map(|(_, c)| *c) - .or_else(|| vkey.map(|vk| vk.pages[index])) + .or_else(|| vkey.and_then(|vk| vk.pages.get(index)).copied()) .unwrap_or_else(|| { page::compute_precomputed_commitment(config, proof_options) }); @@ -953,7 +960,12 @@ pub fn prove_with_options_and_inputs( let __sp = stark::instruments::span("air_construction"); let table_counts = traces.table_counts(); - let airs = VmAirs::new( + // Derive the vkey before AIR construction so each preprocessed + // commitment is computed once and reused. + let vkey = + VmVerifyingKey::from_elf_and_options(&program, proof_options, None, &traces.page_configs); + let vk_digest = vkey.compute_digest(); + let airs = VmAirs::new_with_vkey( &program, proof_options, false, @@ -964,6 +976,7 @@ pub fn prove_with_options_and_inputs( None, None, None, + Some(&vkey), ); #[cfg(feature = "instruments")] @@ -986,7 +999,7 @@ pub fn prove_with_options_and_inputs( let mut transcript = DefaultTranscript::::new(&[]); absorb_statement( &mut transcript, - StatementKind::Monolithic, + StatementKind::Monolithic { vk_digest }, elf_bytes, &traces.public_output_bytes, &table_counts, @@ -1037,6 +1050,7 @@ pub fn prove_with_options_and_inputs( table_counts, public_output: traces.public_output_bytes.clone(), num_private_input_pages, + vk_digest, }) } @@ -1101,11 +1115,17 @@ pub fn verify_with_options( } /// 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. +/// [`VmVerifyingKey`], skipping the preprocessed-commitment recomputation. +/// +/// # Security +/// +/// The vkey is TRUSTED input. Passing one supplied by the prover (as the +/// recursion guest does) proves nothing by itself: a coordinated prover can +/// commit to a forged preprocessed table and ship a matching vkey, and the +/// transcript stays self-consistent. Soundness requires the caller's output +/// to bind `vkey.compute_digest()` (the recursion guest commits it) so an +/// outer verifier can compare it against a digest derived from trusted +/// inputs. See `vkey.rs`. pub fn verify_with_options_with_vkey( vm_proof: &VmProof, elf_bytes: &[u8], @@ -1137,6 +1157,47 @@ pub fn verify_with_options_with_vkey( vm_proof.num_private_input_pages, ); + // Validate the vkey before constructing AIRs: `vk.pages` is indexed + // parallel to `page_configs` (a short vec would panic), and options are + // checked because query count / grinding factor affect soundness but no + // commitment. With no caller vkey, derive one from the trusted ELF. + let owned_vkey; + let vkey = match vkey { + Some(vk) => { + if vk.version != crate::vkey::VKEY_VERSION { + return Err(Error::InvalidVerifyingKey(format!( + "vkey version {} != expected {}", + vk.version, + crate::vkey::VKEY_VERSION, + ))); + } + if vk.pages.len() != page_configs.len() { + return Err(Error::InvalidVerifyingKey(format!( + "vkey has {} page commitments but the proof requires {}", + vk.pages.len(), + page_configs.len(), + ))); + } + if vk.options != *proof_options { + return Err(Error::InvalidVerifyingKey( + "vkey options do not match the verification options".into(), + )); + } + vk + } + None => { + owned_vkey = + VmVerifyingKey::from_elf_and_options(&program, proof_options, None, &page_configs); + &owned_vkey + } + }; + let vk_digest = vkey.compute_digest(); + if vm_proof.vk_digest != vk_digest { + return Err(Error::InvalidVerifyingKey( + "proof vk_digest does not match the verifying key's digest".into(), + )); + } + // Cross-check: table_counts must match the number of sub-proofs. // FIXED_TABLE_COUNT always-present tables, plus page tables. let expected_proof_count = @@ -1162,7 +1223,7 @@ pub fn verify_with_options_with_vkey( None, page_commitments, None, - vkey, + Some(vkey), ); // Recompute the COMMIT output bus offset from VmProof.public_output. @@ -1176,7 +1237,7 @@ pub fn verify_with_options_with_vkey( let mut transcript = DefaultTranscript::::new(&[]); absorb_statement( &mut transcript, - StatementKind::Monolithic, + StatementKind::Monolithic { vk_digest }, elf_bytes, &vm_proof.public_output, &vm_proof.table_counts, diff --git a/prover/src/statement.rs b/prover/src/statement.rs index 3eae6b609..fd695354d 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -16,7 +16,8 @@ use crate::test_utils::E; use crate::{RuntimePageRange, TableCounts}; /// Domain-separation tag. Bump the suffix (`_V2`, ...) on any encoding change. -const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V2"; +/// V3: monolithic statements bind the verifying-key digest after the tag. +const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V3"; fn elf_digest(elf: &[u8]) -> [u8; 32] { let mut h = Keccak256::new(); @@ -24,14 +25,15 @@ fn elf_digest(elf: &[u8]) -> [u8; 32] { h.finalize().into() } -/// Which statement is being bound. Selects the leading domain tag and whether an -/// epoch label is appended, so monolithic and continuation-epoch proofs share one -/// function while each starts with its own tag. `Monolithic` reproduces the -/// original encoding byte-for-byte (no label), so existing proofs are unaffected. +/// Which statement is being bound. Selects the leading domain tag and the +/// kind-specific fields, so monolithic and continuation-epoch proofs share one +/// function while each starts with its own tag. #[derive(Clone, Copy)] pub(crate) enum StatementKind { - /// Whole-program (monolithic) proof. - Monolithic, + /// Whole-program (monolithic) proof. Carries the digest of the + /// [`crate::VmVerifyingKey`] (preprocessed commitments + proof options) + /// so every challenge depends on which vkey the proof was made against. + Monolithic { vk_digest: [u8; 32] }, /// One continuation epoch proof, pinned to its position by `epoch_label`. ContinuationEpoch { epoch_label: u64 }, } @@ -48,11 +50,17 @@ pub(crate) fn absorb_statement( // Leading domain tag — distinct per statement kind, so a monolithic proof and // a continuation epoch proof can never share a transcript prefix. let domain_tag = match kind { - StatementKind::Monolithic => DOMAIN_TAG, + StatementKind::Monolithic { .. } => DOMAIN_TAG, StatementKind::ContinuationEpoch { .. } => CONTINUATION_EPOCH_TAG, }; t.append_bytes(domain_tag); + // Fixed 32 bytes, no length prefix needed; the per-kind tags keep + // kind-specific fields unambiguous. + if let StatementKind::Monolithic { vk_digest } = kind { + t.append_bytes(&vk_digest); + } + // ELF: fixed 32-byte digest — no length prefix needed. t.append_bytes(&elf_digest(elf_bytes)); diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 2b8c16342..fba4b77d4 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -136,6 +136,9 @@ fn prove_vm_minimal(elf_bytes: &[u8], private_inputs: &[u8], max_rows: &MaxRowsC table_counts, public_output: traces.public_output_bytes.clone(), num_private_input_pages, + // Minimal proofs skip statement absorption; only verify_vm_minimal + // can check them, so the digest is never read. + vk_digest: [0u8; 32], } } diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index e2aae52ff..e87726535 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -441,7 +441,9 @@ fn run_profile( } /// Core pipeline: prove the inner program, run the guest to `mode`, assert it -/// committed `[1]` (the in-VM verifier accepted the proof). +/// committed `vk_digest ‖ inner public output` — the outer-verifier check: +/// the digest of the vkey used in-guest must match one derived on the host +/// from the trusted inner ELF. fn run_recursion_pipeline_with_options( label: &str, inner_elf_bytes: &[u8], @@ -480,12 +482,13 @@ fn run_recursion_pipeline_with_options( OuterMode::Prove => prove_outer_and_commit(label, &recursion_elf_bytes, &blob), }; + let mut expected = inner_proof.vk_digest.to_vec(); + expected.extend_from_slice(&inner_proof.public_output); assert_eq!( - committed, - vec![1u8], - "recursion guest must commit the [1] success marker (in-VM verify accepted)" + committed, expected, + "recursion guest must commit vk_digest ‖ inner public output" ); - eprintln!("[{label}] guest committed [1]: in-VM verify accepted ✓"); + eprintln!("[{label}] guest committed vk_digest ‖ output: in-VM verify accepted ✓"); } /// `run_recursion_pipeline_with_options` with `blowup=8` (the `empty`/`fibonacci` default). diff --git a/prover/src/tests/statement_tests.rs b/prover/src/tests/statement_tests.rs index 679c9d369..d32957184 100644 --- a/prover/src/tests/statement_tests.rs +++ b/prover/src/tests/statement_tests.rs @@ -39,7 +39,8 @@ fn sample_ranges() -> Vec { ] } -fn state_after_absorb( +fn state_after_absorb_with_digest( + vk_digest: [u8; 32], elf: &[u8], out: &[u8], counts: &TableCounts, @@ -49,7 +50,7 @@ fn state_after_absorb( let mut t = DefaultTranscript::::new(&[]); absorb_statement( &mut t, - StatementKind::Monolithic, + StatementKind::Monolithic { vk_digest }, elf, out, counts, @@ -59,6 +60,16 @@ fn state_after_absorb( t.state() } +fn state_after_absorb( + elf: &[u8], + out: &[u8], + counts: &TableCounts, + priv_pages: usize, + ranges: &[RuntimePageRange], +) -> [u8; 32] { + state_after_absorb_with_digest([7u8; 32], elf, out, counts, priv_pages, ranges) +} + #[test] fn state_is_deterministic() { let a = state_after_absorb(b"elf", b"out", &sample_counts(), 3, &sample_ranges()); @@ -112,6 +123,19 @@ fn state_depends_on_every_field() { state_after_absorb(b"elf", b"out", &sample_counts(), 1, &[]), "state must depend on runtime_page_ranges", ); + + assert_ne!( + baseline, + state_after_absorb_with_digest( + [8u8; 32], + b"elf", + b"out", + &sample_counts(), + 1, + &sample_ranges() + ), + "state must depend on vk_digest", + ); } #[test] diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs index fc8a0c0e0..941415f31 100644 --- a/prover/src/tests/vkey_tests.rs +++ b/prover/src/tests/vkey_tests.rs @@ -8,7 +8,7 @@ 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::{Error, VmProof, prove}; fn default_options() -> ProofOptions { GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is always valid") @@ -26,22 +26,54 @@ fn page_configs_from_proof(elf: &Elf, vm_proof: &VmProof) -> Vec { ) } -#[test] -fn test_vkey_roundtrip() { +/// Prove `sub`, and derive the honest vkey the same way the verifier would. +fn proof_and_vkey() -> (Vec, VmProof, ProofOptions, VmVerifyingKey) { 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, None, &page_configs); + (elf_bytes, vm_proof, options, vkey) +} + +/// A tampered or malformed vkey must be rejected with an explicit +/// `InvalidVerifyingKey` before any STARK work runs — either by the shape +/// checks or by the `vk_digest` comparison against the proof. +fn assert_rejects_vkey( + elf_bytes: &[u8], + vm_proof: &VmProof, + options: &ProofOptions, + vkey: &VmVerifyingKey, + what: &str, +) { + let result = + crate::verify_with_options_with_vkey(vm_proof, elf_bytes, options, None, None, Some(vkey)); + assert!( + matches!(result, Err(Error::InvalidVerifyingKey(_))), + "{what} must be rejected with InvalidVerifyingKey, got {result:?}" + ); +} + +#[test] +fn test_vkey_roundtrip() { + let (_, vm_proof, options, vkey) = proof_and_vkey(); + let elf_bytes = asm_elf_bytes("sub"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + assert_eq!(vkey.version, VKEY_VERSION, "version field must be set"); + assert_eq!(vkey.options, options, "options must be embedded"); assert_eq!( vkey.pages.len(), page_configs.len(), "vkey.pages must have one entry per page config", ); let digest_before = vkey.compute_digest(); + assert_eq!( + vm_proof.vk_digest, digest_before, + "prover must stamp the same digest the verifier derives" + ); // Two host derivations on the same inputs must produce the same vkey; // the per-table commitment caches should not change between calls. @@ -66,12 +98,7 @@ fn test_vkey_verify_equivalence() { // 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 = 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, None, &page_configs); + let (elf_bytes, vm_proof, options, vkey) = proof_and_vkey(); let baseline = crate::verify_with_options(&vm_proof, &elf_bytes, &options, None, None) .expect("baseline verify errored"); @@ -91,131 +118,64 @@ fn test_vkey_verify_equivalence() { #[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 = 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, None, &page_configs); - + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); vkey.bitwise[0] ^= 0xFF; - - let result = crate::verify_with_options_with_vkey( - &vm_proof, - &elf_bytes, - &options, - None, - None, - Some(&vkey), - ) - .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); - assert!(!result, "tampered bitwise commitment must cause rejection"); + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered bitwise"); } #[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_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); 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, None, &page_configs); - - let target = page_configs + let target = page_configs_from_proof(&elf, &vm_proof) .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, - None, - None, - Some(&vkey), - ) - .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); - assert!(!result, "tampered page commitment must cause rejection"); + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered page"); } #[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, None, &page_configs); - + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); vkey.decode[0] ^= 0xFF; - - let result = crate::verify_with_options_with_vkey( - &vm_proof, - &elf_bytes, - &options, - None, - None, - Some(&vkey), - ) - .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); - assert!(!result, "tampered decode commitment must cause rejection"); + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered decode"); } #[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, None, &page_configs); - + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); vkey.register[0] ^= 0xFF; - - let result = crate::verify_with_options_with_vkey( - &vm_proof, - &elf_bytes, - &options, - None, - None, - Some(&vkey), - ) - .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); - assert!(!result, "tampered register commitment must cause rejection"); + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered register"); } #[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, None, &page_configs); - + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); vkey.keccak_rc[0] ^= 0xFF; + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered keccak_rc"); +} - let result = crate::verify_with_options_with_vkey( - &vm_proof, - &elf_bytes, - &options, - None, - None, - Some(&vkey), - ) - .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); - assert!( - !result, - "tampered keccak_rc commitment must cause rejection" - ); +#[test] +fn test_vkey_short_pages_rejects() { + // A short pages vec must be a clean error, not an out-of-bounds panic. + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); + vkey.pages.clear(); + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "short pages vec"); +} + +#[test] +fn test_vkey_options_mismatch_rejects() { + // Query count and grinding factor affect soundness but no commitment, + // so a weakened-options vkey must be caught by the explicit check. + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); + vkey.options.fri_number_of_queries = 1; + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "weakened options"); +} + +#[test] +fn test_vkey_wrong_version_rejects() { + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); + vkey.version = VKEY_VERSION - 1; + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "wrong version"); } diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs index 1d85d1d28..3de96eb29 100644 --- a/prover/src/vkey.rs +++ b/prover/src/vkey.rs @@ -10,7 +10,8 @@ //! ## Current scope //! //! All five preprocessed tables — BITWISE, DECODE, REGISTER, KECCAK_RC, and -//! every non-private-input PAGE — are cached here. `VmAirs::new_with_vkey` +//! every non-private-input PAGE — are cached here, together with the +//! [`ProofOptions`] the commitments were derived under. `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 @@ -18,13 +19,23 @@ //! //! ## Security //! -//! For this PR the verifying key is only a performance shortcut. The -//! 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. +//! The vkey is **trusted input**. Fiat-Shamir only detects a vkey that is +//! inconsistent with the proof (post-hoc tampering); a coordinated prover +//! can commit to a forged preprocessed table from the start and supply a +//! matching vkey, and the transcript stays self-consistent. The binding +//! that makes recursion sound is `compute_digest()`: +//! +//! - The prover stamps it into `VmProof::vk_digest` and binds it into the +//! Fiat-Shamir statement; the verifier recomputes it from its own vkey +//! and rejects on mismatch before any STARK work. +//! - The recursion guest commits `vk_digest ‖ inner public output`, so the +//! *outer* verifier can check which vkey was used in-guest against a +//! digest derived from the trusted inner ELF. Without that outer check +//! the guest's result says nothing — every guest input is prover-supplied. +//! +//! The digest covers the embedded [`ProofOptions`]: query count and +//! grinding factor affect soundness but no commitment, so nothing else +//! would pin them. use executor::elf::Elf; use sha3::{Digest, Keccak256}; @@ -40,7 +51,7 @@ 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 = 3; +pub const VKEY_VERSION: u32 = 4; /// Placeholder commitment stored in [`VmVerifyingKey::pages`] for /// private-input page slots, where there is no preprocessed commitment to @@ -54,6 +65,10 @@ const PRIVATE_INPUT_PAGE_PLACEHOLDER: Commitment = [0u8; 32]; pub struct VmVerifyingKey { /// Layout version. See [`VKEY_VERSION`]. pub version: u32, + /// The options every commitment below was derived under. In the digest + /// because query count and grinding factor affect soundness but no + /// commitment. + pub options: ProofOptions, /// Merkle root over the LDE of the bitwise preprocessed columns. /// Program-independent; depends only on `ProofOptions`. pub bitwise: Commitment, @@ -107,6 +122,7 @@ impl VmVerifyingKey { .unwrap_or_else(|| register::register_init_from_entry_point(elf.entry_point)); Self { version: VKEY_VERSION, + options: options.clone(), bitwise: bitwise::preprocessed_commitment(options), decode: decode::commitment_from_elf(elf, options) .expect("decode commitment must compute"), From cf60e5a33db7981ddb4e7b3363076fd867162000 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Thu, 2 Jul 2026 17:34:57 -0300 Subject: [PATCH 4/7] Revert comment on `DOMAIN_TAG` The comment should still say when it needs bumping, not why it was bumped the last time. --- prover/src/statement.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prover/src/statement.rs b/prover/src/statement.rs index fd695354d..d1d2adc46 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -16,7 +16,7 @@ use crate::test_utils::E; use crate::{RuntimePageRange, TableCounts}; /// Domain-separation tag. Bump the suffix (`_V2`, ...) on any encoding change. -/// V3: monolithic statements bind the verifying-key digest after the tag. +/// Domain-separation tag. Bump the suffix (`_V3`, ...) on any encoding change. const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V3"; fn elf_digest(elf: &[u8]) -> [u8; 32] { From 9e09ec1072148a4f9684920320e758572553a3b8 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Thu, 2 Jul 2026 17:44:40 -0300 Subject: [PATCH 5/7] test: decode recursion blob as the 4-tuple the guest reads The host roundtrip test still decoded (VmProof, elf, opts); postcard discards trailing bytes, so it silently skipped the vkey and the vkey verify path the guest actually exercises. --- prover/src/tests/recursion_smoke_test.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index e87726535..1345050e3 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -1,5 +1,5 @@ //! End-to-end naive recursion pipeline smoke tests: prove an inner program, -//! hand `(VmProof, elf, opts)` to the in-VM verifier guest, then either prove +//! hand `(VmProof, elf, opts, vkey)` to the in-VM verifier guest, then either prove //! the guest's execution (`OuterMode::Prove`) or just execute it //! (`OuterMode::ExecuteOnly`). Guest ELFs come from `make compile-recursion-elfs`. //! @@ -520,9 +520,16 @@ fn test_recursion_blob_decodes_and_verifies_on_host() { prove_inner_and_encode_blob("roundtrip", &empty_elf_bytes, &[], &MIN_PROOF_OPTIONS); // Decode exactly as the guest does. - let decoded: Result<(crate::VmProof, Vec, crate::ProofOptions), _> = - postcard::from_bytes(&blob); - let (vm_proof, inner_elf, options) = match decoded { + let decoded: Result< + ( + crate::VmProof, + Vec, + crate::ProofOptions, + crate::VmVerifyingKey, + ), + _, + > = postcard::from_bytes(&blob); + let (vm_proof, inner_elf, options, vkey) = match decoded { Ok(t) => t, Err(e) => panic!("[roundtrip] postcard DECODE failed (this is the guest panic): {e}"), }; @@ -532,7 +539,14 @@ fn test_recursion_blob_decodes_and_verifies_on_host() { options.blowup_factor ); - match crate::verify_with_options(&vm_proof, &inner_elf, &options, None, None) { + match crate::verify_with_options_with_vkey( + &vm_proof, + &inner_elf, + &options, + None, + None, + Some(&vkey), + ) { Ok(true) => eprintln!("[roundtrip] verify ok=true — guest path is sound"), Ok(false) => panic!( "[roundtrip] verify returned FALSE (guest hits assert!(ok)) — proof did not survive the postcard round-trip" From 252e62ba7deff11641676113c09d70389e063f53 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Thu, 2 Jul 2026 17:48:41 -0300 Subject: [PATCH 6/7] docs: new_with_vkey covers five tables; note page-order lockstep --- prover/src/lib.rs | 8 +++++--- prover/src/vkey.rs | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 5bc24fae6..aa9fd1df8 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -486,9 +486,11 @@ 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`, the bitwise, decode, register, keccak_rc and + /// per-page preprocessed commitments are taken from it instead of being + /// recomputed — recomputation (dominated by bitwise) is ~87% of verifier + /// cycles inside the recursion guest. Explicit `decode_commitment` / + /// `page_commitments` arguments still take precedence over the vkey. #[allow(clippy::too_many_arguments)] pub fn new_with_vkey( elf: &Elf, diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs index 3de96eb29..e3afd85ef 100644 --- a/prover/src/vkey.rs +++ b/prover/src/vkey.rs @@ -87,6 +87,9 @@ pub struct VmVerifyingKey { /// 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. + /// Prover (`traces.page_configs`) and verifier + /// (`page_configs_from_elf_and_runtime`) must derive the same page order + /// or the digests diverge. pub pages: Vec, } From 65f990f77a9f2c6a489f4ab406cb5d0ec1b633eb Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Fri, 3 Jul 2026 17:54:42 -0300 Subject: [PATCH 7/7] soundness fixes --- bench_vs/lambda/recursion/src/main.rs | 25 ++++-- prover/src/lib.rs | 90 ++++++++++++++++++-- prover/src/statement.rs | 5 +- prover/src/tests/recursion_smoke_test.rs | 29 +++++-- prover/src/tests/vkey_tests.rs | 100 ++++++++++++++++++++++- prover/src/vkey.rs | 24 ++++-- 6 files changed, 238 insertions(+), 35 deletions(-) diff --git a/bench_vs/lambda/recursion/src/main.rs b/bench_vs/lambda/recursion/src/main.rs index 00aac3f03..3856467b6 100644 --- a/bench_vs/lambda/recursion/src/main.rs +++ b/bench_vs/lambda/recursion/src/main.rs @@ -3,10 +3,10 @@ //! Private input layout (postcard-encoded): //! `(VmProof, Vec, ProofOptions, VmVerifyingKey)` //! where the `Vec` holds the inner program's ELF bytes and `ProofOptions` -//! specifies the parameters the inner prover used. Commits -//! `vk_digest ‖ inner public output` on success: every input here is -//! prover-supplied, so soundness comes from the outer verifier checking -//! the committed digest against one derived from the trusted inner ELF. +//! specifies the parameters the inner prover used. On success commits a +//! postcard-encoded [`RecursionCommitment`]: every input here is +//! prover-supplied, so soundness comes from an outer verifier passing it to +//! `verify_recursion_commitment` with the trusted inner ELF. //! //! Not `no_std` (std/alloc are available — `build-std` provides them, and the //! prover links as a normal std crate; its prove-side code is dead-code @@ -17,7 +17,7 @@ #![no_main] -use lambda_vm_prover::{ProofOptions, VmProof, VmVerifyingKey}; +use lambda_vm_prover::{ProofOptions, RecursionCommitment, VmProof, VmVerifyingKey}; #[unsafe(export_name = "main")] pub fn main() -> ! { @@ -49,9 +49,18 @@ pub fn main() -> ! { .expect("verify errored"); assert!(ok, "inner proof failed verification"); - let mut output = Vec::with_capacity(32 + vm_proof.public_output.len()); - output.extend_from_slice(&vkey.compute_digest()); - output.extend_from_slice(&vm_proof.public_output); + // `vm_proof.vk_digest` was just checked equal to `vkey.compute_digest()` + // inside verify, so reuse it instead of re-serializing and re-hashing. + let commitment = RecursionCommitment { + elf_digest: lambda_vm_prover::elf_digest(&inner_elf), + vk_digest: vm_proof.vk_digest, + options, + table_counts: vm_proof.table_counts, + num_private_input_pages: vm_proof.num_private_input_pages, + runtime_page_ranges: vm_proof.runtime_page_ranges, + public_output: vm_proof.public_output, + }; + let output = postcard::to_allocvec(&commitment).expect("failed to serialize commitment"); lambda_vm_syscalls::syscalls::commit(&output); lambda_vm_syscalls::syscalls::sys_halt(); } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index aa9fd1df8..bdf508fde 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -21,6 +21,7 @@ pub mod instruments; mod paged_mem; pub use stark::profile_markers; mod statement; +pub use statement::elf_digest; pub mod tables; pub mod test_utils; #[cfg(test)] @@ -71,7 +72,7 @@ use stark::proof::stark::MultiProof; /// /// Represents `count` contiguous pages starting at `base`, used for /// runtime-allocated memory (stack, heap) not covered by ELF segments. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RuntimePageRange { /// Base address of the first page (4KB-aligned). pub base: u64, @@ -86,7 +87,7 @@ pub const FIXED_TABLE_COUNT: usize = 11; /// Number of chunks for each split table. /// The verifier needs this to reconstruct matching AIRs. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TableCounts { pub cpu: usize, pub lt: usize, @@ -181,6 +182,62 @@ pub struct VmProof { pub vk_digest: [u8; 32], } +/// Public output the recursion guest commits after an in-VM verify. Its fields +/// let [`verify_recursion_commitment`] bind the result to a trusted program and +/// options; see that fn and `vkey.rs` for the soundness argument. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RecursionCommitment { + /// Keccak256 of the raw inner ELF bytes the guest verified. + pub elf_digest: [u8; 32], + pub vk_digest: [u8; 32], + pub options: ProofOptions, + pub table_counts: TableCounts, + pub num_private_input_pages: usize, + pub runtime_page_ranges: Vec, + pub public_output: Vec, +} + +/// Outer-verifier check for a [`RecursionCommitment`]: re-derives the canonical +/// vkey from `trusted_elf_bytes`, rejects on `elf_digest`/`options`/`vk_digest` +/// mismatch, and returns the inner public output. See `vkey.rs`. +/// +/// Two caller obligations, both trusted-input boundaries this fn does not check: +/// - The `commitment` must come from an *already-verified* outer proof of the +/// trusted recursion guest. This fn checks no STARK proof; on unverified bytes +/// it proves nothing. +/// - `mandated_options` must be a vetted secure parameter set, never options +/// echoed from the prover. +pub fn verify_recursion_commitment( + commitment: &RecursionCommitment, + trusted_elf_bytes: &[u8], + mandated_options: &ProofOptions, +) -> Result, Error> { + if commitment.elf_digest != elf_digest(trusted_elf_bytes) { + return Err(Error::InvalidVerifyingKey( + "recursion elf_digest does not match the trusted ELF".into(), + )); + } + if commitment.options != *mandated_options { + return Err(Error::InvalidVerifyingKey( + "recursion options do not match the mandated options".into(), + )); + } + let program = Elf::load(trusted_elf_bytes).map_err(|e| Error::ElfLoad(format!("{e}")))?; + let page_configs = Traces::page_configs_from_elf_and_runtime( + &program, + &commitment.runtime_page_ranges, + commitment.num_private_input_pages, + ); + let canonical = + VmVerifyingKey::from_elf_and_options(&program, mandated_options, None, &page_configs); + if commitment.vk_digest != canonical.compute_digest() { + return Err(Error::InvalidVerifyingKey( + "recursion vk_digest does not match the vkey derived from the trusted ELF".into(), + )); + } + Ok(commitment.public_output.clone()) +} + /// Error type for the prover crate. #[derive(Debug)] pub enum Error { @@ -1121,13 +1178,9 @@ pub fn verify_with_options( /// /// # Security /// -/// The vkey is TRUSTED input. Passing one supplied by the prover (as the -/// recursion guest does) proves nothing by itself: a coordinated prover can -/// commit to a forged preprocessed table and ship a matching vkey, and the -/// transcript stays self-consistent. Soundness requires the caller's output -/// to bind `vkey.compute_digest()` (the recursion guest commits it) so an -/// outer verifier can compare it against a digest derived from trusted -/// inputs. See `vkey.rs`. +/// The vkey is TRUSTED input; a prover-supplied one proves nothing unless the +/// caller binds `vkey.compute_digest()` into an output an outer verifier checks +/// against a trusted-input digest. See `vkey.rs` and [`RecursionCommitment`]. pub fn verify_with_options_with_vkey( vm_proof: &VmProof, elf_bytes: &[u8], @@ -1200,6 +1253,25 @@ pub fn verify_with_options_with_vkey( )); } + // Enforce the page slots `new_with_vkey` derives locally instead of reading + // from the vkey (zero-init, private-input). `compute_digest` hashes them, so + // without this check they contribute to the digest unenforced. See vkey.rs. + let zero_init_commitment = page::zero_init_preprocessed_commitment(proof_options); + for (i, config) in page_configs.iter().enumerate() { + let (expected, kind) = if config.is_private_input { + (crate::vkey::PRIVATE_INPUT_PAGE_PLACEHOLDER, "private-input") + } else if config.init_values.is_none() { + (zero_init_commitment, "zero-init") + } else { + continue; + }; + if vkey.pages[i] != expected { + return Err(Error::InvalidVerifyingKey(format!( + "vkey.pages[{i}] does not match the expected {kind} page commitment", + ))); + } + } + // Cross-check: table_counts must match the number of sub-proofs. // FIXED_TABLE_COUNT always-present tables, plus page tables. let expected_proof_count = diff --git a/prover/src/statement.rs b/prover/src/statement.rs index d1d2adc46..64394f8c2 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -15,11 +15,12 @@ use sha3::{Digest, Keccak256}; use crate::test_utils::E; use crate::{RuntimePageRange, TableCounts}; -/// Domain-separation tag. Bump the suffix (`_V2`, ...) on any encoding change. /// Domain-separation tag. Bump the suffix (`_V3`, ...) on any encoding change. const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V3"; -fn elf_digest(elf: &[u8]) -> [u8; 32] { +/// Keccak256 of the raw ELF bytes — the program identity bound into the +/// statement and committed by the recursion guest. +pub fn elf_digest(elf: &[u8]) -> [u8; 32] { let mut h = Keccak256::new(); h.update(elf); h.finalize().into() diff --git a/prover/src/tests/recursion_smoke_test.rs b/prover/src/tests/recursion_smoke_test.rs index 1345050e3..a4fe1d397 100644 --- a/prover/src/tests/recursion_smoke_test.rs +++ b/prover/src/tests/recursion_smoke_test.rs @@ -441,9 +441,8 @@ fn run_profile( } /// Core pipeline: prove the inner program, run the guest to `mode`, assert it -/// committed `vk_digest ‖ inner public output` — the outer-verifier check: -/// the digest of the vkey used in-guest must match one derived on the host -/// from the trusted inner ELF. +/// committed the expected `RecursionCommitment`, and run the outer-verifier +/// check (`verify_recursion_commitment`) against the trusted inner ELF. fn run_recursion_pipeline_with_options( label: &str, inner_elf_bytes: &[u8], @@ -482,13 +481,27 @@ fn run_recursion_pipeline_with_options( OuterMode::Prove => prove_outer_and_commit(label, &recursion_elf_bytes, &blob), }; - let mut expected = inner_proof.vk_digest.to_vec(); - expected.extend_from_slice(&inner_proof.public_output); + let commitment: crate::RecursionCommitment = + postcard::from_bytes(&committed).expect("decode recursion commitment"); + let expected = crate::RecursionCommitment { + elf_digest: crate::elf_digest(inner_elf_bytes), + vk_digest: inner_proof.vk_digest, + options: inner_proof_options.clone(), + table_counts: inner_proof.table_counts.clone(), + num_private_input_pages: inner_proof.num_private_input_pages, + runtime_page_ranges: inner_proof.runtime_page_ranges.clone(), + public_output: inner_proof.public_output.clone(), + }; assert_eq!( - committed, expected, - "recursion guest must commit vk_digest ‖ inner public output" + commitment, expected, + "recursion guest must commit the full RecursionCommitment" ); - eprintln!("[{label}] guest committed vk_digest ‖ output: in-VM verify accepted ✓"); + // The committed digests must satisfy the outer-verifier check against the + // trusted inner ELF and the same options. + let out = crate::verify_recursion_commitment(&commitment, inner_elf_bytes, &inner_proof_options) + .expect("outer verifier must accept the honest commitment"); + assert_eq!(out, inner_proof.public_output); + eprintln!("[{label}] guest committed RecursionCommitment; outer verify accepted ✓"); } /// `run_recursion_pipeline_with_options` with `blowup=8` (the `empty`/`fibonacci` default). diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs index 941415f31..2408a7f9b 100644 --- a/prover/src/tests/vkey_tests.rs +++ b/prover/src/tests/vkey_tests.rs @@ -26,9 +26,9 @@ fn page_configs_from_proof(elf: &Elf, vm_proof: &VmProof) -> Vec { ) } -/// Prove `sub`, and derive the honest vkey the same way the verifier would. -fn proof_and_vkey() -> (Vec, VmProof, ProofOptions, VmVerifyingKey) { - let elf_bytes = asm_elf_bytes("sub"); +/// Prove `program`, and derive the honest vkey the same way the verifier would. +fn proof_and_vkey_for(program: &str) -> (Vec, VmProof, ProofOptions, VmVerifyingKey) { + let elf_bytes = asm_elf_bytes(program); 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(); @@ -37,6 +37,28 @@ fn proof_and_vkey() -> (Vec, VmProof, ProofOptions, VmVerifyingKey) { (elf_bytes, vm_proof, options, vkey) } +/// Prove `sub`, and derive the honest vkey the same way the verifier would. +fn proof_and_vkey() -> (Vec, VmProof, ProofOptions, VmVerifyingKey) { + proof_and_vkey_for("sub") +} + +/// Build the `RecursionCommitment` the guest would commit for a proof. +fn commitment_for( + elf_bytes: &[u8], + vm_proof: &VmProof, + options: &ProofOptions, +) -> crate::RecursionCommitment { + crate::RecursionCommitment { + elf_digest: crate::elf_digest(elf_bytes), + vk_digest: vm_proof.vk_digest, + options: options.clone(), + table_counts: vm_proof.table_counts.clone(), + num_private_input_pages: vm_proof.num_private_input_pages, + runtime_page_ranges: vm_proof.runtime_page_ranges.clone(), + public_output: vm_proof.public_output.clone(), + } +} + /// A tampered or malformed vkey must be rejected with an explicit /// `InvalidVerifyingKey` before any STARK work runs — either by the shape /// checks or by the `vk_digest` comparison against the proof. @@ -135,6 +157,27 @@ fn test_vkey_page_mismatch_rejects() { assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered page"); } +#[test] +fn test_vkey_zero_init_slot_enforced() { + // A zero-init page's vkey slot is ignored by `new_with_vkey` (the verifier + // derives it locally), yet `compute_digest` still hashes it. Without an + // explicit per-slot check, a slot could differ from the value the verifier + // uses while the digest still matches — the gap that lets a program be + // reclassified under an unchanged identity. Re-stamp `vk_digest` so the + // digest check passes, and confirm the per-slot check rejects anyway. + // `sub` touches no runtime pages; use a program that exercises the stack so + // `page_configs` contains a zero-init page. + let (elf_bytes, mut vm_proof, options, mut vkey) = proof_and_vkey_for("deep_stack"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let target = page_configs_from_proof(&elf, &vm_proof) + .iter() + .position(|c| !c.is_private_input && c.init_values.is_none()) + .expect("test ELF must produce at least one zero-init page (the stack)"); + vkey.pages[target] = [0xAB; 32]; + vm_proof.vk_digest = vkey.compute_digest(); + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered zero-init slot"); +} + #[test] fn test_vkey_decode_mismatch_rejects() { let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); @@ -179,3 +222,54 @@ fn test_vkey_wrong_version_rejects() { vkey.version = VKEY_VERSION - 1; assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "wrong version"); } + +fn assert_rejects_commitment( + commitment: &crate::RecursionCommitment, + trusted_elf: &[u8], + options: &ProofOptions, + what: &str, +) { + let result = crate::verify_recursion_commitment(commitment, trusted_elf, options); + assert!( + matches!(result, Err(Error::InvalidVerifyingKey(_))), + "{what} must be rejected with InvalidVerifyingKey, got {result:?}" + ); +} + +#[test] +fn test_recursion_commitment_accepts_honest() { + let (elf_bytes, vm_proof, options, _vkey) = proof_and_vkey(); + let commitment = commitment_for(&elf_bytes, &vm_proof, &options); + let out = crate::verify_recursion_commitment(&commitment, &elf_bytes, &options) + .expect("honest commitment must be accepted"); + assert_eq!(out, vm_proof.public_output); +} + +#[test] +fn test_recursion_commitment_forged_program_rejected() { + // The core recursion soundness check: a prover commits `elf_digest` for the + // trusted program `sub` but a `vk_digest` derived from a *different* program + // (`deep_stack`). Re-deriving the canonical vkey from the trusted ELF yields + // a different digest, so the substitution is caught. + let (elf_bytes, vm_proof, options, _vkey) = proof_and_vkey(); + let (_other_elf, other_proof, _other_opts, _other_vkey) = proof_and_vkey_for("deep_stack"); + let mut commitment = commitment_for(&elf_bytes, &vm_proof, &options); + commitment.vk_digest = other_proof.vk_digest; + assert_rejects_commitment(&commitment, &elf_bytes, &options, "forged program vk_digest"); +} + +#[test] +fn test_recursion_commitment_wrong_trusted_elf_rejected() { + let (elf_bytes, vm_proof, options, _vkey) = proof_and_vkey(); + let commitment = commitment_for(&elf_bytes, &vm_proof, &options); + let wrong_elf = asm_elf_bytes("deep_stack"); + assert_rejects_commitment(&commitment, &wrong_elf, &options, "wrong trusted ELF"); +} + +#[test] +fn test_recursion_commitment_weak_options_rejected() { + let (elf_bytes, vm_proof, options, _vkey) = proof_and_vkey(); + let mut commitment = commitment_for(&elf_bytes, &vm_proof, &options); + commitment.options.fri_number_of_queries = 1; + assert_rejects_commitment(&commitment, &elf_bytes, &options, "weakened options"); +} diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs index e3afd85ef..10e1686d2 100644 --- a/prover/src/vkey.rs +++ b/prover/src/vkey.rs @@ -28,10 +28,24 @@ //! - The prover stamps it into `VmProof::vk_digest` and binds it into the //! Fiat-Shamir statement; the verifier recomputes it from its own vkey //! and rejects on mismatch before any STARK work. -//! - The recursion guest commits `vk_digest ‖ inner public output`, so the -//! *outer* verifier can check which vkey was used in-guest against a -//! digest derived from the trusted inner ELF. Without that outer check -//! the guest's result says nothing — every guest input is prover-supplied. +//! - The recursion guest commits a [`crate::RecursionCommitment`]. The *outer* +//! verifier passes it to [`crate::verify_recursion_commitment`], which +//! re-derives the canonical vkey from the trusted inner ELF and rejects on a +//! `vk_digest` mismatch. Because the DECODE/REGISTER/PAGE commitments are +//! re-derived from that ELF, `vk_digest` is a faithful program identity: +//! a prover cannot substitute a vkey whose commitments describe a different +//! program. Without that outer check the guest's result says nothing — every +//! guest input is prover-supplied. +//! +//! Two independent guards back this up. First, `compute_digest` hashes every +//! `pages` slot, but the verifier ignores the zero-init and private-input slots +//! (it derives those locally); left unchecked, a prover could reclassify an ELF +//! data page to zero-init under an unchanged digest, so +//! `verify_with_options_with_vkey` asserts every ignored slot equals the value +//! the verifier actually uses. Second, the outer check re-derives `vk_digest` +//! from the trusted ELF, which requires the execution-dependent +//! `runtime_page_ranges` and `num_private_input_pages` — carried in the +//! `RecursionCommitment` because they are not derivable from the ELF alone. //! //! The digest covers the embedded [`ProofOptions`]: query count and //! grinding factor affect soundness but no commitment, so nothing else @@ -57,7 +71,7 @@ pub const VKEY_VERSION: u32 = 4; /// 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]; +pub const PRIVATE_INPUT_PAGE_PLACEHOLDER: Commitment = [0u8; 32]; /// Cached preprocessed-table commitments the verifier would otherwise /// recompute on every call.