Skip to main content

synapse_pingora/interrogator/
captcha_manager.rs

1//! CAPTCHA Challenge Manager
2//!
3//! Provides human verification through simple math challenges.
4//! Uses secure session tokens with HMAC for validation.
5
6use crate::interrogator::ValidationResult;
7use dashmap::DashMap;
8use hmac::{Hmac, Mac};
9use sha2::Sha256;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Configuration for CAPTCHA manager
16#[derive(Debug, Clone)]
17pub struct CaptchaConfig {
18    /// Secret key for HMAC signing
19    pub secret: String,
20    /// Challenge expiration in seconds
21    pub expiry_secs: u64,
22    /// Maximum number of challenges to track
23    pub max_challenges: usize,
24    /// Cleanup interval in seconds
25    pub cleanup_interval_secs: u64,
26}
27
28impl Default for CaptchaConfig {
29    fn default() -> Self {
30        Self {
31            secret: "default_captcha_secret_change_me".to_string(),
32            expiry_secs: 300, // 5 minutes
33            max_challenges: 10_000,
34            cleanup_interval_secs: 60,
35        }
36    }
37}
38
39/// A CAPTCHA challenge
40#[derive(Debug, Clone)]
41pub struct CaptchaChallenge {
42    /// Session ID for this challenge
43    pub session_id: String,
44    /// The question to display
45    pub question: String,
46    /// HTML page with the challenge
47    pub html: String,
48}
49
50/// Internal challenge state
51#[derive(Debug, Clone)]
52struct ChallengeState {
53    actor_id: String,
54    expected_answer: i32,
55    created_at: u64,
56}
57
58/// Statistics for CAPTCHA manager
59#[derive(Debug, Default)]
60pub struct CaptchaStats {
61    pub challenges_issued: AtomicU64,
62    pub challenges_validated: AtomicU64,
63    pub challenges_passed: AtomicU64,
64    pub challenges_failed: AtomicU64,
65    pub challenges_expired: AtomicU64,
66}
67
68impl CaptchaStats {
69    pub fn snapshot(&self) -> CaptchaStatsSnapshot {
70        CaptchaStatsSnapshot {
71            challenges_issued: self.challenges_issued.load(Ordering::Relaxed),
72            challenges_validated: self.challenges_validated.load(Ordering::Relaxed),
73            challenges_passed: self.challenges_passed.load(Ordering::Relaxed),
74            challenges_failed: self.challenges_failed.load(Ordering::Relaxed),
75            challenges_expired: self.challenges_expired.load(Ordering::Relaxed),
76        }
77    }
78}
79
80#[derive(Debug, Clone, serde::Serialize)]
81pub struct CaptchaStatsSnapshot {
82    pub challenges_issued: u64,
83    pub challenges_validated: u64,
84    pub challenges_passed: u64,
85    pub challenges_failed: u64,
86    pub challenges_expired: u64,
87}
88
89/// CAPTCHA challenge manager
90pub struct CaptchaManager {
91    config: CaptchaConfig,
92    /// Maps session_id -> ChallengeState
93    challenges: DashMap<String, ChallengeState>,
94    stats: CaptchaStats,
95    last_cleanup: AtomicU64,
96}
97
98impl CaptchaManager {
99    pub fn new(config: CaptchaConfig) -> Self {
100        Self {
101            config,
102            challenges: DashMap::new(),
103            stats: CaptchaStats::default(),
104            last_cleanup: AtomicU64::new(now_ms()),
105        }
106    }
107
108    /// Issue a new CAPTCHA challenge
109    pub fn issue_challenge(&self, actor_id: &str) -> CaptchaChallenge {
110        self.maybe_cleanup();
111
112        // Generate random numbers for math challenge using cryptographically secure random
113        let (a, b) = generate_math_operands();
114        let expected_answer = a + b;
115        let question = format!("What is {} + {}?", a, b);
116
117        // Generate session ID with HMAC signature
118        let timestamp = now_ms();
119        let session_data = format!("{}:{}:{}", actor_id, timestamp, expected_answer);
120        let signature = hmac_sign(&self.config.secret, &session_data);
121        let session_id = format!("{}:{}", timestamp, &signature[..16]);
122
123        // Store the challenge
124        self.challenges.insert(
125            session_id.clone(),
126            ChallengeState {
127                actor_id: actor_id.to_string(),
128                expected_answer,
129                created_at: timestamp,
130            },
131        );
132
133        self.stats.challenges_issued.fetch_add(1, Ordering::Relaxed);
134
135        // Generate HTML
136        let html = self.generate_html(&session_id, &question);
137
138        CaptchaChallenge {
139            session_id,
140            question,
141            html,
142        }
143    }
144
145    /// Validate a CAPTCHA response
146    ///
147    /// Expected response format: "session_id:answer"
148    /// Note: session_id format is "{timestamp}:{signature}" so we split from the right
149    pub fn validate_response(&self, actor_id: &str, response: &str) -> ValidationResult {
150        self.stats
151            .challenges_validated
152            .fetch_add(1, Ordering::Relaxed);
153
154        // Parse response - split from right since session_id contains colon
155        let Some(last_colon_idx) = response.rfind(':') else {
156            self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
157            return ValidationResult::Invalid("Invalid response format".to_string());
158        };
159
160        let session_id = &response[..last_colon_idx];
161        let answer_str = response[last_colon_idx + 1..].trim();
162
163        // Look up challenge
164        let challenge = match self.challenges.get(session_id) {
165            Some(c) => c.clone(),
166            None => {
167                self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
168                return ValidationResult::NotFound;
169            }
170        };
171
172        // Verify actor matches
173        if challenge.actor_id != actor_id {
174            self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
175            return ValidationResult::Invalid("Actor mismatch".to_string());
176        }
177
178        // Check expiration
179        let now = now_ms();
180        let expiry_ms = self.config.expiry_secs * 1000;
181        if now - challenge.created_at > expiry_ms {
182            self.challenges.remove(session_id);
183            self.stats
184                .challenges_expired
185                .fetch_add(1, Ordering::Relaxed);
186            return ValidationResult::Expired;
187        }
188
189        // Parse and validate answer
190        let answer: i32 = match answer_str.parse() {
191            Ok(a) => a,
192            Err(_) => {
193                self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
194                return ValidationResult::Invalid("Invalid answer format".to_string());
195            }
196        };
197
198        if answer == challenge.expected_answer {
199            // Remove used challenge (one-time use)
200            self.challenges.remove(session_id);
201            self.stats.challenges_passed.fetch_add(1, Ordering::Relaxed);
202            ValidationResult::Valid
203        } else {
204            self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
205            ValidationResult::Invalid("Incorrect answer".to_string())
206        }
207    }
208
209    /// Get stats
210    pub fn stats(&self) -> &CaptchaStats {
211        &self.stats
212    }
213
214    /// Clean up expired challenges
215    fn maybe_cleanup(&self) {
216        let now = now_ms();
217        let last = self.last_cleanup.load(Ordering::Relaxed);
218        let cleanup_interval_ms = self.config.cleanup_interval_secs * 1000;
219
220        if now - last < cleanup_interval_ms {
221            return;
222        }
223
224        if self
225            .last_cleanup
226            .compare_exchange(last, now, Ordering::AcqRel, Ordering::Relaxed)
227            .is_err()
228        {
229            return;
230        }
231
232        let expiry_ms = self.config.expiry_secs * 1000;
233        self.challenges
234            .retain(|_, state| now - state.created_at < expiry_ms);
235    }
236
237    fn generate_html(&self, session_id: &str, question: &str) -> String {
238        format!(
239            r#"<!DOCTYPE html>
240<html>
241<head>
242    <meta charset="UTF-8">
243    <meta name="viewport" content="width=device-width, initial-scale=1.0">
244    <title>Verification Required</title>
245    <style>
246        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
247        body {{
248            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
249            display: flex;
250            justify-content: center;
251            align-items: center;
252            min-height: 100vh;
253            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
254        }}
255        .container {{
256            background: rgba(255, 255, 255, 0.95);
257            padding: 2.5rem;
258            border-radius: 12px;
259            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
260            text-align: center;
261            max-width: 400px;
262            width: 90%;
263        }}
264        h2 {{
265            color: #1a1a2e;
266            margin-bottom: 0.5rem;
267            font-size: 1.5rem;
268        }}
269        p {{
270            color: #666;
271            margin-bottom: 1.5rem;
272            font-size: 0.9rem;
273        }}
274        .challenge {{
275            background: #f8f9fa;
276            padding: 1.5rem;
277            border-radius: 8px;
278            margin-bottom: 1.5rem;
279        }}
280        .question {{
281            font-size: 1.25rem;
282            color: #333;
283            font-weight: 600;
284            margin-bottom: 1rem;
285        }}
286        input[type="text"] {{
287            width: 100%;
288            padding: 0.75rem 1rem;
289            font-size: 1.25rem;
290            border: 2px solid #e0e0e0;
291            border-radius: 6px;
292            text-align: center;
293            transition: border-color 0.2s;
294        }}
295        input[type="text"]:focus {{
296            outline: none;
297            border-color: #667eea;
298        }}
299        button {{
300            width: 100%;
301            padding: 0.875rem 1.5rem;
302            font-size: 1rem;
303            font-weight: 600;
304            color: white;
305            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
306            border: none;
307            border-radius: 6px;
308            cursor: pointer;
309            transition: transform 0.1s, box-shadow 0.2s;
310        }}
311        button:hover {{
312            transform: translateY(-1px);
313            box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
314        }}
315        button:active {{ transform: translateY(0); }}
316        .footer {{
317            margin-top: 1.5rem;
318            font-size: 0.75rem;
319            color: #999;
320        }}
321    </style>
322</head>
323<body>
324    <div class="container">
325        <h2>Human Verification Required</h2>
326        <p>Please solve this simple math problem to continue.</p>
327        <form method="POST" action="/__captcha/verify">
328            <input type="hidden" name="session" value="{session_id}">
329            <div class="challenge">
330                <div class="question">{question}</div>
331                <input type="text" name="answer" autocomplete="off" autofocus required
332                       placeholder="Enter your answer" pattern="[0-9]+" inputmode="numeric">
333            </div>
334            <button type="submit">Verify</button>
335        </form>
336        <p class="footer">Synapse Security Gateway</p>
337    </div>
338</body>
339</html>"#,
340            session_id = session_id,
341            question = question
342        )
343    }
344}
345
346/// Get current time in milliseconds since Unix epoch
347#[inline]
348fn now_ms() -> u64 {
349    SystemTime::now()
350        .duration_since(UNIX_EPOCH)
351        .map(|d| d.as_millis() as u64)
352        .unwrap_or(0)
353}
354
355/// Generate random math operands (1-20) using cryptographically secure random
356fn generate_math_operands() -> (i32, i32) {
357    let mut bytes = [0u8; 2];
358    getrandom::getrandom(&mut bytes).expect("Failed to get random bytes");
359    // Map bytes to range 1..=20
360    let a = (bytes[0] % 20) as i32 + 1;
361    let b = (bytes[1] % 20) as i32 + 1;
362    (a, b)
363}
364
365/// Generate HMAC signature for session data
366fn hmac_sign(secret: &str, data: &str) -> String {
367    let mut mac =
368        HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
369    mac.update(data.as_bytes());
370    let result = mac.finalize();
371    hex::encode(result.into_bytes())
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn test_manager() -> CaptchaManager {
379        CaptchaManager::new(CaptchaConfig {
380            secret: "test_secret".to_string(),
381            expiry_secs: 300,
382            max_challenges: 100,
383            cleanup_interval_secs: 60,
384        })
385    }
386
387    #[test]
388    fn test_issue_challenge() {
389        let manager = test_manager();
390        let challenge = manager.issue_challenge("actor_1");
391
392        assert!(!challenge.session_id.is_empty());
393        assert!(challenge.question.contains("+"));
394        assert!(challenge.html.contains("Verification Required"));
395    }
396
397    #[test]
398    fn test_validate_correct_answer() {
399        let manager = test_manager();
400
401        // Issue challenge
402        let challenge = manager.issue_challenge("actor_1");
403
404        // Extract expected answer from question (e.g., "What is 5 + 3?")
405        let parts: Vec<&str> = challenge.question.split_whitespace().collect();
406        let a: i32 = parts[2].parse().unwrap();
407        let b: i32 = parts[4].trim_end_matches('?').parse().unwrap();
408        let answer = a + b;
409
410        // Validate
411        let response = format!("{}:{}", challenge.session_id, answer);
412        let result = manager.validate_response("actor_1", &response);
413        assert_eq!(result, ValidationResult::Valid);
414    }
415
416    #[test]
417    fn test_validate_wrong_answer() {
418        let manager = test_manager();
419        let challenge = manager.issue_challenge("actor_1");
420
421        let response = format!("{}:9999", challenge.session_id);
422        let result = manager.validate_response("actor_1", &response);
423        assert!(matches!(result, ValidationResult::Invalid(_)));
424    }
425
426    #[test]
427    fn test_validate_wrong_actor() {
428        let manager = test_manager();
429        let challenge = manager.issue_challenge("actor_1");
430
431        let response = format!("{}:42", challenge.session_id);
432        let result = manager.validate_response("actor_2", &response);
433        assert!(matches!(result, ValidationResult::Invalid(_)));
434    }
435
436    #[test]
437    fn test_validate_invalid_format() {
438        let manager = test_manager();
439        let result = manager.validate_response("actor_1", "invalid_format");
440        assert!(matches!(result, ValidationResult::Invalid(_)));
441    }
442
443    #[test]
444    fn test_validate_not_found() {
445        let manager = test_manager();
446        let result = manager.validate_response("actor_1", "nonexistent:42");
447        assert_eq!(result, ValidationResult::NotFound);
448    }
449
450    #[test]
451    fn test_challenge_one_time_use() {
452        let manager = test_manager();
453        let challenge = manager.issue_challenge("actor_1");
454
455        let parts: Vec<&str> = challenge.question.split_whitespace().collect();
456        let a: i32 = parts[2].parse().unwrap();
457        let b: i32 = parts[4].trim_end_matches('?').parse().unwrap();
458        let answer = a + b;
459
460        let response = format!("{}:{}", challenge.session_id, answer);
461
462        // First validation should succeed
463        let result1 = manager.validate_response("actor_1", &response);
464        assert_eq!(result1, ValidationResult::Valid);
465
466        // Second validation should fail (challenge consumed)
467        let result2 = manager.validate_response("actor_1", &response);
468        assert_eq!(result2, ValidationResult::NotFound);
469    }
470}