//! Module de validation des signatures pour le streaming //! //! Ce module implémente la validation HMAC-SHA256 des URLs de streaming //! pour sécuriser l'accès aux fichiers audio. use crate::error::{AppError, Result}; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use sqlx::PgPool; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; use uuid::Uuid; type HmacSha256 = Hmac; /// Configuration de sécurité pour les signatures #[derive(Debug, Clone)] pub struct SignatureConfig { pub secret_key: String, pub default_ttl: Duration, pub max_ttl: Duration, } /// Gestionnaire de validation des signatures pub struct TokenValidator { config: SignatureConfig, active_tokens: Arc>>, db_pool: Option, } /// Informations sur un token actif #[derive(Debug, Clone)] pub struct TokenInfo { pub track_id: String, pub user_id: Option, pub created_at: SystemTime, pub expires_at: SystemTime, pub access_count: u32, } /// Paramètres de requête de streaming #[derive(Debug, Deserialize)] pub struct StreamRequest { pub track_id: String, pub expires: u64, pub sig: String, pub user_id: Option, } impl TokenValidator { /// Crée un nouveau validateur de tokens pub fn new(config: SignatureConfig) -> Self { Self { config, active_tokens: Arc::new(RwLock::new(HashMap::new())), db_pool: None, } } /// Crée un nouveau validateur de tokens avec accès à la base de données pub fn with_db_pool(config: SignatureConfig, db_pool: PgPool) -> Self { Self { config, active_tokens: Arc::new(RwLock::new(HashMap::new())), db_pool: Some(db_pool), } } /// Génère une signature HMAC-SHA256 pour une URL de streaming pub fn generate_signature( &self, track_id: &str, expires: u64, user_id: Option, ) -> Result { let mut mac = HmacSha256::new_from_slice(self.config.secret_key.as_bytes()).map_err(|e| { AppError::SignatureError { message: format!("HMAC creation failed: {}", e), } })?; // Construire le message à signer let message = format!( "{}:{}:{}", track_id, expires, user_id.map(|u| u.to_string()).unwrap_or_default() ); mac.update(message.as_bytes()); let result = mac.finalize(); Ok(hex::encode(result.into_bytes())) } /// Valide une signature de streaming pub fn validate_signature( &self, track_id: &str, expires: u64, signature: &str, user_id: Option, ) -> Result { // Vérifier l'expiration let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| AppError::SignatureError { message: format!("Time error: {}", e), })? .as_secs(); if expires < now { return Err(AppError::SignatureError { message: "Token expired".to_string(), }); } // Générer la signature attendue let expected_signature = self.generate_signature(track_id, expires, user_id)?; // Comparaison constante du temps pour éviter les attaques par timing use subtle::ConstantTimeEq; let signature_bytes = match hex::decode(signature) { Ok(bytes) => bytes, Err(_) => return Ok(false), }; let expected_bytes = hex::decode(&expected_signature).map_err(|e| AppError::SignatureError { message: format!("Invalid hex expected: {}", e), })?; Ok(signature_bytes.ct_eq(&expected_bytes).into()) } /// Valide et enregistre un token de streaming. /// SECURITY(REM-012): Enforces single-use tokens with max_access_count to prevent replay attacks. pub async fn validate_and_register_token(&self, request: &StreamRequest) -> Result { // Valider la signature if !self.validate_signature( &request.track_id, request.expires, &request.sig, request.user_id, )? { return Err(AppError::SignatureError { message: "Invalid signature".to_string(), }); } // SECURITY(REM-012): Include user_id in token key to prevent cross-user replay let uid_str = request.user_id.map(|u| u.to_string()).unwrap_or_default(); let token_key = format!("{}:{}:{}", request.track_id, request.expires, uid_str); let mut active_tokens = self.active_tokens.write().await; // Check if token was already consumed (replay detection) if let Some(existing) = active_tokens.get(&token_key) { if existing.access_count >= Self::MAX_TOKEN_ACCESS { return Err(AppError::SignatureError { message: "Token already consumed (replay detected)".to_string(), }); } } // Créer les informations du token let token_info = TokenInfo { track_id: request.track_id.clone(), user_id: request.user_id, created_at: SystemTime::now(), expires_at: SystemTime::UNIX_EPOCH + Duration::from_secs(request.expires), access_count: 0, }; // Enregistrer le token actif active_tokens.insert(token_key, token_info.clone()); Ok(token_info) } /// Maximum number of times a single token can be used before being rejected. const MAX_TOKEN_ACCESS: u32 = 3; /// Enregistre l'accès à un token. /// SECURITY(REM-012): Enforces max access count to prevent stream URL replay. pub async fn record_token_access(&self, track_id: &str, expires: u64) -> Result<()> { let token_key = format!("{}:{}:", track_id, expires); // empty user_id for backward compat let mut active_tokens = self.active_tokens.write().await; if let Some(token_info) = active_tokens.get_mut(&token_key) { token_info.access_count += 1; if token_info.access_count > Self::MAX_TOKEN_ACCESS { return Err(AppError::SignatureError { message: "Token access limit exceeded".to_string(), }); } } Ok(()) } /// Nettoie les tokens expirés pub async fn cleanup_expired_tokens(&self) -> Result<()> { let now = SystemTime::now(); let mut active_tokens = self.active_tokens.write().await; active_tokens.retain(|_, token_info| token_info.expires_at > now); Ok(()) } /// Génère une URL de streaming sécurisée pub fn generate_secure_url( &self, track_id: &str, ttl: Option, user_id: Option, ) -> Result { let ttl = ttl.unwrap_or(self.config.default_ttl); // Limiter la TTL maximale let ttl = if ttl > self.config.max_ttl { self.config.max_ttl } else { ttl }; let expires = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| AppError::SignatureError { message: format!("Time error: {}", e), })? .as_secs() + ttl.as_secs(); let signature = self.generate_signature(track_id, expires, user_id)?; Ok(SecureStreamUrl { track_id: track_id.to_string(), expires, signature, user_id, }) } /// Obtient les statistiques des tokens pub async fn get_token_stats(&self) -> Result { let active_tokens = self.active_tokens.read().await; let total_tokens = active_tokens.len(); let now = SystemTime::now(); let active_last_hour = active_tokens .values() .filter(|token| { now.duration_since(token.created_at) .map(|d| d.as_secs() < 3600) .unwrap_or(false) }) .count(); let total_accesses: u32 = active_tokens.values().map(|token| token.access_count).sum(); Ok(TokenStats { total_tokens, active_last_hour, total_accesses, }) } /// Valide les permissions d'accès à un track en interrogeant la base de données. /// /// Règles d'accès : /// - Le track doit exister /// - Le track public (`is_public = true`) est accessible à tous /// - Le propriétaire du track y a toujours accès /// - Un utilisateur ayant acheté le track (`orders` avec `status = 'completed'`) y a accès /// - Sinon, accès refusé pub async fn validate_track_access( &self, track_id: &str, user_id: Option, ) -> Result { if track_id.is_empty() { return Ok(false); } let pool = match &self.db_pool { Some(pool) => pool, None => { tracing::warn!( "No DB pool configured for track access validation — denying access by default" ); return Ok(false); } }; let track_uuid = Uuid::parse_str(track_id).map_err(|_| AppError::SignatureError { message: format!("Invalid track ID format: {}", track_id), })?; // Check if the track exists and whether it is public let track_row = sqlx::query_as::<_, (bool, Uuid)>( "SELECT is_public, user_id FROM tracks WHERE id = $1", ) .bind(track_uuid) .fetch_optional(pool) .await .map_err(|e| AppError::SignatureError { message: format!("Database error checking track: {}", e), })?; let (is_public, owner_id) = match track_row { Some(row) => row, None => return Ok(false), // Track does not exist }; // Public tracks are accessible to everyone if is_public { return Ok(true); } // Private track — require a user let uid = match user_id { Some(uid) => uid, None => return Ok(false), // Anonymous access to private track denied }; // Owner always has access if uid == owner_id { return Ok(true); } // Check if the user purchased this track let purchased = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = $1 AND track_id = $2 AND status = 'completed')" ) .bind(uid) .bind(track_uuid) .fetch_one(pool) .await .map_err(|e| AppError::SignatureError { message: format!("Database error checking purchase: {}", e), })?; Ok(purchased) } } /// URL de streaming sécurisée #[derive(Debug, Serialize)] pub struct SecureStreamUrl { pub track_id: String, pub expires: u64, pub signature: String, pub user_id: Option, } impl SecureStreamUrl { /// Construit l'URL complète de streaming pub fn build_url(&self, base_url: &str) -> String { let mut url = format!("{}/stream/{}", base_url, self.track_id); url.push_str(&format!("?expires={}&sig={}", self.expires, self.signature)); if let Some(user_id) = self.user_id { url.push_str(&format!("&user_id={}", user_id)); } url } } /// Statistiques des tokens #[derive(Debug, Serialize)] pub struct TokenStats { pub total_tokens: usize, pub active_last_hour: usize, pub total_accesses: u32, } impl Default for TokenValidator { fn default() -> Self { // SECURITY: Default impl ne doit être utilisé QUE pour les tests // En production, utilisez TokenValidator::with_db_pool() avec require_env_min_length("SECRET_KEY", 32) #[cfg(not(any(test, debug_assertions)))] { panic!("Default TokenValidator should not be used in production"); } #[cfg(any(test, debug_assertions))] { Self { config: SignatureConfig { secret_key: "test_secret_key_minimum_32_characters_long".to_string(), default_ttl: Duration::from_secs(3600), // 1 heure max_ttl: Duration::from_secs(86400), // 24 heures }, active_tokens: Arc::new(RwLock::new(HashMap::new())), db_pool: None, } } } } #[cfg(test)] mod tests { use super::*; use std::time::Duration; #[test] fn test_signature_generation_and_validation() { let validator = TokenValidator::new(SignatureConfig { secret_key: "test_secret".to_string(), default_ttl: Duration::from_secs(3600), max_ttl: Duration::from_secs(86400), }); let track_id = "test_track_123"; let expires = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() + 3600; let user_id = Some(Uuid::new_v4()); // Générer la signature let signature = validator .generate_signature(track_id, expires, user_id) .unwrap(); // Valider la signature let is_valid = validator .validate_signature(track_id, expires, &signature, user_id) .unwrap(); assert!(is_valid); // Test avec une signature invalide let invalid_signature = "invalid_signature"; let is_invalid = validator .validate_signature(track_id, expires, invalid_signature, user_id) .unwrap(); assert!(!is_invalid); } #[test] fn test_expired_token() { let validator = TokenValidator::new(SignatureConfig { secret_key: "test_secret".to_string(), default_ttl: Duration::from_secs(3600), max_ttl: Duration::from_secs(86400), }); let track_id = "test_track_123"; let expired_time = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() - 3600; // Expiré il y a 1 heure let signature = validator .generate_signature(track_id, expired_time, None) .unwrap(); let result = validator.validate_signature(track_id, expired_time, &signature, None); assert!(result.is_err()); } #[tokio::test] async fn test_token_registration() { let validator = TokenValidator::new(SignatureConfig { secret_key: "test_secret".to_string(), default_ttl: Duration::from_secs(3600), max_ttl: Duration::from_secs(86400), }); let expires = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() + 3600; let request = StreamRequest { track_id: "test_track_123".to_string(), expires, sig: validator .generate_signature("test_track_123", expires, None) .unwrap(), user_id: None, }; let token_info = validator .validate_and_register_token(&request) .await .unwrap(); assert_eq!(token_info.track_id, "test_track_123"); assert_eq!(token_info.access_count, 0); } #[test] fn test_generate_secure_url() { let validator = TokenValidator::new(SignatureConfig { secret_key: "test_secret".to_string(), default_ttl: Duration::from_secs(3600), max_ttl: Duration::from_secs(86400), }); let url = validator .generate_secure_url("track-456", None, Some(Uuid::new_v4())) .unwrap(); assert_eq!(url.track_id, "track-456"); assert!(url.expires > 0); assert!(!url.signature.is_empty()); assert!(url.user_id.is_some()); } #[test] fn test_secure_stream_url_build_url() { let validator = TokenValidator::new(SignatureConfig { secret_key: "test_secret".to_string(), default_ttl: Duration::from_secs(3600), max_ttl: Duration::from_secs(86400), }); let url = validator .generate_secure_url("track-789", None, None) .unwrap(); let full_url = url.build_url("https://stream.example.com"); assert!(full_url.starts_with("https://stream.example.com/stream/track-789")); assert!(full_url.contains("expires=")); assert!(full_url.contains("sig=")); } #[test] fn test_validate_signature_invalid_hex() { let validator = TokenValidator::new(SignatureConfig { secret_key: "test_secret".to_string(), default_ttl: Duration::from_secs(3600), max_ttl: Duration::from_secs(86400), }); let expires = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() + 3600; let result = validator.validate_signature("track", expires, "not-valid-hex!!", None); assert!(result.is_ok()); assert!(!result.unwrap()); } #[tokio::test] async fn test_get_token_stats() { let validator = TokenValidator::new(SignatureConfig { secret_key: "test_secret".to_string(), default_ttl: Duration::from_secs(3600), max_ttl: Duration::from_secs(86400), }); let stats = validator.get_token_stats().await.unwrap(); assert_eq!(stats.total_tokens, 0); assert_eq!(stats.active_last_hour, 0); assert_eq!(stats.total_accesses, 0); let expires = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() + 3600; let request = StreamRequest { track_id: "stats_test".to_string(), expires, sig: validator .generate_signature("stats_test", expires, None) .unwrap(), user_id: None, }; validator .validate_and_register_token(&request) .await .unwrap(); let stats2 = validator.get_token_stats().await.unwrap(); assert_eq!(stats2.total_tokens, 1); } }