fix(push): authenticate APNs with certificate

Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-29 14:41:06 -07:00
parent eb02109d07
commit a8eabb623d
12 changed files with 213 additions and 326 deletions
+5
View File
@@ -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"
Generated
-164
View File
@@ -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"
-1
View File
@@ -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 }
+155 -129
View File
@@ -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<i64>,
},
/// 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<i64>) -> 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<Option<CachedJwt>>,
}
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<Self, ApnsError> {
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, ApnsError> {
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<Self, ApnsError> {
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<String, ApnsError> {
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<reqwest::Response, reqwest::Error> {
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<Arc<Mutex<Vec<Vec<u8>>>>>,
// 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<u8>,
}
async fn capture_request(
State(requests): State<Arc<Mutex<Vec<CapturedRequest>>>>,
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 {
+19 -10
View File
@@ -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<KeyConfig>,
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();
+2 -4
View File
@@ -35,10 +35,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
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(
@@ -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-----
@@ -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 }}
@@ -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: |
@@ -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)
+2 -2
View File
@@ -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:
+5 -7
View File
@@ -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/<release>-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/<release>-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled.
## Gateway chart release