@webdecoy/fcaptcha 1.16.0 → 1.18.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/detection.js CHANGED
@@ -97,12 +97,28 @@ async function checkIPReputation(ip) {
97
97
  // HTTP Header Analysis
98
98
  // =============================================================================
99
99
 
100
- const SUSPICIOUS_HEADERS = new Set([
101
- 'x-requested-with', 'x-forwarded-for', 'x-real-ip', 'via',
102
- 'forwarded', 'x-originating-ip', 'cf-connecting-ip',
103
- 'true-client-ip', 'x-cluster-client-ip'
100
+ // Headers a reverse proxy, CDN or load balancer adds on the way in.
101
+ //
102
+ // These are only anomalous when they arrive from a peer that is NOT a proxy we
103
+ // trust: an ordinary client has no business claiming to forward for someone
104
+ // else. Behind a proxy they are the opposite of suspicious — they are how the
105
+ // request is supposed to arrive, and Cloudflare alone contributes three of
106
+ // them.
107
+ //
108
+ // Scoring them unconditionally meant every visitor to every proxied deployment
109
+ // picked up a bot detection, permanently, including the project's own demo
110
+ // behind Railway. The bench measured it firing on 100% of the human panel *and*
111
+ // 100% of the agent corpus — a signal that fires on everyone distinguishes
112
+ // nobody, it just adds noise to both sides of the comparison.
113
+ const FORWARDING_HEADERS = new Set([
114
+ 'x-forwarded-for', 'x-real-ip', 'via', 'forwarded', 'x-originating-ip',
115
+ 'cf-connecting-ip', 'true-client-ip', 'x-cluster-client-ip'
104
116
  ]);
105
117
 
118
+ // Suspicious regardless of who the peer is. x-requested-with is set by XHR
119
+ // libraries, not by proxies, so trusting the peer says nothing about it.
120
+ const SUSPICIOUS_HEADERS = new Set(['x-requested-with']);
121
+
106
122
  const EXPECTED_BROWSER_HEADERS = new Set([
107
123
  'accept', 'accept-language', 'accept-encoding', 'user-agent'
108
124
  ]);
@@ -162,7 +178,16 @@ function checkDeclaredAIAgent(userAgent, headers = {}) {
162
178
  return detections;
163
179
  }
164
180
 
165
- function analyzeHeaders(headers) {
181
+ /**
182
+ * @param {object} headers
183
+ * @param {object} [opts]
184
+ * @param {boolean} [opts.peerTrusted] whether the immediate peer is a proxy we
185
+ * trust. When it is, forwarding headers are expected infrastructure rather
186
+ * than an anomaly. Defaults to false, so a caller that does not know keeps
187
+ * the stricter behaviour.
188
+ */
189
+ function analyzeHeaders(headers, opts = {}) {
190
+ const peerTrusted = opts.peerTrusted === true;
166
191
  const detections = [];
167
192
  const headersLower = {};
168
193
  for (const [key, value] of Object.entries(headers)) {
@@ -185,9 +210,12 @@ function analyzeHeaders(headers) {
185
210
  });
186
211
  }
187
212
 
188
- // Check for suspicious headers
213
+ // Check for suspicious headers. Forwarding headers only count when the peer
214
+ // is not a proxy we trust — see FORWARDING_HEADERS.
189
215
  for (const header of Object.keys(headersLower)) {
190
- if (SUSPICIOUS_HEADERS.has(header)) {
216
+ const isSuspicious =
217
+ SUSPICIOUS_HEADERS.has(header) || (!peerTrusted && FORWARDING_HEADERS.has(header));
218
+ if (isSuspicious) {
191
219
  detections.push({
192
220
  category: 'bot',
193
221
  score: 0.3,
@@ -660,9 +688,17 @@ function analyzeKeystrokeCadence(stats) {
660
688
  // Form Interaction Analysis (Credential Stuffing & Spam Detection)
661
689
  // =============================================================================
662
690
 
663
- function analyzeFormInteraction(formAnalysis) {
691
+ /**
692
+ * @param {object} formAnalysis
693
+ * @param {object} [opts]
694
+ * @param {boolean} [opts.humanPresent] whether the visitor independently showed
695
+ * they were there — real pointer or touch movement. Defaults to false so a
696
+ * caller that does not know keeps the stricter behaviour.
697
+ */
698
+ function analyzeFormInteraction(formAnalysis, opts = {}) {
664
699
  if (!formAnalysis) return [];
665
700
 
701
+ const humanPresent = opts.humanPresent === true;
666
702
  const detections = [];
667
703
  const submit = formAnalysis.submit || {};
668
704
 
@@ -733,8 +769,18 @@ function analyzeFormInteraction(formAnalysis) {
733
769
  });
734
770
  }
735
771
 
736
- // Check for paste-heavy input (spam bots often paste content)
737
- if (stats.pasteCount > 0 && stats.keyCount < 5) {
772
+ // Check for paste-heavy input (spam bots often paste content).
773
+ //
774
+ // Standing down when the visitor independently demonstrated they were
775
+ // present. Pasting is not a bot behaviour — people paste addresses, error
776
+ // messages, snippets and one-time codes constantly, and a form filled by
777
+ // paste alone is an ordinary afternoon. The bench human panel caught this
778
+ // firing on 100% of the paste-by-human persona.
779
+ //
780
+ // What actually distinguishes the spam bot is that pasting is ALL it did:
781
+ // no pointer trajectory, no touch, nothing but the content appearing. So
782
+ // the check now needs the paste AND the absence of a person.
783
+ if (stats.pasteCount > 0 && stats.keyCount < 5 && !humanPresent) {
738
784
  detections.push({
739
785
  category: 'bot',
740
786
  score: 0.6,
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Input forensics v2 — PRD workstream C.
5
+ *
6
+ * Four checks on *how* input arrived rather than how much of it there was:
7
+ * typing cadence, typing modality, the paste-shortcut/platform contradiction,
8
+ * and scroll morphology.
9
+ *
10
+ * ## Every threshold here was measured, not assumed
11
+ *
12
+ * The PRD quotes FP-Agent's observations (Manus 1.39ms inter-key, Browser Use
13
+ * 5.31ms, Skyvern 9.52ms) and then warns, correctly, that they are *not*
14
+ * constants: the paper found Browser Use programs an 18ms delay yet is observed
15
+ * at 5.31ms, because the renderer batches events. The numbers are a function of
16
+ * browser version and machine load. Hardcoding them would be copying someone
17
+ * else's laboratory conditions.
18
+ *
19
+ * So the values below come from the bench corpus (bench/README.md), captured on
20
+ * real hardware through a real browser:
21
+ *
22
+ * signal human scripted agent
23
+ * --------------------- ------------------------ --------------------
24
+ * inter-key interval min 114.6ms, med 226.9ms max 25.9ms, med 7.9ms
25
+ * interval variance 4549 8
26
+ * key hold (dwell) min 41.4ms, med 82.0ms max 25.5ms, med 7.8ms
27
+ * scroll max step 109px 704px
28
+ *
29
+ * The distributions do not overlap, so the thresholds sit in the empty space
30
+ * between them with a wide margin on both sides rather than hugging either.
31
+ * Re-derive them with `node bench/run-bench.js` on your own hardware if you
32
+ * fork this; that is what §7.4 of the PRD is asking for.
33
+ *
34
+ * ## What is deliberately NOT here
35
+ *
36
+ * Keystroke overlap — the claim that humans press the next key before releasing
37
+ * the current one and agents never do. An earlier PRD draft attributed it to
38
+ * FP-Agent; the paper does not say it. It defines a keystroke as "a keydown
39
+ * event followed by a keyup event" and its only overlap language is about
40
+ * modifier keys in shortcuts. Rollover typing is real in the keystroke-dynamics
41
+ * literature, so the hypothesis may well hold — but it has no source and no
42
+ * measurement here, so it earns no weight.
43
+ *
44
+ * ## Weighting
45
+ *
46
+ * All of these are contributory, not decisive, per §7.4. The coalesced-events
47
+ * signal FCaptcha already ships is under active attack from stealth tooling
48
+ * that synthesises it; behavioural signals have a shelf life, and any one of
49
+ * them carrying a verdict on its own is a liability. The one exception in
50
+ * spirit is the platform contradiction, which scores higher because it is not a
51
+ * threshold at all — it is the client disagreeing with itself.
52
+ */
53
+
54
+ // --- measured thresholds ----------------------------------------------------
55
+
56
+ // 2.9x below the slowest human keystroke observed, 1.5x above the fastest agent one.
57
+ const MAX_MACHINE_INTERKEY_MS = 40;
58
+ // Human variance was 4549; the scripted agent's was 8.
59
+ const MAX_MACHINE_INTERVAL_VARIANCE = 200;
60
+ // Human holds bottomed out at 41ms; the agent never exceeded 25.5ms.
61
+ const MAX_MACHINE_DWELL_MS = 35;
62
+ // Enough keystrokes to be a typing sample at all. A paste registers as one or
63
+ // two keydowns, and at n=2 any cadence statistic is noise.
64
+ const MIN_KEYS_FOR_CADENCE = 10;
65
+ // Human scrolling never stepped more than 109px between events; scrollIntoView
66
+ // covered 704px in one.
67
+ const MAX_HUMAN_SCROLL_STEP_PX = 400;
68
+
69
+ const MAC_PLATFORMS = /^(mac|iphone|ipad|ipod)/i;
70
+ const WINDOWS_PLATFORMS = /^win/i;
71
+
72
+ const num = (v, dflt = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : dflt);
73
+
74
+ /**
75
+ * Typing cadence: a hand cannot hold a key for 8ms, forty times, with no spread.
76
+ *
77
+ * Gated hard, because the failure mode is punishing people who paste. Humans
78
+ * paste constantly — password managers, copied addresses, one-time codes — and
79
+ * a paste arrives as one or two keydowns microseconds apart, which is
80
+ * arithmetically indistinguishable from very fast typing. The bench's
81
+ * paste-by-human persona is exactly that shape: 2 keys, 0.8ms apart, variance 0.
82
+ *
83
+ * Hence three preconditions before the cadence is even looked at: enough keys to
84
+ * be a sample, no paste anywhere on the page, and no paste into this field.
85
+ */
86
+ function checkTypingCadence(fields, typing) {
87
+ const detections = [];
88
+ if (!fields) return detections;
89
+
90
+ const pastedAnywhere = num(typing.pasteEvents) > 0 || num(typing.pasteInputs) > 0;
91
+ if (pastedAnywhere) return detections;
92
+
93
+ for (const [, stats] of Object.entries(fields)) {
94
+ if (!stats || typeof stats !== 'object') continue;
95
+ if (num(stats.pasteCount) > 0) continue;
96
+
97
+ const keyCount = num(stats.keyCount);
98
+ if (keyCount < MIN_KEYS_FOR_CADENCE) continue;
99
+
100
+ const interval = num(stats.avgKeyInterval, Infinity);
101
+ const variance = num(stats.keyIntervalVariance, Infinity);
102
+ const dwells = Array.isArray(stats.dwellTimes) ? stats.dwellTimes : [];
103
+ const avgDwell = dwells.length
104
+ ? dwells.reduce((a, b) => a + b, 0) / dwells.length
105
+ : Infinity;
106
+
107
+ // Both the rhythm and the hold have to be inhuman. Either alone is a
108
+ // machine that happens to be slow, or a fast typist on a bad keyboard.
109
+ const machineRhythm = interval < MAX_MACHINE_INTERKEY_MS && variance < MAX_MACHINE_INTERVAL_VARIANCE;
110
+ const machineHold = avgDwell < MAX_MACHINE_DWELL_MS;
111
+
112
+ if (machineRhythm && machineHold) {
113
+ detections.push({
114
+ category: 'behavioral',
115
+ score: 0.6,
116
+ confidence: 0.6,
117
+ reason: 'Typing cadence is mechanical (inter-key and hold both below human range)',
118
+ });
119
+ break; // one detection per request, however many fields were filled
120
+ }
121
+ }
122
+
123
+ return detections;
124
+ }
125
+
126
+ /**
127
+ * The paste shortcut disagrees with the platform the client claims.
128
+ *
129
+ * macOS pastes with Meta+V; Windows and Linux paste with Control+V. These are
130
+ * not conventions a user can be halfway between — the other combination does
131
+ * nothing at all on that OS. So a client reporting `MacIntel` whose visitor
132
+ * pasted with Control+V has told us two incompatible things about itself.
133
+ *
134
+ * Requiring that the paste *landed* is what makes this safe. A Windows
135
+ * switcher on a new Mac really does fat-finger Ctrl+V — and on macOS nothing
136
+ * happens, so no paste event follows. Only a client whose real platform differs
137
+ * from its claimed one gets both the wrong shortcut and a completed paste.
138
+ *
139
+ * No threshold, no calibration, no hardware dependence. Scored higher than the
140
+ * cadence checks for exactly that reason.
141
+ */
142
+ function checkPastePlatformCoherence(typing, platform) {
143
+ if (!platform) return [];
144
+
145
+ const pasted = num(typing.pasteEvents) > 0 || num(typing.pasteInputs) > 0;
146
+ if (!pasted) return [];
147
+
148
+ const ctrl = num(typing.pasteShortcutControl);
149
+ const meta = num(typing.pasteShortcutMeta);
150
+
151
+ const claimsMac = MAC_PLATFORMS.test(platform);
152
+ const claimsWindows = WINDOWS_PLATFORMS.test(platform);
153
+
154
+ if (claimsMac && ctrl > 0 && meta === 0) {
155
+ return [{
156
+ category: 'automation',
157
+ score: 0.7,
158
+ confidence: 0.8,
159
+ reason: `Paste shortcut contradicts platform (Control+V while claiming ${platform})`,
160
+ }];
161
+ }
162
+ if (claimsWindows && meta > 0 && ctrl === 0) {
163
+ return [{
164
+ category: 'automation',
165
+ score: 0.7,
166
+ confidence: 0.8,
167
+ reason: `Paste shortcut contradicts platform (Meta+V while claiming ${platform})`,
168
+ }];
169
+ }
170
+ return [];
171
+ }
172
+
173
+ /**
174
+ * Text that appeared without being typed or pasted.
175
+ *
176
+ * A genuine `input` event carries an `inputType` describing what the browser
177
+ * did; a synthetic one dispatched by script does not. Combined with no
178
+ * keystrokes and no paste, that is a value assigned straight into the DOM.
179
+ *
180
+ * Deliberately low confidence: browser autofill and some password managers also
181
+ * produce input events with no keystrokes behind them, and the whole point of
182
+ * §7.1's warning is that a check which punishes password-manager users is worse
183
+ * than no check. It contributes; it never decides.
184
+ */
185
+ function checkProgrammaticFill(typing) {
186
+ const noType = num(typing.inputsWithoutInputType);
187
+ const noKeys = num(typing.inputsWithoutKeys);
188
+ const pasted = num(typing.pasteEvents) > 0 || num(typing.pasteInputs) > 0;
189
+
190
+ if (noType > 0 && noKeys > 0 && !pasted) {
191
+ return [{
192
+ category: 'automation',
193
+ score: 0.5,
194
+ confidence: 0.4,
195
+ reason: 'Field populated without keystrokes or paste (synthetic input event)',
196
+ }];
197
+ }
198
+ return [];
199
+ }
200
+
201
+ /**
202
+ * Scroll morphology: the page moved further in one event than a hand can move it.
203
+ *
204
+ * A wheel notch or trackpad flick advances tens of pixels and emits an event
205
+ * each time, so covering a long page takes dozens of them. `scrollIntoView()`
206
+ * covers the same distance in one, because it is aiming at an element rather
207
+ * than turning a wheel. Measured: human scrolling never exceeded 109px between
208
+ * consecutive events; the agent's jump was 704px.
209
+ *
210
+ * This is an architecture tell as much as a bot tell — DOM-driven agents jump,
211
+ * vision-driven ones emit short bursts, people produce long variable gestures.
212
+ *
213
+ * Gated on there being no keyboard activity, because PageDown, End and Space
214
+ * are legitimate one-event jumps of roughly a viewport. A visitor using the
215
+ * keyboard to scroll is doing something a hand does; a DOM agent has no keys.
216
+ */
217
+ function checkScrollMorphology(behavioral) {
218
+ const m = behavioral.scrollMorphology;
219
+ if (!m || typeof m !== 'object') return [];
220
+
221
+ if (num(behavioral.keyEvents) > 0) return [];
222
+ if (num(m.samples) < 2) return [];
223
+
224
+ if (num(m.maxStep) > MAX_HUMAN_SCROLL_STEP_PX) {
225
+ return [{
226
+ category: 'automation',
227
+ score: 0.5,
228
+ confidence: 0.5,
229
+ reason: 'Page scrolled by API rather than by gesture (single-event jump)',
230
+ }];
231
+ }
232
+ return [];
233
+ }
234
+
235
+ /**
236
+ * Font list contradicts the claimed operating system.
237
+ *
238
+ * Scores the *contradiction*, never the enumeration. A short font list means a
239
+ * privacy extension, a minimal Linux install or a locked-down corporate image —
240
+ * all ordinary. A list full of Windows-only faces under a macOS claim is not
241
+ * ordinary, it is two different machines' worth of evidence in one payload.
242
+ *
243
+ * Validated in both directions by the bench corpus, though the positive case
244
+ * arrived by accident: the `incoherent-paste` persona spoofs `Win32` in order to
245
+ * test the paste-shortcut check, and while doing so it presents this machine's
246
+ * genuine macOS font set under a Windows claim. This check caught that
247
+ * independently. It stays silent on all 14 human personas.
248
+ *
249
+ * Still weighted as contributory. One machine's font set is thin evidence for a
250
+ * rule about every machine, and the specific shape the PRD describes — ChatGPT
251
+ * Agent reporting a Calibri-only list under both Linux and MacIntel claims — has
252
+ * not been captured here.
253
+ */
254
+ function checkFontPlatformCoherence(fonts, platform) {
255
+ if (!fonts || fonts.supported === false || !platform) return [];
256
+
257
+ // An empty or near-empty list is a blocked enumeration, not a contradiction.
258
+ if (num(fonts.count) < 3) return [];
259
+
260
+ const claimsMac = MAC_PLATFORMS.test(platform);
261
+ const claimsWindows = WINDOWS_PLATFORMS.test(platform);
262
+
263
+ const macFonts = fonts.hasSFPro === true || fonts.hasMenlo === true;
264
+ const winFonts = fonts.hasSegoeUI === true || fonts.hasCalibri === true;
265
+
266
+ if (claimsMac && winFonts && !macFonts) {
267
+ return [{
268
+ category: 'automation',
269
+ score: 0.5,
270
+ confidence: 0.5,
271
+ reason: `Font set contradicts platform (Windows faces while claiming ${platform})`,
272
+ }];
273
+ }
274
+ if (claimsWindows && macFonts && !winFonts) {
275
+ return [{
276
+ category: 'automation',
277
+ score: 0.5,
278
+ confidence: 0.5,
279
+ reason: `Font set contradicts platform (macOS faces while claiming ${platform})`,
280
+ }];
281
+ }
282
+ return [];
283
+ }
284
+
285
+ /** Runs every workstream-C check. */
286
+ function detectInputForensics(signals) {
287
+ const behavioral = signals.behavioral || {};
288
+ const form = signals.formAnalysis || {};
289
+ const env = signals.environmental || {};
290
+ const typing = form.typing || {};
291
+ const platform = (env.navigator && env.navigator.platform) || null;
292
+
293
+ return [
294
+ ...checkTypingCadence(form.textareaKeyboard, typing),
295
+ ...checkPastePlatformCoherence(typing, platform),
296
+ ...checkProgrammaticFill(typing),
297
+ ...checkScrollMorphology(behavioral),
298
+ ...checkFontPlatformCoherence(env.fontsInfo, platform),
299
+ ];
300
+ }
301
+
302
+ module.exports = {
303
+ MAX_HUMAN_SCROLL_STEP_PX,
304
+ MAX_MACHINE_DWELL_MS,
305
+ MAX_MACHINE_INTERKEY_MS,
306
+ MAX_MACHINE_INTERVAL_VARIANCE,
307
+ MIN_KEYS_FOR_CADENCE,
308
+ checkFontPlatformCoherence,
309
+ checkPastePlatformCoherence,
310
+ checkProgrammaticFill,
311
+ checkScrollMorphology,
312
+ checkTypingCadence,
313
+ detectInputForensics,
314
+ };
package/limits.js ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -12,7 +12,11 @@
12
12
  "start": "node server.js",
13
13
  "dev": "node --watch server.js",
14
14
  "test:webbotauth": "node webbotauth.test.js",
15
- "test:clientip": "node clientip.test.js"
15
+ "test:clientip": "node clientip.test.js",
16
+ "test:limits": "node limits.test.js",
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"
16
20
  },
17
21
  "dependencies": {
18
22
  "cors": "^2.8.5",
package/server.js CHANGED
@@ -11,6 +11,8 @@ const path = require('path');
11
11
  const detection = require('./detection');
12
12
  const webbotauth = require('./webbotauth');
13
13
  const { ProxyTrust } = require('./clientip');
14
+ const { BoundedMap, BoundedSet, SiteKeyGuard } = require('./limits');
15
+ const { detectInputForensics } = require('./inputforensics');
14
16
 
15
17
  const app = express();
16
18
  app.use(cors());
@@ -24,6 +26,12 @@ const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
24
26
  // the TLS-fingerprint headers. See clientip.js.
25
27
  const PROXY_TRUST = ProxyTrust.fromEnv();
26
28
 
29
+ // siteKey is client-supplied and validated against no registry, yet it is the
30
+ // first component of every rate-limit, fingerprint and challenge partition key.
31
+ // SITE_KEYS bounds how many distinct values one IP may allocate state for. See
32
+ // limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
33
+ const SITE_KEYS = SiteKeyGuard.fromEnv();
34
+
27
35
  // Express's own `trust proxy` would re-derive req.ip from the same headers on
28
36
  // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
29
37
  app.set('trust proxy', false);
@@ -85,8 +93,8 @@ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
85
93
 
86
94
  // PoW Challenge Store
87
95
  const powChallengeStore = {
88
- challenges: new Map(),
89
- usedSolutions: new Set(),
96
+ challenges: new BoundedMap(),
97
+ usedSolutions: new BoundedSet(),
90
98
 
91
99
  // Generate a new challenge
92
100
  generate(siteKey, ip, difficulty = 4) {
@@ -177,20 +185,15 @@ const powChallengeStore = {
177
185
 
178
186
  _cleanup() {
179
187
  const now = Date.now();
180
- for (const [id, challenge] of this.challenges) {
181
- if (now > challenge.expiresAt) {
182
- this.challenges.delete(id);
183
- }
184
- }
185
- // Cleanup old solutions (keep last hour)
186
- if (this.usedSolutions.size > 10000) {
187
- this.usedSolutions.clear();
188
- }
188
+ this.challenges.prune((challenge) => now <= challenge.expiresAt);
189
+ // usedSolutions is a BoundedSet: it evicts its own oldest entries as it
190
+ // fills. The previous code cleared the whole replay guard once it passed a
191
+ // threshold, which an attacker could force in order to replay a solution.
189
192
  }
190
193
  };
191
194
 
192
195
  const rateLimiter = {
193
- requests: new Map(),
196
+ requests: new BoundedMap(),
194
197
 
195
198
  check(key, windowSeconds = 60, maxRequests = 10) {
196
199
  const now = Date.now();
@@ -211,8 +214,8 @@ const rateLimiter = {
211
214
  };
212
215
 
213
216
  const fingerprintStore = {
214
- fingerprints: new Map(),
215
- ipFingerprints: new Map(),
217
+ fingerprints: new BoundedMap(),
218
+ ipFingerprints: new BoundedMap(),
216
219
 
217
220
  record(fp, ip, siteKey) {
218
221
  const key = `${siteKey}:${fp}`;
@@ -242,7 +245,7 @@ const fingerprintStore = {
242
245
 
243
246
  // Token Store - prevents token replay attacks
244
247
  const tokenStore = {
245
- usedTokens: new Set(),
248
+ usedTokens: new BoundedSet(),
246
249
 
247
250
  // Mark a token as used (returns false if already used)
248
251
  markUsed(tokenSig) {
@@ -261,11 +264,9 @@ const tokenStore = {
261
264
  },
262
265
 
263
266
  _cleanup() {
264
- // In production with Redis, use TTL instead
265
- // For in-memory, just clear if too large (tokens expire in 5 min)
266
- if (this.usedTokens.size > 50000) {
267
- this.usedTokens.clear();
268
- }
267
+ // In production with Redis, use TTL instead. In memory usedTokens is a
268
+ // BoundedSet, so it evicts its oldest entries rather than clearing wholesale
269
+ // — clearing would let an attacker who forced the threshold replay a token.
269
270
  }
270
271
  };
271
272
 
@@ -297,6 +298,57 @@ const WEIGHTS = {
297
298
  // Detection Functions
298
299
  // =============================================================================
299
300
 
301
+ /**
302
+ * Whether this visitor is using a touch or pen device, and so should be exempt
303
+ * from the mouse-trajectory detections.
304
+ *
305
+ * The old rule was `touchEvents >= 3`, which a mobile user who simply taps the
306
+ * checkbox does not meet: the client records touchstart and touchmove, and a
307
+ * clean tap on a page short enough not to need scrolling produces exactly one
308
+ * event. The bench human panel captured precisely that — `touchEvents: 1` — and
309
+ * the visitor collected three agent detections for it, including
310
+ * "Zero mouse, touch, or keyboard events recorded" at confidence 0.9.
311
+ *
312
+ * One touch event is enough to establish modality. Corroborating it with the
313
+ * pointer type keeps a bare forged count from claiming the exemption on its
314
+ * own — though note this is a soft check either way, since every input here is
315
+ * client-supplied and an agent willing to claim `touchEvents: 1` was equally
316
+ * willing to claim 3.
317
+ */
318
+ /**
319
+ * Movement that carries independent evidence of a human hand.
320
+ *
321
+ * A slow pointer is the thing two of these checks look for, and it is also
322
+ * exactly what an elderly or motor-impaired visitor produces. The bench human
323
+ * panel caught both firing on them: "Mouse event rate abnormally low" on the
324
+ * elderly and motor-slow personas, "Mouse velocity too consistent" on
325
+ * motor-slow.
326
+ *
327
+ * Slowness alone cannot separate those users from an agent, but it does not
328
+ * have to. The same captures carry markers no low-effort automation produces:
329
+ * saturated micro-tremor, dozens of direction changes, corrective overshoots.
330
+ * On the bench corpus the split is total - every human persona clears this bar
331
+ * (tremor 1.00, 22-49 direction changes, 1-4 corrections) and every agent
332
+ * misses it (tremor 0.04-0.16, 0-1 direction changes, 0 corrections).
333
+ *
334
+ * So: do not read slowness as automation when the movement independently looks
335
+ * like a hand. An agent can of course fake all three, but faking three
336
+ * correlated properties of human motion is a materially harder job than
337
+ * running slowly, which is the point.
338
+ */
339
+ function hasHumanMovementMarkers(b) {
340
+ const tremor = b.microTremorScore ?? 0.5;
341
+ const corrections = b.overshootCorrections ?? 0;
342
+ const changes = b.directionChanges ?? 0;
343
+ return tremor >= 0.5 && (corrections >= 1 || changes >= 10);
344
+ }
345
+
346
+ function isTouchModality(b) {
347
+ const touchEvents = b.touchEvents ?? 0;
348
+ const touchPoints = b.touchTotalPoints ?? 0;
349
+ return touchEvents >= 3 || ((touchEvents >= 1 || touchPoints >= 1) && b.pointerHasNonMouseType === true);
350
+ }
351
+
300
352
  function getNestedValue(obj, ...keys) {
301
353
  return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
302
354
  }
@@ -313,7 +365,7 @@ function detectVisionAI(signals) {
313
365
  const approachPts = b.approachPoints ?? 0;
314
366
  const touchEvents = b.touchEvents ?? 0;
315
367
  const keyEvents = b.keyEvents ?? 0;
316
- const isTouchUser = touchEvents >= 3;
368
+ const isTouchUser = isTouchModality(b);
317
369
  const isKeyboardUser = keyEvents >= 2 && totalPoints === 0;
318
370
 
319
371
  if (totalPoints < 5 && trajectory < 10 && !isTouchUser && !isKeyboardUser) {
@@ -350,16 +402,39 @@ function detectVisionAI(signals) {
350
402
  }
351
403
 
352
404
  // Micro-tremor
405
+ // Micro-tremor.
406
+ //
407
+ // Two things were wrong here. Go defaulted a missing microTremorScore to 0
408
+ // (maximally suspicious) while Node and Python defaulted to 0.5, so a client
409
+ // that omitted the field was flagged by one server and not the others. And
410
+ // like the approach-directness check above, this fires on a measurement that
411
+ // does not exist for someone who never moved a mouse — the client itself
412
+ // reports 0.5 as its "no mouse data" sentinel.
413
+ //
414
+ // Require real mouse movement before judging its texture, and apply the same
415
+ // exemptions as the surrounding checks.
353
416
  const microTremor = b.microTremorScore ?? 0.5;
354
- if (microTremor < 0.15) {
417
+ const hasMouseMovement = totalPoints >= 5;
418
+ if (hasMouseMovement && !isTouchUser && !isKeyboardUser && microTremor < 0.15) {
355
419
  detections.push({
356
420
  category: 'vision_ai', score: 0.7, confidence: 0.6,
357
421
  reason: 'Mouse movement lacks natural micro-tremor'
358
422
  });
359
423
  }
360
424
 
361
- // Approach directness
362
- if ((b.approachDirectness ?? 0) > 0.95) {
425
+ // Approach directness.
426
+ //
427
+ // The client reports directness 1 (perfectly straight) when there is no
428
+ // approach path to measure at all, so this check used to fire on every
429
+ // keyboard-only, screen-reader and touch user — the populations the
430
+ // surrounding checks go out of their way to exempt. Found by the bench
431
+ // human panel: keyboard-only, screen-reader and touch all reported
432
+ // approachPoints 0 with approachDirectness 1.
433
+ //
434
+ // Require an actual path before judging its shape, and apply the same
435
+ // exemptions as its neighbours.
436
+ const hasApproachPath = approachPts >= 5;
437
+ if (hasApproachPath && !isTouchUser && !isKeyboardUser && (b.approachDirectness ?? 0) > 0.95) {
363
438
  detections.push({
364
439
  category: 'vision_ai', score: 0.5, confidence: 0.5,
365
440
  reason: 'Mouse path to target is unnaturally direct'
@@ -422,6 +497,7 @@ function detectHeadless(signals, userAgent) {
422
497
  if (env.webdriver) {
423
498
  detections.push({
424
499
  category: 'headless', score: 0.95, confidence: 0.95,
500
+ dispositive: true, // navigator.webdriver === true — see DISPOSITIVE_FLOOR
425
501
  reason: 'WebDriver detected'
426
502
  });
427
503
  }
@@ -631,6 +707,7 @@ function detectCDP(signals) {
631
707
  category: 'cdp',
632
708
  score: 0.9,
633
709
  confidence: 0.95,
710
+ dispositive: true, // driver-injected globals — see DISPOSITIVE_FLOOR
634
711
  reason: `CDP automation detected: ${signalList.join(', ')}`
635
712
  });
636
713
  } else if (signalCount >= 2) {
@@ -664,7 +741,7 @@ function detectBehavioral(signals) {
664
741
  const trajectory = b.trajectoryLength ?? 0;
665
742
  const touchEvts = b.touchEvents ?? 0;
666
743
  const keyEvts = b.keyEvents ?? 0;
667
- const isTouchUsr = touchEvts >= 3;
744
+ const isTouchUsr = isTouchModality(b);
668
745
  const isKbdUser = keyEvts >= 2 && totalPoints === 0;
669
746
 
670
747
  if (totalPoints === 0 && !isTouchUsr && !isKbdUser) {
@@ -681,7 +758,7 @@ function detectBehavioral(signals) {
681
758
 
682
759
  // Velocity variance
683
760
  const velVar = b.velocityVariance ?? 1;
684
- if (velVar < 0.02 && trajectory > 50) {
761
+ if (velVar < 0.02 && trajectory > 50 && !hasHumanMovementMarkers(b)) {
685
762
  detections.push({
686
763
  category: 'behavioral', score: 0.6, confidence: 0.6,
687
764
  reason: 'Mouse velocity too consistent'
@@ -727,7 +804,7 @@ function detectBehavioral(signals) {
727
804
  category: 'behavioral', score: 0.6, confidence: 0.5,
728
805
  reason: 'Mouse event rate abnormally high'
729
806
  });
730
- } else if (eventRate > 0 && eventRate < 10) {
807
+ } else if (eventRate > 0 && eventRate < 10 && !hasHumanMovementMarkers(b)) {
731
808
  detections.push({
732
809
  category: 'behavioral', score: 0.4, confidence: 0.4,
733
810
  reason: 'Mouse event rate abnormally low'
@@ -953,6 +1030,36 @@ function detectRateAbuse(ip, siteKey) {
953
1030
  // Scoring
954
1031
  // =============================================================================
955
1032
 
1033
+ /**
1034
+ * Combines the detections within each category into a category score.
1035
+ *
1036
+ * ## Why this is not a mean
1037
+ *
1038
+ * It used to be a confidence-weighted mean, which had a property nobody
1039
+ * intended: corroborating evidence *lowered* the verdict. A visitor whose
1040
+ * browser reported `navigator.webdriver === true` and nothing else scored 0.95
1041
+ * in the headless category. The same visitor, additionally caught with no
1042
+ * plugins, a software renderer, a viewport equal to its window and three more
1043
+ * automation tells, scored 0.686 — because each additional signal, being
1044
+ * individually weaker than the first, pulled the average down. Seven pieces of
1045
+ * corroboration made the case weaker than one.
1046
+ *
1047
+ * Noisy-OR fixes that. Each detection is treated as independent evidence of
1048
+ * strength `score × confidence`, and the category is the probability that at
1049
+ * least one of them is right:
1050
+ *
1051
+ * category = 1 - ∏(1 - scoreᵢ × confidenceᵢ)
1052
+ *
1053
+ * Evidence now accumulates: adding a signal can only raise a category, never
1054
+ * lower it. And it is *more* forgiving of isolated weak evidence than the mean
1055
+ * was — one detection at score 0.4, confidence 0.5 contributes 0.20 rather than
1056
+ * setting the whole category to 0.40 — which is the right treatment for a
1057
+ * lone low-confidence hit on a real user.
1058
+ *
1059
+ * Measured on the bench corpus (bench/tools/compare-aggregation.js): human
1060
+ * median 0.182 → 0.097 and human max 0.260 → 0.171, while agent median rose
1061
+ * 0.517 → 0.570. Both populations moved in the direction they should.
1062
+ */
956
1063
  function calculateCategoryScores(detections) {
957
1064
  const categoryData = {};
958
1065
 
@@ -966,11 +1073,12 @@ function calculateCategoryScores(detections) {
966
1073
  const result = {};
967
1074
  for (const [cat, scores] of Object.entries(categoryData)) {
968
1075
  if (scores.length > 0) {
969
- const totalWeight = scores.reduce((sum, [, conf]) => sum + conf, 0);
970
- if (totalWeight > 0) {
971
- const weightedSum = scores.reduce((sum, [score, conf]) => sum + score * conf, 0);
972
- result[cat] = Math.min(1.0, weightedSum / totalWeight);
1076
+ let survives = 1;
1077
+ for (const [score, conf] of scores) {
1078
+ const strength = Math.max(0, Math.min(1, score * conf));
1079
+ survives *= 1 - strength;
973
1080
  }
1081
+ result[cat] = Math.min(1.0, 1 - survives);
974
1082
  }
975
1083
  }
976
1084
 
@@ -992,6 +1100,57 @@ function calculateFinalScore(categoryScores) {
992
1100
  return Math.min(1.0, total);
993
1101
  }
994
1102
 
1103
+ /**
1104
+ * Score below which a self-declared automated browser cannot fall.
1105
+ *
1106
+ * ## Why a floor exists at all
1107
+ *
1108
+ * The final score is a weighted sum across all eleven categories, so a category
1109
+ * can contribute at most its own weight no matter how certain it is. A local
1110
+ * automated browser trips at most the six categories reachable without a
1111
+ * datacenter IP, a reused fingerprint or a rate-limit hit — about 0.81 of the
1112
+ * weight — and in practice lands near 0.5. The bench measured exactly that: a
1113
+ * Playwright browser reporting `navigator.webdriver === true`, no plugins, a
1114
+ * software renderer and four more automation tells scored 0.549, i.e.
1115
+ * "challenge", not "block".
1116
+ *
1117
+ * That is the weighted sum working as designed. It expresses "what fraction of
1118
+ * the total suspicion budget did this visitor consume", and no single fact can
1119
+ * consume most of that budget. The trouble is that some facts are not
1120
+ * probabilistic evidence at all — they are the browser saying so.
1121
+ *
1122
+ * ## What qualifies
1123
+ *
1124
+ * Only detections marked `dispositive`, and the bar for that mark is that a
1125
+ * browser cannot produce the signal without being automated:
1126
+ *
1127
+ * - `navigator.webdriver === true` — a W3C-specified flag whose sole purpose
1128
+ * is to tell the page it is under automation.
1129
+ * - ChromeDriver / Puppeteer injected globals (`chromedriver_cdc`,
1130
+ * `puppeteer_eval`, `cdp_script_injection`), which exist in no ordinary
1131
+ * browsing session.
1132
+ *
1133
+ * Deliberately excluded, though both look tempting: the "console consumer
1134
+ * attached" CDP check, because the bench human panel proves it fires on a
1135
+ * developer with DevTools open; and the Playwright `webdriver_configurable`
1136
+ * artifact, which relies on a property-descriptor detail no specification
1137
+ * guarantees.
1138
+ *
1139
+ * ## What this does not do
1140
+ *
1141
+ * It does not catch a stealth agent, which patches `navigator.webdriver` before
1142
+ * the page ever sees it. That is not a regression — such an agent scores the
1143
+ * same as it did before — and it is the reason the behavioural workstreams
1144
+ * still matter. The floor closes the case where an agent is not even trying to
1145
+ * hide, which was previously being waved through with a "challenge".
1146
+ */
1147
+ const DISPOSITIVE_FLOOR = 0.9;
1148
+
1149
+ function applyDispositiveFloor(score, detections) {
1150
+ const selfDeclared = detections.some((d) => d.dispositive === true);
1151
+ return selfDeclared ? Math.max(score, DISPOSITIVE_FLOOR) : score;
1152
+ }
1153
+
995
1154
  function generateToken(ip, siteKey, score) {
996
1155
  const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
997
1156
  const data = {
@@ -1070,7 +1229,7 @@ async function verifyWebBotAuth(req) {
1070
1229
  // require the raw request — currently Web Bot Auth signature verification, which
1071
1230
  // needs the accurately-reconstructed signed request. They are seeded into the
1072
1231
  // detection set so they participate in scoring like any other detection.
1073
- function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = []) {
1232
+ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = [], opts = {}) {
1074
1233
  const detections = Array.isArray(preDetections) ? [...preDetections] : [];
1075
1234
 
1076
1235
  // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
@@ -1161,6 +1320,9 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1161
1320
  ...detectAutomation(signals),
1162
1321
  ...detectCDP(signals),
1163
1322
  ...detectBehavioral(signals),
1323
+ // Input forensics v2 (PRD workstream C): typing cadence and modality, the
1324
+ // paste-shortcut/platform contradiction, scroll morphology, font coherence.
1325
+ ...detectInputForensics(signals),
1164
1326
  ...detectTouchAuthenticity(signals, userAgent),
1165
1327
  ...detectSensorEntropy(signals, userAgent),
1166
1328
  ...detectTouchKinematics(signals),
@@ -1179,7 +1341,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1179
1341
  }
1180
1342
 
1181
1343
  // Add header analysis
1182
- const headerDetections = detection.analyzeHeaders(headers);
1344
+ const headerDetections = detection.analyzeHeaders(headers, { peerTrusted: opts.peerTrusted === true });
1183
1345
  detections.push(...headerDetections);
1184
1346
 
1185
1347
  // Add browser consistency checks
@@ -1206,7 +1368,11 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1206
1368
  // Add form interaction analysis (credential stuffing & spam detection)
1207
1369
  const formAnalysis = signals.formAnalysis;
1208
1370
  if (formAnalysis) {
1209
- const formDetections = detection.analyzeFormInteraction(formAnalysis);
1371
+ // A visitor who moved a pointer or touched the screen has shown they are
1372
+ // there; that changes how a paste-only form fill should be read.
1373
+ const beh = signals.behavioral || {};
1374
+ const humanPresent = (beh.totalPoints ?? 0) >= 5 || (beh.touchEvents ?? 0) >= 1;
1375
+ const formDetections = detection.analyzeFormInteraction(formAnalysis, { humanPresent });
1210
1376
  detections.push(...formDetections);
1211
1377
  }
1212
1378
 
@@ -1215,7 +1381,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1215
1381
  detections.push(...advancedDetections);
1216
1382
 
1217
1383
  const categoryScores = calculateCategoryScores(detections);
1218
- const finalScore = calculateFinalScore(categoryScores);
1384
+ const finalScore = applyDispositiveFloor(calculateFinalScore(categoryScores), detections);
1219
1385
 
1220
1386
  let recommendation;
1221
1387
  if (finalScore < 0.3) recommendation = 'allow';
@@ -1260,8 +1426,10 @@ function collectHeaders(req) {
1260
1426
  }
1261
1427
 
1262
1428
  app.post('/api/verify', async (req, res) => {
1263
- const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1429
+ const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1264
1430
  const ip = PROXY_TRUST.clientIP(req);
1431
+ // Bound the state an unvalidated siteKey can allocate (limits.js).
1432
+ const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
1265
1433
  const userAgent = req.headers['user-agent'] || '';
1266
1434
  // Only honoured from a trusted proxy: a client that can state its own TLS
1267
1435
  // fingerprint would just claim a stock Chrome one.
@@ -1269,30 +1437,33 @@ app.post('/api/verify', async (req, res) => {
1269
1437
 
1270
1438
  // Collect headers for analysis
1271
1439
  const headers = collectHeaders(req);
1440
+ const peerTrusted = PROXY_TRUST.peerTrusted(req);
1272
1441
 
1273
1442
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1274
1443
  const webBotAuth = await verifyWebBotAuth(req);
1275
1444
 
1276
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
1445
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
1277
1446
  logVerdict('verify', siteKey, result);
1278
1447
  res.json(result);
1279
1448
  });
1280
1449
 
1281
1450
  app.post('/api/score', async (req, res) => {
1282
- const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1451
+ const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1283
1452
  const ip = PROXY_TRUST.clientIP(req);
1453
+ const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
1284
1454
  const userAgent = req.headers['user-agent'] || '';
1285
1455
  const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
1286
1456
 
1287
- const headers = {};
1288
- for (const [key, value] of Object.entries(req.headers)) {
1289
- headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
1290
- }
1457
+ // Use collectHeaders, not a bare copy of req.headers: this endpoint used to
1458
+ // build its own map, which skipped the trust gate on the TLS-fingerprint
1459
+ // header names and let an untrusted client present its own JA4.
1460
+ const headers = collectHeaders(req);
1461
+ const peerTrusted = PROXY_TRUST.peerTrusted(req);
1291
1462
 
1292
1463
  // Web Bot Auth verification needs the raw request; its verdict is scored.
1293
1464
  const webBotAuth = await verifyWebBotAuth(req);
1294
1465
 
1295
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
1466
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth, { peerTrusted });
1296
1467
  logVerdict('score', siteKey, result);
1297
1468
  res.json({
1298
1469
  success: result.success,
@@ -1314,9 +1485,8 @@ app.post('/api/token/verify', (req, res) => {
1314
1485
 
1315
1486
  // PoW Challenge endpoint - client fetches this on page load
1316
1487
  app.get('/api/pow/challenge', (req, res) => {
1317
- const siteKey = req.query.siteKey || 'default';
1318
-
1319
1488
  const ip = PROXY_TRUST.clientIP(req);
1489
+ const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
1320
1490
 
1321
1491
  // Difficulty scaling based on IP reputation
1322
1492
  let difficulty = 4; // Default: ~100-500ms on average hardware
@@ -1365,4 +1535,5 @@ app.get('/api/challenge', (req, res) => {
1365
1535
  app.listen(PORT, () => {
1366
1536
  console.log(`FCaptcha server running on port ${PORT}`);
1367
1537
  console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1538
+ console.log(`Site keys: ${SITE_KEYS.describe()}`);
1368
1539
  });
package/webbotauth.js CHANGED
@@ -23,8 +23,33 @@
23
23
  //
24
24
  // The upstream package (cloudflare/web-bot-auth) is the reference implementation
25
25
  // the Go module (WebDecoy/web-bot-auth) is cross-validated against, so the two
26
- // servers agree on verdicts. Python has no maintained equivalent and stays
27
- // presence-only.
26
+ // servers agree on verdicts. Python stays presence-only for now, though a
27
+ // maintained RFC 9421 implementation (pyauth/http-message-signatures) now exists
28
+ // and would close that gap.
29
+ //
30
+ // Wire format — draft-meunier-webbotauth-httpsig-protocol-00 (2026-06-26):
31
+ // Signature-Agent is a Structured Fields Dictionary whose member value is the
32
+ // discovery URL and whose optional `type` parameter selects how to resolve keys:
33
+ //
34
+ // sig1="https://a.test" → directory (default)
35
+ // sig1="https://a.test/jwks.json";type=jwks_uri → the URL *is* a JWK Set
36
+ // sig1="https://a.test/card";type=cimd → Client ID Metadata Document
37
+ //
38
+ // The pre-00 bare-String form ("https://a.test") is still emitted by signers
39
+ // that have not migrated and stays supported. A parser that only understood the
40
+ // bare form silently lost the *verified* verdict for any signer using the two
41
+ // new discovery types, degrading them to presence-only.
42
+ //
43
+ // DOCUMENTED DIVERGENCE — `cimd`. This implementation resolves a Client ID
44
+ // Metadata Document (embedded `jwks`, or a nested `jwks_uri` re-fetched through
45
+ // the same guard). The Go module (WebDecoy/web-bot-auth v0.2.0) parses the type
46
+ // but returns "not resolvable", so Go fails open to presence-only where Node
47
+ // reaches verified. Go is otherwise fully protocol-00 compliant — it honors
48
+ // `directory` and `jwks_uri` and enforces the tag — so this is the single
49
+ // verdict difference. It is currently theoretical: no observed signer uses
50
+ // `cimd` (chatgpt.com and agent.bot.goog both serve `directory`). Parity
51
+ // requires a change in the Go module, not here. Tracked rather than silent, per
52
+ // the CLAUDE.md sync rule.
28
53
 
29
54
  const https = require('https');
30
55
  const dns = require('dns');
@@ -48,6 +73,23 @@ const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
48
73
  // signature. Everything else (fetch failure, expiry, missing key) fails open.
49
74
  const CRYPTO_FAILURE_MESSAGE = 'invalid signature';
50
75
 
76
+ // Discovery types from protocol-00. `directory` is the default when the header
77
+ // carries no `type` parameter.
78
+ const DISCOVERY_DIRECTORY = 'directory';
79
+ const DISCOVERY_JWKS_URI = 'jwks_uri';
80
+ const DISCOVERY_CIMD = 'cimd';
81
+
82
+ // protocol-00: `tag` MUST be "web-bot-auth". A signature tagged for some other
83
+ // protocol is not a web-bot-auth signature, so it earns no verified credit.
84
+ const REQUIRED_TAG = 'web-bot-auth';
85
+
86
+ // protocol-00 RECOMMENDS an expiry no more than 24h out. Because that is a
87
+ // RECOMMENDED and not a MUST, an over-long window is not treated as forgery or
88
+ // as a hard rejection — it just does not earn the *verified* verdict, and the
89
+ // request falls back to presence-only. That bounds the replay window a stolen
90
+ // signature buys without false-positiving a signer who picked a longer TTL.
91
+ const MAX_SIGNATURE_LIFETIME_S = 24 * 60 * 60;
92
+
51
93
  // origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
52
94
  const directoryCache = new Map();
53
95
 
@@ -96,11 +138,14 @@ function guardedLookup(hostname, options, callback) {
96
138
 
97
139
  // --- directory fetching -------------------------------------------------------
98
140
 
99
- function fetchDirectory(directoryUrl) {
141
+ // fetchJSON retrieves and parses one https document under the SSRF guard, the
142
+ // size cap and the timeout. Shared by directory, jwks_uri and cimd fetches so a
143
+ // new discovery type cannot accidentally bypass the guard.
144
+ function fetchJSON(documentUrl) {
100
145
  return new Promise((resolve, reject) => {
101
146
  let url;
102
147
  try {
103
- url = new URL(directoryUrl);
148
+ url = new URL(documentUrl);
104
149
  } catch {
105
150
  reject(new FetchFailure('invalid directory URL'));
106
151
  return;
@@ -111,7 +156,7 @@ function fetchDirectory(directoryUrl) {
111
156
  }
112
157
 
113
158
  const req = https.get(
114
- directoryUrl,
159
+ documentUrl,
115
160
  {
116
161
  lookup: guardedLookup,
117
162
  timeout: VERIFY_TIMEOUT_MS,
@@ -138,8 +183,7 @@ function fetchDirectory(directoryUrl) {
138
183
  });
139
184
  res.on('end', () => {
140
185
  try {
141
- const doc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
142
- resolve(Array.isArray(doc.keys) ? doc.keys : []);
186
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
143
187
  } catch {
144
188
  reject(new FetchFailure('directory is not valid JSON'));
145
189
  }
@@ -156,14 +200,46 @@ function fetchDirectory(directoryUrl) {
156
200
  });
157
201
  }
158
202
 
159
- async function getDirectory(directoryUrl) {
203
+ // fetchKeySet pulls a JWK Set — the shape served by both the well-known
204
+ // directory and a jwks_uri.
205
+ async function fetchKeySet(url) {
206
+ const doc = await fetchJSON(url);
207
+ return doc && Array.isArray(doc.keys) ? doc.keys : [];
208
+ }
209
+
210
+ // fetchViaCIMD resolves a Client ID Metadata Document, which either embeds the
211
+ // JWK Set inline or points at one. The nested jwks_uri is re-validated and
212
+ // re-fetched through the same guard rather than trusted.
213
+ async function fetchViaCIMD(cimdUrl) {
214
+ const doc = await fetchJSON(cimdUrl);
215
+ if (doc && doc.jwks && Array.isArray(doc.jwks.keys)) {
216
+ return doc.jwks.keys;
217
+ }
218
+ if (doc && typeof doc.jwks_uri === 'string') {
219
+ let nested;
220
+ try {
221
+ nested = new URL(doc.jwks_uri);
222
+ } catch {
223
+ throw new FetchFailure('cimd jwks_uri is not a URL');
224
+ }
225
+ if (nested.protocol !== 'https:') {
226
+ throw new FetchFailure('cimd jwks_uri is not https');
227
+ }
228
+ return fetchKeySet(nested.toString());
229
+ }
230
+ throw new FetchFailure('cimd document has no jwks or jwks_uri');
231
+ }
232
+
233
+ // getKeys caches by resolved URL, so the three discovery types share one cache
234
+ // without colliding.
235
+ async function getKeys(url, type) {
160
236
  const now = Date.now();
161
- const cached = directoryCache.get(directoryUrl);
237
+ const cached = directoryCache.get(url);
162
238
  if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
163
239
  return cached.keys;
164
240
  }
165
- const keys = await fetchDirectory(directoryUrl);
166
- directoryCache.set(directoryUrl, { keys, fetchedAt: now });
241
+ const keys = type === DISCOVERY_CIMD ? await fetchViaCIMD(url) : await fetchKeySet(url);
242
+ directoryCache.set(url, { keys, fetchedAt: now });
167
243
  return keys;
168
244
  }
169
245
 
@@ -173,11 +249,11 @@ async function getDirectory(directoryUrl) {
173
249
  // lacks the key throws a FetchFailure — treated as fail-open, matching the Go
174
250
  // engine, which counts only a crypto failure as forgery.
175
251
  async function resolveKey(signatureAgentHeader, keyid) {
176
- const origin = directoryOriginFromHeader(signatureAgentHeader);
177
- if (!origin) {
178
- throw new FetchFailure('no resolvable Signature-Agent directory');
252
+ const source = keySourceFromHeader(signatureAgentHeader);
253
+ if (!source) {
254
+ throw new FetchFailure('no resolvable Signature-Agent key source');
179
255
  }
180
- const keys = await getDirectory(origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY);
256
+ const keys = await getKeys(source.url, source.type);
181
257
  for (const jwk of keys) {
182
258
  let thumbprint;
183
259
  try {
@@ -194,31 +270,63 @@ async function resolveKey(signatureAgentHeader, keyid) {
194
270
 
195
271
  // --- header + request helpers -------------------------------------------------
196
272
 
197
- // unwrapSfString extracts the value from a Signature-Agent header, which is an
198
- // RFC 8941 String — bare (`"https://a"`) or a dictionary member
199
- // (`sig1="https://a"`) as some signers emit.
200
- function unwrapSfString(raw) {
201
- if (!raw) return '';
273
+ // parseSignatureAgent reads the first member of the Signature-Agent Structured
274
+ // Fields Dictionary, returning its URL and discovery type. Accepts, in order of
275
+ // how signers actually emit them:
276
+ //
277
+ // sig1="https://a.test/jwks.json";type=jwks_uri protocol-00, typed
278
+ // sig1="https://a.test" protocol-00, default type
279
+ // "https://a.test" pre-00 bare String
280
+ //
281
+ // The previous implementation anchored on a closing quote at end-of-string, so
282
+ // any trailing parameter fell through to the raw header text and failed to
283
+ // parse as a URL. That silently cost the *verified* verdict.
284
+ function parseSignatureAgent(raw) {
285
+ if (!raw) return null;
202
286
  const s = String(raw).trim();
203
- const m = s.match(/^(?:[A-Za-z0-9_-]+=)?"(.*)"$/);
204
- return (m ? m[1] : s).trim();
287
+ if (!s) return null;
288
+ // Optional `key=` prefix, a quoted SF String, then optional ;params up to the
289
+ // next dictionary member.
290
+ const m = s.match(/^(?:[A-Za-z0-9_*-]+\s*=\s*)?"([^"]*)"((?:\s*;[^,]*)?)/);
291
+ if (!m) return null;
292
+ const url = m[1].trim();
293
+ if (!url) return null;
294
+ const typeMatch = (m[2] || '').match(/;\s*type\s*=\s*"?([A-Za-z0-9_-]+)"?/i);
295
+ const type = typeMatch ? typeMatch[1].toLowerCase() : DISCOVERY_DIRECTORY;
296
+ return { url, type };
205
297
  }
206
298
 
207
299
  function displayAgent(raw) {
208
- return unwrapSfString(raw) || 'unknown';
300
+ const parsed = parseSignatureAgent(raw);
301
+ return (parsed && parsed.url) || 'unknown';
209
302
  }
210
303
 
211
- function directoryOriginFromHeader(raw) {
212
- const val = unwrapSfString(raw);
213
- if (!val) return null;
304
+ // keySourceFromHeader resolves the header to the concrete https URL keys are
305
+ // fetched from, plus the discovery type that says how to interpret it. Returns
306
+ // null for anything unusable — a non-https URL, or a discovery type this build
307
+ // does not implement (guessing would be worse than failing open).
308
+ function keySourceFromHeader(raw) {
309
+ const parsed = parseSignatureAgent(raw);
310
+ if (!parsed) return null;
214
311
  let u;
215
312
  try {
216
- u = new URL(val);
313
+ u = new URL(parsed.url);
217
314
  } catch {
218
315
  return null;
219
316
  }
220
317
  if (u.protocol !== 'https:') return null;
221
- return u.origin;
318
+
319
+ switch (parsed.type) {
320
+ case DISCOVERY_DIRECTORY:
321
+ // The member names the signer's origin; keys live at the well-known path.
322
+ return { url: u.origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY, type: DISCOVERY_DIRECTORY };
323
+ case DISCOVERY_JWKS_URI:
324
+ case DISCOVERY_CIMD:
325
+ // The member names the document itself — path and query are significant.
326
+ return { url: u.toString(), type: parsed.type };
327
+ default:
328
+ return null;
329
+ }
222
330
  }
223
331
 
224
332
  function forwardedScheme(req) {
@@ -298,6 +406,7 @@ async function checkWebBotAuth(req) {
298
406
  if (!sigAgentRaw) {
299
407
  throw new FetchFailure('no Signature-Agent to resolve key');
300
408
  }
409
+ assertWebBotAuthParams(params);
301
410
  const jwk = await resolveKey(sigAgentRaw, params.keyid);
302
411
  resolvedKeyId = params.keyid;
303
412
  resolvedAlg = jwk.alg || (jwk.crv === 'Ed25519' ? 'ed25519' : '');
@@ -323,6 +432,51 @@ async function checkWebBotAuth(req) {
323
432
  }
324
433
  }
325
434
 
435
+ // toEpochSeconds normalizes a signature timestamp to epoch seconds. RFC 9421
436
+ // puts integer seconds on the wire, but web-bot-auth hands the verifier callback
437
+ // `Date` objects — so a naive Number() yields milliseconds and any duration
438
+ // comparison is off by 1000×. Accepts Date, seconds, or milliseconds.
439
+ function toEpochSeconds(value) {
440
+ if (value instanceof Date) return Math.floor(value.getTime() / 1000);
441
+ const n = Number(value);
442
+ if (!Number.isFinite(n)) return NaN;
443
+ // Epoch seconds are ~1.8e9 today; epoch milliseconds ~1.8e12.
444
+ return n > 1e11 ? Math.floor(n / 1000) : n;
445
+ }
446
+
447
+ // assertWebBotAuthParams enforces the protocol-00 signature-parameter rules that
448
+ // the underlying library does not.
449
+ //
450
+ // web-bot-auth's own verify() already rejects a wrong `tag`, a `created` in the
451
+ // future, an expired `expires`, and a missing `keyid`. The tag check below is
452
+ // therefore belt-and-braces — kept because it is free and because it documents a
453
+ // MUST that our verdict depends on. What is genuinely additive is the lifetime
454
+ // ceiling: nothing upstream bounds how far out `expires` may sit, and a
455
+ // long-lived signature is a long-lived replay token.
456
+ //
457
+ // Throwing FetchFailure means "not a verifiable web-bot-auth signature", which
458
+ // the caller renders as presence-only — never as forgery, since neither a wrong
459
+ // tag nor a generous TTL is cryptographic proof that anyone spoofed anything.
460
+ function assertWebBotAuthParams(params) {
461
+ const p = params || {};
462
+
463
+ if (p.tag !== REQUIRED_TAG) {
464
+ throw new FetchFailure(`signature tag is not ${REQUIRED_TAG}`);
465
+ }
466
+
467
+ // `expires` is what bounds replay; a signature without one is unbounded.
468
+ const expires = toEpochSeconds(p.expires);
469
+ if (!Number.isFinite(expires)) {
470
+ throw new FetchFailure('signature has no expires parameter');
471
+ }
472
+ const created = Number.isFinite(toEpochSeconds(p.created))
473
+ ? toEpochSeconds(p.created)
474
+ : Math.floor(Date.now() / 1000);
475
+ if (expires - created > MAX_SIGNATURE_LIFETIME_S) {
476
+ throw new FetchFailure('signature lifetime exceeds 24h');
477
+ }
478
+ }
479
+
326
480
  // withTimeout bounds the whole verification, a backstop over the per-fetch
327
481
  // timeout so a stalled directory can never hold the scoring path open.
328
482
  function withTimeout(promise, ms) {
@@ -346,19 +500,24 @@ module.exports = {
346
500
  // Exported for unit tests only.
347
501
  _internal: {
348
502
  isPublicUnicast,
349
- directoryOriginFromHeader,
503
+ parseSignatureAgent,
504
+ keySourceFromHeader,
505
+ assertWebBotAuthParams,
350
506
  displayAgent,
351
507
  verifiedDetection,
352
508
  forgedDetection,
353
509
  presenceDetection,
354
- // Seeds the directory cache so an end-to-end verify can exercise resolveKey
510
+ REQUIRED_TAG,
511
+ MAX_SIGNATURE_LIFETIME_S,
512
+ // Seeds the key cache so an end-to-end verify can exercise resolveKey
355
513
  // without a network fetch (the SSRF guard refuses the loopback address a
356
514
  // local test server would listen on). Test-only.
357
- seedDirectoryCache(origin, keys) {
358
- directoryCache.set(origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY, {
359
- keys,
360
- fetchedAt: Date.now(),
361
- });
515
+ //
516
+ // Seeds by *origin* for the default directory discovery. Pass a full URL as
517
+ // `exactUrl` to seed a jwks_uri/cimd source instead.
518
+ seedDirectoryCache(origin, keys, exactUrl) {
519
+ const url = exactUrl || origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY;
520
+ directoryCache.set(url, { keys, fetchedAt: Date.now() });
362
521
  },
363
522
  },
364
523
  };