diff --git a/docs/security/security-model.md b/docs/security/security-model.md index 5a0491867..751dbdc79 100644 --- a/docs/security/security-model.md +++ b/docs/security/security-model.md @@ -317,6 +317,45 @@ AML access to encrypted/private guest RAM. Verification now rejects tampered tables before the CVM is trusted with keys; the sandbox bounds what tampered AML could have done in the first place. +### The kernel measurement does not depend on the host's QEMU + +QEMU is the boot loader for `-kernel`: it fills in the setup-header fields the +Linux boot protocol expects a boot loader to supply, and OVMF measures the +result into RTMR[1]. QEMU commit `a7542a38f399` ("x86/loader: Don't update +kernel header for CoCo VMs", first released in 10.2.0) stopped rewriting the +header for confidential guests, so the same kernel would otherwise measure +differently depending on which QEMU the host chose to run. + +dstack removes that dependency instead of modelling it. The image build zeroes +the boot-loader-written fields in the kernel it ships, and dstack's OVMF zeroes +them again before the kernel blob is measured and loaded. RTMR[1] is therefore +the plain Authenticode hash of the `bzImage` listed in `sha256sum.txt`, and the +verifier needs nothing from the host to predict it -- not a QEMU version, not a +memory size. + +Images built before this landed keep their original behavior: their firmware +does not normalize, so their digest still covers QEMU's rewritten copy. Which +of the two applies is declared by the image itself -- `kernel_header_normalized`, +recorded in `metadata.json` for the image-download path and mirrored into the +measurement document for the no-image-download path -- so it is never something +the host gets to choose. + +Both carriers are bound to `os_image_hash`. `sha256sum.txt` hashes to +`os_image_hash`, a downloaded image is checked file by file against it, and the +measurement document is one of its entries. + +This matters because everything the host declares about its own VM is +untrusted. A knob the verifier has to consult is a knob the host can lie about; +here there is no knob. It also removes a class of correct-but-rejected +deployments, since the previous QEMU-patched digest varied with guest RAM and +was only reproducible at specific memory sizes. + +The normalized field set comes from the boot protocol rather than from QEMU's +behavior: every field `Documentation/arch/x86/boot.rst` types as `write` is one +the boot loader fills in and the kernel supplies no value for, so zeroing it +discards nothing the kernel provided. Fields typed `modify` carry real +kernel-supplied values and are left measured. + ### TCB status is surfaced, not gated, during verification dstack's `validate_tcb` does not reject a quote based on its TCB status string (`UpToDate`, `OutOfDate`, `ConfigurationNeeded`, `SWHardeningNeeded`, ...). It only enforces hard invariants: debug mode must be off, and the SEAM/service-TD measurements must be well-formed. The verified report carries the `status` field through to the caller. diff --git a/dstack/dstack-mr/cli/src/main.rs b/dstack/dstack-mr/cli/src/main.rs index 783f087d5..fbdfed6ce 100644 --- a/dstack/dstack-mr/cli/src/main.rs +++ b/dstack/dstack-mr/cli/src/main.rs @@ -126,6 +126,7 @@ fn main() -> Result<()> { .initrd(&initrd_path) .kernel_cmdline(&cmdline) .maybe_two_pass_add_pages(config.two_pass_add_pages) + .normalized_setup_header(image_info.kernel_header_normalized) .maybe_pic(config.pic) .smm(config.smm) .maybe_pci_hole64_size(config.pci_hole64_size) @@ -341,6 +342,7 @@ fn run_diagnose(config: &DiagnoseConfig) -> Result<()> { .root_verity(true) .hotplug_off(vm.hotplug_off) .maybe_two_pass_add_pages(vm.qemu_single_pass_add_pages) + .normalized_setup_header(image_info.kernel_header_normalized) .maybe_pic(vm.pic) .maybe_qemu_version(vm.qemu_version.clone()) .maybe_pci_hole64_size(if vm.pci_hole64_size > 0 { diff --git a/dstack/dstack-mr/src/kernel.rs b/dstack/dstack-mr/src/kernel.rs index a4e969563..7a130321d 100644 --- a/dstack/dstack-mr/src/kernel.rs +++ b/dstack/dstack-mr/src/kernel.rs @@ -237,15 +237,35 @@ pub(crate) fn patched_kernel_authenticode_sha384( authenticode_sha384_hash(&kd).context("Failed to compute kernel hash") } -/// Measures a QEMU-patched TDX kernel image. +/// Compute the first RTMR[1] event digest for an image whose OVMF normalizes +/// the Linux setup header: the Authenticode SHA-384 of the kernel file itself. +/// +/// Both sides zero the boot-loader-written fields -- +/// `os/image/normalize-kernel-header.py` in the image build and +/// `0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch` in the +/// firmware -- so what OVMF measures is the file, on every QEMU version and at +/// every guest memory size. +pub(crate) fn kernel_authenticode_sha384(kernel_data: &[u8]) -> Result> { + authenticode_sha384_hash(kernel_data).context("failed to compute kernel hash") +} + +/// Measures the TDX kernel image OVMF loads. +/// +/// `normalized_setup_header` says which kernel bytes OVMF ends up measuring: +/// the kernel file as shipped when the image's firmware normalizes the setup +/// header, otherwise QEMU's rewritten copy. pub(crate) fn rtmr1_log( kernel_data: &[u8], initrd_size: u32, mem_size: u64, acpi_data_size: u32, + normalized_setup_header: bool, ) -> Result>> { - let kernel_hash = - patched_kernel_authenticode_sha384(kernel_data, initrd_size, mem_size, acpi_data_size)?; + let kernel_hash = if normalized_setup_header { + kernel_authenticode_sha384(kernel_data)? + } else { + patched_kernel_authenticode_sha384(kernel_data, initrd_size, mem_size, acpi_data_size)? + }; Ok(vec![ kernel_hash, measure_sha384(b"Calling EFI Application from Boot Option"), @@ -270,6 +290,56 @@ mod tests { u32::from_le_bytes(kernel[0x218..0x21c].try_into().unwrap()) } + /// A minimal PE/COFF so `authenticode_sha384_hash` has something to walk. + fn pe_kernel() -> Vec { + let mut kernel = vec![0u8; 0x2000]; + let lfanew = 0x40usize; + kernel[0x3c..0x40].copy_from_slice(&(lfanew as u32).to_le_bytes()); + kernel[lfanew..lfanew + 4].copy_from_slice(&object::pe::IMAGE_NT_SIGNATURE.to_le_bytes()); + let coff = lfanew + 4; + // SizeOfOptionalHeader, then PE32+ magic and SizeOfHeaders. + kernel[coff + 16..coff + 18].copy_from_slice(&0xf0u16.to_le_bytes()); + let opt = coff + 20; + kernel[opt..opt + 2].copy_from_slice(&0x020bu16.to_le_bytes()); + kernel[opt + 60..opt + 64].copy_from_slice(&0x400u32.to_le_bytes()); + // Setup header: protocol 2.12, and a non-zero heap_end_ptr like a real + // build has, so the two branches cannot coincide by accident. + kernel[0x202..0x206].copy_from_slice(b"HdrS"); + kernel[0x206..0x208].copy_from_slice(&0x020cu16.to_le_bytes()); + // XLF_CAN_BE_LOADED_ABOVE_4G, so QEMU derives the initrd address from + // available low memory and the patched digest moves with guest RAM. + kernel[0x236..0x238].copy_from_slice(&0x0040u16.to_le_bytes()); + kernel[0x224..0x226].copy_from_slice(&0x50a0u16.to_le_bytes()); + kernel + } + + /// The flag has to reach the digest, not just the struct: an image whose + /// firmware normalizes must measure the file, and one whose firmware does + /// not must measure QEMU's rewritten copy. + #[test] + fn the_normalized_flag_selects_which_kernel_bytes_are_measured() { + let kernel = pe_kernel(); + let normalized = rtmr1_log(&kernel, 0x1000, 0x8000_0000, 0x28000, true).unwrap(); + let patched = rtmr1_log(&kernel, 0x1000, 0x8000_0000, 0x28000, false).unwrap(); + assert_ne!(normalized[0], patched[0]); + assert_eq!(normalized[0], kernel_authenticode_sha384(&kernel).unwrap()); + assert_eq!( + patched[0], + patched_kernel_authenticode_sha384(&kernel, 0x1000, 0x8000_0000, 0x28000).unwrap() + ); + } + + /// Normalizing is what makes the digest independent of guest RAM, so the + /// two branches must disagree about that too. + #[test] + fn only_the_patched_digest_moves_with_guest_memory() { + let kernel = pe_kernel(); + let at = |mem| rtmr1_log(&kernel, 0x1000, mem, 0x28000, true).unwrap()[0].clone(); + assert_eq!(at(0x8000_0000), at(0xA000_0000)); + let patched_at = |mem| rtmr1_log(&kernel, 0x1000, mem, 0x28000, false).unwrap()[0].clone(); + assert_ne!(patched_at(0x8000_0000), patched_at(0xA000_0000)); + } + #[test] fn tdx_kernel_patch_uses_precomputed_digest_at_2g_and_high_memory() { let mut kernel = vec![0u8; 0x1000]; diff --git a/dstack/dstack-mr/src/machine.rs b/dstack/dstack-mr/src/machine.rs index eda97e61f..7925d3df0 100644 --- a/dstack/dstack-mr/src/machine.rs +++ b/dstack/dstack-mr/src/machine.rs @@ -20,6 +20,12 @@ pub struct Machine<'a> { pub initrd: &'a str, pub kernel_cmdline: &'a str, pub two_pass_add_pages: Option, + /// Whether this image's OVMF normalizes the Linux setup header before + /// measuring the kernel. Defaults to `false`, which is the behavior of + /// every image built before the normalization landed; callers that have + /// the image metadata set it from `kernel_header_normalized`. + #[builder(default = false)] + pub normalized_setup_header: bool, pub pic: Option, pub qemu_version: Option, #[builder(default = false)] @@ -137,6 +143,7 @@ impl Machine<'_> { initrd_data.len() as u32, self.memory_size, 0x28000, + self.normalized_setup_header, )?; debug_print_log("RTMR1", &rtmr1_log); let rtmr1 = measure_log(&rtmr1_log); diff --git a/dstack/dstack-mr/src/tdx.rs b/dstack/dstack-mr/src/tdx.rs index 3cdec25a0..5afe11e72 100644 --- a/dstack/dstack-mr/src/tdx.rs +++ b/dstack/dstack-mr/src/tdx.rs @@ -11,8 +11,9 @@ //! intentionally excluded and must come from `VmConfig`. use crate::kernel::{ - patched_kernel_authenticode_sha384, tdx_kernel_hash_uses_precomputed_high_mem, - TDX_KERNEL_HASH_COMPAT_2G_MEMORY, TDX_KERNEL_HASH_STABLE_MIN_MEMORY, + kernel_authenticode_sha384, patched_kernel_authenticode_sha384, + tdx_kernel_hash_uses_precomputed_high_mem, TDX_KERNEL_HASH_COMPAT_2G_MEMORY, + TDX_KERNEL_HASH_STABLE_MIN_MEMORY, }; use crate::tdvf::{rtmr0_log_from_td_hob_hash_with_acpi_hashes, AcpiTableHashes, Tdvf}; use crate::util::{measure_log, measure_sha384}; @@ -34,6 +35,11 @@ struct ImageMetadata { bios: String, #[serde(default)] ovmf_variant: Option, + /// Declares whether this image's OVMF normalizes the Linux setup header + /// before measuring the kernel. Absent on every image built before that + /// landed, and `false` is exactly their behavior. + #[serde(default)] + kernel_header_normalized: bool, } #[derive(Debug, Clone)] @@ -151,6 +157,12 @@ fn read_varuint(input: &mut &[u8]) -> Result { } } +/// q35 keeps guest RAM in one block below 4G until it reaches this size, then +/// caps the below-4G block at 2 GiB and moves the remainder above 4G +/// (`lowmem = 0xb0000000 unless ram_size >= 0xb0000000`). The TD HOB memory +/// ranges follow that split. +const Q35_HIGH_MEMORY_SPLIT: u64 = 0xB000_0000; + fn measure_td_hob_from_witness_data(data: &[u8], memory_size: u64) -> Result> { let mut input = data; let base_page = read_varuint(&mut input)?; @@ -217,7 +229,7 @@ fn measure_td_hob_from_witness_data(data: &[u8], memory_size: u64) -> Result= TDX_KERNEL_HASH_STABLE_MIN_MEMORY { + if memory_size >= Q35_HIGH_MEMORY_SPLIT { if last_start < 0x80000000u64 { add_memory_resource_hob(0x07, last_start, 0x80000000u64 - last_start); } @@ -321,15 +333,22 @@ pub fn tdx_os_image_measurement_for_image_dir(image_dir: &Path) -> Result Result<[u8; 32]> /// Compute expected TDX measurements from self-contained TDX measurement /// material and the three ACPI table digests captured in RTMR[0]. /// -/// This path intentionally does not download or read the OS image. Because -/// QEMU's patched kernel Authenticode hash depends on exact guest RAM below -/// `TDX_KERNEL_HASH_STABLE_MIN_MEMORY`, the no-image-download path supports -/// CVMs at or above that threshold plus the exact 2 GiB placement, which QEMU -/// patches to the same kernel bytes as the high-memory case. +/// This path intentionally does not download or read the OS image. Every +/// guest memory size is supported: the kernel digest is a plain hash of the +/// image file, because the setup header is normalized on both sides. pub fn tdx_measurements_from_measurement_document( document: &TdxOsImageMeasurementDocument, vm_config: &VmConfig, acpi_hashes: &TdxRtmr0AcpiHashes, ) -> Result { - if !tdx_kernel_hash_uses_precomputed_high_mem(vm_config.memory_size) { + let measurement = document + .decode_measurement() + .map_err(anyhow::Error::msg) + .context("failed to decode TDX measurement CBOR")?; + if !measurement.kernel_header_normalized + && !tdx_kernel_hash_uses_precomputed_high_mem(vm_config.memory_size) + { bail!( - "TDX lite attestation without image download requires memory_size == {} bytes ({} MiB) or >= {} bytes ({} MiB); got {} bytes", + "TDX lite attestation without image download requires memory_size == {} bytes ({} MiB) or >= {} bytes ({} MiB); got {} bytes. This restriction only applies to images whose OVMF does not normalize the kernel setup header, because QEMU's rewrite moves the initrd with guest RAM; re-emit the image to remove it", TDX_KERNEL_HASH_COMPAT_2G_MEMORY, TDX_KERNEL_HASH_COMPAT_2G_MEMORY / 1024 / 1024, TDX_KERNEL_HASH_STABLE_MIN_MEMORY, @@ -381,11 +404,6 @@ pub fn tdx_measurements_from_measurement_document( vm_config.memory_size ); } - - let measurement = document - .decode_measurement() - .map_err(anyhow::Error::msg) - .context("failed to decode TDX measurement CBOR")?; let mrtd = select_mrtd(&measurement, vm_config)?; let td_hob_hash = @@ -482,6 +500,7 @@ pub fn tdx_measurements_for_image_dir_without_rtmr0( .root_verity(true) .hotplug_off(vm_config.hotplug_off) .maybe_two_pass_add_pages(vm_config.qemu_single_pass_add_pages) + .normalized_setup_header(meta.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 { @@ -507,6 +526,7 @@ pub fn tdx_measurements_for_image_dir_without_rtmr0( initrd_data.len() as u32, vm_config.memory_size, 0x28000, + meta.kernel_header_normalized, ) .context("failed to compute RTMR1")?; let rtmr1 = measure_log(&rtmr1_log); @@ -573,6 +593,7 @@ pub fn tdx_measurements_for_image_dir_with_acpi_hashes( .root_verity(true) .hotplug_off(vm_config.hotplug_off) .maybe_two_pass_add_pages(vm_config.qemu_single_pass_add_pages) + .normalized_setup_header(meta.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 { @@ -611,6 +632,7 @@ pub fn tdx_measurements_for_image_dir_with_acpi_hashes( initrd_data.len() as u32, vm_config.memory_size, 0x28000, + meta.kernel_header_normalized, ) .context("failed to compute RTMR1")?; let rtmr1 = measure_log(&rtmr1_log); diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index 86df226b2..bf9fa91b9 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -2015,6 +2015,15 @@ impl SevOsImageMeasurementDocument { pub struct TdxOsImageMeasurement { pub image: TdxImageMeasurement, pub tdvf: TdxTdvfMeasurement, + /// Whether this image's OVMF normalizes the Linux setup header before + /// measuring the kernel, which decides what + /// [`TdxImageMeasurement::kernel_authenticode`] covers. + /// + /// Omitted from the CBOR when false, so a document from before the + /// normalization existed re-encodes byte for byte and keeps its + /// `os_image_hash`. + #[serde(default)] + pub kernel_header_normalized: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -2025,10 +2034,15 @@ pub struct TdxImageMeasurement { /// `initrd=initrd` suffix, encoded as UTF-16LE with a trailing NUL. #[serde(with = "hex_bytes")] pub kernel_cmdline_sha384: Vec, - /// Authenticode SHA-384 digest of the QEMU-patched kernel image when the - /// guest memory is at or above QEMU's high-memory TDX initrd placement - /// threshold. Below that threshold the patched kernel header depends on the - /// exact guest memory size, so the no-image-download verifier rejects it. + /// Authenticode SHA-384 digest of the kernel image OVMF measures into + /// RTMR[1]. Which bytes that covers is + /// [`TdxOsImageMeasurement::kernel_header_normalized`]. + /// + /// When the header is not normalized it is QEMU's rewritten copy, computed + /// at or above QEMU's high-memory TDX initrd placement threshold; below + /// that threshold the rewritten header depends on the exact guest memory + /// size, so the no-image-download verifier rejects those sizes. When it is + /// normalized it is the kernel file as shipped, with no such restriction. #[serde(with = "hex_bytes")] pub kernel_authenticode: Vec, /// SHA-384 of the initrd file bytes. This is the second RTMR[2] event. @@ -2061,9 +2075,15 @@ struct CborTdxImageMeasurement { /// Measured kernel cmdline SHA-384. #[serde(rename = "cmdline_sha384", with = "hex_bytes")] kernel_cmdline_sha384: Vec, - /// QEMU-patched kernel Authenticode SHA-384. + /// Kernel Authenticode SHA-384. Covers QEMU's rewritten copy, or the + /// kernel file as shipped when `kernel_header_normalized` is set. #[serde(with = "hex_bytes")] kernel_authenticode: Vec, + /// Whether the image's OVMF normalizes the Linux setup header before + /// measuring. Omitted when false, so documents from before this existed + /// encode and decode unchanged. + #[serde(default, skip_serializing_if = "is_false")] + kernel_header_normalized: bool, /// Initrd SHA-384. #[serde(with = "hex_bytes")] initrd_sha384: Vec, @@ -2100,6 +2120,7 @@ impl From<&TdxOsImageMeasurement> for CborTdxOsImageMeasurement { image: CborTdxImageMeasurement { kernel_cmdline_sha384: measurement.image.kernel_cmdline_sha384.clone(), kernel_authenticode: measurement.image.kernel_authenticode.clone(), + kernel_header_normalized: measurement.kernel_header_normalized, initrd_sha384: measurement.image.initrd_sha384.clone(), }, tdvf: CborTdxTdvfMeasurement { @@ -2117,6 +2138,7 @@ impl From<&TdxOsImageMeasurement> for CborTdxOsImageMeasurement { impl From for TdxOsImageMeasurement { fn from(measurement: CborTdxOsImageMeasurement) -> Self { Self { + kernel_header_normalized: measurement.image.kernel_header_normalized, image: TdxImageMeasurement { kernel_cmdline_sha384: measurement.image.kernel_cmdline_sha384, kernel_authenticode: measurement.image.kernel_authenticode, @@ -2149,6 +2171,7 @@ impl TdxOsImageMeasurement { pub const VERSION: u32 = 3; /// CBOR representation stored as `measurement.tdx.cbor`. + /// pub fn to_cbor_vec(&self) -> Vec { cbor_to_vec( &CborTdxOsImageMeasurement::from(self), @@ -2362,6 +2385,11 @@ pub struct ImageInfo { /// fall back to version-based heuristics. #[serde(default, skip_serializing_if = "Option::is_none")] pub ovmf_variant: Option, + /// Whether this image's OVMF normalizes the Linux setup header before + /// measuring the kernel, which decides what RTMR[1] covers. Absent on + /// every image built before that landed, and `false` is their behavior. + #[serde(default, skip_serializing_if = "is_false")] + pub kernel_header_normalized: bool, } pub mod mr_config; @@ -2702,3 +2730,99 @@ mod appcompose_sdk_parity { ); } } + +#[cfg(test)] +mod image_info_tests { + use super::*; + + /// The image-download verification path reads this out of metadata.json to + /// decide which kernel bytes RTMR[1] covers. If the field goes missing here + /// the parse still succeeds and every normalized image is measured the old + /// way, which is a silent attestation failure. + #[test] + fn metadata_declares_whether_the_kernel_header_is_normalized() { + let base = r#"{"cmdline":"c","kernel":"bzImage","initrd":"i","bios":"b""#; + let normalized: ImageInfo = + serde_json::from_str(&format!("{base},\"kernel_header_normalized\":true}}")).unwrap(); + assert!(normalized.kernel_header_normalized); + + // Every image built before the field existed omits it. + let legacy: ImageInfo = serde_json::from_str(&format!("{base}}}")).unwrap(); + assert!(!legacy.kernel_header_normalized); + } +} + +#[cfg(test)] +mod tdx_measurement_cbor_tests { + use super::*; + + fn measurement(kernel_header_normalized: bool) -> TdxOsImageMeasurement { + TdxOsImageMeasurement { + kernel_header_normalized, + image: TdxImageMeasurement { + kernel_cmdline_sha384: vec![0x11; 48], + kernel_authenticode: vec![0x22; 48], + initrd_sha384: vec![0x33; 48], + }, + tdvf: TdxTdvfMeasurement { + ovmf_variant: OvmfVariant::default(), + mrtd: TdxMrtdCandidates { + single_pass: vec![0x44; 48], + two_pass: vec![0x55; 48], + }, + td_hob_witness: vec![0x01, 0x02, 0x03], + }, + } + } + + /// Which kernel bytes the digest covers has to survive a round trip in + /// both directions. + #[test] + fn the_kernel_header_flag_round_trips() { + for normalized in [false, true] { + let original = measurement(normalized); + let decoded = TdxOsImageMeasurement::from_cbor_slice(&original.to_cbor_vec()).unwrap(); + assert_eq!(decoded, original); + assert_eq!(decoded.kernel_header_normalized, normalized); + } + } + + /// The flag is omitted when false, so a document from before the + /// normalization existed encodes to the same bytes it always did. Those + /// bytes are what `sha256sum.txt` -- and therefore `os_image_hash` -- + /// commits to, and the document shape did not change, so the version does + /// not move either. + #[test] + fn a_pre_normalization_document_does_not_drift() { + let cbor = measurement(false).to_cbor_vec(); + assert!( + !cbor.windows(24).any(|w| w == b"kernel_header_normalized"), + "the flag must not appear in a pre-normalization document" + ); + let value = TdxOsImageMeasurement::cbor_json_value_from_slice(&cbor).unwrap(); + assert_eq!( + value["version"], + serde_json::json!(TdxOsImageMeasurement::VERSION) + ); + let twice = TdxOsImageMeasurement::from_cbor_slice(&cbor) + .unwrap() + .to_cbor_vec(); + assert_eq!(cbor, twice); + } + + #[test] + fn unknown_versions_are_rejected() { + // CBOR stores the version as the single unsigned byte following the + // "version" key, so rewriting it forges another version. + let key = [0x67, b'v', b'e', b'r', b's', b'i', b'o', b'n']; + let mut cbor = measurement(true).to_cbor_vec(); + let at = cbor + .windows(key.len()) + .position(|window| window == key) + .expect("encoded document contains a version key"); + cbor[at + key.len()] = 2; + + let err = TdxOsImageMeasurement::from_cbor_slice(&cbor).unwrap_err(); + assert!(err.contains("unsupported version 2"), "unexpected: {err}"); + } +} diff --git a/dstack/verifier/fixtures/tdx-lite-normalized-attestation.json b/dstack/verifier/fixtures/tdx-lite-normalized-attestation.json new file mode 100644 index 000000000..0601d04c6 --- /dev/null +++ b/dstack/verifier/fixtures/tdx-lite-normalized-attestation.json @@ -0,0 +1,3 @@ +{ + "attestation": "0000394e040002008100000000000000939a7233f79c4ca9940a0db3957f0607dca47981c0d1095de780f491950bc3c9000000000e010400000000000000000000000000346bc77a1846cac214dd2e8edeb9ee4349449d6c3f9ff2c52149a634c27b7fd1bd314c2ef6b973eeebd55742952531a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000e70206000000000078cb3ad79ad26d3217e3d28a42b1a55bab034dfc3478bc910596d36c8f46f17eaf8bbe19f27e9858d01af63d316606fb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068102e7b524af310f7b7d426ce75481e36c40f5d513a9009c046e9d37e31551f0134d954b496a3357fd61d03f07ffe9660db95c7f164464e5bb3cc38610c23ade2c81b651256f9712ae55aa4c51ca092d0b550999407de966f35cb878f79d200f873ce9b6d655985280d538d1da3113b7a5b6335a4d63ba534a21a644dbd89528ea7df4f7b174f2bde1d38bb05793535c76695ac02680e1ae25feea17719e206bea64e59e9a209196e38999f70ba5a220a75e433d85522892219e3cd8a92a9c311223344556677880000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cc1000006d355e57a33e5e2f51368270424c90b3385e6ec13c5aa278d33e6c8f50cb59f2bd3f882f4bd44d2440328204116b96daadfcdbe83d37648db84f122c0da25374445e655dfb4499fb6e3eb434c3275c340df9efb896c56d628111c4b955c15bf31774c93727636fdbbc8236633bb83f08388c368684d3c75e29aed47ec050e7720600461000000404191b04ff0006000000000000000000000000000000000000000000000000000000000000000000000000000000001500000000000000e700000000000000e5a3a7b5d830c2953b98534c6c59a3a34fdc34e933f7f5898f0a85cf08846bca0000000000000000000000000000000000000000000000000000000000000000dc9e2a7c6f948f17474e34a7fc43ed030f7c1563f1babddf6340c82e0e54a8c500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c209022cccc4d771dc1ede10db23ac55be3419d22901a89fb8cc6138404337b2000000000000000000000000000000000000000000000000000000000000000018264bac729410b741866c05234532ec0ba2725d1a4b6328a73151cdd0681b4c395ea0fb11bca5e540cfa36967d094400ff61b54d12df313517bd02c88e15ab42000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f05005e0e00002d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d49494538444343424a65674177494241674956414b576652394d75626c4e5642566e4250454a58614b6466567151394d416f4743437147534d343942414d430a4d484178496a416742674e5642414d4d47556c756447567349464e4857434251513073675547786864475a76636d306751304578476a415942674e5642416f4d0a45556c756447567349454e76636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155450a4341774351304578437a414a42674e5642415954416c56544d423458445449324d4463794e6a41334e4445774f466f5844544d7a4d4463794e6a41334e4445770a4f466f77634445694d434147413155454177775a535735305a5777675530645949464244537942445a584a3061575a70593246305a5445614d426747413155450a43677752535735305a577767513239796347397959585270623234784644415342674e564241634d43314e68626e526849454e7359584a684d517377435159440a5651514944414a445154454c4d416b474131554542684d4356564d775754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e43414151510a644b6d76382f774831684e6476705639474d476345442b514b764f382b6276676f752b426362774c432b79582f78427778384d33395444347262436636764a570a54786f442f496d61784b2b337857554864704d496f3449444444434341776777487759445652306a42426777466f41556c5739647a62306234656c4153636e550a3944504f4156634c336c5177617759445652306642475177596a42676f46366758495a616148523063484d364c79396863476b7564484a316333526c5a484e6c0a636e5a705932567a4c6d6c75644756734c6d4e766253397a5a3367765932567964476c6d61574e6864476c76626939324e4339775932746a636d772f593245390a6347786864475a76636d306d5a57356a62325270626d63395a4756794d42304741315564446751574242515a43536c546e6b372f446b746d702f504a6d304f500a7a417455357a414f42674e56485138424166384542414d434273417744415944565230544151482f4241497741444343416a6b4743537147534962345451454e0a4151534341696f776767496d4d42344743697147534962345451454e415145454547396738357947645143775644587367467967472b6f776767466a42676f710a686b69472b453042445145434d494942557a415142677371686b69472b45304244514543415149424244415142677371686b69472b45304244514543416749420a4244415142677371686b69472b4530424451454341774942416a415142677371686b69472b4530424451454342414942416a415142677371686b69472b4530420a44514543425149424244415142677371686b69472b45304244514543426749424154415142677371686b69472b453042445145434277494241444151426773710a686b69472b45304244514543434149424254415142677371686b69472b45304244514543435149424144415142677371686b69472b45304244514543436749420a4144415142677371686b69472b45304244514543437749424144415142677371686b69472b45304244514543444149424144415142677371686b69472b4530420a44514543445149424144415142677371686b69472b45304244514543446749424144415142677371686b69472b453042445145434477494241444151426773710a686b69472b45304244514543454149424144415142677371686b69472b45304244514543455149424454416642677371686b69472b45304244514543456751510a42415143416751424141554141414141414141414144415142676f71686b69472b45304244514544424149414144415542676f71686b69472b453042445145450a4241615177473841414141774477594b4b6f5a496876684e4151304242516f424154416542676f71686b69472b4530424451454742424238724731754c5a50690a3839584569693555414154684d45514743697147534962345451454e415163774e6a415142677371686b69472b45304244514548415145422f7a4151426773710a686b69472b45304244514548416745422f7a415142677371686b69472b45304244514548417745422f7a414b42676771686b6a4f5051514441674e48414442450a4169414a6d6b77376745482b636d2f4446667275664b3834374f7475546465322f7535615031732f42617237504149675364384c50315161743847506a6a34650a684c2b315470714e64557a2f5946733053515279545941466774343d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436c6a4343416a32674177494241674956414a567658633239472b487051456e4a3150517a7a674658433935554d416f4743437147534d343942414d430a4d476778476a415942674e5642414d4d45556c756447567349464e48574342536232393049454e424d526f77474159445651514b4442464a626e526c624342440a62334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e564241674d416b4e424d5173770a435159445651514745774a56557a4165467730784f4441314d6a45784d4455774d5442614677307a4d7a41314d6a45784d4455774d5442614d484178496a41670a42674e5642414d4d47556c756447567349464e4857434251513073675547786864475a76636d306751304578476a415942674e5642416f4d45556c75644756730a49454e76636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b474131554543417743513045780a437a414a42674e5642415954416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741454e53422f377432316c58534f0a3243757a7078773734654a423732457944476757357258437478327456544c7136684b6b367a2b5569525a436e71523770734f766771466553786c6d546c4a6c0a65546d693257597a33714f42757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f536347724442530a42674e5648523845537a424a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e5648513445466751556c5739640a7a62306234656c4153636e553944504f4156634c336c517744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159420a4166384341514177436759494b6f5a497a6a30454177494452774177524149675873566b6930772b6936565947573355462f32327561586530594a446a3155650a6e412b546a44316169356343494359623153416d4435786b66545670766f34556f79695359787244574c6d5552344349394e4b7966504e2b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436a7a4343416a53674177494241674955496d554d316c71644e496e7a6737535655723951477a6b6e42717777436759494b6f5a497a6a3045417749770a614445614d4267474131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e760a636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a0a42674e5642415954416c56544d423458445445344d4455794d5445774e4455784d466f58445451354d54497a4d54497a4e546b314f566f77614445614d4267470a4131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e76636e4276636d46300a615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a42674e56424159540a416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a3044415163445167414543366e45774d4449595a4f6a2f69505773437a61454b69370a314f694f534c52466857476a626e42564a66566e6b59347533496a6b4459594c304d784f346d717379596a6c42616c54565978465032734a424b357a6c4b4f420a757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f5363477244425342674e5648523845537a424a0a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b63325679646d6c6a5a584d75615735300a5a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e564851344546675155496d554d316c71644e496e7a673753560a55723951477a6b6e4271777744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159424166384341514577436759490a4b6f5a497a6a3045417749445351417752674968414f572f35516b522b533943695344634e6f6f774c7550524c735747662f59693747535839344267775477670a41694541344a306c72486f4d732b586f356f2f7358364f39515778485241765a55474f6452513763767152586171493d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c000000000a000000c039740da2ea1e7e6c69e5e6569755b2ad6d622a8a9983a2c5b32d5574659dbdc4ca8b95b1c766bd610bc5c26dcde842f82c616370692d6c6f6164657224414350492044415441000000000a000000c06385b89d2decb3de678d51faba0ff3506835915818fd3ee53a739aa678cd9c76bdd755adfa0aa714cf35a1abe062b25324616370692d7273647024414350492044415441000000000a000000c005705c3ca9d71e908d5ce70d4331b6aadce5331b9a6a546f222f017fb0e1797b951a3dac72f59cb197a0c897a201ca072c616370692d7461626c6573244143504920444154410300000001000008c0f9974020ef507068183313d0ca808e0d1ca9b2d1ad0c61f5784e7157c362c06536f5ddacdad4451693f48fcc72fff6244073797374656d2d707265706172696e67000300000001000008c0dc64696b724968f9dea1a2c3071d8d52c730876ca45242c2ecfad00c07349dc4bd6c565f5c6bc6f9648260254b35065f186170702d696450217001b5ca75468a42218cf69aba596d0e80e96b0300000001000008c01a2fbce333d59cdd3cd37db0ee22f498d9deb188169631a26fbf40dbdd0a685a8d930cf0dba46bb79bee3a0b868bb1bd30636f6d706f73652d6861736880c97c1e4d612936e2cce80c4d17f65590857394cf50dd7f83f85ea1e4ee47315f0300000001000008c0c924c4d54519c63b6f123f662abe1b4852fa41f3db9dfd9a052324cae2ccab4c96b446974c9c2b6d03dc8e96e3c3f5842c696e7374616e63652d69645032100cea9835087181765994eb3231d9ef7033420300000001000008c098bd7e6bd3952720b65027fd494834045d06b4a714bf737a06b874638b3ea00ff402f7f583e3e3b05e921c8570433ac630626f6f742d6d722d646f6e65000300000001000008c07f788209b6106d43ad4b0cef85797120c234f175a10b7cb609a73ec1f4285adfe7abca2e1e3d3bd2ba058c2b78d1771d306b65792d70726f76696465725c7b226e616d65223a226e6f6e65222c226964223a22227d0300000001000008c0ba51104636900268b0e059fa3d266419d079d1e94aea26fb9fcbb8d764bf4c89a67ac271b8a0d1a3989945132a111fc72873746f726167652d66730c7a66730300000001000008c01a76b2a80a0be71eae59f80945d876351a7a3fb8e9fd1ff1cede5734aa84ea11fd72b4edfbb6f04e5a85edd114c751bd3073797374656d2d726561647900204073797374656d2d707265706172696e6700186170702d696450217001b5ca75468a42218cf69aba596d0e80e96b30636f6d706f73652d6861736880c97c1e4d612936e2cce80c4d17f65590857394cf50dd7f83f85ea1e4ee47315f2c696e7374616e63652d69645032100cea9835087181765994eb3231d9ef70334230626f6f742d6d722d646f6e6500306b65792d70726f76696465725c7b226e616d65223a226e6f6e65222c226964223a22227d2873746f726167652d66730c7a66733073797374656d2d72656164790011223344556677880000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd1a7b226f735f696d6167655f68617368223a2265643837643665646539343062316565633638666361313434623762313735323466366431303536636633326334386133303062376164343437363364353637222c226370755f636f756e74223a312c226d656d6f72795f73697a65223a323134373438333634382c2271656d755f76657273696f6e223a22382e322e32222c227063695f686f6c6536345f73697a65223a302c22687567657061676573223a66616c73652c226e756d5f67707573223a302c226e756d5f6e767377697463686573223a302c22686f74706c75675f6f6666223a66616c73652c22696d616765223a2264737461636b2d302e362e30222c22686f73745f73686172655f6d6f6465223a223970222c226f766d665f76617269616e74223a22707265323032353035222c227464785f6174746573746174696f6e5f76617269616e74223a226c697465222c227464785f6d6561737572656d656e74223a7b22636865636b73756d5f66696c65223a224e4445794e6d4a6a5a6a55795a5752694d4755324e7a4d354f4463354e6a4e684d6a49325a6d49325a54686a4e5755785a44526b4f5759785a5467325a545135597a5a69597a67324f4749794f5467344f54566a4e69416762335a745a69356d5a416f7a4d3249794e6d55774f44566d596a566b4f445532597a59354d54557a4d546b784f474e6c4f4759344e325a694d475579596a4d7a4f5451324e5751324e4445344d475978596a4e6d5a4451304d544e6d4e44493149434269656b6c745957646c436d4d325a5759794e6a6330596d55314f5755774d4751335a6a6b774e7a466b5a6a466c4f4449774f4755325a6a45784d32466b4e7a5a684d474a6a596a67305a6a41774f545a684d4467794d54426a4d44557a4d7a4d6749476c75615852795957316d6379356a63476c764c6d6436436a426a596a5979596a4e684e6a68685a5759795a6d526c5a575933596d597a597a51345a4467345a44426a4d5445794f5746684e44466d4e6d49344f574935597a41785a6a4a6d4d474e6a597a4d325a57526c596a67674947316c6447466b595852684c6d707a6232344b5a6a6c6a5a4463795a44517a596a51794e32566a595749795a54426a5a474d314d6d55344e6d566c4f4749345a6a4e6959325a6d4f5467315a5441344d575a684e54526c4d3259304d44457a4d6a64685a575a6d4f53416762575668633356795a57316c626e5175644752344c6d4e696233494b4d6a59314f5751324e44426a4e4759315a5752685a6d49314e4745334d474d344f544e6d596d5a6c5a5459305a5745324e3249774e4745305a6a67335a6a51355a546b304f474d7a59574578597a6c6c4d7a5a6a4f53416762575668633356795a57316c626e5175633235774c6d4e696233494b4d4749784d475a6c4f4755354d574978596a6b354e5467775a47466c4f5752694d544d354e6d46684d4751344e6a63304d6d4d30595751774e6a4a6d4f444131597a4d784d6a6b334e5751354e6a59335a6a4d305a69416762575668633356795a57316c626e51755a324e774c6d4e696233494b222c226d6561737572656d656e74223a226f3264325a584a7a61573975413256706257466e5a6152755932316b62476c755a56397a6147457a4f4452594d4b536e4f30704c71456337782f377367492f32613346784366443656456d4a3473533765473379564a4868686f564835644f426f7145545a44514167656f33736e4e725a584a755a577866595856306147567564476c6a6232526c5744434943574c30464f516d3038436b7042344662354638656b75314732374352497064537647335054636a494e3243706e66795275585733564951387a374d727a42344747746c636d356c6246396f5a57466b5a584a66626d3979625746736158706c5a5056746157357064484a6b58334e6f59544d344e466777542b54336351453070683139377a5636335772464339762b376c4179704d45414e3134676368622f35436f37315949724a4f5a352b5255422f2f6556754255685a48526b646d616a5a47393262575a7063484a6c4d6a41794e5441315a4731796447536961334e70626d64735a56397759584e7a5744434f302f592f6a71442f7a33557a494a653646724172456830596b76322f456b3246493752666e525536716a6a3147647a5738552f335051375675415554544e526f644864765833426863334e594d486a4c4f746561306d3079462b5053696b4b7870567572413033384e4869386b515757303279505276462b7234752b47664a2b6d466a51477659394d5759472b325a305a46396f62324a4d6742414a424141474351494c41684151227d2c22737065635f76657273696f6e223a317d" +} diff --git a/dstack/verifier/fixtures/tdx-lite-normalized-qemu-10-2-attestation.json b/dstack/verifier/fixtures/tdx-lite-normalized-qemu-10-2-attestation.json new file mode 100644 index 000000000..71dedac6a --- /dev/null +++ b/dstack/verifier/fixtures/tdx-lite-normalized-qemu-10-2-attestation.json @@ -0,0 +1,3 @@ +{ + "attestation": "0000ed51040002008100000000000000939a7233f79c4ca9940a0db3957f060731891a79b240af169d0029bd8228e6b300000000090304000000000000000000000000002d2de102461684f14c8d0984a09d895e3e9e15944ce020a03b977e1f114d5e1ed32ef666a47fd19a5851b3800eda3afa00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000e7020600000000008ed3f63f8ea0ffcf75332097ba16b02b121d1892fdbf124d8523b45f9d153aaa38f519dcd6f14ff73d0ed5b805134cd4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000131221a6abfe1dfef41ac2598c1d731b44d19270b39059c59ede8a82ff2c18a812af58cf90d0047085f2bfaf54fb122960db95c7f164464e5bb3cc38610c23ade2c81b651256f9712ae55aa4c51ca092d0b550999407de966f35cb878f79d200f873ce9b6d655985280d538d1da3113b7a5b6335a4d63ba534a21a644dbd89528ea7df4f7b174f2bde1d38bb057935352fe7c9d3c10f0d2e8939f113c3d08e77783a77cdb1aa63c197b530247eead33a3207986a809f1f3026de8755743496a111223344556677880000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cc1000004b64b94c51e3d107fe508c6101a4a52a33c857a72a54995df3cf9488ccb50e69fbed19c0fb27cf7bfce2a2af949ff9d05b60ac51c03a86716ac5f18068dd7831264b8f688ebefbe63e0dca90ecbae0e5663dd6d49bf6f942256e1a8856ea6f6811166a1aa4ecf88c5d0c727138abc9d00c1c8f852c832f927155bee726e98fb70600461000000406090905ff0002000000000000000000000000000000000000000000000000000000000000000000000000000000001500000000000000e700000000000000de1e1bbbc8edef08283e8ad51ce112b5b11af473050828543a0c73b914a33e710000000000000000000000000000000000000000000000000000000000000000dc9e2a7c6f948f17474e34a7fc43ed030f7c1563f1babddf6340c82e0e54a8c500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d1f5f8593e4249ffd4c52b1370295e91bed7b991249aeb5fb30d991ebf47b7bf0000000000000000000000000000000000000000000000000000000000000000c0e06e818a55de0997a7c797c217949dcac0a8b82442365cde29f68ed5095b92cc26b41f392b142c3d3a082176d4c685b7769d92633b767a2536653b8ac513e32000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f05005e0e00002d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d49494538544343424a616741774942416749554e3958714f6d566f6a6c424c7856354956514853636f494e41713077436759494b6f5a497a6a3045417749770a634445694d434147413155454177775a535735305a577767553064594946424453794251624746305a6d397962534244515445614d42674741315545436777520a535735305a577767513239796347397959585270623234784644415342674e564241634d43314e68626e526849454e7359584a684d51737743515944565151490a44414a445154454c4d416b474131554542684d4356564d774868634e4d6a59774f5441794d6a41774e6a55325768634e4d7a4d774f5441794d6a41774e6a55320a576a42774d534977494159445651514444426c4a626e526c624342545231676755454e4c49454e6c636e52705a6d6c6a5958526c4d526f77474159445651514b0a4442464a626e526c6243424462334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e560a4241674d416b4e424d517377435159445651514745774a56557a425a4d424d4742797147534d34394167454743437147534d3439417745484130494142504c6b0a2f7549366b6c4c496b787155332f434b674c5730446a2f6a4f6961443238686d4b6f462b3937473037546f395477707335555a2f44754e555546314658382f6c0a367049427a54734a3244314244547a5a6958536a67674d4d4d4949444344416642674e5648534d4547444157674253566231334e765276683655424a796454300a4d383442567776655644427242674e56485238455a4442694d47436758714263686c706f64485277637a6f764c32467761533530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c334e6e6543396a5a584a3061575a7059324630615739754c3359304c33426a61324e796244396a595431770a624746305a6d397962535a6c626d4e765a476c755a7a316b5a584977485159445652304f42425945464a72566a75346632416755614d5636526742544a456e370a6e5936394d41344741315564447745422f775145417749477744414d42674e5648524d4241663845416a41414d4949434f51594a4b6f5a496876684e415130420a424949434b6a4343416959774867594b4b6f5a496876684e415130424151515156613032666977465771502f456d6579464939467a44434341574d47436971470a534962345451454e41514977676746544d42414743797147534962345451454e41514942416745454d42414743797147534962345451454e41514943416745450a4d42414743797147534962345451454e41514944416745434d42414743797147534962345451454e41514945416745434d42414743797147534962345451454e0a41514946416745454d42414743797147534962345451454e41514947416745424d42414743797147534962345451454e41514948416745414d424147437971470a534962345451454e41514949416745434d42414743797147534962345451454e4151494a416745414d42414743797147534962345451454e4151494b416745410a4d42414743797147534962345451454e4151494c416745414d42414743797147534962345451454e4151494d416745414d42414743797147534962345451454e0a4151494e416745414d42414743797147534962345451454e4151494f416745414d42414743797147534962345451454e41514950416745414d424147437971470a534962345451454e41514951416745414d42414743797147534962345451454e415149524167454e4d42384743797147534962345451454e41514953424241450a42414943424145414167414141414141414141414d42414743697147534962345451454e41514d45416741414d42514743697147534962345451454e415151450a42674367625167414144415042676f71686b69472b45304244514546436745424d42344743697147534962345451454e41515945454f324e6c4d316f716573740a41326e3249363856664938775241594b4b6f5a496876684e41513042427a41324d42414743797147534962345451454e415163424151482f4d424147437971470a534962345451454e41516343415145414d42414743797147534962345451454e415163444151482f4d416f4743437147534d343942414d4341306b414d4559430a4951435061715878444f6634316655727436444979484132687437524c54676f7662643357423643596e4a3044514968414a35584e5043394f4e7859765049710a6a305a2f394b6442417451584b77514f31374c4430433133597575620a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436c6a4343416a32674177494241674956414a567658633239472b487051456e4a3150517a7a674658433935554d416f4743437147534d343942414d430a4d476778476a415942674e5642414d4d45556c756447567349464e48574342536232393049454e424d526f77474159445651514b4442464a626e526c624342440a62334a7762334a6864476c76626a45554d424947413155454277774c553246756447456751327868636d4578437a414a42674e564241674d416b4e424d5173770a435159445651514745774a56557a4165467730784f4441314d6a45784d4455774d5442614677307a4d7a41314d6a45784d4455774d5442614d484178496a41670a42674e5642414d4d47556c756447567349464e4857434251513073675547786864475a76636d306751304578476a415942674e5642416f4d45556c75644756730a49454e76636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b474131554543417743513045780a437a414a42674e5642415954416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a304441516344516741454e53422f377432316c58534f0a3243757a7078773734654a423732457944476757357258437478327456544c7136684b6b367a2b5569525a436e71523770734f766771466553786c6d546c4a6c0a65546d693257597a33714f42757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f536347724442530a42674e5648523845537a424a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b633256790a646d6c6a5a584d75615735305a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e5648513445466751556c5739640a7a62306234656c4153636e553944504f4156634c336c517744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159420a4166384341514177436759494b6f5a497a6a30454177494452774177524149675873566b6930772b6936565947573355462f32327561586530594a446a3155650a6e412b546a44316169356343494359623153416d4435786b66545670766f34556f79695359787244574c6d5552344349394e4b7966504e2b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d4949436a7a4343416a53674177494241674955496d554d316c71644e496e7a6737535655723951477a6b6e42717777436759494b6f5a497a6a3045417749770a614445614d4267474131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e760a636e4276636d4630615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a0a42674e5642415954416c56544d423458445445344d4455794d5445774e4455784d466f58445451354d54497a4d54497a4e546b314f566f77614445614d4267470a4131554541777752535735305a5777675530645949464a766233516751304578476a415942674e5642416f4d45556c756447567349454e76636e4276636d46300a615739754d5251774567594456515148444174545957353059534244624746795954454c4d416b47413155454341774351304578437a414a42674e56424159540a416c56544d466b77457759484b6f5a497a6a3043415159494b6f5a497a6a3044415163445167414543366e45774d4449595a4f6a2f69505773437a61454b69370a314f694f534c52466857476a626e42564a66566e6b59347533496a6b4459594c304d784f346d717379596a6c42616c54565978465032734a424b357a6c4b4f420a757a43427544416642674e5648534d4547444157674251695a517a575770303069664f44744a5653763141624f5363477244425342674e5648523845537a424a0a4d45656752614244686b466f64485277637a6f764c324e6c636e52705a6d6c6a5958526c63793530636e567a6447566b63325679646d6c6a5a584d75615735300a5a577775593239744c306c756447567355306459556d397664454e424c6d526c636a416442674e564851344546675155496d554d316c71644e496e7a673753560a55723951477a6b6e4271777744675944565230504151482f42415144416745474d42494741315564457745422f7751494d4159424166384341514577436759490a4b6f5a497a6a3045417749445351417752674968414f572f35516b522b533943695344634e6f6f774c7550524c735747662f59693747535839344267775477670a41694541344a306c72486f4d732b586f356f2f7358364f39515778485241765a55474f6452513763767152586171493d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c000000000a000000c0d8c90b5d0f756dcd707c190313b44e3129abc6c20118efdd32b78f84fa64a6c51396fa43debc993dec00b85718bf74912c616370692d6c6f6164657224414350492044415441000000000a000000c0ab0063753f886063d08d1e65031fdcd12e210764b476210edf90e006af701c20df1710ab3fcb7dee651727f4a369f29a24616370692d7273647024414350492044415441000000000a000000c08583382b5eb22b3b7b8194777aa82ae7060c6b5e8387ee59926a9dc429e1069a9215c24dd299cee42611fd1960711c502c616370692d7461626c6573244143504920444154410300000001000008c0f9974020ef507068183313d0ca808e0d1ca9b2d1ad0c61f5784e7157c362c06536f5ddacdad4451693f48fcc72fff6244073797374656d2d707265706172696e67000300000001000008c0dc64696b724968f9dea1a2c3071d8d52c730876ca45242c2ecfad00c07349dc4bd6c565f5c6bc6f9648260254b35065f186170702d696450217001b5ca75468a42218cf69aba596d0e80e96b0300000001000008c01a2fbce333d59cdd3cd37db0ee22f498d9deb188169631a26fbf40dbdd0a685a8d930cf0dba46bb79bee3a0b868bb1bd30636f6d706f73652d6861736880c97c1e4d612936e2cce80c4d17f65590857394cf50dd7f83f85ea1e4ee47315f0300000001000008c0d9420f5364695b9a5003702eb3adeeb09535fd756bbecf3e62a118e600172f90ec20e9d94d442ab4c9d6f703deea28a22c696e7374616e63652d6964500803c9828f9a23eb841996a507c40fe5235179650300000001000008c098bd7e6bd3952720b65027fd494834045d06b4a714bf737a06b874638b3ea00ff402f7f583e3e3b05e921c8570433ac630626f6f742d6d722d646f6e65000300000001000008c07f788209b6106d43ad4b0cef85797120c234f175a10b7cb609a73ec1f4285adfe7abca2e1e3d3bd2ba058c2b78d1771d306b65792d70726f76696465725c7b226e616d65223a226e6f6e65222c226964223a22227d0300000001000008c0ba51104636900268b0e059fa3d266419d079d1e94aea26fb9fcbb8d764bf4c89a67ac271b8a0d1a3989945132a111fc72873746f726167652d66730c7a66730300000001000008c01a76b2a80a0be71eae59f80945d876351a7a3fb8e9fd1ff1cede5734aa84ea11fd72b4edfbb6f04e5a85edd114c751bd3073797374656d2d726561647900204073797374656d2d707265706172696e6700186170702d696450217001b5ca75468a42218cf69aba596d0e80e96b30636f6d706f73652d6861736880c97c1e4d612936e2cce80c4d17f65590857394cf50dd7f83f85ea1e4ee47315f2c696e7374616e63652d6964500803c9828f9a23eb841996a507c40fe52351796530626f6f742d6d722d646f6e6500306b65792d70726f76696465725c7b226e616d65223a226e6f6e65222c226964223a22227d2873746f726167652d66730c7a66733073797374656d2d72656164790011223344556677880000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011b7b226f735f696d6167655f68617368223a2265643837643665646539343062316565633638666361313434623762313735323466366431303536636633326334386133303062376164343437363364353637222c226370755f636f756e74223a312c226d656d6f72795f73697a65223a323134373438333634382c2271656d755f76657273696f6e223a2231302e322e31222c227063695f686f6c6536345f73697a65223a302c22687567657061676573223a66616c73652c226e756d5f67707573223a302c226e756d5f6e767377697463686573223a302c22686f74706c75675f6f6666223a66616c73652c22696d616765223a2264737461636b2d302e362e30222c22686f73745f73686172655f6d6f6465223a223970222c226f766d665f76617269616e74223a22707265323032353035222c227464785f6174746573746174696f6e5f76617269616e74223a226c697465222c22737065635f76657273696f6e223a312c227464785f6d6561737572656d656e74223a7b22636865636b73756d5f66696c65223a224e4445794e6d4a6a5a6a55795a5752694d4755324e7a4d354f4463354e6a4e684d6a49325a6d49325a54686a4e5755785a44526b4f5759785a5467325a545135597a5a69597a67324f4749794f5467344f54566a4e69416762335a745a69356d5a416f7a4d3249794e6d55774f44566d596a566b4f445532597a59354d54557a4d546b784f474e6c4f4759344e325a694d475579596a4d7a4f5451324e5751324e4445344d475978596a4e6d5a4451304d544e6d4e44493149434269656b6c745957646c436d4d325a5759794e6a6330596d55314f5755774d4751335a6a6b774e7a466b5a6a466c4f4449774f4755325a6a45784d32466b4e7a5a684d474a6a596a67305a6a41774f545a684d4467794d54426a4d44557a4d7a4d6749476c75615852795957316d6379356a63476c764c6d6436436a426a596a5979596a4e684e6a68685a5759795a6d526c5a575933596d597a597a51345a4467345a44426a4d5445794f5746684e44466d4e6d49344f574935597a41785a6a4a6d4d474e6a597a4d325a57526c596a67674947316c6447466b595852684c6d707a6232344b5a6a6c6a5a4463795a44517a596a51794e32566a595749795a54426a5a474d314d6d55344e6d566c4f4749345a6a4e6959325a6d4f5467315a5441344d575a684e54526c4d3259304d44457a4d6a64685a575a6d4f53416762575668633356795a57316c626e5175644752344c6d4e696233494b4d6a59314f5751324e44426a4e4759315a5752685a6d49314e4745334d474d344f544e6d596d5a6c5a5459305a5745324e3249774e4745305a6a67335a6a51355a546b304f474d7a59574578597a6c6c4d7a5a6a4f53416762575668633356795a57316c626e5175633235774c6d4e696233494b4d4749784d475a6c4f4755354d574978596a6b354e5467775a47466c4f5752694d544d354e6d46684d4751344e6a63304d6d4d30595751774e6a4a6d4f444131597a4d784d6a6b334e5751354e6a59335a6a4d305a69416762575668633356795a57316c626e51755a324e774c6d4e696233494b222c226d6561737572656d656e74223a226f3264325a584a7a61573975413256706257466e5a6152755932316b62476c755a56397a6147457a4f4452594d4b536e4f30704c71456337782f377367492f32613346784366443656456d4a3473533765473379564a4868686f564835644f426f7145545a44514167656f33736e4e725a584a755a577866595856306147567564476c6a6232526c5744434943574c30464f516d3038436b7042344662354638656b75314732374352497064537647335054636a494e3243706e66795275585733564951387a374d727a42344747746c636d356c6246396f5a57466b5a584a66626d3979625746736158706c5a5056746157357064484a6b58334e6f59544d344e466777542b54336351453070683139377a5636335772464339762b376c4179704d45414e3134676368622f35436f37315949724a4f5a352b5255422f2f6556754255685a48526b646d616a5a47393262575a7063484a6c4d6a41794e5441315a4731796447536961334e70626d64735a56397759584e7a5744434f302f592f6a71442f7a33557a494a653646724172456830596b76322f456b3246493752666e525536716a6a3147647a5738552f335051375675415554544e526f644864765833426863334e594d486a4c4f746561306d3079462b5053696b4b7870567572413033384e4869386b515757303279505276462b7234752b47664a2b6d466a51477659394d5759472b325a305a46396f62324a4d6742414a424141474351494c41684151227d7d" +} diff --git a/dstack/verifier/fixtures/tdx-lite.README.md b/dstack/verifier/fixtures/tdx-lite.README.md index 10d782e25..867a1d361 100644 --- a/dstack/verifier/fixtures/tdx-lite.README.md +++ b/dstack/verifier/fixtures/tdx-lite.README.md @@ -65,3 +65,49 @@ Expected result: `Valid: true`, with quote, event log, OS image hash, and ACPI tables all verified. The ACPI digests are regenerated in-process from the fixture's VM shape (2 vCPUs, 2 GiB, QEMU 8.2.2) and must equal the ones the captured CVM reported. + +## Normalized kernel header fixture + +`tdx-lite-normalized-attestation.json` covers the other kernel measurement. +Images whose OVMF normalizes the Linux setup header declare +`"kernel_header_normalized": true` in `metadata.json`, which is mirrored into +the measurement document, and their RTMR[1] is the plain Authenticode hash of the shipped +`bzImage`. The two fixtures above cover the pre-normalization behavior, which +every image built before that landed still has. + +`tdx-lite-normalized-qemu-10-2-attestation.json` is the **same image** captured +on **QEMU 10.2.1**, a version that does *not* rewrite the header. The pair is +the point of the whole change: + +| | QEMU 8.2.2 | QEMU 10.2.1 | | +| --- | --- | --- | --- | +| MRTD | `78cb3ad7...` | `8ed3f63f...` | differ -- page-add ordering | +| RTMR0 | `68102e7b...` | `131221a6...` | differ -- generated ACPI tables | +| **RTMR1** | `60db95c7...` | `60db95c7...` | **identical** | +| RTMR2 | `f873ce9b...` | `f873ce9b...` | identical | + +8.2.2 is the load-bearing one: it *does* rewrite the header, so that capture +only passes if the firmware actually undid the rewrite rather than merely +agreeing with itself. 10.2.1 then shows the digest did not move. + +- quote, event log and RTMR3 runtime events come from one boot of that CVM +- the event log replays to exactly the RTMR values in the quote +- `dstack-mr measure --cpu 1 --memory 2G --qemu-version 8.2.2` over that image + directory reproduces MRTD, RTMR[0], RTMR[1] and RTMR[2]. It reads + `kernel_header_normalized` from the image's `metadata.json`, so pointing it at + a pre-normalization image is expected to give a different RTMR[1]: + +``` +MRTD 78cb3ad7...316606fb +RTMR0 68102e7b...f07ffe96 +RTMR1 60db95c7...8f79d200 <- Authenticode SHA-384 of the shipped bzImage +RTMR2 f873ce9b...05793535 +``` + +Captured with `tools/vm-runner` against an image whose `ovmf.fd` carries +`0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch`. The CVM ran +with no key provider (`kms_enabled` and `local_key_provider_enabled` both +false), which makes the guest mint temporary app keys and boot all the way +through without any external service, and a `pre_launch_script` handed the quote +and the event log back over `notify-host` -- the host-shared 9p mount is +read-only inside the guest. diff --git a/dstack/verifier/src/verification.rs b/dstack/verifier/src/verification.rs index 8f46bf27b..f70a2946f 100644 --- a/dstack/verifier/src/verification.rs +++ b/dstack/verifier/src/verification.rs @@ -183,6 +183,11 @@ fn collect_rtmr_mismatch( // Bump whenever expected RTMR computation changes so stale entries get ignored. // v3: all supported OVMF measurements use the Pre202505 RTMR[0] layout. +// +// Setup-header normalization did not need a bump: images that predate it are +// measured exactly as before, and normalized images are new images whose +// `os_image_hash` -- part of the `VmConfig` this key hashes -- has never been +// cached. const MEASUREMENT_CACHE_VERSION: u32 = 3; #[derive(Clone, Serialize, Deserialize)] @@ -199,6 +204,7 @@ struct ImagePaths { kernel_cmdline: String, is_dev: bool, version: String, + kernel_header_normalized: bool, } pub struct CvmVerifier { @@ -313,6 +319,7 @@ impl CvmVerifier { kernel_path: &Path, initrd_path: &Path, kernel_cmdline: &str, + kernel_header_normalized: bool, ) -> Result { let firmware = fw_path.display().to_string(); let kernel = kernel_path.display().to_string(); @@ -332,6 +339,7 @@ impl CvmVerifier { .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 { @@ -361,6 +369,7 @@ impl CvmVerifier { kernel_path: &Path, initrd_path: &Path, kernel_cmdline: &str, + kernel_header_normalized: bool, ) -> Result { self.compute_measurement_details( vm_config, @@ -368,6 +377,7 @@ impl CvmVerifier { kernel_path, initrd_path, kernel_cmdline, + kernel_header_normalized, ) .map(|details| details.measurements) } @@ -379,6 +389,7 @@ impl CvmVerifier { kernel_path: &Path, initrd_path: &Path, kernel_cmdline: &str, + kernel_header_normalized: bool, ) -> Result { let cache_key = Self::vm_config_cache_key(vm_config)?; @@ -392,6 +403,7 @@ impl CvmVerifier { kernel_path, initrd_path, kernel_cmdline, + kernel_header_normalized, )?; if let Err(e) = self.store_measurements_in_cache(&cache_key, &measurements) { @@ -646,6 +658,7 @@ impl CvmVerifier { kernel_cmdline, is_dev: image_info.is_dev, version: image_info.version, + kernel_header_normalized: image_info.kernel_header_normalized, }) } @@ -666,6 +679,7 @@ impl CvmVerifier { &image_paths.kernel_path, &image_paths.initrd_path, &image_paths.kernel_cmdline, + image_paths.kernel_header_normalized, ) } @@ -944,6 +958,7 @@ impl CvmVerifier { &image_paths.kernel_path, &image_paths.initrd_path, &image_paths.kernel_cmdline, + image_paths.kernel_header_normalized, ) .context("Failed to compute expected measurements")?; @@ -962,6 +977,7 @@ impl CvmVerifier { &image_paths.kernel_path, &image_paths.initrd_path, &image_paths.kernel_cmdline, + image_paths.kernel_header_normalized, ) .context("Failed to compute expected measurements")?, None, @@ -2248,6 +2264,70 @@ mod tests { ); } + /// Captured from a CVM whose OVMF normalizes the Linux setup header, so + /// RTMR[1] is the plain Authenticode hash of the shipped kernel. The host + /// ran QEMU 8.2.2 -- a version that *does* rewrite the header -- so this + /// only passes if the firmware actually undid that rewrite. + #[tokio::test] + async fn verifies_tdx_lite_fixture_with_normalized_kernel_header() { + let request: VerificationRequest = serde_json::from_str(include_str!( + "../fixtures/tdx-lite-normalized-attestation.json" + )) + .expect("normalized TDX lite verifier fixture parses"); + let cache = tempfile::tempdir().expect("temp cache dir"); + let image_cache_dir = cache.path().join("cache"); + let verifier = CvmVerifier::new( + image_cache_dir.display().to_string(), + "http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz".to_string(), + Duration::from_secs(1), + test_attestation_verifier(), + ); + + let response = verifier.verify(request).await.expect("verifier runs"); + assert!(response.is_valid, "{:?}", response.reason); + assert!(response.details.quote_verified); + assert!(response.details.event_log_verified); + assert!(response.details.os_image_hash_verified); + assert!(response.details.acpi_tables_verified); + assert!( + !image_cache_dir.exists(), + "TDX lite verification must not download or cache OS images" + ); + } + + /// The same image as the fixture above, captured on QEMU 10.2.1 -- a + /// version that does *not* rewrite the setup header. Its RTMR[1] is + /// byte-for-byte the one the 8.2.2 capture produced, which is the whole + /// point of normalizing: the digest no longer depends on the host's QEMU. + /// MRTD and RTMR[0] do differ, because page-add ordering and the generated + /// ACPI tables genuinely are version-specific. + #[tokio::test] + async fn verifies_tdx_lite_fixture_with_normalized_kernel_header_on_qemu_10_2() { + let request: VerificationRequest = serde_json::from_str(include_str!( + "../fixtures/tdx-lite-normalized-qemu-10-2-attestation.json" + )) + .expect("QEMU 10.2 normalized TDX lite verifier fixture parses"); + let cache = tempfile::tempdir().expect("temp cache dir"); + let image_cache_dir = cache.path().join("cache"); + let verifier = CvmVerifier::new( + image_cache_dir.display().to_string(), + "http://127.0.0.1:9/should-not-download/{OS_IMAGE_HASH}.tar.gz".to_string(), + Duration::from_secs(1), + test_attestation_verifier(), + ); + + let response = verifier.verify(request).await.expect("verifier runs"); + assert!(response.is_valid, "{:?}", response.reason); + assert!(response.details.quote_verified); + assert!(response.details.event_log_verified); + assert!(response.details.os_image_hash_verified); + assert!(response.details.acpi_tables_verified); + assert!( + !image_cache_dir.exists(), + "TDX lite verification must not download or cache OS images" + ); + } + /// The captured VM ran 2 vCPUs; a VM shape that disagrees with the quote /// must not reproduce its ACPI digests, which is what makes the recomputed /// digests worth comparing in the first place. diff --git a/dstack/vmm/src/app.rs b/dstack/vmm/src/app.rs index 836176d4b..baa947078 100644 --- a/dstack/vmm/src/app.rs +++ b/dstack/vmm/src/app.rs @@ -2413,6 +2413,7 @@ mod tests { fn dummy_tdx_measurement_document() -> TdxOsImageMeasurementDocument { let measurement = TdxOsImageMeasurement { + kernel_header_normalized: true, image: TdxImageMeasurement { kernel_cmdline_sha384: vec![0x10; 48], kernel_authenticode: vec![0x20; 48], diff --git a/os/image/README.md b/os/image/README.md index 91f96e646..b93a39883 100644 --- a/os/image/README.md +++ b/os/image/README.md @@ -46,3 +46,54 @@ the helper lives beside the common assembler. `dstack-image-oci.sh` pushes and lists assembled guest-image directories in an OCI registry. It is likewise independent of the backend that produced the image. + +## Kernel setup-header normalization + +`assemble.sh` runs `normalize-kernel-header.py` over `bzImage` before it +computes any measurement. The shipped kernel therefore differs from the raw +kernel build output, by design. + +QEMU is the boot loader for `-kernel`: it fills in the setup-header fields the +Linux boot protocol expects a boot loader to supply (`type_of_loader`, +`ramdisk_image`/`ramdisk_size`, `heap_end_ptr`, `cmd_line_ptr`, ...) and serves +the result over fw_cfg. OVMF measures those bytes into RTMR[1]. QEMU commit +`a7542a38f399` ("x86/loader: Don't update kernel header for CoCo VMs", first +released in 10.2.0) stopped rewriting the header for confidential guests, so +without normalization the same image measures differently depending on the +host's QEMU version — and the host is the one that declares that version. + +The fix has two halves that must stay in sync: + +- this script zeroes those fields in the kernel we ship; +- `0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch` zeroes them + again in OVMF, before the kernel blob is measured and loaded. + +The result is that RTMR[1] is the plain Authenticode hash of `bzImage` as +listed in `sha256sum.txt`, on every QEMU version and at every guest memory +size. + +`assemble.sh` records this in `metadata.json` as `"kernel_header_normalized": +true`, and re-runs the script with `--check` first so the build fails rather +than shipping a kernel that disagrees with what the image declares. Images +without the field are the ones built before this existed; `dstack-mr` measures +those the old way, against QEMU's rewritten header. + +The field set comes from the boot protocol, not from QEMU's behavior: every +field `Documentation/arch/x86/boot.rst` types as `write` is one the boot loader +fills in and the kernel supplies no value for. Fields typed `modify` carry real +kernel-supplied values — `code32_start` is the protected-mode entry point — and +are deliberately left alone. + +In practice this rewrites **two bytes**: `heap_end_ptr` (0x224) is the only +`write` field a built kernel leaves non-zero. That is safe for every boot path: +the boot protocol types it `write (obligatory)`, `init_heap()` reads it only +when the boot loader has set `CAN_USE_HEAP` (which the kernel builds clear and +this script also clears), and on the EFI-stub path the real-mode setup code +never runs at all. The PE headers sit at 0x40..0x170, so no setup-header field +overlaps them and the EFI entry point is untouched. + +To check an image without modifying it: + +```bash +./normalize-kernel-header.py --check /path/to/bzImage +``` diff --git a/os/image/assemble.sh b/os/image/assemble.sh index 30349c857..b27c5a880 100755 --- a/os/image/assemble.sh +++ b/os/image/assemble.sh @@ -411,6 +411,20 @@ verbose rm -rf "${OUTPUT_DIR}/" verbose mkdir -p "${OUTPUT_DIR}/" verbose cp "$INITRAMFS_IMAGE" "${OUTPUT_DIR}/initramfs.cpio.gz" verbose cp "$KERNEL_IMAGE" "${OUTPUT_DIR}/bzImage" +# QEMU acts as the boot loader for -kernel and fills in the setup-header fields +# the Linux boot protocol expects a boot loader to supply; OVMF measures the +# result into RTMR[1]. QEMU >= 10.2 stopped doing that for confidential guests, +# so leaving the header as built would make the same image measure differently +# per QEMU version. Normalizing here, and again in OVMF before it measures, +# makes RTMR[1] the plain Authenticode hash of this file. Runs before +# tdx-measurement-cbor and sha256sum.txt below, so both cover the normalized +# kernel. The matching half is in OVMF: metadata.json declares +# kernel_header_normalized below, and what makes that declaration true is +# 0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch, which this +# same build applies -- ovmf-build.sh and the bitbake recipe both fail if it +# does not apply. See os/image/README.md. +verbose "$(dirname "${BASH_SOURCE[0]}")/normalize-kernel-header.py" \ + "${OUTPUT_DIR}/bzImage" verbose cp "$OVMF_FIRMWARE" "${OUTPUT_DIR}/ovmf.fd" # AMD SEV firmware (additive). Shipped alongside the TDX firmware so a SEV-SNP @@ -459,7 +473,8 @@ cat < "${OUTPUT_DIR}/metadata.json" "builder": "$BACKEND", "shared_ro": true, "is_dev": ${IS_DEV}, - "ovmf_variant": "$OVMF_VARIANT" + "ovmf_variant": "$OVMF_VARIANT", + "kernel_header_normalized": true } EOF diff --git a/os/image/normalize-kernel-header.py b/os/image/normalize-kernel-header.py new file mode 100755 index 000000000..d628e5f4f --- /dev/null +++ b/os/image/normalize-kernel-header.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 Phala Network +# SPDX-License-Identifier: Apache-2.0 +"""Normalize the Linux setup header of a bzImage. + +QEMU acts as the boot loader for `-kernel` and fills in the setup-header fields +the Linux boot protocol expects a boot loader to supply. OVMF measures the +result into RTMR[1], so the same image measures differently depending on +whether QEMU rewrote the header -- which changed in QEMU 10.2 (commit +a7542a38f399, "x86/loader: Don't update kernel header for CoCo VMs"). + +Zeroing those fields in the shipped kernel, and having OVMF zero them again +before measuring, makes RTMR[1] the plain Authenticode hash of the file we +ship, on every QEMU version. + +The field set comes from the boot protocol, not from QEMU: every field +`Documentation/arch/x86/boot.rst` types as `write` is one the boot loader +fills in and the kernel supplies no value for. Fields typed `modify` carry +real kernel-supplied values (`code32_start` is the protected-mode entry point) +and are left alone. + +In a freshly built kernel every one of these fields is already zero except +`heap_end_ptr`, so this normally rewrites exactly two bytes. +""" + +import argparse +import sys + +# Offset, size, name -- every boot.rst field typed `write`. +WRITE_FIELDS = [ + (0x210, 1, "type_of_loader"), + (0x218, 4, "ramdisk_image"), + (0x21C, 4, "ramdisk_size"), + (0x224, 2, "heap_end_ptr"), + (0x226, 1, "ext_loader_ver"), + (0x227, 1, "ext_loader_type"), + (0x228, 4, "cmd_line_ptr"), + (0x23C, 4, "hardware_subarch"), + (0x240, 8, "hardware_subarch_data"), + (0x250, 8, "setup_data"), +] + +LOADFLAGS_OFFSET = 0x211 +CAN_USE_HEAP = 0x80 + +HEADER_MAGIC_OFFSET = 0x202 +HEADER_MAGIC = b"HdrS" +VERSION_OFFSET = 0x206 +# One past the last field this touches. Anything shorter cannot carry a setup +# header, and slicing past the end would silently grow the image instead of +# failing. The OVMF side applies the same bound. +HEADER_END_OFFSET = 0x258 +# `setup_data` (0x250) requires 2.09+; every field above exists by then. +MIN_PROTOCOL = 0x0209 + + +def normalize(image: bytearray) -> list: + """Zero the boot-loader-written fields. Returns the fields it changed.""" + if len(image) < HEADER_END_OFFSET: + raise ValueError( + f"image is {len(image)} bytes, shorter than the " + f"0x{HEADER_END_OFFSET:x}-byte setup header" + ) + if image[HEADER_MAGIC_OFFSET : HEADER_MAGIC_OFFSET + 4] != HEADER_MAGIC: + raise ValueError("not a Linux bzImage: missing HdrS magic at 0x202") + protocol = int.from_bytes(image[VERSION_OFFSET : VERSION_OFFSET + 2], "little") + if protocol < MIN_PROTOCOL: + raise ValueError( + f"boot protocol {protocol >> 8}.{protocol & 0xFF:02} is older than " + f"{MIN_PROTOCOL >> 8}.{MIN_PROTOCOL & 0xFF:02}; the field layout this " + "script normalizes is not guaranteed" + ) + + changed = [] + for offset, size, name in WRITE_FIELDS: + old = bytes(image[offset : offset + size]) + if old != bytes(size): + changed.append((name, offset, old.hex(), "0" * (size * 2))) + image[offset : offset + size] = bytes(size) + + loadflags = image[LOADFLAGS_OFFSET] + if loadflags & CAN_USE_HEAP: + changed.append( + ( + "loadflags", + LOADFLAGS_OFFSET, + f"{loadflags:02x}", + f"{loadflags & ~CAN_USE_HEAP:02x}", + ) + ) + image[LOADFLAGS_OFFSET] = loadflags & ~CAN_USE_HEAP + + return changed + + +def main() -> int: + """Normalize the image named on the command line, or check it in place.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bzimage", help="kernel image to normalize in place") + parser.add_argument( + "--check", + action="store_true", + help="report whether the image is already normalized, do not write", + ) + args = parser.parse_args() + + with open(args.bzimage, "rb") as f: + image = bytearray(f.read()) + + changed = normalize(image) + + for name, offset, old, new in changed: + print(f"{args.bzimage}: {name} (0x{offset:03x}) {old} -> {new}") + + if args.check: + if changed: + print(f"{args.bzimage}: not normalized", file=sys.stderr) + return 1 + return 0 + + if changed: + with open(args.bzimage, "wb") as f: + f.write(image) + else: + print(f"{args.bzimage}: already normalized") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/os/mkosi/components/ovmf/ovmf-build.sh b/os/mkosi/components/ovmf/ovmf-build.sh index e7766e4c7..d365d1ea0 100755 --- a/os/mkosi/components/ovmf/ovmf-build.sh +++ b/os/mkosi/components/ovmf/ovmf-build.sh @@ -51,7 +51,8 @@ git -C "$src" reset -q --hard "$REV" # build byte-comparable with the production one. for patch in 0003-Debug-prefix-map 0004-Reproduciable \ 0005-UefiCpuPkg-CpuExceptionHandlerLib-fix-push-instructi \ - 0006-OvmfPkg-AmdSev-drop-embedded-grub; do + 0006-OvmfPkg-AmdSev-drop-embedded-grub \ + 0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header; do patch -d "$src" -p1 --forward --fuzz=0 < \ "$ROOT/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/$patch.patch" done diff --git a/os/mkosi/components/ovmf/ovmf.sh b/os/mkosi/components/ovmf/ovmf.sh index 8075ff7f8..1056a2a41 100644 --- a/os/mkosi/components/ovmf/ovmf.sh +++ b/os/mkosi/components/ovmf/ovmf.sh @@ -11,7 +11,8 @@ component_cache_key() { "$ROOT/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0003-Debug-prefix-map.patch" \ "$ROOT/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0004-Reproduciable.patch" \ "$ROOT/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0005-UefiCpuPkg-CpuExceptionHandlerLib-fix-push-instructi.patch" \ - "$ROOT/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0006-OvmfPkg-AmdSev-drop-embedded-grub.patch" + "$ROOT/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0006-OvmfPkg-AmdSev-drop-embedded-grub.patch" \ + "$ROOT/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch" key_tools gcc make python3 key_packages nasm acpica-tools uuid-dev } diff --git a/os/mkosi/tests/acceptance.sh b/os/mkosi/tests/acceptance.sh index 828904e74..493315a44 100755 --- a/os/mkosi/tests/acceptance.sh +++ b/os/mkosi/tests/acceptance.sh @@ -168,14 +168,20 @@ yocto_srcrev=$(sed -n 's/^SRCREV = "\([0-9a-f]\{40\}\)"/\1/p' "$ovmf_recipe") grep -q "^OVMF_REVISION=$yocto_srcrev\$" "$D/versions.env" # 0003 and 0005 are what make the prefix map reach the compiler and let # stable202502 assemble with NASM 3.x; both were previously dropped. +# 0007 is half of the setup-header normalization; without it in the firmware +# the shipped kernel and the measured kernel disagree and every CVM fails on +# RTMR[1]. os/tests/test-kernel-header-normalization.sh keeps the two halves +# in sync; this only checks the patch is still applied. for patch in 0003-Debug-prefix-map 0004-Reproduciable \ 0005-UefiCpuPkg-CpuExceptionHandlerLib-fix-push-instructi \ - 0006-OvmfPkg-AmdSev-drop-embedded-grub; do + 0006-OvmfPkg-AmdSev-drop-embedded-grub \ + 0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header; do grep -q "$patch" "$D/components/ovmf/ovmf-build.sh" done grep -q 'AmdSev/AmdSevX64.dsc' "$D/components/ovmf/ovmf-build.sh" grep -q '0006-OvmfPkg-AmdSev-drop-embedded-grub.patch' "$D/components/ovmf/ovmf.sh" grep -q '0005-UefiCpuPkg' "$D/components/ovmf/ovmf.sh" +grep -q '0007-OvmfPkg-QemuKernelLoaderFsDxe' "$D/components/ovmf/ovmf.sh" grep -q 'objcopy --strip-debug' "$D/mkosi.build" grep -q 'depmod -b.*KERNEL_VERSION-dstack' "$D/mkosi.build" grep -q '^CleanPackageMetadata=yes$' "$D/mkosi.conf" @@ -226,6 +232,7 @@ PYTHONPYCACHEPREFIX="$pycache" python3 -m py_compile "$D"/scripts/*.py "$D"/test # measurements, so both must come from the single shared definition. grep -q 'kernel-cmdline.sh' "$D/scripts/make-release-artifacts.sh" grep -q 'kernel-cmdline.sh' "$D/../image/assemble.sh" +grep -q 'normalize-kernel-header.py' "$D/../image/assemble.sh" if grep -q 'random.trust_bootloader' "$D/scripts/make-release-artifacts.sh"; then echo 'kernel command line must not be restated outside kernel-cmdline.sh' >&2 exit 1 @@ -264,6 +271,9 @@ for component in dstack-rust image-tools container-stack sysbox nvattest kernel grep -q '^component_build()' "$definition" grep -q '^COMPONENT_CACHE_PATHS=' "$definition" done +# The image build and OVMF each implement the setup-header normalization; if +# they drift, every CVM fails on RTMR[1] and nothing points at why. +"$D/../tests/test-kernel-header-normalization.sh" "$D/tests/test-dev-cache.sh" "$D/tests/test-component-framework.sh" "$D/tests/test-component-merge.sh" diff --git a/os/tests/test-kernel-header-normalization.sh b/os/tests/test-kernel-header-normalization.sh new file mode 100755 index 000000000..73bb9e710 --- /dev/null +++ b/os/tests/test-kernel-header-normalization.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: © 2026 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 +# +# The setup-header normalization has two independent implementations that must +# agree byte for byte, because one produces the kernel we ship and the other +# produces the bytes OVMF measures: +# +# os/image/normalize-kernel-header.py (image build) +# 0007-OvmfPkg-...-normalize-setup-header.patch (firmware) +# +# If they ever disagree, every CVM fails attestation with an RTMR[1] mismatch +# and nothing else points at why. So this test parses the field table out of +# both and compares them, then exercises the Python side against a synthetic +# bzImage. +set -euo pipefail + +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +root=$(cd -- "$here/../.." && pwd) +script=$root/os/image/normalize-kernel-header.py +patch=$root/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch + +[ -x "$script" ] || { echo "missing $script" >&2; exit 1; } +[ -f "$patch" ] || { echo "missing $patch" >&2; exit 1; } + +python3 - "$script" "$patch" <<'PYEOF' +import importlib.util +import re +import sys + +script_path, patch_path = sys.argv[1], sys.argv[2] + +spec = importlib.util.spec_from_file_location("normalize", script_path) +normalize = importlib.util.module_from_spec(spec) +spec.loader.exec_module(normalize) + +py_fields = sorted((offset, size) for offset, size, _ in normalize.WRITE_FIELDS) + +# Added lines of the form " { 0x210, 1 }, // type_of_loader". +patch_text = open(patch_path, encoding="ascii").read() +ovmf_fields = sorted( + (int(offset, 16), int(size)) + for offset, size in re.findall( + r"^\+\s*\{\s*(0x[0-9A-Fa-f]+),\s*(\d+)\s*\},", patch_text, re.M + ) +) + +assert ovmf_fields, "no field table found in the OVMF patch" +assert py_fields == ovmf_fields, ( + "normalization field tables disagree\n" + f" image build: {[(hex(o), s) for o, s in py_fields]}\n" + f" OVMF patch: {[(hex(o), s) for o, s in ovmf_fields]}" +) + +for token in ("LINUX_HDR_MIN_PROTOCOL 0x0209", "LINUX_LOADFLAGS_CAN_USE_HEAP 0x80"): + assert token in patch_text, f"OVMF patch no longer defines {token}" +assert normalize.MIN_PROTOCOL == 0x0209 +assert normalize.CAN_USE_HEAP == 0x80 + +# `modify` fields carry kernel-supplied values and must never be normalized. +# code32_start is the protected-mode entry point; zeroing it bricks the kernel. +for forbidden in (0x1F2, 0x1FA, 0x211, 0x212, 0x214): + assert all(offset != forbidden for offset, _ in py_fields), ( + f"0x{forbidden:x} is a `modify` field and must not be zeroed" + ) + +# Synthetic bzImage: a plausible built kernel, then the same image with every +# boot-loader-written field filled in the way QEMU fills them. +def make_image(patched: bool) -> bytearray: + image = bytearray(0x1000) + image[0x202:0x206] = b"HdrS" + image[0x206:0x208] = (0x020F).to_bytes(2, "little") + image[0x1F2:0x1F4] = (0x0001).to_bytes(2, "little") # root_flags + image[0x1FA:0x1FC] = (0xFFFF).to_bytes(2, "little") # vid_mode + image[0x211] = 0x01 # loadflags: LOADED_HIGH + image[0x212:0x214] = (0x8000).to_bytes(2, "little") # setup_move_size + image[0x214:0x218] = (0x100000).to_bytes(4, "little") # code32_start + image[0x224:0x226] = (0x50A0).to_bytes(2, "little") # heap_end_ptr + if patched: + image[0x210] = 0xB0 + image[0x211] |= 0x80 + image[0x218:0x21C] = (0xA97FC000).to_bytes(4, "little") + image[0x21C:0x220] = (0x0062A954).to_bytes(4, "little") + image[0x224:0x226] = (0xFE00).to_bytes(2, "little") + image[0x228:0x22C] = (0x20000).to_bytes(4, "little") + return image + +built, patched = make_image(False), make_image(True) +assert built != patched + +normalize.normalize(built) +normalize.normalize(patched) +assert built == patched, "normalizing a QEMU-patched kernel must reproduce the shipped one" + +# Idempotent, so OVMF re-running it over an already normalized kernel is a no-op. +again = bytearray(built) +assert normalize.normalize(again) == [] +assert again == built + +# The `modify` fields survived. +assert built[0x1F2:0x1F4] == (0x0001).to_bytes(2, "little") +assert built[0x1FA:0x1FC] == (0xFFFF).to_bytes(2, "little") +assert built[0x211] == 0x01 +assert built[0x212:0x214] == (0x8000).to_bytes(2, "little") +assert built[0x214:0x218] == (0x100000).to_bytes(4, "little") + +# The two sides deliberately differ on a non-bzImage. OVMF boots arbitrary EFI +# binaries through the same path and must leave them alone (QEMU stopped +# patching them in commit 05e984c200a), while at build time a kernel without +# the HdrS magic is a broken build and has to fail loudly. +other = bytearray(0x1000) +other[0x210] = 0xB0 +try: + normalize.normalize(other) +except ValueError: + pass +else: + raise AssertionError("the image build must reject a non-bzImage") +assert "return;" in patch_text.split('CompareMem (Data + LINUX_HDR_MAGIC_OFFSET')[1][:400], ( + "the OVMF side must return quietly on a non-bzImage" +) + +# Too old a boot protocol: the field layout is not guaranteed, so neither side +# normalizes. The image build still fails loudly about it. +ancient = bytearray(0x1000) +ancient[0x202:0x206] = b"HdrS" +ancient[0x206:0x208] = (0x0208).to_bytes(2, "little") +try: + normalize.normalize(ancient) +except ValueError: + pass +else: + raise AssertionError("the image build must reject boot protocols below 2.09") + +print("kernel setup-header normalization: OK") +PYEOF diff --git a/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch b/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch new file mode 100644 index 000000000..30f41be5b --- /dev/null +++ b/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf/0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch @@ -0,0 +1,156 @@ +From: dstack +Subject: [PATCH] OvmfPkg/QemuKernelLoaderFsDxe: normalize the Linux setup header + +QEMU is the boot loader for -kernel: it fills in the setup-header fields the +Linux boot protocol expects a boot loader to supply, then serves the result +over fw_cfg. This driver publishes those bytes as the virtual "kernel" file, +and that is what gets measured into RTMR[1] (PCR[4]) and loaded. + +QEMU commit a7542a38f399 ("x86/loader: Don't update kernel header for CoCo +VMs", first released in 10.2.0) stopped rewriting the header for confidential +guests, so from 10.2 on the same kernel measures differently than it does on +10.1 and earlier. Attestation has to know the host's QEMU version to predict +its own RTMR[1], and the host declares that version. + +Zero the fields the boot protocol says a boot loader owns, which makes the +published bytes independent of what QEMU did. The dstack image build applies +the same normalization to the kernel it ships, so the measurement is the plain +Authenticode hash of that file on every QEMU version. + +The field set is taken from Documentation/arch/x86/boot.rst rather than from +QEMU's behavior: fields typed `write` are ones the boot loader fills in and the +kernel supplies no value for. Fields typed `modify` are left alone because they +carry real kernel-supplied values. + +Booting is unaffected. On the EFI-stub path the real-mode setup code never +runs, and heap_end_ptr -- the only one of these fields a built kernel leaves +non-zero -- is read only when the boot loader has set CAN_USE_HEAP, which this +also clears. + +Signed-off-by: dstack +--- +--- a/OvmfPkg/QemuKernelLoaderFsDxe/QemuKernelLoaderFsDxe.c ++++ b/OvmfPkg/QemuKernelLoaderFsDxe/QemuKernelLoaderFsDxe.c +@@ -49,6 +49,111 @@ + KERNEL_BLOB *Next; + }; + ++// ++// Offsets into the Linux x86 setup header, from ++// Documentation/arch/x86/boot.rst. ++// ++#define LINUX_HDR_MAGIC_OFFSET 0x202 ++#define LINUX_HDR_VERSION_OFFSET 0x206 ++#define LINUX_HDR_LOADFLAGS_OFFSET 0x211 ++#define LINUX_HDR_END_OFFSET 0x258 ++ ++// ++// CAN_USE_HEAP in loadflags. The kernel builds it clear; a boot loader sets it ++// when it has also filled in heap_end_ptr. ++// ++#define LINUX_LOADFLAGS_CAN_USE_HEAP 0x80 ++ ++// ++// setup_data (0x250) arrived in boot protocol 2.09. Below that the field ++// layout normalized here is not guaranteed, so the image is left alone. ++// ++#define LINUX_HDR_MIN_PROTOCOL 0x0209 ++ ++// ++// Every setup-header field boot.rst types as `write`: the boot loader fills it ++// in and the kernel supplies no value, so zeroing it discards nothing. Fields ++// typed `modify` carry real kernel-supplied values -- code32_start (0x214) is ++// the protected-mode entry point -- and are deliberately absent. ++// ++STATIC CONST struct { ++ UINT16 Offset; ++ UINT16 Size; ++} mLinuxBootLoaderWrittenFields[] = { ++ { 0x210, 1 }, // type_of_loader ++ { 0x218, 4 }, // ramdisk_image ++ { 0x21C, 4 }, // ramdisk_size ++ { 0x224, 2 }, // heap_end_ptr ++ { 0x226, 1 }, // ext_loader_ver ++ { 0x227, 1 }, // ext_loader_type ++ { 0x228, 4 }, // cmd_line_ptr ++ { 0x23C, 4 }, // hardware_subarch ++ { 0x240, 8 }, // hardware_subarch_data ++ { 0x250, 8 }, // setup_data ++}; ++ ++/** ++ Clear the setup-header fields a boot loader owns, so the measured kernel does ++ not depend on what QEMU wrote there. ++ ++ QEMU acts as the boot loader for -kernel and fills these fields in before ++ serving the image over fw_cfg, which changes the bytes this driver publishes ++ and therefore the digest measured into RTMR[1]/PCR[4]. QEMU 10.2 stopped ++ doing that for confidential guests (commit a7542a38f399), so without this the ++ same kernel measures differently depending on the host's QEMU. ++ ++ Zeroing is idempotent and matches what the image build already writes into ++ the shipped kernel, so the measurement equals the Authenticode hash of the ++ file on disk on every QEMU version. ++ ++ @param[in,out] Data The kernel blob, starting at the setup header. ++ @param[in] Size Size of the blob in bytes. ++**/ ++STATIC ++VOID ++NormalizeLinuxSetupHeader ( ++ IN OUT UINT8 *Data, ++ IN UINT32 Size ++ ) ++{ ++ UINTN Idx; ++ UINT16 Protocol; ++ ++ if (Size < LINUX_HDR_END_OFFSET) { ++ return; ++ } ++ ++ if (CompareMem (Data + LINUX_HDR_MAGIC_OFFSET, "HdrS", 4) != 0) { ++ // ++ // Not a Linux bzImage. OVMF boots arbitrary EFI binaries this way, and ++ // QEMU does not patch those either. ++ // ++ return; ++ } ++ ++ Protocol = (UINT16)(Data[LINUX_HDR_VERSION_OFFSET] | ++ (Data[LINUX_HDR_VERSION_OFFSET + 1] << 8)); ++ if (Protocol < LINUX_HDR_MIN_PROTOCOL) { ++ DEBUG (( ++ DEBUG_WARN, ++ "%a: boot protocol %d.%02d is too old to normalize\n", ++ __func__, ++ Protocol >> 8, ++ Protocol & 0xFF ++ )); ++ return; ++ } ++ ++ for (Idx = 0; Idx < ARRAY_SIZE (mLinuxBootLoaderWrittenFields); Idx++) { ++ ZeroMem ( ++ Data + mLinuxBootLoaderWrittenFields[Idx].Offset, ++ mLinuxBootLoaderWrittenFields[Idx].Size ++ ); ++ } ++ ++ Data[LINUX_HDR_LOADFLAGS_OFFSET] &= (UINT8)~LINUX_LOADFLAGS_CAN_USE_HEAP; ++} ++ + STATIC KERNEL_BLOB_ITEMS mKernelBlobItems[] = { + { + L"kernel", +@@ -1063,6 +1168,10 @@ + ChunkData += BlobItems->FwCfgItem[Idx].Size; + } + ++ if (StrCmp (Blob->Name, L"kernel") == 0) { ++ NormalizeLinuxSetupHeader (Blob->Data, Blob->Size); ++ } ++ + Blob->Next = mKernelBlobs; + mKernelBlobs = Blob; + mKernelBlobCount++; diff --git a/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf_git.bb b/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf_git.bb index 79806beb0..0e5d89ed2 100644 --- a/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf_git.bb +++ b/os/yocto/layers/meta-dstack/recipes-core/dstack-ovmf/dstack-ovmf_git.bb @@ -26,6 +26,7 @@ SRC_URI = "gitsm://github.com/tianocore/edk2.git;branch=master;protocol=https \ file://0004-Reproduciable.patch \ file://0005-UefiCpuPkg-CpuExceptionHandlerLib-fix-push-instructi.patch \ file://0006-OvmfPkg-AmdSev-drop-embedded-grub.patch \ + file://0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch \ " # Pinned to edk2-stable202502 (Feb 2025) instead of the latest stable202505. @@ -87,6 +88,11 @@ DEPENDS = "nasm-native acpica-native ovmf-native util-linux-native" # grub, and that grub cannot be built here anyway (OE has no x86_64-efi grub # modules / no sevsecret). The patch fails loud if a future edk2 bump changes # the AmdSev layout. +# +# 0007-OvmfPkg-QemuKernelLoaderFsDxe-normalize-setup-header.patch zeroes the +# setup-header fields QEMU writes as boot loader, so RTMR[1] no longer depends +# on the host's QEMU version. It pairs with os/image/normalize-kernel-header.py, +# which applies the same normalization to the shipped bzImage. OVMF_BUILD_SEV ??= "1" EDK_TOOLS_DIR="edk2_basetools" diff --git a/tools/vm-runner/vm-runner.py b/tools/vm-runner/vm-runner.py index 7bce7fe4f..0c8f1ac44 100755 --- a/tools/vm-runner/vm-runner.py +++ b/tools/vm-runner/vm-runner.py @@ -130,7 +130,29 @@ def update_guest_config(config_file: str, data: Dict): json.dump(config, f, indent=4) -def gen_vm_config(vm_dir, host_port, manifest=None, os_image_hash=None): +def detect_qemu_version(qemu_path): + """Return QEMU's `major.minor.micro`, or None if it cannot be determined. + + MRTD depends on the QEMU version -- 8.x adds pages in two passes, 9.0+ in + one -- so a vm_config without this field makes the verifier fall back to a + default version and predict the wrong measurement. + """ + try: + output = subprocess.check_output( + [qemu_path, '--version'], text=True, stderr=subprocess.DEVNULL) + except (OSError, subprocess.SubprocessError) as exc: + logging.warning("failed to run %s --version: %s", qemu_path, exc) + return None + match = re.search(r'QEMU emulator version (\d+\.\d+\.\d+)', output) + if not match: + logging.warning("could not parse QEMU version from: %s", + output.splitlines()[0] if output else '') + return None + return match.group(1) + + +def gen_vm_config(vm_dir, host_port, manifest=None, os_image_hash=None, + qemu_version=None): shared_dir = os.path.join(vm_dir, 'shared') for filename in ['config.json', '.sys-config.json']: config_file = os.path.join(shared_dir, filename) @@ -139,12 +161,15 @@ def gen_vm_config(vm_dir, host_port, manifest=None, os_image_hash=None): "host_vsock_port": host_port }) if manifest: + vm_config = { + "os_image_hash": os_image_hash, + "cpu_count": manifest['vcpu'], + "memory_size": manifest['memory'] * 1024 * 1024 + } + if qemu_version: + vm_config["qemu_version"] = qemu_version update_guest_config(config_file, { - "vm_config": json.dumps({ - "os_image_hash": os_image_hash, - "cpu_count": manifest['vcpu'], - "memory_size": manifest['memory'] * 1024 * 1024 - }) + "vm_config": json.dumps(vm_config) }) @@ -408,7 +433,8 @@ def run_instance(self, vm_dir: str, host_port: int, imgdir: Optional[str] = None os_image_hash = open(os.path.join( image_path, 'digest.txt'), 'r').read().strip() - gen_vm_config(vm_dir, host_port, manifest, os_image_hash) + gen_vm_config(vm_dir, host_port, manifest, os_image_hash, + detect_qemu_version(self.config.qemu_path)) mem_gb = manifest['memory'] // 1024 vcpu_count = manifest['vcpu'] @@ -427,7 +453,10 @@ def run_instance(self, vm_dir: str, host_port: int, imgdir: Optional[str] = None # Prepare QEMU command cmd_args = [] rootfs_image = os.path.join(image_path, img_metadata['rootfs']) - if rootfs_image.endswith('.img.verity'): + # Current images ship `rootfs.img.parted.verity`; older ones shipped + # `rootfs.img.verity`. Both are raw verity disks the initramfs finds by + # PARTLABEL, so attach either the same way. + if rootfs_image.endswith('.verity'): cmd_args.extend([ '-drive', f'file={rootfs_image},if=none,id=virtio-disk0,format=raw', '-device', 'virtio-blk-pci,drive=virtio-disk0',