@webdecoy/fcaptcha 1.13.0 → 1.15.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
@@ -131,10 +131,16 @@ const DECLARED_AI_AGENT_PATTERNS = [
131
131
  /\bYouBot\b/i
132
132
  ];
133
133
 
134
- // checkDeclaredAIAgent flags self-identifying AI agents via the User-Agent and
135
- // detects Web Bot Auth signed requests (RFC 9421 HTTP Message Signatures). Both
136
- // are high-confidence identifications surfaced as the declared_ai category so
137
- // callers can apply an allow/block policy instead of relying on a hard score.
134
+ // checkDeclaredAIAgent flags self-identifying AI agents via the User-Agent as
135
+ // the declared_ai category, so callers can apply an allow/block policy instead
136
+ // of relying on a hard score.
137
+ //
138
+ // Web Bot Auth signed requests (RFC 9421 HTTP Message Signatures) used to be
139
+ // detected here by header presence alone. That moved to webbotauth.js, which
140
+ // cryptographically verifies the signature against the agent's published key
141
+ // directory — a presence check can't tell a real signer from three spoofed
142
+ // headers. Verification needs the raw request, so it runs in the route handler;
143
+ // the headers argument is retained for signature parity with the Go server.
138
144
  function checkDeclaredAIAgent(userAgent, headers = {}) {
139
145
  const detections = [];
140
146
  const ua = userAgent || '';
@@ -153,20 +159,6 @@ function checkDeclaredAIAgent(userAgent, headers = {}) {
153
159
  }
154
160
  }
155
161
 
156
- // Web Bot Auth: presence of HTTP Message Signature headers identifies a
157
- // (claimed) verified agent. v1 detects/identifies only; signature verification
158
- // against the agent's published JWKS is a follow-up before trusting it.
159
- const h = headers || {};
160
- if (h['signature'] && h['signature-input'] && h['signature-agent']) {
161
- detections.push({
162
- category: 'declared_ai',
163
- score: 0.4,
164
- confidence: 0.95,
165
- reason: `Signed agent request (Web Bot Auth): ${h['signature-agent']}`,
166
- details: { signatureAgent: h['signature-agent'], verified: false }
167
- });
168
- }
169
-
170
162
  return detections;
171
163
  }
172
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webdecoy/fcaptcha",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -10,12 +10,15 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "node server.js",
13
- "dev": "node --watch server.js"
13
+ "dev": "node --watch server.js",
14
+ "test:webbotauth": "node webbotauth.test.js"
14
15
  },
15
16
  "dependencies": {
16
- "express": "^4.18.2",
17
17
  "cors": "^2.8.5",
18
- "redis": "^4.6.12"
18
+ "express": "^4.18.0",
19
+ "ipaddr.js": "^2.4.0",
20
+ "redis": "^4.6.12",
21
+ "web-bot-auth": "^0.1.3"
19
22
  },
20
23
  "peerDependencies": {
21
24
  "express": "^4.18.0"
package/server.js CHANGED
@@ -9,6 +9,7 @@ const cors = require('cors');
9
9
  const crypto = require('crypto');
10
10
  const path = require('path');
11
11
  const detection = require('./detection');
12
+ const webbotauth = require('./webbotauth');
12
13
 
13
14
  const app = express();
14
15
  app.use(cors());
@@ -495,6 +496,39 @@ function detectHeadless(signals, userAgent) {
495
496
  return detections;
496
497
  }
497
498
 
499
+ // Flags anti-detection patch traces collected by the client. FALSE-POSITIVE-
500
+ // SAFE: a genuine browser never produces these (internal contradictions /
501
+ // native-function tampering, not environment-shape heuristics that would
502
+ // misfire on real Linux/VPN users). Targets stealth agents (e.g. Manus AI)
503
+ // driving real-but-patched Chromium. Only the two FP-safe signals are scored;
504
+ // the client also collects privacy-extension-ambiguous artifacts (patched_*)
505
+ // for observability that are intentionally NOT scored. Keep in sync with Go/Py.
506
+ function detectStealthArtifacts(signals) {
507
+ const detections = [];
508
+ const env = signals.environmental || {};
509
+
510
+ // Function.prototype.toString proxied — the signature move of stealth
511
+ // frameworks (used to make their other native overrides look untouched).
512
+ const artifactSignals = (env.stealthArtifacts && env.stealthArtifacts.signals) || [];
513
+ if (artifactSignals.includes('tostring_proxied')) {
514
+ detections.push({
515
+ category: 'headless', score: 0.9, confidence: 0.85,
516
+ reason: 'Function.prototype.toString is proxied (stealth automation patch)'
517
+ });
518
+ }
519
+
520
+ // Notification.permission === 'denied' while the Permissions API reports
521
+ // 'prompt': a state a real browser cannot reach (classic headless tell).
522
+ if (env.permissionProbe && env.permissionProbe.contradiction === true) {
523
+ detections.push({
524
+ category: 'headless', score: 0.85, confidence: 0.85,
525
+ reason: 'Notification permission contradicts Permissions API (headless/stealth tell)'
526
+ });
527
+ }
528
+
529
+ return detections;
530
+ }
531
+
498
532
  function detectAutomation(signals) {
499
533
  const detections = [];
500
534
  const env = signals.environmental || {};
@@ -1011,8 +1045,24 @@ function verifyToken(token, ip = null) {
1011
1045
  }
1012
1046
  }
1013
1047
 
1014
- function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null) {
1015
- const detections = [];
1048
+ // verifyWebBotAuth runs Web Bot Auth verification and never throws into the
1049
+ // request path: any unexpected error degrades to no detection (fail open), so a
1050
+ // verification bug can't turn a scoring request into a 500.
1051
+ async function verifyWebBotAuth(req) {
1052
+ try {
1053
+ return await webbotauth.checkWebBotAuth(req);
1054
+ } catch (e) {
1055
+ console.error('[webbotauth] verification error:', e && e.message);
1056
+ return [];
1057
+ }
1058
+ }
1059
+
1060
+ // preDetections are detections computed by the caller (the route handler) that
1061
+ // require the raw request — currently Web Bot Auth signature verification, which
1062
+ // needs the accurately-reconstructed signed request. They are seeded into the
1063
+ // detection set so they participate in scoring like any other detection.
1064
+ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash = null, powSolution = null, signalsJson = null, powTiming = null, preDetections = []) {
1065
+ const detections = Array.isArray(preDetections) ? [...preDetections] : [];
1016
1066
 
1017
1067
  // Verify signal commitment (signalsJson hash must match powSolution.signalsHash)
1018
1068
  const clientSignalsHash = powSolution?.signalsHash || null;
@@ -1098,6 +1148,7 @@ function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja3Hash
1098
1148
  detections.push(
1099
1149
  ...detectVisionAI(signals),
1100
1150
  ...detectHeadless(signals, userAgent),
1151
+ ...detectStealthArtifacts(signals),
1101
1152
  ...detectAutomation(signals),
1102
1153
  ...detectCDP(signals),
1103
1154
  ...detectBehavioral(signals),
@@ -1184,7 +1235,7 @@ app.get('/health', (req, res) => {
1184
1235
  res.json({ status: 'ok' });
1185
1236
  });
1186
1237
 
1187
- app.post('/api/verify', (req, res) => {
1238
+ app.post('/api/verify', async (req, res) => {
1188
1239
  const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1189
1240
  let ip = req.headers['x-real-ip'] || '';
1190
1241
  if (!ip) {
@@ -1204,12 +1255,15 @@ app.post('/api/verify', (req, res) => {
1204
1255
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
1205
1256
  }
1206
1257
 
1207
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1258
+ // Web Bot Auth verification needs the raw request; its verdict is scored.
1259
+ const webBotAuth = await verifyWebBotAuth(req);
1260
+
1261
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
1208
1262
  logVerdict('verify', siteKey, result);
1209
1263
  res.json(result);
1210
1264
  });
1211
1265
 
1212
- app.post('/api/score', (req, res) => {
1266
+ app.post('/api/score', async (req, res) => {
1213
1267
  const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1214
1268
  let ip = req.headers['x-real-ip'] || '';
1215
1269
  if (!ip) {
@@ -1228,7 +1282,10 @@ app.post('/api/score', (req, res) => {
1228
1282
  headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
1229
1283
  }
1230
1284
 
1231
- const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming);
1285
+ // Web Bot Auth verification needs the raw request; its verdict is scored.
1286
+ const webBotAuth = await verifyWebBotAuth(req);
1287
+
1288
+ const result = runVerification(signals, ip, siteKey, userAgent, headers, ja3Hash, powSolution, signalsJson, powTiming, webBotAuth);
1232
1289
  logVerdict('score', siteKey, result);
1233
1290
  res.json({
1234
1291
  success: result.success,
package/webbotauth.js ADDED
@@ -0,0 +1,364 @@
1
+ 'use strict';
2
+
3
+ // Web Bot Auth (RFC 9421 HTTP Message Signatures) verification for signed AI
4
+ // agents. Built on Cloudflare's web-bot-auth crypto primitives; mirrors the Go
5
+ // server's ScoringEngine.CheckWebBotAuth verdict model:
6
+ //
7
+ // - verified → declared_ai (verified:true, high confidence): a trustworthy
8
+ // signed identity (e.g. OpenAI Operator). A policy signal.
9
+ // - forged → bot (low confidence, contributory): the request claimed an
10
+ // agent identity and the signature failed cryptographic
11
+ // verification. Only a genuine crypto failure counts.
12
+ // - otherwise → fail open to a presence-only declared_ai signal identical to
13
+ // the pre-verification behavior. A directory we could not fetch
14
+ // (unreachable, blocked, timed out) is not proof of spoofing,
15
+ // so it must never be scored as one.
16
+ //
17
+ // The signer's key directory is named by the untrusted Signature-Agent header,
18
+ // so every fetch is treated as an SSRF vector: https-only, a DNS-lookup guard
19
+ // that refuses any non-global-unicast address (blocks loopback, private,
20
+ // link-local — including the 169.254.169.254 cloud metadata endpoint — CGNAT,
21
+ // ULA, and IPv4-mapped IPv6), a response-size cap, a short timeout, and no
22
+ // redirect following. Directories are cached with a TTL.
23
+ //
24
+ // The upstream package (cloudflare/web-bot-auth) is the reference implementation
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.
28
+
29
+ const https = require('https');
30
+ const dns = require('dns');
31
+ const { URL } = require('url');
32
+
33
+ const ipaddr = require('ipaddr.js');
34
+ const {
35
+ verify,
36
+ jwkToKeyID,
37
+ helpers,
38
+ HTTP_MESSAGE_SIGNATURES_DIRECTORY,
39
+ } = require('web-bot-auth');
40
+ const { verifierFromJWK } = require('web-bot-auth/crypto');
41
+
42
+ const VERIFY_TIMEOUT_MS = 3000;
43
+ const MAX_DIRECTORY_BYTES = 1 << 20; // 1 MiB, matches the Go client's cap
44
+ const DIRECTORY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
45
+
46
+ // The exact message web-bot-auth/crypto throws when crypto.subtle.verify()
47
+ // returns false. This — and only this — is affirmative evidence of a forged
48
+ // signature. Everything else (fetch failure, expiry, missing key) fails open.
49
+ const CRYPTO_FAILURE_MESSAGE = 'invalid signature';
50
+
51
+ // origin (https://host[:port]) → { keys: JsonWebKey[], fetchedAt: number }
52
+ const directoryCache = new Map();
53
+
54
+ // FetchFailure marks failures that are NOT cryptographic — the caller treats
55
+ // them as "could not verify" (fail open), never as a forged-signature signal.
56
+ class FetchFailure extends Error {}
57
+
58
+ // --- SSRF guard ---------------------------------------------------------------
59
+
60
+ // isPublicUnicast reports whether an IP literal is a globally-routable unicast
61
+ // address. ipaddr.process() un-maps IPv4-mapped IPv6 first, so an address like
62
+ // ::ffff:10.0.0.1 is classified by its embedded v4 range ('private'), not as a
63
+ // v6 unicast. Only 'unicast' (public) is allowed; every other range is refused.
64
+ function isPublicUnicast(ip) {
65
+ let addr;
66
+ try {
67
+ addr = ipaddr.process(ip);
68
+ } catch {
69
+ return false;
70
+ }
71
+ return addr.range() === 'unicast';
72
+ }
73
+
74
+ // guardedLookup resolves a hostname and hands the socket a vetted IP. Passing
75
+ // the concrete address (not the hostname) to connect closes the DNS-rebinding
76
+ // window: the socket connects to exactly the address we validated.
77
+ function guardedLookup(hostname, options, callback) {
78
+ const opts = typeof options === 'number' ? { family: options } : options || {};
79
+ dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
80
+ if (err) {
81
+ callback(err);
82
+ return;
83
+ }
84
+ const list = Array.isArray(addresses)
85
+ ? addresses
86
+ : [{ address: addresses, family: opts.family || 0 }];
87
+ for (const a of list) {
88
+ if (isPublicUnicast(a.address)) {
89
+ callback(null, a.address, a.family);
90
+ return;
91
+ }
92
+ }
93
+ callback(new FetchFailure(`refusing to connect to non-global address for ${hostname}`));
94
+ });
95
+ }
96
+
97
+ // --- directory fetching -------------------------------------------------------
98
+
99
+ function fetchDirectory(directoryUrl) {
100
+ return new Promise((resolve, reject) => {
101
+ let url;
102
+ try {
103
+ url = new URL(directoryUrl);
104
+ } catch {
105
+ reject(new FetchFailure('invalid directory URL'));
106
+ return;
107
+ }
108
+ if (url.protocol !== 'https:') {
109
+ reject(new FetchFailure('directory URL is not https'));
110
+ return;
111
+ }
112
+
113
+ const req = https.get(
114
+ directoryUrl,
115
+ {
116
+ lookup: guardedLookup,
117
+ timeout: VERIFY_TIMEOUT_MS,
118
+ headers: {
119
+ accept: 'application/http-message-signatures-directory+json, application/json',
120
+ },
121
+ },
122
+ (res) => {
123
+ // https.get does not follow redirects; anything but 200 is a failure.
124
+ if (res.statusCode !== 200) {
125
+ res.resume();
126
+ reject(new FetchFailure(`directory returned ${res.statusCode}`));
127
+ return;
128
+ }
129
+ let len = 0;
130
+ const chunks = [];
131
+ res.on('data', (chunk) => {
132
+ len += chunk.length;
133
+ if (len > MAX_DIRECTORY_BYTES) {
134
+ req.destroy(new FetchFailure('directory exceeds size cap'));
135
+ return;
136
+ }
137
+ chunks.push(chunk);
138
+ });
139
+ res.on('end', () => {
140
+ try {
141
+ const doc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
142
+ resolve(Array.isArray(doc.keys) ? doc.keys : []);
143
+ } catch {
144
+ reject(new FetchFailure('directory is not valid JSON'));
145
+ }
146
+ });
147
+ },
148
+ );
149
+
150
+ req.on('timeout', () => {
151
+ req.destroy(new FetchFailure('directory fetch timed out'));
152
+ });
153
+ req.on('error', (e) => {
154
+ reject(e instanceof FetchFailure ? e : new FetchFailure(`directory fetch: ${e.message}`));
155
+ });
156
+ });
157
+ }
158
+
159
+ async function getDirectory(directoryUrl) {
160
+ const now = Date.now();
161
+ const cached = directoryCache.get(directoryUrl);
162
+ if (cached && now - cached.fetchedAt < DIRECTORY_CACHE_TTL_MS) {
163
+ return cached.keys;
164
+ }
165
+ const keys = await fetchDirectory(directoryUrl);
166
+ directoryCache.set(directoryUrl, { keys, fetchedAt: now });
167
+ return keys;
168
+ }
169
+
170
+ // resolveKey fetches the directory named by the Signature-Agent header and
171
+ // returns the JWK whose RFC 7638 thumbprint matches keyid (the Web Bot Auth
172
+ // keyid IS the thumbprint, not the JWK's `kid`). A reachable directory that
173
+ // lacks the key throws a FetchFailure — treated as fail-open, matching the Go
174
+ // engine, which counts only a crypto failure as forgery.
175
+ async function resolveKey(signatureAgentHeader, keyid) {
176
+ const origin = directoryOriginFromHeader(signatureAgentHeader);
177
+ if (!origin) {
178
+ throw new FetchFailure('no resolvable Signature-Agent directory');
179
+ }
180
+ const keys = await getDirectory(origin + HTTP_MESSAGE_SIGNATURES_DIRECTORY);
181
+ for (const jwk of keys) {
182
+ let thumbprint;
183
+ try {
184
+ thumbprint = await jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE);
185
+ } catch {
186
+ continue; // unsupported/malformed key in the set — skip it
187
+ }
188
+ if (thumbprint === keyid) {
189
+ return jwk;
190
+ }
191
+ }
192
+ throw new FetchFailure('keyid not found in directory');
193
+ }
194
+
195
+ // --- header + request helpers -------------------------------------------------
196
+
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 '';
202
+ const s = String(raw).trim();
203
+ const m = s.match(/^(?:[A-Za-z0-9_-]+=)?"(.*)"$/);
204
+ return (m ? m[1] : s).trim();
205
+ }
206
+
207
+ function displayAgent(raw) {
208
+ return unwrapSfString(raw) || 'unknown';
209
+ }
210
+
211
+ function directoryOriginFromHeader(raw) {
212
+ const val = unwrapSfString(raw);
213
+ if (!val) return null;
214
+ let u;
215
+ try {
216
+ u = new URL(val);
217
+ } catch {
218
+ return null;
219
+ }
220
+ if (u.protocol !== 'https:') return null;
221
+ return u.origin;
222
+ }
223
+
224
+ function forwardedScheme(req) {
225
+ const xfp = req.headers && req.headers['x-forwarded-proto'];
226
+ if (xfp) return String(xfp).split(',')[0].trim().toLowerCase();
227
+ if (req.protocol) return req.protocol; // express sets this
228
+ return req.socket && req.socket.encrypted ? 'https' : 'http';
229
+ }
230
+
231
+ // --- detections (shape mirrors detection.js / the Go DetectionResult) ---------
232
+
233
+ function verifiedDetection(agent, keyId, algorithm) {
234
+ return {
235
+ category: 'declared_ai',
236
+ score: 0.5,
237
+ confidence: 0.99,
238
+ reason: `Verified AI agent (Web Bot Auth): ${agent}`,
239
+ details: { signatureAgent: agent, verified: true, keyId, algorithm },
240
+ };
241
+ }
242
+
243
+ function forgedDetection(agent, err) {
244
+ return {
245
+ category: 'bot',
246
+ score: 0.5,
247
+ confidence: 0.5,
248
+ reason: `Forged Web Bot Auth signature (crypto verification failed): ${agent}`,
249
+ details: { signatureAgent: agent, verified: false, error: err && err.message },
250
+ };
251
+ }
252
+
253
+ function presenceDetection(agent) {
254
+ return {
255
+ category: 'declared_ai',
256
+ score: 0.4,
257
+ confidence: 0.95,
258
+ reason: `Signed agent request (Web Bot Auth, unverified): ${agent}`,
259
+ details: { signatureAgent: agent, verified: false },
260
+ };
261
+ }
262
+
263
+ // --- entry point --------------------------------------------------------------
264
+
265
+ // checkWebBotAuth verifies a Web Bot Auth signed request and returns detections
266
+ // (empty when the request carries no signature). Async because verification may
267
+ // fetch the signer's key directory. Give it the Express request so the signed
268
+ // URL is reconstructed accurately — a mis-derived authority would make a genuine
269
+ // signature fail crypto verification and false-positive a real agent.
270
+ async function checkWebBotAuth(req) {
271
+ const headers = (req && req.headers) || {};
272
+ if (!headers.signature || !headers['signature-input']) {
273
+ return [];
274
+ }
275
+
276
+ const sigAgentRaw = headers['signature-agent'];
277
+ const agent = displayAgent(sigAgentRaw);
278
+
279
+ const host = headers.host;
280
+ if (!host) {
281
+ // Cannot reconstruct @authority → cannot verify. Fail open.
282
+ return [presenceDetection(agent)];
283
+ }
284
+ const path = req.originalUrl || req.url || '/';
285
+ const message = {
286
+ method: (req.method || 'POST').toUpperCase(),
287
+ url: `${forwardedScheme(req)}://${host}${path}`,
288
+ headers,
289
+ };
290
+
291
+ let cryptoFailed = false;
292
+ let resolvedKeyId = '';
293
+ let resolvedAlg = '';
294
+
295
+ try {
296
+ await withTimeout(
297
+ verify(message, async (data, signature, params) => {
298
+ if (!sigAgentRaw) {
299
+ throw new FetchFailure('no Signature-Agent to resolve key');
300
+ }
301
+ const jwk = await resolveKey(sigAgentRaw, params.keyid);
302
+ resolvedKeyId = params.keyid;
303
+ resolvedAlg = jwk.alg || (jwk.crv === 'Ed25519' ? 'ed25519' : '');
304
+ const verifyFn = await verifierFromJWK(jwk);
305
+ try {
306
+ await verifyFn(data, signature, params);
307
+ } catch (e) {
308
+ if (e && e.message === CRYPTO_FAILURE_MESSAGE) {
309
+ cryptoFailed = true;
310
+ }
311
+ throw e;
312
+ }
313
+ }),
314
+ VERIFY_TIMEOUT_MS,
315
+ );
316
+ return [verifiedDetection(agent, resolvedKeyId, resolvedAlg)];
317
+ } catch (e) {
318
+ if (cryptoFailed) {
319
+ return [forgedDetection(agent, e)];
320
+ }
321
+ // Fetch/structural/timeout/expiry failure: not proof of forgery. Fail open.
322
+ return [presenceDetection(agent)];
323
+ }
324
+ }
325
+
326
+ // withTimeout bounds the whole verification, a backstop over the per-fetch
327
+ // timeout so a stalled directory can never hold the scoring path open.
328
+ function withTimeout(promise, ms) {
329
+ return new Promise((resolve, reject) => {
330
+ const timer = setTimeout(() => reject(new FetchFailure('verification timed out')), ms);
331
+ promise.then(
332
+ (v) => {
333
+ clearTimeout(timer);
334
+ resolve(v);
335
+ },
336
+ (e) => {
337
+ clearTimeout(timer);
338
+ reject(e);
339
+ },
340
+ );
341
+ });
342
+ }
343
+
344
+ module.exports = {
345
+ checkWebBotAuth,
346
+ // Exported for unit tests only.
347
+ _internal: {
348
+ isPublicUnicast,
349
+ directoryOriginFromHeader,
350
+ displayAgent,
351
+ verifiedDetection,
352
+ forgedDetection,
353
+ presenceDetection,
354
+ // Seeds the directory cache so an end-to-end verify can exercise resolveKey
355
+ // without a network fetch (the SSRF guard refuses the loopback address a
356
+ // 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
+ });
362
+ },
363
+ },
364
+ };