Skip to content

Commit 6dd8f44

Browse files
committed
feat: encrypt notification tokens
1 parent e14349e commit 6dd8f44

File tree

4 files changed

+176
-8
lines changed

4 files changed

+176
-8
lines changed

src/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,12 @@ pub enum Config {
441441
/// Enable webxdc realtime features.
442442
#[strum(props(default = "1"))]
443443
WebxdcRealtimeEnabled,
444+
445+
/// Last device token stored on the chatmail server.
446+
///
447+
/// If it has not changed, we do not store
448+
/// the device token again.
449+
DeviceToken,
444450
}
445451

446452
impl Config {

src/context.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1771,6 +1771,7 @@ mod tests {
17711771
"socks5_password",
17721772
"key_id",
17731773
"webxdc_integration",
1774+
"device_token",
17741775
];
17751776
let t = TestContext::new().await;
17761777
let info = t.get_info().await.unwrap();

src/imap.rs

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ use crate::mimeparser;
4141
use crate::net::proxy::ProxyConfig;
4242
use crate::net::session::SessionStream;
4343
use crate::oauth2::get_oauth2_access_token;
44+
use crate::push::encrypt_device_token;
4445
use crate::receive_imf::{
4546
from_field_to_contact_id, get_prefetch_parent_message, receive_imf_inner, ReceivedMsg,
4647
};
@@ -1559,17 +1560,53 @@ impl Session {
15591560
return Ok(());
15601561
};
15611562

1562-
if self.can_metadata() && self.can_push() {
1563+
let device_token_changed = context
1564+
.get_config(Config::DeviceToken)
1565+
.await?
1566+
.map_or(true, |config_token| device_token != config_token);
1567+
1568+
if device_token_changed && self.can_metadata() && self.can_push() {
15631569
let folder = context
15641570
.get_config(Config::ConfiguredInboxFolder)
15651571
.await?
15661572
.context("INBOX is not configured")?;
15671573

1568-
self.run_command_and_check_ok(format!(
1569-
"SETMETADATA \"{folder}\" (/private/devicetoken \"{device_token}\")"
1570-
))
1571-
.await
1572-
.context("SETMETADATA command failed")?;
1574+
let encrypted_device_token =
1575+
encrypt_device_token(&device_token).context("Failed to encrypt device token")?;
1576+
1577+
// We expect that the server supporting `XDELTAPUSH` capability
1578+
// has non-synchronizing literals support as well:
1579+
// <https://www.rfc-editor.org/rfc/rfc7888>.
1580+
let encrypted_device_token_len = encrypted_device_token.len();
1581+
1582+
if encrypted_device_token_len <= 4096 {
1583+
self.run_command_and_check_ok(&format_setmetadata(
1584+
&folder,
1585+
&encrypted_device_token,
1586+
))
1587+
.await
1588+
.context("SETMETADATA command failed")?;
1589+
1590+
// Store device token saved on the server
1591+
// to prevent storing duplicate tokens.
1592+
// The server cannot deduplicate on its own
1593+
// because encryption gives a different
1594+
// result each time.
1595+
context
1596+
.set_config_internal(Config::DeviceToken, Some(&device_token))
1597+
.await?;
1598+
} else {
1599+
// If Apple or Google (FCM) gives us a very large token,
1600+
// do not even try to give it to IMAP servers.
1601+
//
1602+
// Limit of 4096 is arbitrarily selected
1603+
// to be the same as required by LITERAL- IMAP extension.
1604+
//
1605+
// Dovecot supports LITERAL+ and non-synchronizing literals
1606+
// of any length, but there is no reason for tokens
1607+
// to be that large even after OpenPGP encryption.
1608+
warn!(context, "Device token is too long for LITERAL-, ignoring.");
1609+
}
15731610
context.push_subscribed.store(true, Ordering::Relaxed);
15741611
} else if !context.push_subscriber.heartbeat_subscribed().await {
15751612
let context = context.clone();
@@ -1581,6 +1618,13 @@ impl Session {
15811618
}
15821619
}
15831620

1621+
fn format_setmetadata(folder: &str, device_token: &str) -> String {
1622+
let device_token_len = device_token.len();
1623+
format!(
1624+
"SETMETADATA \"{folder}\" (/private/devicetoken {{{device_token_len}+}}\r\n{device_token})"
1625+
)
1626+
}
1627+
15841628
impl Session {
15851629
/// Returns success if we successfully set the flag or we otherwise
15861630
/// think add_flag should not be retried: Disconnection during setting
@@ -2864,4 +2908,16 @@ mod tests {
28642908
vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())]
28652909
);
28662910
}
2911+
2912+
#[test]
2913+
fn test_setmetadata_device_token() {
2914+
assert_eq!(
2915+
format_setmetadata("INBOX", "foobarbaz"),
2916+
"SETMETADATA \"INBOX\" (/private/devicetoken {9+}\r\nfoobarbaz)"
2917+
);
2918+
assert_eq!(
2919+
format_setmetadata("INBOX", "foo\r\nbar\r\nbaz\r\n"),
2920+
"SETMETADATA \"INBOX\" (/private/devicetoken {15+}\r\nfoo\r\nbar\r\nbaz\r\n)"
2921+
);
2922+
}
28672923
}

src/push.rs

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
use std::sync::atomic::Ordering;
22
use std::sync::Arc;
33

4-
use anyhow::Result;
4+
use anyhow::{Context as _, Result};
5+
use base64::Engine as _;
6+
use pgp::crypto::aead::AeadAlgorithm;
7+
use pgp::crypto::sym::SymmetricKeyAlgorithm;
8+
use pgp::ser::Serialize;
9+
use rand::thread_rng;
510
use tokio::sync::RwLock;
611

712
use crate::context::Context;
13+
use crate::key::DcKey;
814

915
/// Manages subscription to Apple Push Notification services.
1016
///
@@ -24,20 +30,85 @@ pub struct PushSubscriber {
2430
inner: Arc<RwLock<PushSubscriberState>>,
2531
}
2632

33+
/// The key was generated with
34+
/// `rsop generate-key --profile rfc9580`
35+
/// and public key was extracted with `rsop extract-cert`.
36+
const NOTIFIERS_PUBLIC_KEY: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----
37+
38+
xioGZ03cdhsAAAAg6PasQQylEuWAp9N5PXN93rqjZdqOqN3s9RJEU/K8FZzCsAYf
39+
GwoAAABBBQJnTdx2AhsDAh4JCAsJCAcKDQwLBRUKCQgLAhYCIiEGiJJktnCmEtXa
40+
qsSIGRJtupMnxycz/yT0xZK9ez+YkmIAAAAAUfgg/sg0sR2mytzADFBpNAaY0Hyu
41+
aru8ics3eUkeNn2ziL4ZsIMx+4mcM5POvD0PG9LtH8Rz/y9iItD0c2aoRBab7iri
42+
/gDm6aQuj3xXgtAiXdaN9s+QPxR9gY/zG1t9iXgBzioGZ03cdhkAAAAgwJ0wQFsk
43+
MGH4jklfK1fFhYoQZMjEFCRBIk+r1S+WaSDClQYYGwgAAAAsBQJnTdx2AhsMIiEG
44+
iJJktnCmEtXaqsSIGRJtupMnxycz/yT0xZK9ez+YkmIAAAAKCRCIkmS2cKYS1WdP
45+
EFerccH2BoIPNbrxi6hwvxxy7G1mHg//ofD90fqmeY9xTfKMYl16bqQh4R1PiYd5
46+
LMc5VqgXHgioqTYKbltlOtWC+HDt/PrymQsN4q/aEmsM
47+
=5jvt
48+
-----END PGP PUBLIC KEY BLOCK-----";
49+
50+
/// Pads the token with spaces.
51+
///
52+
/// This makes it impossible to tell
53+
/// if the user is an Apple user with shorter tokens
54+
/// or FCM user with longer tokens by the length of ciphertext.
55+
fn pad_device_token(s: &str) -> String {
56+
// 512 is larger than any token, tokens seen so far have not been larger than 200 bytes.
57+
let expected_len: usize = 512;
58+
let payload_len = s.len();
59+
let padding_len = expected_len.saturating_sub(payload_len);
60+
let padding = " ".repeat(padding_len);
61+
let res = format!("{s}{padding}");
62+
debug_assert_eq!(res.len(), expected_len);
63+
res
64+
}
65+
66+
/// Encrypts device token with OpenPGP.
67+
///
68+
/// The result is base64-encoded and not ASCII armored to avoid dealing with newlines.
69+
pub(crate) fn encrypt_device_token(device_token: &str) -> Result<String> {
70+
let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0;
71+
let encryption_subkey = public_key
72+
.public_subkeys
73+
.first()
74+
.context("No encryption subkey found")?;
75+
let padded_device_token = pad_device_token(device_token);
76+
let literal_message = pgp::composed::Message::new_literal("", &padded_device_token);
77+
let mut rng = thread_rng();
78+
let chunk_size = 8;
79+
80+
let encrypted_message = literal_message.encrypt_to_keys_seipdv2(
81+
&mut rng,
82+
SymmetricKeyAlgorithm::AES128,
83+
AeadAlgorithm::Ocb,
84+
chunk_size,
85+
&[&encryption_subkey],
86+
)?;
87+
let encoded_message = encrypted_message.to_bytes()?;
88+
Ok(format!(
89+
"openpgp:{}",
90+
base64::engine::general_purpose::STANDARD.encode(encoded_message)
91+
))
92+
}
93+
2794
impl PushSubscriber {
2895
/// Creates new push notification subscriber.
2996
pub(crate) fn new() -> Self {
3097
Default::default()
3198
}
3299

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

38106
/// Retrieves device token.
39107
///
108+
/// The token is encrypted with OpenPGP.
109+
///
40110
/// Token may be not available if application is not running on Apple platform,
111+
/// does not have Google Play services,
41112
/// failed to register for remote notifications or is in the process of registering.
42113
///
43114
/// IMAP loop should periodically check if device token is available
@@ -121,3 +192,37 @@ impl Context {
121192
}
122193
}
123194
}
195+
196+
#[cfg(test)]
197+
mod tests {
198+
use super::*;
199+
200+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
201+
async fn test_set_device_token() {
202+
let push_subscriber = PushSubscriber::new();
203+
assert_eq!(push_subscriber.device_token().await, None);
204+
205+
push_subscriber.set_device_token("some-token").await;
206+
let device_token = push_subscriber.device_token().await.unwrap();
207+
assert_eq!(device_token, "some-token");
208+
}
209+
210+
#[test]
211+
fn test_pad_device_token() {
212+
let apple_token = "0155b93b7eb867a0d8b7328b978bb15bf22f70867e39e168d03f199af9496894";
213+
assert_eq!(pad_device_token(apple_token).trim(), apple_token);
214+
}
215+
216+
#[test]
217+
fn test_encrypt_device_token() {
218+
let fcm_token = encrypt_device_token("fcm-chat.delta:c67DVcpVQN2rJHiSszKNDW:APA91bErcJV2b8qG0IT4aiuCqw6Al0_SbydSuz3V0CHBR1X7Fp8YzyvlpxNZIOGYVDFKejZGE1YiGSaqxmkr9ds0DuALmZNDwqIhuZWGKKrs3r7DTSkQ9MQ").unwrap();
219+
let fcm_beta_token = encrypt_device_token("fcm-chat.delta.beta:chu-GhZCTLyzq1XseJp3na:APA91bFlsfDawdszWTyOLbxBy7KeRCrYM-SBFqutebF5ix0EZKMuCFUT_Y7R7Ex_eTQG_LbOu3Ky_z5UlTMJtI7ufpIp5wEvsFmVzQcOo3YhrUpbiSVGIlk").unwrap();
220+
let apple_token = encrypt_device_token(
221+
"0155b93b7eb867a0d8b7328b978bb15bf22f70867e39e168d03f199af9496894",
222+
)
223+
.unwrap();
224+
225+
assert_eq!(fcm_token.len(), fcm_beta_token.len());
226+
assert_eq!(apple_token.len(), fcm_token.len());
227+
}
228+
}

0 commit comments

Comments
 (0)