Skip to main content

synapse_pingora/interrogator/
cookie_manager.rs

1//! Cookie Challenge Manager
2//!
3//! Implements silent tracking cookies for actor correlation. The cookie challenge
4//! is the softest form of challenge - it doesn't require any user interaction but
5//! allows correlation of requests from the same actor across sessions.
6//!
7//! # Cookie Format
8//!
9//! The cookie value follows the format: `{timestamp}.{actor_id_hash}.{hmac_signature}`
10//!
11//! - `timestamp`: Unix epoch seconds when cookie was issued
12//! - `actor_id_hash`: SHA256 hash of actor ID (first 16 hex chars)
13//! - `hmac_signature`: HMAC-SHA256 signature over timestamp and actor_id_hash
14//!
15//! # Security Properties
16//!
17//! - Cookies are signed with HMAC-SHA256 to prevent tampering
18//! - Actor ID is hashed to prevent direct exposure
19//! - Timestamps enable expiration checking
20//! - HttpOnly and Secure flags prevent XSS and MITM attacks
21
22use dashmap::DashMap;
23use hmac::{Hmac, Mac};
24use sha2::{Digest, Sha256};
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{SystemTime, UNIX_EPOCH};
27use subtle::ConstantTimeEq;
28use tracing::error;
29
30use super::{ChallengeResponse, Interrogator, ValidationResult};
31
32type HmacSha256 = Hmac<Sha256>;
33
34/// A cookie challenge instance
35#[derive(Debug, Clone)]
36pub struct CookieChallenge {
37    /// Name of the cookie
38    pub cookie_name: String,
39    /// Value of the cookie (timestamp.hash.signature)
40    pub cookie_value: String,
41    /// Actor ID this cookie is for
42    pub actor_id: String,
43    /// When the cookie was created (unix timestamp secs)
44    pub created_at: u64,
45    /// When the cookie expires (unix timestamp secs)
46    pub expires_at: u64,
47}
48
49/// Configuration for cookie challenges
50#[derive(Debug, Clone)]
51pub struct CookieConfig {
52    /// Name of the tracking cookie (default: "__tx_verify")
53    pub cookie_name: String,
54    /// Cookie max age in seconds (default: 86400 = 1 day)
55    pub cookie_max_age_secs: u64,
56    /// HMAC secret key (MUST be provided, 32 bytes)
57    pub secret_key: [u8; 32],
58    /// Only send cookie over HTTPS (default: true)
59    pub secure_only: bool,
60    /// Prevent JavaScript access to cookie (default: true)
61    pub http_only: bool,
62    /// SameSite attribute (default: "Strict")
63    pub same_site: String,
64}
65
66/// Error returned when CookieManager construction fails
67#[derive(Debug, Clone, PartialEq)]
68pub enum CookieError {
69    /// Secret key is all zeros (insecure)
70    InvalidSecretKey,
71}
72
73impl std::fmt::Display for CookieError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            CookieError::InvalidSecretKey => {
77                write!(f, "Secret key must not be all zeros")
78            }
79        }
80    }
81}
82
83impl std::error::Error for CookieError {}
84
85/// Statistics for cookie challenge operations
86#[derive(Debug, Default)]
87pub struct CookieStats {
88    /// Total cookies issued
89    pub cookies_issued: AtomicU64,
90    /// Successfully validated cookies
91    pub cookies_validated: AtomicU64,
92    /// Invalid cookies (bad signature, wrong format)
93    pub cookies_invalid: AtomicU64,
94    /// Expired cookies
95    pub cookies_expired: AtomicU64,
96    /// Actors correlated via cookie
97    pub actors_correlated: AtomicU64,
98}
99
100impl CookieStats {
101    /// Create a snapshot of current stats
102    pub fn snapshot(&self) -> CookieStatsSnapshot {
103        CookieStatsSnapshot {
104            cookies_issued: self.cookies_issued.load(Ordering::Relaxed),
105            cookies_validated: self.cookies_validated.load(Ordering::Relaxed),
106            cookies_invalid: self.cookies_invalid.load(Ordering::Relaxed),
107            cookies_expired: self.cookies_expired.load(Ordering::Relaxed),
108            actors_correlated: self.actors_correlated.load(Ordering::Relaxed),
109        }
110    }
111}
112
113/// Snapshot of cookie stats for serialization
114#[derive(Debug, Clone, serde::Serialize)]
115pub struct CookieStatsSnapshot {
116    pub cookies_issued: u64,
117    pub cookies_validated: u64,
118    pub cookies_invalid: u64,
119    pub cookies_expired: u64,
120    pub actors_correlated: u64,
121}
122
123/// Thread-safe cookie challenge manager
124#[derive(Debug)]
125pub struct CookieManager {
126    /// Active challenges by actor ID
127    challenges: DashMap<String, CookieChallenge>,
128    /// Configuration
129    config: CookieConfig,
130    /// Statistics
131    stats: CookieStats,
132}
133
134impl CookieManager {
135    /// Create a new cookie manager with the given configuration
136    ///
137    /// # Errors
138    ///
139    /// Returns `CookieError::InvalidSecretKey` if the secret key is all zeros.
140    pub fn new(config: CookieConfig) -> Result<Self, CookieError> {
141        // Validate secret key is not all zeros
142        if config.secret_key == [0u8; 32] {
143            return Err(CookieError::InvalidSecretKey);
144        }
145
146        Ok(Self {
147            challenges: DashMap::new(),
148            config,
149            stats: CookieStats::default(),
150        })
151    }
152
153    /// Create a new cookie manager with a best-effort fallback for invalid secrets.
154    ///
155    /// This avoids panicking on invalid keys and logs a warning instead.
156    pub fn new_fallback(mut config: CookieConfig) -> Self {
157        if config.secret_key == [0u8; 32] {
158            error!("CookieManager secret key invalid; forcing non-zero fallback key");
159            config.secret_key[0] = 1;
160        }
161
162        Self {
163            challenges: DashMap::new(),
164            config,
165            stats: CookieStats::default(),
166        }
167    }
168
169    /// Create a new cookie manager without validating the secret key.
170    ///
171    /// # Safety
172    ///
173    /// This should only be used in tests. Using a weak secret key in production
174    /// allows attackers to forge valid cookies.
175    #[cfg(test)]
176    pub fn new_unchecked(config: CookieConfig) -> Self {
177        Self {
178            challenges: DashMap::new(),
179            config,
180            stats: CookieStats::default(),
181        }
182    }
183
184    /// Get the configuration
185    pub fn config(&self) -> &CookieConfig {
186        &self.config
187    }
188
189    /// Generate a tracking cookie for an actor
190    pub fn generate_tracking_cookie(&self, actor_id: &str) -> CookieChallenge {
191        let now = now_secs();
192        let expires_at = now + self.config.cookie_max_age_secs;
193
194        // Hash the actor ID
195        let actor_hash = self.hash_actor_id(actor_id);
196
197        // Create signature over timestamp.actor_hash
198        let data_to_sign = format!("{}.{}", now, actor_hash);
199        let signature = self.sign_data(&data_to_sign);
200
201        // Cookie value: timestamp.actor_hash.signature
202        let cookie_value = format!("{}.{}.{}", now, actor_hash, signature);
203
204        let challenge = CookieChallenge {
205            cookie_name: self.config.cookie_name.clone(),
206            cookie_value,
207            actor_id: actor_id.to_string(),
208            created_at: now,
209            expires_at,
210        };
211
212        // Store challenge for later validation
213        self.challenges
214            .insert(actor_id.to_string(), challenge.clone());
215        self.stats.cookies_issued.fetch_add(1, Ordering::Relaxed);
216
217        challenge
218    }
219
220    /// Validate a cookie value for an actor
221    pub fn validate_cookie(&self, actor_id: &str, cookie_value: &str) -> ValidationResult {
222        // Parse cookie: timestamp.actor_hash.signature
223        let parts: Vec<&str> = cookie_value.split('.').collect();
224        if parts.len() != 3 {
225            self.stats.cookies_invalid.fetch_add(1, Ordering::Relaxed);
226            return ValidationResult::Invalid("Invalid cookie format".to_string());
227        }
228
229        let timestamp: u64 = match parts[0].parse() {
230            Ok(ts) => ts,
231            Err(_) => {
232                self.stats.cookies_invalid.fetch_add(1, Ordering::Relaxed);
233                return ValidationResult::Invalid("Invalid timestamp".to_string());
234            }
235        };
236        let actor_hash = parts[1];
237        let signature = parts[2];
238
239        // Check expiration
240        let now = now_secs();
241        if timestamp + self.config.cookie_max_age_secs < now {
242            self.stats.cookies_expired.fetch_add(1, Ordering::Relaxed);
243            return ValidationResult::Expired;
244        }
245
246        // Verify actor hash matches (constant-time to prevent timing attacks)
247        let expected_hash = self.hash_actor_id(actor_id);
248        if !constant_time_eq(actor_hash.as_bytes(), expected_hash.as_bytes()) {
249            self.stats.cookies_invalid.fetch_add(1, Ordering::Relaxed);
250            return ValidationResult::Invalid("Actor mismatch".to_string());
251        }
252
253        // Verify signature (constant-time to prevent timing attacks)
254        let data_to_verify = format!("{}.{}", timestamp, actor_hash);
255        let expected_sig = self.sign_data(&data_to_verify);
256        if !constant_time_eq(signature.as_bytes(), expected_sig.as_bytes()) {
257            self.stats.cookies_invalid.fetch_add(1, Ordering::Relaxed);
258            return ValidationResult::Invalid("Invalid signature".to_string());
259        }
260
261        self.stats.cookies_validated.fetch_add(1, Ordering::Relaxed);
262        ValidationResult::Valid
263    }
264
265    /// Correlate cookie to actor - extract actor_id from valid cookie
266    ///
267    /// This uses timing-safe comparison to find the actor whose hash matches.
268    /// Note: This is O(n) where n is number of tracked actors. For large scale,
269    /// consider maintaining a reverse lookup table.
270    pub fn correlate_actor(&self, cookie_value: &str) -> Option<String> {
271        // Parse cookie: timestamp.actor_hash.signature
272        let parts: Vec<&str> = cookie_value.split('.').collect();
273        if parts.len() != 3 {
274            return None;
275        }
276
277        let timestamp: u64 = parts[0].parse().ok()?;
278        let actor_hash = parts[1];
279        let signature = parts[2];
280
281        // Check expiration
282        let now = now_secs();
283        if timestamp + self.config.cookie_max_age_secs < now {
284            return None;
285        }
286
287        // Search for actor with matching hash (constant-time comparison)
288        for entry in self.challenges.iter() {
289            let challenge = entry.value();
290            let expected_hash = self.hash_actor_id(&challenge.actor_id);
291
292            if constant_time_eq(actor_hash.as_bytes(), expected_hash.as_bytes()) {
293                // Verify signature (constant-time)
294                let data_to_verify = format!("{}.{}", timestamp, actor_hash);
295                let expected_sig = self.sign_data(&data_to_verify);
296                if constant_time_eq(signature.as_bytes(), expected_sig.as_bytes()) {
297                    self.stats.actors_correlated.fetch_add(1, Ordering::Relaxed);
298                    return Some(challenge.actor_id.clone());
299                }
300            }
301        }
302
303        None
304    }
305
306    /// Get statistics
307    pub fn stats(&self) -> &CookieStats {
308        &self.stats
309    }
310
311    /// Get number of tracked challenges
312    pub fn len(&self) -> usize {
313        self.challenges.len()
314    }
315
316    /// Check if no challenges are tracked
317    pub fn is_empty(&self) -> bool {
318        self.challenges.is_empty()
319    }
320
321    /// Remove expired challenges
322    pub fn cleanup_expired(&self) -> usize {
323        let now = now_secs();
324        let mut removed = 0;
325
326        self.challenges.retain(|_, challenge| {
327            if challenge.expires_at < now {
328                removed += 1;
329                false
330            } else {
331                true
332            }
333        });
334
335        removed
336    }
337
338    /// Clear all challenges
339    pub fn clear(&self) {
340        self.challenges.clear();
341    }
342
343    // --- Private helpers ---
344
345    /// Hash actor ID using SHA256, return first 16 hex chars
346    fn hash_actor_id(&self, actor_id: &str) -> String {
347        let mut hasher = Sha256::new();
348        hasher.update(actor_id.as_bytes());
349        let result = hasher.finalize();
350        hex::encode(&result[..8]) // First 8 bytes = 16 hex chars
351    }
352
353    /// Sign data using HMAC-SHA256, return hex signature
354    fn sign_data(&self, data: &str) -> String {
355        let mut mac = match HmacSha256::new_from_slice(&self.config.secret_key) {
356            Ok(mac) => mac,
357            Err(err) => {
358                error!("Failed to initialize HMAC for cookie signature: {}", err);
359                return String::new();
360            }
361        };
362        mac.update(data.as_bytes());
363        let result = mac.finalize();
364        hex::encode(&result.into_bytes()[..16]) // First 16 bytes = 32 hex chars
365    }
366}
367
368impl Interrogator for CookieManager {
369    fn name(&self) -> &'static str {
370        "cookie"
371    }
372
373    fn challenge_level(&self) -> u8 {
374        1
375    }
376
377    fn generate_challenge(&self, actor_id: &str) -> ChallengeResponse {
378        let challenge = self.generate_tracking_cookie(actor_id);
379        ChallengeResponse::Cookie {
380            name: challenge.cookie_name,
381            value: challenge.cookie_value,
382            max_age: self.config.cookie_max_age_secs,
383            http_only: self.config.http_only,
384            secure: self.config.secure_only,
385        }
386    }
387
388    fn validate_response(&self, actor_id: &str, response: &str) -> ValidationResult {
389        self.validate_cookie(actor_id, response)
390    }
391
392    fn should_escalate(&self, _actor_id: &str) -> bool {
393        // Cookie challenges don't escalate on their own - they are silent
394        false
395    }
396}
397
398/// Get current time in seconds since Unix epoch
399#[inline]
400fn now_secs() -> u64 {
401    SystemTime::now()
402        .duration_since(UNIX_EPOCH)
403        .map(|d| d.as_secs())
404        .unwrap_or(0)
405}
406
407/// Constant-time equality comparison to prevent timing attacks
408#[inline]
409fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
410    if a.len() != b.len() {
411        return false;
412    }
413    a.ct_eq(b).into()
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn test_config() -> CookieConfig {
421        CookieConfig {
422            cookie_name: "__test_cookie".to_string(),
423            cookie_max_age_secs: 3600, // 1 hour
424            secret_key: [
425                0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
426                0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
427                0x1d, 0x1e, 0x1f, 0x20,
428            ],
429            secure_only: true,
430            http_only: true,
431            same_site: "Strict".to_string(),
432        }
433    }
434
435    #[test]
436    fn test_reject_zero_secret_key() {
437        let config = CookieConfig {
438            secret_key: [0u8; 32],
439            ..test_config()
440        };
441        let result = CookieManager::new(config);
442        assert_eq!(result.unwrap_err(), CookieError::InvalidSecretKey);
443    }
444
445    #[test]
446    fn test_cookie_generation() {
447        let manager = CookieManager::new(test_config()).unwrap();
448        let challenge = manager.generate_tracking_cookie("actor_123");
449
450        assert_eq!(challenge.cookie_name, "__test_cookie");
451        assert_eq!(challenge.actor_id, "actor_123");
452        assert!(challenge.expires_at > challenge.created_at);
453
454        // Cookie format: timestamp.hash.signature
455        let parts: Vec<&str> = challenge.cookie_value.split('.').collect();
456        assert_eq!(parts.len(), 3);
457        assert!(parts[0].parse::<u64>().is_ok()); // timestamp
458        assert_eq!(parts[1].len(), 16); // hash (16 hex chars)
459        assert_eq!(parts[2].len(), 32); // signature (32 hex chars)
460    }
461
462    #[test]
463    fn test_cookie_validation_success() {
464        let manager = CookieManager::new(test_config()).unwrap();
465        let challenge = manager.generate_tracking_cookie("actor_123");
466
467        let result = manager.validate_cookie("actor_123", &challenge.cookie_value);
468        assert_eq!(result, ValidationResult::Valid);
469    }
470
471    #[test]
472    fn test_cookie_validation_wrong_actor() {
473        let manager = CookieManager::new(test_config()).unwrap();
474        let challenge = manager.generate_tracking_cookie("actor_123");
475
476        // Try to validate with different actor
477        let result = manager.validate_cookie("actor_456", &challenge.cookie_value);
478        assert!(matches!(result, ValidationResult::Invalid(_)));
479    }
480
481    #[test]
482    fn test_cookie_validation_tampered_signature() {
483        let manager = CookieManager::new(test_config()).unwrap();
484        let challenge = manager.generate_tracking_cookie("actor_123");
485
486        // Tamper with the signature
487        let parts: Vec<&str> = challenge.cookie_value.split('.').collect();
488        let tampered = format!(
489            "{}.{}.{}",
490            parts[0], parts[1], "0000000000000000000000000000000"
491        );
492
493        let result = manager.validate_cookie("actor_123", &tampered);
494        assert!(matches!(result, ValidationResult::Invalid(_)));
495    }
496
497    #[test]
498    fn test_cookie_validation_invalid_format() {
499        let manager = CookieManager::new(test_config()).unwrap();
500
501        let result = manager.validate_cookie("actor_123", "invalid_cookie");
502        assert!(matches!(result, ValidationResult::Invalid(_)));
503
504        let result = manager.validate_cookie("actor_123", "only.two.parts");
505        // This will fail due to signature validation, not format
506        assert!(matches!(result, ValidationResult::Invalid(_)));
507    }
508
509    #[test]
510    fn test_cookie_validation_expired() {
511        let config = CookieConfig {
512            cookie_max_age_secs: 0, // Immediate expiration
513            ..test_config()
514        };
515        let manager = CookieManager::new(config).unwrap();
516        let challenge = manager.generate_tracking_cookie("actor_123");
517
518        // Sleep at least 1 second to ensure expiration (cookies use second precision)
519        std::thread::sleep(std::time::Duration::from_secs(1));
520
521        let result = manager.validate_cookie("actor_123", &challenge.cookie_value);
522        assert_eq!(result, ValidationResult::Expired);
523    }
524
525    #[test]
526    fn test_actor_correlation() {
527        let manager = CookieManager::new(test_config()).unwrap();
528        let challenge = manager.generate_tracking_cookie("actor_123");
529
530        // Should correlate back to original actor
531        let correlated = manager.correlate_actor(&challenge.cookie_value);
532        assert_eq!(correlated, Some("actor_123".to_string()));
533
534        // Invalid cookie should not correlate
535        let correlated = manager.correlate_actor("invalid_cookie");
536        assert_eq!(correlated, None);
537    }
538
539    #[test]
540    fn test_hmac_consistency() {
541        let manager = CookieManager::new(test_config()).unwrap();
542
543        // Same actor should get same hash
544        let hash1 = manager.hash_actor_id("actor_123");
545        let hash2 = manager.hash_actor_id("actor_123");
546        assert_eq!(hash1, hash2);
547
548        // Different actors should get different hashes
549        let hash3 = manager.hash_actor_id("actor_456");
550        assert_ne!(hash1, hash3);
551    }
552
553    #[test]
554    fn test_interrogator_trait() {
555        let manager = CookieManager::new(test_config()).unwrap();
556
557        assert_eq!(manager.name(), "cookie");
558        assert_eq!(manager.challenge_level(), 1);
559        assert!(!manager.should_escalate("actor_123"));
560
561        // Generate challenge via trait
562        let response = manager.generate_challenge("actor_123");
563        match response {
564            ChallengeResponse::Cookie {
565                name,
566                value,
567                max_age,
568                http_only,
569                secure,
570            } => {
571                assert_eq!(name, "__test_cookie");
572                assert!(!value.is_empty());
573                assert_eq!(max_age, 3600);
574                assert!(http_only);
575                assert!(secure);
576            }
577            _ => panic!("Expected Cookie response"),
578        }
579    }
580
581    #[test]
582    fn test_stats_tracking() {
583        let manager = CookieManager::new(test_config()).unwrap();
584
585        // Generate cookies
586        manager.generate_tracking_cookie("actor_1");
587        manager.generate_tracking_cookie("actor_2");
588        let challenge = manager.generate_tracking_cookie("actor_3");
589
590        let stats = manager.stats().snapshot();
591        assert_eq!(stats.cookies_issued, 3);
592
593        // Validate
594        manager.validate_cookie("actor_3", &challenge.cookie_value);
595        let stats = manager.stats().snapshot();
596        assert_eq!(stats.cookies_validated, 1);
597
598        // Invalid
599        manager.validate_cookie("actor_3", "invalid");
600        let stats = manager.stats().snapshot();
601        assert_eq!(stats.cookies_invalid, 1);
602    }
603
604    #[test]
605    fn test_cleanup_expired() {
606        let config = CookieConfig {
607            cookie_max_age_secs: 0, // Immediate expiration
608            ..test_config()
609        };
610        let manager = CookieManager::new(config).unwrap();
611
612        manager.generate_tracking_cookie("actor_1");
613        manager.generate_tracking_cookie("actor_2");
614        assert_eq!(manager.len(), 2);
615
616        // Sleep at least 1 second to ensure expiration (cookies use second precision)
617        std::thread::sleep(std::time::Duration::from_secs(1));
618
619        let removed = manager.cleanup_expired();
620        assert_eq!(removed, 2);
621        assert!(manager.is_empty());
622    }
623
624    #[test]
625    fn test_different_secrets_produce_different_signatures() {
626        let config1 = CookieConfig {
627            secret_key: [0x01; 32],
628            ..test_config()
629        };
630        let config2 = CookieConfig {
631            secret_key: [0x02; 32],
632            ..test_config()
633        };
634
635        let manager1 = CookieManager::new(config1).unwrap();
636        let manager2 = CookieManager::new(config2).unwrap();
637
638        let challenge1 = manager1.generate_tracking_cookie("actor_123");
639        let challenge2 = manager2.generate_tracking_cookie("actor_123");
640
641        // Signatures should differ
642        let parts1: Vec<&str> = challenge1.cookie_value.split('.').collect();
643        let parts2: Vec<&str> = challenge2.cookie_value.split('.').collect();
644
645        assert_eq!(parts1[1], parts2[1]); // Hash should be same
646        assert_ne!(parts1[2], parts2[2]); // Signature should differ
647    }
648}