apns.rs (15009B)
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 anyhow::{anyhow, bail}; 20 use aws_lc_rs::{ 21 rand::SystemRandom, 22 signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}, 23 }; 24 use base64::{Engine as _, prelude::BASE64_STANDARD}; 25 use compact_str::CompactString; 26 use http::{StatusCode, header::CONTENT_TYPE}; 27 use http_body_util::{BodyExt, Full}; 28 use hyper::{Method, body::Bytes, header::AUTHORIZATION}; 29 use hyper_rustls::ConfigBuilderExt as _; 30 use hyper_util::rt::{TokioExecutor, TokioTimer}; 31 use jiff::{SignedDuration, Timestamp}; 32 use rustls_pki_types::{PrivateKeyDer, pem::PemObject}; 33 use serde::Deserialize; 34 use taler_common::error::FmtSource; 35 36 use crate::config::ApnsConfig; 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, strum_macros::AsRefStr, strum_macros::Display)] 47 pub enum Reason { 48 BadCollapseId, 49 BadDeviceToken, 50 BadExpirationDate, 51 BadMessageId, 52 BadPriority, 53 BadTopic, 54 DeviceTokenNotForTopic, 55 DuplicateHeaders, 56 IdleTimeout, 57 InvalidPushType, 58 MissingDeviceToken, 59 MissingTopic, 60 PayloadEmpty, 61 TopicDisallowed, 62 BadCertificate, 63 BadCertificateEnvironment, 64 ExpiredProviderToken, 65 Forbidden, 66 InvalidProviderToken, 67 MissingProviderToken, 68 UnrelatedKeyIdInToken, 69 BadEnvironmentKeyIdInToken, 70 BadPath, 71 MethodNotAllowed, 72 ExpiredToken, 73 Unregistered, 74 PayloadTooLarge, 75 TooManyProviderTokenUpdates, 76 TooManyRequests, 77 InternalServerError, 78 ServiceUnavailable, 79 Shutdown, 80 } 81 82 impl Reason { 83 /// Returns the documentation description associated with the error 84 pub fn description(&self) -> &'static str { 85 match self { 86 Self::BadCollapseId => "The collapse identifier exceeds the maximum allowed size", 87 Self::BadDeviceToken => "The specified device token is invalid", 88 Self::BadExpirationDate => "The apns-expiration value is invalid", 89 Self::BadMessageId => "The apns-id value is invalid", 90 Self::BadPriority => "The apns-priority value is invalid", 91 Self::BadTopic => "The apns-topic value is invalid", 92 Self::DeviceTokenNotForTopic => "The device token doesn't match the specified topic", 93 Self::DuplicateHeaders => "One or more headers are repeated", 94 Self::IdleTimeout => "Idle timeout", 95 Self::InvalidPushType => "The apns-push-type value is invalid", 96 Self::MissingDeviceToken => "The device token isn't specified in the request :path", 97 Self::MissingTopic => "The apns-topic header is missing and required", 98 Self::PayloadEmpty => "The message payload is empty", 99 Self::TopicDisallowed => "Pushing to this topic is not allowed", 100 Self::BadCertificate => "The certificate is invalid", 101 Self::BadCertificateEnvironment => { 102 "The client certificate doesn't match the environment" 103 } 104 Self::ExpiredProviderToken => "The provider token is stale", 105 Self::Forbidden => "The specified action is not allowed", 106 Self::InvalidProviderToken => "The provider token is not valid", 107 Self::MissingProviderToken => "No provider certificate or token was specified", 108 Self::UnrelatedKeyIdInToken => { 109 "The key ID in the provider token is unrelated to this connection" 110 } 111 Self::BadEnvironmentKeyIdInToken => { 112 "The key ID in the provider token doesn’t match the environment" 113 } 114 Self::BadPath => "The request contained an invalid :path value", 115 Self::MethodNotAllowed => "The specified :method value isn't POST", 116 Self::ExpiredToken => "The device token has expired", 117 Self::Unregistered => "The device token is inactive for the specified topic", 118 Self::PayloadTooLarge => "The message payload is too large", 119 Self::TooManyProviderTokenUpdates => { 120 "The authentication token is being updated too often" 121 } 122 Self::TooManyRequests => "Too many requests to the same device token", 123 Self::InternalServerError => "An internal server error occurred", 124 Self::ServiceUnavailable => "The service is unavailable", 125 Self::Shutdown => "The APNs server is shutting down", 126 } 127 } 128 129 /// Returns the HTTP status code associated with the error 130 pub fn status_code(&self) -> u16 { 131 match self { 132 Self::BadCollapseId 133 | Self::BadDeviceToken 134 | Self::BadExpirationDate 135 | Self::BadMessageId 136 | Self::BadPriority 137 | Self::BadTopic 138 | Self::DeviceTokenNotForTopic 139 | Self::DuplicateHeaders 140 | Self::IdleTimeout 141 | Self::InvalidPushType 142 | Self::MissingDeviceToken 143 | Self::MissingTopic 144 | Self::PayloadEmpty 145 | Self::TopicDisallowed => 400, 146 147 Self::BadCertificate 148 | Self::BadCertificateEnvironment 149 | Self::ExpiredProviderToken 150 | Self::Forbidden 151 | Self::InvalidProviderToken 152 | Self::MissingProviderToken 153 | Self::UnrelatedKeyIdInToken 154 | Self::BadEnvironmentKeyIdInToken => 403, 155 Self::BadPath => 404, 156 Self::MethodNotAllowed => 405, 157 Self::ExpiredToken | Self::Unregistered => 410, 158 Self::PayloadTooLarge => 413, 159 Self::TooManyProviderTokenUpdates | Self::TooManyRequests => 429, 160 Self::InternalServerError => 500, 161 Self::ServiceUnavailable | Self::Shutdown => 503, 162 } 163 } 164 } 165 166 #[derive(Debug, thiserror::Error)] 167 pub enum ApnsError { 168 #[error("HTTP request: {0}")] 169 ReqTransport(FmtSource<hyper_util::client::legacy::Error>), 170 #[error("HTTP response: {0}")] 171 ResTransport(FmtSource<hyper::Error>), 172 #[error("response {0} JSON body: '{1}' - {2}")] 173 ResJson(StatusCode, Box<str>, serde_json::Error), 174 #[error("APNs unknown error {0}: {1}")] 175 ErrUnknown(StatusCode, CompactString), 176 #[error("APNs error {} {reason} - {}", reason.status_code(), reason.description())] 177 Err { 178 reason: Reason, 179 timestamp: Option<u64>, 180 }, 181 } 182 183 pub struct Client { 184 http: hyper_util::client::legacy::Client< 185 hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>, 186 Full<Bytes>, 187 >, 188 key_pair: EcdsaKeyPair, 189 key_id: CompactString, 190 team_id: CompactString, 191 bundle_id: CompactString, 192 token: Box<str>, 193 issued_at: Timestamp, 194 } 195 196 impl Client { 197 pub fn new(cfg: &ApnsConfig) -> anyhow::Result<Self> { 198 let ApnsConfig { 199 key_path, 200 key_id, 201 team_id, 202 bundle_id, 203 } = cfg; 204 205 rustls::crypto::aws_lc_rs::default_provider() 206 .install_default() 207 .expect("failed to install the default TLS provider"); 208 209 // Load the signature key pair 210 let private_key_der = PrivateKeyDer::from_pem_file(key_path) 211 .map_err(|e| anyhow!("failed to read key file at '{key_path}': {e}"))?; 212 let PrivateKeyDer::Pkcs8(pkcs8_der) = private_key_der else { 213 bail!("invalid key file at '{key_path}': not a valid PKCS#8 private key"); 214 }; 215 let key_pair = EcdsaKeyPair::from_pkcs8( 216 &ECDSA_P256_SHA256_FIXED_SIGNING, 217 pkcs8_der.secret_pkcs8_der(), 218 ) 219 .map_err(|_| anyhow!("invalid key file at '{key_path}': not a valid PKCS#8 private key"))?; 220 221 // Make a signature 222 let now = Timestamp::now(); 223 let token = Self::create_token(&key_pair, key_id, team_id, &now)?; 224 225 // Prepare the TLS client config 226 let tls = rustls::ClientConfig::builder() 227 .with_native_roots()? 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_STANDARD.encode(headers), 361 BASE64_STANDARD.encode(payload) 362 ); 363 let signature = key_pair.sign(&SystemRandom::new(), token.as_bytes())?; 364 365 Ok(format!("Bearer {}.{}", token, BASE64_STANDARD.encode(signature)).into_boxed_str()) 366 } 367 }