@webdecoy/fcaptcha 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.js +52 -17
  2. package/package.json +4 -3
  3. package/server.js +49 -21
  4. package/suspicion.js +172 -0
package/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  const crypto = require('crypto');
13
13
  const detection = require('./detection');
14
14
  const { ProxyTrust } = require('./clientip');
15
+ const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
15
16
 
16
17
  // =============================================================================
17
18
  // PoW Challenge Store (can be extended with Redis)
@@ -25,7 +26,7 @@ class PoWChallengeStore {
25
26
  this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
26
27
  }
27
28
 
28
- generate(siteKey, ip, difficulty = 4) {
29
+ generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
29
30
  const challengeId = crypto.randomBytes(16).toString('hex');
30
31
  const timestamp = Date.now();
31
32
  const expiresAt = timestamp + this.expirationMs;
@@ -36,14 +37,16 @@ class PoWChallengeStore {
36
37
  timestamp,
37
38
  expiresAt,
38
39
  difficulty,
40
+ // How long the client must hold this challenge before submitting a
41
+ // solution. Inside the signed payload so it cannot be talked down.
42
+ minAgeMs,
39
43
  prefix: `${challengeId}:${timestamp}:${difficulty}`
40
44
  };
41
45
 
42
46
  // Sign the challenge
43
47
  const sig = crypto.createHmac('sha256', this.secret)
44
48
  .update(JSON.stringify(challengeData))
45
- .digest('hex')
46
- .slice(0, 16);
49
+ .digest('hex');
47
50
 
48
51
  challengeData.sig = sig;
49
52
 
@@ -100,7 +103,13 @@ class PoWChallengeStore {
100
103
  this.usedSolutions.add(solutionKey);
101
104
  this.challenges.delete(challengeId);
102
105
 
103
- return { valid: true, difficulty: challenge.difficulty };
106
+ return {
107
+ valid: true,
108
+ difficulty: challenge.difficulty,
109
+ serverElapsed: Date.now() - challenge.timestamp,
110
+ // Fall back for challenges issued before adaptive cost existed.
111
+ minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
112
+ };
104
113
  }
105
114
 
106
115
  _cleanup() {
@@ -207,29 +216,31 @@ class ScoringEngine {
207
216
  this.powStore = options.powStore || new PoWChallengeStore({ secret: this.secret });
208
217
  this.rateLimiter = options.rateLimiter || new RateLimiter();
209
218
  this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
219
+ this.suspicion = options.suspicion || new SuspicionLedger();
210
220
  this.weights = options.weights || WEIGHTS;
211
221
  }
212
222
 
213
223
  // Generate a PoW challenge
214
224
  generateChallenge(siteKey, ip, options = {}) {
225
+ // See suspicion.js for why the escalation lands almost entirely on
226
+ // minAgeMs rather than on difficulty.
215
227
  let difficulty = options.difficulty || 4;
228
+ let minAgeMs = BASE_MIN_AGE_MS;
216
229
 
217
230
  if (options.scaleByReputation !== false) {
218
- if (detection.isDatacenterIP(ip)) {
219
- difficulty = Math.max(difficulty, 5);
220
- }
221
-
222
231
  const rateKey = `pow:${siteKey}:${ip}`;
223
232
  const [exceeded, count] = this.rateLimiter.check(rateKey, 60, 20);
224
- if (count > 10) {
225
- difficulty = Math.min(6, difficulty + 1);
226
- }
227
- if (exceeded) {
228
- difficulty = 6;
229
- }
233
+ const cost = computeChallengeCost(
234
+ this.suspicion.count(siteKey, ip),
235
+ detection.isDatacenterIP(ip),
236
+ count,
237
+ exceeded
238
+ );
239
+ difficulty = Math.max(difficulty, cost.difficulty);
240
+ minAgeMs = cost.minAgeMs;
230
241
  }
231
242
 
232
- return this.powStore.generate(siteKey, ip, difficulty);
243
+ return this.powStore.generate(siteKey, ip, difficulty, minAgeMs);
233
244
  }
234
245
 
235
246
  // Verify signals and return score
@@ -260,6 +271,26 @@ class ScoringEngine {
260
271
  confidence: 0.8,
261
272
  reason: `PoW verification failed: ${powResult.reason}`
262
273
  });
274
+ } else if (powResult.serverElapsed < BASE_MIN_AGE_MS) {
275
+ // Under the universal baseline nothing legitimate can have happened —
276
+ // no human completes an interaction that fast.
277
+ detections.push({
278
+ category: 'bot',
279
+ score: 0.8,
280
+ confidence: 0.85,
281
+ reason: `Challenge solved too fast (${powResult.serverElapsed}ms server-side)`
282
+ });
283
+ } else if (powResult.serverElapsed < powResult.minAgeMs) {
284
+ // Between the baseline and this source's own elevated floor is weaker
285
+ // evidence: a client predating adaptive cost, or one served from a
286
+ // stale cache, does not know to wait. It contributes rather than
287
+ // deciding.
288
+ detections.push({
289
+ category: 'bot',
290
+ score: 0.5,
291
+ confidence: 0.5,
292
+ reason: `Challenge submitted before the required delay for this source (${powResult.serverElapsed}ms of ${powResult.minAgeMs}ms)`
293
+ });
263
294
  }
264
295
  } else {
265
296
  detections.push({
@@ -308,6 +339,10 @@ class ScoringEngine {
308
339
  const success = finalScore < 0.5;
309
340
  const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
310
341
 
342
+ // Feed the ledger so the next challenge this source asks for is priced on
343
+ // what it just did.
344
+ this.suspicion.record(siteKey, ip, finalScore);
345
+
311
346
  return {
312
347
  success,
313
348
  score: finalScore,
@@ -332,7 +367,7 @@ class ScoringEngine {
332
367
  delete decoded.sig;
333
368
 
334
369
  const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
335
- const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex').slice(0, 16);
370
+ const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
336
371
 
337
372
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
338
373
  return { valid: false, reason: 'invalid_signature' };
@@ -707,7 +742,7 @@ class ScoringEngine {
707
742
  };
708
743
 
709
744
  const payload = JSON.stringify(data, Object.keys(data).sort());
710
- const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex').slice(0, 16);
745
+ const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
711
746
  data.sig = sig;
712
747
 
713
748
  return Buffer.from(JSON.stringify(data)).toString('base64url');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -15,8 +15,9 @@
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 clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js",
19
- "test:forensics": "node inputforensics.test.js"
18
+ "test": "node clientip.test.js && node limits.test.js && node detection.test.js && node inputforensics.test.js && node webbotauth.test.js && node --test suspicion.test.js",
19
+ "test:forensics": "node inputforensics.test.js",
20
+ "test:suspicion": "node --test suspicion.test.js"
20
21
  },
21
22
  "dependencies": {
22
23
  "cors": "^2.8.5",
package/server.js CHANGED
@@ -12,6 +12,7 @@ const detection = require('./detection');
12
12
  const webbotauth = require('./webbotauth');
13
13
  const { ProxyTrust } = require('./clientip');
14
14
  const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
15
+ const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
15
16
  const { detectInputForensics } = require('./inputforensics');
16
17
 
17
18
  const app = express();
@@ -32,6 +33,10 @@ const PROXY_TRUST = ProxyTrust.fromEnv();
32
33
  // limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
33
34
  const SITE_KEYS = SiteKeyGuard.fromEnv();
34
35
 
36
+ // Recent strong verdicts per source, used to price the next challenge that
37
+ // source asks for. Bounded and short-lived; see suspicion.js.
38
+ const suspicionLedger = new SuspicionLedger();
39
+
35
40
  // Express's own `trust proxy` would re-derive req.ip from the same headers on
36
41
  // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
37
42
  app.set('trust proxy', false);
@@ -97,7 +102,7 @@ const powChallengeStore = {
97
102
  usedSolutions: new BoundedSet(),
98
103
 
99
104
  // Generate a new challenge
100
- generate(siteKey, ip, difficulty = 4) {
105
+ generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
101
106
  const challengeId = crypto.randomBytes(16).toString('hex');
102
107
  const nonce = crypto.randomBytes(16).toString('hex');
103
108
  const timestamp = Date.now();
@@ -110,6 +115,9 @@ const powChallengeStore = {
110
115
  timestamp,
111
116
  expiresAt,
112
117
  difficulty,
118
+ // How long the client must hold this challenge before submitting a
119
+ // solution. Inside the signed payload so it cannot be talked down.
120
+ minAgeMs,
113
121
  nonce,
114
122
  prefix: `${challengeId}:${timestamp}:${difficulty}`
115
123
  };
@@ -180,7 +188,14 @@ const powChallengeStore = {
180
188
  // Calculate server-side elapsed time (un-spoofable)
181
189
  const serverElapsed = Date.now() - challenge.createdAt;
182
190
 
183
- return { valid: true, difficulty: challenge.difficulty, serverElapsed, nonce: challenge.nonce };
191
+ return {
192
+ valid: true,
193
+ difficulty: challenge.difficulty,
194
+ serverElapsed,
195
+ nonce: challenge.nonce,
196
+ // Fall back for challenges issued before adaptive cost existed.
197
+ minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
198
+ };
184
199
  },
185
200
 
186
201
  _cleanup() {
@@ -1311,14 +1326,26 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1311
1326
  }
1312
1327
  }
1313
1328
 
1314
- if (powValid && powVerification.serverElapsed < 1500) {
1315
- // Server-side timing: challenge was solved too fast (un-spoofable)
1329
+ // Server-side timing, the one cost an attacker cannot buy their way out
1330
+ // of. Two thresholds, because they mean different things.
1331
+ if (powValid && powVerification.serverElapsed < BASE_MIN_AGE_MS) {
1332
+ // Under the universal baseline nothing legitimate can have happened.
1316
1333
  detections.push({
1317
1334
  category: 'bot',
1318
1335
  score: 0.8,
1319
1336
  confidence: 0.85,
1320
1337
  reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
1321
1338
  });
1339
+ } else if (powValid && powVerification.serverElapsed < (powVerification.minAgeMs || BASE_MIN_AGE_MS)) {
1340
+ // Between the baseline and this source's own elevated floor is weaker
1341
+ // evidence: a client predating adaptive cost, or one served from a stale
1342
+ // cache, does not know to wait. It contributes rather than deciding.
1343
+ detections.push({
1344
+ category: 'bot',
1345
+ score: 0.5,
1346
+ confidence: 0.5,
1347
+ reason: `Challenge submitted before the required delay for this source (${powVerification.serverElapsed}ms of ${powVerification.minAgeMs}ms)`
1348
+ });
1322
1349
  }
1323
1350
  } else {
1324
1351
  // No PoW solution provided - hard fail
@@ -1409,6 +1436,10 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1409
1436
  const success = finalScore < 0.5;
1410
1437
  const token = success ? generateToken(ip, siteKey, finalScore) : null;
1411
1438
 
1439
+ // Feed the ledger so the next challenge this source asks for is priced on
1440
+ // what it just did.
1441
+ suspicionLedger.record(siteKey, ip, finalScore);
1442
+
1412
1443
  return {
1413
1444
  success,
1414
1445
  score: finalScore,
@@ -1506,25 +1537,18 @@ app.get('/api/pow/challenge', (req, res) => {
1506
1537
  const ip = PROXY_TRUST.clientIP(req);
1507
1538
  const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
1508
1539
 
1509
- // Difficulty scaling based on IP reputation
1510
- let difficulty = 4; // Default: ~100-500ms on average hardware
1511
-
1512
- // Increase difficulty for suspicious IPs
1513
- if (detection.isDatacenterIP(ip)) {
1514
- difficulty = 5; // ~1-3 seconds
1515
- }
1516
-
1517
- // Check rate - high request rate gets harder challenges
1540
+ // Cost scaling. See suspicion.js for why the escalation lands almost
1541
+ // entirely on minAgeMs rather than on difficulty.
1518
1542
  const rateKey = `pow:${siteKey}:${ip}`;
1519
1543
  const [exceeded, count] = rateLimiter.check(rateKey, 60, 20);
1520
- if (count > 10) {
1521
- difficulty = Math.min(6, difficulty + 1); // Up to difficulty 6
1522
- }
1523
- if (exceeded) {
1524
- difficulty = 6; // Maximum difficulty for rate-limited IPs
1525
- }
1544
+ const cost = computeChallengeCost(
1545
+ suspicionLedger.count(siteKey, ip),
1546
+ detection.isDatacenterIP(ip),
1547
+ count,
1548
+ exceeded
1549
+ );
1526
1550
 
1527
- const challenge = powChallengeStore.generate(siteKey, ip, difficulty);
1551
+ const challenge = powChallengeStore.generate(siteKey, ip, cost.difficulty, cost.minAgeMs);
1528
1552
 
1529
1553
  res.json({
1530
1554
  challengeId: challenge.id,
@@ -1532,7 +1556,11 @@ app.get('/api/pow/challenge', (req, res) => {
1532
1556
  difficulty: challenge.difficulty,
1533
1557
  expiresAt: challenge.expiresAt,
1534
1558
  nonce: challenge.nonce,
1535
- sig: challenge.sig
1559
+ sig: challenge.sig,
1560
+ // Tells the client how long to hold a solved challenge before submitting.
1561
+ // Honouring it is how an ordinary visitor pays an elevated cost as a short
1562
+ // wait instead of as a worse score.
1563
+ minAgeMs: challenge.minAgeMs
1536
1564
  });
1537
1565
  });
1538
1566
 
package/suspicion.js ADDED
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Adaptive challenge cost: what a source pays is a function of what that source
5
+ * has recently been caught doing, rather than a constant.
6
+ *
7
+ * Why the cost is mostly time, not hashing
8
+ * ----------------------------------------
9
+ * A constant difficulty is strictly dominated: it either fails to inconvenience
10
+ * an attacker or it does hurt real users. The measurements behind that claim:
11
+ * browser JS runs 1-3M hash/s, native code 100-500M/s, so difficulty 6 costs a
12
+ * native solver about a millisecond and a budget Android phone about sixteen
13
+ * seconds. Raising difficulty is close to a pure tax on the slowest legitimate
14
+ * devices.
15
+ *
16
+ * Wall-clock is the knob that does not have that property. Nobody can make less
17
+ * time pass, so a minimum challenge age caps how fast one source can mint
18
+ * tokens no matter what hardware it brings. So suspicion moves the time floor
19
+ * first and difficulty barely at all.
20
+ *
21
+ * What this deliberately is not
22
+ * -----------------------------
23
+ * This is not Workstream F 10.1 (cross-session correlation). It stores strong-
24
+ * verdict timestamps per source, nothing else: no behavioral vectors, no
25
+ * per-fingerprint history, no traces that survive the window. It is the same
26
+ * shape and the same privacy class as the rate limiter sitting next to it, and
27
+ * it should stay that way — 10.1 has a privacy load this does not, and the two
28
+ * should not be conflated because they happen to both be "server-side memory".
29
+ */
30
+
31
+ // The verdict score at or above which a verification counts as evidence.
32
+ // Deliberately high: a marginal verdict is exactly the case where the scoring
33
+ // might be wrong about a real person, and making the next person from that
34
+ // address wait is not worth the guess.
35
+ const STRONG_SCORE = 0.8;
36
+
37
+ // How long a strong verdict keeps counting. Short enough that a shared egress
38
+ // address recovers on its own within a coffee break.
39
+ const WINDOW_MS = 15 * 60 * 1000;
40
+
41
+ // Only the count matters and every tier saturates well below this.
42
+ const MAX_HITS = 16;
43
+
44
+ // Bounds the table. Sources with no strong verdicts never get an entry at all,
45
+ // so this only has to cover addresses actively failing verification.
46
+ const MAX_SOURCES = 50000;
47
+
48
+ /**
49
+ * Records recent strong verdicts per source.
50
+ *
51
+ * Entries are created only when a source produces a strong verdict, so the
52
+ * common case — a legitimate visitor — allocates nothing and looks up nothing
53
+ * but a miss.
54
+ */
55
+ class SuspicionLedger {
56
+ constructor() {
57
+ this.hits = new Map();
58
+ }
59
+
60
+ static _key(siteKey, ip) {
61
+ return `${siteKey}|${ip}`;
62
+ }
63
+
64
+ /**
65
+ * Note a verdict. Scores below the strong threshold are ignored entirely
66
+ * rather than recorded and weighted, so a source that merely looks unusual
67
+ * never accumulates anything.
68
+ */
69
+ record(siteKey, ip, score) {
70
+ if (!ip || typeof score !== 'number' || score < STRONG_SCORE) return;
71
+
72
+ const key = SuspicionLedger._key(siteKey, ip);
73
+ const now = Date.now();
74
+ const cutoff = now - WINDOW_MS;
75
+
76
+ const kept = (this.hits.get(key) || []).filter((t) => t > cutoff);
77
+ kept.push(now);
78
+
79
+ // Map preserves insertion order, so re-inserting makes this the most
80
+ // recently touched key and the eviction below drops the stalest source.
81
+ this.hits.delete(key);
82
+ this.hits.set(key, kept.length > MAX_HITS ? kept.slice(-MAX_HITS) : kept);
83
+
84
+ if (this.hits.size > MAX_SOURCES) {
85
+ this.hits.delete(this.hits.keys().next().value);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * How many strong verdicts this source produced inside the window. Counted
91
+ * from the timestamps rather than from the entry's existence, so an old hit
92
+ * actually decays while newer ones keep the entry alive.
93
+ */
94
+ count(siteKey, ip) {
95
+ if (!ip) return 0;
96
+ const hits = this.hits.get(SuspicionLedger._key(siteKey, ip));
97
+ if (!hits) return 0;
98
+
99
+ const cutoff = Date.now() - WINDOW_MS;
100
+ const n = hits.filter((t) => t > cutoff).length;
101
+ if (n === 0) this.hits.delete(SuspicionLedger._key(siteKey, ip));
102
+ return n;
103
+ }
104
+ }
105
+
106
+ // Baseline cost. A clean visitor pays exactly this, which is what the server
107
+ // has always charged everyone.
108
+ const BASE_DIFFICULTY = 4;
109
+ const BASE_MIN_AGE_MS = 1500;
110
+
111
+ // Caps the compute knob at 5, below the 6 this server used to reach. Difficulty
112
+ // 6 buys about a millisecond of attacker time and spends about sixteen seconds
113
+ // of a budget phone's; the escalation belongs in minAgeMs where an attacker
114
+ // cannot buy their way out of it.
115
+ const MAX_DIFFICULTY = 5;
116
+
117
+ // Caps the time knob at 15s. At the 1.5s baseline one address can mint roughly
118
+ // 40 tokens a minute; at 15s, four. Pushing further buys little and is felt by
119
+ // anyone sharing a poisoned egress address.
120
+ const MAX_MIN_AGE_MS = 15000;
121
+
122
+ /**
123
+ * Maps accumulated suspicion onto a cost.
124
+ *
125
+ * Note what does NOT raise difficulty here: being on a datacenter address. That
126
+ * used to jump straight to difficulty 5, which charges a real person on a
127
+ * corporate VPN or iCloud Private Relay several seconds of blocked hashing on a
128
+ * slow phone for the offence of having a shared IP. It now moves the time floor
129
+ * instead, which a datacenter-hosted scraper feels as reduced throughput and a
130
+ * person filling in a form does not feel at all.
131
+ *
132
+ * @returns {{difficulty: number, minAgeMs: number}}
133
+ */
134
+ function computeChallengeCost(strongHits, isDatacenter, requestCount, rateExceeded) {
135
+ let difficulty = BASE_DIFFICULTY;
136
+ let minAgeMs = BASE_MIN_AGE_MS;
137
+
138
+ if (strongHits >= 6) {
139
+ difficulty = 5;
140
+ minAgeMs = 15000;
141
+ } else if (strongHits >= 3) {
142
+ difficulty = 5;
143
+ minAgeMs = 8000;
144
+ } else if (strongHits >= 1) {
145
+ minAgeMs = 4000;
146
+ }
147
+
148
+ // Floors from signals that are suggestive rather than damning. They raise the
149
+ // time floor and never the difficulty.
150
+ const raiseTo = (ms) => {
151
+ if (minAgeMs < ms) minAgeMs = ms;
152
+ };
153
+ if (isDatacenter) raiseTo(3000);
154
+ if (requestCount > 10) raiseTo(6000);
155
+ if (rateExceeded) raiseTo(10000);
156
+
157
+ return {
158
+ difficulty: Math.min(difficulty, MAX_DIFFICULTY),
159
+ minAgeMs: Math.min(minAgeMs, MAX_MIN_AGE_MS)
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ SuspicionLedger,
165
+ computeChallengeCost,
166
+ STRONG_SCORE,
167
+ WINDOW_MS,
168
+ BASE_DIFFICULTY,
169
+ BASE_MIN_AGE_MS,
170
+ MAX_DIFFICULTY,
171
+ MAX_MIN_AGE_MS
172
+ };