From be78b6bc3ecf6a05d084be315e7d31feb9448621 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 29 May 2026 23:41:59 +0000 Subject: [PATCH 1/4] Pin enclave measurement: expected-MRTD check (Layer 1, warn-then-enforce) Attestation proved "a genuine TDX VM bound to this key" but never checked the measurement, so any genuine enclave (incl. different/malicious code) with a valid token + bound key passed. mrtd was logged and never compared. Add ita::ExpectedMeasurements (mrtd allowlist + tcb_status + enforce flag) from env (DD_EXPECTED_MRTD / DD_EXPECTED_TCB / DD_MEASUREMENT_ENFORCE). Check after verifier.verify in cp.rs::register and the collector scrape: unset = observe-only (current behavior), pinned+enforce = reject mismatch (401 / drop from store), pinned+!enforce = warn (canary). Default tcb = UpToDate once pinned. Source of the pinned value is a committed/blessed measurement (PR review = trust anchor), or the ee-mini signed manifest once it exists. Note: this pins the firmware+kernel+initrd baseline; covering the rootfs needs dm-verity (ee-mini, Layer 0) and workloads need digest pinning (Layer 2). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/collector.rs | 10 +++++ src/cp.rs | 27 ++++++++++++ src/ita.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/src/collector.rs b/src/collector.rs index eed32db..d858a14 100644 --- a/src/collector.rs +++ b/src/collector.rs @@ -227,6 +227,7 @@ pub async fn run( cp_hostname: String, ee: Arc, verifier: Arc, + expected: Arc, wake: Arc, scrape_interval: Duration, discovery_interval: Duration, @@ -270,6 +271,7 @@ pub async fn run( &prefix, &ee, &verifier, + &expected, &env_label, &cp_hostname, discover, @@ -289,6 +291,7 @@ async fn tick( prefix: &str, ee: &Arc, verifier: &Arc, + expected: &Arc, env_label: &str, cp_hostname: &str, discover: bool, @@ -375,6 +378,13 @@ async fn tick( continue; } }; + if let Err(reason) = expected.check(&claims) { + if expected.enforce { + eprintln!("cp: collector: {name} measurement mismatch — dropping: {reason}"); + continue; + } + eprintln!("cp: collector: {name} measurement mismatch (not enforced): {reason}"); + } // Store key is the tunnel name (authoritative on the CP side), // NOT the agent's self-reported agent_id. let mut s = store.lock().await; diff --git a/src/cp.rs b/src/cp.rs index 52e2143..19c1c90 100644 --- a/src/cp.rs +++ b/src/cp.rs @@ -48,6 +48,9 @@ struct St { collector_wake: Arc, started: Instant, verifier: Arc, + /// Expected enclave measurement allowlist (MRTD/TCB). Pins the fleet to + /// known-good code; unpinned = observe-only. + expected: Arc, /// The CP's own ITA token. Refreshed by a background task. cp_ita_token: Arc>, /// GH OIDC verifier for `/api/agents` callers (CI, humans). Same @@ -304,6 +307,18 @@ pub async fn run() -> Result<()> { // Start the collector with the verifier. It re-verifies each // scraped agent's ita_token, so expired / revoked / unsigned // agents drop off the dashboard automatically. + let expected = Arc::new(ita::ExpectedMeasurements::from_env()); + if expected.is_pinned() { + eprintln!( + "cp: measurement pinning ON ({} mrtd(s), tcb={}, enforce={})", + expected.mrtds.len(), + expected.tcb_status.as_deref().unwrap_or("any"), + expected.enforce + ); + } else { + eprintln!("cp: measurement pinning OFF (DD_EXPECTED_MRTD unset) — observe only"); + } + let collector_wake = Arc::new(Notify::new()); tokio::spawn(collector::run( store.clone(), @@ -312,6 +327,7 @@ pub async fn run() -> Result<()> { cfg.hostname.clone(), ee.clone(), verifier.clone(), + expected.clone(), collector_wake.clone(), Duration::from_secs(cfg.scrape_interval_secs), Duration::from_secs(cfg.discovery_interval_secs), @@ -328,6 +344,7 @@ pub async fn run() -> Result<()> { collector_wake, started: Instant::now(), verifier, + expected, cp_ita_token, gh, }; @@ -514,6 +531,16 @@ async fn register( ita_claims.mrtd.as_deref().unwrap_or("?"), ita_claims.tcb_status.as_deref().unwrap_or("?") ); + if let Err(reason) = s.expected.check(&ita_claims) { + if s.expected.enforce { + eprintln!("cp: REJECT register {}: {reason}", req.vm_name); + return Err(Error::Unauthorized); + } + eprintln!( + "cp: WARN register {} measurement mismatch (not enforced): {reason}", + req.vm_name + ); + } let http = cf::http_client(); let name = cf::agent_tunnel_name(&s.cfg.common.env_label); diff --git a/src/ita.rs b/src/ita.rs index 603ce33..cc6035d 100644 --- a/src/ita.rs +++ b/src/ita.rs @@ -73,6 +73,75 @@ impl Claims { } } +/// Expected enclave measurement allowlist — pins attestation to known-good code. +/// +/// Sourced from env (see [`Self::from_env`]). When no MRTD is configured the +/// fleet/measurement is *unpinned*: [`Self::check`] passes everything (the agent +/// MRTD is still logged at register/scrape). Once pinned, mismatches are rejected +/// (or, with enforcement off, logged as a warning for canary rollout). +#[derive(Debug, Clone, Default)] +pub struct ExpectedMeasurements { + /// Accepted MRTDs (lowercase hex), any-of. Empty = unpinned. + pub mrtds: Vec, + /// Required TCB status (e.g. "UpToDate"); defaults to "UpToDate" once pinned. + pub tcb_status: Option, + /// Reject on mismatch (true) vs. warn-and-allow (false, canary). + pub enforce: bool, +} + +impl ExpectedMeasurements { + /// `DD_EXPECTED_MRTD` = comma/space-separated hex MRTDs (empty = unpinned). + /// `DD_EXPECTED_TCB` = required TCB status (default "UpToDate" when pinned). + /// `DD_MEASUREMENT_ENFORCE` = "0"/"false" to warn instead of reject. + pub fn from_env() -> Self { + let mrtds: Vec = std::env::var("DD_EXPECTED_MRTD") + .unwrap_or_default() + .split([',', ' ', '\n', '\t']) + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + let tcb_status = std::env::var("DD_EXPECTED_TCB") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| (!mrtds.is_empty()).then(|| "UpToDate".to_string())); + let enforce = !matches!( + std::env::var("DD_MEASUREMENT_ENFORCE").as_deref(), + Ok("0") | Ok("false") | Ok("no") + ); + Self { + mrtds, + tcb_status, + enforce, + } + } + + pub fn is_pinned(&self) -> bool { + !self.mrtds.is_empty() + } + + /// `Ok(())` if unpinned or matching; `Err(reason)` if pinned and mismatched. + pub fn check(&self, claims: &Claims) -> std::result::Result<(), String> { + if self.mrtds.is_empty() { + return Ok(()); + } + let mrtd = claims.mrtd.as_deref().unwrap_or("").to_lowercase(); + if !self.mrtds.contains(&mrtd) { + return Err(format!( + "mrtd {} not in expected allowlist", + if mrtd.is_empty() { "" } else { &mrtd } + )); + } + if let Some(want) = &self.tcb_status { + let got = claims.tcb_status.as_deref().unwrap_or(""); + if got != want { + return Err(format!("tcb_status {got:?} != expected {want:?}")); + } + } + Ok(()) + } +} + // ── Minter ────────────────────────────────────────────────────────────── #[derive(Serialize)] @@ -315,4 +384,41 @@ mod tests { assert_eq!(c.report_data.as_deref(), Some("cc")); assert_eq!(c.extra, v); } + + fn claims_with(mrtd: &str, tcb: &str) -> Claims { + Claims { + mrtd: Some(mrtd.into()), + tcb_status: Some(tcb.into()), + ..Default::default() + } + } + + #[test] + fn unpinned_allows_anything() { + let exp = ExpectedMeasurements::default(); + assert!(!exp.is_pinned()); + assert!(exp.check(&claims_with("deadbeef", "OutOfDate")).is_ok()); + } + + #[test] + fn pinned_accepts_match_rejects_mismatch() { + let exp = ExpectedMeasurements { + mrtds: vec!["aa".into(), "bb".into()], + tcb_status: Some("UpToDate".into()), + enforce: true, + }; + assert!(exp.check(&claims_with("bb", "UpToDate")).is_ok()); + assert!(exp.check(&claims_with("cc", "UpToDate")).is_err()); // wrong mrtd + assert!(exp.check(&claims_with("aa", "OutOfDate")).is_err()); // bad tcb + } + + #[test] + fn mrtd_match_is_case_insensitive() { + let exp = ExpectedMeasurements { + mrtds: vec!["abcd".into()], + tcb_status: None, + enforce: true, + }; + assert!(exp.check(&claims_with("ABCD", "UpToDate")).is_ok()); + } } From 75462bcd094d834ef7417fe1cb987c397f47a283 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sat, 30 May 2026 12:29:33 +0000 Subject: [PATCH 2/4] Also read tdx_report_data on the server for consistency Mirror the client fix: Intel TDX tokens expose the quote report_data as tdx_report_data, not attester_held_data. Harmless today (CP reads only mrtd/tcb) but keeps Claims correct as measurement/binding checks expand. --- src/ita.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ita.rs b/src/ita.rs index cc6035d..fc4404f 100644 --- a/src/ita.rs +++ b/src/ita.rs @@ -67,7 +67,9 @@ impl Claims { attester_type: get("attester_type"), mrtd: get("tdx_mrtd"), mrsigner: get("tdx_mrsigner"), - report_data: get("attester_held_data"), + // Intel TDX tokens carry the quote's report_data as `tdx_report_data`; + // `attester_held_data` only appears when held-data is submitted at mint. + report_data: get("attester_held_data").or_else(|| get("tdx_report_data")), extra: v, } } From 01a568cf05df12a559dffeb5446598205bcfcc72 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sat, 30 May 2026 13:40:53 +0000 Subject: [PATCH 3/4] Read tdx_report_data directly (drop needless attester_held_data fallback) --- src/ita.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ita.rs b/src/ita.rs index fc4404f..f64b6c9 100644 --- a/src/ita.rs +++ b/src/ita.rs @@ -67,9 +67,8 @@ impl Claims { attester_type: get("attester_type"), mrtd: get("tdx_mrtd"), mrsigner: get("tdx_mrsigner"), - // Intel TDX tokens carry the quote's report_data as `tdx_report_data`; - // `attester_held_data` only appears when held-data is submitted at mint. - report_data: get("attester_held_data").or_else(|| get("tdx_report_data")), + // Intel TDX tokens carry the quote's report_data as `tdx_report_data`. + report_data: get("tdx_report_data"), extra: v, } } From 363b037698ec4a12dc2682ae39b5602f8582916d Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sat, 30 May 2026 13:42:34 +0000 Subject: [PATCH 4/4] Fix: report_data is dual-mode, not a stray fallback Intel tokens use tdx_report_data; the local dev issuer (mint_local) uses attester_held_data. Read tdx_report_data first, attester_held_data as the local-mode source. (Reverts the over-eager removal that broke local-mode tests.) --- src/ita.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ita.rs b/src/ita.rs index f64b6c9..b9c7f96 100644 --- a/src/ita.rs +++ b/src/ita.rs @@ -67,8 +67,9 @@ impl Claims { attester_type: get("attester_type"), mrtd: get("tdx_mrtd"), mrsigner: get("tdx_mrsigner"), - // Intel TDX tokens carry the quote's report_data as `tdx_report_data`. - report_data: get("tdx_report_data"), + // Real Intel tokens carry the quote's report_data as `tdx_report_data`; + // the local dev issuer (`mint_local`) puts it in `attester_held_data`. + report_data: get("tdx_report_data").or_else(|| get("attester_held_data")), extra: v, } }