@webdecoy/fcaptcha 1.16.0 → 1.17.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/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.17.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,8 @@
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"
16
17
  },
17
18
  "dependencies": {
18
19
  "cors": "^2.8.5",
package/server.js CHANGED
@@ -11,6 +11,7 @@ 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');
14
15
 
15
16
  const app = express();
16
17
  app.use(cors());
@@ -24,6 +25,12 @@ const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames();
24
25
  // the TLS-fingerprint headers. See clientip.js.
25
26
  const PROXY_TRUST = ProxyTrust.fromEnv();
26
27
 
28
+ // siteKey is client-supplied and validated against no registry, yet it is the
29
+ // first component of every rate-limit, fingerprint and challenge partition key.
30
+ // SITE_KEYS bounds how many distinct values one IP may allocate state for. See
31
+ // limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist.
32
+ const SITE_KEYS = SiteKeyGuard.fromEnv();
33
+
27
34
  // Express's own `trust proxy` would re-derive req.ip from the same headers on
28
35
  // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively.
29
36
  app.set('trust proxy', false);
@@ -85,8 +92,8 @@ if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
85
92
 
86
93
  // PoW Challenge Store
87
94
  const powChallengeStore = {
88
- challenges: new Map(),
89
- usedSolutions: new Set(),
95
+ challenges: new BoundedMap(),
96
+ usedSolutions: new BoundedSet(),
90
97
 
91
98
  // Generate a new challenge
92
99
  generate(siteKey, ip, difficulty = 4) {
@@ -177,20 +184,15 @@ const powChallengeStore = {
177
184
 
178
185
  _cleanup() {
179
186
  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
- }
187
+ this.challenges.prune((challenge) => now <= challenge.expiresAt);
188
+ // usedSolutions is a BoundedSet: it evicts its own oldest entries as it
189
+ // fills. The previous code cleared the whole replay guard once it passed a
190
+ // threshold, which an attacker could force in order to replay a solution.
189
191
  }
190
192
  };
191
193
 
192
194
  const rateLimiter = {
193
- requests: new Map(),
195
+ requests: new BoundedMap(),
194
196
 
195
197
  check(key, windowSeconds = 60, maxRequests = 10) {
196
198
  const now = Date.now();
@@ -211,8 +213,8 @@ const rateLimiter = {
211
213
  };
212
214
 
213
215
  const fingerprintStore = {
214
- fingerprints: new Map(),
215
- ipFingerprints: new Map(),
216
+ fingerprints: new BoundedMap(),
217
+ ipFingerprints: new BoundedMap(),
216
218
 
217
219
  record(fp, ip, siteKey) {
218
220
  const key = `${siteKey}:${fp}`;
@@ -242,7 +244,7 @@ const fingerprintStore = {
242
244
 
243
245
  // Token Store - prevents token replay attacks
244
246
  const tokenStore = {
245
- usedTokens: new Set(),
247
+ usedTokens: new BoundedSet(),
246
248
 
247
249
  // Mark a token as used (returns false if already used)
248
250
  markUsed(tokenSig) {
@@ -261,11 +263,9 @@ const tokenStore = {
261
263
  },
262
264
 
263
265
  _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
- }
266
+ // In production with Redis, use TTL instead. In memory usedTokens is a
267
+ // BoundedSet, so it evicts its oldest entries rather than clearing wholesale
268
+ // — clearing would let an attacker who forced the threshold replay a token.
269
269
  }
270
270
  };
271
271
 
@@ -1260,8 +1260,10 @@ function collectHeaders(req) {
1260
1260
  }
1261
1261
 
1262
1262
  app.post('/api/verify', async (req, res) => {
1263
- const { siteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1263
+ const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming } = req.body;
1264
1264
  const ip = PROXY_TRUST.clientIP(req);
1265
+ // Bound the state an unvalidated siteKey can allocate (limits.js).
1266
+ const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
1265
1267
  const userAgent = req.headers['user-agent'] || '';
1266
1268
  // Only honoured from a trusted proxy: a client that can state its own TLS
1267
1269
  // fingerprint would just claim a stock Chrome one.
@@ -1279,8 +1281,9 @@ app.post('/api/verify', async (req, res) => {
1279
1281
  });
1280
1282
 
1281
1283
  app.post('/api/score', async (req, res) => {
1282
- const { siteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1284
+ const { siteKey: rawSiteKey, signals, action, powSolution, signalsJson, powTiming } = req.body;
1283
1285
  const ip = PROXY_TRUST.clientIP(req);
1286
+ const siteKey = SITE_KEYS.normalize(rawSiteKey, ip);
1284
1287
  const userAgent = req.headers['user-agent'] || '';
1285
1288
  const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null;
1286
1289
 
@@ -1314,9 +1317,8 @@ app.post('/api/token/verify', (req, res) => {
1314
1317
 
1315
1318
  // PoW Challenge endpoint - client fetches this on page load
1316
1319
  app.get('/api/pow/challenge', (req, res) => {
1317
- const siteKey = req.query.siteKey || 'default';
1318
-
1319
1320
  const ip = PROXY_TRUST.clientIP(req);
1321
+ const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip);
1320
1322
 
1321
1323
  // Difficulty scaling based on IP reputation
1322
1324
  let difficulty = 4; // Default: ~100-500ms on average hardware
@@ -1365,4 +1367,5 @@ app.get('/api/challenge', (req, res) => {
1365
1367
  app.listen(PORT, () => {
1366
1368
  console.log(`FCaptcha server running on port ${PORT}`);
1367
1369
  console.log(`Trusted proxies: ${PROXY_TRUST.describe()}`);
1370
+ console.log(`Site keys: ${SITE_KEYS.describe()}`);
1368
1371
  });
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
  };