Two fixes surfaced by run #55: 1. veza-stream-server (47 files): cargo fmt had been run locally but never committed — the working tree was clean locally while HEAD had unformatted code. CI's `cargo fmt -- --check` caught the drift. This commit lands the formatting that was already staged. 2. ci.yml Install Go tools: `go install .../cmd/golangci-lint@latest` resolves to v1.64.8 (the old /cmd/ module path). The repo's .golangci.yml is v2-format, so v1 refuses with: "you are using a configuration file for golangci-lint v2 with golangci-lint v1: please use golangci-lint v2" Switch to the /v2/cmd/ path so @latest actually gets v2.x.
586 lines
18 KiB
Rust
586 lines
18 KiB
Rust
//! 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<Sha256>;
|
|
|
|
/// 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<RwLock<HashMap<String, TokenInfo>>>,
|
|
db_pool: Option<PgPool>,
|
|
}
|
|
|
|
/// Informations sur un token actif
|
|
#[derive(Debug, Clone)]
|
|
pub struct TokenInfo {
|
|
pub track_id: String,
|
|
pub user_id: Option<Uuid>,
|
|
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<Uuid>,
|
|
}
|
|
|
|
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<Uuid>,
|
|
) -> Result<String> {
|
|
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<Uuid>,
|
|
) -> Result<bool> {
|
|
// 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<TokenInfo> {
|
|
// 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<Duration>,
|
|
user_id: Option<Uuid>,
|
|
) -> Result<SecureStreamUrl> {
|
|
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<TokenStats> {
|
|
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<Uuid>,
|
|
) -> Result<bool> {
|
|
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<Uuid>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|