use dashmap::DashMap;
use sha2::{Digest, Sha256};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::Notify;
use super::{ChallengeResponse, Interrogator, ValidationResult};
#[derive(Debug, Clone)]
pub struct JsChallenge {
pub challenge_id: String,
pub actor_id: String,
pub difficulty: u32,
pub prefix: String,
pub created_at: u64,
pub expires_at: u64,
pub expected_hash_prefix: String,
}
#[derive(Debug, Clone)]
pub struct JsChallengeConfig {
pub difficulty: u32,
pub challenge_ttl_secs: u64,
pub max_attempts: u32,
pub cleanup_interval_secs: u64,
pub page_title: String,
pub page_message: String,
}
impl Default for JsChallengeConfig {
fn default() -> Self {
Self {
difficulty: 4, challenge_ttl_secs: 300,
max_attempts: 3,
cleanup_interval_secs: 60,
page_title: "Verifying your browser".to_string(),
page_message: "Please wait while we verify your browser...".to_string(),
}
}
}
#[derive(Debug, Default)]
pub struct JsChallengeStats {
pub challenges_issued: AtomicU64,
pub challenges_passed: AtomicU64,
pub challenges_failed: AtomicU64,
pub challenges_expired: AtomicU64,
pub max_attempts_exceeded: AtomicU64,
}
impl JsChallengeStats {
pub fn snapshot(&self) -> JsChallengeStatsSnapshot {
JsChallengeStatsSnapshot {
challenges_issued: self.challenges_issued.load(Ordering::Relaxed),
challenges_passed: self.challenges_passed.load(Ordering::Relaxed),
challenges_failed: self.challenges_failed.load(Ordering::Relaxed),
challenges_expired: self.challenges_expired.load(Ordering::Relaxed),
max_attempts_exceeded: self.max_attempts_exceeded.load(Ordering::Relaxed),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct JsChallengeStatsSnapshot {
pub challenges_issued: u64,
pub challenges_passed: u64,
pub challenges_failed: u64,
pub challenges_expired: u64,
pub max_attempts_exceeded: u64,
}
pub struct JsChallengeManager {
challenges: DashMap<String, JsChallenge>,
attempt_counts: DashMap<String, u32>,
config: JsChallengeConfig,
stats: JsChallengeStats,
shutdown: Arc<Notify>,
shutdown_flag: Arc<AtomicBool>,
}
impl JsChallengeManager {
pub fn new(config: JsChallengeConfig) -> Self {
Self {
challenges: DashMap::new(),
attempt_counts: DashMap::new(),
config,
stats: JsChallengeStats::default(),
shutdown: Arc::new(Notify::new()),
shutdown_flag: Arc::new(AtomicBool::new(false)),
}
}
pub fn config(&self) -> &JsChallengeConfig {
&self.config
}
pub fn generate_pow_challenge(&self, actor_id: &str) -> JsChallenge {
let now = now_ms();
let expires_at = now + (self.config.challenge_ttl_secs * 1000);
let prefix = generate_random_hex(16);
let challenge_id = generate_random_hex(32);
let expected_hash_prefix = "0".repeat(self.config.difficulty as usize);
let challenge = JsChallenge {
challenge_id,
actor_id: actor_id.to_string(),
difficulty: self.config.difficulty,
prefix,
created_at: now,
expires_at,
expected_hash_prefix,
};
self.challenges
.insert(actor_id.to_string(), challenge.clone());
self.stats.challenges_issued.fetch_add(1, Ordering::Relaxed);
challenge
}
pub fn validate_pow(&self, actor_id: &str, nonce: &str) -> ValidationResult {
const MAX_NONCE_LENGTH: usize = 32;
if nonce.len() > MAX_NONCE_LENGTH {
return ValidationResult::Invalid(format!(
"Nonce too long ({} > {} chars)",
nonce.len(),
MAX_NONCE_LENGTH
));
}
if !nonce.chars().all(|c| c.is_ascii_digit()) {
return ValidationResult::Invalid("Nonce must be numeric".to_string());
}
let challenge = match self.challenges.get(actor_id) {
Some(c) => c.clone(),
None => return ValidationResult::NotFound,
};
let now = now_ms();
if challenge.expires_at < now {
self.challenges.remove(actor_id);
self.stats
.challenges_expired
.fetch_add(1, Ordering::Relaxed);
return ValidationResult::Expired;
}
let attempts = {
let mut entry = self.attempt_counts.entry(actor_id.to_string()).or_insert(0);
*entry += 1;
*entry
};
if attempts > self.config.max_attempts {
self.stats
.max_attempts_exceeded
.fetch_add(1, Ordering::Relaxed);
return ValidationResult::Invalid(format!(
"Max attempts ({}) exceeded",
self.config.max_attempts
));
}
let data = format!("{}{}", challenge.prefix, nonce);
let hash = compute_sha256_hex(&data);
if hash.starts_with(&challenge.expected_hash_prefix) {
self.challenges.remove(actor_id);
self.attempt_counts.remove(actor_id);
self.stats.challenges_passed.fetch_add(1, Ordering::Relaxed);
ValidationResult::Valid
} else {
self.stats.challenges_failed.fetch_add(1, Ordering::Relaxed);
ValidationResult::Invalid(format!(
"Hash {} does not have {} leading zeros",
&hash[..8],
self.config.difficulty
))
}
}
pub fn generate_challenge_page(&self, challenge: &JsChallenge) -> String {
format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}}
.container {{
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 400px;
}}
.spinner {{
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 1rem auto;
}}
@keyframes spin {{
0% {{ transform: rotate(0deg); }}
100% {{ transform: rotate(360deg); }}
}}
.progress {{
margin: 1rem 0;
color: #666;
font-size: 0.9rem;
}}
.error {{
color: #e53e3e;
margin-top: 1rem;
}}
noscript {{
color: #e53e3e;
}}
</style>
</head>
<body>
<div class="container">
<h2>{message}</h2>
<div class="spinner" id="spinner"></div>
<div class="progress" id="progress">Computing challenge...</div>
<noscript>
<p class="error">JavaScript is required to complete this verification.</p>
</noscript>
<form id="challengeForm" method="GET" style="display: none;">
<input type="hidden" name="synapse_challenge" value="js">
<input type="hidden" name="challenge_id" value="{challenge_id}">
<input type="hidden" name="synapse_nonce" id="synapse_nonce" value="">
</form>
</div>
<script>
(function() {{
const PREFIX = '{prefix}';
const DIFFICULTY = {difficulty};
const EXPECTED_PREFIX = '{expected_prefix}';
let nonce = 0;
let startTime = Date.now();
let lastUpdate = startTime;
// SHA-256 implementation using Web Crypto API
async function sha256(message) {{
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}}
async function solve() {{
const progressEl = document.getElementById('progress');
while (true) {{
const data = PREFIX + nonce.toString();
const hash = await sha256(data);
// Update progress every 100ms
const now = Date.now();
if (now - lastUpdate > 100) {{
const elapsed = ((now - startTime) / 1000).toFixed(1);
progressEl.textContent = `Computed ${{nonce.toLocaleString()}} hashes (${{elapsed}}s)...`;
lastUpdate = now;
}}
if (hash.startsWith(EXPECTED_PREFIX)) {{
// Found solution!
document.getElementById('synapse_nonce').value = nonce.toString();
document.getElementById('spinner').style.display = 'none';
progressEl.textContent = 'Verification complete! Redirecting...';
document.getElementById('challengeForm').submit();
return;
}}
nonce++;
// Yield to browser every 1000 iterations for responsiveness
if (nonce % 1000 === 0) {{
await new Promise(resolve => setTimeout(resolve, 0));
}}
}}
}}
// Start solving
solve().catch(err => {{
document.getElementById('spinner').style.display = 'none';
document.getElementById('progress').innerHTML =
'<span class="error">Verification failed: ' + err.message + '</span>';
}});
}})();
</script>
</body>
</html>"#,
title = self.config.page_title,
message = self.config.page_message,
challenge_id = challenge.challenge_id,
prefix = challenge.prefix,
difficulty = challenge.difficulty,
expected_prefix = challenge.expected_hash_prefix,
)
}
pub fn get_attempts(&self, actor_id: &str) -> u32 {
self.attempt_counts.get(actor_id).map(|v| *v).unwrap_or(0)
}
pub fn has_challenge(&self, actor_id: &str) -> bool {
self.challenges.contains_key(actor_id)
}
pub fn get_challenge(&self, actor_id: &str) -> Option<JsChallenge> {
self.challenges.get(actor_id).map(|c| c.clone())
}
pub fn start_cleanup(self: Arc<Self>) {
let manager = self.clone();
let interval = Duration::from_secs(self.config.cleanup_interval_secs);
let shutdown = self.shutdown.clone();
let shutdown_flag = self.shutdown_flag.clone();
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
loop {
tokio::select! {
_ = interval_timer.tick() => {
if shutdown_flag.load(Ordering::Relaxed) {
log::info!("JS challenge manager cleanup task shutting down (flag)");
break;
}
manager.cleanup_expired();
}
_ = shutdown.notified() => {
log::info!("JS challenge manager cleanup task shutting down");
break;
}
}
}
});
}
pub fn shutdown(&self) {
self.shutdown_flag.store(true, Ordering::Relaxed);
self.shutdown.notify_one();
}
pub fn cleanup_expired(&self) -> usize {
let now = now_ms();
let mut removed = 0;
self.challenges.retain(|_, challenge| {
if challenge.expires_at < now {
removed += 1;
false
} else {
true
}
});
let actor_ids: Vec<String> = self
.attempt_counts
.iter()
.map(|e| e.key().clone())
.collect();
for actor_id in actor_ids {
if !self.challenges.contains_key(&actor_id) {
self.attempt_counts.remove(&actor_id);
}
}
removed
}
pub fn stats(&self) -> &JsChallengeStats {
&self.stats
}
pub fn len(&self) -> usize {
self.challenges.len()
}
pub fn is_empty(&self) -> bool {
self.challenges.is_empty()
}
pub fn clear(&self) {
self.challenges.clear();
self.attempt_counts.clear();
}
}
impl Interrogator for JsChallengeManager {
fn name(&self) -> &'static str {
"js_challenge"
}
fn challenge_level(&self) -> u8 {
2
}
fn generate_challenge(&self, actor_id: &str) -> ChallengeResponse {
let challenge = self.generate_pow_challenge(actor_id);
let html = self.generate_challenge_page(&challenge);
ChallengeResponse::JsChallenge {
html,
expected_solution: challenge.expected_hash_prefix.clone(),
expires_at: challenge.expires_at,
}
}
fn validate_response(&self, actor_id: &str, response: &str) -> ValidationResult {
self.validate_pow(actor_id, response)
}
fn should_escalate(&self, actor_id: &str) -> bool {
self.get_attempts(actor_id) >= self.config.max_attempts
}
}
#[inline]
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn compute_sha256_hex(data: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}
fn generate_random_hex(len: usize) -> String {
let byte_len = len.div_ceil(2);
let mut bytes = vec![0u8; byte_len];
getrandom::getrandom(&mut bytes).expect("Failed to get random bytes");
let mut result = hex::encode(&bytes);
result.truncate(len);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> JsChallengeConfig {
JsChallengeConfig {
difficulty: 2, challenge_ttl_secs: 300,
max_attempts: 3,
cleanup_interval_secs: 60,
page_title: "Test Challenge".to_string(),
page_message: "Testing...".to_string(),
}
}
#[test]
fn test_challenge_generation() {
let manager = JsChallengeManager::new(test_config());
let challenge = manager.generate_pow_challenge("actor_123");
assert_eq!(challenge.actor_id, "actor_123");
assert_eq!(challenge.difficulty, 2);
assert_eq!(challenge.prefix.len(), 16);
assert_eq!(challenge.challenge_id.len(), 32);
assert_eq!(challenge.expected_hash_prefix, "00");
assert!(challenge.expires_at > challenge.created_at);
}
#[test]
fn test_pow_verification_valid() {
let manager = JsChallengeManager::new(test_config());
let challenge = manager.generate_pow_challenge("actor_123");
let mut nonce = 0u64;
loop {
let data = format!("{}{}", challenge.prefix, nonce);
let hash = compute_sha256_hex(&data);
if hash.starts_with(&challenge.expected_hash_prefix) {
break;
}
nonce += 1;
if nonce > 100_000 {
panic!("Could not find solution in reasonable time");
}
}
let result = manager.validate_pow("actor_123", &nonce.to_string());
assert_eq!(result, ValidationResult::Valid);
}
#[test]
fn test_pow_verification_invalid() {
let manager = JsChallengeManager::new(test_config());
manager.generate_pow_challenge("actor_123");
let result = manager.validate_pow("actor_123", "invalid_nonce");
assert!(matches!(result, ValidationResult::Invalid(_)));
}
#[test]
fn test_pow_verification_not_found() {
let manager = JsChallengeManager::new(test_config());
let result = manager.validate_pow("actor_123", "12345");
assert_eq!(result, ValidationResult::NotFound);
}
#[test]
fn test_pow_verification_expired() {
let config = JsChallengeConfig {
challenge_ttl_secs: 0, ..test_config()
};
let manager = JsChallengeManager::new(config);
manager.generate_pow_challenge("actor_123");
std::thread::sleep(std::time::Duration::from_millis(10));
let result = manager.validate_pow("actor_123", "12345");
assert_eq!(result, ValidationResult::Expired);
}
#[test]
fn test_max_attempts() {
let manager = JsChallengeManager::new(test_config());
manager.generate_pow_challenge("actor_123");
for _ in 0..3 {
let _ = manager.validate_pow("actor_123", "99999999");
}
let result = manager.validate_pow("actor_123", "99999999");
assert!(matches!(result, ValidationResult::Invalid(msg) if msg.contains("Max attempts")));
}
#[test]
fn test_attempt_counting() {
let manager = JsChallengeManager::new(test_config());
manager.generate_pow_challenge("actor_123");
assert_eq!(manager.get_attempts("actor_123"), 0);
manager.validate_pow("actor_123", "99999999");
assert_eq!(manager.get_attempts("actor_123"), 1);
manager.validate_pow("actor_123", "99999999");
assert_eq!(manager.get_attempts("actor_123"), 2);
}
#[test]
fn test_should_escalate() {
let manager = JsChallengeManager::new(test_config());
manager.generate_pow_challenge("actor_123");
assert!(!manager.should_escalate("actor_123"));
for _ in 0..3 {
let _ = manager.validate_pow("actor_123", "99999999");
}
assert!(manager.should_escalate("actor_123"));
}
#[test]
fn test_challenge_page_generation() {
let manager = JsChallengeManager::new(test_config());
let challenge = manager.generate_pow_challenge("actor_123");
let html = manager.generate_challenge_page(&challenge);
assert!(html.contains("Test Challenge")); assert!(html.contains("Testing...")); assert!(html.contains(&challenge.prefix)); assert!(html.contains(&challenge.challenge_id)); assert!(html.contains("sha256")); }
#[test]
fn test_sha256_computation() {
let hash = compute_sha256_hex("test");
assert_eq!(
hash,
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
);
}
#[test]
fn test_cleanup_expired() {
let config = JsChallengeConfig {
challenge_ttl_secs: 0, ..test_config()
};
let manager = JsChallengeManager::new(config);
manager.generate_pow_challenge("actor_1");
manager.generate_pow_challenge("actor_2");
assert_eq!(manager.len(), 2);
std::thread::sleep(std::time::Duration::from_millis(10));
let removed = manager.cleanup_expired();
assert_eq!(removed, 2);
assert!(manager.is_empty());
}
#[test]
fn test_interrogator_trait() {
let manager = JsChallengeManager::new(test_config());
assert_eq!(manager.name(), "js_challenge");
assert_eq!(manager.challenge_level(), 2);
let response = manager.generate_challenge("actor_123");
match response {
ChallengeResponse::JsChallenge {
html,
expected_solution,
expires_at,
} => {
assert!(!html.is_empty());
assert_eq!(expected_solution, "00");
assert!(expires_at > now_ms());
}
_ => panic!("Expected JsChallenge response"),
}
}
#[test]
fn test_stats_tracking() {
let manager = JsChallengeManager::new(test_config());
manager.generate_pow_challenge("actor_1");
manager.generate_pow_challenge("actor_2");
let stats = manager.stats().snapshot();
assert_eq!(stats.challenges_issued, 2);
manager.validate_pow("actor_1", "99999999");
let stats = manager.stats().snapshot();
assert_eq!(stats.challenges_failed, 1);
}
#[test]
fn test_random_hex_generation() {
let hex1 = generate_random_hex(16);
let hex2 = generate_random_hex(16);
assert_eq!(hex1.len(), 16);
assert_eq!(hex2.len(), 16);
assert_ne!(hex1, hex2);
assert!(hex1.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_has_challenge() {
let manager = JsChallengeManager::new(test_config());
assert!(!manager.has_challenge("actor_123"));
manager.generate_pow_challenge("actor_123");
assert!(manager.has_challenge("actor_123"));
manager.clear();
assert!(!manager.has_challenge("actor_123"));
}
#[test]
fn test_successful_validation_clears_state() {
let config = JsChallengeConfig {
difficulty: 4, ..test_config()
};
let manager = JsChallengeManager::new(config);
let challenge = manager.generate_pow_challenge("actor_123");
let result1 = manager.validate_pow("actor_123", "99999999");
assert!(matches!(result1, ValidationResult::Invalid(_)));
let result2 = manager.validate_pow("actor_123", "99999998");
assert!(matches!(result2, ValidationResult::Invalid(_)));
assert_eq!(manager.get_attempts("actor_123"), 2);
assert!(manager.has_challenge("actor_123"));
let mut nonce = 0u64;
loop {
let data = format!("{}{}", challenge.prefix, nonce);
let hash = compute_sha256_hex(&data);
if hash.starts_with("0000") {
break;
}
nonce += 1;
}
let result = manager.validate_pow("actor_123", &nonce.to_string());
assert_eq!(result, ValidationResult::Valid);
assert!(!manager.has_challenge("actor_123"));
assert_eq!(manager.get_attempts("actor_123"), 0);
}
}