1use dashmap::DashMap;
29use sha2::{Digest, Sha256};
30use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
31use std::sync::Arc;
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33use tokio::sync::Notify;
34
35use super::{ChallengeResponse, Interrogator, ValidationResult};
36
37#[derive(Debug, Clone)]
39pub struct JsChallenge {
40 pub challenge_id: String,
42 pub actor_id: String,
44 pub difficulty: u32,
46 pub prefix: String,
48 pub created_at: u64,
50 pub expires_at: u64,
52 pub expected_hash_prefix: String,
54}
55
56#[derive(Debug, Clone)]
58pub struct JsChallengeConfig {
59 pub difficulty: u32,
62 pub challenge_ttl_secs: u64,
64 pub max_attempts: u32,
66 pub cleanup_interval_secs: u64,
68 pub page_title: String,
70 pub page_message: String,
72}
73
74impl Default for JsChallengeConfig {
75 fn default() -> Self {
76 Self {
77 difficulty: 4, challenge_ttl_secs: 300,
79 max_attempts: 3,
80 cleanup_interval_secs: 60,
81 page_title: "Verifying your browser".to_string(),
82 page_message: "Please wait while we verify your browser...".to_string(),
83 }
84 }
85}
86
87#[derive(Debug, Default)]
89pub struct JsChallengeStats {
90 pub challenges_issued: AtomicU64,
92 pub challenges_passed: AtomicU64,
94 pub challenges_failed: AtomicU64,
96 pub challenges_expired: AtomicU64,
98 pub max_attempts_exceeded: AtomicU64,
100}
101
102impl JsChallengeStats {
103 pub fn snapshot(&self) -> JsChallengeStatsSnapshot {
105 JsChallengeStatsSnapshot {
106 challenges_issued: self.challenges_issued.load(Ordering::Relaxed),
107 challenges_passed: self.challenges_passed.load(Ordering::Relaxed),
108 challenges_failed: self.challenges_failed.load(Ordering::Relaxed),
109 challenges_expired: self.challenges_expired.load(Ordering::Relaxed),
110 max_attempts_exceeded: self.max_attempts_exceeded.load(Ordering::Relaxed),
111 }
112 }
113}
114
115#[derive(Debug, Clone, serde::Serialize)]
117pub struct JsChallengeStatsSnapshot {
118 pub challenges_issued: u64,
119 pub challenges_passed: u64,
120 pub challenges_failed: u64,
121 pub challenges_expired: u64,
122 pub max_attempts_exceeded: u64,
123}
124
125pub struct JsChallengeManager {
127 challenges: DashMap<String, JsChallenge>,
129 attempt_counts: DashMap<String, u32>,
131 config: JsChallengeConfig,
133 stats: JsChallengeStats,
135 shutdown: Arc<Notify>,
137 shutdown_flag: Arc<AtomicBool>,
139}
140
141impl JsChallengeManager {
142 pub fn new(config: JsChallengeConfig) -> Self {
144 Self {
145 challenges: DashMap::new(),
146 attempt_counts: DashMap::new(),
147 config,
148 stats: JsChallengeStats::default(),
149 shutdown: Arc::new(Notify::new()),
150 shutdown_flag: Arc::new(AtomicBool::new(false)),
151 }
152 }
153
154 pub fn config(&self) -> &JsChallengeConfig {
156 &self.config
157 }
158
159 pub fn generate_pow_challenge(&self, actor_id: &str) -> JsChallenge {
161 let now = now_ms();
162 let expires_at = now + (self.config.challenge_ttl_secs * 1000);
163
164 let prefix = generate_random_hex(16);
166
167 let challenge_id = generate_random_hex(32);
169
170 let expected_hash_prefix = "0".repeat(self.config.difficulty as usize);
172
173 let challenge = JsChallenge {
174 challenge_id,
175 actor_id: actor_id.to_string(),
176 difficulty: self.config.difficulty,
177 prefix,
178 created_at: now,
179 expires_at,
180 expected_hash_prefix,
181 };
182
183 self.challenges
185 .insert(actor_id.to_string(), challenge.clone());
186 self.stats.challenges_issued.fetch_add(1, Ordering::Relaxed);
187
188 challenge
189 }
190
191 pub fn validate_pow(&self, actor_id: &str, nonce: &str) -> ValidationResult {
193 const MAX_NONCE_LENGTH: usize = 32;
197 if nonce.len() > MAX_NONCE_LENGTH {
198 return ValidationResult::Invalid(format!(
199 "Nonce too long ({} > {} chars)",
200 nonce.len(),
201 MAX_NONCE_LENGTH
202 ));
203 }
204
205 if !nonce.chars().all(|c| c.is_ascii_digit()) {
207 return ValidationResult::Invalid("Nonce must be numeric".to_string());
208 }
209
210 let challenge = match self.challenges.get(actor_id) {
212 Some(c) => c.clone(),
213 None => return ValidationResult::NotFound,
214 };
215
216 let now = now_ms();
218 if challenge.expires_at < now {
219 self.challenges.remove(actor_id);
220 self.stats
221 .challenges_expired
222 .fetch_add(1, Ordering::Relaxed);
223 return ValidationResult::Expired;
224 }
225
226 let attempts = {
228 let mut entry = self.attempt_counts.entry(actor_id.to_string()).or_insert(0);
229 *entry += 1;
230 *entry
231 };
232
233 if attempts > self.config.max_attempts {
235 self.stats
236 .max_attempts_exceeded
237 .fetch_add(1, Ordering::Relaxed);
238 return ValidationResult::Invalid(format!(
239 "Max attempts ({}) exceeded",
240 self.config.max_attempts
241 ));
242 }
243
244 let data = format!("{}{}", challenge.prefix, nonce);
246 let hash = compute_sha256_hex(&data);
247
248 if hash.starts_with(&challenge.expected_hash_prefix) {
249 self.challenges.remove(actor_id);
251 self.attempt_counts.remove(actor_id);
252 self.stats.challenges_passed.fetch_add(1, Ordering::Relaxed);
253 ValidationResult::Valid
254 } else {
255 self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
256 ValidationResult::Invalid(format!(
257 "Hash {} does not have {} leading zeros",
258 &hash[..8],
259 self.config.difficulty
260 ))
261 }
262 }
263
264 pub fn generate_challenge_page(&self, challenge: &JsChallenge) -> String {
266 format!(
267 r#"<!DOCTYPE html>
268<html>
269<head>
270 <meta charset="UTF-8">
271 <meta name="viewport" content="width=device-width, initial-scale=1.0">
272 <title>{title}</title>
273 <style>
274 body {{
275 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
276 display: flex;
277 justify-content: center;
278 align-items: center;
279 min-height: 100vh;
280 margin: 0;
281 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
282 }}
283 .container {{
284 background: white;
285 padding: 2rem;
286 border-radius: 8px;
287 box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
288 text-align: center;
289 max-width: 400px;
290 }}
291 .spinner {{
292 border: 4px solid #f3f3f3;
293 border-top: 4px solid #667eea;
294 border-radius: 50%;
295 width: 40px;
296 height: 40px;
297 animation: spin 1s linear infinite;
298 margin: 1rem auto;
299 }}
300 @keyframes spin {{
301 0% {{ transform: rotate(0deg); }}
302 100% {{ transform: rotate(360deg); }}
303 }}
304 .progress {{
305 margin: 1rem 0;
306 color: #666;
307 font-size: 0.9rem;
308 }}
309 .error {{
310 color: #e53e3e;
311 margin-top: 1rem;
312 }}
313 noscript {{
314 color: #e53e3e;
315 }}
316 </style>
317</head>
318<body>
319 <div class="container">
320 <h2>{message}</h2>
321 <div class="spinner" id="spinner"></div>
322 <div class="progress" id="progress">Computing challenge...</div>
323 <noscript>
324 <p class="error">JavaScript is required to complete this verification.</p>
325 </noscript>
326 <form id="challengeForm" method="GET" style="display: none;">
327 <input type="hidden" name="synapse_challenge" value="js">
328 <input type="hidden" name="challenge_id" value="{challenge_id}">
329 <input type="hidden" name="synapse_nonce" id="synapse_nonce" value="">
330 </form>
331 </div>
332 <script>
333 (function() {{
334 const PREFIX = '{prefix}';
335 const DIFFICULTY = {difficulty};
336 const EXPECTED_PREFIX = '{expected_prefix}';
337
338 let nonce = 0;
339 let startTime = Date.now();
340 let lastUpdate = startTime;
341
342 // SHA-256 implementation using Web Crypto API
343 async function sha256(message) {{
344 const msgBuffer = new TextEncoder().encode(message);
345 const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
346 const hashArray = Array.from(new Uint8Array(hashBuffer));
347 return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
348 }}
349
350 async function solve() {{
351 const progressEl = document.getElementById('progress');
352
353 while (true) {{
354 const data = PREFIX + nonce.toString();
355 const hash = await sha256(data);
356
357 // Update progress every 100ms
358 const now = Date.now();
359 if (now - lastUpdate > 100) {{
360 const elapsed = ((now - startTime) / 1000).toFixed(1);
361 progressEl.textContent = `Computed ${{nonce.toLocaleString()}} hashes (${{elapsed}}s)...`;
362 lastUpdate = now;
363 }}
364
365 if (hash.startsWith(EXPECTED_PREFIX)) {{
366 // Found solution!
367 document.getElementById('synapse_nonce').value = nonce.toString();
368 document.getElementById('spinner').style.display = 'none';
369 progressEl.textContent = 'Verification complete! Redirecting...';
370 document.getElementById('challengeForm').submit();
371 return;
372 }}
373
374 nonce++;
375
376 // Yield to browser every 1000 iterations for responsiveness
377 if (nonce % 1000 === 0) {{
378 await new Promise(resolve => setTimeout(resolve, 0));
379 }}
380 }}
381 }}
382
383 // Start solving
384 solve().catch(err => {{
385 document.getElementById('spinner').style.display = 'none';
386 document.getElementById('progress').innerHTML =
387 '<span class="error">Verification failed: ' + err.message + '</span>';
388 }});
389 }})();
390 </script>
391</body>
392</html>"#,
393 title = self.config.page_title,
394 message = self.config.page_message,
395 challenge_id = challenge.challenge_id,
396 prefix = challenge.prefix,
397 difficulty = challenge.difficulty,
398 expected_prefix = challenge.expected_hash_prefix,
399 )
400 }
401
402 pub fn get_attempts(&self, actor_id: &str) -> u32 {
404 self.attempt_counts.get(actor_id).map(|v| *v).unwrap_or(0)
405 }
406
407 pub fn has_challenge(&self, actor_id: &str) -> bool {
409 self.challenges.contains_key(actor_id)
410 }
411
412 pub fn get_challenge(&self, actor_id: &str) -> Option<JsChallenge> {
414 self.challenges.get(actor_id).map(|c| c.clone())
415 }
416
417 pub fn start_cleanup(self: Arc<Self>) {
422 let manager = self.clone();
423 let interval = Duration::from_secs(self.config.cleanup_interval_secs);
424 let shutdown = self.shutdown.clone();
425 let shutdown_flag = self.shutdown_flag.clone();
426
427 tokio::spawn(async move {
428 let mut interval_timer = tokio::time::interval(interval);
429
430 loop {
431 tokio::select! {
432 _ = interval_timer.tick() => {
433 if shutdown_flag.load(Ordering::Relaxed) {
435 log::info!("JS challenge manager cleanup task shutting down (flag)");
436 break;
437 }
438 manager.cleanup_expired();
439 }
440 _ = shutdown.notified() => {
441 log::info!("JS challenge manager cleanup task shutting down");
442 break;
443 }
444 }
445 }
446 });
447 }
448
449 pub fn shutdown(&self) {
454 self.shutdown_flag.store(true, Ordering::Relaxed);
455 self.shutdown.notify_one();
456 }
457
458 pub fn cleanup_expired(&self) -> usize {
460 let now = now_ms();
461 let mut removed = 0;
462
463 self.challenges.retain(|_, challenge| {
464 if challenge.expires_at < now {
465 removed += 1;
466 false
467 } else {
468 true
469 }
470 });
471
472 let actor_ids: Vec<String> = self
474 .attempt_counts
475 .iter()
476 .map(|e| e.key().clone())
477 .collect();
478 for actor_id in actor_ids {
479 if !self.challenges.contains_key(&actor_id) {
480 self.attempt_counts.remove(&actor_id);
481 }
482 }
483
484 removed
485 }
486
487 pub fn stats(&self) -> &JsChallengeStats {
489 &self.stats
490 }
491
492 pub fn len(&self) -> usize {
494 self.challenges.len()
495 }
496
497 pub fn is_empty(&self) -> bool {
499 self.challenges.is_empty()
500 }
501
502 pub fn clear(&self) {
504 self.challenges.clear();
505 self.attempt_counts.clear();
506 }
507}
508
509impl Interrogator for JsChallengeManager {
510 fn name(&self) -> &'static str {
511 "js_challenge"
512 }
513
514 fn challenge_level(&self) -> u8 {
515 2
516 }
517
518 fn generate_challenge(&self, actor_id: &str) -> ChallengeResponse {
519 let challenge = self.generate_pow_challenge(actor_id);
520 let html = self.generate_challenge_page(&challenge);
521 ChallengeResponse::JsChallenge {
522 html,
523 expected_solution: challenge.expected_hash_prefix.clone(),
524 expires_at: challenge.expires_at,
525 }
526 }
527
528 fn validate_response(&self, actor_id: &str, response: &str) -> ValidationResult {
529 self.validate_pow(actor_id, response)
530 }
531
532 fn should_escalate(&self, actor_id: &str) -> bool {
533 self.get_attempts(actor_id) >= self.config.max_attempts
534 }
535}
536
537#[inline]
539fn now_ms() -> u64 {
540 SystemTime::now()
541 .duration_since(UNIX_EPOCH)
542 .map(|d| d.as_millis() as u64)
543 .unwrap_or(0)
544}
545
546fn compute_sha256_hex(data: &str) -> String {
548 let mut hasher = Sha256::new();
549 hasher.update(data.as_bytes());
550 let result = hasher.finalize();
551 hex::encode(result)
552}
553
554fn generate_random_hex(len: usize) -> String {
556 let byte_len = len.div_ceil(2);
558 let mut bytes = vec![0u8; byte_len];
559 getrandom::getrandom(&mut bytes).expect("Failed to get random bytes");
560
561 let mut result = hex::encode(&bytes);
562 result.truncate(len);
563 result
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 fn test_config() -> JsChallengeConfig {
571 JsChallengeConfig {
572 difficulty: 2, challenge_ttl_secs: 300,
574 max_attempts: 3,
575 cleanup_interval_secs: 60,
576 page_title: "Test Challenge".to_string(),
577 page_message: "Testing...".to_string(),
578 }
579 }
580
581 #[test]
582 fn test_challenge_generation() {
583 let manager = JsChallengeManager::new(test_config());
584 let challenge = manager.generate_pow_challenge("actor_123");
585
586 assert_eq!(challenge.actor_id, "actor_123");
587 assert_eq!(challenge.difficulty, 2);
588 assert_eq!(challenge.prefix.len(), 16);
589 assert_eq!(challenge.challenge_id.len(), 32);
590 assert_eq!(challenge.expected_hash_prefix, "00");
591 assert!(challenge.expires_at > challenge.created_at);
592 }
593
594 #[test]
595 fn test_pow_verification_valid() {
596 let manager = JsChallengeManager::new(test_config());
597 let challenge = manager.generate_pow_challenge("actor_123");
598
599 let mut nonce = 0u64;
601 loop {
602 let data = format!("{}{}", challenge.prefix, nonce);
603 let hash = compute_sha256_hex(&data);
604 if hash.starts_with(&challenge.expected_hash_prefix) {
605 break;
606 }
607 nonce += 1;
608 if nonce > 100_000 {
609 panic!("Could not find solution in reasonable time");
610 }
611 }
612
613 let result = manager.validate_pow("actor_123", &nonce.to_string());
614 assert_eq!(result, ValidationResult::Valid);
615 }
616
617 #[test]
618 fn test_pow_verification_invalid() {
619 let manager = JsChallengeManager::new(test_config());
620 manager.generate_pow_challenge("actor_123");
621
622 let result = manager.validate_pow("actor_123", "invalid_nonce");
624 assert!(matches!(result, ValidationResult::Invalid(_)));
625 }
626
627 #[test]
628 fn test_pow_verification_not_found() {
629 let manager = JsChallengeManager::new(test_config());
630
631 let result = manager.validate_pow("actor_123", "12345");
633 assert_eq!(result, ValidationResult::NotFound);
634 }
635
636 #[test]
637 fn test_pow_verification_expired() {
638 let config = JsChallengeConfig {
639 challenge_ttl_secs: 0, ..test_config()
641 };
642 let manager = JsChallengeManager::new(config);
643 manager.generate_pow_challenge("actor_123");
644
645 std::thread::sleep(std::time::Duration::from_millis(10));
647
648 let result = manager.validate_pow("actor_123", "12345");
649 assert_eq!(result, ValidationResult::Expired);
650 }
651
652 #[test]
653 fn test_max_attempts() {
654 let manager = JsChallengeManager::new(test_config());
655 manager.generate_pow_challenge("actor_123");
656
657 for _ in 0..3 {
659 let _ = manager.validate_pow("actor_123", "99999999");
660 }
661
662 let result = manager.validate_pow("actor_123", "99999999");
664 assert!(matches!(result, ValidationResult::Invalid(msg) if msg.contains("Max attempts")));
665 }
666
667 #[test]
668 fn test_attempt_counting() {
669 let manager = JsChallengeManager::new(test_config());
670 manager.generate_pow_challenge("actor_123");
671
672 assert_eq!(manager.get_attempts("actor_123"), 0);
673
674 manager.validate_pow("actor_123", "99999999");
675 assert_eq!(manager.get_attempts("actor_123"), 1);
676
677 manager.validate_pow("actor_123", "99999999");
678 assert_eq!(manager.get_attempts("actor_123"), 2);
679 }
680
681 #[test]
682 fn test_should_escalate() {
683 let manager = JsChallengeManager::new(test_config());
684 manager.generate_pow_challenge("actor_123");
685
686 assert!(!manager.should_escalate("actor_123"));
687
688 for _ in 0..3 {
690 let _ = manager.validate_pow("actor_123", "99999999");
691 }
692
693 assert!(manager.should_escalate("actor_123"));
694 }
695
696 #[test]
697 fn test_challenge_page_generation() {
698 let manager = JsChallengeManager::new(test_config());
699 let challenge = manager.generate_pow_challenge("actor_123");
700 let html = manager.generate_challenge_page(&challenge);
701
702 assert!(html.contains("Test Challenge")); assert!(html.contains("Testing...")); assert!(html.contains(&challenge.prefix)); assert!(html.contains(&challenge.challenge_id)); assert!(html.contains("sha256")); }
708
709 #[test]
710 fn test_sha256_computation() {
711 let hash = compute_sha256_hex("test");
713 assert_eq!(
714 hash,
715 "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
716 );
717 }
718
719 #[test]
720 fn test_cleanup_expired() {
721 let config = JsChallengeConfig {
722 challenge_ttl_secs: 0, ..test_config()
724 };
725 let manager = JsChallengeManager::new(config);
726
727 manager.generate_pow_challenge("actor_1");
728 manager.generate_pow_challenge("actor_2");
729 assert_eq!(manager.len(), 2);
730
731 std::thread::sleep(std::time::Duration::from_millis(10));
732
733 let removed = manager.cleanup_expired();
734 assert_eq!(removed, 2);
735 assert!(manager.is_empty());
736 }
737
738 #[test]
739 fn test_interrogator_trait() {
740 let manager = JsChallengeManager::new(test_config());
741
742 assert_eq!(manager.name(), "js_challenge");
743 assert_eq!(manager.challenge_level(), 2);
744
745 let response = manager.generate_challenge("actor_123");
747 match response {
748 ChallengeResponse::JsChallenge {
749 html,
750 expected_solution,
751 expires_at,
752 } => {
753 assert!(!html.is_empty());
754 assert_eq!(expected_solution, "00");
755 assert!(expires_at > now_ms());
756 }
757 _ => panic!("Expected JsChallenge response"),
758 }
759 }
760
761 #[test]
762 fn test_stats_tracking() {
763 let manager = JsChallengeManager::new(test_config());
764
765 manager.generate_pow_challenge("actor_1");
767 manager.generate_pow_challenge("actor_2");
768
769 let stats = manager.stats().snapshot();
770 assert_eq!(stats.challenges_issued, 2);
771
772 manager.validate_pow("actor_1", "99999999");
774 let stats = manager.stats().snapshot();
775 assert_eq!(stats.challenges_failed, 1);
776 }
777
778 #[test]
779 fn test_random_hex_generation() {
780 let hex1 = generate_random_hex(16);
781 let hex2 = generate_random_hex(16);
782
783 assert_eq!(hex1.len(), 16);
784 assert_eq!(hex2.len(), 16);
785 assert_ne!(hex1, hex2);
787
788 assert!(hex1.chars().all(|c| c.is_ascii_hexdigit()));
790 }
791
792 #[test]
793 fn test_has_challenge() {
794 let manager = JsChallengeManager::new(test_config());
795
796 assert!(!manager.has_challenge("actor_123"));
797
798 manager.generate_pow_challenge("actor_123");
799 assert!(manager.has_challenge("actor_123"));
800
801 manager.clear();
802 assert!(!manager.has_challenge("actor_123"));
803 }
804
805 #[test]
806 fn test_successful_validation_clears_state() {
807 let config = JsChallengeConfig {
808 difficulty: 4, ..test_config()
810 };
811 let manager = JsChallengeManager::new(config);
812 let challenge = manager.generate_pow_challenge("actor_123");
813
814 let result1 = manager.validate_pow("actor_123", "99999999");
816 assert!(matches!(result1, ValidationResult::Invalid(_)));
817 let result2 = manager.validate_pow("actor_123", "99999998");
818 assert!(matches!(result2, ValidationResult::Invalid(_)));
819 assert_eq!(manager.get_attempts("actor_123"), 2);
820 assert!(manager.has_challenge("actor_123"));
821
822 let mut nonce = 0u64;
824 loop {
825 let data = format!("{}{}", challenge.prefix, nonce);
826 let hash = compute_sha256_hex(&data);
827 if hash.starts_with("0000") {
828 break;
829 }
830 nonce += 1;
831 }
832
833 let result = manager.validate_pow("actor_123", &nonce.to_string());
835 assert_eq!(result, ValidationResult::Valid);
836
837 assert!(!manager.has_challenge("actor_123"));
839 assert_eq!(manager.get_attempts("actor_123"), 0);
840 }
841}