Skip to content
Merged
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
9 changes: 9 additions & 0 deletions dstack/dstack-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,15 @@ pub struct TeeSimulatorConfig {
/// the development NitroTPM simulator.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aws_pcr_replay: Option<AwsPcrReplay>,
/// Image-specific GCP TPM event log replayed by the development simulator.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gcp_tpm_replay: Option<GcpTpmReplay>,
}

#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
pub struct GcpTpmReplay {
#[serde(with = "serde_human_bytes::base64")]
pub event_log: Vec<u8>,
}

#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
Expand Down
90 changes: 76 additions & 14 deletions dstack/tee-simulator/src/tpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -216,26 +221,57 @@ 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])?;
}
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;
Expand All @@ -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(());
Expand Down Expand Up @@ -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<Vec<u8>> {
anyhow::ensure!(command.len() >= 4, "truncated NV_Read command");
let size = read_be_u16(
Expand Down
7 changes: 6 additions & 1 deletion dstack/tests/e2e/attestation/run-platform.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand All @@ -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"
Expand Down Expand Up @@ -97,7 +101,8 @@ cat > "$SIM_CONFIG" <<JSON
"mock_attestation_seed": "$SEED",
"collateral_base_url": "http://127.0.0.1:18088",
"mr_config": $(jq -Rn --arg value "$MR_CONFIG" '$value'),
"vm_config": $(jq -Rn --arg value "$VM_CONFIG" '$value')
"vm_config": $(jq -Rn --arg value "$VM_CONFIG" '$value'),
"gcp_tpm_replay": $GCP_TPM_REPLAY
}
JSON

Expand Down
24 changes: 23 additions & 1 deletion dstack/vmm/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,12 @@ pub(crate) fn sync_tee_simulator_config(
.map(serde_json::from_value)
.transpose()
.context("invalid aws_pcr_replay in vm_config")?;
simulator_config.gcp_tpm_replay = vm_config_value
.get("gcp_tpm_replay")
.cloned()
.map(serde_json::from_value)
.transpose()
.context("invalid gcp_tpm_replay in vm_config")?;
simulator_config.vm_config = Some(sys_config.vm_config);
fs::write(path, serde_json::to_vec(&simulator_config)?)
.context("failed to write TEE simulator config")
Expand Down Expand Up @@ -1599,6 +1605,14 @@ fn make_vm_config(
);
config["aws_pcr_replay"] = serde_json::to_value(replay)?;
}
if is_gcp_tdx {
config["gcp_tpm_replay"] = serde_json::to_value(
image
.gcp_tpm_replay
.as_ref()
.context("GCP TDX simulation requires measurement.gcp.eventlog.bin")?,
)?;
}
if is_amd_sev_snp {
if let Some(mr_config) = mr_config {
MrConfigV3::from_document(&mr_config).context("Invalid mr_config document")?;
Expand Down Expand Up @@ -1806,7 +1820,7 @@ mod tests {
};
let mr_config = r#"{"version":3}"#;
let vm_config = format!(
r#"{{"image":"dev","aws_pcr_replay":{{"version":1,"events":[],"pcr4":"{zero}","pcr7":"{zero}","pcr12":"{zero}"}}}}"#,
r#"{{"image":"dev","aws_pcr_replay":{{"version":1,"events":[],"pcr4":"{zero}","pcr7":"{zero}","pcr12":"{zero}"}},"gcp_tpm_replay":{{"event_log":"AQID"}}}}"#,
zero = "00".repeat(48)
);
let sys_config = serde_json::json!({
Expand All @@ -1830,6 +1844,13 @@ mod tests {
written.aws_pcr_replay.as_ref().map(|replay| replay.version),
Some(1)
);
assert_eq!(
written
.gcp_tpm_replay
.as_ref()
.map(|replay| replay.event_log.as_slice()),
Some([1, 2, 3].as_slice())
);

sync_tee_simulator_config(dir.path(), None, &sys_config)?;
assert!(!dir.path().join(TEE_SIMULATOR_CONFIG).exists());
Expand Down Expand Up @@ -2123,6 +2144,7 @@ mod tests {
gcp_measurement: None,
aws_measurement: None,
aws_pcr_replay: None,
gcp_tpm_replay: None,
}
}

Expand Down
15 changes: 14 additions & 1 deletion dstack/vmm/src/app/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use dstack_types::{
AwsOsImageMeasurementDocument, AwsPcrReplay, GcpOsImageMeasurementDocument,
AwsOsImageMeasurementDocument, AwsPcrReplay, GcpOsImageMeasurementDocument, GcpTpmReplay,
SevOsImageMeasurementDocument, TdxOsImageMeasurementDocument, GCP_MEASUREMENT_FILENAME,
SNP_MEASUREMENT_FILENAME, TDX_MEASUREMENT_FILENAME,
};
use serde::{Deserialize, Serialize};

const AWS_MEASUREMENT_FILENAME: &str = "measurement.aws.cbor";
const AWS_PCR_REPLAY_FILENAME: &str = "measurement.aws.replay.json";
const GCP_TPM_EVENT_LOG_FILENAME: &str = "measurement.gcp.eventlog.bin";

#[derive(Debug, Serialize, Deserialize)]
pub struct ImageInfo {
Expand Down Expand Up @@ -89,6 +90,8 @@ pub struct Image {
pub aws_measurement: Option<AwsOsImageMeasurementDocument>,
/// AWS boot events consumed only by the development NitroTPM simulator.
pub aws_pcr_replay: Option<AwsPcrReplay>,
/// GCP TPM event log consumed only by the development simulator.
pub gcp_tpm_replay: Option<GcpTpmReplay>,
}

impl Image {
Expand Down Expand Up @@ -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();
Expand All @@ -203,6 +215,7 @@ impl Image {
gcp_measurement,
aws_measurement,
aws_pcr_replay,
gcp_tpm_replay,
}
.ensure_exists()
}
Expand Down
1 change: 1 addition & 0 deletions dstack/vmm/src/app/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
6 changes: 6 additions & 0 deletions os/image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions os/image/assemble.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
37 changes: 37 additions & 0 deletions os/image/gcp-tpm-eventlog.py
Original file line number Diff line number Diff line change
@@ -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()
Loading