From ad240a2fc4b21986316f5c5c3e2cb0940922ffba Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 5 Aug 2026 19:03:40 -0700 Subject: [PATCH] fix(simulator): replay image-specific GCP TPM events --- dstack/dstack-types/src/lib.rs | 9 ++ dstack/tee-simulator/src/tpm.rs | 90 +++++++++++++++++--- dstack/tests/e2e/attestation/run-platform.sh | 7 +- dstack/vmm/src/app.rs | 24 +++++- dstack/vmm/src/app/image.rs | 15 +++- dstack/vmm/src/app/qemu.rs | 1 + os/image/README.md | 6 ++ os/image/assemble.sh | 14 +++ os/image/gcp-tpm-eventlog.py | 37 ++++++++ 9 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 os/image/gcp-tpm-eventlog.py diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index bc1e00cee..a1a6e80e8 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -1158,6 +1158,15 @@ pub struct TeeSimulatorConfig { /// the development NitroTPM simulator. #[serde(default, skip_serializing_if = "Option::is_none")] pub aws_pcr_replay: Option, + /// Image-specific GCP TPM event log replayed by the development simulator. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gcp_tpm_replay: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct GcpTpmReplay { + #[serde(with = "serde_human_bytes::base64")] + pub event_log: Vec, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] diff --git a/dstack/tee-simulator/src/tpm.rs b/dstack/tee-simulator/src/tpm.rs index 3e319841e..17f081662 100644 --- a/dstack/tee-simulator/src/tpm.rs +++ b/dstack/tee-simulator/src/tpm.rs @@ -128,8 +128,13 @@ pub fn start_gcp_vtpm(runtime_dir: &Path, config: &TeeSimulatorConfig) -> Result if let Some(error) = startup_error { return Err(error).context("GCP vTPM did not become ready"); } - replay_fixture_event_log()?; - install_fixture_event_log()?; + let replay = config + .gcp_tpm_replay + .as_ref() + .context("tee_simulator.gcp_tpm_replay is required for GCP")?; + validate_gcp_event_log(config, &replay.event_log)?; + replay_gcp_event_log(&replay.event_log)?; + install_gcp_event_log(&replay.event_log)?; let template_with_size = state_dir.join("ak.tpm2b-public"); let generated_public = state_dir.join("ak.public"); @@ -216,9 +221,36 @@ pub fn start_gcp_vtpm(runtime_dir: &Path, config: &TeeSimulatorConfig) -> Result Ok(()) } -fn replay_fixture_event_log() -> Result<()> { - let bytes = include_bytes!("../../cc-eventlog/samples/tpm_eventlog.bin"); - let event_log = cc_eventlog::tpm::TpmEventLog::decode(&mut bytes.as_slice())?; +fn validate_gcp_event_log(config: &TeeSimulatorConfig, mut bytes: &[u8]) -> Result<()> { + let vm_config: dstack_types::VmConfig = serde_json::from_str( + config + .vm_config + .as_deref() + .context("tee_simulator.vm_config is required for GCP")?, + )?; + let expected = vm_config + .gcp_measurement + .as_ref() + .context("vm_config.gcp_measurement is required for GCP")? + .decode_measurement() + .map_err(anyhow::Error::msg)? + .uki_authenticode_sha256; + let event_log = cc_eventlog::tpm::TpmEventLog::decode(&mut bytes)?; + let actual = event_log + .pcr2_events() + .get(2) + .context("GCP TPM event log is missing the UKI event")? + .digest + .clone(); + anyhow::ensure!( + actual == expected, + "GCP TPM event-log UKI digest does not match measurement.gcp.cbor" + ); + Ok(()) +} + +fn replay_gcp_event_log(mut bytes: &[u8]) -> Result<()> { + let event_log = cc_eventlog::tpm::TpmEventLog::decode(&mut bytes)?; for event in event_log.events { let extension = format!("{}:sha256={}", event.pcr_index, hex::encode(event.digest)); command("tpm2_pcrextend", &[&extension])?; @@ -226,16 +258,20 @@ fn replay_fixture_event_log() -> Result<()> { Ok(()) } -fn install_fixture_event_log() -> Result<()> { +fn install_gcp_event_log(bytes: &[u8]) -> Result<()> { let security_root = Path::new("/sys/kernel/security"); let event_log = security_root.join("tpm0/binary_bios_measurements"); if event_log.exists() { + anyhow::ensure!( + fs_err::read(&event_log)? == bytes, + "existing simulated TPM event log does not match the image" + ); return Ok(()); } let tpm_dir = event_log.parent().context("TPM event log has no parent")?; // securityfs does not permit userspace to create a synthetic TPM event // log hierarchy. Shadow it in this development-only guest before - // publishing the fixture that was replayed into the simulated PCRs. + // publishing the event log that was replayed into the simulated PCRs. let flags = nix::mount::MsFlags::MS_NOSUID | nix::mount::MsFlags::MS_NODEV | nix::mount::MsFlags::MS_NOEXEC; @@ -249,14 +285,8 @@ fn install_fixture_event_log() -> Result<()> { .context("failed to mount simulated securityfs shadow")?; fs_err::create_dir_all(tpm_dir) .context("failed to create TPM event-log directory in securityfs shadow")?; - fs_err::write( - event_log, - include_bytes!("../../cc-eventlog/samples/tpm_eventlog.bin"), - ) - .context("failed to install simulated TPM event log")?; - Ok(()) + fs_err::write(event_log, bytes).context("failed to install simulated TPM event log") } - fn create_tpm_device_node() -> Result<()> { if Path::new("/dev/tpm0").exists() { return Ok(()); @@ -586,6 +616,38 @@ fn set_nv_public_size(response: &mut [u8], size: usize) -> Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gcp_event_log_is_bound_to_vm_measurement() { + let fixture = include_bytes!("../../cc-eventlog/samples/tpm_eventlog.bin"); + let fixture_hash = + hex::decode("9ab14a46f858662a89adc102d2a57a13f52f75c1769d65a4c34edbbfc8855f0f") + .unwrap(); + let image_hash = vec![0x5a; 32]; + let offset = fixture + .windows(fixture_hash.len()) + .position(|window| window == fixture_hash) + .unwrap(); + let mut event_log = fixture.to_vec(); + event_log[offset..offset + image_hash.len()].copy_from_slice(&image_hash); + + let measurement = dstack_types::GcpOsImageMeasurement::new(image_hash).unwrap(); + let document = + dstack_types::GcpOsImageMeasurementDocument::from_measurement(Vec::new(), measurement); + let mut config = TeeSimulatorConfig { + vm_config: Some(serde_json::json!({ "gcp_measurement": document }).to_string()), + ..Default::default() + }; + validate_gcp_event_log(&config, &event_log).unwrap(); + + config.vm_config = Some("{}".into()); + assert!(validate_gcp_event_log(&config, &event_log).is_err()); + } +} + fn nv_read_response(command: &[u8], contents: &[u8]) -> Result> { anyhow::ensure!(command.len() >= 4, "truncated NV_Read command"); let size = read_be_u16( diff --git a/dstack/tests/e2e/attestation/run-platform.sh b/dstack/tests/e2e/attestation/run-platform.sh index c2cfa651e..693bd2c7e 100755 --- a/dstack/tests/e2e/attestation/run-platform.sh +++ b/dstack/tests/e2e/attestation/run-platform.sh @@ -17,6 +17,7 @@ mkdir -p /sys/kernel/config/tsm/report VM_CONFIG='{}' MR_CONFIG='{"version":3,"app_id":"","compose_hash":"","key_provider":"none"}' +GCP_TPM_REPLAY=null if [[ "$TEE_PLATFORM" == dstack-tdx ]]; then VM_CONFIG=$(jq -c --arg variant "${TDX_ATTESTATION_VARIANT:?}" \ '.vm_config | fromjson | .tdx_attestation_variant = $variant' \ @@ -33,6 +34,9 @@ elif [[ "$TEE_PLATFORM" == dstack-gcp-tdx ]]; then --arg checksum "$(base64 -w0 "$WORK/sha256sum.txt")" \ --arg measurement "$(base64 -w0 "$WORK/measurement.gcp.cbor")" \ '{os_image_hash:$os,gcp_measurement:{checksum_file:$checksum,measurement:$measurement}}') + GCP_TPM_REPLAY=$(jq -cn \ + --arg event_log "$(base64 -w0 /usr/local/share/dstack/tpm_eventlog.bin)" \ + '{event_log:$event_log}') elif [[ "$TEE_PLATFORM" == dstack-amd-sev-snp ]]; then jq -r .attestation /usr/local/share/dstack/sev-snp-attestation.json | xxd -r -p > "$WORK/snp-fixture.bin" dstack-util attest-json --input "$WORK/snp-fixture.bin" --output "$WORK/snp-fixture.json" @@ -97,7 +101,8 @@ cat > "$SIM_CONFIG" <, /// AWS boot events consumed only by the development NitroTPM simulator. pub aws_pcr_replay: Option, + /// GCP TPM event log consumed only by the development simulator. + pub gcp_tpm_replay: Option, } impl Image { @@ -185,6 +188,15 @@ impl Image { } else { None }; + let gcp_event_log_path = base_path.join(GCP_TPM_EVENT_LOG_FILENAME); + let gcp_tpm_replay = if gcp_event_log_path.exists() { + Some(GcpTpmReplay { + event_log: fs::read(&gcp_event_log_path) + .with_context(|| format!("failed to read {}", gcp_event_log_path.display()))?, + }) + } else { + None + }; if info.version.is_empty() { // Older images does not have version field. Fallback to the version of the image folder name info.version = guess_version(&base_path).unwrap_or_default(); @@ -203,6 +215,7 @@ impl Image { gcp_measurement, aws_measurement, aws_pcr_replay, + gcp_tpm_replay, } .ensure_exists() } diff --git a/dstack/vmm/src/app/qemu.rs b/dstack/vmm/src/app/qemu.rs index 327af5e75..05866ccf1 100644 --- a/dstack/vmm/src/app/qemu.rs +++ b/dstack/vmm/src/app/qemu.rs @@ -1091,6 +1091,7 @@ mod tests { gcp_measurement: None, aws_measurement: None, aws_pcr_replay: None, + gcp_tpm_replay: None, }, cid: 100, workdir: PathBuf::from("/does-not-exist/vm-1"), diff --git a/os/image/README.md b/os/image/README.md index 1b51c4bb4..91f96e646 100644 --- a/os/image/README.md +++ b/os/image/README.md @@ -22,6 +22,12 @@ All measurement artifacts are listed in `sha256sum.txt`, so Deploy tooling (`dstack-cloud prepare`) only **embeds** these files into `VmConfig`; it must not recompute PCRs (that would change the image identity). +Dev images additionally carry `measurement.gcp.eventlog.bin`, a GCP firmware +event-log template with the assembled UKI Authenticode digest for the vTPM +simulator. This simulator-only fixture is not generated for release images and +is deliberately excluded from `sha256sum.txt`, so it does not affect the +production `os_image_hash`. + AWS PCR precompute requires a pinned host `nitro-tpm-pcr-compute` binary (Rust, [aws/NitroTPM-Tools](https://github.com/aws/NitroTPM-Tools)). Set `NITRO_TPM_PCR_COMPUTE_BIN` or install it on `PATH`, for example with diff --git a/os/image/assemble.sh b/os/image/assemble.sh index eca70594d..d61672431 100755 --- a/os/image/assemble.sh +++ b/os/image/assemble.sh @@ -500,6 +500,14 @@ if [[ "$UKI_CREATED" = "1" ]]; then fi echo "Generating measurement.gcp.cbor via ${DSTACK_MR_BIN}" "${DSTACK_MR_BIN}" gcp-measurement-cbor "${OUTPUT_DIR}/auth_hash.txt" > "${OUTPUT_DIR}/measurement.gcp.cbor" + if [[ "$IS_DEV" = "true" ]]; then + gcp_event_log_template="${GCP_TPM_EVENT_LOG_TEMPLATE:-$(dirname "$0")/../../dstack/cc-eventlog/samples/tpm_eventlog.bin}" + echo "Generating image-specific GCP TPM event log for the dev image" + python3 "$(dirname "$0")/gcp-tpm-eventlog.py" \ + --template "$gcp_event_log_template" \ + --uki-hash "${OUTPUT_DIR}/auth_hash.txt" \ + --output "${OUTPUT_DIR}/measurement.gcp.eventlog.bin" + fi HAVE_MEASUREMENT_GCP=1 fi @@ -610,6 +618,9 @@ if [ "$DSTACK_TAR_RELEASE" = "1" ]; then fi if [ "$HAVE_MEASUREMENT_GCP" = "1" ]; then BARE_METAL_FILES+=(measurement.gcp.cbor) + if [[ "$IS_DEV" = "true" ]]; then + BARE_METAL_FILES+=(measurement.gcp.eventlog.bin) + fi fi if [ "$HAVE_MEASUREMENT_AWS" = "1" ]; then BARE_METAL_FILES+=(measurement.aws.cbor measurement.aws.replay.json) @@ -626,6 +637,9 @@ if [ "$DSTACK_TAR_RELEASE" = "1" ]; then rm -rf "${IMAGE_TAR_UKI}" echo "Archiving UKI image to ${IMAGE_TAR_UKI}" UKI_FILES=(disk.raw digest.txt sha256sum.txt measurement.gcp.cbor measurement.aws.cbor measurement.aws.replay.json) + if [[ "$IS_DEV" = "true" ]]; then + UKI_FILES+=(measurement.gcp.eventlog.bin) + fi UKI_TAR_FILES=() for file in "${UKI_FILES[@]}"; do UKI_TAR_FILES+=("$TAR_DIR_NAME/$file") diff --git a/os/image/gcp-tpm-eventlog.py b/os/image/gcp-tpm-eventlog.py new file mode 100644 index 000000000..8ace98197 --- /dev/null +++ b/os/image/gcp-tpm-eventlog.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 Phala Network +# SPDX-License-Identifier: Apache-2.0 + +"""Bind the GCP TPM event-log template to an assembled UKI.""" + +import argparse +from pathlib import Path + +FIXTURE_UKI_HASH = bytes.fromhex( + "9ab14a46f858662a89adc102d2a57a13f52f75c1769d65a4c34edbbfc8855f0f" +) + + +def main() -> None: + """Generate an image-specific event log from the GCP template.""" + parser = argparse.ArgumentParser() + parser.add_argument("--template", type=Path, required=True) + parser.add_argument("--uki-hash", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + uki_hash = bytes.fromhex(args.uki_hash.read_text().strip()) + if len(uki_hash) != 32: + raise SystemExit("GCP UKI Authenticode hash must be SHA-256") + + event_log = args.template.read_bytes() + occurrences = event_log.count(FIXTURE_UKI_HASH) + if occurrences != 1: + raise SystemExit( + f"expected one UKI digest in GCP event-log template, found {occurrences}" + ) + args.output.write_bytes(event_log.replace(FIXTURE_UKI_HASH, uki_hash, 1)) + + +if __name__ == "__main__": + main()