From 8dc14647597cdcce94ccaa8bbe6dc0ab5e2cb8ed Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:41:30 -0700 Subject: [PATCH 1/7] fix(dstack-types): parse sha256sum.txt with one strict grammar sha256sum.txt is what os_image_hash commits to, and it was read by two different grammars. sha256sum_entry_hash split on whitespace and matched the second token, so ` name junk` matched a name GNU sha256sum resolves as `name junk`, while ` *name` -- valid binary-mode syntax naming `name` -- matched nothing and made the entry look missing. Accept exactly the line shape `sha256sum ` emits, which is what os/image/assemble.sh runs: 64 hex digits, two spaces, a flat file name, no duplicates. Everything else is an error instead of a silently different name. --- dstack/dstack-types/src/lib.rs | 166 +++++++++++++++++++++++++++------ 1 file changed, 137 insertions(+), 29 deletions(-) diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index 944bf12e1..dfc19c91a 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -1545,44 +1545,79 @@ pub fn image_hash_from_sha256sum(checksum_file: &[u8]) -> [u8; 32] { sha256(checksum_file) } -pub fn sha256sum_entry_hash(checksum_file: &[u8], filename: &str) -> Result<[u8; 32], String> { +/// One `sha256sum.txt` entry: a file name and the digest it is bound to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sha256sumEntry { + pub hash: [u8; 32], + pub name: String, +} + +/// Parse `sha256sum.txt`, the file whose digest is `os_image_hash`. +/// +/// Every consumer of this file must read it the same way, because it is the +/// only thing binding `os_image_hash` to the bytes that get measured. GNU +/// `sha256sum`'s own grammar is wider than a whitespace split: ` *name` +/// is binary mode and names `name`, a line beginning with `\` is escaped, and +/// an improperly formatted line is *skipped with a warning* while `-c` still +/// exits 0. A parser that disagrees with it on any of those lets one side check +/// a file the other side never looked at. +/// +/// So accept exactly the one line shape `sha256sum ` emits -- 64 hex +/// digits, two spaces, a flat file name -- and reject everything else. All +/// dstack images are assembled with that command (`os/image/assemble.sh`). +pub fn parse_sha256sum_manifest(checksum_file: &[u8]) -> Result, String> { let text = std::str::from_utf8(checksum_file) .map_err(|e| format!("sha256sum.txt is not valid UTF-8: {e}"))?; - let mut found = None; - for (line_no, line) in text.lines().enumerate() { - let line = line.trim(); + let mut entries: Vec = Vec::new(); + for (index, line) in text.split('\n').enumerate() { + let line_no = index + 1; if line.is_empty() { continue; } - let mut parts = line.split_whitespace(); - let Some(hash_hex) = parts.next() else { - continue; + let invalid = |reason: &str| Err(format!("sha256sum.txt line {line_no} {reason}")); + let Some((hash_hex, name)) = line.split_once(" ") else { + return invalid("is not ` `"); }; - let Some(path) = parts.next() else { - return Err(format!( - "sha256sum.txt line {} is missing filename", - line_no + 1 - )); - }; - if path != filename { - continue; - } - if found.is_some() { - return Err(format!( - "sha256sum.txt contains duplicate {filename} entries" - )); + if hash_hex.len() != 64 { + return invalid("does not start with a 64-digit sha256"); } let hash = hex::decode(hash_hex) - .map_err(|e| format!("sha256sum.txt {filename} hash is not valid hex: {e}"))?; - let hash: [u8; 32] = hash.try_into().map_err(|hash: Vec| { - format!( - "sha256sum.txt {filename} hash has invalid length {}, expected 32", - hash.len() - ) - })?; - found = Some(hash); + .map_err(|e| format!("sha256sum.txt line {line_no} digest is not valid hex: {e}"))?; + let hash: [u8; 32] = hash + .try_into() + .map_err(|_| format!("sha256sum.txt line {line_no} digest is not 32 bytes"))?; + if !is_flat_manifest_name(name) { + return invalid("does not name a plain file in the image root"); + } + if entries.iter().any(|entry| entry.name == name) { + return invalid("repeats a name listed earlier"); + } + entries.push(Sha256sumEntry { + hash, + name: name.to_string(), + }); } - found.ok_or_else(|| format!("sha256sum.txt is missing {filename}")) + Ok(entries) +} + +/// A manifest name is resolved relative to the image root by every consumer, so +/// it must be a plain file name: no directory components, no `.`/`..`, and no +/// character a checksum tool would strip, escape, or read as a mode marker. +fn is_flat_manifest_name(name: &str) -> bool { + !name.is_empty() + && name != "." + && name != ".." + && !name + .chars() + .any(|c| c == '/' || c == '\\' || c.is_whitespace() || c.is_control()) +} + +pub fn sha256sum_entry_hash(checksum_file: &[u8], filename: &str) -> Result<[u8; 32], String> { + parse_sha256sum_manifest(checksum_file)? + .into_iter() + .find(|entry| entry.name == filename) + .map(|entry| entry.hash) + .ok_or_else(|| format!("sha256sum.txt is missing {filename}")) } pub fn verify_measurement_material( @@ -1610,6 +1645,79 @@ pub fn verify_measurement_material( Ok(()) } +#[cfg(test)] +mod sha256sum_manifest_tests { + use super::*; + + const DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn manifest_entries_accept_only_the_canonical_sha256sum_line() { + let entries = + parse_sha256sum_manifest(format!("{DIGEST} metadata.json\n").as_bytes()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "metadata.json"); + + // Every one of these is a line `sha256sum -c` reads differently from a + // whitespace split: the binary-mode marker and the `./` prefix name a + // different file than the token does, a single space is an improperly + // formatted line that `sha256sum -c` skips while still exiting 0, and + // trailing junk becomes part of the name. + for line in [ + format!("{DIGEST} *metadata.json"), + format!("{DIGEST} ./metadata.json"), + format!("{DIGEST} metadata.json"), + format!("{DIGEST} metadata.json junk"), + format!("{DIGEST} metadata.json"), + format!("{DIGEST} metadata.json\r"), + format!("{DIGEST} ../metadata.json"), + format!("{DIGEST} sub/metadata.json"), + format!("{DIGEST} .."), + "00 metadata.json".to_string(), + format!("{DIGEST} "), + DIGEST.to_string(), + ] { + assert!( + parse_sha256sum_manifest(line.as_bytes()).is_err(), + "accepted {line:?}" + ); + } + } + + #[test] + fn manifest_rejects_duplicate_names() { + let doc = format!("{DIGEST} metadata.json\n{DIGEST} metadata.json\n"); + assert!(parse_sha256sum_manifest(doc.as_bytes()).is_err()); + } + + /// The entry lookup must read the same grammar as the manifest parser, or a + /// line one accepts and the other resolves to a different name lets the two + /// disagree about which bytes `os_image_hash` commits to. + #[test] + fn entry_lookup_rejects_lines_the_manifest_parser_rejects() { + let hash = sha256(b"payload"); + let doc = format!("{} measurement.tdx.cbor\n", hex::encode(hash)); + assert_eq!( + sha256sum_entry_hash(doc.as_bytes(), TDX_MEASUREMENT_FILENAME).unwrap(), + hash + ); + + for doc in [ + format!("{} measurement.tdx.cbor junk\n", hex::encode(hash)), + format!("{} *measurement.tdx.cbor\n", hex::encode(hash)), + format!( + "{} measurement.tdx.cbor\n{DIGEST} *measurement.tdx.cbor\n", + hex::encode(hash) + ), + ] { + assert!( + sha256sum_entry_hash(doc.as_bytes(), TDX_MEASUREMENT_FILENAME).is_err(), + "accepted {doc:?}" + ); + } + } +} + /// Image-invariant GCP TDX measurement material. GCP's TPM event log measures /// the UKI as a PE/COFF Authenticode SHA-256 digest. The unified image identity /// remains `sha256(sha256sum.txt)`; this material is bound to that identity by From b4539bf5acdc930f9ec213fd6769c7c0b19c50e3 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:44:40 -0700 Subject: [PATCH 2/7] fix(verifier): check the image manifest in-process instead of trusting sha256sum -c The whole content binding for a downloaded OS image was `sha256sum -c sha256sum.txt` plus its exit status. GNU coreutils skips an improperly formatted line with a warning and still exits 0: 5891b5... a.txt 0000...0000 b.txt <- one space $ sha256sum -c sha256sum.txt a.txt: OK sha256sum: WARNING: 1 line is improperly formatted exit=0 validate_image_manifest_paths split on whitespace, so it accepted that line, and prune_unlisted_image_files did too, so b.txt survived the prune. bzImage, ovmf.fd and the initrd were then measured with no content check at all. ` *bzImage` went wrong the other way: coreutils checks `bzImage`, the whitespace split sees `*bzImage`, and the file that was checked is the one that gets pruned. Parse the manifest once with dstack_types::parse_sha256sum_manifest and hash each listed file here. One parser, one grammar, and a missing or mismatched file is an error rather than a warning on someone else's stderr. --- dstack/verifier/src/verification.rs | 166 +++++++++++++++++----------- 1 file changed, 99 insertions(+), 67 deletions(-) diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 191736726..b24be0836 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -20,7 +20,7 @@ use cc_eventlog::{ use dstack_mr::{ tdx::TdxRtmr0AcpiHashes, RtmrLog, RtmrLogs, TdxMeasurementDetails, TdxMeasurements, }; -use dstack_types::{TdxAttestationVariant, VmConfig}; +use dstack_types::{Sha256sumEntry, TdxAttestationVariant, VmConfig}; use hex_literal::hex; use ra_tls::attestation::{ AppInfo, Attestation, AttestationQuote, AttestationVerifier, DstackVerifiedReport, NitroPcrs, @@ -28,7 +28,7 @@ use ra_tls::attestation::{ }; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; -use tokio::{io::AsyncWriteExt, process::Command}; +use tokio::io::AsyncWriteExt; use tracing::{debug, info, warn}; use crate::types::{ @@ -453,39 +453,34 @@ impl CvmVerifier { .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) } - /// A manifest name must be literally a file name, because - /// `prune_unlisted_image_files` matches manifest entries against the - /// `file_name()` of each top-level directory entry, and `sha256sum -c` - /// resolves them relative to the extraction root. - fn is_flat_manifest_name(name: &str) -> bool { - Path::new(name) - .file_name() - .is_some_and(|file_name| file_name == OsStr::new(name)) - } - - fn validate_image_manifest_paths(files_doc: &str) -> Result<()> { - for (line_index, line) in files_doc.lines().enumerate() { - if line.trim().is_empty() { - continue; - } - let mut fields = line.split_whitespace(); - let _digest = fields - .next() - .context("image manifest entry is missing a digest")?; - let name = fields - .next() - .context("image manifest entry is missing a path")?; - if fields.next().is_some() { - bail!("image manifest line {} has extra fields", line_index + 1); - } - if !Self::is_flat_manifest_name(name) { - bail!("image manifest line {} has an unsafe path", line_index + 1); - } - if name == "sha256sum.txt" { + /// Check every file the image manifest lists against the digest it binds. + /// + /// This is the whole content binding for a downloaded image: nothing else + /// looks at the bytes of `bzImage`, `ovmf.fd` or the initrd before they are + /// measured. It used to be `sha256sum -c`, whose exit status does not mean + /// what it looks like -- GNU coreutils skips an improperly formatted line + /// with a warning and still exits 0, so a line this process accepted and + /// coreutils did not left a file unchecked and still measured. Hashing the + /// files here keeps one parser and one grammar over the one file that + /// `os_image_hash` commits to. + fn verify_image_manifest(extracted_dir: &Path, files_doc: &str) -> Result> { + let entries = dstack_types::parse_sha256sum_manifest(files_doc.as_bytes()) + .map_err(anyhow::Error::msg) + .context("failed to parse the image manifest")?; + for entry in &entries { + if entry.name == "sha256sum.txt" { bail!("image manifest must not recursively list sha256sum.txt"); } + let mut file = fs_err::File::open(extracted_dir.join(&entry.name)) + .with_context(|| format!("image is missing manifest entry {}", entry.name))?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher) + .with_context(|| format!("failed to read manifest entry {}", entry.name))?; + if hasher.finalize().as_slice() != entry.hash { + bail!("{} does not match its digest in sha256sum.txt", entry.name); + } } - Ok(()) + Ok(entries) } fn extract_image_archive(tarball_path: &Path, extracted_dir: &Path) -> Result<()> { @@ -520,12 +515,11 @@ impl CvmVerifier { Ok(()) } - fn prune_unlisted_image_files(extracted_dir: &Path, files_doc: &str) -> Result<()> { - let listed_files: Vec<&OsStr> = files_doc - .lines() - .flat_map(|line| line.split_whitespace().nth(1)) - .map(|s| s.as_ref()) - .collect(); + fn prune_unlisted_image_files(extracted_dir: &Path, manifest: &[Sha256sumEntry]) -> Result<()> { + let listed_files: Vec<&OsStr> = manifest + .iter() + .map(|entry| entry.name.as_ref()) + .collect::>(); let files = fs_err::read_dir(extracted_dir).context("Failed to read directory")?; for file in files { let file = file.context("Failed to read directory entry")?; @@ -1330,26 +1324,10 @@ impl CvmVerifier { let sha256sum_path = extracted_dir.join("sha256sum.txt"); let files_doc = fs_err::read_to_string(&sha256sum_path).context("Failed to read sha256sum.txt")?; - Self::validate_image_manifest_paths(&files_doc)?; - - // Verify checksum - let output = Command::new("sha256sum") - .arg("-c") - .arg("sha256sum.txt") - .current_dir(&extracted_dir) - .output() - .await - .context("Failed to verify checksum")?; - - if !output.status.success() { - bail!( - "Checksum verification failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + let manifest = Self::verify_image_manifest(&extracted_dir, &files_doc)?; // Remove the files that are not listed in sha256sum.txt - Self::prune_unlisted_image_files(&extracted_dir, &files_doc)?; + Self::prune_unlisted_image_files(&extracted_dir, &manifest)?; // All image modes are addressed by sha256(sha256sum.txt). Extra // measurement CBOR files are ordinary sha256sum.txt entries and do not @@ -2004,21 +1982,77 @@ mod tests { assert_eq!(entries.len(), 1, "temporary cache files must not survive"); } - #[test] - fn image_cache_pruning_keeps_checksum_identity() { + /// Lay out an extracted image whose manifest is `files_doc`, with each + /// named file holding the payload given for it. + fn image_dir_with_manifest(files_doc: &str, files: &[(&str, &[u8])]) -> tempfile::TempDir { let dir = tempfile::tempdir().expect("temp image directory"); - let files_doc = "00 metadata.json\n"; fs_err::write(dir.path().join("sha256sum.txt"), files_doc).unwrap(); - fs_err::write(dir.path().join("metadata.json"), "{}").unwrap(); - fs_err::write(dir.path().join("unmeasured"), "remove me").unwrap(); + for (name, payload) in files { + fs_err::write(dir.path().join(name), payload).unwrap(); + } + dir + } + + #[test] + fn image_cache_pruning_keeps_checksum_identity() { + let files_doc = format!("{} metadata.json\n", hex::encode(Sha256::digest(b"{}"))); + let dir = image_dir_with_manifest( + &files_doc, + &[("metadata.json", b"{}"), ("unmeasured", b"remove me")], + ); - CvmVerifier::prune_unlisted_image_files(dir.path(), files_doc).unwrap(); + let manifest = CvmVerifier::verify_image_manifest(dir.path(), &files_doc).unwrap(); + CvmVerifier::prune_unlisted_image_files(dir.path(), &manifest).unwrap(); assert!(dir.path().join("sha256sum.txt").exists()); assert!(dir.path().join("metadata.json").exists()); assert!(!dir.path().join("unmeasured").exists()); } + /// The manifest is the only thing binding the downloaded bytes to + /// `os_image_hash`, so a line that does not check out has to stop the + /// image -- including the lines GNU `sha256sum -c` skips with a warning + /// while still exiting 0, which is how an unchecked file used to reach the + /// measurement step. + #[test] + fn every_manifest_entry_is_checked_before_the_image_is_accepted() { + let good = hex::encode(Sha256::digest(b"{}")); + let zero = "00".repeat(32); + let listed_but_wrong = format!("{good} metadata.json\n{zero} bzImage\n"); + let files: &[(&str, &[u8])] = &[("metadata.json", b"{}"), ("bzImage", b"kernel")]; + + for files_doc in [ + // The digest is simply wrong. + listed_but_wrong.clone(), + // One space, not two: an improperly formatted line that + // `sha256sum -c` warns about and skips, exit 0. + listed_but_wrong.replace(&format!("{zero} bzImage"), &format!("{zero} bzImage")), + // Binary-mode marker: `sha256sum -c` checks `bzImage`, a + // whitespace split sees a file named `*bzImage`. + listed_but_wrong.replace(&format!("{zero} bzImage"), &format!("{zero} *bzImage")), + // Listed but absent. + format!("{good} metadata.json\n{zero} missing\n"), + ] { + let dir = image_dir_with_manifest(&files_doc, files); + assert!( + CvmVerifier::verify_image_manifest(dir.path(), &files_doc).is_err(), + "accepted {files_doc:?}" + ); + } + + let good_doc = format!( + "{good} metadata.json\n{} bzImage\n", + hex::encode(Sha256::digest(b"kernel")) + ); + let dir = image_dir_with_manifest(&good_doc, files); + assert_eq!( + CvmVerifier::verify_image_manifest(dir.path(), &good_doc) + .unwrap() + .len(), + 2 + ); + } + #[test] fn image_paths_must_be_confined_and_manifest_paths_must_be_flat() { for path in ["../escape", "/absolute", "nested/../escape"] { @@ -2038,10 +2072,6 @@ mod tests { } let digest = "00".repeat(32); - assert!( - CvmVerifier::validate_image_manifest_paths(&format!("{digest} metadata.json\n")) - .is_ok() - ); for path in [ "../escape", "/absolute", @@ -2050,8 +2080,10 @@ mod tests { ".", "sha256sum.txt", ] { + let files_doc = format!("{digest} {path}\n"); + let dir = image_dir_with_manifest(&files_doc, &[]); assert!( - CvmVerifier::validate_image_manifest_paths(&format!("{digest} {path}\n")).is_err(), + CvmVerifier::verify_image_manifest(dir.path(), &files_doc).is_err(), "{path}" ); } From 96ca6cace3eccdf4bebcdaf2db3a163082ed152a Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:46:30 -0700 Subject: [PATCH 3/7] fix(verifier): give the --debug RTMR diff the events it is diffing collect_rtmr_mismatch takes the indices of the event-log entries that extended the register, and all three call sites passed `&[]`. Both loops inside iterate that slice, so `events` came back empty and `missing_expected_digests` held the entire expected sequence -- for every mismatch, on every RTMR. The whole RtmrEventEntry / RtmrEventStatus machinery was unreachable, and `--debug` answered a question the caller had already been told: the two register values. Pass the indices of the log entries whose `imr` is the register being diffed, in log order, which is what the function's zip against the expected sequence expects. --- dstack/verifier/src/types.rs | 2 +- dstack/verifier/src/verification.rs | 93 ++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/dstack/verifier/src/types.rs b/dstack/verifier/src/types.rs index d9d71442b..c89033564 100644 --- a/dstack/verifier/src/types.rs +++ b/dstack/verifier/src/types.rs @@ -150,7 +150,7 @@ pub struct RtmrEventEntry { pub status: RtmrEventStatus, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RtmrEventStatus { Match, diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index b24be0836..1bf561a53 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -88,6 +88,20 @@ fn decode_key_provider_info(bytes: &[u8]) -> Option Vec { + event_log + .iter() + .enumerate() + .filter(|(_, event)| event.imr == rtmr) + .map(|(index, _)| index) + .collect() +} + fn collect_rtmr_mismatch( rtmr_label: &str, expected: &[u8], @@ -1112,7 +1126,7 @@ impl CvmVerifier { &expected_mrs.rtmr0, &verified_mrs.rtmr0, &expected_logs[0], - &[], + &rtmr_event_indices(event_log, 0), event_log, )); } @@ -1123,7 +1137,7 @@ impl CvmVerifier { &expected_mrs.rtmr1, &verified_mrs.rtmr1, &expected_logs[1], - &[], + &rtmr_event_indices(event_log, 1), event_log, )); } @@ -1134,7 +1148,7 @@ impl CvmVerifier { &expected_mrs.rtmr2, &verified_mrs.rtmr2, &expected_logs[2], - &[], + &rtmr_event_indices(event_log, 2), event_log, )); } @@ -1982,6 +1996,79 @@ mod tests { assert_eq!(entries.len(), 1, "temporary cache files must not survive"); } + /// `--debug` exists to say *which* event moved an RTMR. The diff has to + /// line the quote's event log up with the expected sequence to do that; a + /// report that only restates the two register values and calls every + /// expected digest missing is the answer the caller already had. + #[test] + fn the_debug_rtmr_diff_lines_events_up_with_the_expected_sequence() { + let event = |imr: u32, name: &str, digest: u8| TdxEvent { + imr, + event_type: 1, + digest: vec![digest; 48], + event: name.to_string(), + event_payload: vec![digest], + version: Default::default(), + preimage: None, + }; + // Two RTMR1 events between RTMR0 and RTMR2 ones, so a diff that + // ignores `imr` cannot land on the right pair by accident. + let event_log = vec![ + event(0, "rtmr0-first", 0xa0), + event(1, "kernel", 0xb0), + event(1, "kernel-cmdline", 0xb1), + event(2, "rtmr2-first", 0xc0), + ]; + let expected_logs: RtmrLogs = [ + vec![vec![0xa0; 48]], + // The first expected digest matches the quoted event, the second + // does not, and a third was never extended. + vec![vec![0xb0; 48], vec![0xff; 48], vec![0xee; 48]], + vec![vec![0xc0; 48]], + ]; + let mrs = |rtmr1: u8| Mrs { + mrtd: vec![0; 48], + rtmr0: vec![0; 48], + rtmr1: vec![rtmr1; 48], + rtmr2: vec![0; 48], + }; + + let mut details = VerificationDetails::default(); + let err = test_verifier() + .compare_tdx_mrs( + mrs(1), + mrs(2), + Some(&expected_logs), + &event_log, + true, + &mut details, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("RTMR1 mismatch")); + + let debug = details.rtmr_debug.expect("a debug diff"); + assert_eq!(debug.len(), 1, "only RTMR1 differs"); + let rtmr1 = &debug[0]; + assert_eq!(rtmr1.rtmr, "RTMR1"); + let reported: Vec<_> = rtmr1 + .events + .iter() + .map(|entry| (entry.index, entry.event_name.as_str(), entry.status)) + .collect(); + assert_eq!( + reported, + vec![ + (1, "kernel", RtmrEventStatus::Match), + (2, "kernel-cmdline", RtmrEventStatus::Mismatch), + ] + ); + assert_eq!( + rtmr1.missing_expected_digests, + vec![hex::encode([0xee; 48])], + "only the digest with no event left is missing" + ); + } + /// Lay out an extracted image whose manifest is `files_doc`, with each /// named file holding the payload given for it. fn image_dir_with_manifest(files_doc: &str, files: &[(&str, &[u8])]) -> tempfile::TempDir { From ee44ae018eff5544e0d952220e6bcf32ca25ccf0 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:49:16 -0700 Subject: [PATCH 4/7] fix(verifier): stop reporting --verify-cert results valid when app info does not decode `is_valid` was the literal `true` for any certificate whose RA-TLS attestation verified, and `decode_app_info(false).ok()` threw the decode error away. A certificate carrying an attestation that verifies but whose app identity does not decode printed a valid result with `app_info: null` -- no app_id, no compose_hash, no os_image_hash -- and exited 0. Report it the way `/verify` does: `is_valid` plus a `reason`, and a non-zero exit. The result file is still written first, so a caller inspecting it sees which check failed. --- dstack/verifier/README.md | 11 +-- dstack/verifier/src/main.rs | 130 ++++++++++++++++++++++++++++++------ 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/dstack/verifier/README.md b/dstack/verifier/README.md index 8529dc1f6..067e59388 100644 --- a/dstack/verifier/README.md +++ b/dstack/verifier/README.md @@ -141,9 +141,11 @@ certificate alone: cargo run --bin dstack-verifier -- --verify-cert endpoint-cert.pem ``` -The input may be PEM or DER. On success, the verifier prints JSON and writes the -same result next to the input as `endpoint-cert.pem.ratls-verification.json`. -The verification checks that: +The input may be PEM or DER. The verifier prints JSON and writes the same result +next to the input as `endpoint-cert.pem.ratls-verification.json`; `is_valid` +says whether every check below passed, `reason` says which one did not, and the +exit status is non-zero when `is_valid` is `false`. The verification checks +that: 1. the certificate contains a dstack RA-TLS attestation extension; 2. the embedded attestation verifies against the platform root, including AWS @@ -151,7 +153,8 @@ The verification checks that: 3. the attestation `report_data` is `QuoteContentType::RaTlsCert(SubjectPublicKeyInfo)`, so the verified attestation is bound to this exact TLS public key; and -4. the reported `app_info.os_image_hash` is bound to the attested boot +4. the attested app identity decodes; and +5. the reported `app_info.os_image_hash` is bound to the attested boot measurement, surfaced as `app_info.os_image_hash_verified`. This binding is self-contained (no image download) for AWS NitroTPM, SEV-SNP, Nitro Enclave, GCP TDX, and TDX lite. It is reported as `false` for the TDX legacy diff --git a/dstack/verifier/src/main.rs b/dstack/verifier/src/main.rs index 44e29e8ba..b02e04762 100644 --- a/dstack/verifier/src/main.rs +++ b/dstack/verifier/src/main.rs @@ -220,32 +220,20 @@ async fn run_cert_oneshot(file_path: &str, config: &Config) -> anyhow::Result<() .await .map_err(|e| anyhow::anyhow!("failed to verify RA-TLS certificate: {:#}", e))?; - let app_info = verified.decode_app_info(false).ok(); + let app_info = verified.decode_app_info(false); // Bind the reported os_image_hash to the attested boot measurement. For // every platform except TDX legacy this is a self-contained check (no image // download); relying parties should only trust `os_image_hash` when // `os_image_hash_verified` is true. let os_image_hash_verified = verify_cert_os_image_hash(&verified.attestation, config, &attestation_verifier).await; - let output = serde_json::json!({ - "is_valid": true, - "details": { - "tee_variant": verified.attestation.quote.variant(), - "report_data": hex::encode(verified.attestation.report_data), - "public_key_der": hex::encode(&verified.public_key_der), - "app_info": app_info.map(|info| serde_json::json!({ - "app_id": hex::encode(info.app_id), - "compose_hash": hex::encode(info.compose_hash), - "instance_id": hex::encode(info.instance_id), - "device_id": hex::encode(info.device_id), - "mr_system": hex::encode(info.mr_system), - "mr_aggregated": hex::encode(info.mr_aggregated), - "os_image_hash": hex::encode(info.os_image_hash), - "os_image_hash_verified": os_image_hash_verified, - "key_provider_info": hex::encode(info.key_provider_info), - })), - } - }); + let output = cert_oneshot_result( + verified.attestation.quote.variant(), + &verified.attestation.report_data, + &verified.public_key_der, + app_info, + os_image_hash_verified, + ); let output_path = format!("{file_path}.ratls-verification.json"); fs::write(&output_path, serde_json::to_string_pretty(&output)?).map_err(|e| { @@ -258,9 +246,53 @@ async fn run_cert_oneshot(file_path: &str, config: &Config) -> anyhow::Result<() info!("stored certificate verification result at {}", output_path); println!("{}", serde_json::to_string_pretty(&output)?); + if output["is_valid"] != serde_json::json!(true) { + anyhow::bail!("{}", output["reason"].as_str().unwrap_or("invalid")); + } Ok(()) } +/// Render the `--verify-cert` result. +/// +/// `is_valid` is the one field a relying party reads, so it has to cover every +/// step that ran, not just the signature check. App-info decoding is one of +/// those steps: a certificate whose attested app identity does not decode has +/// no `app_id`, `compose_hash` or `os_image_hash` to act on, and reporting it +/// as valid with a null `app_info` puts the burden of noticing on the caller. +/// Same shape as the HTTP `/verify` response: `is_valid` plus a `reason`. +fn cert_oneshot_result( + tee_variant: ra_tls::attestation::TeeVariant, + report_data: &[u8], + public_key_der: &[u8], + app_info: Result, + os_image_hash_verified: bool, +) -> serde_json::Value { + let reason = app_info + .as_ref() + .err() + .map(|err| format!("failed to decode app info from the certificate: {err:#}")); + serde_json::json!({ + "is_valid": reason.is_none(), + "reason": reason, + "details": { + "tee_variant": tee_variant, + "report_data": hex::encode(report_data), + "public_key_der": hex::encode(public_key_der), + "app_info": app_info.ok().map(|info| serde_json::json!({ + "app_id": hex::encode(info.app_id), + "compose_hash": hex::encode(info.compose_hash), + "instance_id": hex::encode(info.instance_id), + "device_id": hex::encode(info.device_id), + "mr_system": hex::encode(info.mr_system), + "mr_aggregated": hex::encode(info.mr_aggregated), + "os_image_hash": hex::encode(info.os_image_hash), + "os_image_hash_verified": os_image_hash_verified, + "key_provider_info": hex::encode(info.key_provider_info), + })), + } + }) +} + /// Verify that an RA-TLS certificate's `os_image_hash` is bound to its attested /// boot measurement. /// @@ -434,6 +466,64 @@ image_download_timeout_secs = 7 } } +#[cfg(test)] +mod cert_oneshot_result_tests { + use super::*; + use ra_tls::attestation::{AppInfo, TeeVariant}; + + fn app_info() -> AppInfo { + serde_json::from_value(serde_json::json!({ + "app_id": "00".repeat(20), + "compose_hash": "11".repeat(32), + "instance_id": "22".repeat(20), + "device_id": "33".repeat(32), + "mr_system": "44".repeat(32), + "mr_aggregated": "55".repeat(32), + "os_image_hash": "66".repeat(32), + "key_provider_info": "", + })) + .unwrap() + } + + /// `is_valid` is the field a relying party branches on, so it must not say + /// "valid" about a certificate whose app identity never decoded -- the + /// result then carries no app_id, compose_hash or os_image_hash at all. + #[test] + fn a_certificate_whose_app_info_does_not_decode_is_not_reported_valid() { + let valid = cert_oneshot_result( + TeeVariant::DstackTdx, + &[0u8; 64], + b"spki", + Ok(app_info()), + true, + ); + assert_eq!(valid["is_valid"], serde_json::json!(true)); + assert_eq!(valid["reason"], serde_json::Value::Null); + assert_eq!( + valid["details"]["app_info"]["compose_hash"], + "11".repeat(32) + ); + + let undecodable = cert_oneshot_result( + TeeVariant::DstackTdx, + &[0u8; 64], + b"spki", + Err(anyhow::anyhow!("no app-id event")), + true, + ); + assert_eq!(undecodable["is_valid"], serde_json::json!(false)); + assert!(undecodable["reason"] + .as_str() + .unwrap() + .contains("no app-id event")); + assert_eq!( + undecodable["details"]["app_info"], + serde_json::Value::Null, + "there is no app info to report" + ); + } +} + #[cfg(test)] mod certificate_profile_tests { use super::*; From 13836a2e4b4efdf6df895789163bd6af1b4e48e4 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:49:46 -0700 Subject: [PATCH 5/7] docs(verifier): say what event_log_verified actually covers The field documented itself as verifying RTMR 0-2 digests "through replay comparison with the quote". Nothing in dstack-verifier or dstack-attest replays a boot-time event log: the flag is set once the runtime event log replays to its register and the app identity decodes out of its payloads. A TDX quote's RTMR 0-2 entries reach only the `--debug` diff and the three named ACPI digests the lite path cross-checks. Correct the comment rather than the code. Replaying RTMR 0-2 would add nothing: those registers are already checked against measurements recomputed from the OS image, which does not trust the host's event log at all, and is the stronger of the two checks. --- dstack/verifier/README.md | 4 ++-- dstack/verifier/src/types.rs | 20 ++++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/dstack/verifier/README.md b/dstack/verifier/README.md index 067e59388..6761c62e1 100644 --- a/dstack/verifier/README.md +++ b/dstack/verifier/README.md @@ -35,7 +35,7 @@ against the returned evidence. "is_valid": true, "details": { "quote_verified": true, - "event_log_verified": true, // See "Verification Process" for semantics + "event_log_verified": true, // runtime event log only; see "Verification Process" "os_image_hash_verified": true, "acpi_tables_verified": true, // true only when TDX ACPI table contents are verified "os_image_is_dev": false, // true=dev image, false=prod, null=unknown/N/A @@ -240,7 +240,7 @@ $ curl -s -d @quote.json localhost:8080/verify | jq The verifier performs the following verification steps: 1. **Quote Verification**: Validates the platform quote using the platform verifier: DCAP for TDX, AMD SNP report verification for SEV-SNP, NSM for Nitro Enclaves, and AWS NitroTPM attestation-document verification for EC2 NitroTPM. -2. **Event Log Verification**: Replays event logs to ensure RTMR/PCR values match and extracts app information. For RTMR3 and AWS NitroTPM PCR14 launch measurements, both the digest and payload integrity are verified. For TDX RTMR 0-2 boot-time measurements, only the digests are verified; the payload content is not validated as dstack does not define semantics for these payloads. +2. **Event Log Verification**: Replays the runtime event log — RTMR3 on TDX, the corresponding launch measurement on SEV-SNP and AWS NitroTPM PCR14 — to ensure it reproduces the quoted register, and extracts app information from its payloads. Both the digests and the payloads are verified there, because `app_id`, `compose_hash` and the rest are read out of them. `event_log_verified` reports that step only. The boot-time event log a TDX quote carries is not replayed: RTMR 0-2 are verified in step 3 by comparing the quoted values against measurements recomputed from the OS image, which does not depend on the host's event log, and dstack defines no semantics for its payloads. 3. **OS Image Hash Verification**: - Treats `vm_config` and any attached measurement material as untrusted inputs until they are bound to the hardware quote - For the full-image TDX path, downloads or loads the image identified by `os_image_hash`, checks the image checksum manifest, uses dstack-mr to compute expected MRTD/RTMR0-2, and compares them against the verified measurements from the quote diff --git a/dstack/verifier/src/types.rs b/dstack/verifier/src/types.rs index c89033564..1755c8699 100644 --- a/dstack/verifier/src/types.rs +++ b/dstack/verifier/src/types.rs @@ -79,13 +79,21 @@ impl PolicyBootInfo { #[derive(Debug, Clone, Default, Serialize)] pub struct VerificationDetails { pub quote_verified: bool, - /// Indicates that the event log was verified against the quote. + /// Indicates that the runtime event log was replayed against the quote and + /// the app identity was decoded from it. /// - /// For RTMR3 (runtime measurements), both the digest and payload integrity are verified - /// by replaying the event log and comparing against the quote. For RTMR 0-2 (boot-time - /// measurements), only the digests are verified through replay comparison with the quote; - /// the payload content is not validated. dstack does not define semantics for RTMR 0-2 - /// event log payloads. + /// That is RTMR3 on TDX, and the corresponding launch PCR on SEV-SNP and + /// AWS NitroTPM: both the digests and the payloads are verified, because + /// `app_id`, `compose_hash` and the rest are read out of those payloads. + /// + /// It says nothing about the boot-time event log. Nothing replays the + /// RTMR 0-2 entries a TDX quote carries, and that is deliberate: those + /// registers are verified by comparing the quoted values against + /// measurements recomputed from the OS image (see `os_image_hash_verified` + /// and `acpi_tables_verified`), which does not depend on the host's event + /// log at all. The boot event log is carried for diagnostics -- the + /// `--debug` RTMR diff and the three named ACPI digests the TDX lite path + /// cross-checks -- and dstack defines no semantics for its payloads. pub event_log_verified: bool, pub os_image_hash_verified: bool, /// Indicates that TDX ACPI table contents were verified. From bb607ddc6228d04701722f9d945bbf1cacb0f8f4 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:51:31 -0700 Subject: [PATCH 6/7] 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 | 106 +++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 1bf561a53..59164a3e3 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -252,8 +252,26 @@ impl CvmVerifier { .join(format!("{cache_key}.json")) } + /// Key the measurement cache on the VM shape the measurement is computed + /// from, and nothing else. + /// + /// `vm_config` comes from the request, and a miss costs a full firmware and + /// kernel hash plus ACPI generation, and leaves a file under + /// `/measurements/` that nothing evicts. Hashing fields the + /// computation never reads -- an arbitrary `image` string, or the + /// attacker-sized `checksum_file`/CBOR blobs in the measurement documents, + /// which the full-image path does not touch -- lets one captured quote be + /// replayed with a different filler byte each time and miss every time. + /// + /// Clear those fields rather than listing the ones that matter, so a field + /// added to `VmConfig` later is part of the key by default. fn vm_config_cache_key(vm_config: &VmConfig) -> Result { - let serialized = serde_json::to_vec(vm_config) + let mut vm_config = vm_config.clone(); + vm_config.image = None; + vm_config.tdx_measurement = None; + vm_config.gcp_measurement = None; + vm_config.aws_measurement = None; + let serialized = serde_json::to_vec(&vm_config) .context("Failed to serialize VM config for cache key computation")?; Ok(hex::encode(Sha256::digest(&serialized))) } @@ -2069,6 +2087,92 @@ mod tests { ); } + /// A cache miss is a full firmware and kernel hash plus ACPI generation, + /// and a file under `/measurements/` that nothing evicts. The key + /// must therefore be the VM shape the measurement reads -- otherwise one + /// captured quote replayed with a different filler byte per request misses + /// every time and grows the cache without bound. + #[test] + fn the_measurement_cache_key_covers_the_measured_vm_shape_and_nothing_else() { + 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_of = |config: &VmConfig| CvmVerifier::vm_config_cache_key(config).unwrap(); + let key = key_of(&base); + + // Nothing here reaches dstack_mr::Machine on the full-image path, and + // the measurement documents carry caller-sized blobs. + let filler = |mutate: fn(&mut VmConfig)| { + let mut config = base.clone(); + mutate(&mut config); + config + }; + for (name, config) in [ + ("image", filler(|c| c.image = Some("x".repeat(4096)))), + ( + "tdx_measurement", + filler(|c| { + c.tdx_measurement = Some(dstack_types::TdxOsImageMeasurementDocument::new( + vec![0xaa; 4096], + vec![0xbb; 4096], + )) + }), + ), + ( + "gcp_measurement", + filler(|c| { + c.gcp_measurement = Some(dstack_types::GcpOsImageMeasurementDocument::new( + vec![0xaa; 4096], + vec![0xbb; 4096], + )) + }), + ), + ( + "aws_measurement", + filler(|c| { + c.aws_measurement = Some(dstack_types::AwsOsImageMeasurementDocument::new( + vec![0xaa; 4096], + vec![0xbb; 4096], + )) + }), + ), + ] { + assert_eq!(key_of(&config), key, "{name} moved the cache key"); + } + + // Everything the computation does read has to keep moving it. + for (name, config) in [ + ( + "os_image_hash", + filler(|c| c.os_image_hash = vec![0x22; 32]), + ), + ("cpu_count", filler(|c| c.cpu_count += 1)), + ("memory_size", filler(|c| c.memory_size += 0x1000)), + ("num_gpus", filler(|c| c.num_gpus += 1)), + ("num_nics", filler(|c| c.num_nics += 1)), + ("swtpm", filler(|c| c.swtpm = true)), + ("hugepages", filler(|c| c.hugepages = true)), + ("hotplug_off", filler(|c| c.hotplug_off = true)), + ( + "host_share_mode", + filler(|c| c.host_share_mode = "hd2".into()), + ), + ( + "qemu_version", + filler(|c| c.qemu_version = Some("10.2".into())), + ), + ( + "ovmf_variant", + filler(|c| c.ovmf_variant = Some(Default::default())), + ), + ] { + assert_ne!(key_of(&config), key, "{name} did not move the cache key"); + } + } + /// Lay out an extracted image whose manifest is `files_doc`, with each /// named file holding the payload given for it. fn image_dir_with_manifest(files_doc: &str, files: &[(&str, &[u8])]) -> tempfile::TempDir { From fabe6d5429269e6212f70e9c64e75ca478e132a9 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:54:09 -0700 Subject: [PATCH 7/7] refactor(verifier): measure the OS image on a blocking thread compute_measurement_details hashes the firmware and the kernel and generates the ACPI tables -- pure CPU over a multi-hundred-megabyte image -- and was called synchronously from three `async fn`s, so it parked a Rocket worker for the whole computation. tpm-qvl already fetches collateral through spawn_blocking for the same reason. Collect the six arguments the three call sites passed identically into an owned MeasurementInputs and run `measure` through spawn_blocking. The cache lookup and store stay on the async side: they are small file operations, and keeping them there avoids cloning the verifier into the task. --- dstack/verifier/src/verification.rs | 195 +++++++++++++--------------- 1 file changed, 88 insertions(+), 107 deletions(-) diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 59164a3e3..fbe2e7da4 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -221,6 +221,78 @@ struct ImagePaths { kernel_header_normalized: bool, } +/// Everything `dstack_mr::Machine` reads to produce the expected measurements, +/// owned so it can move onto a blocking thread. +/// +/// Measuring an image hashes the firmware and the kernel and generates the ACPI +/// tables -- tens of milliseconds of pure CPU on a multi-hundred-megabyte +/// image. Running that inline in an `async fn` parks a Rocket worker for the +/// duration, so it goes through `spawn_blocking`, the way `tpm-qvl` already +/// fetches collateral. +#[derive(Clone)] +struct MeasurementInputs { + vm_config: VmConfig, + fw_path: PathBuf, + kernel_path: PathBuf, + initrd_path: PathBuf, + kernel_cmdline: String, + kernel_header_normalized: bool, +} + +impl MeasurementInputs { + fn new(vm_config: &VmConfig, image_paths: &ImagePaths) -> Self { + Self { + vm_config: vm_config.clone(), + fw_path: image_paths.fw_path.clone(), + kernel_path: image_paths.kernel_path.clone(), + initrd_path: image_paths.initrd_path.clone(), + kernel_cmdline: image_paths.kernel_cmdline.clone(), + kernel_header_normalized: image_paths.kernel_header_normalized, + } + } + + fn measure(&self) -> Result { + let vm_config = &self.vm_config; + let firmware = self.fw_path.display().to_string(); + let kernel = self.kernel_path.display().to_string(); + let initrd = self.initrd_path.display().to_string(); + + // Prefer the explicit variant the image declared; pre-`ovmf_variant` + // deployments fall back to the only layout that existed back then. + let ovmf_variant = vm_config.ovmf_variant.unwrap_or_default(); + + dstack_mr::Machine::builder() + .cpu_count(vm_config.cpu_count) + .memory_size(vm_config.memory_size) + .firmware(&firmware) + .kernel(&kernel) + .initrd(&initrd) + .kernel_cmdline(&self.kernel_cmdline) + .root_verity(true) + .hotplug_off(vm_config.hotplug_off) + .maybe_two_pass_add_pages(vm_config.qemu_single_pass_add_pages) + .normalized_setup_header(self.kernel_header_normalized) + .maybe_pic(vm_config.pic) + .maybe_qemu_version(vm_config.qemu_version.clone()) + .maybe_pci_hole64_size(if vm_config.pci_hole64_size > 0 { + Some(vm_config.pci_hole64_size) + } else { + None + }) + .hugepages(vm_config.hugepages) + .num_gpus(vm_config.num_gpus) + .num_nics(vm_config.num_nics) + .num_verity_volumes(vm_config.num_verity_volumes) + .swtpm(vm_config.swtpm) + .num_nvswitches(vm_config.num_nvswitches) + .host_share_mode(vm_config.host_share_mode.clone()) + .ovmf_variant(ovmf_variant) + .build() + .measure_with_logs() + .context("Failed to compute expected MRs") + } +} + pub struct CvmVerifier { pub image_cache_dir: String, pub download_url: String, @@ -344,99 +416,26 @@ impl CvmVerifier { Ok(()) } - fn compute_measurement_details( + async fn compute_measurement_details( &self, - vm_config: &VmConfig, - fw_path: &Path, - kernel_path: &Path, - initrd_path: &Path, - kernel_cmdline: &str, - kernel_header_normalized: bool, + inputs: MeasurementInputs, ) -> Result { - let firmware = fw_path.display().to_string(); - let kernel = kernel_path.display().to_string(); - let initrd = initrd_path.display().to_string(); - - // Prefer the explicit variant the image declared; pre-`ovmf_variant` - // deployments fall back to the only layout that existed back then. - let ovmf_variant = vm_config.ovmf_variant.unwrap_or_default(); - - let details = dstack_mr::Machine::builder() - .cpu_count(vm_config.cpu_count) - .memory_size(vm_config.memory_size) - .firmware(&firmware) - .kernel(&kernel) - .initrd(&initrd) - .kernel_cmdline(kernel_cmdline) - .root_verity(true) - .hotplug_off(vm_config.hotplug_off) - .maybe_two_pass_add_pages(vm_config.qemu_single_pass_add_pages) - .normalized_setup_header(kernel_header_normalized) - .maybe_pic(vm_config.pic) - .maybe_qemu_version(vm_config.qemu_version.clone()) - .maybe_pci_hole64_size(if vm_config.pci_hole64_size > 0 { - Some(vm_config.pci_hole64_size) - } else { - None - }) - .hugepages(vm_config.hugepages) - .num_gpus(vm_config.num_gpus) - .num_nics(vm_config.num_nics) - .num_verity_volumes(vm_config.num_verity_volumes) - .swtpm(vm_config.swtpm) - .num_nvswitches(vm_config.num_nvswitches) - .host_share_mode(vm_config.host_share_mode.clone()) - .ovmf_variant(ovmf_variant) - .build() - .measure_with_logs() - .context("Failed to compute expected MRs")?; - - Ok(details) + tokio::task::spawn_blocking(move || inputs.measure()) + .await + .context("expected-measurement task failed")? } - fn compute_measurements( + async fn load_or_compute_measurements( &self, - vm_config: &VmConfig, - fw_path: &Path, - kernel_path: &Path, - initrd_path: &Path, - kernel_cmdline: &str, - kernel_header_normalized: bool, + inputs: MeasurementInputs, ) -> Result { - self.compute_measurement_details( - vm_config, - fw_path, - kernel_path, - initrd_path, - kernel_cmdline, - kernel_header_normalized, - ) - .map(|details| details.measurements) - } - - fn load_or_compute_measurements( - &self, - vm_config: &VmConfig, - fw_path: &Path, - kernel_path: &Path, - initrd_path: &Path, - kernel_cmdline: &str, - kernel_header_normalized: bool, - ) -> Result { - let cache_key = Self::vm_config_cache_key(vm_config)?; + let cache_key = Self::vm_config_cache_key(&inputs.vm_config)?; if let Some(measurements) = self.load_measurements_from_cache(&cache_key)? { return Ok(measurements); } - let measurements = self.compute_measurements( - vm_config, - fw_path, - kernel_path, - initrd_path, - kernel_cmdline, - kernel_header_normalized, - )?; + let measurements = self.compute_measurement_details(inputs).await?.measurements; if let Err(e) = self.store_measurements_in_cache(&cache_key, &measurements) { warn!( @@ -699,14 +698,8 @@ impl CvmVerifier { ) -> Result { let image_paths = self.ensure_image_downloaded(vm_config).await?; - self.load_or_compute_measurements( - vm_config, - &image_paths.fw_path, - &image_paths.kernel_path, - &image_paths.initrd_path, - &image_paths.kernel_cmdline, - image_paths.kernel_header_normalized, - ) + self.load_or_compute_measurements(MeasurementInputs::new(vm_config, &image_paths)) + .await } pub async fn verify(&self, request: VerificationRequest) -> Result { @@ -978,14 +971,8 @@ impl CvmVerifier { rtmr_logs, acpi_tables, } = self - .compute_measurement_details( - vm_config, - &image_paths.fw_path, - &image_paths.kernel_path, - &image_paths.initrd_path, - &image_paths.kernel_cmdline, - image_paths.kernel_header_normalized, - ) + .compute_measurement_details(MeasurementInputs::new(vm_config, &image_paths)) + .await .context("Failed to compute expected measurements")?; details.acpi_tables = Some(AcpiTables { @@ -997,15 +984,9 @@ impl CvmVerifier { (measurements, Some(rtmr_logs)) } else { ( - self.load_or_compute_measurements( - vm_config, - &image_paths.fw_path, - &image_paths.kernel_path, - &image_paths.initrd_path, - &image_paths.kernel_cmdline, - image_paths.kernel_header_normalized, - ) - .context("Failed to compute expected measurements")?, + self.load_or_compute_measurements(MeasurementInputs::new(vm_config, &image_paths)) + .await + .context("Failed to compute expected measurements")?, None, ) };