diff --git a/attestation-gateway/src/android/android_attestation_service.rs b/attestation-gateway/src/android/android_attestation_service.rs index f52573e..0ded5fb 100644 --- a/attestation-gateway/src/android/android_attestation_service.rs +++ b/attestation-gateway/src/android/android_attestation_service.rs @@ -145,6 +145,9 @@ pub struct AndroidAttestationOutput { pub device_public_key: Vec, pub os_patch_level_delta: Option, pub integrity_confidence: IntegrityConfidence, + /// SHA-256 fingerprint of the intermediate (batch) certificate. Used by + /// the keybox-defense layer for rate limiting and blocklisting. + pub batch_cert_fingerprint: Option, } #[derive(Clone)] @@ -535,10 +538,26 @@ impl AndroidAttestationService { tracing::info!("android verify: verification complete, all checks passed"); + // Keybox-bypass rate limiting and blocklisting target legacy batch attestation keys, + // which can leak. Chains rooted in a Remote-Key-Provisioning (RKP) root are issued + // by Google per-device and cannot have been produced from a leaked keybox, so we + // intentionally skip the keybox-defense fingerprint (and therefore both the rate + // limiter and the blocklist lookup) for those chains. + let batch_cert_fingerprint = if is_rkp { + tracing::info!("android verify: RKP-rooted chain, skipping keybox-defense fingerprint"); + None + } else { + cert_chain.intermediate_cert_der().map(|der| { + use super::keybox_defense::KeyboxDefense; + KeyboxDefense::fingerprint(der) + }) + }; + Ok(AndroidAttestationOutput { device_public_key: cert_chain.device_certificate().public_key(), os_patch_level_delta, integrity_confidence, + batch_cert_fingerprint, }) } } diff --git a/attestation-gateway/src/android/keybox_defense.rs b/attestation-gateway/src/android/keybox_defense.rs new file mode 100644 index 0000000..7721e71 --- /dev/null +++ b/attestation-gateway/src/android/keybox_defense.rs @@ -0,0 +1,266 @@ +//! Keybox bypass defense: per-`(batch cert fingerprint, aud)` rate limiting +//! plus an explicit blocklist. +//! +//! Provides a stateful enforcement layer on top of the stateless confidence +//! signals collected in `IntegrityConfidence`. Uses Redis for: +//! +//! * **Sliding-window counter** keyed on +//! `(SHA256(intermediate_cert_DER), aud)` -- the audience is included so a +//! single legitimately popular batch cert that issues many tokens for one +//! verifier does not affect the threshold for a different verifier. +//! * **Blocklist** of known-compromised certificate fingerprints +//! (`keybox:block:{fingerprint}`). +//! +//! Enforcement defaults to **shadow mode**: the verdict is computed and +//! logged/metered, but `should_reject()` always returns `false` unless the +//! gateway is started with `KEYBOX_DEFENSE_ENFORCE=1`. This lets us tune +//! thresholds against production traffic before flipping to enforcement. + +use openssl::sha::sha256; +use redis::aio::ConnectionManager; +use redis::{AsyncCommands, RedisError}; +use thiserror::Error; + +/// Outcome of the keybox defense check on a single attestation request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RiskLevel { + /// No anomalies detected. + Low, + /// Elevated usage count for this `(fingerprint, aud)` -- could be + /// legitimate (popular device model) or the start of abuse. Log only. + Medium, + /// Usage count exceeds the hard block threshold. The caller should + /// reject when running in enforcement mode. + High, + /// Certificate fingerprint is on the explicit blocklist. + Blocked, +} + +/// Configuration for the defense thresholds. +#[derive(Debug, Clone)] +pub struct KeyboxDefenseConfig { + /// Requests per `(fingerprint, aud)` within `window_secs` before the + /// risk level is raised to `Medium` (monitoring threshold). + pub warn_threshold: u64, + /// Requests per `(fingerprint, aud)` within `window_secs` before the + /// risk level is raised to `High` (blocking threshold). + pub block_threshold: u64, + /// Sliding window duration in seconds. + pub window_secs: u64, + /// When `false` (the default) `should_reject()` returns `false` for + /// every verdict; verdicts are still logged and metered. Flip via + /// `KEYBOX_DEFENSE_ENFORCE=1` once thresholds are tuned. + pub enforce: bool, +} + +impl Default for KeyboxDefenseConfig { + fn default() -> Self { + Self { + warn_threshold: 500, + block_threshold: 5000, + window_secs: 3600, + enforce: false, + } + } +} + +impl KeyboxDefenseConfig { + #[must_use] + pub fn from_env() -> Self { + let warn = std::env::var("KEYBOX_WARN_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let block = std::env::var("KEYBOX_BLOCK_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5000); + let window = std::env::var("KEYBOX_WINDOW_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3600); + let enforce = std::env::var("KEYBOX_DEFENSE_ENFORCE").ok().as_deref() == Some("1"); + Self { + warn_threshold: warn, + block_threshold: block, + window_secs: window, + enforce, + } + } +} + +#[derive(Debug, Error)] +pub enum KeyboxDefenseError { + #[error("redis error: {0}")] + Redis(#[source] RedisError), +} + +/// Verdict for a single attestation request. +#[derive(Debug, Clone, serde::Serialize)] +pub struct KeyboxDefenseVerdict { + pub risk_level: RiskLevel, + pub batch_cert_fingerprint: String, + pub aud: String, + pub request_count: u64, + pub blocklisted: bool, + /// Mirrors `KeyboxDefenseConfig::enforce` so callers can decide what to + /// do with `RiskLevel::High` verdicts without re-reading the config. + pub enforce: bool, +} + +impl KeyboxDefenseVerdict { + /// `true` when the caller should reject the attestation. Always returns + /// `false` while the defense layer runs in shadow mode (`enforce: false`), + /// regardless of `risk_level`. + #[must_use] + pub const fn should_reject(&self) -> bool { + if !self.enforce { + return false; + } + matches!(self.risk_level, RiskLevel::Blocked | RiskLevel::High) + } +} + +#[derive(Clone)] +pub struct KeyboxDefense { + config: KeyboxDefenseConfig, +} + +impl KeyboxDefense { + #[must_use] + pub const fn new(config: KeyboxDefenseConfig) -> Self { + Self { config } + } + + /// SHA-256 fingerprint of the intermediate (batch) certificate DER bytes. + /// Hex-encoded, lowercase. + #[must_use] + pub fn fingerprint(intermediate_cert_der: &[u8]) -> String { + hex::encode(sha256(intermediate_cert_der)) + } + + /// Evaluate an attestation request. + /// + /// 1. Check the blocklist (`keybox:block:{fingerprint}`) + /// 2. Increment the sliding-window counter (`keybox:count:{fingerprint}:{aud}`) + /// 3. Map the count to `RiskLevel` + pub async fn evaluate( + &self, + redis: &mut ConnectionManager, + batch_cert_fingerprint: &str, + aud: &str, + ) -> Result { + let block_key = format!("keybox:block:{batch_cert_fingerprint}"); + let count_key = format!("keybox:count:{batch_cert_fingerprint}:{aud}"); + + let blocklisted: bool = redis + .exists(&block_key) + .await + .map_err(KeyboxDefenseError::Redis)?; + + if blocklisted { + metrics::counter!( + "attestation_gateway.keybox_defense", + "action" => "blocked", + "enforce" => self.config.enforce.to_string(), + ) + .increment(1); + + tracing::warn!( + fingerprint = %batch_cert_fingerprint, + aud = %aud, + enforce = self.config.enforce, + "blocklisted batch certificate used in attestation" + ); + + return Ok(KeyboxDefenseVerdict { + risk_level: RiskLevel::Blocked, + batch_cert_fingerprint: batch_cert_fingerprint.to_string(), + aud: aud.to_string(), + request_count: 0, + blocklisted: true, + enforce: self.config.enforce, + }); + } + + let window_secs: i64 = self.config.window_secs.try_into().unwrap_or(i64::MAX); + let count: u64 = redis::pipe() + .atomic() + .incr(&count_key, 1_u64) + .expire(&count_key, window_secs) + .ignore() + .query_async::>(redis) + .await + .map_err(KeyboxDefenseError::Redis)? + .first() + .copied() + .unwrap_or(1); + + let risk_level = if count >= self.config.block_threshold { + RiskLevel::High + } else if count >= self.config.warn_threshold { + RiskLevel::Medium + } else { + RiskLevel::Low + }; + + metrics::counter!( + "attestation_gateway.keybox_defense", + "risk_level" => format!("{risk_level:?}"), + "enforce" => self.config.enforce.to_string(), + ) + .increment(1); + + if risk_level != RiskLevel::Low { + tracing::warn!( + fingerprint = %batch_cert_fingerprint, + aud = %aud, + count = count, + risk_level = ?risk_level, + enforce = self.config.enforce, + warn_threshold = self.config.warn_threshold, + block_threshold = self.config.block_threshold, + "elevated batch certificate usage" + ); + } + + Ok(KeyboxDefenseVerdict { + risk_level, + batch_cert_fingerprint: batch_cert_fingerprint.to_string(), + aud: aud.to_string(), + request_count: count, + blocklisted: false, + enforce: self.config.enforce, + }) + } + + /// Add a certificate fingerprint to the blocklist. Persistent until + /// removed. + pub async fn blocklist_add( + redis: &mut ConnectionManager, + fingerprint: &str, + ) -> Result<(), KeyboxDefenseError> { + let key = format!("keybox:block:{fingerprint}"); + redis + .set::<_, _, ()>(&key, "1") + .await + .map_err(KeyboxDefenseError::Redis)?; + tracing::info!(fingerprint = %fingerprint, "added to keybox blocklist"); + Ok(()) + } + + /// Remove a certificate fingerprint from the blocklist. + pub async fn blocklist_remove( + redis: &mut ConnectionManager, + fingerprint: &str, + ) -> Result<(), KeyboxDefenseError> { + let key = format!("keybox:block:{fingerprint}"); + redis + .del::<_, ()>(&key) + .await + .map_err(KeyboxDefenseError::Redis)?; + tracing::info!(fingerprint = %fingerprint, "removed from keybox blocklist"); + Ok(()) + } +} diff --git a/attestation-gateway/src/android/mod.rs b/attestation-gateway/src/android/mod.rs index fb2d825..20208b9 100644 --- a/attestation-gateway/src/android/mod.rs +++ b/attestation-gateway/src/android/mod.rs @@ -11,6 +11,7 @@ mod android_revocation_list; mod device_certificate; mod integrity_token_data; pub mod key_description; +pub mod keybox_defense; mod root_certificate; pub use android_attestation_service::AndroidAttestationService; diff --git a/attestation-gateway/src/routes/a.rs b/attestation-gateway/src/routes/a.rs index e2e11a9..c0dea05 100644 --- a/attestation-gateway/src/routes/a.rs +++ b/attestation-gateway/src/routes/a.rs @@ -17,7 +17,10 @@ use redis::aio::ConnectionManager; use schemars::JsonSchema; use crate::{ - android::AndroidAttestationService, + android::{ + AndroidAttestationService, + keybox_defense::{KeyboxDefense, KeyboxDefenseConfig}, + }, apple, keys, kms_jws, nonces::{NonceDb, NonceDbError}, utils::{BundleIdentifier, ErrorCode, GlobalConfig, Platform, RequestError}, @@ -159,6 +162,8 @@ pub async fn handler( let challenge = format!("n={},av={}", request.nonce, request.app_version); let platform = request.bundle_identifier.platform(); + let mut android_batch_cert_fingerprint: Option = None; + let device_public_key = match platform { Platform::AppleIOS => { let apple_attestation = request.apple_attestation.ok_or_else(|| RequestError { @@ -243,6 +248,7 @@ pub async fn handler( } } + android_batch_cert_fingerprint = attestation_output.batch_cert_fingerprint.clone(); attestation_output.device_public_key } }; @@ -277,6 +283,37 @@ pub async fn handler( "/a handler: nonce consumed successfully" ); + if let Some(ref fp) = android_batch_cert_fingerprint { + let defense = KeyboxDefense::new(KeyboxDefenseConfig::from_env()); + match defense.evaluate(&mut redis, fp, &token_details.aud).await { + Ok(verdict) => { + tracing::info!( + fingerprint = %fp, + aud = %token_details.aud, + risk_level = ?verdict.risk_level, + request_count = verdict.request_count, + blocklisted = verdict.blocklisted, + enforce = verdict.enforce, + "/a handler: keybox defense verdict" + ); + if verdict.should_reject() { + let reason = if verdict.blocklisted { + "certificate blocklisted" + } else { + "rate limit exceeded for attestation certificate" + }; + return Err(RequestError { + code: ErrorCode::BadRequest, + details: Some(reason.to_string()), + }); + } + } + Err(e) => { + tracing::error!(error = ?e, "/a handler: keybox defense evaluation failed (non-blocking)"); + } + } + } + let exp = match request.exp { Some(exp) => { if exp > token_details.exp_max {