apns.rs (14885B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2026 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use std::time::Duration; 18 19 use aws_lc_rs::{ 20 rand::SystemRandom, 21 signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}, 22 }; 23 use base64::{display::Base64Display, engine::general_purpose::URL_SAFE_NO_PAD}; 24 use compact_str::CompactString; 25 use http::{StatusCode, header::CONTENT_TYPE}; 26 use http_body_util::{BodyExt, Full}; 27 use hyper::{Method, body::Bytes, header::AUTHORIZATION}; 28 use hyper_rustls::ConfigBuilderExt; 29 use hyper_util::rt::{TokioExecutor, TokioTimer}; 30 use jiff::{SignedDuration, Timestamp}; 31 use rustls_pki_types::{PrivateKeyDer, pem::PemObject}; 32 use serde::Deserialize; 33 use taler_common::error::FmtSource; 34 use taler_macros::EnumMeta; 35 36 use crate::config::{ApnsConfig, ApnsServer}; 37 38 /// Raw JSON body returned by APNs when a push is rejected. 39 #[derive(Debug, Deserialize)] 40 pub struct ApnsErrorBody { 41 pub reason: CompactString, 42 /// Milliseconds since epoch. Only present when status is 410 (Unregistered/ExpiredToken). 43 pub timestamp: Option<u64>, 44 } 45 46 #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] 47 #[enum_meta(Description, Str)] 48 pub enum Reason { 49 /// The collapse identifier exceeds the maximum allowed size 50 BadCollapseId, 51 /// The specified device token is invalid 52 BadDeviceToken, 53 /// The apns-expiration value is invalid 54 BadExpirationDate, 55 /// The apns-id value is invalid 56 BadMessageId, 57 /// The apns-priority value is invalid 58 BadPriority, 59 /// The apns-topic value is invalid 60 BadTopic, 61 /// The device token doesn't match the specified topic 62 DeviceTokenNotForTopic, 63 /// One or more headers are repeated 64 DuplicateHeaders, 65 /// Idle timeout 66 IdleTimeout, 67 /// The apns-push-type value is invalid 68 InvalidPushType, 69 /// The device token isn't specified in the request :path 70 MissingDeviceToken, 71 /// The apns-topic header is missing and required 72 MissingTopic, 73 /// The message payload is empty 74 PayloadEmpty, 75 /// Pushing to this topic is not allowed 76 TopicDisallowed, 77 /// The certificate is invalid 78 BadCertificate, 79 /// The client certificate doesn't match the environment 80 BadCertificateEnvironment, 81 /// The provider token is stale 82 ExpiredProviderToken, 83 /// The specified action is not allowed 84 Forbidden, 85 /// The provider token is not valid 86 InvalidProviderToken, 87 /// No provider certificate or token was specified 88 MissingProviderToken, 89 /// The key ID in the provider token is unrelated to this connection 90 UnrelatedKeyIdInToken, 91 /// The key ID in the provider token doesn’t match the environment 92 BadEnvironmentKeyIdInToken, 93 /// The request contained an invalid :path value 94 BadPath, 95 /// The specified :method value isn't POST 96 MethodNotAllowed, 97 /// The device token has expired 98 ExpiredToken, 99 /// The device token is inactive for the specified topic 100 Unregistered, 101 /// The message payload is too large 102 PayloadTooLarge, 103 /// The authentication token is being updated too often 104 TooManyProviderTokenUpdates, 105 /// Too many requests to the same device token 106 TooManyRequests, 107 /// An internal server error 108 InternalServerError, 109 /// The service is unavailable 110 ServiceUnavailable, 111 /// The APNs server is shutting down 112 Shutdown, 113 } 114 115 impl Reason { 116 /// Returns the HTTP status code associated with the error 117 pub fn status_code(&self) -> u16 { 118 match self { 119 Self::BadCollapseId 120 | Self::BadDeviceToken 121 | Self::BadExpirationDate 122 | Self::BadMessageId 123 | Self::BadPriority 124 | Self::BadTopic 125 | Self::DeviceTokenNotForTopic 126 | Self::DuplicateHeaders 127 | Self::IdleTimeout 128 | Self::InvalidPushType 129 | Self::MissingDeviceToken 130 | Self::MissingTopic 131 | Self::PayloadEmpty 132 | Self::TopicDisallowed => 400, 133 134 Self::BadCertificate 135 | Self::BadCertificateEnvironment 136 | Self::ExpiredProviderToken 137 | Self::Forbidden 138 | Self::InvalidProviderToken 139 | Self::MissingProviderToken 140 | Self::UnrelatedKeyIdInToken 141 | Self::BadEnvironmentKeyIdInToken => 403, 142 Self::BadPath => 404, 143 Self::MethodNotAllowed => 405, 144 Self::ExpiredToken | Self::Unregistered => 410, 145 Self::PayloadTooLarge => 413, 146 Self::TooManyProviderTokenUpdates | Self::TooManyRequests => 429, 147 Self::InternalServerError => 500, 148 Self::ServiceUnavailable | Self::Shutdown => 503, 149 } 150 } 151 } 152 153 #[derive(Debug, thiserror::Error)] 154 pub enum ApnsError { 155 #[error("HTTP request: {0}")] 156 ReqTransport(FmtSource<hyper_util::client::legacy::Error>), 157 #[error("HTTP response: {0}")] 158 ResTransport(FmtSource<hyper::Error>), 159 #[error("response {0} JSON body: '{1}' - {2}")] 160 ResJson(StatusCode, Box<str>, serde_json::Error), 161 #[error("APNs unknown error {0}: {1}")] 162 ErrUnknown(StatusCode, CompactString), 163 #[error("APNs error {} {reason} - {}", reason.status_code(), reason.description())] 164 Err { 165 reason: Reason, 166 timestamp: Option<u64>, 167 }, 168 } 169 170 pub struct Client { 171 http: hyper_util::client::legacy::Client< 172 hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>, 173 Full<Bytes>, 174 >, 175 key_pair: EcdsaKeyPair, 176 server: ApnsServer, 177 key_id: CompactString, 178 team_id: CompactString, 179 bundle_id: CompactString, 180 token: Box<str>, 181 issued_at: Timestamp, 182 } 183 184 impl Client { 185 pub fn new(cfg: &ApnsConfig) -> anyhow::Result<Self> { 186 let ApnsConfig { 187 server, 188 key_path, 189 key_id, 190 team_id, 191 bundle_id, 192 } = cfg; 193 194 rustls::crypto::aws_lc_rs::default_provider() 195 .install_default() 196 .expect("failed to install the default TLS provider"); 197 198 let invalid_key = |message: String| taler_common::config::CfgErr::Invalid { 199 ty: Some("PKCS#8 private key file".into()), 200 section: "apns-relay-worker".into(), 201 option: "KEY_FILE".into(), 202 err: message, 203 }; 204 // Load the signature key pair 205 let private_key_der = PrivateKeyDer::from_pem_file(key_path) 206 .map_err(|e| invalid_key(format!("failed to read key file at '{key_path}': {e}")))?; 207 let PrivateKeyDer::Pkcs8(pkcs8_der) = private_key_der else { 208 return Err(invalid_key(format!( 209 "invalid key file at '{key_path}': not a valid PKCS#8 private key" 210 )) 211 .into()); 212 }; 213 let key_pair = EcdsaKeyPair::from_pkcs8( 214 &ECDSA_P256_SHA256_FIXED_SIGNING, 215 pkcs8_der.secret_pkcs8_der(), 216 ) 217 .map_err(|_| { 218 invalid_key(format!( 219 "invalid key file at '{key_path}': not a valid PKCS#8 private key" 220 )) 221 })?; 222 223 // Make a signature 224 let now = Timestamp::now(); 225 let token = Self::create_token(&key_pair, key_id, team_id, &now); 226 227 // Prepare the TLS client config 228 let tls = rustls::ClientConfig::builder() 229 .try_with_platform_verifier() 230 .expect("failed to setup platform TLS verifier") 231 .with_no_client_auth(); 232 233 // Prepare the HTTPS connector 234 let https = hyper_rustls::HttpsConnectorBuilder::new() 235 .with_tls_config(tls) 236 .https_only() 237 .enable_http2() 238 .build(); 239 240 // Send HTTP/2 PING every 1 hour as per: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns#Follow-best-practices-while-sending-push-notifications-with-APNs 241 // Reuse a connection as long as possible. In most cases, you can reuse a connection for many hours to days. If your connection is mostly idle, you may send a HTTP2 PING frame after an hour of inactivity. Reusing a connection often results in less bandwidth and CPU consumption. 242 let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()) 243 .timer(TokioTimer::new()) 244 .pool_idle_timeout(None) 245 .http2_only(true) 246 .http2_keep_alive_interval(Some(Duration::from_secs(60 * 60))) 247 .http2_keep_alive_while_idle(true) 248 .build(https); 249 250 Ok(Self { 251 http, 252 key_pair, 253 server: *server, 254 key_id: key_id.clone(), 255 team_id: team_id.clone(), 256 bundle_id: bundle_id.clone(), 257 token, 258 issued_at: now, 259 }) 260 } 261 262 pub async fn send(&mut self, device_token: &str) -> Result<(), ApnsError> { 263 let now = Timestamp::now(); 264 // Token expire after an hour 265 if now.duration_since(self.issued_at) > SignedDuration::from_mins(55) { 266 self.refresh_token(); 267 } 268 269 let result = self.send_once(device_token).await; 270 if matches!( 271 &result, 272 Err(ApnsError::Err { 273 reason: Reason::ExpiredProviderToken, 274 .. 275 }) 276 ) { 277 self.refresh_token(); 278 return self.send_once(device_token).await; 279 } 280 result 281 } 282 283 fn refresh_token(&mut self) { 284 let now = Timestamp::now(); 285 self.token = Self::create_token(&self.key_pair, &self.key_id, &self.team_id, &now); 286 self.issued_at = now; 287 } 288 289 async fn send_once(&self, device_token: &str) -> Result<(), ApnsError> { 290 let req = hyper::Request::builder() 291 .method(Method::POST) 292 .uri(format!( 293 "https://{}/3/device/{device_token}", 294 self.server.host() 295 )) 296 .header(CONTENT_TYPE, "application/json") 297 .header("apns-push-type", "background") 298 .header("apns-priority", "5") 299 .header("apns-collapse-id", "wakeup") 300 .header("apns-topic", self.bundle_id.as_str()) 301 .header(AUTHORIZATION, self.token.as_ref()) 302 .body(Full::new(Bytes::from_static( 303 r#"{"aps":{"content-available":1}}"#.as_bytes(), 304 ))) 305 .unwrap(); 306 307 let (parts, body) = self 308 .http 309 .request(req) 310 .await 311 .map_err(|e| ApnsError::ReqTransport(e.into()))? 312 .into_parts(); 313 let status = parts.status; 314 if status == StatusCode::OK { 315 return Ok(()); 316 } 317 318 let body = body 319 .collect() 320 .await 321 .map(|it| it.to_bytes()) 322 .map_err(|e| ApnsError::ResTransport(e.into()))?; 323 let body: ApnsErrorBody = serde_json::from_slice(&body).map_err(|e| { 324 ApnsError::ResJson( 325 status, 326 String::from_utf8_lossy(&body).to_string().into_boxed_str(), 327 e, 328 ) 329 })?; 330 let reason = match (status.as_u16(), body.reason.as_str()) { 331 (400, "BadCollapseId") => Reason::BadCollapseId, 332 (400, "BadDeviceToken") => Reason::BadDeviceToken, 333 (400, "BadExpirationDate") => Reason::BadExpirationDate, 334 (400, "BadMessageId") => Reason::BadMessageId, 335 (400, "BadPriority") => Reason::BadPriority, 336 (400, "BadTopic") => Reason::BadTopic, 337 (400, "DeviceTokenNotForTopic") => Reason::DeviceTokenNotForTopic, 338 (400, "DuplicateHeaders") => Reason::DuplicateHeaders, 339 (400, "IdleTimeout") => Reason::IdleTimeout, 340 (400, "InvalidPushType") => Reason::InvalidPushType, 341 (400, "MissingDeviceToken") => Reason::MissingDeviceToken, 342 (400, "MissingTopic") => Reason::MissingTopic, 343 (400, "PayloadEmpty") => Reason::PayloadEmpty, 344 (400, "TopicDisallowed") => Reason::TopicDisallowed, 345 (403, "BadCertificate") => Reason::BadCertificate, 346 (403, "BadCertificateEnvironment") => Reason::BadCertificateEnvironment, 347 (403, "ExpiredProviderToken") => Reason::ExpiredProviderToken, 348 (403, "Forbidden") => Reason::Forbidden, 349 (403, "InvalidProviderToken") => Reason::InvalidProviderToken, 350 (403, "MissingProviderToken") => Reason::MissingProviderToken, 351 (403, "UnrelatedKeyIdInToken") => Reason::UnrelatedKeyIdInToken, 352 (403, "BadEnvironmentKeyIdInToken") => Reason::BadEnvironmentKeyIdInToken, 353 (404, "BadPath") => Reason::BadPath, 354 (405, "MethodNotAllowed") => Reason::MethodNotAllowed, 355 (410, "ExpiredToken") => Reason::ExpiredToken, 356 (410, "Unregistered") => Reason::Unregistered, 357 (413, "PayloadTooLarge") => Reason::PayloadTooLarge, 358 (429, "TooManyProviderTokenUpdates") => Reason::TooManyProviderTokenUpdates, 359 (429, "TooManyRequests") => Reason::TooManyRequests, 360 (500, "InternalServerError") => Reason::InternalServerError, 361 (503, "ServiceUnavailable") => Reason::ServiceUnavailable, 362 (503, "Shutdown") => Reason::Shutdown, 363 _ => return Err(ApnsError::ErrUnknown(status, body.reason)), 364 }; 365 Err(ApnsError::Err { 366 reason, 367 timestamp: body.timestamp, 368 }) 369 } 370 371 fn create_token( 372 key_pair: &EcdsaKeyPair, 373 key_id: &str, 374 team_id: &str, 375 issued_at: &Timestamp, 376 ) -> Box<str> { 377 let headers = format!(r#"{{"alg":"ES256","kid":"{key_id}"}}"#); 378 let payload = format!(r#"{{"iss":"{team_id}","iat":{}}}"#, issued_at.as_second()); 379 let token = format!( 380 "{}.{}", 381 Base64Display::new(headers.as_bytes(), &URL_SAFE_NO_PAD), 382 Base64Display::new(payload.as_bytes(), &URL_SAFE_NO_PAD) 383 ); 384 let signature = key_pair 385 .sign(&SystemRandom::new(), token.as_bytes()) 386 .unwrap(); 387 388 format!( 389 "Bearer {}.{}", 390 token, 391 Base64Display::new(signature.as_ref(), &URL_SAFE_NO_PAD) 392 ) 393 .into_boxed_str() 394 } 395 }