jouleclaw-attest-vendor: read TDX and SNP fields at their published offsets #389

Merged
dcharlot merged 1 commit from fix/attest-vendor-offsets into main 2026-10-21 10:19:10 -04:00

View file

@ -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<usize, QuoteError> {
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(&quote.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<u8> {
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);