apns.rs (13661B)
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 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 as _; 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::{encoding::base64, error::FmtSource}; 34 use taler_macros::EnumMeta; 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, 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 key_id: CompactString, 177 team_id: CompactString, 178 bundle_id: CompactString, 179 token: Box<str>, 180 issued_at: Timestamp, 181 } 182 183 impl Client { 184 pub fn new(cfg: &ApnsConfig) -> anyhow::Result<Self> { 185 let ApnsConfig { 186 key_path, 187 key_id, 188 team_id, 189 bundle_id, 190 } = cfg; 191 192 rustls::crypto::aws_lc_rs::default_provider() 193 .install_default() 194 .expect("failed to install the default TLS provider"); 195 196 // Load the signature key pair 197 let private_key_der = PrivateKeyDer::from_pem_file(key_path) 198 .map_err(|e| anyhow!("failed to read key file at '{key_path}': {e}"))?; 199 let PrivateKeyDer::Pkcs8(pkcs8_der) = private_key_der else { 200 bail!("invalid key file at '{key_path}': not a valid PKCS#8 private key"); 201 }; 202 let key_pair = EcdsaKeyPair::from_pkcs8( 203 &ECDSA_P256_SHA256_FIXED_SIGNING, 204 pkcs8_der.secret_pkcs8_der(), 205 ) 206 .map_err(|_| anyhow!("invalid key file at '{key_path}': not a valid PKCS#8 private key"))?; 207 208 // Make a signature 209 let now = Timestamp::now(); 210 let token = Self::create_token(&key_pair, key_id, team_id, &now)?; 211 212 // Prepare the TLS client config 213 let tls = rustls::ClientConfig::builder() 214 .with_native_roots()? 215 .with_no_client_auth(); 216 217 // Prepare the HTTPS connector 218 let https = hyper_rustls::HttpsConnectorBuilder::new() 219 .with_tls_config(tls) 220 .https_only() 221 .enable_http2() 222 .build(); 223 224 // 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 225 // 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. 226 let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()) 227 .timer(TokioTimer::new()) 228 .pool_idle_timeout(None) 229 .http2_only(true) 230 .http2_keep_alive_interval(Some(Duration::from_secs(60 * 60))) 231 .http2_keep_alive_while_idle(true) 232 .build(https); 233 234 Ok(Self { 235 http, 236 key_pair, 237 key_id: key_id.clone(), 238 team_id: team_id.clone(), 239 bundle_id: bundle_id.clone(), 240 token, 241 issued_at: now, 242 }) 243 } 244 245 pub async fn send(&mut self, device_token: &str) -> Result<(), ApnsError> { 246 let now = Timestamp::now(); 247 // Token expire after an hour 248 if now.duration_since(self.issued_at) > SignedDuration::from_mins(55) { 249 self.token = 250 Self::create_token(&self.key_pair, &self.key_id, &self.team_id, &now).unwrap(); 251 self.issued_at = now; 252 } 253 254 let path = format!( 255 "https://{}/3/device/{device_token}", 256 "api.sandbox.push.apple.com" 257 ); 258 259 let req = hyper::Request::builder() 260 .method(Method::POST) 261 .uri(&path) 262 .header(CONTENT_TYPE, "application/json") 263 .header("apns-push-type", "background") 264 .header("apns-priority", "5") 265 .header("apns-collapse-id", "wakeup") 266 .header("apns-topic", self.bundle_id.as_str()) 267 .header(AUTHORIZATION, self.token.as_ref()) 268 .body(Full::new(Bytes::from_static( 269 r#"{"aps":{"content-available":1}}"#.as_bytes(), 270 ))) 271 .unwrap(); 272 273 let (parts, body) = self 274 .http 275 .request(req) 276 .await 277 .map_err(|e| ApnsError::ReqTransport(e.into()))? 278 .into_parts(); 279 let status = parts.status; 280 if status == StatusCode::OK { 281 return Ok(()); 282 } 283 284 let body = body 285 .collect() 286 .await 287 .map(|it| it.to_bytes()) 288 .map_err(|e| ApnsError::ResTransport(e.into()))?; 289 let body: ApnsErrorBody = serde_json::from_slice(&body).map_err(|e| { 290 ApnsError::ResJson( 291 status, 292 String::from_utf8_lossy(&body).to_string().into_boxed_str(), 293 e, 294 ) 295 })?; 296 let reason = match (status.as_u16(), body.reason.as_str()) { 297 (400, "BadCollapseId") => Reason::BadCollapseId, 298 (400, "BadDeviceToken") => Reason::BadDeviceToken, 299 (400, "BadExpirationDate") => Reason::BadExpirationDate, 300 (400, "BadMessageId") => Reason::BadMessageId, 301 (400, "BadPriority") => Reason::BadPriority, 302 (400, "BadTopic") => Reason::BadTopic, 303 (400, "DeviceTokenNotForTopic") => Reason::DeviceTokenNotForTopic, 304 (400, "DuplicateHeaders") => Reason::DuplicateHeaders, 305 (400, "IdleTimeout") => Reason::IdleTimeout, 306 (400, "InvalidPushType") => Reason::InvalidPushType, 307 (400, "MissingDeviceToken") => Reason::MissingDeviceToken, 308 (400, "MissingTopic") => Reason::MissingTopic, 309 (400, "PayloadEmpty") => Reason::PayloadEmpty, 310 (400, "TopicDisallowed") => Reason::TopicDisallowed, 311 (403, "BadCertificate") => Reason::BadCertificate, 312 (403, "BadCertificateEnvironment") => Reason::BadCertificateEnvironment, 313 (403, "ExpiredProviderToken") => Reason::ExpiredProviderToken, 314 (403, "Forbidden") => Reason::Forbidden, 315 (403, "InvalidProviderToken") => Reason::InvalidProviderToken, 316 (403, "MissingProviderToken") => Reason::MissingProviderToken, 317 (403, "UnrelatedKeyIdInToken") => Reason::UnrelatedKeyIdInToken, 318 (403, "BadEnvironmentKeyIdInToken") => Reason::BadEnvironmentKeyIdInToken, 319 (404, "BadPath") => Reason::BadPath, 320 (405, "MethodNotAllowed") => Reason::MethodNotAllowed, 321 (410, "ExpiredToken") => Reason::ExpiredToken, 322 (410, "Unregistered") => Reason::Unregistered, 323 (413, "PayloadTooLarge") => Reason::PayloadTooLarge, 324 (429, "TooManyProviderTokenUpdates") => Reason::TooManyProviderTokenUpdates, 325 (429, "TooManyRequests") => Reason::TooManyRequests, 326 (500, "InternalServerError") => Reason::InternalServerError, 327 (503, "ServiceUnavailable") => Reason::ServiceUnavailable, 328 (503, "Shutdown") => Reason::Shutdown, 329 _ => return Err(ApnsError::ErrUnknown(status, body.reason)), 330 }; 331 Err(ApnsError::Err { 332 reason, 333 timestamp: body.timestamp, 334 }) 335 } 336 337 fn create_token( 338 key_pair: &EcdsaKeyPair, 339 key_id: &str, 340 team_id: &str, 341 issued_at: &Timestamp, 342 ) -> Result<Box<str>, anyhow::Error> { 343 let headers = format!(r#"{{"alg":"ES256","kid":"{key_id}"}}"#); 344 let payload = format!(r#"{{"iss":"{team_id}","iat":{}}}"#, issued_at.as_second()); 345 let token = format!( 346 "{}.{}", 347 base64::fmt(headers.as_bytes()), 348 base64::fmt(payload.as_bytes()) 349 ); 350 let signature = key_pair.sign(&SystemRandom::new(), token.as_bytes())?; 351 352 Ok(format!("Bearer {}.{}", token, base64::fmt(signature.as_ref())).into_boxed_str()) 353 } 354 }