@webdecoy/fcaptcha 1.1.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/detection.js +376 -0
  2. package/package.json +1 -1
  3. package/server.js +413 -37
package/detection.js CHANGED
@@ -107,6 +107,69 @@ const EXPECTED_BROWSER_HEADERS = new Set([
107
107
  'accept', 'accept-language', 'accept-encoding', 'user-agent'
108
108
  ]);
109
109
 
110
+ // Self-identifying AI agents and crawlers. These agents are honest about who they
111
+ // are, so matching is high-confidence but intentionally LOW-severity: operators
112
+ // decide via policy whether to allow polite agents rather than hard-blocking.
113
+ // Keep in sync with server-go and server-python.
114
+ const DECLARED_AI_AGENT_PATTERNS = [
115
+ /\bClaudeBot\b/i,
116
+ /\bClaude-User\b/i,
117
+ /\bClaude-SearchBot\b/i,
118
+ /\banthropic-ai\b/i,
119
+ /\bGPTBot\b/i,
120
+ /\bChatGPT-User\b/i,
121
+ /\bOAI-SearchBot\b/i,
122
+ /\bPerplexityBot\b/i,
123
+ /\bPerplexity-User\b/i,
124
+ /\bGoogle-Extended\b/i,
125
+ /\bCCBot\b/i,
126
+ /\bBytespider\b/i,
127
+ /\bmeta-externalagent\b/i,
128
+ /\bAmazonbot\b/i,
129
+ /\bcohere-ai\b/i,
130
+ /\bDiffbot\b/i,
131
+ /\bYouBot\b/i
132
+ ];
133
+
134
+ // checkDeclaredAIAgent flags self-identifying AI agents via the User-Agent and
135
+ // detects Web Bot Auth signed requests (RFC 9421 HTTP Message Signatures). Both
136
+ // are high-confidence identifications surfaced as the declared_ai category so
137
+ // callers can apply an allow/block policy instead of relying on a hard score.
138
+ function checkDeclaredAIAgent(userAgent, headers = {}) {
139
+ const detections = [];
140
+ const ua = userAgent || '';
141
+
142
+ for (const re of DECLARED_AI_AGENT_PATTERNS) {
143
+ const match = ua.match(re);
144
+ if (match) {
145
+ detections.push({
146
+ category: 'declared_ai',
147
+ score: 0.5,
148
+ confidence: 0.99,
149
+ reason: `Declared AI agent user-agent: ${match[0]}`,
150
+ details: { matched: match[0] }
151
+ });
152
+ break;
153
+ }
154
+ }
155
+
156
+ // Web Bot Auth: presence of HTTP Message Signature headers identifies a
157
+ // (claimed) verified agent. v1 detects/identifies only; signature verification
158
+ // against the agent's published JWKS is a follow-up before trusting it.
159
+ const h = headers || {};
160
+ if (h['signature'] && h['signature-input'] && h['signature-agent']) {
161
+ detections.push({
162
+ category: 'declared_ai',
163
+ score: 0.4,
164
+ confidence: 0.95,
165
+ reason: `Signed agent request (Web Bot Auth): ${h['signature-agent']}`,
166
+ details: { signatureAgent: h['signature-agent'], verified: false }
167
+ });
168
+ }
169
+
170
+ return detections;
171
+ }
172
+
110
173
  function analyzeHeaders(headers) {
111
174
  const detections = [];
112
175
  const headersLower = {};
@@ -311,6 +374,296 @@ function checkJA3Fingerprint(ja3Hash) {
311
374
  return [];
312
375
  }
313
376
 
377
+ // =============================================================================
378
+ // TLS Fingerprinting (JA4) — read from trusted reverse-proxy headers
379
+ // =============================================================================
380
+ //
381
+ // Unlike the client-supplied X-JA3-Hash (which attackers can spoof), JA4
382
+ // fingerprints are computed from the TLS ClientHello by the reverse proxy
383
+ // and passed via a trusted header the server is configured to accept.
384
+ // Configure with: TRUSTED_JA4_HEADERS=cf-ja4,x-tls-ja4 (comma-separated).
385
+
386
+ const KNOWN_BOT_JA4_HASHES = {
387
+ // Populate with observed automation fingerprints in production.
388
+ // JA4 format: t##d####_####..._####
389
+ // Placeholders — administrators should add real fingerprints as they
390
+ // are collected from deployed environments.
391
+ // Example once identified:
392
+ // 't13d1516h2_8daaf6152771_02713d6af862': 'Go default stdlib TLS'
393
+ };
394
+
395
+ function getTrustedJA4HeaderNames() {
396
+ const env = process.env.TRUSTED_JA4_HEADERS;
397
+ if (!env) return [];
398
+ return env.split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
399
+ }
400
+
401
+ function readJA4FromHeaders(headers, trustedHeaderNames) {
402
+ if (!trustedHeaderNames || trustedHeaderNames.length === 0) return null;
403
+ for (const name of trustedHeaderNames) {
404
+ const v = headers[name];
405
+ if (v && typeof v === 'string' && v.trim()) return v.trim();
406
+ }
407
+ return null;
408
+ }
409
+
410
+ function checkJA4Fingerprint(ja4) {
411
+ if (!ja4) return [];
412
+ const match = KNOWN_BOT_JA4_HASHES[ja4];
413
+ if (match) {
414
+ return [{
415
+ category: 'fingerprint',
416
+ score: 0.8,
417
+ confidence: 0.9,
418
+ reason: `TLS JA4 fingerprint matches: ${match}`
419
+ }];
420
+ }
421
+ return [];
422
+ }
423
+
424
+ // =============================================================================
425
+ // Statistical Utility Functions (Keystroke Cadence Analysis)
426
+ // =============================================================================
427
+
428
+ function mean(arr) {
429
+ if (arr.length === 0) return 0;
430
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
431
+ }
432
+
433
+ function stddev(arr) {
434
+ if (arr.length < 2) return 0;
435
+ const m = mean(arr);
436
+ const variance = arr.reduce((acc, v) => acc + (v - m) * (v - m), 0) / arr.length;
437
+ return Math.sqrt(variance);
438
+ }
439
+
440
+ function erf(x) {
441
+ // Abramowitz & Stegun approximation (max error ~1.5e-7)
442
+ const sign = x >= 0 ? 1 : -1;
443
+ x = Math.abs(x);
444
+ const t = 1.0 / (1.0 + 0.3275911 * x);
445
+ const y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
446
+ return sign * y;
447
+ }
448
+
449
+ function normalCDF(x, mu, sigma) {
450
+ if (sigma <= 0) return x >= mu ? 1.0 : 0.0;
451
+ return 0.5 * (1.0 + erf((x - mu) / (sigma * Math.SQRT2)));
452
+ }
453
+
454
+ function ksTestStatistic(samples, cdfFn) {
455
+ const n = samples.length;
456
+ if (n === 0) return 0;
457
+ const sorted = [...samples].sort((a, b) => a - b);
458
+ let maxD = 0;
459
+ for (let i = 0; i < n; i++) {
460
+ const empirical = (i + 1) / n;
461
+ const theoretical = cdfFn(sorted[i]);
462
+ const d1 = Math.abs(empirical - theoretical);
463
+ const d2 = Math.abs((i / n) - theoretical);
464
+ maxD = Math.max(maxD, d1, d2);
465
+ }
466
+ return maxD;
467
+ }
468
+
469
+ function shannonEntropy(arr, bins) {
470
+ if (arr.length === 0) return 0;
471
+ const min = Math.min(...arr);
472
+ const max = Math.max(...arr);
473
+ if (max === min) return 0;
474
+ const binWidth = (max - min) / bins;
475
+ const counts = new Array(bins).fill(0);
476
+ for (const v of arr) {
477
+ let idx = Math.floor((v - min) / binWidth);
478
+ if (idx >= bins) idx = bins - 1;
479
+ counts[idx]++;
480
+ }
481
+ const n = arr.length;
482
+ let entropy = 0;
483
+ for (const c of counts) {
484
+ if (c > 0) {
485
+ const p = c / n;
486
+ entropy -= p * Math.log2(p);
487
+ }
488
+ }
489
+ return entropy;
490
+ }
491
+
492
+ function lag1Autocorrelation(arr) {
493
+ if (arr.length < 3) return 0;
494
+ const n = arr.length - 1;
495
+ const x = arr.slice(0, n);
496
+ const y = arr.slice(1);
497
+ const mx = mean(x);
498
+ const my = mean(y);
499
+ let num = 0, dx2 = 0, dy2 = 0;
500
+ for (let i = 0; i < n; i++) {
501
+ const dx = x[i] - mx;
502
+ const dy = y[i] - my;
503
+ num += dx * dy;
504
+ dx2 += dx * dx;
505
+ dy2 += dy * dy;
506
+ }
507
+ const denom = Math.sqrt(dx2 * dy2);
508
+ if (denom === 0) return 0;
509
+ return num / denom;
510
+ }
511
+
512
+ // =============================================================================
513
+ // Keystroke Cadence Analysis
514
+ // =============================================================================
515
+
516
+ function analyzeKeystrokeCadence(stats) {
517
+ const keyCount = stats.keyCount || 0;
518
+ const intervals = stats.intervals || [];
519
+ const dwellTimes = stats.dwellTimes || [];
520
+ const rollovers = stats.rollovers || 0;
521
+
522
+ // Gate on minimum data
523
+ if (keyCount < 20 || intervals.length < 15) return null;
524
+
525
+ const metrics = {};
526
+ let totalWeight = 0;
527
+ let weightedSum = 0;
528
+ let activeMetricCount = 0;
529
+
530
+ // 1. Dwell Variance (weight 0.15)
531
+ if (dwellTimes.length >= 10) {
532
+ const dSd = stddev(dwellTimes);
533
+ let score;
534
+ if (dSd < 3) score = 0;
535
+ else if (dSd < 10) score = 0.35 * (dSd - 3) / 7;
536
+ else if (dSd < 20) score = 0.35 + 0.65 * (dSd - 10) / 10;
537
+ else score = 1.0;
538
+ metrics.dwellVariance = score;
539
+ totalWeight += 0.15;
540
+ weightedSum += score * 0.15;
541
+ activeMetricCount++;
542
+ }
543
+
544
+ // 2. Log-Normal Fit (weight 0.20)
545
+ if (intervals.length >= 15) {
546
+ const logIntervals = intervals.filter(v => v > 0).map(v => Math.log(v));
547
+ if (logIntervals.length >= 10) {
548
+ const mu = mean(logIntervals);
549
+ const sigma = stddev(logIntervals);
550
+ const D = ksTestStatistic(logIntervals, (x) => normalCDF(x, mu, sigma));
551
+ // Critical value at α=0.10: 1.22 / sqrt(n)
552
+ const critical = 1.22 / Math.sqrt(logIntervals.length);
553
+ let score;
554
+ if (D <= critical * 0.8) score = 1.0;
555
+ else if (D <= critical) score = 0.7;
556
+ else if (D <= critical * 1.5) score = 0.3;
557
+ else score = 0;
558
+ metrics.logNormalFit = score;
559
+ totalWeight += 0.20;
560
+ weightedSum += score * 0.20;
561
+ activeMetricCount++;
562
+ }
563
+ }
564
+
565
+ // 3. Uniformity Detection (weight 0.15)
566
+ if (intervals.length >= 15) {
567
+ const minI = Math.min(...intervals);
568
+ const maxI = Math.max(...intervals);
569
+ if (maxI > minI) {
570
+ const D = ksTestStatistic(intervals, (x) => (x - minI) / (maxI - minI));
571
+ // Low D = looks uniform = bot-like
572
+ let score;
573
+ if (D < 0.05) score = 0;
574
+ else if (D < 0.1) score = 0.3;
575
+ else if (D < 0.15) score = 0.6;
576
+ else score = 1.0;
577
+ metrics.uniformity = score;
578
+ totalWeight += 0.15;
579
+ weightedSum += score * 0.15;
580
+ activeMetricCount++;
581
+ }
582
+ }
583
+
584
+ // 4. Lag-1 Autocorrelation (weight 0.15)
585
+ if (intervals.length >= 15) {
586
+ const r = Math.abs(lag1Autocorrelation(intervals));
587
+ let score;
588
+ if (r < 0.02) score = 0.1;
589
+ else if (r < 0.1) score = 0.1 + 0.4 * (r - 0.02) / 0.08;
590
+ else if (r < 0.2) score = 0.5 + 0.4 * (r - 0.1) / 0.1;
591
+ else if (r <= 0.4) score = 0.9;
592
+ else if (r <= 0.6) score = 0.9 - 0.4 * (r - 0.4) / 0.2;
593
+ else score = 0.5;
594
+ metrics.autocorrelation = score;
595
+ totalWeight += 0.15;
596
+ weightedSum += score * 0.15;
597
+ activeMetricCount++;
598
+ }
599
+
600
+ // 5. Burst Regularity (weight 0.10)
601
+ const burstGaps = [];
602
+ for (let i = 0; i < intervals.length; i++) {
603
+ if (intervals[i] > 300) burstGaps.push(intervals[i]);
604
+ }
605
+ if (burstGaps.length >= 3) {
606
+ const burstMean = mean(burstGaps);
607
+ const burstSd = stddev(burstGaps);
608
+ const cv = burstMean > 0 ? burstSd / burstMean : 0;
609
+ let score;
610
+ if (cv < 0.1) score = 0.1;
611
+ else if (cv < 0.3) score = 0.1 + 0.9 * (cv - 0.1) / 0.2;
612
+ else score = 1.0;
613
+ metrics.burstRegularity = score;
614
+ totalWeight += 0.10;
615
+ weightedSum += score * 0.10;
616
+ activeMetricCount++;
617
+ }
618
+
619
+ // 6. Shannon Entropy (weight 0.15)
620
+ if (intervals.length >= 15) {
621
+ const H = shannonEntropy(intervals, 10);
622
+ // Bell curve scoring: peak around H≈2.5
623
+ let score;
624
+ if (H < 0.5) score = 0.1;
625
+ else if (H < 1.5) score = 0.1 + 0.5 * (H - 0.5);
626
+ else if (H < 2.0) score = 0.6 + 0.4 * (H - 1.5) / 0.5;
627
+ else if (H <= 3.0) score = 1.0;
628
+ else if (H <= 3.3) score = 0.7;
629
+ else score = 0.4;
630
+ metrics.entropy = score;
631
+ totalWeight += 0.15;
632
+ weightedSum += score * 0.15;
633
+ activeMetricCount++;
634
+ }
635
+
636
+ // 7. Rollover Rate (weight 0.10)
637
+ if (keyCount >= 30) {
638
+ const rate = rollovers / keyCount;
639
+ let score;
640
+ if (rate === 0) score = 0.5;
641
+ else if (rate < 0.05) score = 0.5 + 0.3 * (rate / 0.05);
642
+ else if (rate < 0.15) score = 0.8 + 0.2 * ((rate - 0.05) / 0.1);
643
+ else score = 1.0;
644
+ metrics.rolloverRate = score;
645
+ totalWeight += 0.10;
646
+ weightedSum += score * 0.10;
647
+ activeMetricCount++;
648
+ }
649
+
650
+ if (totalWeight === 0) return null;
651
+
652
+ const humanScore = weightedSum / totalWeight;
653
+ const botScore = 1.0 - humanScore;
654
+
655
+ // Only emit detection when botScore > 0.55
656
+ if (botScore <= 0.55) return null;
657
+
658
+ return {
659
+ category: 'bot',
660
+ score: botScore * 0.7,
661
+ confidence: Math.min(0.7, activeMetricCount / 7),
662
+ reason: 'Keystroke cadence analysis indicates non-human typing pattern',
663
+ details: { metrics, cadenceHumanScore: humanScore }
664
+ };
665
+ }
666
+
314
667
  // =============================================================================
315
668
  // Form Interaction Analysis (Credential Stuffing & Spam Detection)
316
669
  // =============================================================================
@@ -376,6 +729,18 @@ function analyzeFormInteraction(formAnalysis) {
376
729
  const textareaData = formAnalysis.textareaKeyboard;
377
730
  if (textareaData) {
378
731
  for (const [fieldId, stats] of Object.entries(textareaData)) {
732
+ // Programmatic fill: meaningful content appeared with zero keystrokes and
733
+ // zero pastes (e.g. Playwright fill() / element.value=). The length gate
734
+ // keeps this off browser autofill of short fields.
735
+ if ((stats.contentLength || 0) > 8 && (stats.keyCount || 0) === 0 && (stats.pasteCount || 0) === 0) {
736
+ detections.push({
737
+ category: 'automation',
738
+ score: 0.8,
739
+ confidence: 0.75,
740
+ reason: `Textarea "${fieldId}" filled programmatically (${stats.contentLength} chars, no keystrokes or paste)`
741
+ });
742
+ }
743
+
379
744
  // Check for paste-heavy input (spam bots often paste content)
380
745
  if (stats.pasteCount > 0 && stats.keyCount < 5) {
381
746
  detections.push({
@@ -425,6 +790,12 @@ function analyzeFormInteraction(formAnalysis) {
425
790
  reason: `Textarea "${fieldId}" has ${stats.contentLength} chars but no keyboard events (DOM manipulation)`
426
791
  });
427
792
  }
793
+
794
+ // Keystroke cadence analysis
795
+ const cadenceResult = analyzeKeystrokeCadence(stats);
796
+ if (cadenceResult) {
797
+ detections.push(cadenceResult);
798
+ }
428
799
  }
429
800
  }
430
801
 
@@ -748,7 +1119,12 @@ module.exports = {
748
1119
  analyzeHeaders,
749
1120
  parseUserAgent,
750
1121
  checkBrowserConsistency,
1122
+ checkDeclaredAIAgent,
751
1123
  checkJA3Fingerprint,
1124
+ checkJA4Fingerprint,
1125
+ getTrustedJA4HeaderNames,
1126
+ readJA4FromHeaders,
1127
+ KNOWN_BOT_JA4_HASHES,
752
1128
  analyzeFormInteraction,
753
1129
  // Advanced fingerprint detection functions
754
1130
  analyzeWebRTC,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.1.0",
3
+ "version": "1.13.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
package/server.js CHANGED
@@ -7,6 +7,7 @@
7
7
  const express = require('express');
8
8
  const cors = require('cors');
9
9
  const crypto = require('crypto');
10
+ const path = require('path');
10
11
  const detection = require('./detection');
11
12
 
12
13
  const app = express();
@@ -15,6 +16,58 @@ app.use(express.json());
15
16
 
16
17
  const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-production';
17
18
  const PORT = process.env.PORT || 3000;
19
+ const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
20
+
21
+ // Verdict logging is off by default: a self-hosted FCaptcha emits no per-request
22
+ // logs unless the operator opts in via FCAPTCHA_LOG_VERDICTS (1/true/yes/on).
23
+ // When on, each /api/verify and /api/score logs one privacy-safe JSON line
24
+ // (score, recommendation, category scores, and per-hit category/score/confidence)
25
+ // for observability and tuning. It deliberately omits IP, user agent, raw
26
+ // signals, and the free-text detection `reason`, which can interpolate
27
+ // visitor-derived data (reverse-DNS hostname, UA/header fragments, field ids).
28
+ //
29
+ // FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW additionally includes that free-text reason.
30
+ // Separate and off by default because reasons can carry visitor-derived data —
31
+ // only enable in trusted debugging contexts with no privacy obligations.
32
+ const envFlag = (name) => ['1', 'true', 'yes', 'on']
33
+ .includes(String(process.env[name] || '').trim().toLowerCase());
34
+ const VERDICT_LOGGING_ENABLED = envFlag('FCAPTCHA_LOG_VERDICTS');
35
+ const VERDICT_LOG_INCLUDE_RAW = envFlag('FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW');
36
+ if (VERDICT_LOGGING_ENABLED && VERDICT_LOG_INCLUDE_RAW) {
37
+ console.warn('WARNING: FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW enabled — verdict logs include free-text detection reasons that may contain visitor-derived data (hostnames, UA/header fragments, field ids). Do not enable where you have privacy obligations.');
38
+ }
39
+
40
+ function logVerdict(endpoint, siteKey, result) {
41
+ if (!VERDICT_LOGGING_ENABLED || !result) return;
42
+ console.log(JSON.stringify({
43
+ event: 'verdict',
44
+ endpoint,
45
+ siteKey,
46
+ success: result.success,
47
+ score: result.score,
48
+ recommendation: result.recommendation,
49
+ categoryScores: result.categoryScores,
50
+ detections: (result.detections || []).map((d) => {
51
+ const det = { category: d.category, score: d.score, confidence: d.confidence };
52
+ if (VERDICT_LOG_INCLUDE_RAW) det.reason = d.reason;
53
+ return det;
54
+ })
55
+ }));
56
+ }
57
+
58
+ // Serve the browser widget alongside the API so deployments expose a single
59
+ // origin to integrators (the implicit contract behind <serverUrl>/fcaptcha.js).
60
+ // Set FCAPTCHA_SERVE_CLIENT=false to opt out — useful when hosting the widget
61
+ // on a separate CDN / edge cache. Set FCAPTCHA_CLIENT_PATH=/abs/path/fcaptcha.js
62
+ // to override the default lookup when server-node is deployed standalone.
63
+ const CLIENT_PATH = process.env.FCAPTCHA_CLIENT_PATH
64
+ || path.join(__dirname, '..', 'client', 'fcaptcha.js');
65
+
66
+ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
67
+ app.get('/fcaptcha.js', (req, res) => {
68
+ res.sendFile(CLIENT_PATH);
69
+ });
70
+ }
18
71
 
19
72
  // =============================================================================
20
73
  // In-Memory Storage (Use Redis in production)
@@ -28,6 +81,7 @@ const powChallengeStore = {
28
81
  // Generate a new challenge
29
82
  generate(siteKey, ip, difficulty = 4) {
30
83
  const challengeId = crypto.randomBytes(16).toString('hex');
84
+ const nonce = crypto.randomBytes(16).toString('hex');
31
85
  const timestamp = Date.now();
32
86
  const expiresAt = timestamp + (5 * 60 * 1000); // 5 minutes
33
87
 
@@ -38,14 +92,14 @@ const powChallengeStore = {
38
92
  timestamp,
39
93
  expiresAt,
40
94
  difficulty,
95
+ nonce,
41
96
  prefix: `${challengeId}:${timestamp}:${difficulty}`
42
97
  };
43
98
 
44
99
  // Sign the challenge
45
100
  challengeData.sig = crypto.createHmac('sha256', SECRET_KEY)
46
101
  .update(JSON.stringify(challengeData))
47
- .digest('hex')
48
- .slice(0, 16);
102
+ .digest('hex');
49
103
 
50
104
  // Store challenge
51
105
  this.challenges.set(challengeId, {
@@ -60,8 +114,8 @@ const powChallengeStore = {
60
114
  return challengeData;
61
115
  },
62
116
 
63
- // Verify a PoW solution
64
- verify(challengeId, nonce, hash, siteKey) {
117
+ // Verify a PoW solution (signalsHash is optional for backward compat)
118
+ verify(challengeId, nonce, hash, siteKey, signalsHash = null) {
65
119
  const challenge = this.challenges.get(challengeId);
66
120
 
67
121
  if (!challenge) {
@@ -83,8 +137,10 @@ const powChallengeStore = {
83
137
  return { valid: false, reason: 'solution_already_used' };
84
138
  }
85
139
 
86
- // Verify the hash
87
- const input = `${challenge.prefix}:${nonce}`;
140
+ // Verify the hash (with optional signalsHash binding)
141
+ const input = signalsHash
142
+ ? `${challenge.prefix}:${signalsHash}:${nonce}`
143
+ : `${challenge.prefix}:${nonce}`;
88
144
  const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
89
145
 
90
146
  if (hash !== expectedHash) {
@@ -103,7 +159,10 @@ const powChallengeStore = {
103
159
  // Delete challenge (one-time use)
104
160
  this.challenges.delete(challengeId);
105
161
 
106
- return { valid: true, difficulty: challenge.difficulty };
162
+ // Calculate server-side elapsed time (un-spoofable)
163
+ const serverElapsed = Date.now() - challenge.createdAt;
164
+
165
+ return { valid: true, difficulty: challenge.difficulty, serverElapsed, nonce: challenge.nonce };
107
166
  },
108
167
 
109
168
  _cleanup() {
@@ -213,14 +272,15 @@ const AUTOMATION_UA_PATTERNS = [
213
272
  const WEIGHTS = {
214
273
  vision_ai: 0.15,
215
274
  headless: 0.15,
216
- automation: 0.10,
275
+ automation: 0.08,
217
276
  cdp: 0.12,
218
277
  behavioral: 0.18,
219
- fingerprint: 0.10,
220
- rate_limit: 0.05,
221
- datacenter: 0.10,
222
- tor_vpn: 0.05,
223
- bot: 0.10
278
+ fingerprint: 0.08,
279
+ rate_limit: 0.01,
280
+ datacenter: 0.07,
281
+ tor_vpn: 0.01,
282
+ bot: 0.13,
283
+ declared_ai: 0.02
224
284
  };
225
285
 
226
286
  // =============================================================================
@@ -236,6 +296,30 @@ function detectVisionAI(signals) {
236
296
  const b = signals.behavioral || {};
237
297
  const t = signals.temporal || {};
238
298
 
299
+ // Zero/minimal mouse movement - strong indicator of AI agent or programmatic click
300
+ // Exempt: touch users (mobile) and keyboard-only users (accessibility)
301
+ const totalPoints = b.totalPoints ?? 0;
302
+ const trajectory = b.trajectoryLength ?? 0;
303
+ const approachPts = b.approachPoints ?? 0;
304
+ const touchEvents = b.touchEvents ?? 0;
305
+ const keyEvents = b.keyEvents ?? 0;
306
+ const isTouchUser = touchEvents >= 3;
307
+ const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
308
+
309
+ if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
310
+ detections.push({
311
+ category: 'vision_ai', score: 0.9, confidence: 0.85,
312
+ reason: 'No mouse movement detected before click (AI agent pattern)'
313
+ });
314
+ }
315
+
316
+ if (approachPts === 0 && !isTouchUser && !isKeyboardUser) {
317
+ detections.push({
318
+ category: 'vision_ai', score: 0.7, confidence: 0.8,
319
+ reason: 'No approach trajectory to target'
320
+ });
321
+ }
322
+
239
323
  // PoW timing
240
324
  const pow = t.pow || {};
241
325
  if (pow.duration && pow.iterations) {
@@ -283,7 +367,6 @@ function detectVisionAI(signals) {
283
367
 
284
368
  // Exploration
285
369
  const exploration = b.explorationRatio ?? 0.3;
286
- const trajectory = b.trajectoryLength ?? 0;
287
370
  if (exploration < 0.05 && trajectory > 50) {
288
371
  detections.push({
289
372
  category: 'vision_ai', score: 0.4, confidence: 0.4,
@@ -291,6 +374,31 @@ function detectVisionAI(signals) {
291
374
  });
292
375
  }
293
376
 
377
+ // Input-event forensics: teleport clicks and agent think-time cadence.
378
+ const fcs = b.inputForensics;
379
+ if (fcs) {
380
+ const teleports = fcs.teleportClicks ?? 0;
381
+ if (teleports >= 1 && !isTouchUser) {
382
+ detections.push({
383
+ category: 'vision_ai', score: 0.7, confidence: 0.7,
384
+ reason: `Click injected with no pointer trajectory (${teleports} teleport clicks)`
385
+ });
386
+ }
387
+ // Bursts of activity separated by multi-second perfect silence — the agent
388
+ // act -> screenshot -> inference loop. Low confidence (slow humans idle too);
389
+ // requires silence to dominate. Keyboard-only users are exempt.
390
+ if (!isKeyboardUser &&
391
+ (fcs.cadenceEvents ?? 0) >= 12 &&
392
+ (fcs.cadenceSilentGaps ?? 0) >= 3 &&
393
+ (fcs.cadenceGapCV ?? 0) > 2.5 &&
394
+ (fcs.cadenceSilentRatio ?? 0) > 0.6) {
395
+ detections.push({
396
+ category: 'vision_ai', score: 0.6, confidence: 0.5,
397
+ reason: 'Interaction cadence matches agent act/think loop (bursts + dead air)'
398
+ });
399
+ }
400
+ }
401
+
294
402
  return detections;
295
403
  }
296
404
 
@@ -365,6 +473,25 @@ function detectHeadless(signals, userAgent) {
365
473
  });
366
474
  }
367
475
 
476
+ // Playwright-specific detection
477
+ const playwright = env.playwright || {};
478
+ if (playwright.detected) {
479
+ const playwrightSignals = playwright.signals || [];
480
+ const scoreMap = {
481
+ playwright_globals: 0.95,
482
+ webdriver_deleted: 0.8,
483
+ webdriver_configurable: 0.7,
484
+ chrome_runtime_missing: 0.6,
485
+ };
486
+ for (const sig of playwrightSignals) {
487
+ const sigScore = scoreMap[sig] || 0.7;
488
+ detections.push({
489
+ category: 'headless', score: sigScore, confidence: 0.8,
490
+ reason: `Playwright artifact detected: ${sig}`
491
+ });
492
+ }
493
+ }
494
+
368
495
  return detections;
369
496
  }
370
497
 
@@ -416,6 +543,38 @@ function detectCDP(signals) {
416
543
  const env = signals.environmental || {};
417
544
  const cdp = env.cdp || {};
418
545
 
546
+ // Input-event forensics: catch CDP-injected input that reports isTrusted:true
547
+ // and so evades the global-based checks below. Touch users are exempt.
548
+ const b = signals.behavioral || {};
549
+ const isTouchUser = (b.touchEvents ?? 0) >= 3;
550
+ const fcs = b.inputForensics;
551
+ if (fcs && !isTouchUser) {
552
+ // Real mice coalesce several hardware samples per frame; a stream of
553
+ // pointermoves that NEVER coalesced is synthetic injection.
554
+ if ((fcs.coalescedSamples ?? 0) >= 20 && (fcs.coalescedMax ?? 0) <= 1) {
555
+ detections.push({
556
+ category: 'cdp', score: 0.8, confidence: 0.6,
557
+ reason: 'Pointer moves never coalesced across many samples (synthetic/CDP input)'
558
+ });
559
+ }
560
+ // movementX/Y incoherent with actual position deltas across most moves.
561
+ if ((fcs.pointerMoveSamples ?? 0) >= 20 && (fcs.pointerMoveZeroRatio ?? 0) > 0.9) {
562
+ detections.push({
563
+ category: 'cdp', score: 0.6, confidence: 0.5,
564
+ reason: 'Pointer movement deltas incoherent with position (synthetic input)'
565
+ });
566
+ }
567
+ }
568
+
569
+ // CDP Runtime/DevTools console consumer attached. Low confidence: a developer
570
+ // with DevTools open also trips this, so it contributes rather than blocks.
571
+ if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
572
+ detections.push({
573
+ category: 'cdp', score: 0.6, confidence: 0.5,
574
+ reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
575
+ });
576
+ }
577
+
419
578
  if (cdp.detected) {
420
579
  const signalList = cdp.signals || [];
421
580
  const signalCount = signalList.length;
@@ -456,9 +615,29 @@ function detectBehavioral(signals) {
456
615
  const b = signals.behavioral || {};
457
616
  const t = signals.temporal || {};
458
617
 
618
+ // Insufficient mouse data - critical check for zero-click bots
619
+ // Exempt: touch users (mobile) and keyboard-only users (accessibility)
620
+ const totalPoints = b.totalPoints ?? 0;
621
+ const trajectory = b.trajectoryLength ?? 0;
622
+ const touchEvts = b.touchEvents ?? 0;
623
+ const keyEvts = b.keyEvents ?? 0;
624
+ const isTouchUsr = touchEvts >= 3;
625
+ const isKbdUser = keyEvts >= 2 && totalPoints === 0;
626
+
627
+ if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
628
+ detections.push({
629
+ category: 'behavioral', score: 0.8, confidence: 0.9,
630
+ reason: 'Zero mouse, touch, or keyboard events recorded'
631
+ });
632
+ } else if (totalPoints < 10 && !isTouchUsr && !isKbdUser && trajectory < 30) {
633
+ detections.push({
634
+ category: 'behavioral', score: 0.6, confidence: 0.7,
635
+ reason: 'Insufficient mouse movement before interaction'
636
+ });
637
+ }
638
+
459
639
  // Velocity variance
460
640
  const velVar = b.velocityVariance ?? 1;
461
- const trajectory = b.trajectoryLength ?? 0;
462
641
  if (velVar < 0.02 && trajectory > 50) {
463
642
  detections.push({
464
643
  category: 'behavioral', score: 0.6, confidence: 0.6,
@@ -523,7 +702,6 @@ function detectBehavioral(signals) {
523
702
 
524
703
  // Direction changes
525
704
  const dirChanges = b.directionChanges ?? 10;
526
- const totalPoints = b.totalPoints ?? 0;
527
705
  if (totalPoints > 50 && dirChanges < 3) {
528
706
  detections.push({
529
707
  category: 'behavioral', score: 0.4, confidence: 0.4,
@@ -534,6 +712,134 @@ function detectBehavioral(signals) {
534
712
  return detections;
535
713
  }
536
714
 
715
+ // =============================================================================
716
+ // Mobile-native detectors (touch authenticity, sensor entropy, touch kinematics)
717
+ // UA-gated on mobile. Non-mobile UAs: no-op. Designed to never penalize iOS
718
+ // Safari without permission (absence of motion events treated as neutral).
719
+ // =============================================================================
720
+
721
+ function _isMobileUA(userAgent) {
722
+ const ua = (userAgent || '').toLowerCase();
723
+ return /mobile|android|iphone|ipad|ipod/.test(ua);
724
+ }
725
+
726
+ function detectTouchAuthenticity(signals, userAgent) {
727
+ const detections = [];
728
+ if (!_isMobileUA(userAgent)) return detections;
729
+
730
+ const b = signals.behavioral || {};
731
+ const touchPoints = b.touchTotalPoints ?? b.touchEvents ?? 0;
732
+ if (touchPoints < 3) return detections;
733
+
734
+ const forceVariance = b.touchForceVariance ?? 0;
735
+ const radiusVariance = b.touchRadiusVariance ?? 0;
736
+ const forceAllOne = b.touchForceAllOne === true;
737
+ const uniqueIds = b.touchUniqueIdentifiers ?? 0;
738
+ const forceMax = b.touchForceMax ?? 0;
739
+ const radiusMax = b.touchRadiusMax ?? 0;
740
+
741
+ // Uniform non-zero force across all events → synthetic injection.
742
+ // Older Android returning all-zero is legitimate — only penalize uniformity
743
+ // when max > 0.
744
+ if (forceVariance === 0 && forceMax > 0 && touchPoints >= 5) {
745
+ detections.push({
746
+ category: 'behavioral', score: 0.75, confidence: 0.85,
747
+ reason: 'Touch force is identical across all events (synthetic touch)'
748
+ });
749
+ }
750
+
751
+ // All force=1 exactly is a common synthetic default in automation frameworks.
752
+ if (forceAllOne && touchPoints >= 5) {
753
+ detections.push({
754
+ category: 'behavioral', score: 0.8, confidence: 0.9,
755
+ reason: 'All touches report force=1.0 exactly (synthetic pattern)'
756
+ });
757
+ }
758
+
759
+ // Uniform contact radius across many events is unusual on real phones.
760
+ if (radiusVariance === 0 && radiusMax > 0 && touchPoints >= 5) {
761
+ detections.push({
762
+ category: 'behavioral', score: 0.7, confidence: 0.8,
763
+ reason: 'Touch contact radius identical across all events'
764
+ });
765
+ }
766
+
767
+ // Mobile UA with real touches but zero unique identifiers — framework default.
768
+ if (touchPoints >= 5 && uniqueIds === 0) {
769
+ detections.push({
770
+ category: 'behavioral', score: 0.6, confidence: 0.7,
771
+ reason: 'Mobile touches lack identifier tracking (synthetic injection)'
772
+ });
773
+ }
774
+
775
+ return detections;
776
+ }
777
+
778
+ function detectSensorEntropy(signals, userAgent) {
779
+ const detections = [];
780
+ if (!_isMobileUA(userAgent)) return detections;
781
+
782
+ const env = signals.environmental || {};
783
+ const sensor = env.sensor || {};
784
+ const motionCount = sensor.motionEventCount ?? 0;
785
+ const motionVariance = sensor.motionAccelVariance ?? 0;
786
+ const orientationCount = sensor.orientationEventCount ?? 0;
787
+ const orientationVariance = sensor.orientationVariance ?? 0;
788
+
789
+ // Sensor events fired but completely flat → emulator / headless mobile.
790
+ if (motionCount >= 10 && motionVariance < 0.01) {
791
+ detections.push({
792
+ category: 'headless', score: 0.7, confidence: 0.8,
793
+ reason: `Motion sensor active but flat (variance=${motionVariance.toFixed(4)}) — likely emulator`
794
+ });
795
+ }
796
+
797
+ if (orientationCount >= 10 && orientationVariance < 0.01) {
798
+ detections.push({
799
+ category: 'headless', score: 0.6, confidence: 0.7,
800
+ reason: 'Orientation sensor active but completely flat — likely emulator'
801
+ });
802
+ }
803
+
804
+ // motionCount == 0 is NEUTRAL (iOS w/o permission is the common case).
805
+
806
+ return detections;
807
+ }
808
+
809
+ function detectTouchKinematics(signals) {
810
+ const detections = [];
811
+ const b = signals.behavioral || {};
812
+ const touchPoints = b.touchTotalPoints ?? 0;
813
+ if (touchPoints < 10) return detections;
814
+
815
+ const straightLine = b.touchStraightLineRatio ?? 0;
816
+ const tremor = b.touchMicroTremorScore ?? 0;
817
+ const dirChanges = b.touchDirectionChanges ?? 0;
818
+
819
+ if (straightLine > 0.85 && touchPoints >= 20) {
820
+ detections.push({
821
+ category: 'behavioral', score: 0.65, confidence: 0.75,
822
+ reason: `Touch path too straight (ratio=${straightLine.toFixed(2)}) — automation pattern`
823
+ });
824
+ }
825
+
826
+ if (tremor < 0.05 && touchPoints >= 30) {
827
+ detections.push({
828
+ category: 'behavioral', score: 0.55, confidence: 0.65,
829
+ reason: 'Touch path has no micro-tremor (unnaturally smooth)'
830
+ });
831
+ }
832
+
833
+ if (dirChanges === 0 && touchPoints >= 30) {
834
+ detections.push({
835
+ category: 'behavioral', score: 0.5, confidence: 0.6,
836
+ reason: 'Touch path has zero direction changes over long trajectory'
837
+ });
838
+ }
839
+
840
+ return detections;
841
+ }
842
+
537
843
  function detectFingerprint(signals, ip, siteKey) {
538
844
  const detections = [];
539
845
  const env = signals.environmental || {};
@@ -653,7 +959,7 @@ function generateToken(ip, siteKey, score) {
653
959
  };
654
960
 
655
961
  const payload = JSON.stringify(data, Object.keys(data).sort());
656
- data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
962
+ data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
657
963
 
658
964
  return Buffer.from(JSON.stringify(data)).toString('base64url');
659
965
  }
@@ -671,7 +977,7 @@ function verifyToken(token, ip = null) {
671
977
  delete decoded.sig;
672
978
 
673
979
  const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
674
- const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
980
+ const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
675
981
 
676
982
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
677
983
  return { valid: false, reason: 'invalid_signature' };
@@ -705,16 +1011,34 @@ function verifyToken(token, ip = null) {
705
1011
  }
706
1012
  }
707
1013
 
708
- function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null) {
709
- const detections = [
710
- ...detectVisionAI(signals),
711
- ...detectHeadless(signals, userAgent),
712
- ...detectAutomation(signals),
713
- ...detectCDP(signals),
714
- ...detectBehavioral(signals),
715
- ...detectFingerprint(signals, ip, siteKey),
716
- ...detectRateAbuse(ip, siteKey)
717
- ];
1014
+ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null) {
1015
+ const detections = [];
1016
+
1017
+ // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
1018
+ const clientSignalsHash = powSolution?.signalsHash || null;
1019
+ if (signalsJson && clientSignalsHash) {
1020
+ const computedHash = crypto.createHash('sha256').update(signalsJson).digest('hex');
1021
+ if (computedHash !== clientSignalsHash) {
1022
+ detections.push({
1023
+ category: 'bot',
1024
+ score: 0.95,
1025
+ confidence: 0.95,
1026
+ reason: 'Signals tampered after PoW (signalsHash mismatch)'
1027
+ });
1028
+ }
1029
+ // Use signalsJson as the canonical signals source
1030
+ try {
1031
+ signals = JSON.parse(signalsJson);
1032
+ } catch (e) {
1033
+ // Fall back to parsed signals if signalsJson is invalid
1034
+ }
1035
+ }
1036
+
1037
+ // Inject powTiming into signals.temporal.pow for detection functions
1038
+ if (powTiming) {
1039
+ if (!signals.temporal) signals.temporal = {};
1040
+ signals.temporal.pow = powTiming;
1041
+ }
718
1042
 
719
1043
  // Verify PoW if provided
720
1044
  let powValid = false;
@@ -724,7 +1048,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
724
1048
  powSolution.challengeId,
725
1049
  powSolution.nonce,
726
1050
  powSolution.hash,
727
- siteKey
1051
+ siteKey,
1052
+ clientSignalsHash
728
1053
  );
729
1054
  powValid = powVerification.valid;
730
1055
 
@@ -736,16 +1061,53 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
736
1061
  reason: `PoW verification failed: ${powVerification.reason}`
737
1062
  });
738
1063
  }
1064
+
1065
+ // Verify challenge nonce binding
1066
+ if (powValid && powVerification.nonce) {
1067
+ const clientNonce = signals.meta?.challengeNonce;
1068
+ if (!clientNonce || clientNonce !== powVerification.nonce) {
1069
+ detections.push({
1070
+ category: 'bot',
1071
+ score: 0.9,
1072
+ confidence: 0.9,
1073
+ reason: 'Challenge nonce mismatch (signals not bound to challenge)'
1074
+ });
1075
+ }
1076
+ }
1077
+
1078
+ if (powValid && powVerification.serverElapsed < 1500) {
1079
+ // Server-side timing: challenge was solved too fast (un-spoofable)
1080
+ detections.push({
1081
+ category: 'bot',
1082
+ score: 0.8,
1083
+ confidence: 0.85,
1084
+ reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
1085
+ });
1086
+ }
739
1087
  } else {
740
- // No PoW solution provided - suspicious
1088
+ // No PoW solution provided - hard fail
741
1089
  detections.push({
742
1090
  category: 'bot',
743
- score: 0.5,
744
- confidence: 0.6,
1091
+ score: 0.9,
1092
+ confidence: 0.95,
745
1093
  reason: 'No PoW solution provided'
746
1094
  });
747
1095
  }
748
1096
 
1097
+ // Run behavioral detectors
1098
+ detections.push(
1099
+ ...detectVisionAI(signals),
1100
+ ...detectHeadless(signals, userAgent),
1101
+ ...detectAutomation(signals),
1102
+ ...detectCDP(signals),
1103
+ ...detectBehavioral(signals),
1104
+ ...detectTouchAuthenticity(signals, userAgent),
1105
+ ...detectSensorEntropy(signals, userAgent),
1106
+ ...detectTouchKinematics(signals),
1107
+ ...detectFingerprint(signals, ip, siteKey),
1108
+ ...detectRateAbuse(ip, siteKey)
1109
+ );
1110
+
749
1111
  // Add IP reputation check (async but we'll use sync version for simplicity)
750
1112
  if (detection.isDatacenterIP(ip)) {
751
1113
  detections.push({
@@ -764,12 +1126,23 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
764
1126
  const consistencyDetections = detection.checkBrowserConsistency(userAgent, signals);
765
1127
  detections.push(...consistencyDetections);
766
1128
 
767
- // Add JA3 fingerprint check
1129
+ // Flag declared/verified AI agents (self-identifying UA or Web Bot Auth signature)
1130
+ detections.push(...detection.checkDeclaredAIAgent(userAgent, headers));
1131
+
1132
+ // Add JA3 fingerprint check (client-supplied — spoofable)
768
1133
  if (ja3Hash) {
769
1134
  const ja3Detections = detection.checkJA3Fingerprint(ja3Hash);
770
1135
  detections.push(...ja3Detections);
771
1136
  }
772
1137
 
1138
+ // Add JA4 fingerprint check from trusted proxy headers (un-spoofable by client)
1139
+ if (TRUSTED_JA4_HEADERS.length > 0) {
1140
+ const ja4 = detection.readJA4FromHeaders(headers, TRUSTED_JA4_HEADERS);
1141
+ if (ja4) {
1142
+ detections.push(...detection.checkJA4Fingerprint(ja4));
1143
+ }
1144
+ }
1145
+
773
1146
  // Add form interaction analysis (credential stuffing & spam detection)
774
1147
  const formAnalysis = signals.formAnalysis;
775
1148
  if (formAnalysis) {
@@ -812,7 +1185,7 @@ app.get('/health', (req, res) => {
812
1185
  });
813
1186
 
814
1187
  app.post('/api/verify', (req, res) => {
815
- const { siteKey, signals, powSolution } = req.body;
1188
+ const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
816
1189
  let ip = req.headers['x-real-ip'] || '';
817
1190
  if (!ip) {
818
1191
  const forwarded = req.headers['x-forwarded-for'];
@@ -831,12 +1204,13 @@ app.post('/api/verify', (req, res) => {
831
1204
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
832
1205
  }
833
1206
 
834
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
1207
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1208
+ logVerdict('verify', siteKey, result);
835
1209
  res.json(result);
836
1210
  });
837
1211
 
838
1212
  app.post('/api/score', (req, res) => {
839
- const { siteKey, signals, action, powSolution } = req.body;
1213
+ const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
840
1214
  let ip = req.headers['x-real-ip'] || '';
841
1215
  if (!ip) {
842
1216
  const forwarded = req.headers['x-forwarded-for'];
@@ -854,7 +1228,8 @@ app.post('/api/score', (req, res) => {
854
1228
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
855
1229
  }
856
1230
 
857
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
1231
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1232
+ logVerdict('score', siteKey, result);
858
1233
  res.json({
859
1234
  success: result.success,
860
1235
  score: result.score,
@@ -920,6 +1295,7 @@ app.get('/api/pow/challenge', (req, res) => {
920
1295
  prefix: challenge.prefix,
921
1296
  difficulty: challenge.difficulty,
922
1297
  expiresAt: challenge.expiresAt,
1298
+ nonce: challenge.nonce,
923
1299
  sig: challenge.sig
924
1300
  });
925
1301
  });