@webdecoy/fcaptcha 1.31.0 → 1.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -15,7 +15,7 @@
15
15
  "test:clientip": "node clientip.test.js",
16
16
  "test:limits": "node limits.test.js",
17
17
  "test:detection": "node detection.test.js",
18
- "test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node index.test.js && node --test suspicion.test.js",
18
+ "test": "node version.test.js && node config.test.js && node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node interactionmode.test.js && node exemptions.test.js && node webbotauth.test.js && node siteverify.test.js && node redis-state.test.js && node index.test.js && node --test suspicion.test.js",
19
19
  "test:siteverify": "node siteverify.test.js",
20
20
  "test:forensics": "node inputforensics.test.js",
21
21
  "test:suspicion": "node --test suspicion.test.js"
package/redis-state.js ADDED
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { createClient } = require('redis');
5
+
6
+ const PREFIX = 'fcaptcha:v1:';
7
+ const POW_TTL_MS = 5 * 60 * 1000;
8
+ const SPENT_TTL_MS = 10 * 60 * 1000;
9
+ const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
10
+ const DETECTION_TTL_MS = 15 * 60 * 1000;
11
+
12
+ const CLAIM_CHALLENGE = `
13
+ if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
14
+ if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) == false then return -1 end
15
+ redis.call('DEL', KEYS[1])
16
+ return 1
17
+ `;
18
+ const RATE_CHECK = `
19
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
20
+ local count = redis.call('ZCARD', KEYS[1])
21
+ local added = 0
22
+ if count < tonumber(ARGV[3]) then
23
+ redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
24
+ count = count + 1
25
+ added = 1
26
+ end
27
+ redis.call('PEXPIRE', KEYS[1], ARGV[5])
28
+ return {count, added}
29
+ `;
30
+ const SITEKEY_CLAIM = `
31
+ if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
32
+ redis.call('PEXPIRE', KEYS[1], ARGV[3])
33
+ return 1
34
+ end
35
+ if redis.call('SCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
36
+ redis.call('SADD', KEYS[1], ARGV[1])
37
+ redis.call('PEXPIRE', KEYS[1], ARGV[3])
38
+ return 1
39
+ `;
40
+
41
+ class RedisState {
42
+ constructor(url, client = null) {
43
+ this.client = client || createClient({ url });
44
+ this.client.on?.('error', (err) => console.error('[redis]', err.message));
45
+ }
46
+
47
+ async connect() {
48
+ if (!this.client.isOpen) await this.client.connect();
49
+ await this.client.ping();
50
+ }
51
+
52
+ challengeKey(id) {
53
+ return `${PREFIX}pow:challenge:${id}`;
54
+ }
55
+
56
+ opaqueKey(kind, value) {
57
+ const digest = crypto.createHash('sha256').update(value).digest('hex');
58
+ return `${PREFIX}${kind}:${digest}`;
59
+ }
60
+
61
+ async putChallenge(challenge) {
62
+ const ttl = challenge.expiresAt - Date.now();
63
+ if (ttl <= 0) throw new Error('challenge already expired');
64
+ // challengeId is the canonical cross-runtime field used by Go and Node.
65
+ const stored = { ...challenge, challengeId: challenge.id };
66
+ delete stored.id;
67
+ await this.client.set(this.challengeKey(challenge.id), JSON.stringify(stored), { PX: ttl });
68
+ }
69
+
70
+ async getChallenge(id) {
71
+ const payload = await this.client.get(this.challengeKey(id));
72
+ if (!payload) return null;
73
+ const challenge = JSON.parse(payload);
74
+ return { ...challenge, id: challenge.challengeId };
75
+ }
76
+
77
+ async claimChallenge(challengeId, solutionKey) {
78
+ const result = Number(await this.client.eval(CLAIM_CHALLENGE, {
79
+ keys: [this.challengeKey(challengeId), `${PREFIX}pow:spent:${solutionKey}`],
80
+ arguments: [String(SPENT_TTL_MS)]
81
+ }));
82
+ if (result === 1) return { claimed: true };
83
+ return {
84
+ claimed: false,
85
+ reason: result === -1 ? 'solution_already_used' : 'challenge_not_found'
86
+ };
87
+ }
88
+
89
+ async claimToken(signature) {
90
+ return Boolean(await this.client.set(
91
+ `${PREFIX}token:spent:${signature}`,
92
+ '1',
93
+ { NX: true, PX: SPENT_TTL_MS }
94
+ ));
95
+ }
96
+
97
+ idempotencyKey(idempotencyKey, token) {
98
+ const tokenHash = crypto.createHash('sha256').update(token).digest('hex').slice(0, 32);
99
+ const composite = `${idempotencyKey}:${tokenHash}`;
100
+ const opaque = crypto.createHash('sha256').update(composite).digest('hex');
101
+ return `${PREFIX}siteverify:idempotency:${opaque}`;
102
+ }
103
+
104
+ async getIdempotency(idempotencyKey, token) {
105
+ if (!idempotencyKey) return null;
106
+ const payload = await this.client.get(this.idempotencyKey(idempotencyKey, token));
107
+ return payload ? JSON.parse(payload) : null;
108
+ }
109
+
110
+ async setIdempotency(idempotencyKey, token, response) {
111
+ if (!idempotencyKey) return;
112
+ await this.client.set(
113
+ this.idempotencyKey(idempotencyKey, token),
114
+ JSON.stringify(response),
115
+ { PX: IDEMPOTENCY_TTL_MS }
116
+ );
117
+ }
118
+
119
+ async rateCheck(key, windowSeconds, maxRequests) {
120
+ const now = Date.now();
121
+ const result = await this.client.eval(RATE_CHECK, {
122
+ keys: [this.opaqueKey('rate', key)],
123
+ arguments: [
124
+ String(now - windowSeconds * 1000), String(now), String(maxRequests),
125
+ `${now}:${crypto.randomBytes(8).toString('hex')}`, String(windowSeconds * 1000 + 1000)
126
+ ]
127
+ });
128
+ return [Number(result[1]) === 0, Number(result[0])];
129
+ }
130
+
131
+ async recordSuspicion(siteKey, ip) {
132
+ const now = Date.now();
133
+ const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
134
+ await this.client.multi()
135
+ .zRemRangeByScore(key, '-inf', now - DETECTION_TTL_MS)
136
+ .zAdd(key, { score: now, value: `${now}:${crypto.randomBytes(8).toString('hex')}` })
137
+ .zRemRangeByRank(key, 0, -17)
138
+ .pExpire(key, DETECTION_TTL_MS)
139
+ .exec();
140
+ }
141
+
142
+ async suspicionCount(siteKey, ip) {
143
+ const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
144
+ await this.client.zRemRangeByScore(key, '-inf', Date.now() - DETECTION_TTL_MS);
145
+ return Number(await this.client.zCard(key));
146
+ }
147
+
148
+ async recordFingerprint(fingerprint, ip, siteKey) {
149
+ const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
150
+ const ipKey = this.opaqueKey('fingerprint:fps', ip);
151
+ await this.client.multi()
152
+ .sAdd(fpKey, this.opaqueKey('value:ip', ip)).pExpire(fpKey, DETECTION_TTL_MS)
153
+ .sAdd(ipKey, this.opaqueKey('value:fp', fingerprint)).pExpire(ipKey, DETECTION_TTL_MS)
154
+ .exec();
155
+ }
156
+
157
+ async ipFingerprintCount(ip) {
158
+ return Number(await this.client.sCard(this.opaqueKey('fingerprint:fps', ip)));
159
+ }
160
+
161
+ async fingerprintIpCount(fingerprint, siteKey) {
162
+ return Number(await this.client.sCard(this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
163
+ }
164
+
165
+ async claimSiteKey(siteKey, ip, maxPerIp) {
166
+ return Number(await this.client.eval(SITEKEY_CLAIM, {
167
+ keys: [this.opaqueKey('sitekeys', ip)],
168
+ arguments: [this.opaqueKey('value:sitekey', siteKey), String(maxPerIp), String(60 * 60 * 1000)]
169
+ })) === 1;
170
+ }
171
+ }
172
+
173
+ module.exports = { RedisState, PREFIX, POW_TTL_MS, SPENT_TTL_MS, IDEMPOTENCY_TTL_MS };
package/server.js CHANGED
@@ -15,6 +15,7 @@ const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
15
15
  const { signingSecretFromEnv } = require('./config');
16
16
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
17
17
  const { detectInputForensics } = require('./inputforensics');
18
+ const { RedisState } = require('./redis-state');
18
19
  const {
19
20
  HostnameAllowlist,
20
21
  IdempotencyStore,
@@ -22,7 +23,7 @@ const {
22
23
  sanitizeAction,
23
24
  sanitizeCdata,
24
25
  secretMatches,
25
- siteverify
26
+ siteverifyAsync
26
27
  } = require('./siteverify');
27
28
 
28
29
  const app = express();
@@ -37,6 +38,8 @@ app.use(express.json({ limit: MAX_REQUEST_BODY_BYTES }));
37
38
  app.use(express.urlencoded({ extended: false, limit: MAX_REQUEST_BODY_BYTES }));
38
39
 
39
40
  const SECRET_KEY = signingSecretFromEnv();
41
+ const REDIS_URL = process.env.REDIS_URL || '';
42
+ const SHARED_STATE = REDIS_URL ? new RedisState(REDIS_URL) : null;
40
43
 
41
44
  // The credential a backend presents to validate a token. Defaults to the signing
42
45
  // key, which is what the README has always documented, but can be separated:
@@ -58,7 +61,12 @@ const LEGACY_UNAUTH_VERIFY = /^(1|true|yes|on)$/i.test(
58
61
  const ALLOWED_HOSTNAMES = HostnameAllowlist.fromEnv();
59
62
 
60
63
  // Lets a caller retry a validation that timed out without burning the token.
61
- const IDEMPOTENCY = new IdempotencyStore();
64
+ const IDEMPOTENCY = SHARED_STATE
65
+ ? {
66
+ get: (key, token) => SHARED_STATE.getIdempotency(key, token),
67
+ set: (key, token, response) => SHARED_STATE.setIdempotency(key, token, response)
68
+ }
69
+ : new IdempotencyStore();
62
70
 
63
71
  const PORT = process.env.PORT || 3000;
64
72
  const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
@@ -73,9 +81,32 @@ const PROXY_TRUST = ProxyTrust.fromEnv();
73
81
  // limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
74
82
  const SITE_KEYS = SiteKeyGuard.fromEnv();
75
83
 
84
+ async function normalizeSiteKey(siteKey, ip) {
85
+ const key = siteKey || 'default';
86
+ if (SITE_KEYS.allowlist && !SITE_KEYS.allowlist.has(key)) return ' overflow';
87
+ if (!SHARED_STATE || !ip) return SITE_KEYS.normalize(key, ip);
88
+ try {
89
+ return await SHARED_STATE.claimSiteKey(key, ip, SITE_KEYS.maxPerIp) ? key : ' overflow';
90
+ } catch (_) {
91
+ return ' overflow';
92
+ }
93
+ }
94
+
76
95
  // Recent strong verdicts per source, used to price the next challenge that
77
96
  // source asks for. Bounded and short-lived; see suspicion.js.
78
- const suspicionLedger = new SuspicionLedger();
97
+ const localSuspicionLedger = new SuspicionLedger();
98
+ const suspicionLedger = {
99
+ async record(siteKey, ip, score) {
100
+ if (!SHARED_STATE) return localSuspicionLedger.record(siteKey, ip, score);
101
+ if (score < 0.8 || !ip) return;
102
+ try { await SHARED_STATE.recordSuspicion(siteKey, ip); } catch (_) { /* fail closed on reads */ }
103
+ },
104
+ async count(siteKey, ip) {
105
+ if (!SHARED_STATE) return localSuspicionLedger.count(siteKey, ip);
106
+ if (!ip) return 0;
107
+ try { return await SHARED_STATE.suspicionCount(siteKey, ip); } catch (_) { return 16; }
108
+ }
109
+ };
79
110
 
80
111
  // Express's own `trust proxy` would re-derive req.ip from the same headers on
81
112
  // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
@@ -142,7 +173,7 @@ const powChallengeStore = {
142
173
  usedSolutions: new BoundedSet(),
143
174
 
144
175
  // Generate a new challenge
145
- generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
176
+ async generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
146
177
  const challengeId = crypto.randomBytes(16).toString('hex');
147
178
  const nonce = crypto.randomBytes(16).toString('hex');
148
179
  const timestamp = Date.now();
@@ -167,12 +198,16 @@ const powChallengeStore = {
167
198
  .update(JSON.stringify(challengeData))
168
199
  .digest('hex');
169
200
 
170
- // Store challenge
171
- this.challenges.set(challengeId, {
201
+ const storedChallenge = {
172
202
  ...challengeData,
173
203
  ip,
174
204
  createdAt: timestamp
175
- });
205
+ };
206
+ if (SHARED_STATE) {
207
+ await SHARED_STATE.putChallenge(storedChallenge);
208
+ } else {
209
+ this.challenges.set(challengeId, storedChallenge);
210
+ }
176
211
 
177
212
  // Cleanup old challenges periodically
178
213
  if (Math.random() < 0.1) this._cleanup();
@@ -181,15 +216,22 @@ const powChallengeStore = {
181
216
  },
182
217
 
183
218
  // Verify a PoW solution (signalsHash is optional for backward compat)
184
- verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
185
- const challenge = this.challenges.get(challengeId);
219
+ async verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
220
+ let challenge;
221
+ try {
222
+ challenge = SHARED_STATE
223
+ ? await SHARED_STATE.getChallenge(challengeId)
224
+ : this.challenges.get(challengeId);
225
+ } catch (_) {
226
+ return { valid: false, reason: 'state_unavailable' };
227
+ }
186
228
 
187
229
  if (!challenge) {
188
230
  return { valid: false, reason: 'challenge_not_found' };
189
231
  }
190
232
 
191
233
  if (Date.now() > challenge.expiresAt) {
192
- this.challenges.delete(challengeId);
234
+ if (!SHARED_STATE) this.challenges.delete(challengeId);
193
235
  return { valid: false, reason: 'challenge_expired' };
194
236
  }
195
237
 
@@ -223,11 +265,22 @@ const powChallengeStore = {
223
265
  return { valid: false, reason: 'insufficient_difficulty' };
224
266
  }
225
267
 
226
- // Mark solution as used
227
- this.usedSolutions.add(solutionKey);
228
-
229
- // Delete challenge (one-time use)
230
- this.challenges.delete(challengeId);
268
+ if (SHARED_STATE) {
269
+ try {
270
+ const claim = await SHARED_STATE.claimChallenge(challengeId, solutionKey);
271
+ if (!claim.claimed) return { valid: false, reason: claim.reason };
272
+ } catch (_) {
273
+ return { valid: false, reason: 'state_unavailable' };
274
+ }
275
+ } else {
276
+ // The local store is synchronous, so this test-and-set is atomic within
277
+ // one event-loop turn.
278
+ if (this.usedSolutions.has(solutionKey)) {
279
+ return { valid: false, reason: 'solution_already_used' };
280
+ }
281
+ this.usedSolutions.add(solutionKey);
282
+ this.challenges.delete(challengeId);
283
+ }
231
284
 
232
285
  // Calculate server-side elapsed time (un-spoofable)
233
286
  const serverElapsed = Date.now() - challenge.createdAt;
@@ -254,7 +307,11 @@ const powChallengeStore = {
254
307
  const rateLimiter = {
255
308
  requests: new BoundedMap(),
256
309
 
257
- check(key, windowSeconds = 60, maxRequests = 10) {
310
+ async check(key, windowSeconds = 60, maxRequests = 10) {
311
+ if (SHARED_STATE) {
312
+ try { return await SHARED_STATE.rateCheck(key, windowSeconds, maxRequests); }
313
+ catch (_) { return [true, maxRequests]; }
314
+ }
258
315
  const now = Date.now();
259
316
  const cutoff = now - (windowSeconds * 1000);
260
317
 
@@ -276,7 +333,11 @@ const fingerprintStore = {
276
333
  fingerprints: new BoundedMap(),
277
334
  ipFingerprints: new BoundedMap(),
278
335
 
279
- record(fp, ip, siteKey) {
336
+ async record(fp, ip, siteKey) {
337
+ if (SHARED_STATE) {
338
+ try { await SHARED_STATE.recordFingerprint(fp, ip, siteKey); } catch (_) { /* reads fail closed */ }
339
+ return;
340
+ }
280
341
  const key = `${siteKey}:${fp}`;
281
342
 
282
343
  if (!this.fingerprints.has(key)) {
@@ -292,11 +353,17 @@ const fingerprintStore = {
292
353
  this.ipFingerprints.get(ip).add(fp);
293
354
  },
294
355
 
295
- getIpFpCount(ip) {
356
+ async getIpFpCount(ip) {
357
+ if (SHARED_STATE) {
358
+ try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; }
359
+ }
296
360
  return this.ipFingerprints.get(ip)?.size || 0;
297
361
  },
298
362
 
299
- getFpIpCount(fp, siteKey) {
363
+ async getFpIpCount(fp, siteKey) {
364
+ if (SHARED_STATE) {
365
+ try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; }
366
+ }
300
367
  const key = `${siteKey}:${fp}`;
301
368
  return this.fingerprints.get(key)?.ips.size || 0;
302
369
  }
@@ -351,7 +418,7 @@ const {
351
418
  } = require('./engine');
352
419
 
353
420
  // Stateful detectors stay here: they read the module-level stores below.
354
- function detectFingerprint(signals, ip, siteKey) {
421
+ async function detectFingerprint(signals, ip, siteKey) {
355
422
  const detections = [];
356
423
  const env = signals.environmental || {};
357
424
  const automation = env.automationFlags || {};
@@ -365,10 +432,10 @@ function detectFingerprint(signals, ip, siteKey) {
365
432
  ];
366
433
  const fp = crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
367
434
 
368
- fingerprintStore.record(fp, ip, siteKey);
435
+ await fingerprintStore.record(fp, ip, siteKey);
369
436
 
370
437
  // IP fingerprint count
371
- const ipFpCount = fingerprintStore.getIpFpCount(ip);
438
+ const ipFpCount = await fingerprintStore.getIpFpCount(ip);
372
439
  if (ipFpCount > 5) {
373
440
  detections.push({
374
441
  category: 'fingerprint', score: 0.6, confidence: 0.6,
@@ -377,7 +444,7 @@ function detectFingerprint(signals, ip, siteKey) {
377
444
  }
378
445
 
379
446
  // Fingerprint IP count
380
- const fpIpCount = fingerprintStore.getFpIpCount(fp, siteKey);
447
+ const fpIpCount = await fingerprintStore.getFpIpCount(fp, siteKey);
381
448
  if (fpIpCount > 10) {
382
449
  detections.push({
383
450
  category: 'fingerprint', score: 0.5, confidence: 0.5,
@@ -397,11 +464,11 @@ function detectFingerprint(signals, ip, siteKey) {
397
464
  return detections;
398
465
  }
399
466
 
400
- function detectRateAbuse(ip, siteKey) {
467
+ async function detectRateAbuse(ip, siteKey) {
401
468
  const detections = [];
402
469
  const key = `${siteKey}:${ip}`;
403
470
 
404
- const [exceeded, count] = rateLimiter.check(key, 60, 10);
471
+ const [exceeded, count] = await rateLimiter.check(key, 60, 10);
405
472
  if (exceeded) {
406
473
  detections.push({
407
474
  category: 'rate_limit', score: 0.8, confidence: 0.9,
@@ -446,7 +513,7 @@ function generateToken(ip, siteKey, score, binding = {}) {
446
513
  return Buffer.from(JSON.stringify(data)).toString('base64url');
447
514
  }
448
515
 
449
- function verifyToken(token, ip = null) {
516
+ async function verifyToken(token, ip = null) {
450
517
  try {
451
518
  const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
452
519
 
@@ -465,11 +532,6 @@ function verifyToken(token, ip = null) {
465
532
  return { valid: false, reason: 'invalid_signature' };
466
533
  }
467
534
 
468
- // Check for token replay (single-use tokens)
469
- if (tokenStore.isUsed(sig)) {
470
- return { valid: false, reason: 'token_already_used' };
471
- }
472
-
473
535
  // Verify IP matches (if provided)
474
536
  if (ip) {
475
537
  const expectedIpHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
@@ -478,8 +540,15 @@ function verifyToken(token, ip = null) {
478
540
  }
479
541
  }
480
542
 
481
- // Mark token as used (prevents replay)
482
- tokenStore.markUsed(sig);
543
+ let claimed;
544
+ try {
545
+ claimed = SHARED_STATE
546
+ ? await SHARED_STATE.claimToken(sig)
547
+ : tokenStore.markUsed(sig);
548
+ } catch (_) {
549
+ return { valid: false, reason: 'state_unavailable' };
550
+ }
551
+ if (!claimed) return { valid: false, reason: 'token_already_used' };
483
552
 
484
553
  // hostname/action/cdata default to '' so a token minted before they existed
485
554
  // still verifies and reports the same shape. The signature covers whatever
@@ -495,8 +564,8 @@ function verifyToken(token, ip = null) {
495
564
  action: decoded.action || '',
496
565
  cdata: decoded.cdata || ''
497
566
  };
498
- } catch (e) {
499
- return { valid: false, reason: e.message };
567
+ } catch (_) {
568
+ return { valid: false, reason: 'invalid_token' };
500
569
  }
501
570
  }
502
571
 
@@ -516,7 +585,7 @@ async function verifyWebBotAuth(req) {
516
585
  // require the raw request — currently Web Bot Auth signature verification, which
517
586
  // needs the accurately-reconstructed signed request. They are seeded into the
518
587
  // detection set so they participate in scoring like any other detection.
519
- function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
588
+ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
520
589
  const detections = Array.isArray(preDetections) ? [...preDetections] : [];
521
590
 
522
591
  // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
@@ -567,7 +636,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
567
636
  let powSatisfied = false;
568
637
  let powVerification = null;
569
638
  if (powSolution && powSolution.challengeId) {
570
- powVerification = powChallengeStore.verify(
639
+ powVerification = await powChallengeStore.verify(
571
640
  powSolution.challengeId,
572
641
  powSolution.nonce,
573
642
  powSolution.hash,
@@ -658,10 +727,10 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
658
727
  ...detectInputForensics(signals),
659
728
  ...detectTouchAuthenticity(signals, userAgent),
660
729
  ...detectSensorEntropy(signals, userAgent),
661
- ...detectTouchKinematics(signals),
662
- ...detectFingerprint(signals, ip, siteKey),
663
- ...detectRateAbuse(ip, siteKey)
730
+ ...detectTouchKinematics(signals)
664
731
  );
732
+ detections.push(...await detectFingerprint(signals, ip, siteKey));
733
+ detections.push(...await detectRateAbuse(ip, siteKey));
665
734
 
666
735
  // Add IP reputation check (async but we'll use sync version for simplicity)
667
736
  if (detection.isDatacenterIP(ip)) {
@@ -763,7 +832,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
763
832
 
764
833
  // Feed the ledger so the next challenge this source asks for is priced on
765
834
  // what it just did.
766
- suspicionLedger.record(siteKey, ip, finalScore);
835
+ await suspicionLedger.record(siteKey, ip, finalScore);
767
836
 
768
837
  return {
769
838
  success,
@@ -805,7 +874,7 @@ app.post('/api/verify', async (req, res) => {
805
874
  const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
806
875
  const ip = PROXY_TRUST.clientIP(req);
807
876
  // Bound the state an unvalidated siteKey can allocate (limits.js).
808
- const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
877
+ const siteKey = await normalizeSiteKey(rawSiteKey, ip);
809
878
  const userAgent = req.headers['user-agent'] || '';
810
879
  // Only honoured from a trusted proxy: a client that can state its own TLS
811
880
  // fingerprint would just claim a stock Chrome one.
@@ -818,7 +887,7 @@ app.post('/api/verify', async (req, res) => {
818
887
  // Web Bot Auth verification needs the raw request; its verdict is scored.
819
888
  const webBotAuth = await verifyWebBotAuth(req);
820
889
 
821
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
890
+ const result = await runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
822
891
  logVerdict('verify', siteKey, result);
823
892
  res.json(result);
824
893
  });
@@ -826,7 +895,7 @@ app.post('/api/verify', async (req, res) => {
826
895
  app.post('/api/score', async (req, res) => {
827
896
  const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
828
897
  const ip = PROXY_TRUST.clientIP(req);
829
- const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
898
+ const siteKey = await normalizeSiteKey(rawSiteKey, ip);
830
899
  const userAgent = req.headers['user-agent'] || '';
831
900
  const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
832
901
 
@@ -840,7 +909,7 @@ app.post('/api/score', async (req, res) => {
840
909
  const webBotAuth = await verifyWebBotAuth(req);
841
910
 
842
911
  // Invisible scoring: no widget, so no click analysis in the signals.
843
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: false });
912
+ const result = await runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: false });
844
913
  logVerdict('score', siteKey, result);
845
914
  res.json({
846
915
  success: result.success,
@@ -857,7 +926,7 @@ app.post('/api/score', async (req, res) => {
857
926
  });
858
927
  });
859
928
 
860
- app.post('/api/token/verify', (req, res) => {
929
+ app.post('/api/token/verify', async (req, res) => {
861
930
  const { token, secret, remoteip } = req.body;
862
931
 
863
932
  // The secret gate. This endpoint is the boundary between "a browser finished a
@@ -876,7 +945,7 @@ app.post('/api/token/verify', (req, res) => {
876
945
  // This request comes from the integrating backend, not from the visitor who
877
946
  // received the token. Bind only when that trusted backend explicitly supplies
878
947
  // the visitor address; using the caller socket here compares unrelated hosts.
879
- res.json(verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null));
948
+ res.json(await verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null));
880
949
  });
881
950
 
882
951
  // Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
@@ -885,9 +954,9 @@ app.post('/api/token/verify', (req, res) => {
885
954
  // plugins we want to be usable against FCaptcha: pointing an existing
886
955
  // integration at this server should be a base-URL change and nothing else.
887
956
  // See siteverify.js for the adapter itself.
888
- function siteverifyHandler(req, res) {
957
+ async function siteverifyHandler(req, res) {
889
958
  res.json(
890
- siteverify({
959
+ await siteverifyAsync({
891
960
  body: req.body,
892
961
  // Bind to this server's token store, so replay state is shared with the
893
962
  // native endpoint rather than kept in a parallel universe.
@@ -904,22 +973,27 @@ app.post('/recaptcha/api/siteverify', siteverifyHandler);
904
973
  app.post('/siteverify', siteverifyHandler);
905
974
 
906
975
  // PoW Challenge endpoint - client fetches this on page load
907
- app.get('/api/pow/challenge', (req, res) => {
976
+ app.get('/api/pow/challenge', async (req, res) => {
908
977
  const ip = PROXY_TRUST.clientIP(req);
909
- const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
978
+ const siteKey = await normalizeSiteKey(req.query.siteKey, ip);
910
979
 
911
980
  // Cost scaling. See suspicion.js for why the escalation lands almost
912
981
  // entirely on minAgeMs rather than on difficulty.
913
982
  const rateKey = `pow:${siteKey}:${ip}`;
914
- const [exceeded, count] = rateLimiter.check(rateKey, 60, 20);
983
+ const [exceeded, count] = await rateLimiter.check(rateKey, 60, 20);
915
984
  const cost = computeChallengeCost(
916
- suspicionLedger.count(siteKey, ip),
985
+ await suspicionLedger.count(siteKey, ip),
917
986
  detection.isDatacenterIP(ip),
918
987
  count,
919
988
  exceeded
920
989
  );
921
990
 
922
- const challenge = powChallengeStore.generate(siteKey, ip, cost.difficulty, cost.minAgeMs);
991
+ let challenge;
992
+ try {
993
+ challenge = await powChallengeStore.generate(siteKey, ip, cost.difficulty, cost.minAgeMs);
994
+ } catch (_) {
995
+ return res.status(503).json({ error: 'state_unavailable' });
996
+ }
923
997
 
924
998
  res.json({
925
999
  challengeId: challenge.id,
@@ -959,8 +1033,17 @@ app.use((err, req, res, next) => {
959
1033
  // Start
960
1034
  // =============================================================================
961
1035
 
962
- app.listen(PORT, () => {
963
- console.log(`FCaptcha server running on port ${PORT}`);
964
- console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
965
- console.log(`Site keys: ${SITE_KEYS.describe()}`);
1036
+ async function start() {
1037
+ if (SHARED_STATE) await SHARED_STATE.connect();
1038
+ app.listen(PORT, () => {
1039
+ console.log(`FCaptcha server running on port ${PORT}`);
1040
+ console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1041
+ console.log(`Site keys: ${SITE_KEYS.describe()}`);
1042
+ console.log(`Shared state: ${SHARED_STATE ? 'Redis (PoW)' : 'in-memory'}`);
1043
+ });
1044
+ }
1045
+
1046
+ start().catch((err) => {
1047
+ console.error(`FCaptcha failed to start: ${err.message}`);
1048
+ process.exit(1);
966
1049
  });
package/siteverify.js CHANGED
@@ -314,6 +314,44 @@ function siteverify({
314
314
  return out;
315
315
  }
316
316
 
317
+ // Async form used by Redis-backed servers. Kept separate so the public
318
+ // synchronous adapter remains backward compatible for library callers.
319
+ async function siteverifyAsync({
320
+ body,
321
+ verifyToken,
322
+ expectedSecret,
323
+ idempotencyStore,
324
+ requireSecret = true,
325
+ }) {
326
+ const { secret, response, remoteip, idempotencyKey } = readParams(body);
327
+ if (requireSecret) {
328
+ if (!secret) return failure(ERROR_CODES.MISSING_SECRET);
329
+ if (!secretMatches(secret, expectedSecret)) return failure(ERROR_CODES.INVALID_SECRET);
330
+ }
331
+ if (!response) return failure(ERROR_CODES.MISSING_RESPONSE);
332
+
333
+ try {
334
+ const cached = idempotencyStore && await idempotencyStore.get(idempotencyKey, response);
335
+ if (cached) return cached;
336
+ const result = await verifyToken(response, remoteip || null);
337
+ const out = result && result.valid
338
+ ? {
339
+ success: true,
340
+ challenge_ts: new Date((result.timestamp || 0) * 1000).toISOString(),
341
+ hostname: result.hostname || '',
342
+ action: result.action || '',
343
+ cdata: result.cdata || '',
344
+ 'error-codes': [],
345
+ score: typeof result.score === 'number' ? result.score : null,
346
+ }
347
+ : failure(reasonToErrorCode(result && result.reason));
348
+ if (idempotencyStore) await idempotencyStore.set(idempotencyKey, response, out);
349
+ return out;
350
+ } catch {
351
+ return failure(ERROR_CODES.INTERNAL_ERROR);
352
+ }
353
+ }
354
+
317
355
  module.exports = {
318
356
  ERROR_CODES,
319
357
  HostnameAllowlist,
@@ -325,6 +363,7 @@ module.exports = {
325
363
  sanitizeCdata,
326
364
  secretMatches,
327
365
  siteverify,
366
+ siteverifyAsync,
328
367
  MAX_ACTION_LENGTH,
329
368
  MAX_CDATA_LENGTH,
330
369
  IDEMPOTENCY_TTL_SECONDS,