diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index aec3c43b02..c7701c7f5f 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -7,6 +7,10 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[features] +default = [] +dev-app-attest-bypass = [] + [lib] name = "buzz_push_gateway" path = "src/lib.rs" diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs index 8f6f182000..0d8522034b 100644 --- a/crates/buzz-push-gateway/src/apns.rs +++ b/crates/buzz-push-gateway/src/apns.rs @@ -218,6 +218,7 @@ impl PushTransport for ApnsTransport { AppProfile::BuzzIosProduction => &self.production_base_url, AppProfile::BuzzIosSandbox => &self.sandbox_base_url, }; + crate::metrics::record_apns_send_attempt(); let response = self .client .post(format!("{base_url}/3/device/{endpoint}")) diff --git a/crates/buzz-push-gateway/src/app_attest_policy.rs b/crates/buzz-push-gateway/src/app_attest_policy.rs new file mode 100644 index 0000000000..3ea5796075 --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest_policy.rs @@ -0,0 +1,82 @@ +//! Selects the production Apple verifier or the feature-gated development stub. + +use crate::app_attest::{ + AppAttestError, AppAttestVerifier, VerifiedAssertion, VerifiedAttestation, +}; + +#[cfg_attr( + not(feature = "dev-app-attest-bypass"), + doc = r#" +The development policy is structurally unavailable in default builds: + +```compile_fail +use buzz_push_gateway::app_attest_policy::AppAttestPolicy; + +let _ = AppAttestPolicy::Development; +``` +"# +)] +#[derive(Clone)] +pub enum AppAttestPolicy { + Apple(AppAttestVerifier), + #[cfg(feature = "dev-app-attest-bypass")] + Development, +} + +impl AppAttestPolicy { + pub fn apple(verifier: AppAttestVerifier) -> Self { + Self::Apple(verifier) + } + + #[cfg(feature = "dev-app-attest-bypass")] + pub fn development() -> Self { + Self::Development + } + + pub fn verify_attestation( + &self, + attestation_b64: &str, + key_id_b64: &str, + client_data: &[u8], + ) -> Result { + match self { + Self::Apple(verifier) => { + verifier.verify_attestation(attestation_b64, key_id_b64, client_data) + } + #[cfg(feature = "dev-app-attest-bypass")] + Self::Development => { + crate::dev_app_attest::verify_attestation(attestation_b64, key_id_b64, client_data) + } + } + } + + pub fn verify_assertion( + &self, + assertion_b64: &str, + client_data: &[u8], + public_key: &[u8], + previous_counter: u32, + challenge: &str, + stored_challenge: &str, + ) -> Result { + match self { + Self::Apple(verifier) => verifier.verify_assertion( + assertion_b64, + client_data, + public_key, + previous_counter, + challenge, + stored_challenge, + ), + #[cfg(feature = "dev-app-attest-bypass")] + Self::Development => crate::dev_app_attest::verify_assertion( + assertion_b64, + client_data, + public_key, + previous_counter, + challenge, + stored_challenge, + ), + } + } +} diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index c6194edbcb..0730ad0c72 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -25,6 +25,8 @@ pub struct Config { pub database_url: String, pub app_attest_app_id: String, pub app_attest_root_cert_path: PathBuf, + #[cfg(feature = "dev-app-attest-bypass")] + pub dev_app_attest_bypass: bool, /// Ordered current key first, followed by decrypt-only predecessors. pub grant_keys: Vec, /// Independent token-custody keyring. These keys MUST NOT be reused for @@ -152,19 +154,38 @@ impl Config { if enabled_profiles.is_empty() { return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); } + let bind_addr = e + .get("BUZZ_PUSH_BIND_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8080") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?; + let health_addr = e + .get("BUZZ_PUSH_HEALTH_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8081") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?; + #[cfg(feature = "dev-app-attest-bypass")] + let dev_app_attest_bypass = e + .get("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS") + .is_some_and(|value| value == "true"); + #[cfg(feature = "dev-app-attest-bypass")] + if dev_app_attest_bypass + && (!bind_addr.ip().is_loopback() || !health_addr.ip().is_loopback()) + { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } + #[cfg(feature = "dev-app-attest-bypass")] + if dev_app_attest_bypass + && (enabled_profiles.len() != 1 + || !enabled_profiles.contains(&crate::model::AppProfile::BuzzIosSandbox)) + { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } Ok(Self { - bind_addr: e - .get("BUZZ_PUSH_BIND_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8080") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?, - health_addr: e - .get("BUZZ_PUSH_HEALTH_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8081") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?, + bind_addr, + health_addr, public_delivery_url, max_grant_lifetime_seconds, max_installation_lifetime_seconds, @@ -174,6 +195,8 @@ impl Config { database_url: req(e, "DATABASE_URL")?.to_owned(), app_attest_app_id: req(e, "BUZZ_PUSH_APP_ATTEST_APP_ID")?.to_owned(), app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), + #[cfg(feature = "dev-app-attest-bypass")] + dev_app_attest_bypass, grant_keys, token_keys, apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(), @@ -220,7 +243,7 @@ mod tests { ), ( "DATABASE_URL".into(), - "postgres://buzz:test@localhost/buzz".into(), + "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 -- existing local test-only credentials ), ("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()), ( @@ -231,6 +254,8 @@ mod tests { ("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()), ("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()), ("BUZZ_PUSH_APNS_TOPIC".into(), "app".into()), + ("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()), + ("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()), ]) } @@ -279,6 +304,135 @@ mod tests { } } + #[test] + fn listener_defaults_remain_public_when_addresses_are_absent() { + let mut env = base(); + env.remove("BUZZ_PUSH_BIND_ADDR"); + env.remove("BUZZ_PUSH_HEALTH_ADDR"); + + let config = Config::from_map(&env).unwrap(); + assert_eq!(config.bind_addr, "0.0.0.0:8080".parse().unwrap()); + assert_eq!(config.health_addr, "0.0.0.0:8081".parse().unwrap()); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_is_off_when_absent_or_exactly_false() { + let absent = Config::from_map(&base()).unwrap(); + assert!(!absent.dev_app_attest_bypass); + + let mut explicit_false = base(); + explicit_false.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "false".into()); + let explicit_false = Config::from_map(&explicit_false).unwrap(); + assert!(!explicit_false.dev_app_attest_bypass); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_selects_apple_for_every_value_other_than_exact_true() { + for value in [ + None, + Some(""), + Some("false"), + Some("TRUE"), + Some("1"), + Some("yes"), + Some(" true"), + ] { + let mut env = base(); + if let Some(value) = value { + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), value.into()); + } + let config = Config::from_map(&env).unwrap(); + assert!(!config.dev_app_attest_bypass, "enabled for {value:?}"); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_requires_both_loopback_listeners() { + for (key, value) in [ + ("BUZZ_PUSH_BIND_ADDR", "0.0.0.0:8080"), + ("BUZZ_PUSH_HEALTH_ADDR", "[::]:8081"), + ] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-sandbox".into(), + ); + env.insert(key.into(), value.into()); + assert!(Config::from_map(&env).is_err(), "accepted {key}={value}"); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn non_loopback_bind_is_rejected_before_loopback_equivalent_is_accepted() { + let mut non_loopback = base(); + non_loopback.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into()); + non_loopback.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-sandbox".into(), + ); + non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "0.0.0.0:8080".into()); + assert!(Config::from_map(&non_loopback).is_err()); + + non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()); + assert!( + Config::from_map(&non_loopback) + .unwrap() + .dev_app_attest_bypass + ); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_requires_sandbox_as_the_only_profile() { + for profiles in [ + "buzz-ios-production", + "buzz-ios-sandbox,buzz-ios-production", + ] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into()); + env.insert("BUZZ_PUSH_ENABLED_PROFILES".into(), profiles.into()); + assert!( + Config::from_map(&env).is_err(), + "accepted profiles {profiles}" + ); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_accepts_explicit_true_for_loopback_sandbox() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-sandbox".into(), + ); + assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn second_profile_is_rejected_before_sandbox_only_is_accepted() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-sandbox,buzz-ios-production".into(), + ); + assert!(Config::from_map(&env).is_err()); + + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-sandbox".into(), + ); + assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); + } + #[test] fn malformed_or_empty_keyrings_fail_startup() { for (variable, value) in [ diff --git a/crates/buzz-push-gateway/src/dev_app_attest.rs b/crates/buzz-push-gateway/src/dev_app_attest.rs new file mode 100644 index 0000000000..038d8b524d --- /dev/null +++ b/crates/buzz-push-gateway/src/dev_app_attest.rs @@ -0,0 +1,168 @@ +//! Explicit development-only App Attest sentinel verification. +//! +//! This module is absent unless the non-default `dev-app-attest-bypass` Cargo +//! feature is enabled. Runtime configuration adds a second, independent gate. + +use crate::app_attest::{AppAttestError, VerifiedAssertion, VerifiedAttestation}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use sha2::{Digest, Sha256}; + +const ATTESTATION_PREFIX: &[u8] = b"buzz-dev-app-attest-v1:"; +const ASSERTION_SENTINEL: &[u8] = b"buzz-dev-app-assertion-v1"; +const PUBLIC_KEY_PREFIX: &[u8] = b"buzz-dev-app-attest-public-key-v1:"; +const NONCE_BYTES: usize = 32; + +pub fn assertion_sentinel() -> String { + STANDARD.encode(ASSERTION_SENTINEL) +} + +pub fn verify_attestation( + attestation_b64: &str, + key_id_b64: &str, + client_data: &[u8], +) -> Result { + let attestation = STANDARD + .decode(attestation_b64) + .map_err(|_| AppAttestError::Invalid)?; + let supplied_key_id = STANDARD + .decode(key_id_b64) + .map_err(|_| AppAttestError::Invalid)?; + let expected_key_id = Sha256::digest(&attestation); + if !attestation.starts_with(ATTESTATION_PREFIX) + || attestation.len() != ATTESTATION_PREFIX.len() + NONCE_BYTES + || supplied_key_id.as_slice() != expected_key_id.as_slice() + || client_data.is_empty() + { + return Err(AppAttestError::Invalid); + } + let mut public_key = PUBLIC_KEY_PREFIX.to_vec(); + public_key.extend_from_slice(&expected_key_id); + Ok(VerifiedAttestation { + key_id: expected_key_id.to_vec(), + public_key, + }) +} + +pub fn verify_assertion( + assertion_b64: &str, + client_data: &[u8], + public_key: &[u8], + previous_counter: u32, + challenge: &str, + stored_challenge: &str, +) -> Result { + let assertion = STANDARD + .decode(assertion_b64) + .map_err(|_| AppAttestError::Invalid)?; + if assertion != ASSERTION_SENTINEL + || client_data.is_empty() + || !public_key.starts_with(PUBLIC_KEY_PREFIX) + || public_key.len() != PUBLIC_KEY_PREFIX.len() + 32 + || challenge.is_empty() + || challenge != stored_challenge + { + return Err(AppAttestError::Invalid); + } + Ok(VerifiedAssertion { + counter: previous_counter + .checked_add(1) + .ok_or(AppAttestError::Invalid)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attestation(nonce: u8) -> (String, String) { + let mut sentinel = ATTESTATION_PREFIX.to_vec(); + sentinel.extend_from_slice(&[nonce; NONCE_BYTES]); + let key_id = Sha256::digest(&sentinel); + (STANDARD.encode(sentinel), STANDARD.encode(key_id)) + } + + #[test] + fn versioned_attestation_derives_a_unique_bound_key_id() { + let (attestation_a, key_id_a) = attestation(1); + let verified_a = verify_attestation( + &attestation_a, + &key_id_a, + b"canonical enrollment transcript", + ) + .unwrap(); + assert_eq!(verified_a.key_id.len(), 32); + assert!(verified_a.public_key.starts_with(PUBLIC_KEY_PREFIX)); + + let (attestation_b, key_id_b) = attestation(2); + let verified_b = verify_attestation( + &attestation_b, + &key_id_b, + b"canonical enrollment transcript", + ) + .unwrap(); + assert_ne!(verified_a.key_id, verified_b.key_id); + + for (attestation, key, transcript) in [ + ("bad".to_owned(), key_id_a.clone(), b"transcript".as_slice()), + (attestation_a.clone(), key_id_b, b"transcript"), + (attestation_a, key_id_a, b""), + ] { + assert!(verify_attestation(&attestation, &key, transcript).is_err()); + } + } + + #[test] + fn bad_attestation_sentinel_is_rejected_before_good_sentinel_is_accepted() { + let (good_attestation, key_id) = attestation(1); + let bad_attestation = STANDARD.encode(b"buzz-dev-app-attest-v1:bad"); + + assert!(verify_attestation(&bad_attestation, &key_id, b"transcript").is_err()); + assert!(verify_attestation(&good_attestation, &key_id, b"transcript").is_ok()); + } + + #[test] + fn exact_assertion_sentinel_and_stored_dev_marker_advance_counter() { + let (attestation, key_id) = attestation(1); + let public_key = verify_attestation(&attestation, &key_id, b"transcript") + .unwrap() + .public_key; + let verified = verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + &public_key, + 7, + "challenge", + "challenge", + ) + .unwrap(); + assert_eq!(verified.counter, 8); + + assert!(verify_assertion( + "bad", + b"canonical assertion transcript", + &public_key, + 7, + "challenge", + "challenge", + ) + .is_err()); + assert!(verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + b"not-the-development-marker", + 7, + "challenge", + "challenge", + ) + .is_err()); + assert!(verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + &public_key, + u32::MAX, + "challenge", + "challenge", + ) + .is_err()); + } +} diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 0564972c07..a9b5fe8037 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -1,7 +1,7 @@ //! Stateful installation, delegation, delivery, and health APIs. use crate::{ apns::{DeliveryAttempt, DeliveryOutcome, PushTransport}, - app_attest::AppAttestVerifier, + app_attest_policy::AppAttestPolicy, authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, @@ -36,7 +36,7 @@ use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; #[derive(Clone)] pub struct AppState { pub grant_keyring: Arc, - pub app_attest: Arc, + pub app_attest: Arc, pub authority: Arc, pub token_keyring: Arc, pub transport: Arc, diff --git a/crates/buzz-push-gateway/src/lib.rs b/crates/buzz-push-gateway/src/lib.rs index 563d725db9..59fa2ca09b 100644 --- a/crates/buzz-push-gateway/src/lib.rs +++ b/crates/buzz-push-gateway/src/lib.rs @@ -1,8 +1,11 @@ //! Stateful, capability-gated APNs last hop for NIP-PL. pub mod apns; pub mod app_attest; +pub mod app_attest_policy; pub mod authority; pub mod config; +#[cfg(feature = "dev-app-attest-bypass")] +pub mod dev_app_attest; pub mod grant; pub mod http; pub mod metrics; diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index 55e1853d3b..0fc617c407 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -1,6 +1,7 @@ use buzz_push_gateway::{ apns::ApnsTransport, app_attest::AppAttestVerifier, + app_attest_policy::AppAttestPolicy, authority::AuthorityStore, config::Config, grant::{GrantKey, GrantKeyring}, @@ -77,10 +78,19 @@ async fn main() -> Result<(), Box> { } } }); - let app_attest = Arc::new(AppAttestVerifier::new( - c.app_attest_app_id, - fs::read(&c.app_attest_root_cert_path)?, - )?); + let apple_app_attest = + AppAttestVerifier::new(c.app_attest_app_id, fs::read(&c.app_attest_root_cert_path)?)?; + #[cfg(feature = "dev-app-attest-bypass")] + let app_attest = if c.dev_app_attest_bypass { + tracing::warn!( + "DEVELOPMENT APP ATTEST BYPASS ACTIVE; Apple attestation and assertion verification are disabled" + ); + Arc::new(AppAttestPolicy::development()) + } else { + Arc::new(AppAttestPolicy::apple(apple_app_attest)) + }; + #[cfg(not(feature = "dev-app-attest-bypass"))] + let app_attest = Arc::new(AppAttestPolicy::apple(apple_app_attest)); let accepting = Arc::new(AtomicBool::new(true)); let (public, health) = router_with_metrics( AppState { diff --git a/crates/buzz-push-gateway/src/metrics.rs b/crates/buzz-push-gateway/src/metrics.rs index f40c126c79..d12ebb0ef5 100644 --- a/crates/buzz-push-gateway/src/metrics.rs +++ b/crates/buzz-push-gateway/src/metrics.rs @@ -53,6 +53,13 @@ fn outcome_label(outcome: DeliveryOutcome) -> &'static str { } } +/// Record entry into the concrete APNs HTTP send seam. This counter is kept +/// separate from terminal outcomes so a control scrape can distinguish +/// "transport never reached" from "APNs send returned an error". +pub fn record_apns_send_attempt() { + metrics::counter!("push_gateway_apns_send_attempts_total").increment(1); +} + /// Record the terminal APNs outcome and its send round-trip latency. pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { metrics::counter!("push_gateway_apns_deliveries_total", "outcome" => outcome_label(outcome)) @@ -159,6 +166,7 @@ mod tests { fn recorder_renders_sanitized_bounded_series() { let handle = install().expect("recorder installs exactly once per test process"); + record_apns_send_attempt(); record_apns_delivery(DeliveryOutcome::Accepted, 0.012); record_apns_delivery( DeliveryOutcome::InvalidEndpoint { @@ -180,6 +188,7 @@ mod tests { // All expected series are present. for needle in [ + "push_gateway_apns_send_attempts_total", "push_gateway_apns_deliveries_total", "push_gateway_apns_delivery_seconds", "push_gateway_apns_credential_refreshes_total", diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 4dd1142801..ea47a653d8 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -100,6 +100,9 @@ run_unit_tests() { run_test_step "buzz-push-gateway tests" \ cargo test -p buzz-push-gateway -- --nocapture + + run_test_step "buzz-push-gateway dev App Attest bypass tests" \ + cargo test -p buzz-push-gateway --features dev-app-attest-bypass -- --nocapture } # ---- DB / integration tests (infra required) --------------------------------