taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

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 }