synapse_pingora/interrogator/
captcha_manager.rs1use 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#[derive(Debug, Clone)]
17pub struct CaptchaConfig {
18 pub secret: String,
20 pub expiry_secs: u64,
22 pub max_challenges: usize,
24 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, max_challenges: 10_000,
34 cleanup_interval_secs: 60,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct CaptchaChallenge {
42 pub session_id: String,
44 pub question: String,
46 pub html: String,
48}
49
50#[derive(Debug, Clone)]
52struct ChallengeState {
53 actor_id: String,
54 expected_answer: i32,
55 created_at: u64,
56}
57
58#[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
89pub struct CaptchaManager {
91 config: CaptchaConfig,
92 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 pub fn issue_challenge(&self, actor_id: &str) -> CaptchaChallenge {
110 self.maybe_cleanup();
111
112 let (a, b) = generate_math_operands();
114 let expected_answer = a + b;
115 let question = format!("What is {} + {}?", a, b);
116
117 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 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 let html = self.generate_html(&session_id, &question);
137
138 CaptchaChallenge {
139 session_id,
140 question,
141 html,
142 }
143 }
144
145 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 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 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 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 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 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 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 pub fn stats(&self) -> &CaptchaStats {
211 &self.stats
212 }
213
214 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#[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
355fn generate_math_operands() -> (i32, i32) {
357 let mut bytes = [0u8; 2];
358 getrandom::getrandom(&mut bytes).expect("Failed to get random bytes");
359 let a = (bytes[0] % 20) as i32 + 1;
361 let b = (bytes[1] % 20) as i32 + 1;
362 (a, b)
363}
364
365fn 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 let challenge = manager.issue_challenge("actor_1");
403
404 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 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 let result1 = manager.validate_response("actor_1", &response);
464 assert_eq!(result1, ValidationResult::Valid);
465
466 let result2 = manager.validate_response("actor_1", &response);
468 assert_eq!(result2, ValidationResult::NotFound);
469 }
470}