deltachat/
push.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
//! # Push notifications module.
//!
//! This module is responsible for Apple Push Notification Service
//! and Firebase Cloud Messaging push notifications.
//!
//! It provides [`PushSubscriber`] type
//! which holds push notification token for the device,
//! shared by all accounts.
use std::sync::atomic::Ordering;
use std::sync::Arc;

use anyhow::{Context as _, Result};
use base64::Engine as _;
use pgp::crypto::aead::AeadAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::ser::Serialize;
use rand::thread_rng;
use tokio::sync::RwLock;

use crate::context::Context;
use crate::key::DcKey;

/// Manages subscription to Apple Push Notification services.
///
/// This structure is created by account manager and is shared between accounts.
/// To enable notifications, application should request the device token as described in
/// <https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns>
/// and give it to the account manager, which will forward the token in this structure.
///
/// Each account (context) can then retrieve device token
/// from this structure and give it to the email server.
/// If email server does not support push notifications,
/// account can call `subscribe` method
/// to register device token with the heartbeat
/// notification provider server as a fallback.
#[derive(Debug, Clone, Default)]
pub struct PushSubscriber {
    inner: Arc<RwLock<PushSubscriberState>>,
}

/// The key was generated with
/// `rsop generate-key --profile rfc9580`
/// and public key was extracted with `rsop extract-cert`.
const NOTIFIERS_PUBLIC_KEY: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----

xioGZ03cdhsAAAAg6PasQQylEuWAp9N5PXN93rqjZdqOqN3s9RJEU/K8FZzCsAYf
GwoAAABBBQJnTdx2AhsDAh4JCAsJCAcKDQwLBRUKCQgLAhYCIiEGiJJktnCmEtXa
qsSIGRJtupMnxycz/yT0xZK9ez+YkmIAAAAAUfgg/sg0sR2mytzADFBpNAaY0Hyu
aru8ics3eUkeNn2ziL4ZsIMx+4mcM5POvD0PG9LtH8Rz/y9iItD0c2aoRBab7iri
/gDm6aQuj3xXgtAiXdaN9s+QPxR9gY/zG1t9iXgBzioGZ03cdhkAAAAgwJ0wQFsk
MGH4jklfK1fFhYoQZMjEFCRBIk+r1S+WaSDClQYYGwgAAAAsBQJnTdx2AhsMIiEG
iJJktnCmEtXaqsSIGRJtupMnxycz/yT0xZK9ez+YkmIAAAAKCRCIkmS2cKYS1WdP
EFerccH2BoIPNbrxi6hwvxxy7G1mHg//ofD90fqmeY9xTfKMYl16bqQh4R1PiYd5
LMc5VqgXHgioqTYKbltlOtWC+HDt/PrymQsN4q/aEmsM
=5jvt
-----END PGP PUBLIC KEY BLOCK-----";

/// Pads the token with spaces.
///
/// This makes it impossible to tell
/// if the user is an Apple user with shorter tokens
/// or FCM user with longer tokens by the length of ciphertext.
fn pad_device_token(s: &str) -> String {
    // 512 is larger than any token, tokens seen so far have not been larger than 200 bytes.
    let expected_len: usize = 512;
    let payload_len = s.len();
    let padding_len = expected_len.saturating_sub(payload_len);
    let padding = " ".repeat(padding_len);
    let res = format!("{s}{padding}");
    debug_assert_eq!(res.len(), expected_len);
    res
}

/// Encrypts device token with OpenPGP.
///
/// The result is base64-encoded and not ASCII armored to avoid dealing with newlines.
pub(crate) fn encrypt_device_token(device_token: &str) -> Result<String> {
    let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0;
    let encryption_subkey = public_key
        .public_subkeys
        .first()
        .context("No encryption subkey found")?;
    let padded_device_token = pad_device_token(device_token);
    let literal_message = pgp::composed::Message::new_literal("", &padded_device_token);
    let mut rng = thread_rng();
    let chunk_size = 8;

    let encrypted_message = literal_message.encrypt_to_keys_seipdv2(
        &mut rng,
        SymmetricKeyAlgorithm::AES128,
        AeadAlgorithm::Ocb,
        chunk_size,
        &[&encryption_subkey],
    )?;
    let encoded_message = encrypted_message.to_bytes()?;
    Ok(format!(
        "openpgp:{}",
        base64::engine::general_purpose::STANDARD.encode(encoded_message)
    ))
}

impl PushSubscriber {
    /// Creates new push notification subscriber.
    pub(crate) fn new() -> Self {
        Default::default()
    }

    /// Sets device token for Apple Push Notification service
    /// or Firebase Cloud Messaging.
    pub(crate) async fn set_device_token(&self, token: &str) {
        self.inner.write().await.device_token = Some(token.to_string());
    }

    /// Retrieves device token.
    ///
    /// The token is encrypted with OpenPGP.
    ///
    /// Token may be not available if application is not running on Apple platform,
    /// does not have Google Play services,
    /// failed to register for remote notifications or is in the process of registering.
    ///
    /// IMAP loop should periodically check if device token is available
    /// and send the token to the email server if it supports push notifications.
    pub(crate) async fn device_token(&self) -> Option<String> {
        self.inner.read().await.device_token.clone()
    }

    /// Subscribes for heartbeat notifications with previously set device token.
    #[cfg(target_os = "ios")]
    pub(crate) async fn subscribe(&self, context: &Context) -> Result<()> {
        use crate::net::http;

        let mut state = self.inner.write().await;

        if state.heartbeat_subscribed {
            return Ok(());
        }

        let Some(ref token) = state.device_token else {
            return Ok(());
        };

        if http::post_string(
            context,
            "https://notifications.delta.chat/register",
            format!("{{\"token\":\"{token}\"}}"),
        )
        .await?
        {
            state.heartbeat_subscribed = true;
        }
        Ok(())
    }

    /// Placeholder to skip subscribing to heartbeat notifications outside iOS.
    #[cfg(not(target_os = "ios"))]
    pub(crate) async fn subscribe(&self, _context: &Context) -> Result<()> {
        let mut state = self.inner.write().await;
        state.heartbeat_subscribed = true;
        Ok(())
    }

    pub(crate) async fn heartbeat_subscribed(&self) -> bool {
        self.inner.read().await.heartbeat_subscribed
    }
}

#[derive(Debug, Default)]
pub(crate) struct PushSubscriberState {
    /// Device token.
    device_token: Option<String>,

    /// If subscribed to heartbeat push notifications.
    heartbeat_subscribed: bool,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive)]
#[repr(i8)]
pub enum NotifyState {
    /// Not subscribed to push notifications.
    #[default]
    NotConnected = 0,

    /// Subscribed to heartbeat push notifications.
    Heartbeat = 1,

    /// Subscribed to push notifications for new messages.
    Connected = 2,
}

impl Context {
    /// Returns push notification subscriber state.
    pub async fn push_state(&self) -> NotifyState {
        if self.push_subscribed.load(Ordering::Relaxed) {
            NotifyState::Connected
        } else if self.push_subscriber.heartbeat_subscribed().await {
            NotifyState::Heartbeat
        } else {
            NotifyState::NotConnected
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_set_device_token() {
        let push_subscriber = PushSubscriber::new();
        assert_eq!(push_subscriber.device_token().await, None);

        push_subscriber.set_device_token("some-token").await;
        let device_token = push_subscriber.device_token().await.unwrap();
        assert_eq!(device_token, "some-token");
    }

    #[test]
    fn test_pad_device_token() {
        let apple_token = "0155b93b7eb867a0d8b7328b978bb15bf22f70867e39e168d03f199af9496894";
        assert_eq!(pad_device_token(apple_token).trim(), apple_token);
    }

    #[test]
    fn test_encrypt_device_token() {
        let fcm_token = encrypt_device_token("fcm-chat.delta:c67DVcpVQN2rJHiSszKNDW:APA91bErcJV2b8qG0IT4aiuCqw6Al0_SbydSuz3V0CHBR1X7Fp8YzyvlpxNZIOGYVDFKejZGE1YiGSaqxmkr9ds0DuALmZNDwqIhuZWGKKrs3r7DTSkQ9MQ").unwrap();
        let fcm_beta_token = encrypt_device_token("fcm-chat.delta.beta:chu-GhZCTLyzq1XseJp3na:APA91bFlsfDawdszWTyOLbxBy7KeRCrYM-SBFqutebF5ix0EZKMuCFUT_Y7R7Ex_eTQG_LbOu3Ky_z5UlTMJtI7ufpIp5wEvsFmVzQcOo3YhrUpbiSVGIlk").unwrap();
        let apple_token = encrypt_device_token(
            "0155b93b7eb867a0d8b7328b978bb15bf22f70867e39e168d03f199af9496894",
        )
        .unwrap();

        assert_eq!(fcm_token.len(), fcm_beta_token.len());
        assert_eq!(apple_token.len(), fcm_token.len());
    }
}