Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ pub async fn run(
cp_hostname: String,
ee: Arc<Ee>,
verifier: Arc<ita::Verifier>,
expected: Arc<ita::ExpectedMeasurements>,
wake: Arc<Notify>,
scrape_interval: Duration,
discovery_interval: Duration,
Expand Down Expand Up @@ -270,6 +271,7 @@ pub async fn run(
&prefix,
&ee,
&verifier,
&expected,
&env_label,
&cp_hostname,
discover,
Expand All @@ -289,6 +291,7 @@ async fn tick(
prefix: &str,
ee: &Arc<Ee>,
verifier: &Arc<ita::Verifier>,
expected: &Arc<ita::ExpectedMeasurements>,
env_label: &str,
cp_hostname: &str,
discover: bool,
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions src/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ struct St {
collector_wake: Arc<Notify>,
started: Instant,
verifier: Arc<ita::Verifier>,
/// Expected enclave measurement allowlist (MRTD/TCB). Pins the fleet to
/// known-good code; unpinned = observe-only.
expected: Arc<ita::ExpectedMeasurements>,
/// The CP's own ITA token. Refreshed by a background task.
cp_ita_token: Arc<RwLock<String>>,
/// GH OIDC verifier for `/api/agents` callers (CI, humans). Same
Expand Down Expand Up @@ -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(),
Expand All @@ -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),
Expand All @@ -328,6 +344,7 @@ pub async fn run() -> Result<()> {
collector_wake,
started: Instant::now(),
verifier,
expected,
cp_ita_token,
gh,
};
Expand Down Expand Up @@ -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);
Expand Down
110 changes: 109 additions & 1 deletion src/ita.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Required TCB status (e.g. "UpToDate"); defaults to "UpToDate" once pinned.
pub tcb_status: Option<String>,
/// 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<String> = 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() { "<none>" } 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)]
Expand Down Expand Up @@ -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());
}
}
Loading