@webdecoy/fcaptcha 1.36.0 → 1.38.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/admission.js +50 -0
- package/config.js +2 -2
- package/engine.js +99 -8
- package/index.js +21 -5
- package/ip-binding.js +11 -0
- package/package.json +4 -4
- package/redis-state.js +76 -16
- package/server.js +47 -7
- package/webbotauth.js +40 -5
package/admission.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fixed windows bound both request work and limiter memory. A full table denies
|
|
4
|
+
// new sources; it never evicts a source that still has a live quota.
|
|
5
|
+
class AdmissionLimiter {
|
|
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
|
+
allow(key, maximum, seconds = 60) {
|
|
14
|
+
const now = this.now();
|
|
15
|
+
if (now >= this.nextCleanup) {
|
|
16
|
+
for (const [k, entry] of this.entries) if (entry.expires <= now) this.entries.delete(k);
|
|
17
|
+
this.nextCleanup = now + 60000;
|
|
18
|
+
}
|
|
19
|
+
let entry = this.entries.get(key);
|
|
20
|
+
if (entry && entry.expires <= now) { this.entries.delete(key); entry = null; }
|
|
21
|
+
if (!entry) {
|
|
22
|
+
if (this.entries.size >= this.maxEntries) return false;
|
|
23
|
+
entry = { count: 0, expires: now + seconds * 1000 };
|
|
24
|
+
this.entries.set(key, entry);
|
|
25
|
+
}
|
|
26
|
+
if (entry.count >= maximum) return false;
|
|
27
|
+
entry.count++;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Insertion order is expiry order (all challenges have the same lifetime).
|
|
33
|
+
// Cleanup only visits expired entries, rather than scanning the live map.
|
|
34
|
+
class ChallengeMap extends Map {
|
|
35
|
+
constructor(maxEntries = 100000) { super(); this.maxEntries = maxEntries; }
|
|
36
|
+
pruneExpired(now = Date.now()) {
|
|
37
|
+
for (const [id, challenge] of this) {
|
|
38
|
+
if (challenge.expiresAt > now) break;
|
|
39
|
+
this.delete(id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
set(id, challenge) {
|
|
43
|
+
this.pruneExpired();
|
|
44
|
+
if (!this.has(id) && this.size >= this.maxEntries) throw new Error('challenge_store_full');
|
|
45
|
+
return super.set(id, challenge);
|
|
46
|
+
}
|
|
47
|
+
prune() { this.pruneExpired(); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { AdmissionLimiter, ChallengeMap };
|
package/config.js
CHANGED
|
@@ -10,7 +10,7 @@ let devModeWarned = false;
|
|
|
10
10
|
|
|
11
11
|
function signingSecretFromEnv(env = process.env) {
|
|
12
12
|
const secret = String(env.FCAPTCHA_SECRET || '').trim();
|
|
13
|
-
if (secret && secret !== INSECURE_DEFAULT_SECRET) return secret;
|
|
13
|
+
if (Buffer.byteLength(secret) >= 32 && new Set(secret).size >= 8 && secret !== INSECURE_DEFAULT_SECRET) return secret;
|
|
14
14
|
if (/^(1|true|yes|on)$/i.test(String(env.FCAPTCHA_INSECURE_DEV_MODE || '').trim())) {
|
|
15
15
|
if (!devModeWarned) {
|
|
16
16
|
devModeWarned = true;
|
|
@@ -18,7 +18,7 @@ function signingSecretFromEnv(env = process.env) {
|
|
|
18
18
|
}
|
|
19
19
|
return INSECURE_DEFAULT_SECRET;
|
|
20
20
|
}
|
|
21
|
-
throw new Error('FCAPTCHA_SECRET is required
|
|
21
|
+
throw new Error('FCAPTCHA_SECRET is required: use at least 32 random bytes (openssl rand -hex 32), not a short or repetitive password. For local-only development, explicitly set FCAPTCHA_INSECURE_DEV_MODE=1.');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function signingSecret(explicit, env = process.env) {
|
package/engine.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Shared detection and scoring core.
|
|
5
7
|
*
|
|
@@ -130,6 +132,90 @@ function setInteractionMode(signals, widget) {
|
|
|
130
132
|
* Whether click-derived behavioural fields carry meaning for this request.
|
|
131
133
|
* Absent context means widget mode — the long-standing behaviour.
|
|
132
134
|
*/
|
|
135
|
+
// Caps how many tokens one page instance on one device at one address can be
|
|
136
|
+
// issued per minute. A person verifies a form once and retries a handful of
|
|
137
|
+
// times; the automated client observed on the public demo (2026-09-09) verified
|
|
138
|
+
// fourteen times in fifty-two seconds. Same limit as the per-address detection
|
|
139
|
+
// so the two read consistently in logs.
|
|
140
|
+
const DEVICE_VERIFICATIONS_PER_MINUTE = 10;
|
|
141
|
+
|
|
142
|
+
const nested = (o, ...keys) => keys.reduce((v, k) => (v && typeof v === 'object') ? v[k] : undefined, o);
|
|
143
|
+
|
|
144
|
+
// Coarse device identity used for cardinality checks and the per-device rate
|
|
145
|
+
// gate: canvas hash, WebGL renderer, platform and core count. Identical
|
|
146
|
+
// hardware produces identical values, which is why the rate gate also keys on
|
|
147
|
+
// the widget instance.
|
|
148
|
+
function deviceFingerprint(signals) {
|
|
149
|
+
const env = (signals && signals.environmental) || {};
|
|
150
|
+
const automation = env.automationFlags || {};
|
|
151
|
+
const components = [
|
|
152
|
+
String(nested(env, 'canvasHash', 'hash') || ''),
|
|
153
|
+
String(nested(env, 'webglInfo', 'renderer') || ''),
|
|
154
|
+
String(automation.platform || ''),
|
|
155
|
+
String(automation.hardwareConcurrency || '')
|
|
156
|
+
];
|
|
157
|
+
return crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The per-page-load id the widget or invisible session reports about itself.
|
|
161
|
+
// Bounded, because it is client-supplied and becomes part of a rate-limiter key.
|
|
162
|
+
function widgetInstance(signals) {
|
|
163
|
+
const meta = (signals && signals.meta) || {};
|
|
164
|
+
const id = typeof meta.sessionId === 'string' && meta.sessionId ? meta.sessionId
|
|
165
|
+
: typeof meta.widgetId === 'string' ? meta.widgetId : '';
|
|
166
|
+
return id.slice(0, 64);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The rate gate as one function so server.js and index.js cannot drift.
|
|
170
|
+
// Returns the detection to record when the budget is exhausted, else null.
|
|
171
|
+
function deviceRateDetection(exceeded, count) {
|
|
172
|
+
if (!exceeded) return null;
|
|
173
|
+
return { category: 'rate_limit', score: 0.8, confidence: 0.9,
|
|
174
|
+
reason: 'Verification rate exceeded for this device (per-minute)', details: { count, window: 60 } };
|
|
175
|
+
}
|
|
176
|
+
const deviceRateKey = (siteKey, ip, signals, instance) => `device:${siteKey}:${ip}:${deviceFingerprint(signals)}:${instance}`;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Keyboard-only accessibility exemption.
|
|
180
|
+
*
|
|
181
|
+
* A visitor who works the page from the keyboard — a screen-reader user, a
|
|
182
|
+
* switch-access user, anyone who never touches a pointer — produces key events
|
|
183
|
+
* and no pointer samples, and must not be scored as "no mouse movement". An
|
|
184
|
+
* agent typing through an automation protocol produces exactly the same counts,
|
|
185
|
+
* which is how a keyboard-driven form filler claimed the exemption on the public
|
|
186
|
+
* demo (2026-09-09) and was scored as a person.
|
|
187
|
+
*
|
|
188
|
+
* The counts cannot tell them apart; the key holds can. A finger on a key holds
|
|
189
|
+
* it for tens of milliseconds (the bench's keyboard personas never drop below
|
|
190
|
+
* 41ms); an automation protocol releases it in one or two. So the exemption
|
|
191
|
+
* asks, when hold data exists, that it look like fingers. With no hold data at
|
|
192
|
+
* all — a widget older than this check, or a visitor who only tabbed to the
|
|
193
|
+
* checkbox and never typed into a field — the exemption stands, because refusing
|
|
194
|
+
* it would score exactly the people it protects.
|
|
195
|
+
*/
|
|
196
|
+
// Average keydown→keyup hold below which the keys were not pressed by a hand:
|
|
197
|
+
// well under the 41ms bench floor, well over the 1–8ms an automation protocol
|
|
198
|
+
// produces. A real-human capture should confirm it before it is tightened.
|
|
199
|
+
const MACHINE_KEY_HOLD_MS = 20;
|
|
200
|
+
// How many holds the average has to rest on.
|
|
201
|
+
const MIN_KEY_HOLD_SAMPLES = 3;
|
|
202
|
+
|
|
203
|
+
function mechanicalKeyHold(signals, b) {
|
|
204
|
+
let n = Number(b.keyHoldSamples) || 0;
|
|
205
|
+
let sum = n * (Number(b.keyHoldAvg) || 0);
|
|
206
|
+
const fields = nested(signals, 'formAnalysis', 'textareaKeyboard') || {};
|
|
207
|
+
for (const stats of Object.values(fields)) {
|
|
208
|
+
const dwells = stats && Array.isArray(stats.dwellTimes) ? stats.dwellTimes : [];
|
|
209
|
+
for (const d of dwells) { if (typeof d === 'number') { n++; sum += d; } }
|
|
210
|
+
}
|
|
211
|
+
return n >= MIN_KEY_HOLD_SAMPLES && sum / n < MACHINE_KEY_HOLD_MS;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function keyboardOnlyUser(signals, b) {
|
|
215
|
+
if ((b.keyEvents ?? 0) < 2 || (b.totalPoints ?? 0) !== 0) return false;
|
|
216
|
+
return !mechanicalKeyHold(signals, b);
|
|
217
|
+
}
|
|
218
|
+
|
|
133
219
|
function hasWidgetInteraction(signals) {
|
|
134
220
|
const ctx = signals && signals[SERVER_CONTEXT_KEY];
|
|
135
221
|
if (!ctx || typeof ctx.widgetInteraction !== 'boolean') return true;
|
|
@@ -149,7 +235,7 @@ function detectVisionAI(signals) {
|
|
|
149
235
|
const touchEvents = b.touchEvents ?? 0;
|
|
150
236
|
const keyEvents = b.keyEvents ?? 0;
|
|
151
237
|
const isTouchUser = isTouchModality(b);
|
|
152
|
-
const isKeyboardUser =
|
|
238
|
+
const isKeyboardUser = keyboardOnlyUser(signals, b);
|
|
153
239
|
// Click-derived fields only exist when a widget was there to click.
|
|
154
240
|
const isWidget = hasWidgetInteraction(signals);
|
|
155
241
|
|
|
@@ -427,12 +513,8 @@ function detectAutomation(signals) {
|
|
|
427
513
|
// JS execution timing
|
|
428
514
|
const jsTime = getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
|
|
429
515
|
if (jsTime > 0) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
category: 'automation', score: 0.4, confidence: 0.3,
|
|
433
|
-
reason: 'JS execution unusually fast'
|
|
434
|
-
});
|
|
435
|
-
} else if (jsTime > 50) {
|
|
516
|
+
// Fast execution is common on real hardware and is not evidence of automation.
|
|
517
|
+
if (jsTime > 50) {
|
|
436
518
|
detections.push({
|
|
437
519
|
category: 'automation', score: 0.3, confidence: 0.3,
|
|
438
520
|
reason: 'JS execution unusually slow'
|
|
@@ -550,7 +632,7 @@ function detectBehavioral(signals) {
|
|
|
550
632
|
const touchEvts = b.touchEvents ?? 0;
|
|
551
633
|
const keyEvts = b.keyEvents ?? 0;
|
|
552
634
|
const isTouchUsr = isTouchModality(b);
|
|
553
|
-
const isKbdUser =
|
|
635
|
+
const isKbdUser = keyboardOnlyUser(signals, b);
|
|
554
636
|
|
|
555
637
|
if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
|
|
556
638
|
detections.push({
|
|
@@ -961,6 +1043,15 @@ function applyCorroborationFloor(score, detections) {
|
|
|
961
1043
|
|
|
962
1044
|
module.exports = {
|
|
963
1045
|
// Constants
|
|
1046
|
+
DEVICE_VERIFICATIONS_PER_MINUTE,
|
|
1047
|
+
MACHINE_KEY_HOLD_MS,
|
|
1048
|
+
MIN_KEY_HOLD_SAMPLES,
|
|
1049
|
+
deviceFingerprint,
|
|
1050
|
+
widgetInstance,
|
|
1051
|
+
deviceRateKey,
|
|
1052
|
+
deviceRateDetection,
|
|
1053
|
+
keyboardOnlyUser,
|
|
1054
|
+
mechanicalKeyHold,
|
|
964
1055
|
AUTOMATION_UA_PATTERNS,
|
|
965
1056
|
WEIGHTS,
|
|
966
1057
|
DISPOSITIVE_FLOOR,
|
package/index.js
CHANGED
|
@@ -14,6 +14,8 @@ 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 { AdmissionLimiter, ChallengeMap } = require('./admission');
|
|
18
|
+
const { ipBinding } = require('./ip-binding');
|
|
17
19
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
18
20
|
const { FingerprintStore } = require('./fingerprint-store');
|
|
19
21
|
const { TokenStore } = require('./token-store');
|
|
@@ -26,12 +28,14 @@ const { resolveSignals } = require('./protocol');
|
|
|
26
28
|
class PoWChallengeStore {
|
|
27
29
|
constructor(options = {}) {
|
|
28
30
|
this.secret = signingSecret(options.secret);
|
|
29
|
-
this.challenges = new
|
|
31
|
+
this.challenges = new ChallengeMap();
|
|
32
|
+
this.issuance = new AdmissionLimiter();
|
|
30
33
|
this.usedSolutions = new BoundedSet();
|
|
31
34
|
this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
38
|
+
if (!this.issuance.allow(ip, 128, Math.ceil(this.expirationMs / 1000))) throw new Error('challenge_quota_exceeded');
|
|
35
39
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
36
40
|
const timestamp = Date.now();
|
|
37
41
|
const expiresAt = timestamp + this.expirationMs;
|
|
@@ -187,6 +191,7 @@ const {
|
|
|
187
191
|
calculateFinalScore,
|
|
188
192
|
applyDispositiveFloor,
|
|
189
193
|
applyCorroborationFloor,
|
|
194
|
+
widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
|
|
190
195
|
} = require('./engine');
|
|
191
196
|
const { detectInputForensics } = require('./inputforensics');
|
|
192
197
|
|
|
@@ -342,6 +347,17 @@ class ScoringEngine {
|
|
|
342
347
|
// Calculate scores
|
|
343
348
|
// Same aggregation as server.js: noisy-OR within a category, weighted sum
|
|
344
349
|
// across them, then the dispositive and corroboration floors.
|
|
350
|
+
// Per-device verification rate — a precondition, not evidence. Keyed on the
|
|
351
|
+
// widget instance too, so identical machines behind one NAT do not share a
|
|
352
|
+
// budget. See DEVICE_VERIFICATIONS_PER_MINUTE in engine.js.
|
|
353
|
+
let deviceRateExceeded = false;
|
|
354
|
+
const instance = widgetInstance(signals);
|
|
355
|
+
if (instance) {
|
|
356
|
+
const [exceeded, count] = this.rateLimiter.check(deviceRateKey(siteKey, ip, signals, instance), 60, DEVICE_VERIFICATIONS_PER_MINUTE);
|
|
357
|
+
const hit = deviceRateDetection(exceeded, count);
|
|
358
|
+
if (hit) { deviceRateExceeded = true; detections.push(hit); }
|
|
359
|
+
}
|
|
360
|
+
|
|
345
361
|
const categoryScores = calculateCategoryScores(detections, this.weights);
|
|
346
362
|
const finalScore = applyCorroborationFloor(
|
|
347
363
|
applyDispositiveFloor(calculateFinalScore(categoryScores, this.weights), detections),
|
|
@@ -358,7 +374,7 @@ class ScoringEngine {
|
|
|
358
374
|
// dilute it below the allow threshold and used to mint a token for an empty
|
|
359
375
|
// request. Keep this gate independent of scoring so weight changes cannot
|
|
360
376
|
// reopen the bypass.
|
|
361
|
-
const success = finalScore < 0.5 && powSatisfied;
|
|
377
|
+
const success = finalScore < 0.5 && powSatisfied && !deviceRateExceeded;
|
|
362
378
|
const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
|
|
363
379
|
|
|
364
380
|
// Feed the ledger so the next challenge this source asks for is priced on
|
|
@@ -373,7 +389,7 @@ class ScoringEngine {
|
|
|
373
389
|
recommendation,
|
|
374
390
|
categoryScores,
|
|
375
391
|
detections,
|
|
376
|
-
...(!powSatisfied ? { reason: 'pow_not_satisfied' } : {})
|
|
392
|
+
...(deviceRateExceeded ? { reason: 'rate_limited' } : !powSatisfied ? { reason: 'pow_not_satisfied' } : {})
|
|
377
393
|
};
|
|
378
394
|
}
|
|
379
395
|
|
|
@@ -396,7 +412,7 @@ class ScoringEngine {
|
|
|
396
412
|
return { valid: false, reason: 'invalid_signature' };
|
|
397
413
|
}
|
|
398
414
|
|
|
399
|
-
if (ip && decoded.ip_hash !==
|
|
415
|
+
if (ip && decoded.ip_hash !== ipBinding(this.secret, ip)) {
|
|
400
416
|
return { valid: false, reason: 'ip_mismatch' };
|
|
401
417
|
}
|
|
402
418
|
const claim = this.tokenStore.claim(sig);
|
|
@@ -483,7 +499,7 @@ class ScoringEngine {
|
|
|
483
499
|
}
|
|
484
500
|
|
|
485
501
|
_generateToken(ip, siteKey, score) {
|
|
486
|
-
const ipHash =
|
|
502
|
+
const ipHash = ipBinding(this.secret, ip);
|
|
487
503
|
const data = {
|
|
488
504
|
site_key: siteKey,
|
|
489
505
|
jti: crypto.randomBytes(16).toString('hex'),
|
package/ip-binding.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const ipaddr = require('ipaddr.js');
|
|
4
|
+
|
|
5
|
+
function ipBinding(secret, ip) {
|
|
6
|
+
let canonical = String(ip);
|
|
7
|
+
try { canonical = ipaddr.process(canonical).toString(); } catch (_) { /* non-IP test identities */ }
|
|
8
|
+
const key = crypto.createHmac('sha256', secret).update('fcaptcha:ip-binding:v2').digest();
|
|
9
|
+
return crypto.createHmac('sha256', key).update(canonical).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
module.exports = { ipBinding };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webdecoy/fcaptcha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -15,14 +15,14 @@
|
|
|
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 security.test.js corroboration.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 admission.test.js suspicion.test.js security.test.js corroboration.test.js device-rate.test.js keyboard-exemption.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"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"cors": "^2.8.5",
|
|
25
|
-
"express": "^4.
|
|
25
|
+
"express": "^4.22.3",
|
|
26
26
|
"ipaddr.js": "^2.4.0",
|
|
27
27
|
"redis": "^4.6.12",
|
|
28
28
|
"web-bot-auth": "^0.1.3"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
39
|
+
"node": ">=22.0.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
package/redis-state.js
CHANGED
|
@@ -8,6 +8,23 @@ const POW_TTL_MS = 5 * 60 * 1000;
|
|
|
8
8
|
const SPENT_TTL_MS = 10 * 60 * 1000;
|
|
9
9
|
const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
|
|
10
10
|
const DETECTION_TTL_MS = 15 * 60 * 1000;
|
|
11
|
+
const ADMIT = `
|
|
12
|
+
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
13
|
+
if count >= tonumber(ARGV[1]) then return 0 end
|
|
14
|
+
redis.call('INCR', KEYS[1])
|
|
15
|
+
if count == 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
|
16
|
+
return 1
|
|
17
|
+
`;
|
|
18
|
+
const PUT_CHALLENGE = `
|
|
19
|
+
for i = 2, 3 do redis.call('ZREMRANGEBYSCORE', KEYS[i], '-inf', ARGV[1]) end
|
|
20
|
+
if redis.call('ZCARD', KEYS[2]) >= 100000 or redis.call('ZCARD', KEYS[3]) >= 128 then return 0 end
|
|
21
|
+
if not redis.call('SET', KEYS[1], ARGV[4], 'NX', 'PX', ARGV[3]) then return 0 end
|
|
22
|
+
for i = 2, 3 do
|
|
23
|
+
redis.call('ZADD', KEYS[i], ARGV[2], KEYS[1])
|
|
24
|
+
redis.call('PEXPIRE', KEYS[i], ARGV[3])
|
|
25
|
+
end
|
|
26
|
+
return 1
|
|
27
|
+
`;
|
|
11
28
|
|
|
12
29
|
const CLAIM_CHALLENGE = `
|
|
13
30
|
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
|
|
@@ -50,8 +67,22 @@ return 1
|
|
|
50
67
|
|
|
51
68
|
class RedisState {
|
|
52
69
|
constructor(url, client = null) {
|
|
53
|
-
this.client = client || createClient({ url });
|
|
70
|
+
this.client = client || createClient({ url, disableOfflineQueue: true, commandsQueueMaxLength: 1024, socket: { connectTimeout: 2000 } });
|
|
54
71
|
this.client.on?.('error', (err) => console.error('[redis]', err.message));
|
|
72
|
+
this.commandTimeoutMs = 2000;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async deadline(promise) {
|
|
76
|
+
let timer;
|
|
77
|
+
try {
|
|
78
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => reject(new Error('state_unavailable')), this.commandTimeoutMs);
|
|
80
|
+
})]);
|
|
81
|
+
} finally { clearTimeout(timer); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
execute(method, ...args) {
|
|
85
|
+
return this.deadline(Promise.resolve().then(() => this.client[method](...args)));
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
async connect() {
|
|
@@ -59,6 +90,25 @@ class RedisState {
|
|
|
59
90
|
await this.client.ping();
|
|
60
91
|
}
|
|
61
92
|
|
|
93
|
+
async ready(timeoutMs = 2000) {
|
|
94
|
+
if (this.readinessPending) return this.readinessPending;
|
|
95
|
+
// node-redis 4's abort signal only handles queued commands and may outlive
|
|
96
|
+
// their queue nodes. Bound the probe externally instead. Retain a timed-out
|
|
97
|
+
// probe until it settles so repeated health checks cannot fill the queue.
|
|
98
|
+
let timer;
|
|
99
|
+
const ping = Promise.resolve().then(() => this.client.ping());
|
|
100
|
+
const pending = Promise.race([ping, new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => reject(new Error('state_unavailable')), timeoutMs);
|
|
102
|
+
})]);
|
|
103
|
+
this.readinessPending = pending;
|
|
104
|
+
const settled = () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
if (this.readinessPending === pending) this.readinessPending = null;
|
|
107
|
+
};
|
|
108
|
+
ping.then(settled, settled);
|
|
109
|
+
return pending;
|
|
110
|
+
}
|
|
111
|
+
|
|
62
112
|
challengeKey(id) {
|
|
63
113
|
return `${PREFIX}pow:challenge:${id}`;
|
|
64
114
|
}
|
|
@@ -74,18 +124,28 @@ class RedisState {
|
|
|
74
124
|
// challengeId is the canonical cross-runtime field used by Go and Node.
|
|
75
125
|
const stored = { ...challenge, challengeId: challenge.id };
|
|
76
126
|
delete stored.id;
|
|
77
|
-
await this.
|
|
127
|
+
const added = await this.execute('eval', PUT_CHALLENGE, {
|
|
128
|
+
keys: [this.challengeKey(challenge.id), `${PREFIX}pow:quota:global`, this.opaqueKey('pow:quota:source', challenge.ip)],
|
|
129
|
+
arguments: [String(Date.now()), String(challenge.expiresAt), String(ttl), JSON.stringify(stored)]
|
|
130
|
+
});
|
|
131
|
+
if (Number(added) !== 1) throw new Error('challenge_quota_exceeded');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async admit(key, maximum, seconds = 60) {
|
|
135
|
+
return Number(await this.execute('eval', ADMIT, {
|
|
136
|
+
keys: [this.opaqueKey('admission', key)], arguments: [String(maximum), String(seconds * 1000)]
|
|
137
|
+
})) === 1;
|
|
78
138
|
}
|
|
79
139
|
|
|
80
140
|
async getChallenge(id) {
|
|
81
|
-
const payload = await this.
|
|
141
|
+
const payload = await this.execute('get', this.challengeKey(id));
|
|
82
142
|
if (!payload) return null;
|
|
83
143
|
const challenge = JSON.parse(payload);
|
|
84
144
|
return { ...challenge, id: challenge.challengeId };
|
|
85
145
|
}
|
|
86
146
|
|
|
87
147
|
async claimChallenge(challengeId, solutionKey) {
|
|
88
|
-
const result = Number(await this.
|
|
148
|
+
const result = Number(await this.execute('eval', CLAIM_CHALLENGE, {
|
|
89
149
|
keys: [this.challengeKey(challengeId), `${PREFIX}pow:spent:${solutionKey}`],
|
|
90
150
|
arguments: [String(SPENT_TTL_MS)]
|
|
91
151
|
}));
|
|
@@ -97,7 +157,7 @@ class RedisState {
|
|
|
97
157
|
}
|
|
98
158
|
|
|
99
159
|
async claimToken(signature) {
|
|
100
|
-
return Boolean(await this.
|
|
160
|
+
return Boolean(await this.execute('set',
|
|
101
161
|
`${PREFIX}token:spent:${signature}`,
|
|
102
162
|
'1',
|
|
103
163
|
{ NX: true, PX: SPENT_TTL_MS }
|
|
@@ -113,13 +173,13 @@ class RedisState {
|
|
|
113
173
|
|
|
114
174
|
async getIdempotency(idempotencyKey, token) {
|
|
115
175
|
if (!idempotencyKey) return null;
|
|
116
|
-
const payload = await this.
|
|
176
|
+
const payload = await this.execute('get', this.idempotencyKey(idempotencyKey, token));
|
|
117
177
|
return payload ? JSON.parse(payload) : null;
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
async setIdempotency(idempotencyKey, token, response) {
|
|
121
181
|
if (!idempotencyKey) return;
|
|
122
|
-
await this.
|
|
182
|
+
await this.execute('set',
|
|
123
183
|
this.idempotencyKey(idempotencyKey, token),
|
|
124
184
|
JSON.stringify(response),
|
|
125
185
|
{ PX: IDEMPOTENCY_TTL_MS }
|
|
@@ -128,7 +188,7 @@ class RedisState {
|
|
|
128
188
|
|
|
129
189
|
async rateCheck(key, windowSeconds, maxRequests) {
|
|
130
190
|
const now = Date.now();
|
|
131
|
-
const result = await this.
|
|
191
|
+
const result = await this.execute('eval', RATE_CHECK, {
|
|
132
192
|
keys: [this.opaqueKey('rate', key)],
|
|
133
193
|
arguments: [
|
|
134
194
|
String(now - windowSeconds * 1000), String(now), String(maxRequests),
|
|
@@ -141,39 +201,39 @@ class RedisState {
|
|
|
141
201
|
async recordSuspicion(siteKey, ip) {
|
|
142
202
|
const now = Date.now();
|
|
143
203
|
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
|
|
144
|
-
await this.client.multi()
|
|
204
|
+
await this.deadline(this.client.multi()
|
|
145
205
|
.zRemRangeByScore(key, '-inf', now - DETECTION_TTL_MS)
|
|
146
206
|
.zAdd(key, { score: now, value: `${now}:${crypto.randomBytes(8).toString('hex')}` })
|
|
147
207
|
.zRemRangeByRank(key, 0, -17)
|
|
148
208
|
.pExpire(key, DETECTION_TTL_MS)
|
|
149
|
-
.exec();
|
|
209
|
+
.exec());
|
|
150
210
|
}
|
|
151
211
|
|
|
152
212
|
async suspicionCount(siteKey, ip) {
|
|
153
213
|
const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`);
|
|
154
|
-
await this.
|
|
155
|
-
return Number(await this.
|
|
214
|
+
await this.execute('zRemRangeByScore', key, '-inf', Date.now() - DETECTION_TTL_MS);
|
|
215
|
+
return Number(await this.execute('zCard', key));
|
|
156
216
|
}
|
|
157
217
|
|
|
158
218
|
async recordFingerprint(fingerprint, ip, siteKey) {
|
|
159
219
|
const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`);
|
|
160
220
|
const ipKey = this.opaqueKey('fingerprint:fps', ip);
|
|
161
|
-
await this.
|
|
221
|
+
await this.execute('eval', RECORD_FINGERPRINT, {
|
|
162
222
|
keys: [fpKey, ipKey],
|
|
163
223
|
arguments: [this.opaqueKey('value:ip', ip), this.opaqueKey('value:fp', fingerprint), String(DETECTION_TTL_MS)]
|
|
164
224
|
});
|
|
165
225
|
}
|
|
166
226
|
|
|
167
227
|
async ipFingerprintCount(ip) {
|
|
168
|
-
return Number(await this.
|
|
228
|
+
return Number(await this.execute('sCard', this.opaqueKey('fingerprint:fps', ip)));
|
|
169
229
|
}
|
|
170
230
|
|
|
171
231
|
async fingerprintIpCount(fingerprint, siteKey) {
|
|
172
|
-
return Number(await this.
|
|
232
|
+
return Number(await this.execute('sCard', this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`)));
|
|
173
233
|
}
|
|
174
234
|
|
|
175
235
|
async claimSiteKey(siteKey, ip, maxPerIp) {
|
|
176
|
-
return Number(await this.
|
|
236
|
+
return Number(await this.execute('eval', SITEKEY_CLAIM, {
|
|
177
237
|
keys: [this.opaqueKey('sitekeys', ip)],
|
|
178
238
|
arguments: [this.opaqueKey('value:sitekey', siteKey), String(maxPerIp), String(60 * 60 * 1000)]
|
|
179
239
|
})) === 1;
|
package/server.js
CHANGED
|
@@ -13,6 +13,8 @@ const webbotauth = require('./webbotauth');
|
|
|
13
13
|
const { ProxyTrust, networkIdentity } = require('./clientip');
|
|
14
14
|
const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
|
|
15
15
|
const { signingSecretFromEnv } = require('./config');
|
|
16
|
+
const { AdmissionLimiter, ChallengeMap } = require('./admission');
|
|
17
|
+
const { ipBinding } = require('./ip-binding');
|
|
16
18
|
const { SuspicionLedger, computeChallengeCost, BASE_MIN_AGE_MS } = require('./suspicion');
|
|
17
19
|
const { detectInputForensics } = require('./inputforensics');
|
|
18
20
|
const { RedisState } = require('./redis-state');
|
|
@@ -172,11 +174,13 @@ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
|
|
|
172
174
|
|
|
173
175
|
// PoW Challenge Store
|
|
174
176
|
const powChallengeStore = {
|
|
175
|
-
challenges: new
|
|
177
|
+
challenges: new ChallengeMap(),
|
|
178
|
+
issuance: new AdmissionLimiter(),
|
|
176
179
|
usedSolutions: new BoundedSet(),
|
|
177
180
|
|
|
178
181
|
// Generate a new challenge
|
|
179
182
|
async generate(siteKey, ip, difficulty = 4, minAgeMs = BASE_MIN_AGE_MS) {
|
|
183
|
+
if (!SHARED_STATE && !this.issuance.allow(ip, 128, 300)) throw new Error('challenge_quota_exceeded');
|
|
180
184
|
const challengeId = crypto.randomBytes(16).toString('hex');
|
|
181
185
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
182
186
|
const timestamp = Date.now();
|
|
@@ -369,6 +373,7 @@ const {
|
|
|
369
373
|
calculateFinalScore,
|
|
370
374
|
applyDispositiveFloor,
|
|
371
375
|
applyCorroborationFloor,
|
|
376
|
+
widgetInstance, deviceRateKey, deviceRateDetection, DEVICE_VERIFICATIONS_PER_MINUTE
|
|
372
377
|
} = require('./engine');
|
|
373
378
|
|
|
374
379
|
// Stateful detectors stay here: they read the module-level stores below.
|
|
@@ -450,7 +455,7 @@ async function detectRateAbuse(ip, siteKey) {
|
|
|
450
455
|
// happened to send would be a second payload shape to keep in sync across three
|
|
451
456
|
// languages. Fixed shape, empty when unknown.
|
|
452
457
|
function generateToken(ip, siteKey, score, binding = {}) {
|
|
453
|
-
const ipHash =
|
|
458
|
+
const ipHash = ipBinding(SECRET_KEY, ip);
|
|
454
459
|
const data = {
|
|
455
460
|
site_key: siteKey,
|
|
456
461
|
jti: crypto.randomBytes(16).toString('hex'),
|
|
@@ -489,7 +494,7 @@ async function verifyToken(token, ip = null) {
|
|
|
489
494
|
|
|
490
495
|
// Verify IP matches (if provided)
|
|
491
496
|
if (ip) {
|
|
492
|
-
const expectedIpHash =
|
|
497
|
+
const expectedIpHash = ipBinding(SECRET_KEY, ip);
|
|
493
498
|
if (decoded.ip_hash !== expectedIpHash) {
|
|
494
499
|
return { valid: false, reason: 'ip_mismatch' };
|
|
495
500
|
}
|
|
@@ -675,6 +680,18 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
675
680
|
detections.push(...await detectFingerprint(signals, ip, siteKey));
|
|
676
681
|
detections.push(...await detectRateAbuse(ip, siteKey));
|
|
677
682
|
|
|
683
|
+
// Per-device verification rate — a precondition, not evidence: rate_limit
|
|
684
|
+
// weighs 0.01, so the per-address detection above can never move a verdict.
|
|
685
|
+
// Keyed on the widget instance too, so identical machines behind one NAT do
|
|
686
|
+
// not share a budget. See DEVICE_VERIFICATIONS_PER_MINUTE in engine.js.
|
|
687
|
+
let deviceRateExceeded = false;
|
|
688
|
+
const instance = widgetInstance(signals);
|
|
689
|
+
if (instance) {
|
|
690
|
+
const [exceeded, count] = await rateLimiter.check(deviceRateKey(siteKey, ip, signals, instance), 60, DEVICE_VERIFICATIONS_PER_MINUTE);
|
|
691
|
+
const hit = deviceRateDetection(exceeded, count);
|
|
692
|
+
if (hit) { deviceRateExceeded = true; detections.push(hit); }
|
|
693
|
+
}
|
|
694
|
+
|
|
678
695
|
// Add IP reputation check (async but we'll use sync version for simplicity)
|
|
679
696
|
if (detection.isDatacenterIP(ip)) {
|
|
680
697
|
detections.push({
|
|
@@ -754,7 +771,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
754
771
|
// who never completed the challenge. Gating here means no future reweighting
|
|
755
772
|
// can reopen the bypass, and it holds even if the dispositive floor is
|
|
756
773
|
// lowered or removed.
|
|
757
|
-
const success = finalScore < 0.5 && hostnameAllowed && powSatisfied;
|
|
774
|
+
const success = finalScore < 0.5 && hostnameAllowed && powSatisfied && !deviceRateExceeded;
|
|
758
775
|
|
|
759
776
|
// Name the failed precondition whenever one fails, not only when the score
|
|
760
777
|
// would otherwise have allowed. Gating it on the score made the PoW case
|
|
@@ -762,7 +779,9 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja
|
|
|
762
779
|
// and the branch never fired — which is exactly the case a caller most needs
|
|
763
780
|
// explained.
|
|
764
781
|
let withheldReason = '';
|
|
765
|
-
|
|
782
|
+
// Rate first because it is the one the caller can act on: back off.
|
|
783
|
+
if (deviceRateExceeded) withheldReason = 'rate_limited';
|
|
784
|
+
else if (!powSatisfied) withheldReason = 'pow_not_satisfied';
|
|
766
785
|
else if (!hostnameAllowed) withheldReason = 'hostname_not_allowed';
|
|
767
786
|
|
|
768
787
|
const token = success
|
|
@@ -813,7 +832,27 @@ function collectHeaders(req) {
|
|
|
813
832
|
return headers;
|
|
814
833
|
}
|
|
815
834
|
|
|
816
|
-
const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res)).catch(next);
|
|
835
|
+
const asyncRoute = (handler) => (req, res, next) => Promise.resolve().then(() => handler(req, res, next)).catch(next);
|
|
836
|
+
|
|
837
|
+
const admission = new AdmissionLimiter();
|
|
838
|
+
app.use(['/api', '/siteverify', '/turnstile', '/recaptcha'], asyncRoute(async (req, res, next) => {
|
|
839
|
+
const ip = PROXY_TRUST.clientIP(req);
|
|
840
|
+
const allow = (key, max) => SHARED_STATE ? SHARED_STATE.admit(key, max) : admission.allow(key, max);
|
|
841
|
+
try {
|
|
842
|
+
if (!await allow('global', 20000) || !await allow(`source:${ip}`, 600) ||
|
|
843
|
+
(req.originalUrl.split('?')[0].replace(/\/+$/, '').toLowerCase() === '/api/pow/challenge' && !await allow(`challenge:${ip}`, 60))) {
|
|
844
|
+
return res.status(429).set('Retry-After', '60').json({ error: 'rate_limited' });
|
|
845
|
+
}
|
|
846
|
+
} catch (_) { return res.status(503).json({ error: 'state_unavailable' }); }
|
|
847
|
+
next();
|
|
848
|
+
}));
|
|
849
|
+
|
|
850
|
+
app.get('/ready', asyncRoute(async (req, res) => {
|
|
851
|
+
try {
|
|
852
|
+
if (SHARED_STATE) await SHARED_STATE.ready();
|
|
853
|
+
res.json({ status: 'ok' });
|
|
854
|
+
} catch (_) { res.status(503).json({ status: 'unavailable' }); }
|
|
855
|
+
}));
|
|
817
856
|
|
|
818
857
|
function validateScoringRequest(req, res, next) {
|
|
819
858
|
const body = req.body;
|
|
@@ -991,7 +1030,8 @@ app.use((err, req, res, next) => {
|
|
|
991
1030
|
|
|
992
1031
|
async function start() {
|
|
993
1032
|
if (SHARED_STATE) await SHARED_STATE.connect();
|
|
994
|
-
const
|
|
1033
|
+
const host = SECRET_KEY === require('./config').INSECURE_DEFAULT_SECRET ? '127.0.0.1' : '0.0.0.0';
|
|
1034
|
+
const server = app.listen(PORT, host, () => {
|
|
995
1035
|
console.log(`FCaptcha server running on port ${server.address().port}`);
|
|
996
1036
|
console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
|
|
997
1037
|
console.log(`Site keys: ${SITE_KEYS.describe()}`);
|
package/webbotauth.js
CHANGED
|
@@ -65,7 +65,8 @@ const {
|
|
|
65
65
|
const { verifierFromJWK } = require('web-bot-auth/crypto');
|
|
66
66
|
|
|
67
67
|
const VERIFY_TIMEOUT_MS = 3000;
|
|
68
|
-
const
|
|
68
|
+
const directoryAgent = new https.Agent({ keepAlive: true, maxTotalSockets: 16, maxSockets: 2, maxFreeSockets: 2, timeout: VERIFY_TIMEOUT_MS });
|
|
69
|
+
const MAX_DIRECTORY_BYTES = 256 * 1024;
|
|
69
70
|
const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
70
71
|
|
|
71
72
|
// The exact message web-bot-auth/crypto throws when crypto.subtle.verify()
|
|
@@ -91,7 +92,11 @@ const REQUIRED_TAG = 'web-bot-auth';
|
|
|
91
92
|
const MAX_SIGNATURE_LIFETIME_S = 24 * 60 * 60;
|
|
92
93
|
|
|
93
94
|
// origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
|
|
94
|
-
const
|
|
95
|
+
const { BoundedMap } = require('./limits');
|
|
96
|
+
// Each response is at most 256 KiB. Sixteen entries bound retained payload bytes;
|
|
97
|
+
// limiting parsed keys also bounds key-import work per verification.
|
|
98
|
+
const directoryCache = new BoundedMap(16);
|
|
99
|
+
const directoryInflight = new Map();
|
|
95
100
|
|
|
96
101
|
// FetchFailure marks failures that are NOT cryptographic — the caller treats
|
|
97
102
|
// them as "could not verify" (fail open), never as a forged-signature signal.
|
|
@@ -159,6 +164,7 @@ function fetchJSON(documentUrl) {
|
|
|
159
164
|
documentUrl,
|
|
160
165
|
{
|
|
161
166
|
lookup: guardedLookup,
|
|
167
|
+
agent: directoryAgent,
|
|
162
168
|
timeout: VERIFY_TIMEOUT_MS,
|
|
163
169
|
headers: {
|
|
164
170
|
accept: 'application/http-message-signatures-directory+json, application/json',
|
|
@@ -191,6 +197,8 @@ function fetchJSON(documentUrl) {
|
|
|
191
197
|
},
|
|
192
198
|
);
|
|
193
199
|
|
|
200
|
+
const deadline = setTimeout(() => req.destroy(new FetchFailure('directory fetch deadline exceeded')), VERIFY_TIMEOUT_MS);
|
|
201
|
+
req.on('close', () => clearTimeout(deadline));
|
|
194
202
|
req.on('timeout', () => {
|
|
195
203
|
req.destroy(new FetchFailure('directory fetch timed out'));
|
|
196
204
|
});
|
|
@@ -236,11 +244,38 @@ async function getKeys(url, type) {
|
|
|
236
244
|
const now = Date.now();
|
|
237
245
|
const cached = directoryCache.get(url);
|
|
238
246
|
if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
|
|
247
|
+
if (cached.error) {
|
|
248
|
+
if (now - cached.fetchedAt < 30000) throw new FetchFailure('directory temporarily unavailable');
|
|
249
|
+
} else {
|
|
239
250
|
return cached.keys;
|
|
251
|
+
}
|
|
240
252
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
253
|
+
if (directoryInflight.has(url)) return directoryInflight.get(url);
|
|
254
|
+
if (directoryInflight.size >= 8) throw new FetchFailure('directory fetch capacity reached');
|
|
255
|
+
const pending = (async () => {
|
|
256
|
+
try {
|
|
257
|
+
const rawKeys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
|
|
258
|
+
if (rawKeys.length > 64) throw new FetchFailure('too many directory keys');
|
|
259
|
+
// Cache only public JWK fields, not arbitrary nested directory metadata.
|
|
260
|
+
const keys = rawKeys.map(raw => {
|
|
261
|
+
if (!raw || typeof raw !== 'object') throw new FetchFailure('invalid directory key');
|
|
262
|
+
const key = {};
|
|
263
|
+
for (const field of ['kty', 'kid', 'alg', 'crv', 'x', 'y', 'n', 'e', 'use']) {
|
|
264
|
+
if (raw[field] == null) continue;
|
|
265
|
+
if (typeof raw[field] !== 'string' || raw[field].length > 4096) throw new FetchFailure('invalid key field');
|
|
266
|
+
key[field] = raw[field];
|
|
267
|
+
}
|
|
268
|
+
return key;
|
|
269
|
+
});
|
|
270
|
+
directoryCache.set(url, { keys, fetchedAt: Date.now() });
|
|
271
|
+
return keys;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
directoryCache.set(url, { error: true, fetchedAt: Date.now() });
|
|
274
|
+
throw error;
|
|
275
|
+
} finally { directoryInflight.delete(url); }
|
|
276
|
+
})();
|
|
277
|
+
directoryInflight.set(url, pending);
|
|
278
|
+
return pending;
|
|
244
279
|
}
|
|
245
280
|
|
|
246
281
|
// resolveKey fetches the directory named by the Signature-Agent header and
|