diff --git a/src/config.rs b/src/config.rs index 65b1ba6719..d34ef1c4a3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -441,6 +441,12 @@ pub enum Config { /// Enable webxdc realtime features. #[strum(props(default = "1"))] WebxdcRealtimeEnabled, + + /// Last device token stored on the chatmail server. + /// + /// If it has not changed, we do not store + /// the device token again. + DeviceToken, } impl Config { diff --git a/src/context.rs b/src/context.rs index 7d1c8c774e..0c597fb6ee 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1771,6 +1771,7 @@ mod tests { "socks5_password", "key_id", "webxdc_integration", + "device_token", ]; let t = TestContext::new().await; let info = t.get_info().await.unwrap(); diff --git a/src/imap.rs b/src/imap.rs index 4eb432599d..f04d84e13d 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -41,6 +41,7 @@ use crate::mimeparser; use crate::net::proxy::ProxyConfig; use crate::net::session::SessionStream; use crate::oauth2::get_oauth2_access_token; +use crate::push::encrypt_device_token; use crate::receive_imf::{ from_field_to_contact_id, get_prefetch_parent_message, receive_imf_inner, ReceivedMsg, }; @@ -1559,17 +1560,53 @@ impl Session { return Ok(()); }; - if self.can_metadata() && self.can_push() { + let device_token_changed = context + .get_config(Config::DeviceToken) + .await? + .map_or(true, |config_token| device_token != config_token); + + if device_token_changed && self.can_metadata() && self.can_push() { let folder = context .get_config(Config::ConfiguredInboxFolder) .await? .context("INBOX is not configured")?; - self.run_command_and_check_ok(format!( - "SETMETADATA \"{folder}\" (/private/devicetoken \"{device_token}\")" - )) - .await - .context("SETMETADATA command failed")?; + let encrypted_device_token = + encrypt_device_token(&device_token).context("Failed to encrypt device token")?; + + // We expect that the server supporting `XDELTAPUSH` capability + // has non-synchronizing literals support as well: + // . + let encrypted_device_token_len = encrypted_device_token.len(); + + if encrypted_device_token_len <= 4096 { + self.run_command_and_check_ok(&format_setmetadata( + &folder, + &encrypted_device_token, + )) + .await + .context("SETMETADATA command failed")?; + + // Store device token saved on the server + // to prevent storing duplicate tokens. + // The server cannot deduplicate on its own + // because encryption gives a different + // result each time. + context + .set_config_internal(Config::DeviceToken, Some(&device_token)) + .await?; + } else { + // If Apple or Google (FCM) gives us a very large token, + // do not even try to give it to IMAP servers. + // + // Limit of 4096 is arbitrarily selected + // to be the same as required by LITERAL- IMAP extension. + // + // Dovecot supports LITERAL+ and non-synchronizing literals + // of any length, but there is no reason for tokens + // to be that large even after OpenPGP encryption. + warn!(context, "Device token is too long for LITERAL-, ignoring."); + } context.push_subscribed.store(true, Ordering::Relaxed); } else if !context.push_subscriber.heartbeat_subscribed().await { let context = context.clone(); @@ -1581,6 +1618,13 @@ impl Session { } } +fn format_setmetadata(folder: &str, device_token: &str) -> String { + let device_token_len = device_token.len(); + format!( + "SETMETADATA \"{folder}\" (/private/devicetoken {{{device_token_len}+}}\r\n{device_token})" + ) +} + impl Session { /// Returns success if we successfully set the flag or we otherwise /// think add_flag should not be retried: Disconnection during setting @@ -2864,4 +2908,16 @@ mod tests { vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())] ); } + + #[test] + fn test_setmetadata_device_token() { + assert_eq!( + format_setmetadata("INBOX", "foobarbaz"), + "SETMETADATA \"INBOX\" (/private/devicetoken {9+}\r\nfoobarbaz)" + ); + assert_eq!( + format_setmetadata("INBOX", "foo\r\nbar\r\nbaz\r\n"), + "SETMETADATA \"INBOX\" (/private/devicetoken {15+}\r\nfoo\r\nbar\r\nbaz\r\n)" + ); + } } diff --git a/src/push.rs b/src/push.rs index fbdf7ff5ad..de7b619b6b 100644 --- a/src/push.rs +++ b/src/push.rs @@ -1,10 +1,14 @@ use std::sync::atomic::Ordering; use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context as _, Result}; +use pgp::crypto::aead::AeadAlgorithm; +use pgp::crypto::sym::SymmetricKeyAlgorithm; +use rand::thread_rng; use tokio::sync::RwLock; use crate::context::Context; +use crate::key::DcKey; /// Manages subscription to Apple Push Notification services. /// @@ -24,20 +28,77 @@ pub struct PushSubscriber { inner: Arc>, } +/// The key was generated with +/// `rsop generate-key --profile rfc9580 | rsop extract-cert`. +const NOTIFIERS_PUBLIC_KEY: &str = "-----BEGIN PGP PUBLIC KEY BLOCK----- + +xioGZz5XrhsAAAAgArETOonYhhUGVk0fw0t8b4MbvoFJFHVKwD352OiEizPCsAYf +GwoAAABBBQJnPleuAhsDAh4JCAsJCAcKDQwLBRUKCQgLAhYCIiEGCeNFWH7kM/8A +XS8A2HouJVoSe2JbPKDxomei7cnPVg4AAAAAVtIgQI/MVJd3abu2ITUxpFMsTro8 +3sc5WC8dXB5Et5GNROsCQzfdmpmCb5RcVmjSirVnOIlxBvGg00ajCs76PKK0FUIk +xIQfFcNpKVnsqEcXja//Hq37Q5YQIHBswDTy4tUIzioGZz5XrhkAAAAgwgebLt0s +ZFniutxUyR+0wtgUjdPyqUiI5Tu9Qp4R/1fClQYYGwgAAAAsBQJnPleuAhsMIiEG +CeNFWH7kM/8AXS8A2HouJVoSe2JbPKDxomei7cnPVg4AAAAKCRAJ40VYfuQz/+nV +EJGsfYwfQw1ErVtGRNLHsrX/dmwpFlKS1fbCWXjxL8yPSfwvPPSKh0sMTbZ9X7NC +KvGiT8+VtKuphR9lga2BoUWnrQCXViPkJyPztVQSg0kO +=9WnP +-----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 { + 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 +} + +pub(crate) fn encrypt_device_token(device_token: &str) -> Result { + 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_armored_string(Default::default())?; + Ok(encoded_message) +} + impl PushSubscriber { /// Creates new push notification subscriber. pub(crate) fn new() -> Self { Default::default() } - /// Sets device token for Apple Push Notification service. + /// 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 @@ -121,3 +182,37 @@ impl Context { } } } + +#[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()); + } +}