@webdecoy/fcaptcha 1.20.0 → 1.22.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/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.22.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,10 @@
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 siteverify.test.js && node --test suspicion.test.js",
19
+ "test:siteverify": "node siteverify.test.js",
20
+ "test:forensics": "node inputforensics.test.js",
21
+ "test:suspicion": "node --test suspicion.test.js"
20
22
  },
21
23
  "dependencies": {
22
24
  "cors": "^2.8.5",
package/server.js CHANGED
@@ -12,13 +12,51 @@ 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');
17
+ const {
18
+ ERROR_CODES,
19
+ HostnameAllowlist,
20
+ IdempotencyStore,
21
+ requestHostname,
22
+ sanitizeAction,
23
+ sanitizeCdata,
24
+ secretMatches,
25
+ siteverify
26
+ } = require('./siteverify');
16
27
 
17
28
  const app = express();
18
29
  app.use(cors());
19
30
  app.use(express.json());
31
+ // The siteverify contract accepts form-encoded bodies as well as JSON, and most
32
+ // PHP and Python integrations in the wild post form-encoded. Parsing both here
33
+ // costs nothing on the JSON path.
34
+ app.use(express.urlencoded({ extended: false }));
20
35
 
21
36
  const SECRET_KEY = process.env.FCAPTCHA_SECRET || 'dev-secret-change-in-production';
37
+
38
+ // The credential a backend presents to validate a token. Defaults to the signing
39
+ // key, which is what the README has always documented, but can be separated:
40
+ // the signing key is a long-lived secret that must never leave the server, while
41
+ // this one is handed to every backend that verifies. Splitting them means a
42
+ // leaked verify credential does not let the holder mint tokens.
43
+ const VERIFY_SECRET = process.env.FCAPTCHA_VERIFY_SECRET || SECRET_KEY;
44
+
45
+ // Token verification used to accept anyone who could reach the endpoint: all
46
+ // three servers read `secret` out of the body and dropped it. Enforcing it is a
47
+ // breaking change for deployments that never sent one, so there is one release
48
+ // of escape hatch before the parameter becomes mandatory outright.
49
+ const LEGACY_UNAUTH_VERIFY = /^(1|true|yes|on)$/i.test(
50
+ process.env.FCAPTCHA_LEGACY_UNAUTH_VERIFY || ''
51
+ );
52
+
53
+ // Optional: restrict which page origins may mint tokens. Off by default so
54
+ // zero-config self-hosting keeps working. See siteverify.js.
55
+ const ALLOWED_HOSTNAMES = HostnameAllowlist.fromEnv();
56
+
57
+ // Lets a caller retry a validation that timed out without burning the token.
58
+ const IDEMPOTENCY = new IdempotencyStore();
59
+
22
60
  const PORT = process.env.PORT || 3000;
23
61
  const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
24
62
 
@@ -32,6 +70,10 @@ const PROXY_TRUST = ProxyTrust.fromEnv();
32
70
  // limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
33
71
  const SITE_KEYS = SiteKeyGuard.fromEnv();
34
72
 
73
+ // Recent strong verdicts per source, used to price the next challenge that
74
+ // source asks for. Bounded and short-lived; see suspicion.js.
75
+ const suspicionLedger = new SuspicionLedger();
76
+
35
77
  // Express's own `trust proxy` would re-derive req.ip from the same headers on
36
78
  // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
37
79
  app.set('trust proxy', false);
@@ -97,7 +139,7 @@ const powChallengeStore = {
97
139
  usedSolutions: new BoundedSet(),
98
140
 
99
141
  // Generate a new challenge
100
- generate(siteKey, ip, difficulty = 4) {
142
+ generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
101
143
  const challengeId = crypto.randomBytes(16).toString('hex');
102
144
  const nonce = crypto.randomBytes(16).toString('hex');
103
145
  const timestamp = Date.now();
@@ -110,6 +152,9 @@ const powChallengeStore = {
110
152
  timestamp,
111
153
  expiresAt,
112
154
  difficulty,
155
+ // How long the client must hold this challenge before submitting a
156
+ // solution. Inside the signed payload so it cannot be talked down.
157
+ minAgeMs,
113
158
  nonce,
114
159
  prefix: `${challengeId}:${timestamp}:${difficulty}`
115
160
  };
@@ -180,7 +225,14 @@ const powChallengeStore = {
180
225
  // Calculate server-side elapsed time (un-spoofable)
181
226
  const serverElapsed = Date.now() - challenge.createdAt;
182
227
 
183
- return { valid: true, difficulty: challenge.difficulty, serverElapsed, nonce: challenge.nonce };
228
+ return {
229
+ valid: true,
230
+ difficulty: challenge.difficulty,
231
+ serverElapsed,
232
+ nonce: challenge.nonce,
233
+ // Fall back for challenges issued before adaptive cost existed.
234
+ minAgeMs: challenge.minAgeMs || BASE_MIN_AGE_MS
235
+ };
184
236
  },
185
237
 
186
238
  _cleanup() {
@@ -1169,13 +1221,26 @@ function applyDispositiveFloor(score, detections) {
1169
1221
  return selfDeclared ? Math.max(score, DISPOSITIVE_FLOOR) : score;
1170
1222
  }
1171
1223
 
1172
- function generateToken(ip, siteKey, score) {
1224
+ // Bind the token to the page that minted it and to the action it was minted
1225
+ // for. All three ride inside the signed payload, so a token issued for
1226
+ // `action=login` on example.com cannot be replayed against a password-reset
1227
+ // endpoint or presented from a site that lifted the key — the backend compares
1228
+ // what siteverify reports against what it expected.
1229
+ //
1230
+ // Empty strings rather than omitted keys: the signing payload is a sorted-key
1231
+ // JSON serialisation, so a token whose key set varies with what the browser
1232
+ // happened to send would be a second payload shape to keep in sync across three
1233
+ // languages. Fixed shape, empty when unknown.
1234
+ function generateToken(ip, siteKey, score, binding = {}) {
1173
1235
  const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
1174
1236
  const data = {
1175
1237
  site_key: siteKey,
1176
1238
  timestamp: Math.floor(Date.now() / 1000),
1177
1239
  score: Math.round(score * 1000) / 1000,
1178
- ip_hash: ipHash
1240
+ ip_hash: ipHash,
1241
+ hostname: binding.hostname || '',
1242
+ action: binding.action || '',
1243
+ cdata: binding.cdata || ''
1179
1244
  };
1180
1245
 
1181
1246
  const payload = JSON.stringify(data, Object.keys(data).sort());
@@ -1219,12 +1284,19 @@ function verifyToken(token, ip = null) {
1219
1284
  // Mark token as used (prevents replay)
1220
1285
  tokenStore.markUsed(sig);
1221
1286
 
1287
+ // hostname/action/cdata default to '' so a token minted before they existed
1288
+ // still verifies and reports the same shape. The signature covers whatever
1289
+ // keys the token actually carries, so old four-key tokens validate
1290
+ // unchanged — this is additive, not a format break.
1222
1291
  return {
1223
1292
  valid: true,
1224
1293
  site_key: decoded.site_key,
1225
1294
  timestamp: decoded.timestamp,
1226
1295
  score: decoded.score,
1227
- ip_hash: decoded.ip_hash
1296
+ ip_hash: decoded.ip_hash,
1297
+ hostname: decoded.hostname || '',
1298
+ action: decoded.action || '',
1299
+ cdata: decoded.cdata || ''
1228
1300
  };
1229
1301
  } catch (e) {
1230
1302
  return { valid: false, reason: e.message };
@@ -1311,14 +1383,26 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1311
1383
  }
1312
1384
  }
1313
1385
 
1314
- if (powValid && powVerification.serverElapsed < 1500) {
1315
- // Server-side timing: challenge was solved too fast (un-spoofable)
1386
+ // Server-side timing, the one cost an attacker cannot buy their way out
1387
+ // of. Two thresholds, because they mean different things.
1388
+ if (powValid && powVerification.serverElapsed < BASE_MIN_AGE_MS) {
1389
+ // Under the universal baseline nothing legitimate can have happened.
1316
1390
  detections.push({
1317
1391
  category: 'bot',
1318
1392
  score: 0.8,
1319
1393
  confidence: 0.85,
1320
1394
  reason: `Challenge solved too fast (${powVerification.serverElapsed}ms server-side)`
1321
1395
  });
1396
+ } else if (powValid && powVerification.serverElapsed < (powVerification.minAgeMs || BASE_MIN_AGE_MS)) {
1397
+ // Between the baseline and this source's own elevated floor is weaker
1398
+ // evidence: a client predating adaptive cost, or one served from a stale
1399
+ // cache, does not know to wait. It contributes rather than deciding.
1400
+ detections.push({
1401
+ category: 'bot',
1402
+ score: 0.5,
1403
+ confidence: 0.5,
1404
+ reason: `Challenge submitted before the required delay for this source (${powVerification.serverElapsed}ms of ${powVerification.minAgeMs}ms)`
1405
+ });
1322
1406
  }
1323
1407
  } else {
1324
1408
  // No PoW solution provided - hard fail
@@ -1406,8 +1490,29 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1406
1490
  else if (finalScore < 0.6) recommendation = 'challenge';
1407
1491
  else recommendation = 'block';
1408
1492
 
1409
- const success = finalScore < 0.5;
1410
- const token = success ? generateToken(ip, siteKey, finalScore) : null;
1493
+ // The hostname comes from the request headers rather than the request body:
1494
+ // it is what the browser reported about the page, not what the page claimed
1495
+ // about itself.
1496
+ const hostname = requestHostname(headers);
1497
+
1498
+ // An unlisted hostname withholds the token but does not touch the score. The
1499
+ // visitor is not the problem — a key registered to another site is — so the
1500
+ // detection layer has nothing to say about it and the refusal is reported as
1501
+ // its own reason rather than smuggled in as a bot verdict.
1502
+ const hostnameAllowed = ALLOWED_HOSTNAMES.permits(hostname);
1503
+ const success = finalScore < 0.5 && hostnameAllowed;
1504
+
1505
+ const token = success
1506
+ ? generateToken(ip, siteKey, finalScore, {
1507
+ hostname,
1508
+ action: sanitizeAction(opts.action),
1509
+ cdata: sanitizeCdata(opts.cdata)
1510
+ })
1511
+ : null;
1512
+
1513
+ // Feed the ledger so the next challenge this source asks for is priced on
1514
+ // what it just did.
1515
+ suspicionLedger.record(siteKey, ip, finalScore);
1411
1516
 
1412
1517
  return {
1413
1518
  success,
@@ -1416,7 +1521,8 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1416
1521
  timestamp: Math.floor(Date.now() / 1000),
1417
1522
  recommendation,
1418
1523
  categoryScores,
1419
- detections
1524
+ detections,
1525
+ ...(hostnameAllowed ? {} : { reason: 'hostname_not_allowed', hostname })
1420
1526
  };
1421
1527
  }
1422
1528
 
@@ -1444,7 +1550,7 @@ function collectHeaders(req) {
1444
1550
  }
1445
1551
 
1446
1552
  app.post('/api/verify', async (req, res) => {
1447
- const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1553
+ const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body;
1448
1554
  const ip = PROXY_TRUST.clientIP(req);
1449
1555
  // Bound the state an unvalidated siteKey can allocate (limits.js).
1450
1556
  const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
@@ -1460,13 +1566,13 @@ app.post('/api/verify', async (req, res) => {
1460
1566
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1461
1567
  const webBotAuth = await verifyWebBotAuth(req);
1462
1568
 
1463
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
1569
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
1464
1570
  logVerdict('verify', siteKey, result);
1465
1571
  res.json(result);
1466
1572
  });
1467
1573
 
1468
1574
  app.post('/api/score', async (req, res) => {
1469
- const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1575
+ const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body;
1470
1576
  const ip = PROXY_TRUST.clientIP(req);
1471
1577
  const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
1472
1578
  const userAgent = req.headers['user-agent'] || '';
@@ -1481,19 +1587,35 @@ app.post('/api/score', async (req, res) => {
1481
1587
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1482
1588
  const webBotAuth = await verifyWebBotAuth(req);
1483
1589
 
1484
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
1590
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted, action, cdata });
1485
1591
  logVerdict('score', siteKey, result);
1486
1592
  res.json({
1487
1593
  success: result.success,
1488
1594
  score: result.score,
1489
1595
  token: result.token,
1490
- action: action || '',
1596
+ // Echo the sanitized form, not the raw input: this is what got signed into
1597
+ // the token, so a caller comparing the two sees the same value.
1598
+ action: sanitizeAction(action),
1599
+ cdata: sanitizeCdata(cdata),
1491
1600
  recommendation: result.recommendation
1492
1601
  });
1493
1602
  });
1494
1603
 
1495
1604
  app.post('/api/token/verify', (req, res) => {
1496
- const { token } = req.body;
1605
+ const { token, secret } = req.body;
1606
+
1607
+ // The secret gate. This endpoint is the boundary between "a browser finished a
1608
+ // challenge" and "my backend believes it", so it is server-to-server and needs
1609
+ // a credential — without one, anyone who can reach the host can spend a token
1610
+ // they observed, and the single-use guard then denies the real user.
1611
+ if (!LEGACY_UNAUTH_VERIFY) {
1612
+ if (!secret) {
1613
+ return res.status(401).json({ valid: false, reason: 'missing_secret' });
1614
+ }
1615
+ if (!secretMatches(secret, VERIFY_SECRET)) {
1616
+ return res.status(401).json({ valid: false, reason: 'invalid_secret' });
1617
+ }
1618
+ }
1497
1619
 
1498
1620
  // Extract client IP for verification
1499
1621
  const ip = PROXY_TRUST.clientIP(req);
@@ -1501,30 +1623,47 @@ app.post('/api/token/verify', (req, res) => {
1501
1623
  res.json(verifyToken(token, ip));
1502
1624
  });
1503
1625
 
1626
+ // Turnstile / reCAPTCHA / hCaptcha drop-in compatibility.
1627
+ //
1628
+ // Same contract, three paths, because the path is hardcoded in the SDKs and
1629
+ // plugins we want to be usable against FCaptcha: pointing an existing
1630
+ // integration at this server should be a base-URL change and nothing else.
1631
+ // See siteverify.js for the adapter itself.
1632
+ function siteverifyHandler(req, res) {
1633
+ res.json(
1634
+ siteverify({
1635
+ body: req.body,
1636
+ // Bind to this server's token store, so replay state is shared with the
1637
+ // native endpoint rather than kept in a parallel universe.
1638
+ verifyToken,
1639
+ expectedSecret: VERIFY_SECRET,
1640
+ idempotencyStore: IDEMPOTENCY,
1641
+ requireSecret: !LEGACY_UNAUTH_VERIFY
1642
+ })
1643
+ );
1644
+ }
1645
+
1646
+ app.post('/turnstile/v0/siteverify', siteverifyHandler);
1647
+ app.post('/recaptcha/api/siteverify', siteverifyHandler);
1648
+ app.post('/siteverify', siteverifyHandler);
1649
+
1504
1650
  // PoW Challenge endpoint - client fetches this on page load
1505
1651
  app.get('/api/pow/challenge', (req, res) => {
1506
1652
  const ip = PROXY_TRUST.clientIP(req);
1507
1653
  const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
1508
1654
 
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
1655
+ // Cost scaling. See suspicion.js for why the escalation lands almost
1656
+ // entirely on minAgeMs rather than on difficulty.
1518
1657
  const rateKey = `pow:${siteKey}:${ip}`;
1519
1658
  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
- }
1659
+ const cost = computeChallengeCost(
1660
+ suspicionLedger.count(siteKey, ip),
1661
+ detection.isDatacenterIP(ip),
1662
+ count,
1663
+ exceeded
1664
+ );
1526
1665
 
1527
- const challenge = powChallengeStore.generate(siteKey, ip, difficulty);
1666
+ const challenge = powChallengeStore.generate(siteKey, ip, cost.difficulty, cost.minAgeMs);
1528
1667
 
1529
1668
  res.json({
1530
1669
  challengeId: challenge.id,
@@ -1532,7 +1671,11 @@ app.get('/api/pow/challenge', (req, res) => {
1532
1671
  difficulty: challenge.difficulty,
1533
1672
  expiresAt: challenge.expiresAt,
1534
1673
  nonce: challenge.nonce,
1535
- sig: challenge.sig
1674
+ sig: challenge.sig,
1675
+ // Tells the client how long to hold a solved challenge before submitting.
1676
+ // Honouring it is how an ordinary visitor pays an elevated cost as a short
1677
+ // wait instead of as a worse score.
1678
+ minAgeMs: challenge.minAgeMs
1536
1679
  });
1537
1680
  });
1538
1681
 
package/siteverify.js ADDED
@@ -0,0 +1,331 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Drop-in compatibility with the Turnstile / reCAPTCHA / hCaptcha siteverify
5
+ * contract.
6
+ *
7
+ * Every one of those services validates a token the same way: a server-to-server
8
+ * POST carrying `secret` and `response`, answered with a small JSON object whose
9
+ * shape has been stable for a decade. Every backend SDK, CMS plugin and Stack
10
+ * Overflow snippet in circulation speaks it:
11
+ *
12
+ * POST /turnstile/v0/siteverify (form-encoded or JSON)
13
+ * secret, response, remoteip, idempotency_key
14
+ * -> {success, challenge_ts, hostname, action, cdata, error-codes}
15
+ *
16
+ * FCaptcha's native endpoint (`POST /api/token/verify` -> `{valid, score, ...}`)
17
+ * is a different shape, which means none of that existing integration work
18
+ * applies. Serving the familiar contract alongside the native one turns a
19
+ * migration into a base-URL change, so this module is deliberately an *adapter*
20
+ * over `verifyToken` rather than a second verification path — there is one
21
+ * implementation of token validity and this translates its vocabulary.
22
+ *
23
+ * Three things the native endpoint did not do, which the contract requires:
24
+ *
25
+ * - `secret` is checked. All three servers previously accepted the parameter
26
+ * and ignored it, so anyone who could reach the endpoint could verify
27
+ * tokens. The README documented sending it, which made the omission worse:
28
+ * integrators believed they were authenticated.
29
+ * - `hostname` is reported, so the caller can confirm the token was minted on
30
+ * a page they actually serve. This is what stops a lifted site key from
31
+ * being used against someone else's deployment.
32
+ * - `idempotency_key` makes retries safe. Tokens are single-use, so a network
33
+ * timeout on the first validation would otherwise burn the token and fail
34
+ * the user's request on the retry.
35
+ *
36
+ * Mirrors server-go/siteverify.go and server-python/siteverify.py.
37
+ */
38
+
39
+ const crypto = require('crypto');
40
+ const { BoundedMap } = require('./limits');
41
+
42
+ // How long a validation result stays replayable under its idempotency key.
43
+ // Matched to the token lifetime: past that the token is expired anyway, so a
44
+ // retry has nothing left to be idempotent about.
45
+ const IDEMPOTENCY_TTL_SECONDS = 300;
46
+
47
+ // Bounded because the key is caller-supplied. Same reasoning as limits.js:
48
+ // anything keyed on a value the client chooses needs a ceiling.
49
+ const MAX_IDEMPOTENCY_ENTRIES = 10000;
50
+
51
+ // `action` and `cdata` are echoed back verbatim, so they are attacker-controlled
52
+ // output. Length-capped to keep them from bloating the token, and in cdata's
53
+ // case to keep it a label rather than a smuggling channel.
54
+ const MAX_ACTION_LENGTH = 32;
55
+ const MAX_CDATA_LENGTH = 255;
56
+
57
+ /**
58
+ * The error-code vocabulary. These strings are the contract — integrators
59
+ * branch on them — so they match Cloudflare's spelling exactly, hyphens and all.
60
+ */
61
+ const ERROR_CODES = {
62
+ MISSING_SECRET: 'missing-input-secret',
63
+ INVALID_SECRET: 'invalid-input-secret',
64
+ MISSING_RESPONSE: 'missing-input-response',
65
+ INVALID_RESPONSE: 'invalid-input-response',
66
+ BAD_REQUEST: 'bad-request',
67
+ TIMEOUT_OR_DUPLICATE: 'timeout-or-duplicate',
68
+ INTERNAL_ERROR: 'internal-error',
69
+ };
70
+
71
+ /**
72
+ * How `verifyToken`'s internal `reason` maps onto that vocabulary.
73
+ *
74
+ * The collapse is lossy on purpose. A caller learning *why* a token failed
75
+ * learns something about the signing key or the replay window, so every
76
+ * structural failure reports the same `invalid-input-response`, and expiry and
77
+ * replay share `timeout-or-duplicate` exactly as Cloudflare's do.
78
+ */
79
+ const REASON_TO_ERROR_CODE = {
80
+ expired: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
81
+ token_already_used: ERROR_CODES.TIMEOUT_OR_DUPLICATE,
82
+ invalid_signature: ERROR_CODES.INVALID_RESPONSE,
83
+ invalid_encoding: ERROR_CODES.INVALID_RESPONSE,
84
+ invalid_json: ERROR_CODES.INVALID_RESPONSE,
85
+ missing_signature: ERROR_CODES.INVALID_RESPONSE,
86
+ ip_mismatch: ERROR_CODES.INVALID_RESPONSE,
87
+ hostname_not_allowed: ERROR_CODES.INVALID_RESPONSE,
88
+ };
89
+
90
+ function reasonToErrorCode(reason) {
91
+ return REASON_TO_ERROR_CODE[reason] || ERROR_CODES.INVALID_RESPONSE;
92
+ }
93
+
94
+ /**
95
+ * Pull the host out of a URL-shaped header value.
96
+ *
97
+ * Returns '' rather than throwing on anything unparseable, including the
98
+ * literal `null` that browsers send as Origin from a sandboxed iframe or a
99
+ * file:// page. An opaque origin is genuinely the absence of a hostname, not an
100
+ * error — the token simply carries no host binding.
101
+ */
102
+ function hostFromUrl(value) {
103
+ if (!value || typeof value !== 'string') return '';
104
+ if (value === 'null') return '';
105
+ try {
106
+ // URL.hostname excludes the port and keeps IPv6 brackets, which is the
107
+ // normalisation we want: example.com:8443 and example.com are one host.
108
+ return new URL(value).hostname.toLowerCase();
109
+ } catch {
110
+ return '';
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The hostname a token should be bound to, from the headers of the request that
116
+ * mints it.
117
+ *
118
+ * Origin first: the widget's verification call is a CORS POST, so browsers
119
+ * attach it, and unlike Referer it is not suppressed by referrer policy. Referer
120
+ * is the fallback for same-origin deployments where Origin may be absent on some
121
+ * request shapes. Neither is trustworthy against a non-browser caller — anything
122
+ * that can forge one can forge both — so this binds the *browser* case, which is
123
+ * the case that matters: it stops a site key lifted from your page from minting
124
+ * tokens that your own backend will accept.
125
+ */
126
+ function requestHostname(headers) {
127
+ if (!headers) return '';
128
+ return hostFromUrl(headers['origin']) || hostFromUrl(headers['referer']) || '';
129
+ }
130
+
131
+ /** Trim a caller-supplied label to something safe to sign and echo. */
132
+ function sanitizeLabel(value, maxLength) {
133
+ if (typeof value !== 'string') return '';
134
+ // Control characters would ride through into the JSON response and any log
135
+ // line that records it.
136
+ return value.replace(/[\x00-\x1f\x7f]/g, '').slice(0, maxLength);
137
+ }
138
+
139
+ function sanitizeAction(value) {
140
+ return sanitizeLabel(value, MAX_ACTION_LENGTH);
141
+ }
142
+
143
+ function sanitizeCdata(value) {
144
+ return sanitizeLabel(value, MAX_CDATA_LENGTH);
145
+ }
146
+
147
+ /**
148
+ * Constant-time secret comparison.
149
+ *
150
+ * timingSafeEqual demands equal-length buffers and throws otherwise, which would
151
+ * itself leak length, so both sides are hashed to a fixed width first.
152
+ */
153
+ function secretMatches(provided, expected) {
154
+ if (typeof provided !== 'string' || typeof expected !== 'string') return false;
155
+ const a = crypto.createHash('sha256').update(provided).digest();
156
+ const b = crypto.createHash('sha256').update(expected).digest();
157
+ return crypto.timingSafeEqual(a, b);
158
+ }
159
+
160
+ /**
161
+ * Optional allowlist of hostnames permitted to mint tokens.
162
+ *
163
+ * Off by default so zero-config self-hosting keeps working, and reported rather
164
+ * than enforced when a request carries no derivable Origin — a native mobile
165
+ * client or a server-side integration legitimately has none, and refusing those
166
+ * would break them for no security gain.
167
+ */
168
+ class HostnameAllowlist {
169
+ constructor(hostnames = []) {
170
+ this.hostnames = new Set(
171
+ hostnames
172
+ .map((h) => String(h).trim().toLowerCase())
173
+ .filter(Boolean)
174
+ );
175
+ }
176
+
177
+ static fromEnv(env = process.env) {
178
+ const raw = env.FCAPTCHA_ALLOWED_HOSTNAMES || '';
179
+ return new HostnameAllowlist(raw.split(',').filter(Boolean));
180
+ }
181
+
182
+ get enabled() {
183
+ return this.hostnames.size > 0;
184
+ }
185
+
186
+ /**
187
+ * Whether a hostname may mint tokens. An empty hostname passes: see the class
188
+ * comment — absence of an Origin is not evidence of a bad one.
189
+ */
190
+ permits(hostname) {
191
+ if (!this.enabled) return true;
192
+ if (!hostname) return true;
193
+ return this.hostnames.has(hostname);
194
+ }
195
+
196
+ describe() {
197
+ return this.enabled ? [...this.hostnames].join(', ') : 'any (unrestricted)';
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Caches validation results so a retried siteverify returns the first answer
203
+ * instead of tripping the single-use guard.
204
+ *
205
+ * Keyed on the idempotency key *and* the token: reusing one key across different
206
+ * tokens is a caller bug, and answering the second one from the first one's
207
+ * cache entry would report success for a token nobody validated. Binding both
208
+ * degrades that case to an ordinary fresh verification.
209
+ */
210
+ class IdempotencyStore {
211
+ constructor({ ttlSeconds = IDEMPOTENCY_TTL_SECONDS, maxEntries = MAX_IDEMPOTENCY_ENTRIES } = {}) {
212
+ this.ttlMs = ttlSeconds * 1000;
213
+ this.entries = new BoundedMap(maxEntries);
214
+ }
215
+
216
+ _key(idempotencyKey, token) {
217
+ const tokenHash = crypto.createHash('sha256').update(token).digest('hex').slice(0, 32);
218
+ return `${idempotencyKey}:${tokenHash}`;
219
+ }
220
+
221
+ get(idempotencyKey, token) {
222
+ if (!idempotencyKey) return null;
223
+ const entry = this.entries.get(this._key(idempotencyKey, token));
224
+ if (!entry) return null;
225
+ // >= so that a zero TTL means "never replay" rather than "replay once".
226
+ if (Date.now() - entry.storedAt >= this.ttlMs) return null;
227
+ return entry.response;
228
+ }
229
+
230
+ set(idempotencyKey, token, response) {
231
+ if (!idempotencyKey) return;
232
+ this.entries.set(this._key(idempotencyKey, token), {
233
+ storedAt: Date.now(),
234
+ response,
235
+ });
236
+ }
237
+ }
238
+
239
+ /** A failing siteverify response carrying the given codes. */
240
+ function failure(...codes) {
241
+ return { success: false, 'error-codes': codes };
242
+ }
243
+
244
+ /**
245
+ * Read the siteverify parameters out of a request body.
246
+ *
247
+ * Both encodings are accepted because the contract accepts both, and callers in
248
+ * the wild are split: PHP and Python examples overwhelmingly post form-encoded,
249
+ * Node and Go examples post JSON.
250
+ */
251
+ function readParams(body) {
252
+ if (!body || typeof body !== 'object') return {};
253
+ const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
254
+ return {
255
+ secret: str(body.secret),
256
+ response: str(body.response),
257
+ remoteip: str(body.remoteip),
258
+ idempotencyKey: str(body.idempotency_key),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Run a siteverify request.
264
+ *
265
+ * `verifyToken` is injected rather than imported so the standalone server and
266
+ * the library export can each supply their own — they hold separate token
267
+ * stores, and this module should not decide which one is authoritative.
268
+ */
269
+ function siteverify({
270
+ body,
271
+ verifyToken,
272
+ expectedSecret,
273
+ idempotencyStore,
274
+ requireSecret = true,
275
+ }) {
276
+ const { secret, response, remoteip, idempotencyKey } = readParams(body);
277
+
278
+ if (requireSecret) {
279
+ if (!secret) return failure(ERROR_CODES.MISSING_SECRET);
280
+ if (!secretMatches(secret, expectedSecret)) return failure(ERROR_CODES.INVALID_SECRET);
281
+ }
282
+
283
+ if (!response) return failure(ERROR_CODES.MISSING_RESPONSE);
284
+
285
+ const cached = idempotencyStore && idempotencyStore.get(idempotencyKey, response);
286
+ if (cached) return cached;
287
+
288
+ let result;
289
+ try {
290
+ // remoteip is passed through to the IP-binding check. Empty means the caller
291
+ // declined to assert one, which the token verifier treats as "don't check".
292
+ result = verifyToken(response, remoteip || null);
293
+ } catch {
294
+ // Never surface an exception's text: it can carry key material or internals.
295
+ return failure(ERROR_CODES.INTERNAL_ERROR);
296
+ }
297
+
298
+ const out = result && result.valid
299
+ ? {
300
+ success: true,
301
+ challenge_ts: new Date((result.timestamp || 0) * 1000).toISOString(),
302
+ hostname: result.hostname || '',
303
+ action: result.action || '',
304
+ cdata: result.cdata || '',
305
+ 'error-codes': [],
306
+ // Not part of the upstream contract, but the whole reason to run
307
+ // FCaptcha: a caller that only wants pass/fail can ignore it, and one
308
+ // that wants to risk-band on the score has it without a second call.
309
+ score: typeof result.score === 'number' ? result.score : null,
310
+ }
311
+ : failure(reasonToErrorCode(result && result.reason));
312
+
313
+ if (idempotencyStore) idempotencyStore.set(idempotencyKey, response, out);
314
+ return out;
315
+ }
316
+
317
+ module.exports = {
318
+ ERROR_CODES,
319
+ HostnameAllowlist,
320
+ IdempotencyStore,
321
+ hostFromUrl,
322
+ requestHostname,
323
+ reasonToErrorCode,
324
+ sanitizeAction,
325
+ sanitizeCdata,
326
+ secretMatches,
327
+ siteverify,
328
+ MAX_ACTION_LENGTH,
329
+ MAX_CDATA_LENGTH,
330
+ IDEMPOTENCY_TTL_SECONDS,
331
+ };
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
+ };