@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.
- package/detection.js +442 -0
- package/index.js +799 -0
- package/package.json +51 -0
- 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
|
+
});
|