diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml index a321714bc..2687fa87f 100644 --- a/.intersect/sadscan.yaml +++ b/.intersect/sadscan.yaml @@ -2,3 +2,8 @@ exclude_rules_for_files: sq.pii.cc.visa: - Cargo.lock + # Self-signed test fixture generated solely to exercise reqwest identity parsing. + kingfisher.privkey.2: + - "*apns-test-identity.pem" + np.pem.1: + - "*apns-test-identity.pem" diff --git a/Cargo.lock b/Cargo.lock index 3b60dc457..e85d94975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1101,7 +1101,6 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr", - "p256", "proptest", "rand 0.10.1", "reqwest 0.13.4", @@ -1733,12 +1732,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1891,22 +1884,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -1924,9 +1901,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", ] [[package]] @@ -2021,7 +1996,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -2473,21 +2447,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der", - "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature", - "spki", - "zeroize", -] - [[package]] name = "ed25519" version = "3.0.0" @@ -2524,27 +2483,6 @@ dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" -dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff", - "group", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core 0.10.1", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "embedded-io" version = "0.4.0" @@ -2714,16 +2652,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3148,17 +3076,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" version = "0.4.14" @@ -3496,9 +3413,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] @@ -6070,19 +5985,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.0", -] - [[package]] name = "palette" version = "0.7.6" @@ -6538,33 +6440,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint", - "crypto-common 0.2.2", - "ff", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -7339,16 +7214,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint", - "hmac 0.13.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -7708,20 +7573,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" version = "0.29.1" @@ -8091,10 +7942,6 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] [[package]] name = "simd-adler32" @@ -10466,17 +10313,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "wnaf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" -dependencies = [ - "ff", - "group", - "hybrid-array", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index 9c092f7d1..06376c02d 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -29,7 +29,6 @@ getrandom = "0.4" metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } nostr = { workspace = true } -p256 = { version = "0.14", features = ["ecdsa", "pem", "pkcs8"] } rand = { workspace = true } reqwest = { workspace = true, features = ["http2"] } serde = { workspace = true } diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs index e6d96b286..ffd56c479 100644 --- a/crates/buzz-push-gateway/src/apns.rs +++ b/crates/buzz-push-gateway/src/apns.rs @@ -1,17 +1,9 @@ //! APNs envelope construction, endpoint encryption, and response classification. -use std::{sync::Mutex, time::Duration}; +use std::time::Duration; use async_trait::async_trait; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use p256::{ - ecdsa::{signature::Signer, Signature, SigningKey}, - pkcs8::DecodePrivateKey, -}; -use reqwest::{ - header::{AUTHORIZATION, CONTENT_TYPE}, - StatusCode, -}; +use reqwest::{header::CONTENT_TYPE, StatusCode}; use serde::Deserialize; use thiserror::Error; @@ -32,7 +24,7 @@ pub enum DeliveryOutcome { /// Retry-After delay in seconds, clamped by the transport. retry_after_seconds: Option, }, - /// Refresh the cached provider JWT, then retry once within normal attempt bounds. + /// Refresh a transport credential, then retry once within normal attempt bounds. RefreshCredential, /// Provider credential/profile configuration is unhealthy; do not invalidate endpoints. ConfigurationFault, @@ -52,7 +44,6 @@ pub fn classify(code: u16, reason: Option<&str>, timestamp: Option) -> Deli unregistered_at: None, } } - (403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential, (403, _) | (429, Some("TooManyProviderTokenUpdates")) => { DeliveryOutcome::ConfigurationFault } @@ -91,107 +82,95 @@ pub trait PushTransport: Send + Sync { profile: AppProfile, endpoint: &str, ) -> DeliveryOutcome; - /// Discard a cached credential after APNs reports expiry. - fn refresh_credential(&self) {} + /// Refresh a transport credential after a refreshable provider outcome. + fn refresh_credential(&self); } -struct CachedJwt { - token: String, - issued_at: i64, -} - -/// Direct HTTP/2 APNs transport using a cached ES256 provider token. +/// Direct HTTP/2 APNs transport using a client certificate identity. pub struct ApnsTransport { client: reqwest::Client, - signing_key: SigningKey, - key_id: String, - team_id: String, topic: String, production_base_url: String, sandbox_base_url: String, - cached_jwt: Mutex>, } impl ApnsTransport { - /// Build a reusable APNs client from an Apple `.p8` private key. - pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result { - let client = reqwest::Client::builder() - // APNs requires HTTP/2. This no-op method reference is intentionally - // feature-gated so removing reqwest's `http2` feature fails the build. - .http2_keep_alive_while_idle(false) - .timeout(Duration::from_secs(15)) - .build() - .map_err(|_| ApnsError::Client)?; - Self::token_with_client( - p8, - key_id, - team_id, + /// Build a reusable APNs client from a combined PEM private key and certificate. + pub fn certificate(identity_pem: &[u8], topic: String) -> Result { + Self::certificate_with_base_urls( + identity_pem, topic, - client, "https://api.push.apple.com".to_owned(), "https://api.sandbox.push.apple.com".to_owned(), ) } - fn token_with_client( - p8: &[u8], - key_id: &str, - team_id: &str, + fn certificate_with_base_urls( + identity_pem: &[u8], topic: String, - client: reqwest::Client, production_base_url: String, sandbox_base_url: String, ) -> Result { - let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?; - let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?; + let identity = + reqwest::Identity::from_pem(identity_pem).map_err(|_| ApnsError::Credential)?; + let client = reqwest::Client::builder() + // APNs requires HTTP/2. This no-op method reference is intentionally + // feature-gated so removing reqwest's `http2` feature fails the build. + .http2_keep_alive_while_idle(false) + .identity(identity) + .timeout(Duration::from_secs(15)) + // Identity validation completes while the TLS client is built, so a + // malformed or mismatched certificate/key pair is a credential error. + .build() + .map_err(|_| ApnsError::Credential)?; Ok(Self { client, - signing_key, - key_id: key_id.to_owned(), - team_id: team_id.to_owned(), topic, production_base_url, sandbox_base_url, - cached_jwt: Mutex::new(None), }) } - fn jwt(&self, now: i64) -> Result { - let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?; - if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) { - return Ok(jwt.token.clone()); - } - let header = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id})) - .map_err(|_| ApnsError::Credential)?, - ); - let claims = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now})) - .map_err(|_| ApnsError::Credential)?, - ); - let signing_input = format!("{header}.{claims}"); - let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); - let token = format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature.to_bytes()) - ); - *cached = Some(CachedJwt { - token: token.clone(), - issued_at: now, - }); - Ok(token) + fn request( + &self, + attempt: DeliveryAttempt, + profile: AppProfile, + endpoint: &str, + ) -> reqwest::RequestBuilder { + let base_url = match profile { + AppProfile::BuzzIosProduction => &self.production_base_url, + AppProfile::BuzzIosSandbox => &self.sandbox_base_url, + }; + self.client + .post(format!("{base_url}/3/device/{endpoint}")) + .header(CONTENT_TYPE, "application/json") + .header("apns-id", attempt.request_id.to_string()) + .header("apns-topic", &self.topic) + .header("apns-push-type", "alert") + .header("apns-priority", "10") + .header("apns-expiration", attempt.expires_at.to_string()) + // This is the only APNs application body in the program. It is a + // byte constant, not a serialization of the relay request, grant, + // endpoint, headers, route, provider response, or any generic JSON map. + .body(APNS_RECONNECT_PAYLOAD) + } + + async fn send_response( + &self, + attempt: DeliveryAttempt, + profile: AppProfile, + endpoint: &str, + ) -> Result { + self.request(attempt, profile, endpoint).send().await } } /// APNs transport setup failure. It intentionally carries no credential material. #[derive(Debug, Error)] pub enum ApnsError { - /// Invalid provider key material. + /// Invalid client certificate identity material. #[error("invalid APNs credential")] Credential, - /// HTTP client setup failed. - #[error("failed to construct APNs client")] - Client, } #[derive(Deserialize)] @@ -208,32 +187,7 @@ impl PushTransport for ApnsTransport { profile: AppProfile, endpoint: &str, ) -> DeliveryOutcome { - // This is the only APNs application body in the program. It is a - // byte constant, not a serialization of the relay request, grant, - // endpoint, headers, route, provider response, or any generic JSON map. - let body = APNS_RECONNECT_PAYLOAD; - let now = chrono::Utc::now().timestamp(); - let token = match self.jwt(now) { - Ok(token) => token, - Err(_) => return DeliveryOutcome::ConfigurationFault, - }; - let base_url = match profile { - AppProfile::BuzzIosProduction => &self.production_base_url, - AppProfile::BuzzIosSandbox => &self.sandbox_base_url, - }; - let response = self - .client - .post(format!("{base_url}/3/device/{endpoint}")) - .header(AUTHORIZATION, format!("bearer {token}")) - .header(CONTENT_TYPE, "application/json") - .header("apns-id", attempt.request_id.to_string()) - .header("apns-topic", &self.topic) - .header("apns-push-type", "alert") - .header("apns-priority", "10") - .header("apns-expiration", attempt.expires_at.to_string()) - .body(body) - .send() - .await; + let response = self.send_response(attempt, profile, endpoint).await; let response = match response { Ok(response) => response, Err(_) => { @@ -267,44 +221,56 @@ impl PushTransport for ApnsTransport { } fn refresh_credential(&self) { - if let Ok(mut cached) = self.cached_jwt.lock() { - *cached = None; - } + panic!("certificate-authenticated APNs transport has no refreshable credential") } } #[cfg(test)] mod tests { use super::*; - use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router}; - use p256::pkcs8::{EncodePrivateKey, LineEnding}; - use std::sync::Arc; + use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + routing::post, + Router, + }; + use std::sync::{Arc, Mutex}; - async fn capture_body( - State(bodies): State>>>>, + // Self-signed test-only identity. It is not an Apple credential. + const TEST_IDENTITY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-identity.pem"); + + #[derive(Default)] + struct CapturedRequest { + headers: HeaderMap, + body: Vec, + } + + async fn capture_request( + State(requests): State>>>, + headers: HeaderMap, body: Bytes, ) -> StatusCode { - bodies.lock().unwrap().push(body.to_vec()); + requests.lock().unwrap().push(CapturedRequest { + headers, + body: body.to_vec(), + }); StatusCode::OK } + #[tokio::test] - async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() { - let bodies = Arc::new(Mutex::new(Vec::new())); + async fn certificate_transport_sends_no_bearer_and_exact_body_for_every_attempt() { + let requests = Arc::new(Mutex::new(Vec::new())); let app = Router::new() - .route("/3/device/{endpoint}", post(capture_body)) - .with_state(bodies.clone()); + .route("/3/device/{endpoint}", post(capture_request)) + .with_state(requests.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let signing_key = SigningKey::from_slice(&[7; 32]).unwrap(); - let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap(); - let transport = ApnsTransport::token_with_client( - pem.as_bytes(), - "kid", - "team", + let transport = ApnsTransport::certificate_with_base_urls( + TEST_IDENTITY_PEM, "app.topic".to_owned(), - reqwest::Client::new(), base_url.clone(), base_url, ) @@ -337,11 +303,69 @@ mod tests { DeliveryOutcome::Accepted ); } - let captured = bodies.lock().unwrap(); + let captured = requests.lock().unwrap(); assert_eq!(captured.len(), 2); assert!(captured .iter() - .all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD)); + .all(|request| request.body.as_slice() == APNS_RECONNECT_PAYLOAD)); + assert!(captured + .iter() + .all(|request| !request.headers.contains_key(reqwest::header::AUTHORIZATION))); + assert!(captured.iter().all(|request| request + .headers + .get("apns-topic") + .is_some_and(|topic| topic == "app.topic"))); + } + + #[tokio::test] + #[ignore = "requires the exported dogfood Apple Push Services PEM"] + async fn live_sandbox_probe_reports_literal_status_and_body() { + let cert_path = std::env::var("BUZZ_PUSH_LIVE_APNS_CERT_PATH") + .expect("set BUZZ_PUSH_LIVE_APNS_CERT_PATH to the dogfood identity PEM"); + let topic = std::env::var("BUZZ_PUSH_LIVE_APNS_TOPIC") + .expect("set BUZZ_PUSH_LIVE_APNS_TOPIC to the dogfood bundle id"); + let identity = std::fs::read(cert_path).unwrap(); + let transport = ApnsTransport::certificate_with_base_urls( + &identity, + topic, + "https://api.push.apple.com".to_owned(), + "https://api.sandbox.push.apple.com".to_owned(), + ) + .unwrap(); + let response = transport + .send_response( + DeliveryAttempt { + request_id: uuid::Uuid::nil(), + expires_at: chrono::Utc::now().timestamp() + 60, + }, + AppProfile::BuzzIosSandbox, + &"00".repeat(32), + ) + .await + .unwrap(); + let status = response.status(); + let body = response.text().await.unwrap(); + eprintln!("live APNs response: status={status}, body={body}"); + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body, r#"{"reason":"BadDeviceToken"}"#); + } + + #[test] + fn malformed_certificate_identity_fails_as_a_credential_error() { + assert!(matches!( + ApnsTransport::certificate(b"not a PEM identity", "app.topic".to_owned()), + Err(ApnsError::Credential) + )); + } + + #[test] + #[should_panic( + expected = "certificate-authenticated APNs transport has no refreshable credential" + )] + fn certificate_transport_fails_loudly_if_refresh_is_requested() { + let transport = + ApnsTransport::certificate(TEST_IDENTITY_PEM, "app.topic".to_owned()).unwrap(); + transport.refresh_credential(); } #[test] @@ -352,10 +376,12 @@ mod tests { unregistered_at: Some(7) } ); - assert_eq!( - classify(403, Some("InvalidProviderToken"), None), - DeliveryOutcome::ConfigurationFault - ); + for reason in ["InvalidProviderToken", "ExpiredProviderToken"] { + assert_eq!( + classify(403, Some(reason), None), + DeliveryOutcome::ConfigurationFault + ); + } assert_eq!( classify(429, Some("TooManyRequests"), None), DeliveryOutcome::Retry { diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index c6194edbc..b0645f542 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -30,9 +30,7 @@ pub struct Config { /// Independent token-custody keyring. These keys MUST NOT be reused for /// externally presented delivery capabilities. pub token_keys: Vec, - pub apns_key_path: PathBuf, - pub apns_key_id: String, - pub apns_team_id: String, + pub apns_cert_path: PathBuf, pub apns_topic: String, } #[derive(Debug, Error)] @@ -176,9 +174,7 @@ impl Config { app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), grant_keys, token_keys, - apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(), - apns_key_id: req(e, "BUZZ_PUSH_APNS_KEY_ID")?.to_owned(), - apns_team_id: req(e, "BUZZ_PUSH_APNS_TEAM_ID")?.to_owned(), + apns_cert_path: req(e, "BUZZ_PUSH_APNS_CERT_PATH")?.into(), apns_topic: req(e, "BUZZ_PUSH_APNS_TOPIC")?.to_owned(), }) } @@ -220,20 +216,33 @@ mod tests { ), ( "DATABASE_URL".into(), - "postgres://buzz:test@localhost/buzz".into(), + "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 ), ("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), "/apple-root.pem".into(), ), - ("BUZZ_PUSH_APNS_KEY_PATH".into(), "/key.p8".into()), - ("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()), - ("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()), + ("BUZZ_PUSH_APNS_CERT_PATH".into(), "/identity.pem".into()), ("BUZZ_PUSH_APNS_TOPIC".into(), "app".into()), ]) } + #[test] + fn certificate_path_and_topic_are_both_required() { + let config = Config::from_map(&base()).unwrap(); + assert_eq!(config.apns_cert_path, PathBuf::from("/identity.pem")); + assert_eq!(config.apns_topic, "app"); + + for variable in ["BUZZ_PUSH_APNS_CERT_PATH", "BUZZ_PUSH_APNS_TOPIC"] { + let mut env = base(); + env.remove(variable); + assert!( + matches!(Config::from_map(&env), Err(ConfigError::Missing(key)) if key == variable) + ); + } + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index 55e1853d3..431c61d9b 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -35,10 +35,8 @@ async fn main() -> Result<(), Box> { } let c = Config::from_env()?; let metrics_handle = buzz_push_gateway::metrics::install()?; - let transport = Arc::new(ApnsTransport::token( - &fs::read(&c.apns_key_path)?, - &c.apns_key_id, - &c.apns_team_id, + let transport = Arc::new(ApnsTransport::certificate( + &fs::read(&c.apns_cert_path)?, c.apns_topic, )?); let grant_keyring = GrantKeyring::new( diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem new file mode 100644 index 000000000..f17481171 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0 +AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu +RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 38f69dee6..c9cf3f5c8 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -34,14 +34,14 @@ spec: - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_APP_ID, value: {{ .Values.appAttestAppId | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } - - { name: BUZZ_PUSH_APNS_KEY_PATH, value: /run/buzz/apns/provider.p8 } - {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_KEY_ID" "BUZZ_PUSH_APNS_TEAM_ID" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} + - { name: BUZZ_PUSH_APNS_CERT_PATH, value: /run/buzz/apns/identity.pem } + {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} - name: {{ $name }} valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } {{- end }} volumeMounts: - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } - - { name: apns-key, mountPath: /run/buzz/apns, readOnly: true } + - { name: apns-cert, mountPath: /run/buzz/apns, readOnly: true } livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 } startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 } @@ -49,8 +49,8 @@ spec: volumes: - name: app-attest-root secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] } - - name: apns-key - secret: { secretName: {{ .Values.apnsKey.secretName }}, items: [{ key: {{ .Values.apnsKey.secretKey }}, path: provider.p8 }] } + - name: apns-cert + secret: { secretName: {{ .Values.apnsCert.secretName }}, items: [{ key: {{ .Values.apnsCert.secretKey }}, path: identity.pem }] } {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml index 20b989428..7a718bda7 100644 --- a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml +++ b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml @@ -21,9 +21,9 @@ spec: annotations: summary: Push gateway APNs configuration faults description: >- - APNs is returning configuration faults (bad/expired provider token - or topic). Deliveries are failing without invalidating endpoints. - See runbook: check the APNs .p8 key, key id, team id, and topic. + APNs is returning certificate or topic configuration faults. + Deliveries are failing without invalidating endpoints. See the + runbook and check the APNs certificate identity and topic. # Authority store unavailable at admission = durable dependency is down. - alert: PushGatewayAdmissionUnavailable expr: | diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 137f8d0ad..e9e1951a9 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -42,7 +42,7 @@ assert j['metadata']['annotations']=={ 'helm.sh/hook-delete-policy':'before-hook-creation,hook-succeeded', } env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']} -required={'DATABASE_URL','BUZZ_PUSH_APNS_KEY_ID','BUZZ_PUSH_APNS_TEAM_ID','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'} +required={'DATABASE_URL','BUZZ_PUSH_APNS_CERT_PATH','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'} assert required <= env assert d['spec']['replicas'] >= 2 assert not any(x and x.get('kind')=='HTTPRoute' for x in xs) diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index ec46d9dbd..5c34f61ba 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -27,9 +27,9 @@ appAttestAppId: TEAMID.xyz.buzz appAttestRoot: secretName: buzz-push-gateway secretKey: app-attest-root.pem -apnsKey: +apnsCert: secretName: buzz-push-gateway - secretKey: apns-provider.p8 + secretKey: apns-identity.pem service: port: 8080 httpRoute: diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 63c63355a..64c10dd79 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -19,9 +19,7 @@ | `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated `buzz-ios-production` and/or `buzz-ios-sandbox`. | | `BUZZ_PUSH_APP_ATTEST_APP_ID` | Exact Apple App Attest application identifier (`TEAMID.bundle-id`). | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | -| `BUZZ_PUSH_APNS_KEY_PATH` | Read-only mounted Apple APNs `.p8` provider key. | -| `BUZZ_PUSH_APNS_KEY_ID` | APNs provider key id. | -| `BUZZ_PUSH_APNS_TEAM_ID` | Apple developer team id. | +| `BUZZ_PUSH_APNS_CERT_PATH` | Read-only mounted PEM containing the Apple Push Services certificate and unencrypted PKCS#8 private key. | | `BUZZ_PUSH_APNS_TOPIC` | Buzz iOS bundle id. | | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | @@ -30,7 +28,7 @@ Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SE ## Secret and key rotation rules -Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs key and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. +Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs certificate identity and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefore contain ciphertext plus authority metadata and must receive the same access controls and retention treatment as the service secrets. @@ -50,7 +48,7 @@ The gateway serves Prometheus metrics at `GET /metrics` on the **private health |---|---|---|---| | `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `refresh_credential` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | | `push_gateway_apns_delivery_seconds` | histogram | — | APNs send round-trip latency (seconds). | -| `push_gateway_apns_credential_refreshes_total` | counter | — | Provider JWT refreshed after APNs reported expiry. | +| `push_gateway_apns_credential_refreshes_total` | counter | — | Transport credential refreshes requested after a refreshable provider outcome. | | `push_gateway_admissions_total` | counter | `result` = `admitted` \| `rejected` \| `unavailable` | Outcome at the `authorize_delivery` replay/quota fence. | | `push_gateway_delivery_errors_total` | counter | `class` (static) | Selected delivery-handler exit classes only (see note). | | `push_gateway_reaper_failures_total` | counter | — | Retention reaper sweep failures. | @@ -64,7 +62,7 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe | Alert | Fires when | Severity | Action | |---|---|---|---| -| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | APNs provider token/topic is unhealthy. Check the `.p8` key, `BUZZ_PUSH_APNS_KEY_ID`, `..._TEAM_ID`, and `..._TOPIC`. No endpoints are being invalidated, but nothing is delivering. | +| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | APNs certificate/topic is unhealthy. Check `BUZZ_PUSH_APNS_CERT_PATH` and `BUZZ_PUSH_APNS_TOPIC`. No endpoints are being invalidated, but nothing is delivering. | | `PushGatewayAdmissionUnavailable` | any admission `unavailable` for 5m | critical | PostgreSQL authority store is unreachable. Check DB connectivity and the pod's `postgresEgressCidrs` NetworkPolicy. | | `PushGatewayReadinessAuthorityFailing` | readiness `authority` failures for 5m | warning | Replicas are being pulled from the Service on DB check failure. Fix DB health before capacity drops below the PodDisruptionBudget. | | `PushGatewayReaperFailing` | reaper failed ≥2 times within 30m (runs every 5m) | warning | Expired reservations aren't being swept, growing the bounded-until-expiry window. Check DB write availability. | @@ -103,7 +101,7 @@ Only after that command succeeds, set the exact digest as `image.digest`; the ch Network policy keeps APNs HTTPS and PostgreSQL egress in separate CIDR lists. APNs currently requires broad TCP/443 reachability; `networkPolicy.postgresEgressCidrs` must be narrowed to the production database network, and the DNS namespace/pod selectors must match the cluster DNS deployment. The sample private CIDR is not a claim about the production topology. -Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs credential rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. +Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs certificate rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. ## Gateway chart release