taler-rust

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

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 }