From b4218e7e9c77ce975908de483224c405e19037ac Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:51:31 -0700 Subject: [PATCH] fix(verifier): key the measurement cache on the measured VM shape only vm_config_cache_key hashed the whole VmConfig, including fields the full-image measurement never reads: an arbitrary `image` string, and the `tdx_measurement`/`gcp_measurement`/`aws_measurement` documents, each carrying a caller-sized checksum_file and CBOR blob. A cache miss costs a full firmware and kernel hash plus ACPI generation and leaves a file under /measurements/ that nothing evicts, so one captured quote replayed with a different filler byte per request misses every time and grows the cache without bound. Clear those four before hashing. Clearing rather than listing the fields that matter keeps a VmConfig field added later in the key by default, which is the safe direction: an extra miss, never a stale measurement. --- dstack/verifier/src/verification.rs | 44 ++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 6313177ae..e8cb585c8 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -142,8 +142,18 @@ impl CvmVerifier { .join(format!("{cache_key}.json")) } + /// Hash only what the measurement reads: `image` and the measurement documents + /// are caller-controlled and unused, so they must not be able to force a miss. + /// Clear them rather than allowlisting, so new `VmConfig` fields stay in the key. fn vm_config_cache_key(vm_config: &VmConfig) -> Result { - let serialized = serde_json::to_vec(vm_config) + let vm_config = VmConfig { + image: None, + tdx_measurement: None, + gcp_measurement: None, + aws_measurement: None, + ..vm_config.clone() + }; + let serialized = serde_json::to_vec(&vm_config) .context("Failed to serialize VM config for cache key computation")?; Ok(hex::encode(Sha256::digest(&serialized))) } @@ -1723,6 +1733,38 @@ mod tests { assert_eq!(entries.len(), 1, "temporary cache files must not survive"); } + #[test] + fn measurement_cache_key_ignores_unmeasured_fields() { + let base: VmConfig = serde_json::from_value(serde_json::json!({ + "os_image_hash": "11".repeat(32), + "cpu_count": 2, + "memory_size": 0x8000_0000u64, + })) + .unwrap(); + let key = |config: &VmConfig| CvmVerifier::vm_config_cache_key(config).unwrap(); + let blob = || vec![0xaa; 4096]; + + let mut padded = base.clone(); + padded.image = Some("x".repeat(4096)); + padded.tdx_measurement = Some(dstack_types::TdxOsImageMeasurementDocument::new( + blob(), + blob(), + )); + padded.gcp_measurement = Some(dstack_types::GcpOsImageMeasurementDocument::new( + blob(), + blob(), + )); + padded.aws_measurement = Some(dstack_types::AwsOsImageMeasurementDocument::new( + blob(), + blob(), + )); + assert_eq!(key(&padded), key(&base)); + + let mut resized = base.clone(); + resized.cpu_count += 1; + assert_ne!(key(&resized), key(&base)); + } + #[test] fn image_cache_pruning_keeps_checksum_identity() { let dir = tempfile::tempdir().expect("temp image directory");