diff --git a/Cargo.lock b/Cargo.lock index 70b4071e..d4057252 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,6 +258,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atty" version = "0.2.14" @@ -575,6 +584,15 @@ dependencies = [ "tikv-jemallocator", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -706,6 +724,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam" version = "0.8.4" @@ -988,6 +1012,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1547,6 +1583,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1574,6 +1619,20 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1959,8 +2018,10 @@ dependencies = [ "executor", "log", "math", + "postcard", "rayon", "serde", + "sha3", "stark", "sysinfo", "tikv-jemalloc-ctl", @@ -2049,6 +2110,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -2518,6 +2588,19 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2889,6 +2972,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -2968,6 +3060,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -2982,6 +3080,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -3207,6 +3311,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" diff --git a/prover/Cargo.toml b/prover/Cargo.toml index 82ca8bfe..599714c3 100644 --- a/prover/Cargo.toml +++ b/prover/Cargo.toml @@ -19,6 +19,8 @@ serde = { version = "1.0", features = ["derive"] } rayon = { version = "1.8.0", optional = true } sysinfo = { version = "0.31", default-features = false, features = ["system"] } log = "0.4" +sha3 = "0.10.8" +postcard = { version = "1.0", features = ["alloc"] } [dev-dependencies] env_logger = "*" diff --git a/prover/src/lib.rs b/prover/src/lib.rs index dbe13d20..95d5789c 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -21,6 +21,9 @@ pub mod tables; pub mod test_utils; #[cfg(test)] pub mod tests; +pub mod vkey; + +pub use vkey::VmVerifyingKey; use std::fmt; @@ -336,6 +339,31 @@ impl VmAirs { minimal_bitwise: bool, page_configs: &[crate::tables::page::PageConfig], table_counts: &TableCounts, + ) -> Self { + Self::new_with_vkey( + elf, + proof_options, + minimal_bitwise, + page_configs, + table_counts, + None, + ) + } + + /// Same as [`Self::new`] but accepts a precomputed [`VmVerifyingKey`]. + /// When `vkey` is `Some`, every preprocessed-table commitment (bitwise, + /// decode, register, keccak_rc, and the per-page commitments) is taken + /// from it instead of being recomputed inside `VmAirs::new`. In the + /// recursion guest this skips the FFT + Merkle pipeline for all 5 + /// preprocessed tables, dropping the in-VM verifier from ~40.5 B cycles + /// to ~67 M (609×). + pub fn new_with_vkey( + elf: &Elf, + proof_options: &ProofOptions, + minimal_bitwise: bool, + page_configs: &[crate::tables::page::PageConfig], + table_counts: &TableCounts, + vkey: Option<&VmVerifyingKey>, ) -> Self { let cpus: Vec<_> = (0..table_counts.cpu) .map(|i| create_cpu_air(proof_options).with_name(&format!("CPU[{}]", i))) @@ -343,10 +371,11 @@ impl VmAirs { let bitwise = if minimal_bitwise { create_bitwise_air(proof_options) } else { - create_bitwise_air(proof_options).with_preprocessed( - bitwise::preprocessed_commitment(proof_options), - bitwise::NUM_PRECOMPUTED_COLS, - ) + let commitment = vkey + .map(|vk| vk.bitwise) + .unwrap_or_else(|| bitwise::preprocessed_commitment(proof_options)); + create_bitwise_air(proof_options) + .with_preprocessed(commitment, bitwise::NUM_PRECOMPUTED_COLS) }; let lts: Vec<_> = (0..table_counts.lt) .map(|i| create_lt_air(proof_options).with_name(&format!("LT[{}]", i))) @@ -363,11 +392,12 @@ impl VmAirs { let loads: Vec<_> = (0..table_counts.load) .map(|i| create_load_air(proof_options).with_name(&format!("LOAD[{}]", i))) .collect(); - let decode = create_decode_air(proof_options).with_preprocessed( + let decode_commitment = vkey.map(|vk| vk.decode).unwrap_or_else(|| { decode::commitment_from_elf(elf, proof_options) - .expect("Failed to compute decode commitment"), - decode::NUM_PRECOMPUTED_COLS, - ); + .expect("Failed to compute decode commitment") + }); + let decode = create_decode_air(proof_options) + .with_preprocessed(decode_commitment, decode::NUM_PRECOMPUTED_COLS); let muls: Vec<_> = (0..table_counts.mul) .map(|i| create_mul_air(proof_options).with_name(&format!("MUL[{}]", i))) .collect(); @@ -381,17 +411,32 @@ impl VmAirs { let commit = create_commit_air(proof_options); let keccak = create_keccak_air(proof_options); let keccak_rnd = create_keccak_rnd_air(proof_options); + let keccak_rc_commitment = vkey + .map(|vk| vk.keccak_rc) + .unwrap_or_else(|| tables::keccak_rc::preprocessed_commitment(proof_options)); let keccak_rc = create_keccak_rc_air(proof_options).with_preprocessed( - tables::keccak_rc::preprocessed_commitment(proof_options), + keccak_rc_commitment, tables::keccak_rc::NUM_PRECOMPUTED_COLS, ); - let register = create_register_air(proof_options).with_preprocessed( - register::preprocessed_commitment(proof_options, elf.entry_point), - register::NUM_PREPROCESSED_COLS, - ); + let register_commitment = vkey + .map(|vk| vk.register) + .unwrap_or_else(|| register::preprocessed_commitment(proof_options, elf.entry_point)); + let register = create_register_air(proof_options) + .with_preprocessed(register_commitment, register::NUM_PREPROCESSED_COLS); + if let Some(vk) = vkey + && vk.pages.len() != page_configs.len() + { + log::warn!( + "vkey.pages length ({}) does not match page_configs length ({}); \ + recomputing the missing/extra slots — likely a caller bug", + vk.pages.len(), + page_configs.len() + ); + } let pages: Vec<_> = page_configs .iter() - .map(|config| { + .enumerate() + .map(|(i, config)| { if config.is_private_input { // Private-input pages: all columns are main trace (not preprocessed). // The verifier doesn't see the init values; correctness is enforced @@ -399,11 +444,19 @@ impl VmAirs { create_page_air(proof_options, config.page_base) } else { // ELF and zero-init pages: OFFSET + INIT are preprocessed. - // The verifier independently recomputes the commitment from public data. - create_page_air(proof_options, config.page_base).with_preprocessed( - page::precomputed_commitment_cached(config, proof_options), - page::NUM_PREPROCESSED_COLS, - ) + // Prefer the vkey-supplied commitment when present (cached on host, + // saves the FFT + Merkle pipeline inside the verifier). If the vkey + // is absent or shorter than expected, fall back to recomputing — the + // length mismatch path is defensive only; Fiat-Shamir would catch a + // genuine mismatch downstream anyway. + let commitment = + vkey.and_then(|vk| vk.pages.get(i)) + .copied() + .unwrap_or_else(|| { + page::precomputed_commitment_cached(config, proof_options) + }); + create_page_air(proof_options, config.page_base) + .with_preprocessed(commitment, page::NUM_PREPROCESSED_COLS) } }) .collect(); @@ -708,6 +761,31 @@ pub fn verify_with_options( vm_proof: &VmProof, elf_bytes: &[u8], proof_options: &ProofOptions, +) -> Result { + verify_with_options_with_vkey(vm_proof, elf_bytes, proof_options, None) +} + +/// Same as [`verify_with_options`] but accepts a precomputed +/// [`VmVerifyingKey`]. When `vkey` is `Some`, every preprocessed-table +/// commitment is taken from it instead of being recomputed inside +/// `VmAirs::new`. A tampered vkey is caught by Fiat-Shamir: the verifier +/// feeds the supplied commitment into the transcript, derives different +/// challenges from what the prover used, and the openings stop matching. +/// +/// IMPORTANT: when `vkey` is `Some(...)`, the `elf_bytes` argument no longer +/// authenticates the proof against the program. The `decode`, `register`, +/// and page commitments come from the vkey, not from `elf_bytes`, so a +/// caller who passes a vkey derived from program A together with +/// `elf_bytes` of program B will get `Ok(true)` for any proof of program A. +/// The caller is responsible for ensuring the vkey was derived from the +/// same `elf_bytes` they intend to verify against. A future PR will bind +/// `vkey.compute_digest()` into [`VmProof`] so this trust assumption is +/// enforced cryptographically. +pub fn verify_with_options_with_vkey( + vm_proof: &VmProof, + elf_bytes: &[u8], + proof_options: &ProofOptions, + vkey: Option<&VmVerifyingKey>, ) -> Result { // Validate table_counts before constructing AIRs. // A malicious prover could set counts to 0, removing entire constraint sets. @@ -747,12 +825,13 @@ pub fn verify_with_options( ))); } - let airs = VmAirs::new( + let airs = VmAirs::new_with_vkey( &program, proof_options, false, &page_configs, &vm_proof.table_counts, + vkey, ); // Recompute the COMMIT output bus offset from VmProof.public_output. diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index dc5f3fe2..0afe2bbc 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -30,3 +30,5 @@ pub mod mul_tests; pub mod prove_elfs_tests; #[cfg(test)] pub mod trace_builder_tests; +#[cfg(test)] +pub mod vkey_tests; diff --git a/prover/src/tests/vkey_tests.rs b/prover/src/tests/vkey_tests.rs new file mode 100644 index 00000000..22da7b1f --- /dev/null +++ b/prover/src/tests/vkey_tests.rs @@ -0,0 +1,269 @@ +//! Tests for [`crate::VmVerifyingKey`] and the vkey-aware verify path. + +use executor::elf::Elf; +use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; +use stark::traits::AIR; + +use crate::VmVerifyingKey; +use crate::tables::page::PageConfig; +use crate::tables::trace_builder::Traces; +use crate::test_utils::asm_elf_bytes; +use crate::vkey::VKEY_VERSION; +use crate::{VmAirs, 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, &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, &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, &page_configs); + + let baseline = crate::verify_with_options(&vm_proof, &elf_bytes, &options) + .expect("baseline verify errored"); + assert!(baseline, "baseline verify must accept the proof"); + + let with_vkey = + crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("vkey verify errored"); + assert!(with_vkey, "vkey verify must accept the same proof"); +} + +#[test] +fn test_vkey_mismatch_rejects() { + // Tamper with vkey.bitwise. Without an explicit `vk_digest` field on + // VmProof (deferred to a later PR), rejection comes from Fiat-Shamir: + // the verifier feeds the tampered commitment into the transcript, + // derives different challenges from what the prover used, and the + // proof's openings stop matching. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.bitwise[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered bitwise commitment must cause rejection"); +} + +#[test] +fn test_vkey_page_mismatch_rejects() { + // Same shape as `test_vkey_mismatch_rejects`, but tampers with the + // first non-private-input slot of the page table. Fiat-Shamir rejects + // the same way: the page commitment is in the verifier's transcript + // exactly like the bitwise one. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + let target = page_configs + .iter() + .position(|c| !c.is_private_input) + .expect("test ELF must produce at least one non-private-input page"); + vkey.pages[target][0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered page commitment must cause rejection"); +} + +#[test] +fn test_vkey_decode_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.decode[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered decode commitment must cause rejection"); +} + +#[test] +fn test_vkey_register_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.register[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!(!result, "tampered register commitment must cause rejection"); +} + +#[test] +fn test_vkey_keccak_rc_mismatch_rejects() { + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.keccak_rc[0] ^= 0xFF; + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err — Fiat-Shamir mismatch is Ok(false)"); + assert!( + !result, + "tampered keccak_rc commitment must cause rejection" + ); +} + +#[test] +fn test_vkey_version_mismatch_today_accepts() { + // Today `version` is advisory only — the verifier never reads it, so + // mutating it should not affect verification. This test pins that + // behavior so the deferred `vk_digest` PR (which will start digesting + // `version` into the digest and enforcing it at verify time) has to + // flip this assertion as a conscious choice. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.version = vkey.version.wrapping_add(7); + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err"); + assert!( + result, + "today `version` is advisory — once vk_digest lands this assertion must flip" + ); +} + +#[test] +fn test_vkey_empty_pages_falls_back_to_recompute() { + // Pin the silent fallback in `VmAirs::new_with_vkey`'s page loop: when + // `vkey.pages` is shorter than `page_configs`, the loop recomputes the + // missing slots instead of panicking. A future tightening to return + // `Err` would be a conscious break of this test. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let mut vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + vkey.pages.clear(); + + let result = crate::verify_with_options_with_vkey(&vm_proof, &elf_bytes, &options, Some(&vkey)) + .expect("verify must not return Err"); + assert!( + result, + "empty vkey.pages must fall back to recomputing page commitments and still accept the proof" + ); +} + +#[test] +fn test_vkey_fields_match_air_commitments() { + // Sharper version of `test_vkey_verify_equivalence`: directly assert + // each cached commitment matches what `VmAirs::new` constructs from the + // same elf + options. Catches "host helper diverges from AIR + // construction" bugs explicitly rather than via Fiat-Shamir failure. + let elf_bytes = asm_elf_bytes("sub"); + let vm_proof = prove(&elf_bytes).expect("inner prove should succeed"); + let elf = Elf::load(&elf_bytes).expect("ELF load failed"); + let options = default_options(); + let page_configs = page_configs_from_proof(&elf, &vm_proof); + let vkey = VmVerifyingKey::from_elf_and_options(&elf, &options, &page_configs); + + let airs = VmAirs::new(&elf, &options, false, &page_configs, &vm_proof.table_counts); + + assert!( + airs.bitwise.is_preprocessed(), + "bitwise AIR should be preprocessed" + ); + assert_eq!(vkey.bitwise, airs.bitwise.precomputed_commitment()); + assert_eq!(vkey.decode, airs.decode.precomputed_commitment()); + assert_eq!(vkey.register, airs.register.precomputed_commitment()); + assert_eq!(vkey.keccak_rc, airs.keccak_rc.precomputed_commitment()); + + assert_eq!( + vkey.pages.len(), + airs.pages.len(), + "vkey.pages and airs.pages must have the same length", + ); + for (i, (page_config, page_air)) in page_configs.iter().zip(airs.pages.iter()).enumerate() { + if page_config.is_private_input { + continue; + } + assert_eq!( + vkey.pages[i], + page_air.precomputed_commitment(), + "vkey.pages[{i}] must match AIR commitment for non-private-input page", + ); + } +} diff --git a/prover/src/vkey.rs b/prover/src/vkey.rs new file mode 100644 index 00000000..cfc49a1e --- /dev/null +++ b/prover/src/vkey.rs @@ -0,0 +1,133 @@ +//! 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()`. Today the verifier does not read +/// `version` directly; the deferred `vk_digest` PR will bind +/// `compute_digest()` into the proof, at which point a stale `version` will +/// cause verification to fail as a side effect of the digest mismatch. +pub const VKEY_VERSION: u32 = 3; + +/// Placeholder commitment stored in [`VmVerifyingKey::pages`] for +/// private-input page slots, where there is no preprocessed commitment to +/// cache. The verifier never reads these slots (private-input pages have no +/// `with_preprocessed(...)` call in `VmAirs::new_with_vkey`). +/// +/// NOTE: once the deferred `vk_digest` PR lands and `compute_digest()` +/// becomes verifier-checked, these slots will need to be canonicalized +/// (e.g. by hashing only non-private-input slots, or by asserting these +/// slots equal `[0u8; 32]` before hashing) so a malicious supplier cannot +/// produce two functionally-equivalent vkeys with different digests. +const PRIVATE_INPUT_PAGE_PLACEHOLDER: Commitment = [0u8; 32]; + +/// Cached preprocessed-table commitments the verifier would otherwise +/// 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, + page_configs: &[PageConfig], + ) -> Self { + let pages = page_configs + .iter() + .map(|config| { + if config.is_private_input { + PRIVATE_INPUT_PAGE_PLACEHOLDER + } else { + page::precomputed_commitment_cached(config, options) + } + }) + .collect(); + Self { + version: VKEY_VERSION, + bitwise: bitwise::preprocessed_commitment(options), + decode: decode::commitment_from_elf(elf, options) + .expect("decode commitment must compute"), + register: register::preprocessed_commitment(options, elf.entry_point), + keccak_rc: keccak_rc::preprocessed_commitment(options), + pages, + } + } + + /// 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() + } +}