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