Skip to content

Commit b1a22f0

Browse files
committed
fix(simulator): replay image-specific GCP TPM events
1 parent a6ee463 commit b1a22f0

9 files changed

Lines changed: 183 additions & 17 deletions

File tree

dstack/dstack-types/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,15 @@ pub struct TeeSimulatorConfig {
11581158
/// the development NitroTPM simulator.
11591159
#[serde(default, skip_serializing_if = "Option::is_none")]
11601160
pub aws_pcr_replay: Option<AwsPcrReplay>,
1161+
/// Image-specific GCP TPM event log replayed by the development simulator.
1162+
#[serde(default, skip_serializing_if = "Option::is_none")]
1163+
pub gcp_tpm_replay: Option<GcpTpmReplay>,
1164+
}
1165+
1166+
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
1167+
pub struct GcpTpmReplay {
1168+
#[serde(with = "serde_human_bytes::base64")]
1169+
pub event_log: Vec<u8>,
11611170
}
11621171

11631172
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]

dstack/tee-simulator/src/tpm.rs

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,13 @@ pub fn start_gcp_vtpm(runtime_dir: &Path, config: &TeeSimulatorConfig) -> Result
128128
if let Some(error) = startup_error {
129129
return Err(error).context("GCP vTPM did not become ready");
130130
}
131-
replay_fixture_event_log()?;
132-
install_fixture_event_log()?;
131+
let replay = config
132+
.gcp_tpm_replay
133+
.as_ref()
134+
.context("tee_simulator.gcp_tpm_replay is required for GCP")?;
135+
validate_gcp_event_log(config, &replay.event_log)?;
136+
replay_gcp_event_log(&replay.event_log)?;
137+
install_gcp_event_log(&replay.event_log)?;
133138

134139
let template_with_size = state_dir.join("ak.tpm2b-public");
135140
let generated_public = state_dir.join("ak.public");
@@ -216,26 +221,57 @@ pub fn start_gcp_vtpm(runtime_dir: &Path, config: &TeeSimulatorConfig) -> Result
216221
Ok(())
217222
}
218223

219-
fn replay_fixture_event_log() -> Result<()> {
220-
let bytes = include_bytes!("../../cc-eventlog/samples/tpm_eventlog.bin");
221-
let event_log = cc_eventlog::tpm::TpmEventLog::decode(&mut bytes.as_slice())?;
224+
fn validate_gcp_event_log(config: &TeeSimulatorConfig, bytes: &[u8]) -> Result<()> {
225+
let vm_config: dstack_types::VmConfig = serde_json::from_str(
226+
config
227+
.vm_config
228+
.as_deref()
229+
.context("tee_simulator.vm_config is required for GCP")?,
230+
)?;
231+
let expected = vm_config
232+
.gcp_measurement
233+
.as_ref()
234+
.context("vm_config.gcp_measurement is required for GCP")?
235+
.decode_measurement()
236+
.map_err(anyhow::Error::msg)?
237+
.uki_authenticode_sha256;
238+
let event_log = cc_eventlog::tpm::TpmEventLog::decode(&mut bytes.as_ref())?;
239+
let actual = event_log
240+
.pcr2_events()
241+
.get(2)
242+
.context("GCP TPM event log is missing the UKI event")?
243+
.digest
244+
.clone();
245+
anyhow::ensure!(
246+
actual == expected,
247+
"GCP TPM event-log UKI digest does not match measurement.gcp.cbor"
248+
);
249+
Ok(())
250+
}
251+
252+
fn replay_gcp_event_log(bytes: &[u8]) -> Result<()> {
253+
let event_log = cc_eventlog::tpm::TpmEventLog::decode(&mut bytes.as_ref())?;
222254
for event in event_log.events {
223255
let extension = format!("{}:sha256={}", event.pcr_index, hex::encode(event.digest));
224256
command("tpm2_pcrextend", &[&extension])?;
225257
}
226258
Ok(())
227259
}
228260

229-
fn install_fixture_event_log() -> Result<()> {
261+
fn install_gcp_event_log(bytes: &[u8]) -> Result<()> {
230262
let security_root = Path::new("/sys/kernel/security");
231263
let event_log = security_root.join("tpm0/binary_bios_measurements");
232264
if event_log.exists() {
265+
anyhow::ensure!(
266+
fs_err::read(&event_log)? == bytes,
267+
"existing simulated TPM event log does not match the image"
268+
);
233269
return Ok(());
234270
}
235271
let tpm_dir = event_log.parent().context("TPM event log has no parent")?;
236272
// securityfs does not permit userspace to create a synthetic TPM event
237273
// log hierarchy. Shadow it in this development-only guest before
238-
// publishing the fixture that was replayed into the simulated PCRs.
274+
// publishing the event log that was replayed into the simulated PCRs.
239275
let flags = nix::mount::MsFlags::MS_NOSUID
240276
| nix::mount::MsFlags::MS_NODEV
241277
| nix::mount::MsFlags::MS_NOEXEC;
@@ -249,14 +285,8 @@ fn install_fixture_event_log() -> Result<()> {
249285
.context("failed to mount simulated securityfs shadow")?;
250286
fs_err::create_dir_all(tpm_dir)
251287
.context("failed to create TPM event-log directory in securityfs shadow")?;
252-
fs_err::write(
253-
event_log,
254-
include_bytes!("../../cc-eventlog/samples/tpm_eventlog.bin"),
255-
)
256-
.context("failed to install simulated TPM event log")?;
257-
Ok(())
288+
fs_err::write(event_log, bytes).context("failed to install simulated TPM event log")
258289
}
259-
260290
fn create_tpm_device_node() -> Result<()> {
261291
if Path::new("/dev/tpm0").exists() {
262292
return Ok(());
@@ -586,6 +616,38 @@ fn set_nv_public_size(response: &mut [u8], size: usize) -> Result<()> {
586616
Ok(())
587617
}
588618

619+
#[cfg(test)]
620+
mod tests {
621+
use super::*;
622+
623+
#[test]
624+
fn gcp_event_log_is_bound_to_vm_measurement() {
625+
let fixture = include_bytes!("../../cc-eventlog/samples/tpm_eventlog.bin");
626+
let fixture_hash =
627+
hex::decode("9ab14a46f858662a89adc102d2a57a13f52f75c1769d65a4c34edbbfc8855f0f")
628+
.unwrap();
629+
let image_hash = vec![0x5a; 32];
630+
let offset = fixture
631+
.windows(fixture_hash.len())
632+
.position(|window| window == fixture_hash)
633+
.unwrap();
634+
let mut event_log = fixture.to_vec();
635+
event_log[offset..offset + image_hash.len()].copy_from_slice(&image_hash);
636+
637+
let measurement = dstack_types::GcpOsImageMeasurement::new(image_hash).unwrap();
638+
let document =
639+
dstack_types::GcpOsImageMeasurementDocument::from_measurement(Vec::new(), measurement);
640+
let mut config = TeeSimulatorConfig {
641+
vm_config: Some(serde_json::json!({ "gcp_measurement": document }).to_string()),
642+
..Default::default()
643+
};
644+
validate_gcp_event_log(&config, &event_log).unwrap();
645+
646+
config.vm_config = Some("{}".into());
647+
assert!(validate_gcp_event_log(&config, &event_log).is_err());
648+
}
649+
}
650+
589651
fn nv_read_response(command: &[u8], contents: &[u8]) -> Result<Vec<u8>> {
590652
anyhow::ensure!(command.len() >= 4, "truncated NV_Read command");
591653
let size = read_be_u16(

dstack/tests/e2e/attestation/run-platform.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mkdir -p /sys/kernel/config/tsm/report
1717

1818
VM_CONFIG='{}'
1919
MR_CONFIG='{"version":3,"app_id":"","compose_hash":"","key_provider":"none"}'
20+
GCP_TPM_REPLAY=null
2021
if [[ "$TEE_PLATFORM" == dstack-tdx ]]; then
2122
VM_CONFIG=$(jq -c --arg variant "${TDX_ATTESTATION_VARIANT:?}" \
2223
'.vm_config | fromjson | .tdx_attestation_variant = $variant' \
@@ -33,6 +34,9 @@ elif [[ "$TEE_PLATFORM" == dstack-gcp-tdx ]]; then
3334
--arg checksum "$(base64 -w0 "$WORK/sha256sum.txt")" \
3435
--arg measurement "$(base64 -w0 "$WORK/measurement.gcp.cbor")" \
3536
'{os_image_hash:$os,gcp_measurement:{checksum_file:$checksum,measurement:$measurement}}')
37+
GCP_TPM_REPLAY=$(jq -cn \
38+
--arg event_log "$(base64 -w0 /usr/local/share/dstack/tpm_eventlog.bin)" \
39+
'{event_log:$event_log}')
3640
elif [[ "$TEE_PLATFORM" == dstack-amd-sev-snp ]]; then
3741
jq -r .attestation /usr/local/share/dstack/sev-snp-attestation.json | xxd -r -p > "$WORK/snp-fixture.bin"
3842
dstack-util attest-json --input "$WORK/snp-fixture.bin" --output "$WORK/snp-fixture.json"
@@ -97,7 +101,8 @@ cat > "$SIM_CONFIG" <<JSON
97101
"mock_attestation_seed": "$SEED",
98102
"collateral_base_url": "http://127.0.0.1:18088",
99103
"mr_config": $(jq -Rn --arg value "$MR_CONFIG" '$value'),
100-
"vm_config": $(jq -Rn --arg value "$VM_CONFIG" '$value')
104+
"vm_config": $(jq -Rn --arg value "$VM_CONFIG" '$value'),
105+
"gcp_tpm_replay": $GCP_TPM_REPLAY
101106
}
102107
JSON
103108

dstack/vmm/src/app.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,12 @@ pub(crate) fn sync_tee_simulator_config(
12851285
.map(serde_json::from_value)
12861286
.transpose()
12871287
.context("invalid aws_pcr_replay in vm_config")?;
1288+
simulator_config.gcp_tpm_replay = vm_config_value
1289+
.get("gcp_tpm_replay")
1290+
.cloned()
1291+
.map(serde_json::from_value)
1292+
.transpose()
1293+
.context("invalid gcp_tpm_replay in vm_config")?;
12881294
simulator_config.vm_config = Some(sys_config.vm_config);
12891295
fs::write(path, serde_json::to_vec(&simulator_config)?)
12901296
.context("failed to write TEE simulator config")
@@ -1522,6 +1528,14 @@ fn make_vm_config(
15221528
);
15231529
config["aws_pcr_replay"] = serde_json::to_value(replay)?;
15241530
}
1531+
if is_gcp_tdx {
1532+
config["gcp_tpm_replay"] = serde_json::to_value(
1533+
image
1534+
.gcp_tpm_replay
1535+
.as_ref()
1536+
.context("GCP TDX simulation requires measurement.gcp.eventlog.bin")?,
1537+
)?;
1538+
}
15251539
if is_amd_sev_snp {
15261540
if let Some(mr_config) = mr_config {
15271541
MrConfigV3::from_document(&mr_config).context("Invalid mr_config document")?;
@@ -1658,7 +1672,7 @@ mod tests {
16581672
};
16591673
let mr_config = r#"{"version":3}"#;
16601674
let vm_config = format!(
1661-
r#"{{"image":"dev","aws_pcr_replay":{{"version":1,"events":[],"pcr4":"{zero}","pcr7":"{zero}","pcr12":"{zero}"}}}}"#,
1675+
r#"{{"image":"dev","aws_pcr_replay":{{"version":1,"events":[],"pcr4":"{zero}","pcr7":"{zero}","pcr12":"{zero}"}},"gcp_tpm_replay":{{"event_log":"AQID"}}}}"#,
16621676
zero = "00".repeat(48)
16631677
);
16641678
let sys_config = serde_json::json!({
@@ -1682,6 +1696,13 @@ mod tests {
16821696
written.aws_pcr_replay.as_ref().map(|replay| replay.version),
16831697
Some(1)
16841698
);
1699+
assert_eq!(
1700+
written
1701+
.gcp_tpm_replay
1702+
.as_ref()
1703+
.map(|replay| replay.event_log.as_slice()),
1704+
Some([1, 2, 3].as_slice())
1705+
);
16851706

16861707
sync_tee_simulator_config(dir.path(), None, &sys_config)?;
16871708
assert!(!dir.path().join(TEE_SIMULATOR_CONFIG).exists());
@@ -1975,6 +1996,7 @@ mod tests {
19751996
gcp_measurement: None,
19761997
aws_measurement: None,
19771998
aws_pcr_replay: None,
1999+
gcp_tpm_replay: None,
19782000
}
19792001
}
19802002

dstack/vmm/src/app/image.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ use std::path::{Path, PathBuf};
88

99
use anyhow::{bail, Context, Result};
1010
use dstack_types::{
11-
AwsOsImageMeasurementDocument, AwsPcrReplay, GcpOsImageMeasurementDocument,
11+
AwsOsImageMeasurementDocument, AwsPcrReplay, GcpOsImageMeasurementDocument, GcpTpmReplay,
1212
SevOsImageMeasurementDocument, TdxOsImageMeasurementDocument, GCP_MEASUREMENT_FILENAME,
1313
SNP_MEASUREMENT_FILENAME, TDX_MEASUREMENT_FILENAME,
1414
};
1515
use serde::{Deserialize, Serialize};
1616

1717
const AWS_MEASUREMENT_FILENAME: &str = "measurement.aws.cbor";
1818
const AWS_PCR_REPLAY_FILENAME: &str = "measurement.aws.replay.json";
19+
const GCP_TPM_EVENT_LOG_FILENAME: &str = "measurement.gcp.eventlog.bin";
1920

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

9497
impl Image {
@@ -185,6 +188,15 @@ impl Image {
185188
} else {
186189
None
187190
};
191+
let gcp_event_log_path = base_path.join(GCP_TPM_EVENT_LOG_FILENAME);
192+
let gcp_tpm_replay = if gcp_event_log_path.exists() {
193+
Some(GcpTpmReplay {
194+
event_log: fs::read(&gcp_event_log_path)
195+
.with_context(|| format!("failed to read {}", gcp_event_log_path.display()))?,
196+
})
197+
} else {
198+
None
199+
};
188200
if info.version.is_empty() {
189201
// Older images does not have version field. Fallback to the version of the image folder name
190202
info.version = guess_version(&base_path).unwrap_or_default();
@@ -203,6 +215,7 @@ impl Image {
203215
gcp_measurement,
204216
aws_measurement,
205217
aws_pcr_replay,
218+
gcp_tpm_replay,
206219
}
207220
.ensure_exists()
208221
}

dstack/vmm/src/app/qemu.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,7 @@ mod tests {
10911091
gcp_measurement: None,
10921092
aws_measurement: None,
10931093
aws_pcr_replay: None,
1094+
gcp_tpm_replay: None,
10941095
},
10951096
cid: 100,
10961097
workdir: PathBuf::from("/does-not-exist/vm-1"),

os/image/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ All measurement artifacts are listed in `sha256sum.txt`, so
2222
Deploy tooling (`dstack-cloud prepare`) only **embeds** these files into
2323
`VmConfig`; it must not recompute PCRs (that would change the image identity).
2424

25+
Dev images additionally carry `measurement.gcp.eventlog.bin`, a GCP firmware
26+
event-log template with the assembled UKI Authenticode digest for the vTPM
27+
simulator. This simulator-only fixture is not generated for release images and
28+
is deliberately excluded from `sha256sum.txt`, so it does not affect the
29+
production `os_image_hash`.
30+
2531
AWS PCR precompute requires a pinned host `nitro-tpm-pcr-compute` binary (Rust,
2632
[aws/NitroTPM-Tools](https://github.com/aws/NitroTPM-Tools)). Set
2733
`NITRO_TPM_PCR_COMPUTE_BIN` or install it on `PATH`, for example with

os/image/assemble.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ if [[ "$UKI_CREATED" = "1" ]]; then
500500
fi
501501
echo "Generating measurement.gcp.cbor via ${DSTACK_MR_BIN}"
502502
"${DSTACK_MR_BIN}" gcp-measurement-cbor "${OUTPUT_DIR}/auth_hash.txt" > "${OUTPUT_DIR}/measurement.gcp.cbor"
503+
if [[ "$IS_DEV" = "true" ]]; then
504+
gcp_event_log_template="${GCP_TPM_EVENT_LOG_TEMPLATE:-$(dirname "$0")/../../dstack/cc-eventlog/samples/tpm_eventlog.bin}"
505+
echo "Generating image-specific GCP TPM event log for the dev image"
506+
python3 "$(dirname "$0")/gcp-tpm-eventlog.py" \
507+
--template "$gcp_event_log_template" \
508+
--uki-hash "${OUTPUT_DIR}/auth_hash.txt" \
509+
--output "${OUTPUT_DIR}/measurement.gcp.eventlog.bin"
510+
fi
503511
HAVE_MEASUREMENT_GCP=1
504512
fi
505513

@@ -610,6 +618,9 @@ if [ "$DSTACK_TAR_RELEASE" = "1" ]; then
610618
fi
611619
if [ "$HAVE_MEASUREMENT_GCP" = "1" ]; then
612620
BARE_METAL_FILES+=(measurement.gcp.cbor)
621+
if [[ "$IS_DEV" = "true" ]]; then
622+
BARE_METAL_FILES+=(measurement.gcp.eventlog.bin)
623+
fi
613624
fi
614625
if [ "$HAVE_MEASUREMENT_AWS" = "1" ]; then
615626
BARE_METAL_FILES+=(measurement.aws.cbor measurement.aws.replay.json)
@@ -626,6 +637,9 @@ if [ "$DSTACK_TAR_RELEASE" = "1" ]; then
626637
rm -rf "${IMAGE_TAR_UKI}"
627638
echo "Archiving UKI image to ${IMAGE_TAR_UKI}"
628639
UKI_FILES=(disk.raw digest.txt sha256sum.txt measurement.gcp.cbor measurement.aws.cbor measurement.aws.replay.json)
640+
if [[ "$IS_DEV" = "true" ]]; then
641+
UKI_FILES+=(measurement.gcp.eventlog.bin)
642+
fi
629643
UKI_TAR_FILES=()
630644
for file in "${UKI_FILES[@]}"; do
631645
UKI_TAR_FILES+=("$TAR_DIR_NAME/$file")

os/image/gcp-tpm-eventlog.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env python3
2+
"""Bind the GCP TPM event-log template to an assembled UKI."""
3+
4+
import argparse
5+
from pathlib import Path
6+
7+
8+
FIXTURE_UKI_HASH = bytes.fromhex(
9+
"9ab14a46f858662a89adc102d2a57a13f52f75c1769d65a4c34edbbfc8855f0f"
10+
)
11+
12+
13+
def main() -> None:
14+
parser = argparse.ArgumentParser()
15+
parser.add_argument("--template", type=Path, required=True)
16+
parser.add_argument("--uki-hash", type=Path, required=True)
17+
parser.add_argument("--output", type=Path, required=True)
18+
args = parser.parse_args()
19+
20+
uki_hash = bytes.fromhex(args.uki_hash.read_text().strip())
21+
if len(uki_hash) != 32:
22+
raise SystemExit("GCP UKI Authenticode hash must be SHA-256")
23+
24+
event_log = args.template.read_bytes()
25+
occurrences = event_log.count(FIXTURE_UKI_HASH)
26+
if occurrences != 1:
27+
raise SystemExit(
28+
f"expected one UKI digest in GCP event-log template, found {occurrences}"
29+
)
30+
args.output.write_bytes(event_log.replace(FIXTURE_UKI_HASH, uki_hash, 1))
31+
32+
33+
if __name__ == "__main__":
34+
main()

0 commit comments

Comments
 (0)