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..3856467b6 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. 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 @@ -14,7 +17,7 @@ #![no_main] -use lambda_vm_prover::{ProofOptions, VmProof}; +use lambda_vm_prover::{ProofOptions, RecursionCommitment, VmProof, VmVerifyingKey}; #[unsafe(export_name = "main")] pub fn main() -> ! { @@ -29,16 +32,35 @@ 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]); + // `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/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/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..bdf508fde 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -21,10 +21,14 @@ 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)] pub mod tests; +pub mod vkey; + +pub use vkey::VmVerifyingKey; use std::fmt; @@ -68,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, @@ -83,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, @@ -172,6 +176,66 @@ 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], +} + +/// 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. @@ -189,6 +253,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. @@ -212,6 +278,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}") } @@ -459,6 +526,41 @@ 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, 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, + 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 +570,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 +607,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 +637,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 +657,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 +675,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 +685,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.and_then(|vk| vk.pages.get(index)).copied()) .unwrap_or_else(|| { page::compute_precomputed_commitment(config, proof_options) }); @@ -899,7 +1019,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, @@ -910,6 +1035,7 @@ pub fn prove_with_options_and_inputs( None, None, None, + Some(&vkey), ); #[cfg(feature = "instruments")] @@ -932,7 +1058,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, @@ -983,6 +1109,7 @@ pub fn prove_with_options_and_inputs( table_counts, public_output: traces.public_output_bytes.clone(), num_private_input_pages, + vk_digest, }) } @@ -1035,6 +1162,32 @@ 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`], skipping the preprocessed-commitment recomputation. +/// +/// # Security +/// +/// 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], + 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. @@ -1059,6 +1212,66 @@ pub fn verify_with_options( 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(), + )); + } + + // 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 = @@ -1073,7 +1286,7 @@ pub fn verify_with_options( ))); } - let airs = VmAirs::new( + let airs = VmAirs::new_with_vkey( &program, proof_options, false, @@ -1084,6 +1297,7 @@ pub fn verify_with_options( None, page_commitments, None, + Some(vkey), ); // Recompute the COMMIT output bus offset from VmProof.public_output. @@ -1097,7 +1311,7 @@ pub fn verify_with_options( 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..64394f8c2 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -15,23 +15,26 @@ use sha3::{Digest, Keccak256}; 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"; +/// 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() } -/// 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 +51,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/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/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 a32d44c7c..a4fe1d397 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`. //! @@ -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) } @@ -433,7 +441,8 @@ 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 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], @@ -472,12 +481,27 @@ fn run_recursion_pipeline_with_options( OuterMode::Prove => prove_outer_and_commit(label, &recursion_elf_bytes, &blob), }; + 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, - vec![1u8], - "recursion guest must commit the [1] success marker (in-VM verify accepted)" + commitment, expected, + "recursion guest must commit the full RecursionCommitment" ); - eprintln!("[{label}] guest committed [1]: 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). @@ -509,9 +533,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}"), }; @@ -521,7 +552,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" 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 new file mode 100644 index 000000000..2408a7f9b --- /dev/null +++ b/prover/src/tests/vkey_tests.rs @@ -0,0 +1,275 @@ +//! 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::{Error, 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, + ) +} + +/// 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(); + 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) +} + +/// 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. +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. + 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, vm_proof, options, vkey) = proof_and_vkey(); + + 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() { + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); + vkey.bitwise[0] ^= 0xFF; + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered bitwise"); +} + +#[test] +fn test_vkey_page_mismatch_rejects() { + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); + 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) + .expect("test ELF must produce at least one non-private-input page"); + vkey.pages[target][0] ^= 0xFF; + 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(); + vkey.decode[0] ^= 0xFF; + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered decode"); +} + +#[test] +fn test_vkey_register_mismatch_rejects() { + let (elf_bytes, vm_proof, options, mut vkey) = proof_and_vkey(); + vkey.register[0] ^= 0xFF; + assert_rejects_vkey(&elf_bytes, &vm_proof, &options, &vkey, "tampered register"); +} + +#[test] +fn test_vkey_keccak_rc_mismatch_rejects() { + 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"); +} + +#[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"); +} + +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 new file mode 100644 index 000000000..10e1686d2 --- /dev/null +++ b/prover/src/vkey.rs @@ -0,0 +1,161 @@ +//! 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, 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 +//! validating. +//! +//! ## Security +//! +//! 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 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 +//! would pin them. + +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 = 4; + +/// 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`). +pub 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, + /// 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, + /// 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. + /// 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, +} + +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, + options: options.clone(), + 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() + } +}