diff --git a/src/config.rs b/src/config.rs index 705ba0e1df..2734b577c7 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 2358f70865..d6319e67dd 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..85837e1d3d 100644 --- a/src/push.rs +++ b/src/push.rs @@ -1,10 +1,16 @@ use std::sync::atomic::Ordering; use std::sync::Arc; -use anyhow::Result; +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. /// @@ -24,20 +30,84 @@ pub struct PushSubscriber { inner: Arc>, } +/// 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 { + 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 { + 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. + /// 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 +191,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()); + } +}