@webdecoy/fcaptcha 1.0.4 → 1.12.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 +697 -1
  2. package/package.json +1 -1
  3. package/server.js +378 -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,18 +790,349 @@ 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
 
431
802
  return detections;
432
803
  }
433
804
 
805
+ // =============================================================================
806
+ // Advanced Fingerprint Detection Functions
807
+ // =============================================================================
808
+
809
+ /**
810
+ * Analyze WebRTC signals for headless browser and VM detection
811
+ */
812
+ function analyzeWebRTC(webrtcInfo) {
813
+ if (!webrtcInfo || !webrtcInfo.supported) return [];
814
+
815
+ const detections = [];
816
+
817
+ // Check media devices - headless browsers typically have 0 devices
818
+ const mediaDevices = webrtcInfo.mediaDevices || {};
819
+ if (mediaDevices.supported && mediaDevices.totalDevices === 0) {
820
+ detections.push({
821
+ category: 'headless',
822
+ score: 0.7,
823
+ confidence: 0.75,
824
+ reason: 'No media devices detected (typical of headless browsers)'
825
+ });
826
+ }
827
+
828
+ // Suspicious: has video inputs but no audio (unusual)
829
+ if (mediaDevices.supported && mediaDevices.videoInputs > 0 && mediaDevices.audioInputs === 0) {
830
+ detections.push({
831
+ category: 'bot',
832
+ score: 0.4,
833
+ confidence: 0.5,
834
+ reason: 'Has video devices but no audio devices (unusual configuration)'
835
+ });
836
+ }
837
+
838
+ // Check local IP detection - VMs and some headless setups may not expose local IPs
839
+ if (webrtcInfo.hasLocalIP === false && !webrtcInfo.localIPError) {
840
+ detections.push({
841
+ category: 'headless',
842
+ score: 0.4,
843
+ confidence: 0.5,
844
+ reason: 'No local IP addresses detected via WebRTC'
845
+ });
846
+ }
847
+
848
+ return detections;
849
+ }
850
+
851
+ /**
852
+ * Analyze Speech API signals - voices are OS/browser specific and hard to spoof
853
+ */
854
+ function analyzeSpeechAPI(speechInfo) {
855
+ if (!speechInfo || !speechInfo.supported) return [];
856
+
857
+ const detections = [];
858
+
859
+ // No voices at all - suspicious
860
+ if (speechInfo.totalVoices === 0) {
861
+ detections.push({
862
+ category: 'headless',
863
+ score: 0.6,
864
+ confidence: 0.7,
865
+ reason: 'No speech synthesis voices available'
866
+ });
867
+ }
868
+
869
+ // Very few voices (less than 5) - could be headless or minimal VM
870
+ if (speechInfo.totalVoices > 0 && speechInfo.totalVoices < 5) {
871
+ detections.push({
872
+ category: 'headless',
873
+ score: 0.3,
874
+ confidence: 0.4,
875
+ reason: `Very few speech voices available (${speechInfo.totalVoices})`
876
+ });
877
+ }
878
+
879
+ // No local voices - all remote/network voices suggests unusual setup
880
+ if (speechInfo.localVoices === 0 && speechInfo.totalVoices > 0) {
881
+ detections.push({
882
+ category: 'bot',
883
+ score: 0.3,
884
+ confidence: 0.4,
885
+ reason: 'No local speech synthesis voices'
886
+ });
887
+ }
888
+
889
+ return detections;
890
+ }
891
+
892
+ /**
893
+ * Analyze Worker consistency - spoofed values often don't match between contexts
894
+ */
895
+ function analyzeWorkerConsistency(workerConsistency) {
896
+ if (!workerConsistency || !workerConsistency.supported) return [];
897
+
898
+ const detections = [];
899
+
900
+ // Mismatches indicate fingerprint spoofing
901
+ if (!workerConsistency.consistent && workerConsistency.mismatchCount > 0) {
902
+ const score = Math.min(0.9, 0.3 + (workerConsistency.mismatchCount * 0.15));
903
+ detections.push({
904
+ category: 'bot',
905
+ score: score,
906
+ confidence: 0.85,
907
+ reason: `Worker/main thread mismatch detected: ${workerConsistency.mismatches.join(', ')}`
908
+ });
909
+ }
910
+
911
+ return detections;
912
+ }
913
+
914
+ /**
915
+ * Analyze CSS Media Queries for environment consistency
916
+ */
917
+ function analyzeCSSMediaQueries(cssMedia, signals) {
918
+ if (!cssMedia || !cssMedia.supported) return [];
919
+
920
+ const detections = [];
921
+ const nav = (signals.environmental || {}).navigator || {};
922
+
923
+ // Check pointer consistency with touch capability
924
+ const maxTouch = nav.maxTouchPoints || 0;
925
+ if (cssMedia.pointer === 'coarse' && maxTouch === 0) {
926
+ detections.push({
927
+ category: 'bot',
928
+ score: 0.5,
929
+ confidence: 0.6,
930
+ reason: 'CSS reports coarse pointer but no touch support'
931
+ });
932
+ }
933
+
934
+ // Check hover capability - headless browsers may have unusual values
935
+ if (cssMedia.hover === false && cssMedia.pointer === 'fine') {
936
+ detections.push({
937
+ category: 'bot',
938
+ score: 0.3,
939
+ confidence: 0.4,
940
+ reason: 'Fine pointer reported but no hover capability'
941
+ });
942
+ }
943
+
944
+ // Forced colors mode with reduced motion - accessibility features
945
+ // Note: These are NOT suspicious on their own, just for fingerprinting
946
+ // Don't penalize accessibility users
947
+
948
+ return detections;
949
+ }
950
+
951
+ /**
952
+ * Analyze font detection results for consistency
953
+ */
954
+ function analyzeFonts(fontsInfo, userAgent) {
955
+ if (!fontsInfo || !fontsInfo.supported) return [];
956
+
957
+ const detections = [];
958
+
959
+ // Very few fonts detected could indicate headless browser
960
+ if (fontsInfo.count < 3) {
961
+ detections.push({
962
+ category: 'headless',
963
+ score: 0.5,
964
+ confidence: 0.5,
965
+ reason: `Very few fonts detected (${fontsInfo.count})`
966
+ });
967
+ }
968
+
969
+ // Check OS-specific fonts against UA
970
+ const ua = (userAgent || '').toLowerCase();
971
+
972
+ // Windows UA but no Segoe UI
973
+ if (ua.includes('windows') && fontsInfo.hasSegoeUI === false && fontsInfo.count > 5) {
974
+ detections.push({
975
+ category: 'bot',
976
+ score: 0.5,
977
+ confidence: 0.6,
978
+ reason: 'Windows UA but Segoe UI font not detected'
979
+ });
980
+ }
981
+
982
+ // Mac UA but no SF Pro (modern macOS)
983
+ if ((ua.includes('mac os x') || ua.includes('macintosh')) && fontsInfo.hasSFPro === false &&
984
+ ua.includes('10_15') === false && ua.includes('10_14') === false && fontsInfo.count > 5) {
985
+ // SF Pro is on macOS 10.15+ (Catalina and later)
986
+ detections.push({
987
+ category: 'bot',
988
+ score: 0.3,
989
+ confidence: 0.4,
990
+ reason: 'Modern macOS UA but SF Pro font not detected'
991
+ });
992
+ }
993
+
994
+ // Linux UA but no DejaVu Sans (very common on Linux)
995
+ if (ua.includes('linux') && !ua.includes('android') &&
996
+ fontsInfo.hasDejaVuSans === false && fontsInfo.count > 5) {
997
+ detections.push({
998
+ category: 'bot',
999
+ score: 0.4,
1000
+ confidence: 0.5,
1001
+ reason: 'Linux UA but DejaVu Sans font not detected'
1002
+ });
1003
+ }
1004
+
1005
+ return detections;
1006
+ }
1007
+
1008
+ /**
1009
+ * Analyze permissions/API availability for headless detection
1010
+ */
1011
+ function analyzePermissions(permissionsInfo) {
1012
+ if (!permissionsInfo || !permissionsInfo.supported) return [];
1013
+
1014
+ const detections = [];
1015
+
1016
+ // Count available APIs
1017
+ const apiKeys = [
1018
+ 'hasPermissionsAPI', 'hasClipboard', 'hasShare', 'hasCredentials',
1019
+ 'hasBluetooth', 'hasUsb', 'hasSerial', 'hasHid', 'hasXR',
1020
+ 'hasGeolocation', 'hasMIDI'
1021
+ ];
1022
+
1023
+ const availableApis = apiKeys.filter(key => permissionsInfo[key] === true).length;
1024
+
1025
+ // Very few APIs available could indicate minimal/headless browser
1026
+ if (availableApis < 3) {
1027
+ detections.push({
1028
+ category: 'headless',
1029
+ score: 0.4,
1030
+ confidence: 0.5,
1031
+ reason: `Very few navigator APIs available (${availableApis})`
1032
+ });
1033
+ }
1034
+
1035
+ return detections;
1036
+ }
1037
+
1038
+ /**
1039
+ * Analyze DOMRect/geometry fingerprint for rendering anomalies
1040
+ */
1041
+ function analyzeDOMRect(domRectInfo) {
1042
+ if (!domRectInfo || !domRectInfo.supported) return [];
1043
+
1044
+ const detections = [];
1045
+
1046
+ // Check for zero or very small dimensions (rendering issues)
1047
+ if (domRectInfo.rectAWidth === 0 || domRectInfo.rectBWidth === 0) {
1048
+ detections.push({
1049
+ category: 'headless',
1050
+ score: 0.6,
1051
+ confidence: 0.7,
1052
+ reason: 'DOMRect rendering returned zero-width elements'
1053
+ });
1054
+ }
1055
+
1056
+ // Check for exact integer values (unusual in real browsers)
1057
+ if (domRectInfo.rectAWidth === Math.floor(domRectInfo.rectAWidth) &&
1058
+ domRectInfo.rectBWidth === Math.floor(domRectInfo.rectBWidth) &&
1059
+ domRectInfo.rangeWidth === Math.floor(domRectInfo.rangeWidth)) {
1060
+ detections.push({
1061
+ category: 'bot',
1062
+ score: 0.3,
1063
+ confidence: 0.4,
1064
+ reason: 'DOMRect measurements are all exact integers (unusual)'
1065
+ });
1066
+ }
1067
+
1068
+ return detections;
1069
+ }
1070
+
1071
+ /**
1072
+ * Master function to analyze all advanced fingerprint signals
1073
+ */
1074
+ function analyzeAdvancedSignals(signals, userAgent) {
1075
+ const detections = [];
1076
+ const env = signals.environmental || {};
1077
+
1078
+ // WebRTC analysis
1079
+ if (env.webrtcInfo) {
1080
+ detections.push(...analyzeWebRTC(env.webrtcInfo));
1081
+ }
1082
+
1083
+ // Speech API analysis
1084
+ if (env.speechInfo) {
1085
+ detections.push(...analyzeSpeechAPI(env.speechInfo));
1086
+ }
1087
+
1088
+ // Worker consistency analysis
1089
+ if (env.workerConsistency) {
1090
+ detections.push(...analyzeWorkerConsistency(env.workerConsistency));
1091
+ }
1092
+
1093
+ // CSS Media Queries analysis
1094
+ if (env.cssMediaQueries) {
1095
+ detections.push(...analyzeCSSMediaQueries(env.cssMediaQueries, signals));
1096
+ }
1097
+
1098
+ // Font analysis
1099
+ if (env.fontsInfo) {
1100
+ detections.push(...analyzeFonts(env.fontsInfo, userAgent));
1101
+ }
1102
+
1103
+ // Permissions analysis
1104
+ if (env.permissionsInfo) {
1105
+ detections.push(...analyzePermissions(env.permissionsInfo));
1106
+ }
1107
+
1108
+ // DOMRect analysis
1109
+ if (env.domRectFingerprint) {
1110
+ detections.push(...analyzeDOMRect(env.domRectFingerprint));
1111
+ }
1112
+
1113
+ return detections;
1114
+ }
1115
+
434
1116
  module.exports = {
435
1117
  isDatacenterIP,
436
1118
  checkIPReputation,
437
1119
  analyzeHeaders,
438
1120
  parseUserAgent,
439
1121
  checkBrowserConsistency,
1122
+ checkDeclaredAIAgent,
440
1123
  checkJA3Fingerprint,
441
- analyzeFormInteraction
1124
+ checkJA4Fingerprint,
1125
+ getTrustedJA4HeaderNames,
1126
+ readJA4FromHeaders,
1127
+ KNOWN_BOT_JA4_HASHES,
1128
+ analyzeFormInteraction,
1129
+ // Advanced fingerprint detection functions
1130
+ analyzeWebRTC,
1131
+ analyzeSpeechAPI,
1132
+ analyzeWorkerConsistency,
1133
+ analyzeCSSMediaQueries,
1134
+ analyzeFonts,
1135
+ analyzePermissions,
1136
+ analyzeDOMRect,
1137
+ analyzeAdvancedSignals
442
1138
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.0.4",
3
+ "version": "1.12.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,21 @@ 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
+ // Serve the browser widget alongside the API so deployments expose a single
22
+ // origin to integrators (the implicit contract behind <serverUrl>/fcaptcha.js).
23
+ // Set FCAPTCHA_SERVE_CLIENT=false to opt out — useful when hosting the widget
24
+ // on a separate CDN / edge cache. Set FCAPTCHA_CLIENT_PATH=/abs/path/fcaptcha.js
25
+ // to override the default lookup when server-node is deployed standalone.
26
+ const CLIENT_PATH = process.env.FCAPTCHA_CLIENT_PATH
27
+ || path.join(__dirname, '..', 'client', 'fcaptcha.js');
28
+
29
+ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
30
+ app.get('/fcaptcha.js', (req, res) => {
31
+ res.sendFile(CLIENT_PATH);
32
+ });
33
+ }
18
34
 
19
35
  // =============================================================================
20
36
  // In-Memory Storage (Use Redis in production)
@@ -28,6 +44,7 @@ const powChallengeStore = {
28
44
  // Generate a new challenge
29
45
  generate(siteKey, ip, difficulty = 4) {
30
46
  const challengeId = crypto.randomBytes(16).toString('hex');
47
+ const nonce = crypto.randomBytes(16).toString('hex');
31
48
  const timestamp = Date.now();
32
49
  const expiresAt = timestamp + (5 * 60 * 1000); // 5 minutes
33
50
 
@@ -38,14 +55,14 @@ const powChallengeStore = {
38
55
  timestamp,
39
56
  expiresAt,
40
57
  difficulty,
58
+ nonce,
41
59
  prefix: `${challengeId}:${timestamp}:${difficulty}`
42
60
  };
43
61
 
44
62
  // Sign the challenge
45
63
  challengeData.sig = crypto.createHmac('sha256', SECRET_KEY)
46
64
  .update(JSON.stringify(challengeData))
47
- .digest('hex')
48
- .slice(0, 16);
65
+ .digest('hex');
49
66
 
50
67
  // Store challenge
51
68
  this.challenges.set(challengeId, {
@@ -60,8 +77,8 @@ const powChallengeStore = {
60
77
  return challengeData;
61
78
  },
62
79
 
63
- // Verify a PoW solution
64
- verify(challengeId, nonce, hash, siteKey) {
80
+ // Verify a PoW solution (signalsHash is optional for backward compat)
81
+ verify(challengeId, nonce, hash, siteKey, signalsHash = null) {
65
82
  const challenge = this.challenges.get(challengeId);
66
83
 
67
84
  if (!challenge) {
@@ -83,8 +100,10 @@ const powChallengeStore = {
83
100
  return { valid: false, reason: 'solution_already_used' };
84
101
  }
85
102
 
86
- // Verify the hash
87
- const input = `${challenge.prefix}:${nonce}`;
103
+ // Verify the hash (with optional signalsHash binding)
104
+ const input = signalsHash
105
+ ? `${challenge.prefix}:${signalsHash}:${nonce}`
106
+ : `${challenge.prefix}:${nonce}`;
88
107
  const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
89
108
 
90
109
  if (hash !== expectedHash) {
@@ -103,7 +122,10 @@ const powChallengeStore = {
103
122
  // Delete challenge (one-time use)
104
123
  this.challenges.delete(challengeId);
105
124
 
106
- return { valid: true, difficulty: challenge.difficulty };
125
+ // Calculate server-side elapsed time (un-spoofable)
126
+ const serverElapsed = Date.now() - challenge.createdAt;
127
+
128
+ return { valid: true, difficulty: challenge.difficulty, serverElapsed, nonce: challenge.nonce };
107
129
  },
108
130
 
109
131
  _cleanup() {
@@ -213,14 +235,15 @@ const AUTOMATION_UA_PATTERNS = [
213
235
  const WEIGHTS = {
214
236
  vision_ai: 0.15,
215
237
  headless: 0.15,
216
- automation: 0.10,
238
+ automation: 0.08,
217
239
  cdp: 0.12,
218
240
  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
241
+ fingerprint: 0.08,
242
+ rate_limit: 0.01,
243
+ datacenter: 0.07,
244
+ tor_vpn: 0.01,
245
+ bot: 0.13,
246
+ declared_ai: 0.02
224
247
  };
225
248
 
226
249
  // =============================================================================
@@ -236,6 +259,30 @@ function detectVisionAI(signals) {
236
259
  const b = signals.behavioral || {};
237
260
  const t = signals.temporal || {};
238
261
 
262
+ // Zero/minimal mouse movement - strong indicator of AI agent or programmatic click
263
+ // Exempt: touch users (mobile) and keyboard-only users (accessibility)
264
+ const totalPoints = b.totalPoints ?? 0;
265
+ const trajectory = b.trajectoryLength ?? 0;
266
+ const approachPts = b.approachPoints ?? 0;
267
+ const touchEvents = b.touchEvents ?? 0;
268
+ const keyEvents = b.keyEvents ?? 0;
269
+ const isTouchUser = touchEvents >= 3;
270
+ const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
271
+
272
+ if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
273
+ detections.push({
274
+ category: 'vision_ai', score: 0.9, confidence: 0.85,
275
+ reason: 'No mouse movement detected before click (AI agent pattern)'
276
+ });
277
+ }
278
+
279
+ if (approachPts === 0 && !isTouchUser && !isKeyboardUser) {
280
+ detections.push({
281
+ category: 'vision_ai', score: 0.7, confidence: 0.8,
282
+ reason: 'No approach trajectory to target'
283
+ });
284
+ }
285
+
239
286
  // PoW timing
240
287
  const pow = t.pow || {};
241
288
  if (pow.duration && pow.iterations) {
@@ -283,7 +330,6 @@ function detectVisionAI(signals) {
283
330
 
284
331
  // Exploration
285
332
  const exploration = b.explorationRatio ?? 0.3;
286
- const trajectory = b.trajectoryLength ?? 0;
287
333
  if (exploration < 0.05 && trajectory > 50) {
288
334
  detections.push({
289
335
  category: 'vision_ai', score: 0.4, confidence: 0.4,
@@ -291,6 +337,31 @@ function detectVisionAI(signals) {
291
337
  });
292
338
  }
293
339
 
340
+ // Input-event forensics: teleport clicks and agent think-time cadence.
341
+ const fcs = b.inputForensics;
342
+ if (fcs) {
343
+ const teleports = fcs.teleportClicks ?? 0;
344
+ if (teleports >= 1 && !isTouchUser) {
345
+ detections.push({
346
+ category: 'vision_ai', score: 0.7, confidence: 0.7,
347
+ reason: `Click injected with no pointer trajectory (${teleports} teleport clicks)`
348
+ });
349
+ }
350
+ // Bursts of activity separated by multi-second perfect silence — the agent
351
+ // act -> screenshot -> inference loop. Low confidence (slow humans idle too);
352
+ // requires silence to dominate. Keyboard-only users are exempt.
353
+ if (!isKeyboardUser &&
354
+ (fcs.cadenceEvents ?? 0) >= 12 &&
355
+ (fcs.cadenceSilentGaps ?? 0) >= 3 &&
356
+ (fcs.cadenceGapCV ?? 0) > 2.5 &&
357
+ (fcs.cadenceSilentRatio ?? 0) > 0.6) {
358
+ detections.push({
359
+ category: 'vision_ai', score: 0.6, confidence: 0.5,
360
+ reason: 'Interaction cadence matches agent act/think loop (bursts + dead air)'
361
+ });
362
+ }
363
+ }
364
+
294
365
  return detections;
295
366
  }
296
367
 
@@ -365,6 +436,25 @@ function detectHeadless(signals, userAgent) {
365
436
  });
366
437
  }
367
438
 
439
+ // Playwright-specific detection
440
+ const playwright = env.playwright || {};
441
+ if (playwright.detected) {
442
+ const playwrightSignals = playwright.signals || [];
443
+ const scoreMap = {
444
+ playwright_globals: 0.95,
445
+ webdriver_deleted: 0.8,
446
+ webdriver_configurable: 0.7,
447
+ chrome_runtime_missing: 0.6,
448
+ };
449
+ for (const sig of playwrightSignals) {
450
+ const sigScore = scoreMap[sig] || 0.7;
451
+ detections.push({
452
+ category: 'headless', score: sigScore, confidence: 0.8,
453
+ reason: `Playwright artifact detected: ${sig}`
454
+ });
455
+ }
456
+ }
457
+
368
458
  return detections;
369
459
  }
370
460
 
@@ -416,6 +506,38 @@ function detectCDP(signals) {
416
506
  const env = signals.environmental || {};
417
507
  const cdp = env.cdp || {};
418
508
 
509
+ // Input-event forensics: catch CDP-injected input that reports isTrusted:true
510
+ // and so evades the global-based checks below. Touch users are exempt.
511
+ const b = signals.behavioral || {};
512
+ const isTouchUser = (b.touchEvents ?? 0) >= 3;
513
+ const fcs = b.inputForensics;
514
+ if (fcs && !isTouchUser) {
515
+ // Real mice coalesce several hardware samples per frame; a stream of
516
+ // pointermoves that NEVER coalesced is synthetic injection.
517
+ if ((fcs.coalescedSamples ?? 0) >= 20 && (fcs.coalescedMax ?? 0) <= 1) {
518
+ detections.push({
519
+ category: 'cdp', score: 0.8, confidence: 0.6,
520
+ reason: 'Pointer moves never coalesced across many samples (synthetic/CDP input)'
521
+ });
522
+ }
523
+ // movementX/Y incoherent with actual position deltas across most moves.
524
+ if ((fcs.pointerMoveSamples ?? 0) >= 20 && (fcs.pointerMoveZeroRatio ?? 0) > 0.9) {
525
+ detections.push({
526
+ category: 'cdp', score: 0.6, confidence: 0.5,
527
+ reason: 'Pointer movement deltas incoherent with position (synthetic input)'
528
+ });
529
+ }
530
+ }
531
+
532
+ // CDP Runtime/DevTools console consumer attached. Low confidence: a developer
533
+ // with DevTools open also trips this, so it contributes rather than blocks.
534
+ if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
535
+ detections.push({
536
+ category: 'cdp', score: 0.6, confidence: 0.5,
537
+ reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
538
+ });
539
+ }
540
+
419
541
  if (cdp.detected) {
420
542
  const signalList = cdp.signals || [];
421
543
  const signalCount = signalList.length;
@@ -456,9 +578,29 @@ function detectBehavioral(signals) {
456
578
  const b = signals.behavioral || {};
457
579
  const t = signals.temporal || {};
458
580
 
581
+ // Insufficient mouse data - critical check for zero-click bots
582
+ // Exempt: touch users (mobile) and keyboard-only users (accessibility)
583
+ const totalPoints = b.totalPoints ?? 0;
584
+ const trajectory = b.trajectoryLength ?? 0;
585
+ const touchEvts = b.touchEvents ?? 0;
586
+ const keyEvts = b.keyEvents ?? 0;
587
+ const isTouchUsr = touchEvts >= 3;
588
+ const isKbdUser = keyEvts >= 2 && totalPoints === 0;
589
+
590
+ if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
591
+ detections.push({
592
+ category: 'behavioral', score: 0.8, confidence: 0.9,
593
+ reason: 'Zero mouse, touch, or keyboard events recorded'
594
+ });
595
+ } else if (totalPoints < 10 && !isTouchUsr && !isKbdUser && trajectory < 30) {
596
+ detections.push({
597
+ category: 'behavioral', score: 0.6, confidence: 0.7,
598
+ reason: 'Insufficient mouse movement before interaction'
599
+ });
600
+ }
601
+
459
602
  // Velocity variance
460
603
  const velVar = b.velocityVariance ?? 1;
461
- const trajectory = b.trajectoryLength ?? 0;
462
604
  if (velVar < 0.02 && trajectory > 50) {
463
605
  detections.push({
464
606
  category: 'behavioral', score: 0.6, confidence: 0.6,
@@ -523,7 +665,6 @@ function detectBehavioral(signals) {
523
665
 
524
666
  // Direction changes
525
667
  const dirChanges = b.directionChanges ?? 10;
526
- const totalPoints = b.totalPoints ?? 0;
527
668
  if (totalPoints > 50 && dirChanges < 3) {
528
669
  detections.push({
529
670
  category: 'behavioral', score: 0.4, confidence: 0.4,
@@ -534,6 +675,134 @@ function detectBehavioral(signals) {
534
675
  return detections;
535
676
  }
536
677
 
678
+ // =============================================================================
679
+ // Mobile-native detectors (touch authenticity, sensor entropy, touch kinematics)
680
+ // UA-gated on mobile. Non-mobile UAs: no-op. Designed to never penalize iOS
681
+ // Safari without permission (absence of motion events treated as neutral).
682
+ // =============================================================================
683
+
684
+ function _isMobileUA(userAgent) {
685
+ const ua = (userAgent || '').toLowerCase();
686
+ return /mobile|android|iphone|ipad|ipod/.test(ua);
687
+ }
688
+
689
+ function detectTouchAuthenticity(signals, userAgent) {
690
+ const detections = [];
691
+ if (!_isMobileUA(userAgent)) return detections;
692
+
693
+ const b = signals.behavioral || {};
694
+ const touchPoints = b.touchTotalPoints ?? b.touchEvents ?? 0;
695
+ if (touchPoints < 3) return detections;
696
+
697
+ const forceVariance = b.touchForceVariance ?? 0;
698
+ const radiusVariance = b.touchRadiusVariance ?? 0;
699
+ const forceAllOne = b.touchForceAllOne === true;
700
+ const uniqueIds = b.touchUniqueIdentifiers ?? 0;
701
+ const forceMax = b.touchForceMax ?? 0;
702
+ const radiusMax = b.touchRadiusMax ?? 0;
703
+
704
+ // Uniform non-zero force across all events → synthetic injection.
705
+ // Older Android returning all-zero is legitimate — only penalize uniformity
706
+ // when max > 0.
707
+ if (forceVariance === 0 && forceMax > 0 && touchPoints >= 5) {
708
+ detections.push({
709
+ category: 'behavioral', score: 0.75, confidence: 0.85,
710
+ reason: 'Touch force is identical across all events (synthetic touch)'
711
+ });
712
+ }
713
+
714
+ // All force=1 exactly is a common synthetic default in automation frameworks.
715
+ if (forceAllOne && touchPoints >= 5) {
716
+ detections.push({
717
+ category: 'behavioral', score: 0.8, confidence: 0.9,
718
+ reason: 'All touches report force=1.0 exactly (synthetic pattern)'
719
+ });
720
+ }
721
+
722
+ // Uniform contact radius across many events is unusual on real phones.
723
+ if (radiusVariance === 0 && radiusMax > 0 && touchPoints >= 5) {
724
+ detections.push({
725
+ category: 'behavioral', score: 0.7, confidence: 0.8,
726
+ reason: 'Touch contact radius identical across all events'
727
+ });
728
+ }
729
+
730
+ // Mobile UA with real touches but zero unique identifiers — framework default.
731
+ if (touchPoints >= 5 && uniqueIds === 0) {
732
+ detections.push({
733
+ category: 'behavioral', score: 0.6, confidence: 0.7,
734
+ reason: 'Mobile touches lack identifier tracking (synthetic injection)'
735
+ });
736
+ }
737
+
738
+ return detections;
739
+ }
740
+
741
+ function detectSensorEntropy(signals, userAgent) {
742
+ const detections = [];
743
+ if (!_isMobileUA(userAgent)) return detections;
744
+
745
+ const env = signals.environmental || {};
746
+ const sensor = env.sensor || {};
747
+ const motionCount = sensor.motionEventCount ?? 0;
748
+ const motionVariance = sensor.motionAccelVariance ?? 0;
749
+ const orientationCount = sensor.orientationEventCount ?? 0;
750
+ const orientationVariance = sensor.orientationVariance ?? 0;
751
+
752
+ // Sensor events fired but completely flat → emulator / headless mobile.
753
+ if (motionCount >= 10 && motionVariance < 0.01) {
754
+ detections.push({
755
+ category: 'headless', score: 0.7, confidence: 0.8,
756
+ reason: `Motion sensor active but flat (variance=${motionVariance.toFixed(4)}) — likely emulator`
757
+ });
758
+ }
759
+
760
+ if (orientationCount >= 10 && orientationVariance < 0.01) {
761
+ detections.push({
762
+ category: 'headless', score: 0.6, confidence: 0.7,
763
+ reason: 'Orientation sensor active but completely flat — likely emulator'
764
+ });
765
+ }
766
+
767
+ // motionCount == 0 is NEUTRAL (iOS w/o permission is the common case).
768
+
769
+ return detections;
770
+ }
771
+
772
+ function detectTouchKinematics(signals) {
773
+ const detections = [];
774
+ const b = signals.behavioral || {};
775
+ const touchPoints = b.touchTotalPoints ?? 0;
776
+ if (touchPoints < 10) return detections;
777
+
778
+ const straightLine = b.touchStraightLineRatio ?? 0;
779
+ const tremor = b.touchMicroTremorScore ?? 0;
780
+ const dirChanges = b.touchDirectionChanges ?? 0;
781
+
782
+ if (straightLine > 0.85 && touchPoints >= 20) {
783
+ detections.push({
784
+ category: 'behavioral', score: 0.65, confidence: 0.75,
785
+ reason: `Touch path too straight (ratio=${straightLine.toFixed(2)}) — automation pattern`
786
+ });
787
+ }
788
+
789
+ if (tremor < 0.05 && touchPoints >= 30) {
790
+ detections.push({
791
+ category: 'behavioral', score: 0.55, confidence: 0.65,
792
+ reason: 'Touch path has no micro-tremor (unnaturally smooth)'
793
+ });
794
+ }
795
+
796
+ if (dirChanges === 0 && touchPoints >= 30) {
797
+ detections.push({
798
+ category: 'behavioral', score: 0.5, confidence: 0.6,
799
+ reason: 'Touch path has zero direction changes over long trajectory'
800
+ });
801
+ }
802
+
803
+ return detections;
804
+ }
805
+
537
806
  function detectFingerprint(signals, ip, siteKey) {
538
807
  const detections = [];
539
808
  const env = signals.environmental || {};
@@ -653,7 +922,7 @@ function generateToken(ip, siteKey, score) {
653
922
  };
654
923
 
655
924
  const payload = JSON.stringify(data, Object.keys(data).sort());
656
- data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
925
+ data.sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
657
926
 
658
927
  return Buffer.from(JSON.stringify(data)).toString('base64url');
659
928
  }
@@ -671,7 +940,7 @@ function verifyToken(token, ip = null) {
671
940
  delete decoded.sig;
672
941
 
673
942
  const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
674
- const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
943
+ const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex');
675
944
 
676
945
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
677
946
  return { valid: false, reason: 'invalid_signature' };
@@ -705,16 +974,34 @@ function verifyToken(token, ip = null) {
705
974
  }
706
975
  }
707
976
 
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
- ];
977
+ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null) {
978
+ const detections = [];
979
+
980
+ // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
981
+ const clientSignalsHash = powSolution?.signalsHash || null;
982
+ if (signalsJson && clientSignalsHash) {
983
+ const computedHash = crypto.createHash('sha256').update(signalsJson).digest('hex');
984
+ if (computedHash !== clientSignalsHash) {
985
+ detections.push({
986
+ category: 'bot',
987
+ score: 0.95,
988
+ confidence: 0.95,
989
+ reason: 'Signals tampered after PoW (signalsHash mismatch)'
990
+ });
991
+ }
992
+ // Use signalsJson as the canonical signals source
993
+ try {
994
+ signals = JSON.parse(signalsJson);
995
+ } catch (e) {
996
+ // Fall back to parsed signals if signalsJson is invalid
997
+ }
998
+ }
999
+
1000
+ // Inject powTiming into signals.temporal.pow for detection functions
1001
+ if (powTiming) {
1002
+ if (!signals.temporal) signals.temporal = {};
1003
+ signals.temporal.pow = powTiming;
1004
+ }
718
1005
 
719
1006
  // Verify PoW if provided
720
1007
  let powValid = false;
@@ -724,7 +1011,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
724
1011
  powSolution.challengeId,
725
1012
  powSolution.nonce,
726
1013
  powSolution.hash,
727
- siteKey
1014
+ siteKey,
1015
+ clientSignalsHash
728
1016
  );
729
1017
  powValid = powVerification.valid;
730
1018
 
@@ -736,16 +1024,53 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
736
1024
  reason: `PoW verification failed: ${powVerification.reason}`
737
1025
  });
738
1026
  }
1027
+
1028
+ // Verify challenge nonce binding
1029
+ if (powValid && powVerification.nonce) {
1030
+ const clientNonce = signals.meta?.challengeNonce;
1031
+ if (!clientNonce || clientNonce !== powVerification.nonce) {
1032
+ detections.push({
1033
+ category: 'bot',
1034
+ score: 0.9,
1035
+ confidence: 0.9,
1036
+ reason: 'Challenge nonce mismatch (signals not bound to challenge)'
1037
+ });
1038
+ }
1039
+ }
1040
+
1041
+ if (powValid && powVerification.serverElapsed < 1500) {
1042
+ // Server-side timing: challenge was solved too fast (un-spoofable)
1043
+ detections.push({
1044
+ category: 'bot',
1045
+ score: 0.8,
1046
+ confidence: 0.85,
1047
+ reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
1048
+ });
1049
+ }
739
1050
  } else {
740
- // No PoW solution provided - suspicious
1051
+ // No PoW solution provided - hard fail
741
1052
  detections.push({
742
1053
  category: 'bot',
743
- score: 0.5,
744
- confidence: 0.6,
1054
+ score: 0.9,
1055
+ confidence: 0.95,
745
1056
  reason: 'No PoW solution provided'
746
1057
  });
747
1058
  }
748
1059
 
1060
+ // Run behavioral detectors
1061
+ detections.push(
1062
+ ...detectVisionAI(signals),
1063
+ ...detectHeadless(signals, userAgent),
1064
+ ...detectAutomation(signals),
1065
+ ...detectCDP(signals),
1066
+ ...detectBehavioral(signals),
1067
+ ...detectTouchAuthenticity(signals, userAgent),
1068
+ ...detectSensorEntropy(signals, userAgent),
1069
+ ...detectTouchKinematics(signals),
1070
+ ...detectFingerprint(signals, ip, siteKey),
1071
+ ...detectRateAbuse(ip, siteKey)
1072
+ );
1073
+
749
1074
  // Add IP reputation check (async but we'll use sync version for simplicity)
750
1075
  if (detection.isDatacenterIP(ip)) {
751
1076
  detections.push({
@@ -764,12 +1089,23 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
764
1089
  const consistencyDetections = detection.checkBrowserConsistency(userAgent, signals);
765
1090
  detections.push(...consistencyDetections);
766
1091
 
767
- // Add JA3 fingerprint check
1092
+ // Flag declared/verified AI agents (self-identifying UA or Web Bot Auth signature)
1093
+ detections.push(...detection.checkDeclaredAIAgent(userAgent, headers));
1094
+
1095
+ // Add JA3 fingerprint check (client-supplied — spoofable)
768
1096
  if (ja3Hash) {
769
1097
  const ja3Detections = detection.checkJA3Fingerprint(ja3Hash);
770
1098
  detections.push(...ja3Detections);
771
1099
  }
772
1100
 
1101
+ // Add JA4 fingerprint check from trusted proxy headers (un-spoofable by client)
1102
+ if (TRUSTED_JA4_HEADERS.length > 0) {
1103
+ const ja4 = detection.readJA4FromHeaders(headers, TRUSTED_JA4_HEADERS);
1104
+ if (ja4) {
1105
+ detections.push(...detection.checkJA4Fingerprint(ja4));
1106
+ }
1107
+ }
1108
+
773
1109
  // Add form interaction analysis (credential stuffing & spam detection)
774
1110
  const formAnalysis = signals.formAnalysis;
775
1111
  if (formAnalysis) {
@@ -777,6 +1113,10 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
777
1113
  detections.push(...formDetections);
778
1114
  }
779
1115
 
1116
+ // Add advanced fingerprint detection analysis
1117
+ const advancedDetections = detection.analyzeAdvancedSignals(signals, userAgent);
1118
+ detections.push(...advancedDetections);
1119
+
780
1120
  const categoryScores = calculateCategoryScores(detections);
781
1121
  const finalScore = calculateFinalScore(categoryScores);
782
1122
 
@@ -808,7 +1148,7 @@ app.get('/health', (req, res) => {
808
1148
  });
809
1149
 
810
1150
  app.post('/api/verify', (req, res) => {
811
- const { siteKey, signals, powSolution } = req.body;
1151
+ const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
812
1152
  let ip = req.headers['x-real-ip'] || '';
813
1153
  if (!ip) {
814
1154
  const forwarded = req.headers['x-forwarded-for'];
@@ -827,12 +1167,12 @@ app.post('/api/verify', (req, res) => {
827
1167
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
828
1168
  }
829
1169
 
830
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
1170
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
831
1171
  res.json(result);
832
1172
  });
833
1173
 
834
1174
  app.post('/api/score', (req, res) => {
835
- const { siteKey, signals, action, powSolution } = req.body;
1175
+ const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
836
1176
  let ip = req.headers['x-real-ip'] || '';
837
1177
  if (!ip) {
838
1178
  const forwarded = req.headers['x-forwarded-for'];
@@ -850,7 +1190,7 @@ app.post('/api/score', (req, res) => {
850
1190
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
851
1191
  }
852
1192
 
853
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
1193
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
854
1194
  res.json({
855
1195
  success: result.success,
856
1196
  score: result.score,
@@ -916,6 +1256,7 @@ app.get('/api/pow/challenge', (req, res) => {
916
1256
  prefix: challenge.prefix,
917
1257
  difficulty: challenge.difficulty,
918
1258
  expiresAt: challenge.expiresAt,
1259
+ nonce: challenge.nonce,
919
1260
  sig: challenge.sig
920
1261
  });
921
1262
  });