Skip to content

Commit 2fa2ba2

Browse files
committed
gcp: unify OS image hash measurement
1 parent 208de3f commit 2fa2ba2

9 files changed

Lines changed: 285 additions & 43 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: 35 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_hex_or_file>
2021
dstack-mr tdx-measurement-hash <image_dir>
2122
dstack-mr snp-measurement-hash <image_dir>
2223
@@ -65,6 +66,24 @@ fn main() -> Result<()> {
6566
.context("failed to write amd sev-snp measurement CBOR")?;
6667
Ok(())
6768
}
69+
Some("gcp-measurement-cbor") => {
70+
let hash_arg = args.next().context(USAGE)?;
71+
let hash = read_hex_arg_or_file(&hash_arg)
72+
.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+
if hash.len() != 32 {
76+
bail!(
77+
"GCP UKI Authenticode hash has invalid length {}, expected 32",
78+
hash.len()
79+
);
80+
}
81+
let cbor = dstack_types::GcpOsImageMeasurement::new(hash).to_cbor_vec();
82+
std::io::stdout()
83+
.write_all(&cbor)
84+
.context("failed to write GCP measurement CBOR")?;
85+
Ok(())
86+
}
6887
Some("tdx-measurement-cbor") => {
6988
let image_dir = args.next().context(USAGE)?;
7089
let cbor =
@@ -105,7 +124,18 @@ fn inspect_measurement(kind: &str, path: &Path) -> Result<Value> {
105124
.map_err(anyhow::Error::msg),
106125
"snp" | "sev" => dstack_types::SevOsImageMeasurement::cbor_json_value_from_slice(&cbor)
107126
.map_err(anyhow::Error::msg),
108-
other => bail!("unknown measurement kind {other:?}; expected tdx or snp"),
127+
"gcp" => dstack_types::GcpOsImageMeasurement::cbor_json_value_from_slice(&cbor)
128+
.map_err(anyhow::Error::msg),
129+
other => bail!("unknown measurement kind {other:?}; expected tdx, snp, or gcp"),
130+
}
131+
}
132+
133+
fn read_hex_arg_or_file(arg: &str) -> Result<String> {
134+
let path = Path::new(arg);
135+
if path.exists() {
136+
fs_err::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
137+
} else {
138+
Ok(arg.to_string())
109139
}
110140
}
111141

@@ -118,7 +148,9 @@ fn infer_measurement_kind(path: &str) -> Result<String> {
118148
Ok("tdx".to_string())
119149
} else if filename.contains(".snp.") || filename.contains("snp") || filename.contains("sev") {
120150
Ok("snp".to_string())
151+
} else if filename.contains(".gcp.") || filename.contains("gcp") {
152+
Ok("gcp".to_string())
121153
} else {
122-
bail!("cannot infer measurement kind from {filename:?}; pass tdx or snp explicitly")
154+
bail!("cannot infer measurement kind from {filename:?}; pass tdx, snp, or gcp explicitly")
123155
}
124156
}

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: 127 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,122 @@ 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+
448+
pub fn new(uki_authenticode_sha256: Vec<u8>) -> Self {
449+
Self {
450+
uki_authenticode_sha256,
451+
}
452+
}
453+
454+
pub fn to_cbor_vec(&self) -> Vec<u8> {
455+
cbor_to_vec(
456+
&CborGcpOsImageMeasurement::from(self),
457+
"GcpOsImageMeasurement",
458+
)
459+
}
460+
461+
pub fn from_cbor_slice(bytes: &[u8]) -> Result<Self, String> {
462+
let measurement: CborGcpOsImageMeasurement =
463+
cbor_from_slice(bytes, "GcpOsImageMeasurement")?;
464+
if measurement.version != Self::VERSION {
465+
return Err(format!(
466+
"GcpOsImageMeasurement unsupported version {}, expected {}",
467+
measurement.version,
468+
Self::VERSION
469+
));
470+
}
471+
Ok(measurement.into())
472+
}
473+
474+
pub fn cbor_json_value_from_slice(bytes: &[u8]) -> Result<serde_json::Value, String> {
475+
let measurement: CborGcpOsImageMeasurement =
476+
cbor_from_slice(bytes, "GcpOsImageMeasurement")?;
477+
serde_json::to_value(measurement)
478+
.map_err(|e| format!("GcpOsImageMeasurement: failed to convert to JSON: {e}"))
479+
}
480+
481+
pub fn measurement_hash(&self) -> [u8; 32] {
482+
sha256(&self.to_cbor_vec())
483+
}
484+
}
485+
486+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487+
pub struct GcpOsImageMeasurementDocument {
488+
/// Raw checksum file bytes (`sha256sum.txt`). `sha256(checksum_file)` is
489+
/// the unified `os_image_hash`.
490+
#[serde(with = "serde_human_bytes::base64")]
491+
pub checksum_file: Vec<u8>,
492+
/// Raw bytes of `measurement.gcp.cbor`.
493+
#[serde(with = "serde_human_bytes::base64")]
494+
pub measurement: Vec<u8>,
495+
}
496+
497+
impl GcpOsImageMeasurementDocument {
498+
pub fn new(checksum_file: Vec<u8>, measurement: Vec<u8>) -> Self {
499+
Self {
500+
checksum_file,
501+
measurement,
502+
}
503+
}
504+
505+
pub fn from_measurement(checksum_file: Vec<u8>, measurement: GcpOsImageMeasurement) -> Self {
506+
Self::new(checksum_file, measurement.to_cbor_vec())
507+
}
508+
509+
pub fn decode_measurement(&self) -> Result<GcpOsImageMeasurement, String> {
510+
GcpOsImageMeasurement::from_cbor_slice(&self.measurement)
511+
}
512+
513+
pub fn decode_measurement_value(&self) -> Result<serde_json::Value, String> {
514+
GcpOsImageMeasurement::cbor_json_value_from_slice(&self.measurement)
515+
}
516+
517+
pub fn verify(&self, os_image_hash: &[u8]) -> Result<(), String> {
518+
verify_measurement_material(
519+
os_image_hash,
520+
&self.checksum_file,
521+
&self.measurement,
522+
GCP_MEASUREMENT_FILENAME,
523+
)
524+
}
525+
}
526+
404527
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405528
struct CborOvmfSection {
406529
gpa: u64,
@@ -796,6 +919,8 @@ pub struct OsImageMeasurementDocument {
796919
pub tdx: Option<TdxOsImageMeasurementDocument>,
797920
#[serde(default, skip_serializing_if = "Option::is_none")]
798921
pub snp: Option<SevOsImageMeasurementDocument>,
922+
#[serde(default, skip_serializing_if = "Option::is_none")]
923+
pub gcp: Option<GcpOsImageMeasurementDocument>,
799924
}
800925

801926
impl OsImageMeasurementDocument {
@@ -804,11 +929,13 @@ impl OsImageMeasurementDocument {
804929
pub fn new(
805930
tdx: Option<TdxOsImageMeasurementDocument>,
806931
snp: Option<SevOsImageMeasurementDocument>,
932+
gcp: Option<GcpOsImageMeasurementDocument>,
807933
) -> Self {
808934
Self {
809935
version: Self::VERSION,
810936
tdx,
811937
snp,
938+
gcp,
812939
}
813940
}
814941
}

0 commit comments

Comments
 (0)