@webdecoy/fcaptcha 1.34.1 → 1.36.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/engine.js CHANGED
@@ -495,7 +495,10 @@ function detectCDP(signals) {
495
495
  if (env.cdpRuntime && env.cdpRuntime.consoleAttached) {
496
496
  detections.push({
497
497
  category: 'cdp', score: 0.6, confidence: 0.5,
498
- reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)'
498
+ reason: 'CDP/DevTools console consumer attached (automation protocol or open DevTools)',
499
+ // A developer with DevTools open produces exactly this signal, so it
500
+ // cannot be one of the independent views the corroboration floor counts.
501
+ nonCorroborating: true
499
502
  });
500
503
  }
501
504
 
@@ -912,33 +915,47 @@ function applyDispositiveFloor(score, detections) {
912
915
  * product. It treats absence of environmental evidence as evidence of absence — but
913
916
  * on this adversary a clean environment is the attack working, not innocence.
914
917
  *
915
- * The rule: when two or more behavioural categories independently reach 0.5, floor
916
- * the score at 0.6.
918
+ * The rule: when two or more behavioural categories independently reach 0.4, floor
919
+ * the score at 0.6. A signal a developer's own tooling produces is not an
920
+ * independent view: the DevTools console-attach probe is marked nonCorroborating,
921
+ * so it still scores but cannot be one of the two that agree.
917
922
  *
918
923
  * Constants swept over a 40-point grid against the labelled corpus
919
924
  * (bench/tools/sweep-corroboration.js), not reasoned about. No human in the
920
925
  * 126-sample panel reaches two agreeing behavioural categories at any threshold
921
- * tested, while 66 of 75 agents do at 0.5. Requiring three fails outright — all
922
- * sixteen such combinations leave the adversary allowed. The floor value does not
923
- * affect separation, so 0.6 is a policy choice: the block boundary, kept distinct
924
- * from the 0.9 reserved for a browser that declares its own automation.
926
+ * tested, and 0.3, 0.4 and 0.5 catch the same 66 of 75 agents. The bar moved from
927
+ * 0.5 to 0.4 on a live measurement the corpus cannot make (2026-09-09): an
928
+ * extension-driven click in a real Chrome on the public demo scored vision_ai 0.40
929
+ * and behavioral 0.44 with every environmental category clean — weighted sum
930
+ * 0.189, allowed at 0.5, floored at 0.4. The exclusion is what keeps the lower
931
+ * bar honest: the human-looking sessions beside it sat at behavioral 0.37 with cdp
932
+ * 0.30 from the console probe alone. Requiring three fails outright — all sixteen
933
+ * such combinations leave the adversary allowed. The floor value does not affect
934
+ * separation, so 0.6 is a policy choice: the block boundary, kept distinct from
935
+ * the 0.9 reserved for a browser that declares its own automation.
925
936
  *
926
937
  * Caveat: the adversary is one synthetic, hand-authored sample. This shows the
927
938
  * arithmetic works on the shape the corpus describes, not that it works on a real
928
939
  * source-patched browser.
929
940
  */
930
- const CORROBORATION_AGREE_AT = 0.5;
941
+ const CORROBORATION_AGREE_AT = 0.4;
931
942
  const CORROBORATION_MIN_AGREE = 2;
932
943
  const CORROBORATION_FLOOR = 0.6;
944
+ // Absorbs the few ulps a noisy-OR product lands from a round number (0.75×0.8 is
945
+ // 0.3999999999999999, not 0.4), so agreement does not depend on arithmetic order.
946
+ const CORROBORATION_EPSILON = 1e-9;
933
947
 
934
948
  // Categories a browser trips by how it moves rather than by what it is. A
935
949
  // patched binary can hide what it is; it cannot hide that nothing is moving the
936
950
  // pointer like a hand.
937
951
  const BEHAVIOURAL_CATEGORIES = ['vision_ai', 'behavioral', 'automation', 'cdp'];
938
952
 
939
- function applyCorroborationFloor(score, categoryScores) {
953
+ function applyCorroborationFloor(score, detections) {
954
+ // Only independent views count. A signal that a developer's own tooling
955
+ // produces (nonCorroborating) still scores, but cannot be one of the two.
956
+ const categoryScores = calculateCategoryScores(detections.filter((d) => !d.nonCorroborating));
940
957
  const agreeing = BEHAVIOURAL_CATEGORIES
941
- .filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT).length;
958
+ .filter((c) => (categoryScores[c] || 0) >= CORROBORATION_AGREE_AT - CORROBORATION_EPSILON).length;
942
959
  return agreeing >= CORROBORATION_MIN_AGREE ? Math.max(score, CORROBORATION_FLOOR) : score;
943
960
  }
944
961
 
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const { BoundedMap } = require('./limits');
4
+
5
+ const WINDOW_MS = 15 * 60 * 1000;
6
+ // Detectors only distinguish >5 and >10. Saturating at 16 preserves those
7
+ // decisions without retaining every attacker-selected value.
8
+ const MAX_MEMBERS = 16;
9
+
10
+ class FingerprintStore {
11
+ constructor({ maxEntries = 100000, ttlMs = WINDOW_MS, now = Date.now } = {}) {
12
+ this.fingerprints = new BoundedMap(maxEntries);
13
+ this.ipFingerprints = new BoundedMap(maxEntries);
14
+ this.ttlMs = ttlMs;
15
+ this.now = now;
16
+ }
17
+
18
+ _get(store, key) {
19
+ const entry = store.get(key);
20
+ if (entry && entry.expiresAt <= this.now()) {
21
+ store.delete(key);
22
+ return undefined;
23
+ }
24
+ return entry;
25
+ }
26
+
27
+ _add(store, key, value) {
28
+ let entry = this._get(store, key);
29
+ if (!entry) {
30
+ entry = { values: new Set(), expiresAt: this.now() + this.ttlMs };
31
+ store.set(key, entry);
32
+ }
33
+ if (entry.values.size < MAX_MEMBERS) entry.values.add(value);
34
+ }
35
+
36
+ record(fp, ip, siteKey) {
37
+ const entry = this._get(this.ipFingerprints, ip);
38
+ // Do not allocate more fingerprint keys once this source has saturated.
39
+ if (entry && entry.values.size >= MAX_MEMBERS && !entry.values.has(fp)) return;
40
+ this._add(this.ipFingerprints, ip, fp);
41
+ this._add(this.fingerprints, `${siteKey}:${fp}`, ip);
42
+ }
43
+
44
+ getIpFpCount(ip) {
45
+ return this._get(this.ipFingerprints, ip)?.values.size || 0;
46
+ }
47
+
48
+ getFpIpCount(fp, siteKey) {
49
+ return this._get(this.fingerprints, `${siteKey}:${fp}`)?.values.size || 0;
50
+ }
51
+ }
52
+
53
+ module.exports = { FingerprintStore, WINDOW_MS, MAX_MEMBERS };
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Usage:
7
7
  * const fcaptcha = require('@webdecoy/fcaptcha');
8
8
  * const engine = fcaptcha.createScoringEngine({ secret: 'your-secret' });
9
- * const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
9
+ * const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming);
10
10
  */
11
11
 
12
12
  const crypto = require('crypto');
@@ -14,6 +14,10 @@ const detection = require('./detection');
14
14
  const { ProxyTrust, networkIdentity } = require('./clientip');
15
15
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
16
16
  const { signingSecret } = require('./config');
17
+ const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
18
+ const { FingerprintStore } = require('./fingerprint-store');
19
+ const { TokenStore } = require('./token-store');
20
+ const { resolveSignals } = require('./protocol');
17
21
 
18
22
  // =============================================================================
19
23
  // PoW Challenge Store (can be extended with Redis)
@@ -22,8 +26,8 @@ const { signingSecret } = require('./config');
22
26
  class PoWChallengeStore {
23
27
  constructor(options = {}) {
24
28
  this.secret = signingSecret(options.secret);
25
- this.challenges = new Map();
26
- this.usedSolutions = new Set();
29
+ this.challenges = new BoundedMap();
30
+ this.usedSolutions = new BoundedSet();
27
31
  this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
28
32
  }
29
33
 
@@ -41,6 +45,7 @@ class PoWChallengeStore {
41
45
  // How long the client must hold this challenge before submitting a
42
46
  // solution. Inside the signed payload so it cannot be talked down.
43
47
  minAgeMs,
48
+ nonce: crypto.randomBytes(16).toString('hex'),
44
49
  prefix: `${challengeId}:${timestamp}:${difficulty}`
45
50
  };
46
51
 
@@ -64,7 +69,7 @@ class PoWChallengeStore {
64
69
  return challengeData;
65
70
  }
66
71
 
67
- verify(challengeId, nonce, hash, siteKey, ip) {
72
+ verify(challengeId, nonce, hash, siteKey, ip, signalsHash = null) {
68
73
  const challenge = this.challenges.get(challengeId);
69
74
 
70
75
  if (!challenge) {
@@ -91,7 +96,7 @@ class PoWChallengeStore {
91
96
  }
92
97
 
93
98
  // Verify the hash
94
- const input = `${challenge.prefix}:${nonce}`;
99
+ const input = signalsHash ? `${challenge.prefix}:${signalsHash}:${nonce}` : `${challenge.prefix}:${nonce}`;
95
100
  const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
96
101
 
97
102
  if (hash !== expectedHash) {
@@ -111,6 +116,7 @@ class PoWChallengeStore {
111
116
  return {
112
117
  valid: true,
113
118
  difficulty: challenge.difficulty,
119
+ nonce: challenge.nonce,
114
120
  serverElapsed: Date.now() - challenge.timestamp,
115
121
  // Fall back for challenges issued before adaptive cost existed.
116
122
  minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
@@ -124,9 +130,6 @@ class PoWChallengeStore {
124
130
  this.challenges.delete(id);
125
131
  }
126
132
  }
127
- if (this.usedSolutions.size > 10000) {
128
- this.usedSolutions.clear();
129
- }
130
133
  }
131
134
  }
132
135
 
@@ -136,7 +139,7 @@ class PoWChallengeStore {
136
139
 
137
140
  class RateLimiter {
138
141
  constructor() {
139
- this.requests = new Map();
142
+ this.requests = new BoundedMap();
140
143
  }
141
144
 
142
145
  check(key, windowSeconds = 60, maxRequests = 10) {
@@ -157,42 +160,6 @@ class RateLimiter {
157
160
  }
158
161
  }
159
162
 
160
- // =============================================================================
161
- // Fingerprint Store
162
- // =============================================================================
163
-
164
- class FingerprintStore {
165
- constructor() {
166
- this.fingerprints = new Map();
167
- this.ipFingerprints = new Map();
168
- }
169
-
170
- record(fp, ip, siteKey) {
171
- const key = `${siteKey}:${fp}`;
172
-
173
- if (!this.fingerprints.has(key)) {
174
- this.fingerprints.set(key, { count: 0, ips: new Set() });
175
- }
176
- const data = this.fingerprints.get(key);
177
- data.count++;
178
- data.ips.add(ip);
179
-
180
- if (!this.ipFingerprints.has(ip)) {
181
- this.ipFingerprints.set(ip, new Set());
182
- }
183
- this.ipFingerprints.get(ip).add(fp);
184
- }
185
-
186
- getIpFpCount(ip) {
187
- return this.ipFingerprints.get(ip)?.size || 0;
188
- }
189
-
190
- getFpIpCount(fp, siteKey) {
191
- const key = `${siteKey}:${fp}`;
192
- return this.fingerprints.get(key)?.ips.size || 0;
193
- }
194
- }
195
-
196
163
  // =============================================================================
197
164
  // Scoring Engine
198
165
  // =============================================================================
@@ -231,6 +198,7 @@ class ScoringEngine {
231
198
  this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
232
199
  this.suspicion = options.suspicion || new SuspicionLedger();
233
200
  this.weights = options.weights || WEIGHTS;
201
+ this.tokenStore = options.tokenStore || new TokenStore();
234
202
  }
235
203
 
236
204
  // Generate a PoW challenge
@@ -257,8 +225,16 @@ class ScoringEngine {
257
225
  }
258
226
 
259
227
  // Verify signals and return score
260
- verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null) {
228
+ verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null, signalsJson = null, powTiming = null) {
229
+ const resolved = resolveSignals(signals, signalsJson, powSolution?.signalsHash);
230
+ signals = resolved.signals;
231
+ if (powTiming) {
232
+ signals.temporal = signals.temporal || {};
233
+ signals.temporal.pow = powTiming;
234
+ }
261
235
  const detections = [];
236
+ if (!resolved.commitmentValid) detections.push({ category: 'bot', score: 0.95,
237
+ confidence: 0.95, reason: 'Signals tampered after PoW (signalsHash mismatch)' });
262
238
  let powSatisfied = false;
263
239
 
264
240
  // Run all detection modules
@@ -288,7 +264,8 @@ class ScoringEngine {
288
264
  powSolution.nonce,
289
265
  powSolution.hash,
290
266
  siteKey,
291
- ip
267
+ ip,
268
+ powSolution.signalsHash
292
269
  );
293
270
 
294
271
  if (!powResult.valid) {
@@ -299,7 +276,7 @@ class ScoringEngine {
299
276
  reason: `PoW verification failed: ${powResult.reason}`
300
277
  });
301
278
  } else if (powResult.serverElapsed < BASE_MIN_AGE_MS) {
302
- powSatisfied = true;
279
+ powSatisfied = false;
303
280
  // Under the universal baseline nothing legitimate can have happened —
304
281
  // no human completes an interaction that fast.
305
282
  detections.push({
@@ -309,11 +286,9 @@ class ScoringEngine {
309
286
  reason: `Challenge solved too fast (${powResult.serverElapsed}ms server-side)`
310
287
  });
311
288
  } else if (powResult.serverElapsed < powResult.minAgeMs) {
312
- powSatisfied = true;
313
- // Between the baseline and this source's own elevated floor is weaker
314
- // evidence: a client predating adaptive cost, or one served from a
315
- // stale cache, does not know to wait. It contributes rather than
316
- // deciding.
289
+ powSatisfied = false;
290
+ // Keep the diagnostic weaker than a baseline violation; token issuance
291
+ // independently requires the full advertised delay.
317
292
  detections.push({
318
293
  category: 'bot',
319
294
  score: 0.5,
@@ -321,7 +296,13 @@ class ScoringEngine {
321
296
  reason: `Challenge submitted before the required delay for this source (${powResult.serverElapsed}ms of ${powResult.minAgeMs}ms)`
322
297
  });
323
298
  } else {
324
- powSatisfied = true;
299
+ powSatisfied = resolved.commitmentValid;
300
+ }
301
+ // The server-issued nonce is required even for legacy, uncommitted PoW.
302
+ if (powResult.valid && powResult.nonce && signals.meta?.challengeNonce !== powResult.nonce) {
303
+ powSatisfied = false;
304
+ detections.push({ category: 'bot', score: 0.9, confidence: 0.9,
305
+ reason: 'Challenge nonce mismatch (signals not bound to challenge)' });
325
306
  }
326
307
  } else {
327
308
  detections.push({
@@ -364,7 +345,7 @@ class ScoringEngine {
364
345
  const categoryScores = calculateCategoryScores(detections, this.weights);
365
346
  const finalScore = applyCorroborationFloor(
366
347
  applyDispositiveFloor(calculateFinalScore(categoryScores, this.weights), detections),
367
- categoryScores
348
+ detections
368
349
  );
369
350
 
370
351
  let recommendation;
@@ -397,7 +378,7 @@ class ScoringEngine {
397
378
  }
398
379
 
399
380
  // Verify a previously issued token
400
- verifyToken(token) {
381
+ verifyToken(token, ip = null) {
401
382
  try {
402
383
  const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
403
384
 
@@ -415,14 +396,22 @@ class ScoringEngine {
415
396
  return { valid: false, reason: 'invalid_signature' };
416
397
  }
417
398
 
399
+ if (ip && decoded.ip_hash !== crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8)) {
400
+ return { valid: false, reason: 'ip_mismatch' };
401
+ }
402
+ const claim = this.tokenStore.claim(sig);
403
+ if (!claim.claimed) return { valid: false, reason: claim.reason };
418
404
  return {
419
405
  valid: true,
420
406
  site_key: decoded.site_key,
421
407
  timestamp: decoded.timestamp,
422
- score: decoded.score
408
+ score: decoded.score,
409
+ hostname: decoded.hostname || '',
410
+ action: decoded.action || '',
411
+ cdata: decoded.cdata || ''
423
412
  };
424
413
  } catch (e) {
425
- return { valid: false, reason: e.message };
414
+ return { valid: false, reason: 'invalid_token' };
426
415
  }
427
416
  }
428
417
 
@@ -497,6 +486,7 @@ class ScoringEngine {
497
486
  const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
498
487
  const data = {
499
488
  site_key: siteKey,
489
+ jti: crypto.randomBytes(16).toString('hex'),
500
490
  timestamp: Math.floor(Date.now() / 1000),
501
491
  score: Math.round(score * 1000) / 1000,
502
492
  ip_hash: ipHash
@@ -516,6 +506,7 @@ class ScoringEngine {
516
506
 
517
507
  function createMiddleware(options = {}) {
518
508
  const engine = new ScoringEngine(options);
509
+ const siteKeys = SiteKeyGuard.fromEnv();
519
510
  const proxyTrust = options.trustedProxies !== undefined
520
511
  ? new ProxyTrust(options.trustedProxies)
521
512
  : ProxyTrust.fromEnv();
@@ -533,8 +524,9 @@ function createMiddleware(options = {}) {
533
524
 
534
525
  // Challenge route handler
535
526
  challengeHandler: (req, res) => {
536
- const siteKey = req.query.siteKey || 'default';
527
+ const rawSiteKey = req.query.siteKey || 'default';
537
528
  const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
529
+ const siteKey = siteKeys.normalize(rawSiteKey, ip);
538
530
  const challenge = engine.generateChallenge(siteKey, ip);
539
531
 
540
532
  res.json({
@@ -542,14 +534,17 @@ function createMiddleware(options = {}) {
542
534
  prefix: challenge.prefix,
543
535
  difficulty: challenge.difficulty,
544
536
  expiresAt: challenge.expiresAt,
545
- sig: challenge.sig
537
+ sig: challenge.sig,
538
+ nonce: challenge.nonce,
539
+ minAgeMs: challenge.minAgeMs
546
540
  });
547
541
  },
548
542
 
549
543
  // Verify route handler
550
544
  verifyHandler: (req, res) => {
551
- const { siteKey, signals, powSolution } = req.body;
545
+ const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
552
546
  const ip = options.getIP ? options.getIP(req) : proxyTrust.clientIP(req);
547
+ const siteKey = siteKeys.normalize(rawSiteKey || 'default', ip);
553
548
  const userAgent = req.headers['user-agent'] || '';
554
549
 
555
550
  const headers = {};
@@ -557,14 +552,14 @@ function createMiddleware(options = {}) {
557
552
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
558
553
  }
559
554
 
560
- const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
555
+ const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution, signalsJson, powTiming);
561
556
  res.json(result);
562
557
  },
563
558
 
564
559
  // Token verify route handler
565
560
  tokenVerifyHandler: (req, res) => {
566
- const { token } = req.body;
567
- res.json(engine.verifyToken(token));
561
+ const { token, remoteip } = req.body;
562
+ res.json(engine.verifyToken(token, typeof remoteip === 'string' ? remoteip : null));
568
563
  }
569
564
  };
570
565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.34.1",
3
+ "version": "1.36.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 redis-state.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 security.test.js corroboration.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/protocol.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
6
+
7
+ function invalidRequest() {
8
+ return Object.assign(new Error('invalid_request'), { status: 400 });
9
+ }
10
+
11
+ // Resolve the exact bytes committed by the proof before any detector mutates
12
+ // signals. Legacy proofs without a commitment still use the signals object.
13
+ function resolveSignals(signals, signalsJson, signalsHash) {
14
+ let commitmentValid = true;
15
+ if (signalsHash) {
16
+ if (typeof signalsJson !== 'string' || typeof signalsHash !== 'string') {
17
+ commitmentValid = false;
18
+ } else {
19
+ commitmentValid = crypto.createHash('sha256').update(signalsJson).digest('hex') === signalsHash;
20
+ try { signals = JSON.parse(signalsJson); } catch { throw invalidRequest(); }
21
+ }
22
+ }
23
+ if (!isObject(signals)) throw invalidRequest();
24
+ for (const key of ['behavioral', 'environmental', 'temporal', 'meta', 'formAnalysis']) {
25
+ if (signals[key] != null && !isObject(signals[key])) throw invalidRequest();
26
+ }
27
+ return { signals, commitmentValid };
28
+ }
29
+
30
+ module.exports = { resolveSignals, isObject, invalidRequest };
package/redis-state.js CHANGED
@@ -38,6 +38,16 @@ redis.call('PEXPIRE', KEYS[1], ARGV[3])
38
38
  return 1
39
39
  `;
40
40
 
41
+ const RECORD_FINGERPRINT = `
42
+ if redis.call('SCARD', KEYS[2]) >= 16 and redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 0 then return 0 end
43
+ if redis.call('SCARD', KEYS[1]) < 16 then redis.call('SADD', KEYS[1], ARGV[1]) end
44
+ if redis.call('SCARD', KEYS[2]) < 16 then redis.call('SADD', KEYS[2], ARGV[2]) end
45
+ for i = 1, 2 do
46
+ if redis.call('PTTL', KEYS[i]) < 0 then redis.call('PEXPIRE', KEYS[i], ARGV[3]) end
47
+ end
48
+ return 1
49
+ `;
50
+
41
51
  class RedisState {
42
52
  constructor(url, client = null) {
43
53
  this.client = client || createClient({ url });
@@ -148,10 +158,10 @@ class RedisState {
148
158
  async recordFingerprint(fingerprint, ip, siteKey) {
149
159
  const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
150
160
  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();
161
+ await this.client.eval(RECORD_FINGERPRINT, {
162
+ keys: [fpKey, ipKey],
163
+ arguments: [this.opaqueKey('value:ip', ip), this.opaqueKey('value:fp', fingerprint), String(DETECTION_TTL_MS)]
164
+ });
155
165
  }
156
166
 
157
167
  async ipFingerprintCount(ip) {
package/server.js CHANGED
@@ -16,6 +16,9 @@ const { signingSecretFromEnv } = require('./config');
16
16
  const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
17
17
  const { detectInputForensics } = require('./inputforensics');
18
18
  const { RedisState } = require('./redis-state');
19
+ const { FingerprintStore } = require('./fingerprint-store');
20
+ const { TokenStore } = require('./token-store');
21
+ const { resolveSignals, isObject, invalidRequest } = require('./protocol');
19
22
  const {
20
23
  HostnameAllowlist,
21
24
  IdempotencyStore,
@@ -283,7 +286,8 @@ const powChallengeStore = {
283
286
  }
284
287
 
285
288
  // Calculate server-side elapsed time (un-spoofable)
286
- const serverElapsed = Date.now() - challenge.createdAt;
289
+ // timestamp is shared by all Redis writers; createdAt was Node-only.
290
+ const serverElapsed = Date.now() - challenge.timestamp;
287
291
 
288
292
  return {
289
293
  valid: true,
@@ -329,72 +333,22 @@ const rateLimiter = {
329
333
  }
330
334
  };
331
335
 
336
+ const localFingerprintStore = new FingerprintStore();
332
337
  const fingerprintStore = {
333
- fingerprints: new BoundedMap(),
334
- ipFingerprints: new BoundedMap(),
335
-
336
338
  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
- }
341
- const key = `${siteKey}:${fp}`;
342
-
343
- if (!this.fingerprints.has(key)) {
344
- this.fingerprints.set(key, { count: 0, ips: new Set() });
345
- }
346
- const data = this.fingerprints.get(key);
347
- data.count++;
348
- data.ips.add(ip);
349
-
350
- if (!this.ipFingerprints.has(ip)) {
351
- this.ipFingerprints.set(ip, new Set());
352
- }
353
- this.ipFingerprints.get(ip).add(fp);
339
+ if (!SHARED_STATE) return localFingerprintStore.record(fp, ip, siteKey);
340
+ try { await SHARED_STATE.recordFingerprint(fp, ip, siteKey); } catch (_) { /* reads fail closed */ }
354
341
  },
355
-
356
342
  async getIpFpCount(ip) {
357
- if (SHARED_STATE) {
358
- try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; }
359
- }
360
- return this.ipFingerprints.get(ip)?.size || 0;
343
+ if (!SHARED_STATE) return localFingerprintStore.getIpFpCount(ip);
344
+ try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; }
361
345
  },
362
-
363
346
  async getFpIpCount(fp, siteKey) {
364
- if (SHARED_STATE) {
365
- try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; }
366
- }
367
- const key = `${siteKey}:${fp}`;
368
- return this.fingerprints.get(key)?.ips.size || 0;
369
- }
370
- };
371
-
372
- // Token Store - prevents token replay attacks
373
- const tokenStore = {
374
- usedTokens: new BoundedSet(),
375
-
376
- // Mark a token as used (returns false if already used)
377
- markUsed(tokenSig) {
378
- if (this.usedTokens.has(tokenSig)) {
379
- return false; // Already used
380
- }
381
- this.usedTokens.add(tokenSig);
382
-
383
- // Cleanup old tokens periodically (tokens expire after 5 min anyway)
384
- if (Math.random() < 0.1) this._cleanup();
385
- return true;
386
- },
387
-
388
- isUsed(tokenSig) {
389
- return this.usedTokens.has(tokenSig);
390
- },
391
-
392
- _cleanup() {
393
- // In production with Redis, use TTL instead. In memory usedTokens is a
394
- // BoundedSet, so it evicts its oldest entries rather than clearing wholesale
395
- // — clearing would let an attacker who forced the threshold replay a token.
347
+ if (!SHARED_STATE) return localFingerprintStore.getFpIpCount(fp, siteKey);
348
+ try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; }
396
349
  }
397
350
  };
351
+ const tokenStore = new TokenStore();
398
352
 
399
353
  // Detection patterns, the pure detectors and the scoring aggregation now
400
354
  // live in engine.js, shared with the npm library entry point (index.js).
@@ -499,6 +453,7 @@ function generateToken(ip, siteKey, score, binding = {}) {
499
453
  const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
500
454
  const data = {
501
455
  site_key: siteKey,
456
+ jti: crypto.randomBytes(16).toString('hex'),
502
457
  timestamp: Math.floor(Date.now() / 1000),
503
458
  score: Math.round(score * 1000) / 1000,
504
459
  ip_hash: ipHash,
@@ -540,15 +495,15 @@ async function verifyToken(token, ip = null) {
540
495
  }
541
496
  }
542
497
 
543
- let claimed;
498
+ let claim;
544
499
  try {
545
- claimed = SHARED_STATE
546
- ? await SHARED_STATE.claimToken(sig)
547
- : tokenStore.markUsed(sig);
500
+ claim = SHARED_STATE
501
+ ? { claimed: await SHARED_STATE.claimToken(sig), reason: 'token_already_used' }
502
+ : tokenStore.claim(sig);
548
503
  } catch (_) {
549
504
  return { valid: false, reason: 'state_unavailable' };
550
505
  }
551
- if (!claimed) return { valid: false, reason: 'token_already_used' };
506
+ if (!claim.claimed) return { valid: false, reason: claim.reason };
552
507
 
553
508
  // hostname/action/cdata default to '' so a token minted before they existed
554
509
  // still verifies and reports the same shape. The signature covers whatever
@@ -588,24 +543,12 @@ async function verifyWebBotAuth(req) {
588
543
  async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
589
544
  const detections = Array.isArray(preDetections) ? [...preDetections] : [];
590
545
 
591
- // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
592
546
  const clientSignalsHash = powSolution?.signalsHash || null;
593
- if (signalsJson && clientSignalsHash) {
594
- const computedHash = crypto.createHash('sha256').update(signalsJson).digest('hex');
595
- if (computedHash !== clientSignalsHash) {
596
- detections.push({
597
- category: 'bot',
598
- score: 0.95,
599
- confidence: 0.95,
600
- reason: 'Signals tampered after PoW (signalsHash mismatch)'
601
- });
602
- }
603
- // Use signalsJson as the canonical signals source
604
- try {
605
- signals = JSON.parse(signalsJson);
606
- } catch (e) {
607
- // Fall back to parsed signals if signalsJson is invalid
608
- }
547
+ const resolved = resolveSignals(signals, signalsJson, clientSignalsHash);
548
+ signals = resolved.signals;
549
+ if (!resolved.commitmentValid) {
550
+ detections.push({ category: 'bot', score: 0.95, confidence: 0.95,
551
+ reason: 'Signals tampered after PoW (signalsHash mismatch)' });
609
552
  }
610
553
 
611
554
  // Inject powTiming into signals.temporal.pow for detection functions
@@ -660,7 +603,8 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
660
603
  // replays a solution. All ordinary things that happen to real people.
661
604
  });
662
605
  } else {
663
- powSatisfied = true;
606
+ powSatisfied = resolved.commitmentValid &&
607
+ powVerification.serverElapsed >= Math.max(BASE_MIN_AGE_MS, powVerification.minAgeMs || 0);
664
608
  }
665
609
 
666
610
  // Verify challenge nonce binding
@@ -692,9 +636,8 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
692
636
  reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
693
637
  });
694
638
  } else if (powValid && powVerification.serverElapsed < (powVerification.minAgeMs || BASE_MIN_AGE_MS)) {
695
- // Between the baseline and this source's own elevated floor is weaker
696
- // evidence: a client predating adaptive cost, or one served from a stale
697
- // cache, does not know to wait. It contributes rather than deciding.
639
+ // Keep the diagnostic weaker than a baseline violation; token issuance
640
+ // independently requires the full advertised delay.
698
641
  detections.push({
699
642
  category: 'bot',
700
643
  score: 0.5,
@@ -785,7 +728,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
785
728
  const categoryScores = calculateCategoryScores(detections);
786
729
  const finalScore = applyCorroborationFloor(
787
730
  applyDispositiveFloor(calculateFinalScore(categoryScores), detections),
788
- categoryScores
731
+ detections
789
732
  );
790
733
 
791
734
  let recommendation;
@@ -870,7 +813,18 @@ function collectHeaders(req) {
870
813
  return headers;
871
814
  }
872
815
 
873
- app.post('/api/verify', async (req, res) => {
816
+ const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res)).catch(next);
817
+
818
+ function validateScoringRequest(req, res, next) {
819
+ const body = req.body;
820
+ if (!isObject(body) || !isObject(body.signals) ||
821
+ (body.siteKey != null && typeof body.siteKey !== 'string') ||
822
+ (body.powSolution != null && !isObject(body.powSolution)) ||
823
+ (body.powTiming != null && !isObject(body.powTiming))) return next(invalidRequest());
824
+ next();
825
+ }
826
+
827
+ app.post('/api/verify', validateScoringRequest, asyncRoute(async (req, res) => {
874
828
  const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
875
829
  const ip = PROXY_TRUST.clientIP(req);
876
830
  // Bound the state an unvalidated siteKey can allocate (limits.js).
@@ -890,9 +844,9 @@ app.post('/api/verify', async (req, res) => {
890
844
  const result = await runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata, widgetInteraction: true });
891
845
  logVerdict('verify', siteKey, result);
892
846
  res.json(result);
893
- });
847
+ }));
894
848
 
895
- app.post('/api/score', async (req, res) => {
849
+ app.post('/api/score', validateScoringRequest, asyncRoute(async (req, res) => {
896
850
  const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
897
851
  const ip = PROXY_TRUST.clientIP(req);
898
852
  const siteKey = await normalizeSiteKey(rawSiteKey, ip);
@@ -924,9 +878,9 @@ app.post('/api/score', async (req, res) => {
924
878
  // needs to know which precondition failed.
925
879
  ...(result.reason ? { reason: result.reason } : {})
926
880
  });
927
- });
881
+ }));
928
882
 
929
- app.post('/api/token/verify', async (req, res) => {
883
+ app.post('/api/token/verify', asyncRoute(async (req, res) => {
930
884
  const { token, secret, remoteip } = req.body;
931
885
 
932
886
  // The secret gate. This endpoint is the boundary between "a browser finished a
@@ -946,7 +900,7 @@ app.post('/api/token/verify', async (req, res) => {
946
900
  // received the token. Bind only when that trusted backend explicitly supplies
947
901
  // the visitor address; using the caller socket here compares unrelated hosts.
948
902
  res.json(await verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null));
949
- });
903
+ }));
950
904
 
951
905
  // Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
952
906
  //
@@ -968,12 +922,13 @@ async function siteverifyHandler(req, res) {
968
922
  );
969
923
  }
970
924
 
971
- app.post('/turnstile/v0/siteverify', siteverifyHandler);
972
- app.post('/recaptcha/api/siteverify', siteverifyHandler);
973
- app.post('/siteverify', siteverifyHandler);
925
+ app.post('/turnstile/v0/siteverify', asyncRoute(siteverifyHandler));
926
+ app.post('/recaptcha/api/siteverify', asyncRoute(siteverifyHandler));
927
+ app.post('/siteverify', asyncRoute(siteverifyHandler));
974
928
 
975
929
  // PoW Challenge endpoint - client fetches this on page load
976
- app.get('/api/pow/challenge', async (req, res) => {
930
+ app.get('/api/pow/challenge', asyncRoute(async (req, res) => {
931
+ if (req.query.siteKey != null && typeof req.query.siteKey !== 'string') throw invalidRequest();
977
932
  const ip = PROXY_TRUST.clientIP(req);
978
933
  const siteKey = await normalizeSiteKey(req.query.siteKey, ip);
979
934
 
@@ -1007,7 +962,7 @@ app.get('/api/pow/challenge', async (req, res) => {
1007
962
  // wait instead of as a worse score.
1008
963
  minAgeMs: challenge.minAgeMs
1009
964
  });
1010
- });
965
+ }));
1011
966
 
1012
967
  // Legacy challenge endpoint for backwards compatibility
1013
968
  app.get('/api/challenge', (req, res) => {
@@ -1026,7 +981,8 @@ app.use((err, req, res, next) => {
1026
981
  if (err && (err.status === 413 || err.type === 'entity.too.large')) {
1027
982
  return res.status(413).json({ error: 'request_too_large' });
1028
983
  }
1029
- return next(err);
984
+ const status = err && err.status === 400 ? 400 : 500;
985
+ res.status(status).json({ error: status === 400 ? 'invalid_request' : 'internal_error' });
1030
986
  });
1031
987
 
1032
988
  // =============================================================================
@@ -1035,15 +991,19 @@ app.use((err, req, res, next) => {
1035
991
 
1036
992
  async function start() {
1037
993
  if (SHARED_STATE) await SHARED_STATE.connect();
1038
- app.listen(PORT, () => {
1039
- console.log(`FCaptcha server running on port ${PORT}`);
994
+ const server = app.listen(PORT, () => {
995
+ console.log(`FCaptcha server running on port ${server.address().port}`);
1040
996
  console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1041
997
  console.log(`Site keys: ${SITE_KEYS.describe()}`);
1042
998
  console.log(`Shared state: ${SHARED_STATE ? 'Redis (PoW)' : 'in-memory'}`);
1043
999
  });
1000
+ return server;
1044
1001
  }
1045
1002
 
1046
- start().catch((err) => {
1047
- console.error(`FCaptcha failed to start: ${err.message}`);
1048
- process.exit(1);
1049
- });
1003
+ if (require.main === module) {
1004
+ start().catch((err) => {
1005
+ console.error(`FCaptcha failed to start: ${err.message}`);
1006
+ process.exit(1);
1007
+ });
1008
+ }
1009
+ module.exports = { app, start };
package/siteverify.js CHANGED
@@ -79,6 +79,7 @@ const ERROR_CODES = {
79
79
  const REASON_TO_ERROR_CODE = {
80
80
  expired: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
81
81
  token_already_used: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
82
+ token_store_full: ERROR_CODES.INTERNAL_ERROR,
82
83
  invalid_signature: ERROR_CODES.INVALID_RESPONSE,
83
84
  invalid_encoding: ERROR_CODES.INVALID_RESPONSE,
84
85
  invalid_json: ERROR_CODES.INVALID_RESPONSE,
package/token-store.js ADDED
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // Spent tokens must not be evicted while they can still verify. If capacity is
4
+ // exhausted, refuse new claims rather than reopening replay of older tokens.
5
+ class TokenStore {
6
+ constructor({ maxEntries = 100000, now = Date.now } = {}) {
7
+ this.entries = new Map();
8
+ this.maxEntries = maxEntries;
9
+ this.now = now;
10
+ this.nextCleanup = 0;
11
+ }
12
+
13
+ markUsed(signature) {
14
+ return this.claim(signature).claimed;
15
+ }
16
+
17
+ claim(signature) {
18
+ const now = this.now();
19
+ if (now >= this.nextCleanup) {
20
+ for (const [key, expiresAt] of this.entries) {
21
+ if (expiresAt <= now) this.entries.delete(key);
22
+ }
23
+ this.nextCleanup = now + 60000;
24
+ }
25
+ if (this.entries.has(signature)) return { claimed: false, reason: 'token_already_used' };
26
+ if (this.entries.size >= this.maxEntries) return { claimed: false, reason: 'token_store_full' };
27
+ this.entries.set(signature, now + 10 * 60 * 1000);
28
+ return { claimed: true };
29
+ }
30
+ }
31
+
32
+ module.exports = { TokenStore };