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