mirror of
https://github.com/chatmail/core.git
synced 2026-04-28 19:06:35 +03:00
feat: encrypt notification tokens
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
68
src/imap.rs
68
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:
|
||||
// <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);
|
||||
} 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
109
src/push.rs
109
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,85 @@ 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.
|
||||
/// 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 +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