1use 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#[derive(Debug, Clone)]
36pub struct CookieChallenge {
37 pub cookie_name: String,
39 pub cookie_value: String,
41 pub actor_id: String,
43 pub created_at: u64,
45 pub expires_at: u64,
47}
48
49#[derive(Debug, Clone)]
51pub struct CookieConfig {
52 pub cookie_name: String,
54 pub cookie_max_age_secs: u64,
56 pub secret_key: [u8; 32],
58 pub secure_only: bool,
60 pub http_only: bool,
62 pub same_site: String,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub enum CookieError {
69 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#[derive(Debug, Default)]
87pub struct CookieStats {
88 pub cookies_issued: AtomicU64,
90 pub cookies_validated: AtomicU64,
92 pub cookies_invalid: AtomicU64,
94 pub cookies_expired: AtomicU64,
96 pub actors_correlated: AtomicU64,
98}
99
100impl CookieStats {
101 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#[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#[derive(Debug)]
125pub struct CookieManager {
126 challenges: DashMap<String, CookieChallenge>,
128 config: CookieConfig,
130 stats: CookieStats,
132}
133
134impl CookieManager {
135 pub fn new(config: CookieConfig) -> Result<Self, CookieError> {
141 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 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 #[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 pub fn config(&self) -> &CookieConfig {
186 &self.config
187 }
188
189 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 let actor_hash = self.hash_actor_id(actor_id);
196
197 let data_to_sign = format!("{}.{}", now, actor_hash);
199 let signature = self.sign_data(&data_to_sign);
200
201 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 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 pub fn validate_cookie(&self, actor_id: &str, cookie_value: &str) -> ValidationResult {
222 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 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 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 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 pub fn correlate_actor(&self, cookie_value: &str) -> Option<String> {
271 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 let now = now_secs();
283 if timestamp + self.config.cookie_max_age_secs < now {
284 return None;
285 }
286
287 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 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 pub fn stats(&self) -> &CookieStats {
308 &self.stats
309 }
310
311 pub fn len(&self) -> usize {
313 self.challenges.len()
314 }
315
316 pub fn is_empty(&self) -> bool {
318 self.challenges.is_empty()
319 }
320
321 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 pub fn clear(&self) {
340 self.challenges.clear();
341 }
342
343 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]) }
352
353 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]) }
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 false
395 }
396}
397
398#[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#[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, 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 let parts: Vec<&str> = challenge.cookie_value.split('.').collect();
456 assert_eq!(parts.len(), 3);
457 assert!(parts[0].parse::<u64>().is_ok()); assert_eq!(parts[1].len(), 16); assert_eq!(parts[2].len(), 32); }
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 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 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 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, ..test_config()
514 };
515 let manager = CookieManager::new(config).unwrap();
516 let challenge = manager.generate_tracking_cookie("actor_123");
517
518 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 let correlated = manager.correlate_actor(&challenge.cookie_value);
532 assert_eq!(correlated, Some("actor_123".to_string()));
533
534 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 let hash1 = manager.hash_actor_id("actor_123");
545 let hash2 = manager.hash_actor_id("actor_123");
546 assert_eq!(hash1, hash2);
547
548 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 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 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 manager.validate_cookie("actor_3", &challenge.cookie_value);
595 let stats = manager.stats().snapshot();
596 assert_eq!(stats.cookies_validated, 1);
597
598 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, ..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 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 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]); assert_ne!(parts1[2], parts2[2]); }
648}