diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-attest-vendor/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-attest-vendor/src/lib.rs index 757580c69..49c998523 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-attest-vendor/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-attest-vendor/src/lib.rs @@ -5,10 +5,10 @@ //! by shipping a sidecar crate that handles the three concrete //! formats with stable published specs: //! -//! - **Intel TDX TD Quote** — header per Intel's -//! `TDX 1.5 DCAP Spec`; we parse the version + tcb_svn + mrtd -//! + rtmr0..3 + report_data fields and emit them as -//! [`jouleclaw_attest::Measurement`] records keyed by +//! - **Intel TDX TD Quote** — per Intel's DCAP quote format, versions 4 +//! and 5; we check the version and the TEE type, read the mrtd, the +//! four rtmrs and the report_data from the TD report body, and emit +//! them as [`jouleclaw_attest::Measurement`] records keyed by //! `tdx.mrtd`, `tdx.rtmr0`..`tdx.rtmr3`, `tdx.report_data`. //! - **AMD SEV-SNP attestation report** — per AMD's SNP firmware //! spec; we parse the launch measurement + host_data + @@ -163,35 +163,84 @@ fn measurement(key: &str, digest: &[u8], alg: &str) -> Measurement { // Intel TDX TD Quote parser // ───────────────────────────────────────────────────────────────────── -/// Intel TDX TD Quote v4/v5 header layout (we parse v4; v5 is -/// backward-compat on the header fields we use). +/// Intel TDX TD Quote layout (DCAP quote format, versions 4 and 5). /// -/// Bytes 0..=1 : version (u16 LE) -/// Bytes 2..=3 : attestation_key_type (u16 LE) -/// Bytes 4..=7 : tee_type (u32 LE) -/// Bytes 8..=15 : reserved -/// Bytes 16..=31 : qe_vendor_id -/// Bytes 32..=51 : user_data -/// Bytes 52.. : TD report body +/// The 48-byte header: /// -/// The TD report body (528 bytes for v4) contains: -/// - mrtd (48 bytes at offset 0 in the body) -/// - rtmr0..rtmr3 (each 48 bytes, at offsets 168/216/264/312) -/// - report_data (64 bytes at offset 376) +/// | Bytes | Field | +/// |---|---| +/// | 0..2 | version (u16 LE): 4 or 5 | +/// | 2..4 | attestation_key_type (u16 LE) | +/// | 4..8 | tee_type (u32 LE): `0x81` for TDX | +/// | 8..12 | reserved | +/// | 12..28 | qe_vendor_id | +/// | 28..48 | user_data | /// -/// We extract these into [`Measurement`] records using SHA-384 -/// (the TDX measurement algorithm). -const TDX_HEADER_LEN: usize = 52; -const TDX_REPORT_BODY_LEN: usize = 528; -const TDX_MRTD_OFFSET: usize = 0; -const TDX_RTMR0_OFFSET: usize = 168; +/// A version 4 quote's TD report body follows at byte 48. A version 5 quote +/// puts a 6-byte body descriptor there first, `body_type` (u16 LE) and +/// `body_size` (u32 LE): type 2 is a 584-byte TD 1.0 report and type 3 a +/// 648-byte TD 1.5 report, whose first 584 bytes are laid out as TD 1.0's. +/// +/// The TD report body fields this parser reads, at their offsets in the body: +/// +/// | Offset | Field | +/// |---|---| +/// | 136 | mrtd (48) | +/// | 328, 376, 424, 472 | rtmr0..rtmr3 (48 each) | +/// | 520 | report_data (64) | +/// +/// The measurements are SHA-384, the TDX measurement algorithm. +const TDX_HEADER_LEN: usize = 48; +const TDX_TEE_TYPE: u32 = 0x81; +const TDX_V5_DESCRIPTOR_LEN: usize = 6; +const TDX_TD10_BODY_LEN: usize = 584; +const TDX_TD15_BODY_LEN: usize = 648; +const TDX_MRTD_OFFSET: usize = 136; +const TDX_RTMR0_OFFSET: usize = 328; const TDX_RTMR1_OFFSET: usize = TDX_RTMR0_OFFSET + 48; const TDX_RTMR2_OFFSET: usize = TDX_RTMR1_OFFSET + 48; const TDX_RTMR3_OFFSET: usize = TDX_RTMR2_OFFSET + 48; -const TDX_REPORT_DATA_OFFSET: usize = 376; +const TDX_REPORT_DATA_OFFSET: usize = 520; const TDX_MEASUREMENT_LEN: usize = 48; // SHA-384 const TDX_REPORT_DATA_LEN: usize = 64; +/// Where a TD quote's report body starts, from its header and, for version +/// 5, its body descriptor. A quote that is not a TD quote, or whose body is +/// not a TD report, is refused rather than read at the wrong offsets. +fn tdx_body_start(bytes: &[u8]) -> Result { + if bytes.len() < TDX_HEADER_LEN { + return Err(QuoteError::TooShort { need: TDX_HEADER_LEN, got: bytes.len() }); + } + let version = u16::from_le_bytes([bytes[0], bytes[1]]); + if version != 4 && version != 5 { + return Err(QuoteError::UnsupportedVersion(version)); + } + let tee_type = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + if tee_type != TDX_TEE_TYPE { + return Err(QuoteError::Malformed(format!("tee_type {tee_type:#x} is not a TD quote"))); + } + if version == 4 { + return Ok(TDX_HEADER_LEN); + } + let start = TDX_HEADER_LEN + TDX_V5_DESCRIPTOR_LEN; + if bytes.len() < start { + return Err(QuoteError::TooShort { need: start, got: bytes.len() }); + } + let body_type = u16::from_le_bytes([bytes[48], bytes[49]]); + let body_size = u32::from_le_bytes([bytes[50], bytes[51], bytes[52], bytes[53]]) as usize; + let expected = match body_type { + 2 => TDX_TD10_BODY_LEN, + 3 => TDX_TD15_BODY_LEN, + t => return Err(QuoteError::Malformed(format!("quote body type {t} is not a TD report"))), + }; + if body_size != expected { + return Err(QuoteError::Malformed(format!( + "a type {body_type} body is {expected} bytes, the descriptor says {body_size}" + ))); + } + Ok(start) +} + /// Parser for [`QuoteKind::IntelTdx`]. #[derive(Debug, Default, Clone, Copy)] pub struct IntelTdxParser; @@ -208,51 +257,44 @@ impl QuoteParser for IntelTdxParser { }); } let bytes = decode_hex("e.bytes_hex)?; - let need = TDX_HEADER_LEN + TDX_REPORT_BODY_LEN; + let start = tdx_body_start(&bytes)?; + let need = start + TDX_TD10_BODY_LEN; if bytes.len() < need { - return Err(QuoteError::TooShort { - need, - got: bytes.len(), - }); + return Err(QuoteError::TooShort { need, got: bytes.len() }); } - // version little-endian, bytes 0..2 - let version = u16::from_le_bytes([bytes[0], bytes[1]]); - if version != 4 && version != 5 { - return Err(QuoteError::UnsupportedVersion(version)); - } - let body = &bytes[TDX_HEADER_LEN..TDX_HEADER_LEN + TDX_REPORT_BODY_LEN]; - let mut out = Vec::with_capacity(6); - out.push(measurement( - "tdx.mrtd", - &body[TDX_MRTD_OFFSET..TDX_MRTD_OFFSET + TDX_MEASUREMENT_LEN], - "sha384", - )); - out.push(measurement( - "tdx.rtmr0", - &body[TDX_RTMR0_OFFSET..TDX_RTMR0_OFFSET + TDX_MEASUREMENT_LEN], - "sha384", - )); - out.push(measurement( - "tdx.rtmr1", - &body[TDX_RTMR1_OFFSET..TDX_RTMR1_OFFSET + TDX_MEASUREMENT_LEN], - "sha384", - )); - out.push(measurement( - "tdx.rtmr2", - &body[TDX_RTMR2_OFFSET..TDX_RTMR2_OFFSET + TDX_MEASUREMENT_LEN], - "sha384", - )); - out.push(measurement( - "tdx.rtmr3", - &body[TDX_RTMR3_OFFSET..TDX_RTMR3_OFFSET + TDX_MEASUREMENT_LEN], - "sha384", - )); - out.push(measurement( - "tdx.report_data", - &body[TDX_REPORT_DATA_OFFSET..TDX_REPORT_DATA_OFFSET + TDX_REPORT_DATA_LEN], - "raw", - )); - Ok(out) + let body = &bytes[start..need]; + Ok(vec![ + measurement( + "tdx.mrtd", + &body[TDX_MRTD_OFFSET..TDX_MRTD_OFFSET + TDX_MEASUREMENT_LEN], + "sha384", + ), + measurement( + "tdx.rtmr0", + &body[TDX_RTMR0_OFFSET..TDX_RTMR0_OFFSET + TDX_MEASUREMENT_LEN], + "sha384", + ), + measurement( + "tdx.rtmr1", + &body[TDX_RTMR1_OFFSET..TDX_RTMR1_OFFSET + TDX_MEASUREMENT_LEN], + "sha384", + ), + measurement( + "tdx.rtmr2", + &body[TDX_RTMR2_OFFSET..TDX_RTMR2_OFFSET + TDX_MEASUREMENT_LEN], + "sha384", + ), + measurement( + "tdx.rtmr3", + &body[TDX_RTMR3_OFFSET..TDX_RTMR3_OFFSET + TDX_MEASUREMENT_LEN], + "sha384", + ), + measurement( + "tdx.report_data", + &body[TDX_REPORT_DATA_OFFSET..TDX_REPORT_DATA_OFFSET + TDX_REPORT_DATA_LEN], + "raw", + ), + ]) } } @@ -260,24 +302,27 @@ impl QuoteParser for IntelTdxParser { // AMD SEV-SNP attestation report parser // ───────────────────────────────────────────────────────────────────── -/// AMD SEV-SNP attestation report layout (per -/// `SNP_GET_REPORT` spec). The report is 1184 bytes; we extract -/// the four most-load-bearing measurements: +/// AMD SEV-SNP attestation report layout (the `ATTESTATION_REPORT` +/// structure of the SNP firmware ABI). The report is 1184 bytes; we extract +/// the four most load-bearing fields: /// -/// - launch_measurement (48 bytes at offset 0x130 = 304) -/// - host_data (32 bytes at offset 0xC0 = 192) -/// - id_key_digest (48 bytes at offset 0x160 = 352) -/// - report_data (64 bytes at offset 0x50 = 80) +/// | Offset | Field | +/// |---|---| +/// | 0x050 | report_data (64) | +/// | 0x090 | launch measurement (48, SHA-384) | +/// | 0x0C0 | host_data (32) | +/// | 0x0E0 | id_key_digest (48, SHA-384) | /// -/// All are SHA-384 / SHA-256 / raw per AMD's spec. +/// The neighbouring fields this must not be confused with are +/// author_key_digest at 0x110, report_id at 0x140 and report_id_ma at 0x160. const SNP_REPORT_LEN: usize = 1184; const SNP_REPORT_DATA_OFFSET: usize = 0x50; const SNP_REPORT_DATA_LEN: usize = 64; const SNP_HOST_DATA_OFFSET: usize = 0xC0; const SNP_HOST_DATA_LEN: usize = 32; -const SNP_LAUNCH_MEASUREMENT_OFFSET: usize = 0x130; +const SNP_LAUNCH_MEASUREMENT_OFFSET: usize = 0x090; const SNP_LAUNCH_MEASUREMENT_LEN: usize = 48; -const SNP_ID_KEY_DIGEST_OFFSET: usize = 0x160; +const SNP_ID_KEY_DIGEST_OFFSET: usize = 0x0E0; const SNP_ID_KEY_DIGEST_LEN: usize = 48; /// Parser for [`QuoteKind::AmdSevSnp`]. @@ -302,29 +347,29 @@ impl QuoteParser for AmdSevSnpParser { got: bytes.len(), }); } - let mut out = Vec::with_capacity(4); - out.push(measurement( - "snp.launch_measurement", - &bytes[SNP_LAUNCH_MEASUREMENT_OFFSET - ..SNP_LAUNCH_MEASUREMENT_OFFSET + SNP_LAUNCH_MEASUREMENT_LEN], - "sha384", - )); - out.push(measurement( - "snp.host_data", - &bytes[SNP_HOST_DATA_OFFSET..SNP_HOST_DATA_OFFSET + SNP_HOST_DATA_LEN], - "sha256", - )); - out.push(measurement( - "snp.id_key_digest", - &bytes[SNP_ID_KEY_DIGEST_OFFSET..SNP_ID_KEY_DIGEST_OFFSET + SNP_ID_KEY_DIGEST_LEN], - "sha384", - )); - out.push(measurement( - "snp.report_data", - &bytes[SNP_REPORT_DATA_OFFSET..SNP_REPORT_DATA_OFFSET + SNP_REPORT_DATA_LEN], - "raw", - )); - Ok(out) + Ok(vec![ + measurement( + "snp.launch_measurement", + &bytes[SNP_LAUNCH_MEASUREMENT_OFFSET + ..SNP_LAUNCH_MEASUREMENT_OFFSET + SNP_LAUNCH_MEASUREMENT_LEN], + "sha384", + ), + measurement( + "snp.host_data", + &bytes[SNP_HOST_DATA_OFFSET..SNP_HOST_DATA_OFFSET + SNP_HOST_DATA_LEN], + "sha256", + ), + measurement( + "snp.id_key_digest", + &bytes[SNP_ID_KEY_DIGEST_OFFSET..SNP_ID_KEY_DIGEST_OFFSET + SNP_ID_KEY_DIGEST_LEN], + "sha384", + ), + measurement( + "snp.report_data", + &bytes[SNP_REPORT_DATA_OFFSET..SNP_REPORT_DATA_OFFSET + SNP_REPORT_DATA_LEN], + "raw", + ), + ]) } } @@ -386,54 +431,85 @@ impl QuoteParser for InTotoSlsaParser { mod tests { use super::*; - fn synthesize_tdx_v4_quote() -> Quote { - let total = TDX_HEADER_LEN + TDX_REPORT_BODY_LEN; - let mut bytes = vec![0u8; total]; - // version = 4 (little-endian) - bytes[0] = 4; - bytes[1] = 0; - // MRTD = 0xAA * 48 at body offset 0 - for i in 0..TDX_MEASUREMENT_LEN { - bytes[TDX_HEADER_LEN + TDX_MRTD_OFFSET + i] = 0xAA; - } - // RTMR0 = 0xBB * 48 - for i in 0..TDX_MEASUREMENT_LEN { - bytes[TDX_HEADER_LEN + TDX_RTMR0_OFFSET + i] = 0xBB; - } - // report_data = 0xCC * 64 - for i in 0..TDX_REPORT_DATA_LEN { - bytes[TDX_HEADER_LEN + TDX_REPORT_DATA_OFFSET + i] = 0xCC; - } - Quote { - kind: QuoteKind::IntelTdx, - bytes_hex: encode_hex(&bytes), - } + /// Fill `len` bytes at `at` with `marker`. + fn mark(bytes: &mut [u8], at: usize, len: usize, marker: u8) { + bytes[at..at + len].fill(marker); } - fn synthesize_snp_report() -> Quote { - let mut bytes = vec![0u8; SNP_REPORT_LEN]; - for i in 0..SNP_LAUNCH_MEASUREMENT_LEN { - bytes[SNP_LAUNCH_MEASUREMENT_OFFSET + i] = 0xDD; + /// A TD quote laid out by the published format, written from literal + /// offsets rather than the parser's constants, so a wrong constant cannot + /// agree with a fixture built from it. Every field the parser reads, and + /// the fields beside the offsets it used to read, carries its own marker. + fn published_td_quote(version: u16) -> Vec { + let start = if version == 5 { 54 } else { 48 }; + let mut q = vec![0u8; start + 584]; + q[0..2].copy_from_slice(&version.to_le_bytes()); + q[4..8].copy_from_slice(&0x81u32.to_le_bytes()); + if version == 5 { + q[48..50].copy_from_slice(&2u16.to_le_bytes()); + q[50..54].copy_from_slice(&584u32.to_le_bytes()); } - for i in 0..SNP_HOST_DATA_LEN { - bytes[SNP_HOST_DATA_OFFSET + i] = 0xEE; - } - Quote { - kind: QuoteKind::AmdSevSnp, - bytes_hex: encode_hex(&bytes), + let body = &mut q[start..]; + mark(body, 0, 16, 0x01); // TEE_TCB_SVN + mark(body, 16, 48, 0x02); // MRSEAM + mark(body, 136, 48, 0xAA); // MRTD + mark(body, 184, 48, 0x03); // MRCONFIGID, which the old rtmr0 read straddled + for (k, at) in [328, 376, 424, 472].into_iter().enumerate() { + mark(body, at, 48, 0xB0 + k as u8); // RTMR0..3 } + mark(body, 520, 64, 0xCC); // REPORTDATA + q } + fn td_quote(bytes: &[u8]) -> Quote { + Quote { kind: QuoteKind::IntelTdx, bytes_hex: encode_hex(bytes) } + } + + fn digest_of<'m>(m: &'m [Measurement], key: &str) -> &'m str { + &m.iter().find(|x| x.key == key).unwrap().digest + } + + /// Each field is read from its published offset: MRTD at 136, not the + /// TEE_TCB_SVN at 0, and report_data at 520, not RTMR1 at 376. #[test] - fn tdx_parser_extracts_five_measurements_plus_report_data() { - let q = synthesize_tdx_v4_quote(); - let m = IntelTdxParser.parse(&q).unwrap(); - assert_eq!(m.len(), 6); - let mrtd = m.iter().find(|x| x.key == "tdx.mrtd").unwrap(); - assert_eq!(mrtd.digest, "aa".repeat(TDX_MEASUREMENT_LEN)); - assert_eq!(mrtd.alg, "sha384"); - let rd = m.iter().find(|x| x.key == "tdx.report_data").unwrap(); - assert_eq!(rd.digest, "cc".repeat(TDX_REPORT_DATA_LEN)); + fn tdx_parser_reads_each_field_at_its_published_offset() { + for version in [4u16, 5] { + let m = IntelTdxParser.parse(&td_quote(&published_td_quote(version))).unwrap(); + assert_eq!(m.len(), 6); + assert_eq!(digest_of(&m, "tdx.mrtd"), "aa".repeat(48), "v{version}"); + for k in 0..4 { + assert_eq!(digest_of(&m, &format!("tdx.rtmr{k}")), format!("{:02x}", 0xB0 + k).repeat(48), "v{version}"); + } + assert_eq!(digest_of(&m, "tdx.report_data"), "cc".repeat(64), "v{version}"); + assert_eq!(m.iter().find(|x| x.key == "tdx.mrtd").unwrap().alg, "sha384"); + } + } + + /// A quote whose header says it is not a TD quote, or whose version 5 + /// body is not a TD report, is refused rather than read at TD offsets. + #[test] + fn tdx_parser_refuses_what_is_not_a_td_report() { + let mut sgx = published_td_quote(4); + sgx[4..8].copy_from_slice(&0u32.to_le_bytes()); + assert!(matches!(IntelTdxParser.parse(&td_quote(&sgx)), Err(QuoteError::Malformed(_)))); + + let mut enclave = published_td_quote(5); + enclave[48..50].copy_from_slice(&1u16.to_le_bytes()); + assert!(matches!(IntelTdxParser.parse(&td_quote(&enclave)), Err(QuoteError::Malformed(_)))); + + let mut missized = published_td_quote(5); + missized[50..54].copy_from_slice(&648u32.to_le_bytes()); + assert!(matches!(IntelTdxParser.parse(&td_quote(&missized)), Err(QuoteError::Malformed(_)))); + + let mut td15 = published_td_quote(5); + td15[48..50].copy_from_slice(&3u16.to_le_bytes()); + td15[50..54].copy_from_slice(&648u32.to_le_bytes()); + td15.resize(54 + 648, 0); + let m = IntelTdxParser.parse(&td_quote(&td15)).unwrap(); + assert_eq!(digest_of(&m, "tdx.report_data"), "cc".repeat(64), "a TD 1.5 body reads as TD 1.0's"); + + let short = &published_td_quote(4)[..48 + 583]; + assert!(matches!(IntelTdxParser.parse(&td_quote(short)), Err(QuoteError::TooShort { need: 632, .. }))); } #[test] @@ -450,8 +526,7 @@ mod tests { #[test] fn tdx_parser_rejects_unsupported_version() { - let total = TDX_HEADER_LEN + TDX_REPORT_BODY_LEN; - let mut bytes = vec![0u8; total]; + let mut bytes = published_td_quote(4); bytes[0] = 99; let q = Quote { kind: QuoteKind::IntelTdx, @@ -475,19 +550,33 @@ mod tests { )); } + /// An SNP report laid out by the published `ATTESTATION_REPORT` format, + /// from literal offsets, with a marker in each field the parser reads and + /// in the fields at the offsets it used to read. + fn published_snp_report() -> Quote { + let mut r = vec![0u8; 1184]; + mark(&mut r, 0x050, 64, 0x50); // REPORT_DATA + mark(&mut r, 0x090, 48, 0x90); // MEASUREMENT + mark(&mut r, 0x0C0, 32, 0xC0); // HOST_DATA + mark(&mut r, 0x0E0, 48, 0xE0); // ID_KEY_DIGEST + mark(&mut r, 0x110, 48, 0x11); // AUTHOR_KEY_DIGEST, where the measurement was read + mark(&mut r, 0x140, 32, 0x14); // REPORT_ID + mark(&mut r, 0x160, 32, 0x16); // REPORT_ID_MA, where id_key_digest was read + Quote { kind: QuoteKind::AmdSevSnp, bytes_hex: encode_hex(&r) } + } + + /// Each field is read from its published offset: the launch measurement + /// at 0x90, not inside author_key_digest, and id_key_digest at 0xE0, not + /// report_id_ma. #[test] - fn snp_parser_extracts_four_measurements() { - let q = synthesize_snp_report(); - let m = AmdSevSnpParser.parse(&q).unwrap(); + fn snp_parser_reads_each_field_at_its_published_offset() { + let m = AmdSevSnpParser.parse(&published_snp_report()).unwrap(); assert_eq!(m.len(), 4); - let lm = m - .iter() - .find(|x| x.key == "snp.launch_measurement") - .unwrap(); - assert_eq!(lm.digest, "dd".repeat(SNP_LAUNCH_MEASUREMENT_LEN)); - let hd = m.iter().find(|x| x.key == "snp.host_data").unwrap(); - assert_eq!(hd.alg, "sha256"); - assert_eq!(hd.digest, "ee".repeat(SNP_HOST_DATA_LEN)); + assert_eq!(digest_of(&m, "snp.report_data"), "50".repeat(64)); + assert_eq!(digest_of(&m, "snp.launch_measurement"), "90".repeat(48)); + assert_eq!(digest_of(&m, "snp.host_data"), "c0".repeat(32)); + assert_eq!(digest_of(&m, "snp.id_key_digest"), "e0".repeat(48)); + assert_eq!(m.iter().find(|x| x.key == "snp.host_data").unwrap().alg, "sha256"); } #[test] @@ -572,7 +661,7 @@ mod tests { Box::new(AmdSevSnpParser), Box::new(InTotoSlsaParser), ]; - let q = synthesize_tdx_v4_quote(); + let q = td_quote(&published_td_quote(4)); let m = dispatch(&parsers, &q).unwrap(); assert_eq!(m.len(), 6);