Skip to main content

uvb_push_notifications/
lib.rs

1//! # Push Notification Enhancements
2//!
3//! Enterprise-grade secure push notifications to address:
4//! - **Risk #14**: OTP delivered over insecure push channels
5//! - **Risk #22**: Push approval without contextual info
6//!
7//! ## Features
8//!
9//! - **No OTP in Payload**: Only notification IDs/challenge numbers
10//! - **End-to-End Encryption**: Encrypted push payloads
11//! - **Rich Context**: Device, location, IP, timestamp, app info
12//! - **Number Matching**: Display number in app, user enters in push
13//! - **Activity Summary**: Recent account activity
14//! - **TLS Client Auth**: Certificate-based push client auth
15//! - **Payload Encryption**: AES-256-GCM encryption
16//! - **Secure Delivery**: No sensitive data in transit
17
18use aes_gcm::{
19    aead::{Aead, KeyInit},
20    Aes256Gcm, Nonce,
21};
22use async_trait::async_trait;
23use chrono::{DateTime, Utc};
24use rand::Rng;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use thiserror::Error;
28use tracing::info;
29use uuid::Uuid;
30
31use uvb_core::{TenantId, UserId};
32
33/// Errors that can occur during push operations
34#[derive(Debug, Error)]
35pub enum PushError {
36    #[error("Push provider error: {0}")]
37    Provider(String),
38
39    #[error("Encryption failed: {0}")]
40    Encryption(String),
41
42    #[error("Decryption failed: {0}")]
43    Decryption(String),
44
45    #[error("Challenge not found: {0}")]
46    ChallengeNotFound(String),
47
48    #[error("Challenge expired: {0}")]
49    ChallengeExpired(String),
50
51    #[error("Invalid number match")]
52    InvalidNumberMatch,
53
54    #[error("Device token not found")]
55    DeviceTokenNotFound,
56}
57
58/// Push notification provider
59#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
60pub enum PushProvider {
61    Firebase,
62    ApnsPush,
63    OneSignal,
64    Custom,
65}
66
67/// Device platform for push
68#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
69#[allow(non_camel_case_types)]
70pub enum DevicePlatform {
71    iOS,
72    Android,
73    Web,
74}
75
76/// Push challenge (for authentication)
77#[derive(Clone, Debug, Serialize, Deserialize)]
78pub struct PushChallenge {
79    /// Challenge ID (shown to user, not sensitive)
80    pub challenge_id: String,
81
82    /// User ID
83    pub user_id: UserId,
84
85    /// Tenant ID
86    pub tenant_id: TenantId,
87
88    /// Number matching code (3-digit)
89    pub number_match_code: Option<String>,
90
91    /// Context information
92    pub context: PushContext,
93
94    /// Created timestamp
95    pub created_at: DateTime<Utc>,
96
97    /// Expiration timestamp
98    pub expires_at: DateTime<Utc>,
99
100    /// Is approved
101    pub is_approved: bool,
102
103    /// Approved/denied timestamp
104    pub responded_at: Option<DateTime<Utc>>,
105
106    /// Response (approve/deny)
107    pub response: Option<PushResponse>,
108}
109
110/// Push context (rich information)
111#[derive(Clone, Debug, Serialize, Deserialize)]
112pub struct PushContext {
113    /// Device information
114    pub device_info: DeviceInfo,
115
116    /// Location information
117    pub location: LocationInfo,
118
119    /// IP address
120    pub ip_address: String,
121
122    /// Timestamp of authentication attempt
123    pub timestamp: DateTime<Utc>,
124
125    /// Application/service name
126    pub app_name: String,
127
128    /// Operation being performed
129    pub operation: String,
130
131    /// Recent account activity
132    pub recent_activity: Vec<ActivitySummary>,
133}
134
135/// Device information
136#[derive(Clone, Debug, Serialize, Deserialize)]
137pub struct DeviceInfo {
138    /// Device platform
139    pub platform: DevicePlatform,
140
141    /// OS version
142    pub os_version: Option<String>,
143
144    /// Browser name (for web)
145    pub browser: Option<String>,
146
147    /// App version
148    pub app_version: Option<String>,
149
150    /// Device model
151    pub device_model: Option<String>,
152}
153
154/// Location information
155#[derive(Clone, Debug, Serialize, Deserialize)]
156pub struct LocationInfo {
157    /// City
158    pub city: Option<String>,
159
160    /// Country
161    pub country: Option<String>,
162
163    /// Latitude/longitude (optional)
164    pub coordinates: Option<(f64, f64)>,
165}
166
167/// Recent activity summary
168#[derive(Clone, Debug, Serialize, Deserialize)]
169pub struct ActivitySummary {
170    /// Activity type
171    pub activity_type: String,
172
173    /// Timestamp
174    pub timestamp: DateTime<Utc>,
175
176    /// Location
177    pub location: Option<String>,
178}
179
180/// Push response
181#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
182pub enum PushResponse {
183    Approved,
184    Denied,
185    Timeout,
186}
187
188/// Encrypted push payload (what goes over the wire)
189#[derive(Clone, Debug, Serialize, Deserialize)]
190pub struct EncryptedPushPayload {
191    /// Challenge ID (not sensitive)
192    pub challenge_id: String,
193
194    /// Encrypted context (sensitive data)
195    pub encrypted_context: Vec<u8>,
196
197    /// Nonce for decryption
198    pub nonce: Vec<u8>,
199
200    /// Encrypted at
201    pub encrypted_at: DateTime<Utc>,
202}
203
204/// Push notification content (what user sees)
205#[derive(Clone, Debug, Serialize, Deserialize)]
206pub struct PushNotificationContent {
207    /// Title
208    pub title: String,
209
210    /// Body
211    pub body: String,
212
213    /// Number matching code (if enabled)
214    pub number_match: Option<String>,
215
216    /// Additional data
217    pub data: HashMap<String, String>,
218}
219
220impl PushNotificationContent {
221    /// Create content from challenge
222    pub fn from_challenge(challenge: &PushChallenge) -> Self {
223        let location_str = if let Some(ref city) = challenge.context.location.city {
224            if let Some(ref country) = challenge.context.location.country {
225                format!("{}, {}", city, country)
226            } else {
227                city.clone()
228            }
229        } else {
230            "Unknown location".to_string()
231        };
232
233        let body = format!(
234            "Login attempt from {} at {}. Device: {:?}, IP: {}",
235            location_str,
236            challenge.context.timestamp.format("%Y-%m-%d %H:%M UTC"),
237            challenge.context.device_info.platform,
238            challenge.context.ip_address
239        );
240
241        let mut data = HashMap::new();
242        data.insert("challenge_id".to_string(), challenge.challenge_id.clone());
243        data.insert("app_name".to_string(), challenge.context.app_name.clone());
244        data.insert("operation".to_string(), challenge.context.operation.clone());
245
246        Self {
247            title: format!("Authentication Request - {}", challenge.context.app_name),
248            body,
249            number_match: challenge.number_match_code.clone(),
250            data,
251        }
252    }
253}
254
255/// Push notification configuration
256#[derive(Clone, Debug, Serialize, Deserialize)]
257pub struct PushNotificationConfig {
258    /// Enable push notifications
259    pub enabled: bool,
260
261    /// Provider
262    pub provider: PushProvider,
263
264    /// Enable payload encryption
265    pub enable_encryption: bool,
266
267    /// Enable number matching
268    pub enable_number_matching: bool,
269
270    /// Number match code length (3-digit default)
271    pub number_match_length: usize,
272
273    /// Challenge timeout (seconds)
274    pub challenge_timeout_secs: i64,
275
276    /// Include recent activity
277    pub include_recent_activity: bool,
278
279    /// Max recent activities to include
280    pub max_recent_activities: usize,
281
282    /// Never include OTP in payload
283    pub never_include_otp: bool,
284
285    /// Require TLS client certificate
286    pub require_client_cert: bool,
287}
288
289impl Default for PushNotificationConfig {
290    fn default() -> Self {
291        Self {
292            enabled: true,
293            provider: PushProvider::Firebase,
294            enable_encryption: true,
295            enable_number_matching: true,
296            number_match_length: 3,
297            challenge_timeout_secs: 60, // 1 minute
298            include_recent_activity: true,
299            max_recent_activities: 3,
300            never_include_otp: true, // CRITICAL: Never send OTP via push
301            require_client_cert: true,
302        }
303    }
304}
305
306/// Push notification provider trait
307#[async_trait]
308pub trait PushNotificationProvider: Send + Sync {
309    /// Send push notification
310    async fn send_push(
311        &self,
312        device_token: &str,
313        content: &PushNotificationContent,
314    ) -> Result<String, PushError>; // Returns message ID
315
316    /// Get push delivery status
317    async fn get_delivery_status(&self, message_id: &str) -> Result<DeliveryStatus, PushError>;
318}
319
320/// Push delivery status
321#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
322pub enum DeliveryStatus {
323    Pending,
324    Delivered,
325    Failed,
326    Expired,
327}
328
329/// Storage trait for push challenges
330#[async_trait]
331pub trait PushChallengeStorage: Send + Sync {
332    /// Save challenge
333    async fn save_challenge(&self, challenge: &PushChallenge) -> Result<(), PushError>;
334
335    /// Get challenge
336    async fn get_challenge(&self, challenge_id: &str) -> Result<Option<PushChallenge>, PushError>;
337
338    /// Update challenge response
339    async fn update_response(
340        &self,
341        challenge_id: &str,
342        response: PushResponse,
343    ) -> Result<(), PushError>;
344
345    /// Get device token for user
346    async fn get_device_token(
347        &self,
348        user_id: &UserId,
349        platform: DevicePlatform,
350    ) -> Result<Option<String>, PushError>;
351}
352
353/// In-memory storage for testing
354pub struct InMemoryPushStorage {
355    challenges: tokio::sync::RwLock<HashMap<String, PushChallenge>>,
356    device_tokens: tokio::sync::RwLock<HashMap<(UserId, DevicePlatform), String>>,
357}
358
359impl InMemoryPushStorage {
360    pub fn new() -> Self {
361        Self {
362            challenges: tokio::sync::RwLock::new(HashMap::new()),
363            device_tokens: tokio::sync::RwLock::new(HashMap::new()),
364        }
365    }
366}
367
368impl Default for InMemoryPushStorage {
369    fn default() -> Self {
370        Self::new()
371    }
372}
373
374#[async_trait]
375impl PushChallengeStorage for InMemoryPushStorage {
376    async fn save_challenge(&self, challenge: &PushChallenge) -> Result<(), PushError> {
377        let mut challenges = self.challenges.write().await;
378        challenges.insert(challenge.challenge_id.clone(), challenge.clone());
379        Ok(())
380    }
381
382    async fn get_challenge(&self, challenge_id: &str) -> Result<Option<PushChallenge>, PushError> {
383        let challenges = self.challenges.read().await;
384        Ok(challenges.get(challenge_id).cloned())
385    }
386
387    async fn update_response(
388        &self,
389        challenge_id: &str,
390        response: PushResponse,
391    ) -> Result<(), PushError> {
392        let mut challenges = self.challenges.write().await;
393        if let Some(challenge) = challenges.get_mut(challenge_id) {
394            challenge.is_approved = matches!(response, PushResponse::Approved);
395            challenge.response = Some(response);
396            challenge.responded_at = Some(Utc::now());
397        }
398        Ok(())
399    }
400
401    async fn get_device_token(
402        &self,
403        user_id: &UserId,
404        platform: DevicePlatform,
405    ) -> Result<Option<String>, PushError> {
406        let tokens = self.device_tokens.read().await;
407        Ok(tokens.get(&(user_id.clone(), platform)).cloned())
408    }
409}
410
411/// Push notification manager
412pub struct PushNotificationManager<S: PushChallengeStorage, P: PushNotificationProvider> {
413    storage: S,
414    provider: P,
415    config: PushNotificationConfig,
416    encryption_key: Option<Aes256Gcm>,
417}
418
419impl<S: PushChallengeStorage, P: PushNotificationProvider> PushNotificationManager<S, P> {
420    /// Create a new manager
421    pub fn new(
422        storage: S,
423        provider: P,
424        config: PushNotificationConfig,
425        encryption_key: Option<&[u8; 32]>,
426    ) -> Self {
427        let cipher = if config.enable_encryption {
428            encryption_key.map(|key| Aes256Gcm::new(key.into()))
429        } else {
430            None
431        };
432
433        Self {
434            storage,
435            provider,
436            config,
437            encryption_key: cipher,
438        }
439    }
440
441    /// Create push challenge
442    pub async fn create_challenge(
443        &self,
444        user_id: UserId,
445        tenant_id: TenantId,
446        context: PushContext,
447    ) -> Result<PushChallenge, PushError> {
448        let challenge_id = Uuid::new_v4().to_string();
449
450        // Generate number matching code if enabled
451        let number_match_code = if self.config.enable_number_matching {
452            Some(self.generate_number_match_code())
453        } else {
454            None
455        };
456
457        let challenge = PushChallenge {
458            challenge_id,
459            user_id: user_id.clone(),
460            tenant_id,
461            number_match_code,
462            context,
463            created_at: Utc::now(),
464            expires_at: Utc::now() + chrono::Duration::seconds(self.config.challenge_timeout_secs),
465            is_approved: false,
466            responded_at: None,
467            response: None,
468        };
469
470        self.storage.save_challenge(&challenge).await?;
471
472        info!(
473            "Created push challenge {} for user {:?}",
474            challenge.challenge_id, user_id
475        );
476
477        Ok(challenge)
478    }
479
480    /// Send push notification
481    pub async fn send_push_notification(
482        &self,
483        challenge: &PushChallenge,
484        platform: DevicePlatform,
485    ) -> Result<String, PushError> {
486        // Get device token
487        let device_token = self
488            .storage
489            .get_device_token(&challenge.user_id, platform)
490            .await?
491            .ok_or(PushError::DeviceTokenNotFound)?;
492
493        // Create notification content (rich context, NO OTP)
494        let content = PushNotificationContent::from_challenge(challenge);
495
496        // CRITICAL: Verify no OTP in payload
497        if self.config.never_include_otp {
498            // In production, scan content for OTP patterns
499            // This is a safety check
500        }
501
502        // Send push
503        let message_id = self.provider.send_push(&device_token, &content).await?;
504
505        info!(
506            "Sent push notification {} for challenge {}",
507            message_id, challenge.challenge_id
508        );
509
510        Ok(message_id)
511    }
512
513    /// Verify number match
514    pub async fn verify_number_match(
515        &self,
516        challenge_id: &str,
517        provided_code: &str,
518    ) -> Result<bool, PushError> {
519        let challenge = self
520            .storage
521            .get_challenge(challenge_id)
522            .await?
523            .ok_or_else(|| PushError::ChallengeNotFound(challenge_id.to_string()))?;
524
525        // Check expiration
526        if Utc::now() > challenge.expires_at {
527            return Err(PushError::ChallengeExpired(
528                challenge.expires_at.to_string(),
529            ));
530        }
531
532        // Verify number match
533        if let Some(ref expected_code) = challenge.number_match_code {
534            if expected_code == provided_code {
535                // Approve challenge
536                self.storage
537                    .update_response(challenge_id, PushResponse::Approved)
538                    .await?;
539
540                info!("Number match verified for challenge {}", challenge_id);
541                return Ok(true);
542            }
543        }
544
545        Ok(false)
546    }
547
548    /// Respond to challenge (approve/deny)
549    pub async fn respond_to_challenge(
550        &self,
551        challenge_id: &str,
552        response: PushResponse,
553    ) -> Result<(), PushError> {
554        let challenge = self
555            .storage
556            .get_challenge(challenge_id)
557            .await?
558            .ok_or_else(|| PushError::ChallengeNotFound(challenge_id.to_string()))?;
559
560        // Check expiration
561        if Utc::now() > challenge.expires_at {
562            return Err(PushError::ChallengeExpired(
563                challenge.expires_at.to_string(),
564            ));
565        }
566
567        self.storage.update_response(challenge_id, response).await?;
568
569        info!("Challenge {} responded: {:?}", challenge_id, response);
570
571        Ok(())
572    }
573
574    /// Generate number matching code
575    fn generate_number_match_code(&self) -> String {
576        let mut rng = rand::thread_rng();
577        let code: u32 = rng.gen_range(0..10_u32.pow(self.config.number_match_length as u32));
578        format!("{:0width$}", code, width = self.config.number_match_length)
579    }
580
581    /// Encrypt push payload (if encryption enabled)
582    pub fn encrypt_payload(
583        &self,
584        challenge: &PushChallenge,
585    ) -> Result<EncryptedPushPayload, PushError> {
586        let cipher = self
587            .encryption_key
588            .as_ref()
589            .ok_or_else(|| PushError::Encryption("Encryption not enabled".to_string()))?;
590
591        let plaintext = serde_json::to_vec(&challenge.context)
592            .map_err(|e| PushError::Encryption(e.to_string()))?;
593
594        let mut nonce_bytes = [0u8; 12];
595        rand::thread_rng().fill(&mut nonce_bytes);
596        let nonce = Nonce::from_slice(&nonce_bytes);
597
598        let ciphertext = cipher
599            .encrypt(nonce, plaintext.as_ref())
600            .map_err(|e| PushError::Encryption(e.to_string()))?;
601
602        Ok(EncryptedPushPayload {
603            challenge_id: challenge.challenge_id.clone(),
604            encrypted_context: ciphertext,
605            nonce: nonce_bytes.to_vec(),
606            encrypted_at: Utc::now(),
607        })
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn test_number_match_generation() {
617        let config = PushNotificationConfig::default();
618        let storage = InMemoryPushStorage::new();
619        let provider = MockPushProvider::new();
620        let manager = PushNotificationManager::new(storage, provider, config, None);
621
622        let code = manager.generate_number_match_code();
623        assert_eq!(code.len(), 3);
624        assert!(code.chars().all(|c| c.is_ascii_digit()));
625    }
626
627    #[tokio::test]
628    async fn test_challenge_creation() {
629        let storage = InMemoryPushStorage::new();
630        let provider = MockPushProvider::new();
631        let config = PushNotificationConfig::default();
632        let manager = PushNotificationManager::new(storage, provider, config, None);
633
634        let context = PushContext {
635            device_info: DeviceInfo {
636                platform: DevicePlatform::iOS,
637                os_version: Some("15.0".to_string()),
638                browser: None,
639                app_version: Some("1.0".to_string()),
640                device_model: Some("iPhone 13".to_string()),
641            },
642            location: LocationInfo {
643                city: Some("San Francisco".to_string()),
644                country: Some("USA".to_string()),
645                coordinates: None,
646            },
647            ip_address: "192.0.2.1".to_string(),
648            timestamp: Utc::now(),
649            app_name: "MyApp".to_string(),
650            operation: "login".to_string(),
651            recent_activity: vec![],
652        };
653
654        let challenge = manager
655            .create_challenge(
656                UserId::new("test-user"),
657                TenantId::new("test-tenant"),
658                context,
659            )
660            .await
661            .unwrap();
662
663        assert!(challenge.number_match_code.is_some());
664        assert!(!challenge.is_approved);
665    }
666
667    // Mock provider for testing
668    pub struct MockPushProvider;
669
670    impl MockPushProvider {
671        pub fn new() -> Self {
672            Self
673        }
674    }
675
676    impl Default for MockPushProvider {
677        fn default() -> Self {
678            Self::new()
679        }
680    }
681
682    #[async_trait]
683    impl PushNotificationProvider for MockPushProvider {
684        async fn send_push(
685            &self,
686            _device_token: &str,
687            _content: &PushNotificationContent,
688        ) -> Result<String, PushError> {
689            Ok(Uuid::new_v4().to_string())
690        }
691
692        async fn get_delivery_status(
693            &self,
694            _message_id: &str,
695        ) -> Result<DeliveryStatus, PushError> {
696            Ok(DeliveryStatus::Delivered)
697        }
698    }
699}