deltachat/
push.rs

1//! # Push notifications module.
2//!
3//! This module is responsible for Apple Push Notification Service
4//! and Firebase Cloud Messaging push notifications.
5//!
6//! It provides [`PushSubscriber`] type
7//! which holds push notification token for the device,
8//! shared by all accounts.
9use std::sync::atomic::Ordering;
10use std::sync::Arc;
11
12use anyhow::{Context as _, Result};
13use base64::Engine as _;
14use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
15use pgp::crypto::sym::SymmetricKeyAlgorithm;
16use rand::thread_rng;
17use tokio::sync::RwLock;
18
19use crate::context::Context;
20use crate::key::DcKey;
21
22/// Manages subscription to Apple Push Notification services.
23///
24/// This structure is created by account manager and is shared between accounts.
25/// To enable notifications, application should request the device token as described in
26/// <https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns>
27/// and give it to the account manager, which will forward the token in this structure.
28///
29/// Each account (context) can then retrieve device token
30/// from this structure and give it to the email server.
31/// If email server does not support push notifications,
32/// account can call `subscribe` method
33/// to register device token with the heartbeat
34/// notification provider server as a fallback.
35#[derive(Debug, Clone, Default)]
36pub struct PushSubscriber {
37    inner: Arc<RwLock<PushSubscriberState>>,
38}
39
40/// The key was generated with
41/// `rsop generate-key --profile rfc9580`
42/// and public key was extracted with `rsop extract-cert`.
43const NOTIFIERS_PUBLIC_KEY: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----
44
45xioGZ03cdhsAAAAg6PasQQylEuWAp9N5PXN93rqjZdqOqN3s9RJEU/K8FZzCsAYf
46GwoAAABBBQJnTdx2AhsDAh4JCAsJCAcKDQwLBRUKCQgLAhYCIiEGiJJktnCmEtXa
47qsSIGRJtupMnxycz/yT0xZK9ez+YkmIAAAAAUfgg/sg0sR2mytzADFBpNAaY0Hyu
48aru8ics3eUkeNn2ziL4ZsIMx+4mcM5POvD0PG9LtH8Rz/y9iItD0c2aoRBab7iri
49/gDm6aQuj3xXgtAiXdaN9s+QPxR9gY/zG1t9iXgBzioGZ03cdhkAAAAgwJ0wQFsk
50MGH4jklfK1fFhYoQZMjEFCRBIk+r1S+WaSDClQYYGwgAAAAsBQJnTdx2AhsMIiEG
51iJJktnCmEtXaqsSIGRJtupMnxycz/yT0xZK9ez+YkmIAAAAKCRCIkmS2cKYS1WdP
52EFerccH2BoIPNbrxi6hwvxxy7G1mHg//ofD90fqmeY9xTfKMYl16bqQh4R1PiYd5
53LMc5VqgXHgioqTYKbltlOtWC+HDt/PrymQsN4q/aEmsM
54=5jvt
55-----END PGP PUBLIC KEY BLOCK-----";
56
57/// Pads the token with spaces.
58///
59/// This makes it impossible to tell
60/// if the user is an Apple user with shorter tokens
61/// or FCM user with longer tokens by the length of ciphertext.
62fn pad_device_token(s: &str) -> String {
63    // 512 is larger than any token, tokens seen so far have not been larger than 200 bytes.
64    let expected_len: usize = 512;
65    let payload_len = s.len();
66    let padding_len = expected_len.saturating_sub(payload_len);
67    let padding = " ".repeat(padding_len);
68    let res = format!("{s}{padding}");
69    debug_assert_eq!(res.len(), expected_len);
70    res
71}
72
73/// Encrypts device token with OpenPGP.
74///
75/// The result is base64-encoded and not ASCII armored to avoid dealing with newlines.
76pub(crate) fn encrypt_device_token(device_token: &str) -> Result<String> {
77    let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0;
78    let encryption_subkey = public_key
79        .public_subkeys
80        .first()
81        .context("No encryption subkey found")?;
82    let padded_device_token = pad_device_token(device_token);
83    let mut rng = thread_rng();
84
85    let mut msg = pgp::composed::MessageBuilder::from_bytes("", padded_device_token).seipd_v2(
86        &mut rng,
87        SymmetricKeyAlgorithm::AES128,
88        AeadAlgorithm::Ocb,
89        ChunkSize::C8KiB,
90    );
91    msg.encrypt_to_key(&mut rng, &encryption_subkey)?;
92    let encoded_message = msg.to_vec(&mut rng)?;
93
94    Ok(format!(
95        "openpgp:{}",
96        base64::engine::general_purpose::STANDARD.encode(encoded_message)
97    ))
98}
99
100impl PushSubscriber {
101    /// Creates new push notification subscriber.
102    pub(crate) fn new() -> Self {
103        Default::default()
104    }
105
106    /// Sets device token for Apple Push Notification service
107    /// or Firebase Cloud Messaging.
108    pub(crate) async fn set_device_token(&self, token: &str) {
109        self.inner.write().await.device_token = Some(token.to_string());
110    }
111
112    /// Retrieves device token.
113    ///
114    /// The token is encrypted with OpenPGP.
115    ///
116    /// Token may be not available if application is not running on Apple platform,
117    /// does not have Google Play services,
118    /// failed to register for remote notifications or is in the process of registering.
119    ///
120    /// IMAP loop should periodically check if device token is available
121    /// and send the token to the email server if it supports push notifications.
122    pub(crate) async fn device_token(&self) -> Option<String> {
123        self.inner.read().await.device_token.clone()
124    }
125
126    /// Subscribes for heartbeat notifications with previously set device token.
127    #[cfg(target_os = "ios")]
128    pub(crate) async fn subscribe(&self, context: &Context) -> Result<()> {
129        use crate::net::http;
130
131        let mut state = self.inner.write().await;
132
133        if state.heartbeat_subscribed {
134            return Ok(());
135        }
136
137        let Some(ref token) = state.device_token else {
138            return Ok(());
139        };
140
141        if http::post_string(
142            context,
143            "https://notifications.delta.chat/register",
144            format!("{{\"token\":\"{token}\"}}"),
145        )
146        .await?
147        {
148            state.heartbeat_subscribed = true;
149        }
150        Ok(())
151    }
152
153    /// Placeholder to skip subscribing to heartbeat notifications outside iOS.
154    #[cfg(not(target_os = "ios"))]
155    pub(crate) async fn subscribe(&self, _context: &Context) -> Result<()> {
156        let mut state = self.inner.write().await;
157        state.heartbeat_subscribed = true;
158        Ok(())
159    }
160
161    pub(crate) async fn heartbeat_subscribed(&self) -> bool {
162        self.inner.read().await.heartbeat_subscribed
163    }
164}
165
166#[derive(Debug, Default)]
167pub(crate) struct PushSubscriberState {
168    /// Device token.
169    device_token: Option<String>,
170
171    /// If subscribed to heartbeat push notifications.
172    heartbeat_subscribed: bool,
173}
174
175#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive)]
176#[repr(i8)]
177pub enum NotifyState {
178    /// Not subscribed to push notifications.
179    #[default]
180    NotConnected = 0,
181
182    /// Subscribed to heartbeat push notifications.
183    Heartbeat = 1,
184
185    /// Subscribed to push notifications for new messages.
186    Connected = 2,
187}
188
189impl Context {
190    /// Returns push notification subscriber state.
191    pub async fn push_state(&self) -> NotifyState {
192        if self.push_subscribed.load(Ordering::Relaxed) {
193            NotifyState::Connected
194        } else if self.push_subscriber.heartbeat_subscribed().await {
195            NotifyState::Heartbeat
196        } else {
197            NotifyState::NotConnected
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
207    async fn test_set_device_token() {
208        let push_subscriber = PushSubscriber::new();
209        assert_eq!(push_subscriber.device_token().await, None);
210
211        push_subscriber.set_device_token("some-token").await;
212        let device_token = push_subscriber.device_token().await.unwrap();
213        assert_eq!(device_token, "some-token");
214    }
215
216    #[test]
217    fn test_pad_device_token() {
218        let apple_token = "0155b93b7eb867a0d8b7328b978bb15bf22f70867e39e168d03f199af9496894";
219        assert_eq!(pad_device_token(apple_token).trim(), apple_token);
220    }
221
222    #[test]
223    fn test_encrypt_device_token() {
224        let fcm_token = encrypt_device_token("fcm-chat.delta:c67DVcpVQN2rJHiSszKNDW:APA91bErcJV2b8qG0IT4aiuCqw6Al0_SbydSuz3V0CHBR1X7Fp8YzyvlpxNZIOGYVDFKejZGE1YiGSaqxmkr9ds0DuALmZNDwqIhuZWGKKrs3r7DTSkQ9MQ").unwrap();
225        let fcm_beta_token = encrypt_device_token("fcm-chat.delta.beta:chu-GhZCTLyzq1XseJp3na:APA91bFlsfDawdszWTyOLbxBy7KeRCrYM-SBFqutebF5ix0EZKMuCFUT_Y7R7Ex_eTQG_LbOu3Ky_z5UlTMJtI7ufpIp5wEvsFmVzQcOo3YhrUpbiSVGIlk").unwrap();
226        let apple_token = encrypt_device_token(
227            "0155b93b7eb867a0d8b7328b978bb15bf22f70867e39e168d03f199af9496894",
228        )
229        .unwrap();
230
231        assert_eq!(fcm_token.len(), fcm_beta_token.len());
232        assert_eq!(apple_token.len(), fcm_token.len());
233    }
234}