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..b9c7f96 100644 --- a/src/ita.rs +++ b/src/ita.rs @@ -67,12 +67,83 @@ impl Claims { attester_type: get("attester_type"), mrtd: get("tdx_mrtd"), mrsigner: get("tdx_mrsigner"), - report_data: get("attester_held_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, } } } +/// 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 +386,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()); + } }