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