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