mirror of
https://github.com/chatmail/core.git
synced 2026-05-18 22:36:29 +03:00
feat: encrypt notification tokens
This commit is contained in:
@@ -441,6 +441,12 @@ pub enum Config {
|
|||||||
/// Enable webxdc realtime features.
|
/// Enable webxdc realtime features.
|
||||||
#[strum(props(default = "1"))]
|
#[strum(props(default = "1"))]
|
||||||
WebxdcRealtimeEnabled,
|
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 {
|
impl Config {
|
||||||
|
|||||||
@@ -1771,6 +1771,7 @@ mod tests {
|
|||||||
"socks5_password",
|
"socks5_password",
|
||||||
"key_id",
|
"key_id",
|
||||||
"webxdc_integration",
|
"webxdc_integration",
|
||||||
|
"device_token",
|
||||||
];
|
];
|
||||||
let t = TestContext::new().await;
|
let t = TestContext::new().await;
|
||||||
let info = t.get_info().await.unwrap();
|
let info = t.get_info().await.unwrap();
|
||||||
|
|||||||
68
src/imap.rs
68
src/imap.rs
@@ -41,6 +41,7 @@ use crate::mimeparser;
|
|||||||
use crate::net::proxy::ProxyConfig;
|
use crate::net::proxy::ProxyConfig;
|
||||||
use crate::net::session::SessionStream;
|
use crate::net::session::SessionStream;
|
||||||
use crate::oauth2::get_oauth2_access_token;
|
use crate::oauth2::get_oauth2_access_token;
|
||||||
|
use crate::push::encrypt_device_token;
|
||||||
use crate::receive_imf::{
|
use crate::receive_imf::{
|
||||||
from_field_to_contact_id, get_prefetch_parent_message, receive_imf_inner, ReceivedMsg,
|
from_field_to_contact_id, get_prefetch_parent_message, receive_imf_inner, ReceivedMsg,
|
||||||
};
|
};
|
||||||
@@ -1559,17 +1560,53 @@ impl Session {
|
|||||||
return Ok(());
|
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
|
let folder = context
|
||||||
.get_config(Config::ConfiguredInboxFolder)
|
.get_config(Config::ConfiguredInboxFolder)
|
||||||
.await?
|
.await?
|
||||||
.context("INBOX is not configured")?;
|
.context("INBOX is not configured")?;
|
||||||
|
|
||||||
self.run_command_and_check_ok(format!(
|
let encrypted_device_token =
|
||||||
"SETMETADATA \"{folder}\" (/private/devicetoken \"{device_token}\")"
|
encrypt_device_token(&device_token).context("Failed to encrypt device token")?;
|
||||||
))
|
|
||||||
.await
|
// We expect that the server supporting `XDELTAPUSH` capability
|
||||||
.context("SETMETADATA command failed")?;
|
// has non-synchronizing literals support as well:
|
||||||
|
// <https://www.rfc-editor.org/rfc/rfc7888>.
|
||||||
|
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);
|
context.push_subscribed.store(true, Ordering::Relaxed);
|
||||||
} else if !context.push_subscriber.heartbeat_subscribed().await {
|
} else if !context.push_subscriber.heartbeat_subscribed().await {
|
||||||
let context = context.clone();
|
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 {
|
impl Session {
|
||||||
/// Returns success if we successfully set the flag or we otherwise
|
/// Returns success if we successfully set the flag or we otherwise
|
||||||
/// think add_flag should not be retried: Disconnection during setting
|
/// 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())]
|
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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/push.rs
109
src/push.rs
@@ -1,10 +1,16 @@
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::Arc;
|
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 tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
|
use crate::key::DcKey;
|
||||||
|
|
||||||
/// Manages subscription to Apple Push Notification services.
|
/// Manages subscription to Apple Push Notification services.
|
||||||
///
|
///
|
||||||
@@ -24,20 +30,85 @@ pub struct PushSubscriber {
|
|||||||
inner: Arc<RwLock<PushSubscriberState>>,
|
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 {
|
impl PushSubscriber {
|
||||||
/// Creates new push notification subscriber.
|
/// Creates new push notification subscriber.
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Default::default()
|
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) {
|
pub(crate) async fn set_device_token(&self, token: &str) {
|
||||||
self.inner.write().await.device_token = Some(token.to_string());
|
self.inner.write().await.device_token = Some(token.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves device token.
|
/// Retrieves device token.
|
||||||
///
|
///
|
||||||
|
/// The token is encrypted with OpenPGP.
|
||||||
|
///
|
||||||
/// Token may be not available if application is not running on Apple platform,
|
/// 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.
|
/// failed to register for remote notifications or is in the process of registering.
|
||||||
///
|
///
|
||||||
/// IMAP loop should periodically check if device token is available
|
/// IMAP loop should periodically check if device token is available
|
||||||
@@ -121,3 +192,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user