Skip to content

Commit a2fbb67

Browse files
authored
Merge pull request #757 from Dstack-TEE/codex/gcp-unified-os-image-hash
gcp: unify OS image hash measurement
2 parents 208de3f + 592bfab commit a2fbb67

9 files changed

Lines changed: 281 additions & 46 deletions

File tree

docs/attestation-gcp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ Verification runs in `Attestation::verify_with_time` and splits into TDX + TPM.
3636
`tpm_qvl::get_collateral_and_verify(tpm_quote)`.
3737
2. **Replay runtime events** to compute runtime PCR and compare with quoted PCR.
3838
3. **Check qualifying data** equals `sha256(tdx_quote)`.
39+
4. **Bind the OS image identity**:
40+
`vm_config.os_image_hash` is the unified image digest
41+
`sha256(sha256sum.txt)`. The verifier requires `vm_config.gcp_measurement`,
42+
checks that `sha256sum.txt` commits to `measurement.gcp.cbor`, then compares
43+
the UKI Authenticode hash inside that CBOR file with the GCP TPM PCR2 UKI
44+
event.
3945

4046
### Optional RA TLS binding
4147
If the verifier provides a RA TLS pubkey, it enforces:

docs/security/security-model.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,27 +106,30 @@ to match the measurements in the quote. If the host substitutes either the image
106106
hash or the VM configuration, the recomputed measurements no longer match the
107107
quote.
108108

109-
For the no-image-download TDX lite path and the AMD SEV-SNP path,
109+
For the no-image-download TDX lite path, the AMD SEV-SNP path, and the GCP TDX
110+
path,
110111
`os_image_hash` is the unified image identity: `sha256(sha256sum.txt)`. The
111112
`sha256sum.txt` file is the image checksum manifest generated at image build
112113
time. It is a text file whose lines contain a SHA-256 digest and relative file
113114
name for each manifest entry, such as `metadata.json`, the kernel, initrd,
114115
firmware, and the split measurement file. Some launch-critical artifacts are
115116
represented indirectly instead of as direct manifest entries: for example, the
116117
rootfs is committed by the measured `dstack.rootfs_hash` kernel command-line
117-
parameter, and the SEV firmware is committed by `measurement.snp.cbor`. The exact
118-
`sha256sum.txt` bytes are hashed, so the manifest contents, file names, ordering,
119-
and line endings are all part of the image identity.
118+
parameter, the SEV firmware is committed by `measurement.snp.cbor`, and the GCP
119+
UKI Authenticode hash is committed by `measurement.gcp.cbor`. The exact
120+
`sha256sum.txt` bytes are hashed, so the manifest contents, file names,
121+
ordering, and line endings are all part of the image identity.
120122

121123
The attestation carries a copy of the image's `sha256sum.txt` plus the platform
122124
specific measurement material (`measurement.tdx.cbor` or
123-
`measurement.snp.cbor`). The verifier checks that:
125+
`measurement.snp.cbor`, or `measurement.gcp.cbor`). The verifier checks that:
124126

125127
1. `sha256(checksum_file) == os_image_hash`;
126128
2. the checksum file contains the expected `measurement.*.cbor` entry and that
127129
entry hashes to the supplied measurement material;
128130
3. the supplied measurement material replays to the hardware-signed TDX
129-
MRTD/RTMR values or SEV-SNP launch `MEASUREMENT`/`HOST_DATA`.
131+
MRTD/RTMR values, SEV-SNP launch `MEASUREMENT`/`HOST_DATA`, or the GCP TPM
132+
UKI event.
130133

131134
Only after these checks pass does the verifier treat the returned
132135
`os_image_hash` as the measured OS image identity. Downstream authorization

dstack-mr/src/main.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ use std::path::Path;
1414
const USAGE: &str = "\
1515
usage:
1616
dstack-mr measure-os <image_dir>
17-
dstack-mr inspect-measurement [tdx|snp] <measurement.cbor>
17+
dstack-mr inspect-measurement [tdx|snp|gcp] <measurement.cbor>
1818
dstack-mr tdx-measurement-cbor <image_dir>
1919
dstack-mr snp-measurement-cbor <image_dir>
20+
dstack-mr gcp-measurement-cbor <uki_auth_hash_file>
2021
dstack-mr tdx-measurement-hash <image_dir>
2122
dstack-mr snp-measurement-hash <image_dir>
2223
@@ -65,6 +66,20 @@ fn main() -> Result<()> {
6566
.context("failed to write amd sev-snp measurement CBOR")?;
6667
Ok(())
6768
}
69+
Some("gcp-measurement-cbor") => {
70+
let hash_file = args.next().context(USAGE)?;
71+
let hash =
72+
read_hex_file(&hash_file).context("failed to read GCP UKI Authenticode hash")?;
73+
let hash =
74+
hex::decode(hash.trim()).context("GCP UKI Authenticode hash is not valid hex")?;
75+
let cbor = dstack_types::GcpOsImageMeasurement::new(hash)
76+
.map_err(anyhow::Error::msg)?
77+
.to_cbor_vec();
78+
std::io::stdout()
79+
.write_all(&cbor)
80+
.context("failed to write GCP measurement CBOR")?;
81+
Ok(())
82+
}
6883
Some("tdx-measurement-cbor") => {
6984
let image_dir = args.next().context(USAGE)?;
7085
let cbor =
@@ -105,10 +120,17 @@ fn inspect_measurement(kind: &str, path: &Path) -> Result<Value> {
105120
.map_err(anyhow::Error::msg),
106121
"snp" | "sev" => dstack_types::SevOsImageMeasurement::cbor_json_value_from_slice(&cbor)
107122
.map_err(anyhow::Error::msg),
108-
other => bail!("unknown measurement kind {other:?}; expected tdx or snp"),
123+
"gcp" => dstack_types::GcpOsImageMeasurement::cbor_json_value_from_slice(&cbor)
124+
.map_err(anyhow::Error::msg),
125+
other => bail!("unknown measurement kind {other:?}; expected tdx, snp, or gcp"),
109126
}
110127
}
111128

129+
fn read_hex_file(path: &str) -> Result<String> {
130+
let path = Path::new(path);
131+
fs_err::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
132+
}
133+
112134
fn infer_measurement_kind(path: &str) -> Result<String> {
113135
let filename = Path::new(path)
114136
.file_name()
@@ -118,7 +140,9 @@ fn infer_measurement_kind(path: &str) -> Result<String> {
118140
Ok("tdx".to_string())
119141
} else if filename.contains(".snp.") || filename.contains("snp") || filename.contains("sev") {
120142
Ok("snp".to_string())
143+
} else if filename.contains(".gcp.") || filename.contains("gcp") {
144+
Ok("gcp".to_string())
121145
} else {
122-
bail!("cannot infer measurement kind from {filename:?}; pass tdx or snp explicitly")
146+
bail!("cannot infer measurement kind from {filename:?}; pass tdx, snp, or gcp explicitly")
123147
}
124148
}

dstack-mr/src/measurement.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
77
use anyhow::{Context, Result};
88
use dstack_types::{
9-
OsImageMeasurementDocument, SevOsImageMeasurementDocument, TdxOsImageMeasurementDocument,
10-
SNP_MEASUREMENT_FILENAME, TDX_MEASUREMENT_FILENAME,
9+
GcpOsImageMeasurementDocument, OsImageMeasurementDocument, SevOsImageMeasurementDocument,
10+
TdxOsImageMeasurementDocument, GCP_MEASUREMENT_FILENAME, SNP_MEASUREMENT_FILENAME,
11+
TDX_MEASUREMENT_FILENAME,
1112
};
1213
use fs_err as fs;
1314
use serde::Deserialize;
@@ -56,5 +57,16 @@ pub fn os_image_measurement_document_for_image_dir(
5657
None
5758
};
5859

59-
Ok(OsImageMeasurementDocument::new(tdx, snp))
60+
let gcp_path = image_dir.join(GCP_MEASUREMENT_FILENAME);
61+
let gcp = if gcp_path.exists() {
62+
Some(GcpOsImageMeasurementDocument::new(
63+
fs::read(&sha256sum_path)
64+
.with_context(|| format!("cannot read {}", sha256sum_path.display()))?,
65+
fs::read(&gcp_path).with_context(|| format!("cannot read {}", gcp_path.display()))?,
66+
))
67+
} else {
68+
None
69+
};
70+
71+
Ok(OsImageMeasurementDocument::new(tdx, snp, gcp))
6072
}

dstack-types/src/lib.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,12 @@ pub struct VmConfig {
298298
/// `tdx_attestation_variant = "lite"` and omitted for legacy TDX.
299299
#[serde(default, skip_serializing_if = "Option::is_none")]
300300
pub tdx_measurement: Option<TdxOsImageMeasurementDocument>,
301+
/// GCP TDX no-image-download measurement material. Present for GCP
302+
/// deployments so `os_image_hash` can remain the unified image digest
303+
/// (`sha256(sha256sum.txt)`) while the verifier still binds the TPM UKI
304+
/// Authenticode event to that digest.
305+
#[serde(default, skip_serializing_if = "Option::is_none")]
306+
pub gcp_measurement: Option<GcpOsImageMeasurementDocument>,
301307
}
302308

303309
/// One OVMF SEV metadata section (gpa/size/type) that affects the SEV-SNP
@@ -331,6 +337,7 @@ fn sha256(bytes: &[u8]) -> [u8; 32] {
331337

332338
pub const TDX_MEASUREMENT_FILENAME: &str = "measurement.tdx.cbor";
333339
pub const SNP_MEASUREMENT_FILENAME: &str = "measurement.snp.cbor";
340+
pub const GCP_MEASUREMENT_FILENAME: &str = "measurement.gcp.cbor";
334341

335342
pub fn image_hash_from_sha256sum(checksum_file: &[u8]) -> [u8; 32] {
336343
sha256(checksum_file)
@@ -401,6 +408,130 @@ pub fn verify_measurement_material(
401408
Ok(())
402409
}
403410

411+
/// Image-invariant GCP TDX measurement material. GCP's TPM event log measures
412+
/// the UKI as a PE/COFF Authenticode SHA-256 digest. The unified image identity
413+
/// remains `sha256(sha256sum.txt)`; this material is bound to that identity by
414+
/// the `measurement.gcp.cbor` entry in `sha256sum.txt`.
415+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416+
pub struct GcpOsImageMeasurement {
417+
#[serde(with = "hex_bytes")]
418+
pub uki_authenticode_sha256: Vec<u8>,
419+
}
420+
421+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422+
struct CborGcpOsImageMeasurement {
423+
version: u32,
424+
#[serde(rename = "uki_auth", with = "hex_bytes")]
425+
uki_authenticode_sha256: Vec<u8>,
426+
}
427+
428+
impl From<&GcpOsImageMeasurement> for CborGcpOsImageMeasurement {
429+
fn from(measurement: &GcpOsImageMeasurement) -> Self {
430+
Self {
431+
version: GcpOsImageMeasurement::VERSION,
432+
uki_authenticode_sha256: measurement.uki_authenticode_sha256.clone(),
433+
}
434+
}
435+
}
436+
437+
impl From<CborGcpOsImageMeasurement> for GcpOsImageMeasurement {
438+
fn from(measurement: CborGcpOsImageMeasurement) -> Self {
439+
Self {
440+
uki_authenticode_sha256: measurement.uki_authenticode_sha256,
441+
}
442+
}
443+
}
444+
445+
impl GcpOsImageMeasurement {
446+
pub const VERSION: u32 = 1;
447+
pub const UKI_AUTHENTICODE_SHA256_LEN: usize = 32;
448+
449+
pub fn new(uki_authenticode_sha256: Vec<u8>) -> Result<Self, String> {
450+
if uki_authenticode_sha256.len() != Self::UKI_AUTHENTICODE_SHA256_LEN {
451+
return Err(format!(
452+
"GcpOsImageMeasurement: UKI Authenticode hash has invalid length {}, expected {}",
453+
uki_authenticode_sha256.len(),
454+
Self::UKI_AUTHENTICODE_SHA256_LEN
455+
));
456+
}
457+
Ok(Self {
458+
uki_authenticode_sha256,
459+
})
460+
}
461+
462+
pub fn to_cbor_vec(&self) -> Vec<u8> {
463+
cbor_to_vec(
464+
&CborGcpOsImageMeasurement::from(self),
465+
"GcpOsImageMeasurement",
466+
)
467+
}
468+
469+
pub fn from_cbor_slice(bytes: &[u8]) -> Result<Self, String> {
470+
let measurement: CborGcpOsImageMeasurement =
471+
cbor_from_slice(bytes, "GcpOsImageMeasurement")?;
472+
if measurement.version != Self::VERSION {
473+
return Err(format!(
474+
"GcpOsImageMeasurement unsupported version {}, expected {}",
475+
measurement.version,
476+
Self::VERSION
477+
));
478+
}
479+
Self::new(measurement.uki_authenticode_sha256)
480+
}
481+
482+
pub fn cbor_json_value_from_slice(bytes: &[u8]) -> Result<serde_json::Value, String> {
483+
let measurement: CborGcpOsImageMeasurement =
484+
cbor_from_slice(bytes, "GcpOsImageMeasurement")?;
485+
serde_json::to_value(measurement)
486+
.map_err(|e| format!("GcpOsImageMeasurement: failed to convert to JSON: {e}"))
487+
}
488+
489+
pub fn measurement_hash(&self) -> [u8; 32] {
490+
sha256(&self.to_cbor_vec())
491+
}
492+
}
493+
494+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495+
pub struct GcpOsImageMeasurementDocument {
496+
/// Raw checksum file bytes (`sha256sum.txt`). `sha256(checksum_file)` is
497+
/// the unified `os_image_hash`.
498+
#[serde(with = "serde_human_bytes::base64")]
499+
pub checksum_file: Vec<u8>,
500+
/// Raw bytes of `measurement.gcp.cbor`.
501+
#[serde(with = "serde_human_bytes::base64")]
502+
pub measurement: Vec<u8>,
503+
}
504+
505+
impl GcpOsImageMeasurementDocument {
506+
pub fn new(checksum_file: Vec<u8>, measurement: Vec<u8>) -> Self {
507+
Self {
508+
checksum_file,
509+
measurement,
510+
}
511+
}
512+
513+
pub fn from_measurement(checksum_file: Vec<u8>, measurement: GcpOsImageMeasurement) -> Self {
514+
Self::new(checksum_file, measurement.to_cbor_vec())
515+
}
516+
517+
pub fn decode_measurement(&self) -> Result<GcpOsImageMeasurement, String> {
518+
GcpOsImageMeasurement::from_cbor_slice(&self.measurement)
519+
}
520+
521+
pub fn decode_measurement_value(&self) -> Result<serde_json::Value, String> {
522+
GcpOsImageMeasurement::cbor_json_value_from_slice(&self.measurement)
523+
}
524+
525+
pub fn verify(&self, os_image_hash: &[u8]) -> Result<(), String> {
526+
verify_measurement_material(
527+
os_image_hash,
528+
&self.checksum_file,
529+
&self.measurement,
530+
GCP_MEASUREMENT_FILENAME,
531+
)
532+
}
533+
}
534+
404535
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405536
struct CborOvmfSection {
406537
gpa: u64,
@@ -796,6 +927,8 @@ pub struct OsImageMeasurementDocument {
796927
pub tdx: Option<TdxOsImageMeasurementDocument>,
797928
#[serde(default, skip_serializing_if = "Option::is_none")]
798929
pub snp: Option<SevOsImageMeasurementDocument>,
930+
#[serde(default, skip_serializing_if = "Option::is_none")]
931+
pub gcp: Option<GcpOsImageMeasurementDocument>,
799932
}
800933

801934
impl OsImageMeasurementDocument {
@@ -804,11 +937,13 @@ impl OsImageMeasurementDocument {
804937
pub fn new(
805938
tdx: Option<TdxOsImageMeasurementDocument>,
806939
snp: Option<SevOsImageMeasurementDocument>,
940+
gcp: Option<GcpOsImageMeasurementDocument>,
807941
) -> Self {
808942
Self {
809943
version: Self::VERSION,
810944
tdx,
811945
snp,
946+
gcp,
812947
}
813948
}
814949
}

0 commit comments

Comments
 (0)