diff --git a/docs/unifiedpush.md b/docs/unifiedpush.md index 877c1fd..6138b3a 100644 --- a/docs/unifiedpush.md +++ b/docs/unifiedpush.md @@ -32,6 +32,11 @@ A `2xx` response is treated as success. Any other response is logged at `error!` The endpoint store is loaded once at startup. Failures to read or parse the file are logged and the service starts with an empty map. +Logs never include raw UnifiedPush `device_id` values or endpoint URL prefixes. +Device IDs are represented by the same salted, truncated BLAKE3 correlator used +for other privacy-sensitive identifiers, with a random in-memory salt per +process. + ## Platform support `UnifiedPushService::supports_platform` returns `true` only for `Platform::Android`. iOS clients are FCM-only. diff --git a/src/push/unifiedpush.rs b/src/push/unifiedpush.rs index cea89ca..643423d 100644 --- a/src/push/unifiedpush.rs +++ b/src/push/unifiedpush.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use log::{debug, error, info, warn}; +use rand::RngCore; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -10,6 +11,7 @@ use tokio::sync::RwLock; use super::PushService; use crate::config::Config; use crate::store::Platform; +use crate::utils::log_pubkey::log_pubkey; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UnifiedPushEndpoint { @@ -26,20 +28,28 @@ pub struct UnifiedPushService { client: Arc, endpoints: RwLock>, storage_path: PathBuf, + log_salt: Arc<[u8; 32]>, } impl UnifiedPushService { pub fn new(config: Config, client: Arc) -> Self { let storage_path = PathBuf::from("data/unifiedpush_endpoints.json"); + let mut salt_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut salt_bytes); Self { config, client, endpoints: RwLock::new(HashMap::new()), storage_path, + log_salt: Arc::new(salt_bytes), } } + fn log_device_id(&self, device_id: &str) -> String { + log_unifiedpush_identifier(self.log_salt.as_ref(), device_id) + } + /// Load endpoints from disk on startup pub async fn load_endpoints(&self) -> Result<(), Box> { // Create data directory if it doesn't exist @@ -107,7 +117,10 @@ impl UnifiedPushService { // Persist to disk self.save_endpoints().await?; - info!("Registered UnifiedPush endpoint for device: {}", device_id); + info!( + "Registered UnifiedPush endpoint device={}", + self.log_device_id(&device_id) + ); Ok(()) } @@ -126,13 +139,17 @@ impl UnifiedPushService { self.save_endpoints().await?; info!( - "Unregistered UnifiedPush endpoint for device: {}", - device_id + "Unregistered UnifiedPush endpoint device={}", + self.log_device_id(device_id) ); Ok(()) } } +fn log_unifiedpush_identifier(salt: &[u8; 32], value: &str) -> String { + log_pubkey(salt, value) +} + #[async_trait] impl PushService for UnifiedPushService { async fn send_to_token( @@ -146,10 +163,7 @@ impl PushService for UnifiedPushService { "timestamp": chrono::Utc::now().timestamp() }); - debug!( - "Sending UnifiedPush to endpoint: {}...", - &device_token[..30.min(device_token.len())] - ); + debug!("Sending UnifiedPush notification"); let response = self.client.post(device_token).json(&payload).send().await?; @@ -169,3 +183,25 @@ impl PushService for UnifiedPushService { matches!(platform, Platform::Android) } } + +#[cfg(test)] +mod tests { + use super::log_unifiedpush_identifier; + + #[test] + fn unifiedpush_log_identifier_is_stable_and_redacted() { + let salt = [7u8; 32]; + let device_id = "device-secret-123"; + + let first = log_unifiedpush_identifier(&salt, device_id); + let second = log_unifiedpush_identifier(&salt, device_id); + + assert_eq!(first, second); + assert_eq!(first.len(), 8); + assert_ne!(first, device_id); + assert!( + !device_id.contains(&first), + "hashed log identifier must not be a raw device_id substring" + ); + } +}