Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/security/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,29 @@ 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.

### A host-declared QEMU version selects between digests, it does not add one

QEMU 10.2 stopped rewriting the Linux setup header for confidential guests
(commit a7542a38f399, "x86/loader: Don't update kernel header for CoCo VMs"),
because doing so changed the kernel bytes away from the file the operator
passed to `-kernel` and broke TDX attestation. A TDX CVM therefore measures the
patched kernel into RTMR[1] under QEMU <= 10.1 and the kernel as built under
>= 10.2, so the OS image records both Authenticode digests and the verifier
picks one using the host-declared `vm_config.qemu_version`.

That declaration is untrusted, and it does not need to be trusted. Both
candidates are committed by the same measurement document, which is itself
bound to `os_image_hash`, so a lying host can only choose between two digests
that already belong to the image it declared. Whichever it picks still has to
equal the RTMR[1] the hardware signed, so misdeclaring the version cannot make
a different kernel verify -- it can only turn a good CVM into a rejected one,
which is the host degrading its own deployment.

Images built before the measurement document carried both digests are rejected
on the document's version number, with an error naming the unsupported version,
rather than half-decoded into a document that is missing the digest the CVM
actually needs.

### 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.
Expand Down
9 changes: 9 additions & 0 deletions dstack/dstack-mr/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ struct MachineConfig {
#[arg(long)]
pic: Option<Bool>,

/// Whether QEMU rewrites the Linux setup header before serving the kernel
/// over fw_cfg. Defaults to the behavior of --qemu-version: QEMU >= 10.2
/// leaves it alone for confidential guests. Set this only for a fork whose
/// behavior disagrees with its version number.
#[arg(long)]
patch_kernel_header: Option<Bool>,

/// Enable SMM
#[arg(long, default_value = "false")]
smm: Bool,
Expand Down Expand Up @@ -126,6 +133,7 @@ fn main() -> Result<()> {
.initrd(&initrd_path)
.kernel_cmdline(&cmdline)
.maybe_two_pass_add_pages(config.two_pass_add_pages)
.maybe_patch_kernel_header(config.patch_kernel_header)
.maybe_pic(config.pic)
.smm(config.smm)
.maybe_pci_hole64_size(config.pci_hole64_size)
Expand Down Expand Up @@ -341,6 +349,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)
.maybe_patch_kernel_header(vm.qemu_patches_kernel_header)
.maybe_pic(vm.pic)
.maybe_qemu_version(vm.qemu_version.clone())
.maybe_pci_hole64_size(if vm.pci_hole64_size > 0 {
Expand Down
21 changes: 18 additions & 3 deletions dstack/dstack-mr/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,30 @@ 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 a kernel QEMU serves untouched:
/// the Authenticode SHA-384 hash of the image file exactly as built.
///
/// QEMU >= 10.2 takes this path for every confidential guest, TDX included.
pub(crate) fn kernel_authenticode_sha384(kernel_data: &[u8]) -> Result<Vec<u8>> {
authenticode_sha384_hash(kernel_data).context("failed to compute kernel hash")
}

/// Measures the TDX kernel image QEMU hands to OVMF.
///
/// `patch_kernel_header` selects between the two kernel images QEMU can serve
/// over fw_cfg; see [`crate::machine::VersionedOptions::patch_kernel_header`].
pub(crate) fn rtmr1_log(
kernel_data: &[u8],
initrd_size: u32,
mem_size: u64,
acpi_data_size: u32,
patch_kernel_header: bool,
) -> Result<Vec<Vec<u8>>> {
let kernel_hash =
patched_kernel_authenticode_sha384(kernel_data, initrd_size, mem_size, acpi_data_size)?;
let kernel_hash = if patch_kernel_header {
patched_kernel_authenticode_sha384(kernel_data, initrd_size, mem_size, acpi_data_size)?
} else {
kernel_authenticode_sha384(kernel_data)?
};
Ok(vec![
kernel_hash,
measure_sha384(b"Calling EFI Application from Boot Option"),
Expand Down
99 changes: 99 additions & 0 deletions dstack/dstack-mr/src/machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pub struct Machine<'a> {
pub initrd: &'a str,
pub kernel_cmdline: &'a str,
pub two_pass_add_pages: Option<bool>,
/// Override for whether QEMU rewrites the Linux setup header before serving
/// the kernel over fw_cfg. `None` derives it from `qemu_version`.
pub patch_kernel_header: Option<bool>,
pub pic: Option<bool>,
pub qemu_version: Option<String>,
#[builder(default = false)]
Expand Down Expand Up @@ -91,18 +94,41 @@ impl Machine<'_> {
default_pic = false;
default_two_pass = false;
};
let default_patch_kernel_header = version < QEMU_COCO_KERNEL_HEADER_UNPATCHED;
Ok(VersionedOptions {
version,
pic: self.pic.unwrap_or(default_pic),
two_pass_add_pages: self.two_pass_add_pages.unwrap_or(default_two_pass),
patch_kernel_header: self
.patch_kernel_header
.unwrap_or(default_patch_kernel_header),
})
}
}

/// First QEMU release that stops rewriting the Linux setup header for
/// confidential guests, from commit a7542a38f399 ("x86/loader: Don't update
/// kernel header for CoCo VMs"), which widened the pre-existing SEV-only skip
/// (`!sev_enabled()`) to every confidential guest (`!MACHINE(x86ms)->cgs`).
///
/// The commit was not backported to 10.1 or earlier, and every release since
/// -- 10.2.x, 11.0.x, 11.1.x and master -- keeps the widened check, so the
/// boundary is a single step at 10.2.0 rather than a per-release quirk.
pub const QEMU_COCO_KERNEL_HEADER_UNPATCHED: (u32, u32, u32) = (10, 2, 0);

pub struct VersionedOptions {
pub version: (u32, u32, u32),
pub pic: bool,
pub two_pass_add_pages: bool,
/// Whether QEMU rewrites the Linux setup header (`type_of_loader`,
/// `loadflags`, `heap_end_ptr`, `cmdline_addr`, `initrd_addr`,
/// `initrd_size`) before exposing the kernel over fw_cfg.
///
/// QEMU <= 10.1 does, so OVMF measures the patched image into RTMR[1].
/// QEMU >= 10.2 leaves it alone for every confidential guest, so OVMF
/// measures the kernel file as built and the digest no longer depends on
/// guest memory size.
pub patch_kernel_header: bool,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -137,6 +163,7 @@ impl Machine<'_> {
initrd_data.len() as u32,
self.memory_size,
0x28000,
self.versioned_options()?.patch_kernel_header,
)?;
debug_print_log("RTMR1", &rtmr1_log);
let rtmr1 = measure_log(&rtmr1_log);
Expand All @@ -160,3 +187,75 @@ impl Machine<'_> {
})
}
}

#[cfg(test)]
mod tests {
use super::*;

const TDX_TEST_MEMORY: u64 = 0x8000_0000;

fn patch_kernel_header_for(version: &str) -> bool {
Machine::builder()
.cpu_count(1)
.memory_size(TDX_TEST_MEMORY)
.firmware("")
.kernel("")
.initrd("")
.kernel_cmdline("")
.hugepages(false)
.num_gpus(0)
.num_nvswitches(0)
.hotplug_off(false)
.root_verity(true)
.qemu_version(version.to_string())
.build()
.versioned_options()
.unwrap()
.patch_kernel_header
}

/// QEMU commit a7542a38f399 landed in 10.2.0 and was not backported, so the
/// boundary is exactly between 10.1.x and 10.2.0.
#[test]
fn kernel_header_patching_stops_at_qemu_10_2() {
for version in ["8.2.2", "9.1.0", "9.2.1", "10.0.0", "10.1.0", "10.1.9"] {
assert!(
patch_kernel_header_for(version),
"QEMU {version} still patches the setup header"
);
}
for version in ["10.2.0", "10.2.1", "10.2.4", "11.0.0", "11.1.0", "12.0.0"] {
assert!(
!patch_kernel_header_for(version),
"QEMU {version} leaves the setup header alone for CoCo guests"
);
}
}

/// A fork can carry or omit the commit against its version number, so the
/// explicit override has to win over the version-derived default.
#[test]
fn explicit_patch_kernel_header_overrides_the_version_default() {
for (version, override_value) in [("10.2.1", true), ("9.2.1", false)] {
let machine = Machine::builder()
.cpu_count(1)
.memory_size(TDX_TEST_MEMORY)
.firmware("")
.kernel("")
.initrd("")
.kernel_cmdline("")
.hugepages(false)
.num_gpus(0)
.num_nvswitches(0)
.hotplug_off(false)
.root_verity(true)
.qemu_version(version.to_string())
.patch_kernel_header(override_value)
.build();
assert_eq!(
machine.versioned_options().unwrap().patch_kernel_header,
override_value
);
}
}
}
2 changes: 1 addition & 1 deletion dstack/dstack-mr/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ usage:
dstack-mr snp-measurement-hash <image_dir>

features:
split-cbor-measurement-v3";
split-cbor-measurement-v4";

fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
Expand Down
Loading
Loading