@webdecoy/fcaptcha 1.0.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 (4) hide show
  1. package/detection.js +442 -0
  2. package/index.js +799 -0
  3. package/package.json +51 -0
  4. package/server.js +842 -0
package/server.js ADDED
@@ -0,0 +1,842 @@
1
+ /**
2
+ * FCaptcha Server - Node.js/Express Implementation
3
+ *
4
+ * Run: node server.js
5
+ */
6
+
7
+ const express = require('express');
8
+ const cors = require('cors');
9
+ const crypto = require('crypto');
10
+ const detection = require('./detection');
11
+
12
+ const app = express();
13
+ app.use(cors());
14
+ app.use(express.json());
15
+
16
+ const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-production';
17
+ const PORT = process.env.PORT || 3000;
18
+
19
+ // =============================================================================
20
+ // In-Memory Storage (Use Redis in production)
21
+ // =============================================================================
22
+
23
+ // PoW Challenge Store
24
+ const powChallengeStore = {
25
+ challenges: new Map(),
26
+ usedSolutions: new Set(),
27
+
28
+ // Generate a new challenge
29
+ generate(siteKey, ip, difficulty = 4) {
30
+ const challengeId = crypto.randomBytes(16).toString('hex');
31
+ const timestamp = Date.now();
32
+ const expiresAt = timestamp + (5 * 60 * 1000); // 5 minutes
33
+
34
+ // Challenge data to be signed
35
+ const challengeData = {
36
+ id: challengeId,
37
+ siteKey,
38
+ timestamp,
39
+ expiresAt,
40
+ difficulty,
41
+ prefix: `${challengeId}:${timestamp}:${difficulty}`
42
+ };
43
+
44
+ // Sign the challenge
45
+ const sig = crypto.createHmac('sha256', SECRET_KEY)
46
+ .update(JSON.stringify(challengeData))
47
+ .digest('hex')
48
+ .slice(0, 16);
49
+
50
+ challengeData.sig = sig;
51
+
52
+ // Store challenge
53
+ this.challenges.set(challengeId, {
54
+ ...challengeData,
55
+ ip,
56
+ createdAt: timestamp
57
+ });
58
+
59
+ // Cleanup old challenges periodically
60
+ if (Math.random() < 0.1) this._cleanup();
61
+
62
+ return challengeData;
63
+ },
64
+
65
+ // Verify a PoW solution
66
+ verify(challengeId, nonce, hash, siteKey) {
67
+ const challenge = this.challenges.get(challengeId);
68
+
69
+ if (!challenge) {
70
+ return { valid: false, reason: 'challenge_not_found' };
71
+ }
72
+
73
+ if (Date.now() > challenge.expiresAt) {
74
+ this.challenges.delete(challengeId);
75
+ return { valid: false, reason: 'challenge_expired' };
76
+ }
77
+
78
+ if (challenge.siteKey !== siteKey) {
79
+ return { valid: false, reason: 'site_key_mismatch' };
80
+ }
81
+
82
+ // Check if solution was already used (prevent replay)
83
+ const solutionKey = `${challengeId}:${nonce}`;
84
+ if (this.usedSolutions.has(solutionKey)) {
85
+ return { valid: false, reason: 'solution_already_used' };
86
+ }
87
+
88
+ // Verify the hash
89
+ const input = `${challenge.prefix}:${nonce}`;
90
+ const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
91
+
92
+ if (hash !== expectedHash) {
93
+ return { valid: false, reason: 'invalid_hash' };
94
+ }
95
+
96
+ // Check difficulty (hash must start with N zeros)
97
+ const target = '0'.repeat(challenge.difficulty);
98
+ if (!hash.startsWith(target)) {
99
+ return { valid: false, reason: 'insufficient_difficulty' };
100
+ }
101
+
102
+ // Mark solution as used
103
+ this.usedSolutions.add(solutionKey);
104
+
105
+ // Delete challenge (one-time use)
106
+ this.challenges.delete(challengeId);
107
+
108
+ return { valid: true, difficulty: challenge.difficulty };
109
+ },
110
+
111
+ _cleanup() {
112
+ const now = Date.now();
113
+ for (const [id, challenge] of this.challenges) {
114
+ if (now > challenge.expiresAt) {
115
+ this.challenges.delete(id);
116
+ }
117
+ }
118
+ // Cleanup old solutions (keep last hour)
119
+ if (this.usedSolutions.size > 10000) {
120
+ this.usedSolutions.clear();
121
+ }
122
+ }
123
+ };
124
+
125
+ const rateLimiter = {
126
+ requests: new Map(),
127
+
128
+ check(key, windowSeconds = 60, maxRequests = 10) {
129
+ const now = Date.now();
130
+ const cutoff = now - (windowSeconds * 1000);
131
+
132
+ let timestamps = this.requests.get(key) || [];
133
+ timestamps = timestamps.filter(t => t > cutoff);
134
+
135
+ const count = timestamps.length;
136
+ if (count >= maxRequests) {
137
+ return [true, count];
138
+ }
139
+
140
+ timestamps.push(now);
141
+ this.requests.set(key, timestamps);
142
+ return [false, count + 1];
143
+ }
144
+ };
145
+
146
+ const fingerprintStore = {
147
+ fingerprints: new Map(),
148
+ ipFingerprints: new Map(),
149
+
150
+ record(fp, ip, siteKey) {
151
+ const key = `${siteKey}:${fp}`;
152
+
153
+ if (!this.fingerprints.has(key)) {
154
+ this.fingerprints.set(key, { count: 0, ips: new Set() });
155
+ }
156
+ const data = this.fingerprints.get(key);
157
+ data.count++;
158
+ data.ips.add(ip);
159
+
160
+ if (!this.ipFingerprints.has(ip)) {
161
+ this.ipFingerprints.set(ip, new Set());
162
+ }
163
+ this.ipFingerprints.get(ip).add(fp);
164
+ },
165
+
166
+ getIpFpCount(ip) {
167
+ return this.ipFingerprints.get(ip)?.size || 0;
168
+ },
169
+
170
+ getFpIpCount(fp, siteKey) {
171
+ const key = `${siteKey}:${fp}`;
172
+ return this.fingerprints.get(key)?.ips.size || 0;
173
+ }
174
+ };
175
+
176
+ // =============================================================================
177
+ // Detection Patterns
178
+ // =============================================================================
179
+
180
+ const AUTOMATION_UA_PATTERNS = [
181
+ /headless/i, /phantomjs/i, /selenium/i, /webdriver/i,
182
+ /puppeteer/i, /playwright/i, /cypress/i, /nightwatch/i,
183
+ /zombie/i, /electron/i, /chromium.*headless/i
184
+ ];
185
+
186
+ const WEIGHTS = {
187
+ vision_ai: 0.15,
188
+ headless: 0.15,
189
+ automation: 0.10,
190
+ behavioral: 0.20,
191
+ fingerprint: 0.10,
192
+ rate_limit: 0.05,
193
+ datacenter: 0.10,
194
+ tor_vpn: 0.05,
195
+ bot: 0.10
196
+ };
197
+
198
+ // =============================================================================
199
+ // Detection Functions
200
+ // =============================================================================
201
+
202
+ function getNestedValue(obj, ...keys) {
203
+ return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
204
+ }
205
+
206
+ function detectVisionAI(signals) {
207
+ const detections = [];
208
+ const b = signals.behavioral || {};
209
+ const t = signals.temporal || {};
210
+
211
+ // PoW timing
212
+ const pow = t.pow || {};
213
+ if (pow.duration && pow.iterations) {
214
+ const expectedMin = (pow.iterations / 500000) * 1000;
215
+ const expectedMax = (pow.iterations / 50000) * 1000;
216
+
217
+ if (pow.duration < expectedMin * 0.5) {
218
+ detections.push({
219
+ category: 'vision_ai', score: 0.8, confidence: 0.7,
220
+ reason: 'PoW completed impossibly fast'
221
+ });
222
+ } else if (pow.duration > expectedMax * 3) {
223
+ detections.push({
224
+ category: 'vision_ai', score: 0.6, confidence: 0.5,
225
+ reason: 'PoW timing suggests external processing'
226
+ });
227
+ }
228
+ }
229
+
230
+ // Micro-tremor
231
+ const microTremor = b.microTremorScore ?? 0.5;
232
+ if (microTremor < 0.15) {
233
+ detections.push({
234
+ category: 'vision_ai', score: 0.7, confidence: 0.6,
235
+ reason: 'Mouse movement lacks natural micro-tremor'
236
+ });
237
+ }
238
+
239
+ // Approach directness
240
+ if ((b.approachDirectness ?? 0) > 0.95) {
241
+ detections.push({
242
+ category: 'vision_ai', score: 0.5, confidence: 0.5,
243
+ reason: 'Mouse path to target is unnaturally direct'
244
+ });
245
+ }
246
+
247
+ // Click precision
248
+ const precision = b.clickPrecision ?? 10;
249
+ if (precision > 0 && precision < 2) {
250
+ detections.push({
251
+ category: 'vision_ai', score: 0.4, confidence: 0.5,
252
+ reason: 'Click precision is unnaturally accurate'
253
+ });
254
+ }
255
+
256
+ // Exploration
257
+ const exploration = b.explorationRatio ?? 0.3;
258
+ const trajectory = b.trajectoryLength ?? 0;
259
+ if (exploration < 0.05 && trajectory > 50) {
260
+ detections.push({
261
+ category: 'vision_ai', score: 0.4, confidence: 0.4,
262
+ reason: 'No exploratory mouse movement before click'
263
+ });
264
+ }
265
+
266
+ return detections;
267
+ }
268
+
269
+ function detectHeadless(signals, userAgent) {
270
+ const detections = [];
271
+ const env = signals.environmental || {};
272
+ const headless = env.headlessIndicators || {};
273
+ const automation = env.automationFlags || {};
274
+
275
+ // WebDriver
276
+ if (env.webdriver) {
277
+ detections.push({
278
+ category: 'headless', score: 0.95, confidence: 0.95,
279
+ reason: 'WebDriver detected'
280
+ });
281
+ }
282
+
283
+ // Automation flags
284
+ if (automation.plugins === 0) {
285
+ detections.push({
286
+ category: 'headless', score: 0.6, confidence: 0.6,
287
+ reason: 'No browser plugins detected'
288
+ });
289
+ }
290
+
291
+ if (automation.languages === false) {
292
+ detections.push({
293
+ category: 'headless', score: 0.5, confidence: 0.5,
294
+ reason: 'No navigator.languages'
295
+ });
296
+ }
297
+
298
+ // Headless indicators
299
+ if (headless.hasOuterDimensions === false) {
300
+ detections.push({
301
+ category: 'headless', score: 0.7, confidence: 0.7,
302
+ reason: 'Window lacks outer dimensions'
303
+ });
304
+ }
305
+
306
+ if (headless.innerEqualsOuter === true) {
307
+ detections.push({
308
+ category: 'headless', score: 0.4, confidence: 0.5,
309
+ reason: 'Viewport equals window size'
310
+ });
311
+ }
312
+
313
+ if (headless.notificationPermission === 'denied') {
314
+ detections.push({
315
+ category: 'headless', score: 0.3, confidence: 0.4,
316
+ reason: 'Notifications pre-denied'
317
+ });
318
+ }
319
+
320
+ // User-Agent patterns
321
+ for (const pattern of AUTOMATION_UA_PATTERNS) {
322
+ if (pattern.test(userAgent)) {
323
+ detections.push({
324
+ category: 'headless', score: 0.9, confidence: 0.9,
325
+ reason: 'Automation pattern in User-Agent'
326
+ });
327
+ break;
328
+ }
329
+ }
330
+
331
+ // WebGL renderer
332
+ const renderer = (getNestedValue(env, 'webglInfo', 'renderer') || '').toLowerCase();
333
+ if (renderer.includes('swiftshader') || renderer.includes('llvmpipe')) {
334
+ detections.push({
335
+ category: 'headless', score: 0.8, confidence: 0.8,
336
+ reason: 'Software WebGL renderer detected'
337
+ });
338
+ }
339
+
340
+ return detections;
341
+ }
342
+
343
+ function detectAutomation(signals) {
344
+ const detections = [];
345
+ const env = signals.environmental || {};
346
+ const b = signals.behavioral || {};
347
+
348
+ // JS execution timing
349
+ const jsTime = getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
350
+ if (jsTime > 0) {
351
+ if (jsTime < 0.1) {
352
+ detections.push({
353
+ category: 'automation', score: 0.4, confidence: 0.3,
354
+ reason: 'JS execution unusually fast'
355
+ });
356
+ } else if (jsTime > 50) {
357
+ detections.push({
358
+ category: 'automation', score: 0.3, confidence: 0.3,
359
+ reason: 'JS execution unusually slow'
360
+ });
361
+ }
362
+ }
363
+
364
+ // RAF consistency
365
+ const raf = env.rafConsistency || {};
366
+ if (raf.frameTimeVariance !== undefined && raf.frameTimeVariance < 0.1) {
367
+ detections.push({
368
+ category: 'automation', score: 0.5, confidence: 0.4,
369
+ reason: 'RequestAnimationFrame timing too consistent'
370
+ });
371
+ }
372
+
373
+ // Event timing
374
+ const eventVar = b.eventDeltaVariance ?? 10;
375
+ const totalPoints = b.totalPoints ?? 0;
376
+ if (eventVar < 2 && totalPoints > 10) {
377
+ detections.push({
378
+ category: 'automation', score: 0.6, confidence: 0.6,
379
+ reason: 'Mouse event timing unnaturally consistent'
380
+ });
381
+ }
382
+
383
+ return detections;
384
+ }
385
+
386
+ function detectBehavioral(signals) {
387
+ const detections = [];
388
+ const b = signals.behavioral || {};
389
+ const t = signals.temporal || {};
390
+
391
+ // Velocity variance
392
+ const velVar = b.velocityVariance ?? 1;
393
+ const trajectory = b.trajectoryLength ?? 0;
394
+ if (velVar < 0.02 && trajectory > 50) {
395
+ detections.push({
396
+ category: 'behavioral', score: 0.6, confidence: 0.6,
397
+ reason: 'Mouse velocity too consistent'
398
+ });
399
+ }
400
+
401
+ // Overshoot
402
+ const overshoots = b.overshootCorrections ?? 0;
403
+ if (overshoots === 0 && trajectory > 200) {
404
+ detections.push({
405
+ category: 'behavioral', score: 0.4, confidence: 0.4,
406
+ reason: 'No overshoot corrections on long trajectory'
407
+ });
408
+ }
409
+
410
+ // Interaction speed
411
+ const interactionTime = b.interactionDuration ?? 1000;
412
+ if (interactionTime > 0 && interactionTime < 200) {
413
+ detections.push({
414
+ category: 'behavioral', score: 0.7, confidence: 0.7,
415
+ reason: 'Interaction completed too quickly'
416
+ });
417
+ } else if (interactionTime > 60000) {
418
+ detections.push({
419
+ category: 'captcha_farm', score: 0.3, confidence: 0.3,
420
+ reason: 'Unusually long interaction time'
421
+ });
422
+ }
423
+
424
+ // First interaction
425
+ const firstInt = t.pageLoadToFirstInteraction;
426
+ if (firstInt !== null && firstInt > 0 && firstInt < 100) {
427
+ detections.push({
428
+ category: 'behavioral', score: 0.5, confidence: 0.5,
429
+ reason: 'First interaction too soon after page load'
430
+ });
431
+ }
432
+
433
+ // Mouse event rate
434
+ const eventRate = b.mouseEventRate ?? 60;
435
+ if (eventRate > 200) {
436
+ detections.push({
437
+ category: 'behavioral', score: 0.6, confidence: 0.5,
438
+ reason: 'Mouse event rate abnormally high'
439
+ });
440
+ } else if (eventRate > 0 && eventRate < 10) {
441
+ detections.push({
442
+ category: 'behavioral', score: 0.4, confidence: 0.4,
443
+ reason: 'Mouse event rate abnormally low'
444
+ });
445
+ }
446
+
447
+ // Straight line ratio
448
+ const straight = b.straightLineRatio ?? 0;
449
+ if (straight > 0.8 && trajectory > 100) {
450
+ detections.push({
451
+ category: 'behavioral', score: 0.5, confidence: 0.5,
452
+ reason: 'Mouse movements too straight'
453
+ });
454
+ }
455
+
456
+ // Direction changes
457
+ const dirChanges = b.directionChanges ?? 10;
458
+ const totalPoints = b.totalPoints ?? 0;
459
+ if (totalPoints > 50 && dirChanges < 3) {
460
+ detections.push({
461
+ category: 'behavioral', score: 0.4, confidence: 0.4,
462
+ reason: 'Too few direction changes'
463
+ });
464
+ }
465
+
466
+ return detections;
467
+ }
468
+
469
+ function detectFingerprint(signals, ip, siteKey) {
470
+ const detections = [];
471
+ const env = signals.environmental || {};
472
+ const automation = env.automationFlags || {};
473
+
474
+ // Generate fingerprint
475
+ const components = [
476
+ String(getNestedValue(env, 'canvasHash', 'hash') || ''),
477
+ String(getNestedValue(env, 'webglInfo', 'renderer') || ''),
478
+ String(automation.platform || ''),
479
+ String(automation.hardwareConcurrency || '')
480
+ ];
481
+ const fp = crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
482
+
483
+ fingerprintStore.record(fp, ip, siteKey);
484
+
485
+ // IP fingerprint count
486
+ const ipFpCount = fingerprintStore.getIpFpCount(ip);
487
+ if (ipFpCount > 5) {
488
+ detections.push({
489
+ category: 'fingerprint', score: 0.6, confidence: 0.6,
490
+ reason: 'IP has used many different fingerprints'
491
+ });
492
+ }
493
+
494
+ // Fingerprint IP count
495
+ const fpIpCount = fingerprintStore.getFpIpCount(fp, siteKey);
496
+ if (fpIpCount > 10) {
497
+ detections.push({
498
+ category: 'fingerprint', score: 0.5, confidence: 0.5,
499
+ reason: 'Fingerprint seen from many IPs'
500
+ });
501
+ }
502
+
503
+ // Canvas issues
504
+ const canvas = env.canvasHash || {};
505
+ if (canvas.error || canvas.supported === false) {
506
+ detections.push({
507
+ category: 'fingerprint', score: 0.4, confidence: 0.4,
508
+ reason: 'Canvas fingerprinting blocked or failed'
509
+ });
510
+ }
511
+
512
+ return detections;
513
+ }
514
+
515
+ function detectRateAbuse(ip, siteKey) {
516
+ const detections = [];
517
+ const key = `${siteKey}:${ip}`;
518
+
519
+ const [exceeded, count] = rateLimiter.check(key, 60, 10);
520
+ if (exceeded) {
521
+ detections.push({
522
+ category: 'rate_limit', score: 0.8, confidence: 0.9,
523
+ reason: 'Rate limit exceeded'
524
+ });
525
+ } else if (count > 5) {
526
+ detections.push({
527
+ category: 'rate_limit', score: 0.3, confidence: 0.5,
528
+ reason: 'High request rate'
529
+ });
530
+ }
531
+
532
+ return detections;
533
+ }
534
+
535
+ // =============================================================================
536
+ // Scoring
537
+ // =============================================================================
538
+
539
+ function calculateCategoryScores(detections) {
540
+ const categoryData = {};
541
+
542
+ for (const d of detections) {
543
+ if (!categoryData[d.category]) {
544
+ categoryData[d.category] = [];
545
+ }
546
+ categoryData[d.category].push([d.score, d.confidence]);
547
+ }
548
+
549
+ const result = {};
550
+ for (const [cat, scores] of Object.entries(categoryData)) {
551
+ if (scores.length > 0) {
552
+ const totalWeight = scores.reduce((sum, [, conf]) => sum + conf, 0);
553
+ if (totalWeight > 0) {
554
+ const weightedSum = scores.reduce((sum, [score, conf]) => sum + score * conf, 0);
555
+ result[cat] = Math.min(1.0, weightedSum / totalWeight);
556
+ }
557
+ }
558
+ }
559
+
560
+ // Fill missing
561
+ for (const cat of Object.keys(WEIGHTS)) {
562
+ if (!(cat in result)) {
563
+ result[cat] = 0.0;
564
+ }
565
+ }
566
+
567
+ return result;
568
+ }
569
+
570
+ function calculateFinalScore(categoryScores) {
571
+ let total = 0;
572
+ for (const [cat, weight] of Object.entries(WEIGHTS)) {
573
+ total += (categoryScores[cat] || 0) * weight;
574
+ }
575
+ return Math.min(1.0, total);
576
+ }
577
+
578
+ function generateToken(ip, siteKey, score) {
579
+ const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
580
+ const data = {
581
+ site_key: siteKey,
582
+ timestamp: Math.floor(Date.now() / 1000),
583
+ score: Math.round(score * 1000) / 1000,
584
+ ip_hash: ipHash
585
+ };
586
+
587
+ const payload = JSON.stringify(data, Object.keys(data).sort());
588
+ const sig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
589
+ data.sig = sig;
590
+
591
+ return Buffer.from(JSON.stringify(data)).toString('base64url');
592
+ }
593
+
594
+ function verifyToken(token) {
595
+ try {
596
+ const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
597
+
598
+ // Check expiration
599
+ if (Date.now() / 1000 - decoded.timestamp > 300) {
600
+ return { valid: false, reason: 'expired' };
601
+ }
602
+
603
+ const sig = decoded.sig;
604
+ delete decoded.sig;
605
+
606
+ const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
607
+ const expectedSig = crypto.createHmac('sha256', SECRET_KEY).update(payload).digest('hex').slice(0, 16);
608
+
609
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
610
+ return { valid: false, reason: 'invalid_signature' };
611
+ }
612
+
613
+ return {
614
+ valid: true,
615
+ site_key: decoded.site_key,
616
+ timestamp: decoded.timestamp,
617
+ score: decoded.score
618
+ };
619
+ } catch (e) {
620
+ return { valid: false, reason: e.message };
621
+ }
622
+ }
623
+
624
+ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null) {
625
+ const detections = [
626
+ ...detectVisionAI(signals),
627
+ ...detectHeadless(signals, userAgent),
628
+ ...detectAutomation(signals),
629
+ ...detectBehavioral(signals),
630
+ ...detectFingerprint(signals, ip, siteKey),
631
+ ...detectRateAbuse(ip, siteKey)
632
+ ];
633
+
634
+ // Verify PoW if provided
635
+ let powValid = false;
636
+ let powVerification = null;
637
+ if (powSolution && powSolution.challengeId) {
638
+ powVerification = powChallengeStore.verify(
639
+ powSolution.challengeId,
640
+ powSolution.nonce,
641
+ powSolution.hash,
642
+ siteKey
643
+ );
644
+ powValid = powVerification.valid;
645
+
646
+ if (!powValid) {
647
+ detections.push({
648
+ category: 'bot',
649
+ score: 0.7,
650
+ confidence: 0.8,
651
+ reason: `PoW verification failed: ${powVerification.reason}`
652
+ });
653
+ }
654
+ } else {
655
+ // No PoW solution provided - suspicious
656
+ detections.push({
657
+ category: 'bot',
658
+ score: 0.5,
659
+ confidence: 0.6,
660
+ reason: 'No PoW solution provided'
661
+ });
662
+ }
663
+
664
+ // Add IP reputation check (async but we'll use sync version for simplicity)
665
+ if (detection.isDatacenterIP(ip)) {
666
+ detections.push({
667
+ category: 'datacenter',
668
+ score: 0.6,
669
+ confidence: 0.8,
670
+ reason: 'Request from known datacenter IP range'
671
+ });
672
+ }
673
+
674
+ // Add header analysis
675
+ const headerDetections = detection.analyzeHeaders(headers);
676
+ detections.push(...headerDetections);
677
+
678
+ // Add browser consistency checks
679
+ const consistencyDetections = detection.checkBrowserConsistency(userAgent, signals);
680
+ detections.push(...consistencyDetections);
681
+
682
+ // Add JA3 fingerprint check
683
+ if (ja3Hash) {
684
+ const ja3Detections = detection.checkJA3Fingerprint(ja3Hash);
685
+ detections.push(...ja3Detections);
686
+ }
687
+
688
+ // Add form interaction analysis (credential stuffing & spam detection)
689
+ const formAnalysis = signals.formAnalysis;
690
+ if (formAnalysis) {
691
+ const formDetections = detection.analyzeFormInteraction(formAnalysis);
692
+ detections.push(...formDetections);
693
+ }
694
+
695
+ const categoryScores = calculateCategoryScores(detections);
696
+ const finalScore = calculateFinalScore(categoryScores);
697
+
698
+ let recommendation;
699
+ if (finalScore < 0.3) recommendation = 'allow';
700
+ else if (finalScore < 0.6) recommendation = 'challenge';
701
+ else recommendation = 'block';
702
+
703
+ const success = finalScore < 0.5;
704
+ const token = success ? generateToken(ip, siteKey, finalScore) : null;
705
+
706
+ return {
707
+ success,
708
+ score: finalScore,
709
+ token,
710
+ timestamp: Math.floor(Date.now() / 1000),
711
+ recommendation,
712
+ categoryScores,
713
+ detections
714
+ };
715
+ }
716
+
717
+ // =============================================================================
718
+ // Routes
719
+ // =============================================================================
720
+
721
+ app.get('/health', (req, res) => {
722
+ res.json({ status: 'ok' });
723
+ });
724
+
725
+ app.post('/api/verify', (req, res) => {
726
+ const { siteKey, signals, powSolution } = req.body;
727
+ let ip = req.headers['x-real-ip'] || '';
728
+ if (!ip) {
729
+ const forwarded = req.headers['x-forwarded-for'];
730
+ if (forwarded) {
731
+ ip = forwarded.split(',')[0].trim();
732
+ } else {
733
+ ip = req.socket.remoteAddress;
734
+ }
735
+ }
736
+ const userAgent = req.headers['user-agent'] || '';
737
+ const ja3Hash = req.headers['x-ja3-hash'] || null;
738
+
739
+ // Collect headers for analysis
740
+ const headers = {};
741
+ for (const [key, value] of Object.entries(req.headers)) {
742
+ headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
743
+ }
744
+
745
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
746
+ res.json(result);
747
+ });
748
+
749
+ app.post('/api/score', (req, res) => {
750
+ const { siteKey, signals, action, powSolution } = req.body;
751
+ let ip = req.headers['x-real-ip'] || '';
752
+ if (!ip) {
753
+ const forwarded = req.headers['x-forwarded-for'];
754
+ if (forwarded) {
755
+ ip = forwarded.split(',')[0].trim();
756
+ } else {
757
+ ip = req.socket.remoteAddress;
758
+ }
759
+ }
760
+ const userAgent = req.headers['user-agent'] || '';
761
+ const ja3Hash = req.headers['x-ja3-hash'] || null;
762
+
763
+ const headers = {};
764
+ for (const [key, value] of Object.entries(req.headers)) {
765
+ headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
766
+ }
767
+
768
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution);
769
+ res.json({
770
+ success: result.success,
771
+ score: result.score,
772
+ token: result.token,
773
+ action: action || '',
774
+ recommendation: result.recommendation
775
+ });
776
+ });
777
+
778
+ app.post('/api/token/verify', (req, res) => {
779
+ const { token } = req.body;
780
+ res.json(verifyToken(token));
781
+ });
782
+
783
+ // PoW Challenge endpoint - client fetches this on page load
784
+ app.get('/api/pow/challenge', (req, res) => {
785
+ const siteKey = req.query.siteKey || 'default';
786
+
787
+ let ip = req.headers['x-real-ip'] || '';
788
+ if (!ip) {
789
+ const forwarded = req.headers['x-forwarded-for'];
790
+ if (forwarded) {
791
+ ip = forwarded.split(',')[0].trim();
792
+ } else {
793
+ ip = req.socket.remoteAddress;
794
+ }
795
+ }
796
+
797
+ // Difficulty scaling based on IP reputation
798
+ let difficulty = 4; // Default: ~100-500ms on average hardware
799
+
800
+ // Increase difficulty for suspicious IPs
801
+ if (detection.isDatacenterIP(ip)) {
802
+ difficulty = 5; // ~1-3 seconds
803
+ }
804
+
805
+ // Check rate - high request rate gets harder challenges
806
+ const rateKey = `pow:${siteKey}:${ip}`;
807
+ const [exceeded, count] = rateLimiter.check(rateKey, 60, 20);
808
+ if (count > 10) {
809
+ difficulty = Math.min(6, difficulty + 1); // Up to difficulty 6
810
+ }
811
+ if (exceeded) {
812
+ difficulty = 6; // Maximum difficulty for rate-limited IPs
813
+ }
814
+
815
+ const challenge = powChallengeStore.generate(siteKey, ip, difficulty);
816
+
817
+ res.json({
818
+ challengeId: challenge.id,
819
+ prefix: challenge.prefix,
820
+ difficulty: challenge.difficulty,
821
+ expiresAt: challenge.expiresAt,
822
+ sig: challenge.sig
823
+ });
824
+ });
825
+
826
+ // Legacy challenge endpoint for backwards compatibility
827
+ app.get('/api/challenge', (req, res) => {
828
+ const challengeId = crypto.createHash('sha256').update(String(Date.now())).digest('hex').slice(0, 32);
829
+ res.json({
830
+ challengeId,
831
+ powDifficulty: 4,
832
+ expires: Math.floor(Date.now() / 1000) + 300
833
+ });
834
+ });
835
+
836
+ // =============================================================================
837
+ // Start
838
+ // =============================================================================
839
+
840
+ app.listen(PORT, () => {
841
+ console.log(`FCaptcha server running on port ${PORT}`);
842
+ });