@blamejs/core 0.9.49 → 0.10.2
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/CHANGELOG.md +952 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +78 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/cli.js +13 -0
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-graphql.js +37 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-regex.js +138 -1
- package/lib/guard-smtp-command.js +58 -3
- package/lib/guard-xml.js +39 -1
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/otel-export.js +13 -4
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -161,19 +161,44 @@ function memoryStore(opts) {
|
|
|
161
161
|
* `SELECT k, status_code, expires_at FROM <tableName>` —
|
|
162
162
|
* non-sealed columns are forensic-queryable without unsealing.
|
|
163
163
|
*
|
|
164
|
-
* **Defense-in-depth defaults
|
|
164
|
+
* **Defense-in-depth defaults — every option below ships on by default:**
|
|
165
165
|
*
|
|
166
|
-
* - `hashKeys: true` — operator-supplied keys are
|
|
167
|
-
* namespace-hashed via
|
|
168
|
-
* key)` before
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
166
|
+
* - `hashKeys: true` (since 0.9.15) — operator-supplied keys are
|
|
167
|
+
* sha3-512 namespace-hashed via
|
|
168
|
+
* `b.crypto.namespaceHash("idempotency-key", key)` before
|
|
169
|
+
* insert/lookup. The `k` column carries the hash, not the raw key.
|
|
170
|
+
* Operator keys often carry PII (order numbers, emails, vendor
|
|
171
|
+
* prefixes); the DB never sees them.
|
|
172
|
+
* - `seal: true` (since 0.9.15) — `headers` and `body` columns are
|
|
173
|
+
* sealed via `b.cryptoField.sealRow` (vault-managed key, AEAD
|
|
174
|
+
* envelope) so a DB dump leaks neither cached response bodies nor
|
|
175
|
+
* headers. Requires `b.vault.init(...)` to have run; falls back to
|
|
176
|
+
* plain-text with a one-shot audit warning when vault isn't ready,
|
|
177
|
+
* so test-fixture / boot-script callers still work.
|
|
178
|
+
* - `aad: true` (since 0.9.58 — CRYPTO-1) — sealed columns are bound
|
|
179
|
+
* via Additional Authenticated Data to (table, k, column,
|
|
180
|
+
* schemaVersion) so a DB-write attacker can't copy a sealed
|
|
181
|
+
* header/body cell from one row to another (which previously
|
|
182
|
+
* decrypted cleanly under plain `vault.seal`). Existing v0.9.15-
|
|
183
|
+
* v0.9.57 dbStore tables continue to read because unsealRow auto-
|
|
184
|
+
* detects the envelope shape; lazy re-seal on next `set()` upgrades
|
|
185
|
+
* each row to AAD form. Operators wanting a one-shot migration
|
|
186
|
+
* call `b.middleware.idempotencyKey.resealMigrate(store)`.
|
|
187
|
+
* - `fingerprintSeal: true` (since 0.9.58 — CRYPTO-4) — the request
|
|
188
|
+
* `fingerprint` column carries an HMAC under a vault-derived
|
|
189
|
+
* secret instead of a bare SHA3-256 of method+path+body. The
|
|
190
|
+
* compare path is constant-time so the column doubles as a
|
|
191
|
+
* mismatch oracle without offline-brute-force exposure.
|
|
192
|
+
* - `bodyFingerprintFallback: "deny"` (since 0.9.58 — SUBSTRATE-13) —
|
|
193
|
+
* when neither `bodyFingerprint` nor `req._rawBody`/`req.body` is
|
|
194
|
+
* populated for a body-bearing method, the middleware previously
|
|
195
|
+
* silently degraded the fingerprint to method+path. Set to
|
|
196
|
+
* `"deny"` (the new default) and the middleware refuses the
|
|
197
|
+
* request with HTTP 400 `idempotency/missing-body-fingerprint`
|
|
198
|
+
* instead. Operators with a documented "no body" use case set
|
|
199
|
+
* `bodyFingerprintFallback: "method-path-only"` to restore the
|
|
200
|
+
* pre-0.9.58 behavior — the audit chain still emits
|
|
201
|
+
* `idempotency.empty_body_fingerprint` so the misorder is visible.
|
|
177
202
|
*
|
|
178
203
|
* Lazily-expired: `get(key)` returns `null` for any row whose
|
|
179
204
|
* `expires_at` has passed. The cleanup is scoped by the observed
|
|
@@ -203,6 +228,8 @@ function memoryStore(opts) {
|
|
|
203
228
|
* init?: boolean, // default true — run CREATE TABLE IF NOT EXISTS at construction
|
|
204
229
|
* hashKeys?: boolean, // default true — store sha3-512 namespace-hash of the key, not the raw key
|
|
205
230
|
* seal?: boolean, // default true — seal headers + body via b.cryptoField when vault is ready
|
|
231
|
+
* aad?: boolean, // default true — AAD-bind seal to (table,k,column) so a DB-write attacker can't cross-row swap (CRYPTO-1)
|
|
232
|
+
* fingerprintSeal?: boolean, // default true — HMAC fingerprint under a vault-derived secret instead of bare sha3-256 (CRYPTO-4)
|
|
206
233
|
*
|
|
207
234
|
* @example
|
|
208
235
|
* // single-process daemon, framework's internal sqlite, both defaults on:
|
|
@@ -237,6 +264,15 @@ function dbStore(opts) {
|
|
|
237
264
|
var doInit = opts.init !== false;
|
|
238
265
|
var hashKeys = opts.hashKeys !== false;
|
|
239
266
|
var sealReq = opts.seal !== false;
|
|
267
|
+
// CRYPTO-1 — AAD-bind sealing to (table, k, column) by default.
|
|
268
|
+
// Forms a defense-in-depth pair with seal: cross-row swap fails
|
|
269
|
+
// Poly1305 even when the attacker controls the DB layer.
|
|
270
|
+
var aadOn = opts.aad !== false;
|
|
271
|
+
// CRYPTO-4 — HMAC the fingerprint under a vault-derived secret by
|
|
272
|
+
// default. Bare SHA3-256 of method+path+body is offline-brute-
|
|
273
|
+
// forceable for any DB-dump attacker; HMAC under a vault secret
|
|
274
|
+
// forces them to break the vault first.
|
|
275
|
+
var fpSealOn = opts.fingerprintSeal !== false;
|
|
240
276
|
var db = opts.db;
|
|
241
277
|
|
|
242
278
|
// Probe vault readiness with a sentinel seal. If vault.init() hasn't
|
|
@@ -247,6 +283,8 @@ function dbStore(opts) {
|
|
|
247
283
|
var sealEnabled = false;
|
|
248
284
|
if (sealReq) {
|
|
249
285
|
try {
|
|
286
|
+
// allow:seal-without-aad — vault-readiness probe; throwaway
|
|
287
|
+
// sentinel value, not row-bound data
|
|
250
288
|
vault.seal("__idempotency_seal_probe__");
|
|
251
289
|
sealEnabled = true;
|
|
252
290
|
} catch (_vaultErr) {
|
|
@@ -259,13 +297,42 @@ function dbStore(opts) {
|
|
|
259
297
|
|
|
260
298
|
// Register the table with cryptoField. registerTable is idempotent
|
|
261
299
|
// — subsequent dbStore() calls with the same tableName re-declare
|
|
262
|
-
// the same sealedFields and no-op.
|
|
300
|
+
// the same sealedFields and no-op. CRYPTO-1: when aad is on,
|
|
301
|
+
// (table, k, column) is threaded into the AEAD AAD so a DB-write
|
|
302
|
+
// attacker can't copy a sealed value between rows.
|
|
263
303
|
if (sealEnabled) {
|
|
264
304
|
cryptoField.registerTable(tableNameRaw, {
|
|
265
305
|
sealedFields: ["headers", "body"],
|
|
306
|
+
aad: aadOn,
|
|
307
|
+
rowIdField: "k",
|
|
308
|
+
schemaVersion: "1",
|
|
266
309
|
});
|
|
267
310
|
}
|
|
268
311
|
|
|
312
|
+
// CRYPTO-4 — derive a per-vault HMAC secret for fingerprint sealing.
|
|
313
|
+
// The vault root key is the trust root; without it the secret is
|
|
314
|
+
// unrecoverable. Lazy: only derived when fpSealOn is enabled AND the
|
|
315
|
+
// vault is ready, so test fixtures that haven't initialized the
|
|
316
|
+
// vault still construct a dbStore (the fingerprint then falls back
|
|
317
|
+
// to bare sha3-256 with a single audit warning).
|
|
318
|
+
var fpHmacSecret = null;
|
|
319
|
+
if (fpSealOn) {
|
|
320
|
+
try {
|
|
321
|
+
// Use vault.aad.buildContextAad as a stable derivation input;
|
|
322
|
+
// the derivedHashSalt is per-deployment so the same dbStore
|
|
323
|
+
// instance across hosts converges on the same HMAC key.
|
|
324
|
+
var fpDeriveInput = "idempotency.fingerprint:" + tableNameRaw + ":" +
|
|
325
|
+
vault.getDerivedHashSalt().toString("hex");
|
|
326
|
+
fpHmacSecret = bCrypto.kdf(Buffer.from(fpDeriveInput, "utf8"), C.BYTES.bytes(32));
|
|
327
|
+
} catch (_fpErr) {
|
|
328
|
+
_emitAudit("idempotency.fingerprint_seal_skipped_no_vault",
|
|
329
|
+
{ tableName: tableNameRaw,
|
|
330
|
+
reason: "vault.getDerivedHashSalt() unavailable; fingerprint falls back to plain sha3-256" },
|
|
331
|
+
"warning");
|
|
332
|
+
fpHmacSecret = null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
269
336
|
if (doInit) {
|
|
270
337
|
db.prepare("CREATE TABLE IF NOT EXISTS " + qTable + " (" +
|
|
271
338
|
"k TEXT PRIMARY KEY, " +
|
|
@@ -279,9 +346,12 @@ function dbStore(opts) {
|
|
|
279
346
|
}
|
|
280
347
|
|
|
281
348
|
// Prepared statements. status_code + expires_at stay non-sealed
|
|
282
|
-
// so audit/forensic SELECTs don't have to unseal-everything.
|
|
349
|
+
// so audit/forensic SELECTs don't have to unseal-everything. The
|
|
350
|
+
// `k` column is selected even when not strictly needed for read
|
|
351
|
+
// because cryptoField.unsealRow uses it as the rowId in AAD when
|
|
352
|
+
// the table is AAD-bound (CRYPTO-1).
|
|
283
353
|
var stmtGet = db.prepare(
|
|
284
|
-
"SELECT fingerprint, status_code, headers, body, expires_at FROM " +
|
|
354
|
+
"SELECT k, fingerprint, status_code, headers, body, expires_at FROM " +
|
|
285
355
|
qTable + " WHERE k = ?");
|
|
286
356
|
var stmtUpsert = db.prepare(
|
|
287
357
|
"INSERT INTO " + qTable +
|
|
@@ -302,6 +372,13 @@ function dbStore(opts) {
|
|
|
302
372
|
return bCrypto.namespaceHash("idempotency-key", rawKey);
|
|
303
373
|
}
|
|
304
374
|
|
|
375
|
+
// CRYPTO-4 — emit / compare HMAC-shape fingerprints. The store
|
|
376
|
+
// round-trips the column as plain text (no transformation per-get);
|
|
377
|
+
// sealing happens at MINT time (when the middleware builds the
|
|
378
|
+
// fingerprint and hands it to set()). The store's responsibility is
|
|
379
|
+
// pass-through. The middleware's `_fingerprintRequest` consults
|
|
380
|
+
// `store.fingerprintMode` to know whether to HMAC.
|
|
381
|
+
|
|
305
382
|
return {
|
|
306
383
|
get: function (rawKey) {
|
|
307
384
|
var row = stmtGet.get(_k(rawKey));
|
|
@@ -314,16 +391,26 @@ function dbStore(opts) {
|
|
|
314
391
|
if (sealEnabled) {
|
|
315
392
|
try { liveRow = cryptoField.unsealRow(tableNameRaw, row); }
|
|
316
393
|
catch (_unsealErr) {
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
394
|
+
// CRYPTO-13 — decryption failure used to delete the row,
|
|
395
|
+
// which let an attacker probe key presence via a "tamper +
|
|
396
|
+
// observe subsequent SELECT" oracle. The fix: emit audit,
|
|
397
|
+
// return null, do NOT delete. TTL sweeps stale rows out
|
|
398
|
+
// bounded by ttlMs; an out-of-band sweeper handles the
|
|
399
|
+
// corrupt-but-not-yet-expired case.
|
|
400
|
+
_emitAudit("idempotency.unseal_failed",
|
|
401
|
+
{ tableName: tableNameRaw,
|
|
402
|
+
keyHash: _hashKey(rawKey),
|
|
403
|
+
reason: String(_unsealErr && _unsealErr.message || _unsealErr) },
|
|
404
|
+
"warning");
|
|
321
405
|
return null;
|
|
322
406
|
}
|
|
323
407
|
}
|
|
324
408
|
var headersObj;
|
|
325
409
|
try {
|
|
326
|
-
|
|
410
|
+
// CRYPTO-22 — route through C.BYTES.mib(4); raw `4 * 1024 * 1024`
|
|
411
|
+
// was a drift smell flagged by codebase-patterns. 4 MiB ceiling
|
|
412
|
+
// unchanged.
|
|
413
|
+
headersObj = safeJson.parse(liveRow.headers, { maxBytes: C.BYTES.mib(4) });
|
|
327
414
|
} catch (_jsonErr) {
|
|
328
415
|
// Parse failure has two distinct causes:
|
|
329
416
|
// 1. Genuine corruption (truncated row, encoding mishap) — drop.
|
|
@@ -336,7 +423,8 @@ function dbStore(opts) {
|
|
|
336
423
|
// execution. Treat as miss + LEAVE the row in place.
|
|
337
424
|
// Per Codex P1 on PR #45.
|
|
338
425
|
var lookedSealed = typeof liveRow.headers === "string" &&
|
|
339
|
-
liveRow.headers.indexOf("vault:") === 0
|
|
426
|
+
(liveRow.headers.indexOf("vault:") === 0 ||
|
|
427
|
+
liveRow.headers.indexOf("vault.aad:") === 0);
|
|
340
428
|
if (!lookedSealed) {
|
|
341
429
|
stmtDeleteStale.run(_k(rawKey), row.expires_at);
|
|
342
430
|
}
|
|
@@ -368,9 +456,56 @@ function dbStore(opts) {
|
|
|
368
456
|
delete: function (rawKey) {
|
|
369
457
|
stmtDelete.run(_k(rawKey));
|
|
370
458
|
},
|
|
459
|
+
// CRYPTO-4 — the middleware consults this hook to HMAC the
|
|
460
|
+
// method+path+body digest under a vault-derived secret before
|
|
461
|
+
// insert + compare. Returns null when fpSeal is disabled OR the
|
|
462
|
+
// vault wasn't ready at construction; the middleware then falls
|
|
463
|
+
// back to bare sha3-256.
|
|
464
|
+
fingerprintHmac: function (preimageBytes) {
|
|
465
|
+
if (!fpSealOn || !fpHmacSecret) return null;
|
|
466
|
+
return nodeCrypto.createHmac("sha3-256", fpHmacSecret)
|
|
467
|
+
.update(preimageBytes).digest("hex");
|
|
468
|
+
},
|
|
469
|
+
// CRYPTO-1 — operator helper: walk the table and reseal every row
|
|
470
|
+
// under the AAD form. Existing v0.9.15-v0.9.57 rows continue to
|
|
471
|
+
// read on a per-row basis (unsealRow auto-detects shape), but
|
|
472
|
+
// operators wanting an explicit migration step call this once.
|
|
473
|
+
// Idempotent; rows already AAD-sealed pass through unchanged.
|
|
474
|
+
resealMigrate: function () {
|
|
475
|
+
if (!sealEnabled || !aadOn) {
|
|
476
|
+
return { migrated: 0, skipped: 0, reason: "aad-or-seal-disabled" };
|
|
477
|
+
}
|
|
478
|
+
var migrated = 0;
|
|
479
|
+
var skipped = 0;
|
|
480
|
+
var rows = db.prepare("SELECT k, fingerprint, status_code, headers, body, expires_at FROM " +
|
|
481
|
+
qTable).all();
|
|
482
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
483
|
+
var r = rows[i];
|
|
484
|
+
// If headers/body already start with vault.aad: this row is
|
|
485
|
+
// already migrated.
|
|
486
|
+
var alreadyAad = typeof r.headers === "string" &&
|
|
487
|
+
r.headers.indexOf("vault.aad:") === 0 &&
|
|
488
|
+
typeof r.body === "string" &&
|
|
489
|
+
r.body.indexOf("vault.aad:") === 0;
|
|
490
|
+
if (alreadyAad) { skipped += 1; continue; }
|
|
491
|
+
var unsealed;
|
|
492
|
+
try { unsealed = cryptoField.unsealRow(tableNameRaw, r); }
|
|
493
|
+
catch (_e) { skipped += 1; continue; }
|
|
494
|
+
var resealed = cryptoField.sealRow(tableNameRaw, unsealed);
|
|
495
|
+
stmtUpsert.run(
|
|
496
|
+
resealed.k, resealed.fingerprint, resealed.status_code,
|
|
497
|
+
resealed.headers, resealed.body, resealed.expires_at);
|
|
498
|
+
migrated += 1;
|
|
499
|
+
}
|
|
500
|
+
_emitAudit("idempotency.reseal_migrate_complete",
|
|
501
|
+
{ tableName: tableNameRaw, migrated: migrated, skipped: skipped });
|
|
502
|
+
return { migrated: migrated, skipped: skipped, reason: null };
|
|
503
|
+
},
|
|
371
504
|
_tableName: tableNameRaw,
|
|
372
505
|
_hashKeys: hashKeys,
|
|
373
506
|
_sealEnabled: sealEnabled,
|
|
507
|
+
_aadOn: aadOn,
|
|
508
|
+
_fpSealOn: fpSealOn && fpHmacSecret !== null,
|
|
374
509
|
};
|
|
375
510
|
}
|
|
376
511
|
|
|
@@ -387,21 +522,27 @@ function _validateStore(store, where) {
|
|
|
387
522
|
}
|
|
388
523
|
}
|
|
389
524
|
|
|
390
|
-
function _fingerprintRequest(req, bodyBytes) {
|
|
391
|
-
// Fingerprint = method + path + body
|
|
392
|
-
// a key+body mismatch is a client-side mistake; our
|
|
393
|
-
//
|
|
394
|
-
// endpoints is also caught.
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
525
|
+
function _fingerprintRequest(req, bodyBytes, store) {
|
|
526
|
+
// Fingerprint preimage = method + path + body. Per the draft §4.3,
|
|
527
|
+
// a key+body mismatch is a client-side mistake; our preimage covers
|
|
528
|
+
// method + path so a client reusing a key across different
|
|
529
|
+
// endpoints is also caught. CRYPTO-4: when the store exposes a
|
|
530
|
+
// `fingerprintHmac` hook (dbStore with fingerprintSeal:true + vault
|
|
531
|
+
// ready), the preimage is HMAC'd under a vault-derived secret so a
|
|
532
|
+
// DB dump leaks neither the preimage nor a brute-forceable digest.
|
|
533
|
+
// The middleware then compares the HMAC'd column directly; both
|
|
534
|
+
// sides of the compare use the same secret so the equality is
|
|
535
|
+
// exact, no key-recovery is performed on the dump side.
|
|
536
|
+
var preimage = Buffer.concat([
|
|
537
|
+
Buffer.from((req.method || "GET") + "\n", "utf8"),
|
|
538
|
+
Buffer.from((req.url || "/") + "\n", "utf8"),
|
|
539
|
+
bodyBytes && bodyBytes.length > 0 ? bodyBytes : Buffer.alloc(0),
|
|
540
|
+
]);
|
|
541
|
+
if (store && typeof store.fingerprintHmac === "function") {
|
|
542
|
+
var hmacOut = store.fingerprintHmac(preimage);
|
|
543
|
+
if (hmacOut !== null) return hmacOut;
|
|
403
544
|
}
|
|
404
|
-
return
|
|
545
|
+
return nodeCrypto.createHash("sha3-256").update(preimage).digest("hex");
|
|
405
546
|
}
|
|
406
547
|
|
|
407
548
|
function _emitAudit(action, metadata, outcome) {
|
|
@@ -461,6 +602,15 @@ function _emitAudit(action, metadata, outcome) {
|
|
|
461
602
|
* requireIdempotencyKey: boolean, // default: false — refuse missing-key
|
|
462
603
|
* bodyFingerprint: function, // (req) => Buffer|string|object|null — operator-supplied body extractor
|
|
463
604
|
* maxBodyBytes: number, // default: 1 MiB — replay-cache body cap
|
|
605
|
+
* bodyFingerprintFallback: string, // default "deny" (SUBSTRATE-13) — when neither
|
|
606
|
+
* // bodyFingerprint nor req._rawBody / req.body is
|
|
607
|
+
* // available for POST/PUT/PATCH, refuse with HTTP 400
|
|
608
|
+
* // idempotency/missing-body-fingerprint instead of
|
|
609
|
+
* // silently degrading the fingerprint to method+path.
|
|
610
|
+
* // Set to "method-path-only" to restore the pre-0.9.58
|
|
611
|
+
* // behavior (the audit chain still logs
|
|
612
|
+
* // idempotency.empty_body_fingerprint so the
|
|
613
|
+
* // misorder is visible in operator review).
|
|
464
614
|
*
|
|
465
615
|
* **Mount order — idempotency MUST run AFTER body-parser.** The hook
|
|
466
616
|
* (and the default `req._rawBody||req.body` lookup) reads request
|
|
@@ -519,6 +669,22 @@ function create(opts) {
|
|
|
519
669
|
opts.bodyFingerprint, "idempotencyKey.bodyFingerprint",
|
|
520
670
|
IdempotencyError, "idempotency/bad-body-fingerprint"
|
|
521
671
|
) || null;
|
|
672
|
+
// SUBSTRATE-13 — default "deny" refuses body-bearing requests that
|
|
673
|
+
// arrive with neither req._rawBody / req.body NOR an operator-
|
|
674
|
+
// supplied bodyFingerprint hook. The silent-degrade-to-method+path
|
|
675
|
+
// path was a §4.3 violation (same key + different body returned
|
|
676
|
+
// false replays); the runtime now refuses with HTTP 400 instead.
|
|
677
|
+
// Operators with a documented "method-path-only" use case opt in.
|
|
678
|
+
var bodyFpFallback = "deny";
|
|
679
|
+
if (opts.bodyFingerprintFallback !== undefined) {
|
|
680
|
+
if (opts.bodyFingerprintFallback !== "deny" &&
|
|
681
|
+
opts.bodyFingerprintFallback !== "method-path-only") {
|
|
682
|
+
throw new IdempotencyError("idempotency/bad-body-fingerprint-fallback",
|
|
683
|
+
"idempotencyKey: opts.bodyFingerprintFallback must be \"deny\" or \"method-path-only\", got " +
|
|
684
|
+
JSON.stringify(opts.bodyFingerprintFallback), true);
|
|
685
|
+
}
|
|
686
|
+
bodyFpFallback = opts.bodyFingerprintFallback;
|
|
687
|
+
}
|
|
522
688
|
|
|
523
689
|
// Per-response collector cap. Idempotency replay only makes sense
|
|
524
690
|
// for response bodies that fit in memory; the cap is operator-
|
|
@@ -595,11 +761,11 @@ function create(opts) {
|
|
|
595
761
|
|
|
596
762
|
// Misordered-mount detector — body-bearing method reached us
|
|
597
763
|
// with neither a parsed body nor a raw-body buffer. Most likely
|
|
598
|
-
// body-parser hasn't run yet, which silently
|
|
599
|
-
// fingerprint to method+path
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
764
|
+
// body-parser hasn't run yet, which used to silently degrade the
|
|
765
|
+
// fingerprint to method+path; SUBSTRATE-13 (v0.9.58) makes that
|
|
766
|
+
// case refuse with HTTP 400 by default. The audit emit fires in
|
|
767
|
+
// both fallback modes so operator review surfaces the
|
|
768
|
+
// misconfiguration regardless of the chosen fallback.
|
|
603
769
|
if (!bodyBytes && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
604
770
|
_emitAudit("idempotency.empty_body_fingerprint",
|
|
605
771
|
{
|
|
@@ -608,11 +774,24 @@ function create(opts) {
|
|
|
608
774
|
hasRawBody: Boolean(req._rawBody),
|
|
609
775
|
hasParsedBody: req.body !== undefined && req.body !== null,
|
|
610
776
|
hasFingerprintHook: Boolean(bodyFingerprintFn),
|
|
777
|
+
fallback: bodyFpFallback,
|
|
611
778
|
},
|
|
612
|
-
"warning");
|
|
779
|
+
bodyFpFallback === "deny" ? "denied" : "warning");
|
|
780
|
+
if (bodyFpFallback === "deny") {
|
|
781
|
+
var missingBody = problemDetails().create({
|
|
782
|
+
type: problemDetails().getBase() + "/idempotency/missing-body-fingerprint",
|
|
783
|
+
title: "Idempotency body fingerprint unavailable",
|
|
784
|
+
status: 400, // allow:raw-byte-literal — HTTP status 400 Bad Request
|
|
785
|
+
detail: "The idempotency middleware could not derive a body fingerprint for this " +
|
|
786
|
+
"request. Mount body-parser BEFORE the idempotency middleware, OR provide an " +
|
|
787
|
+
"opts.bodyFingerprint(req) hook. To restore the pre-0.9.58 method+path-only " +
|
|
788
|
+
"behavior, set opts.bodyFingerprintFallback=\"method-path-only\".",
|
|
789
|
+
});
|
|
790
|
+
return problemDetails().respond(res, missingBody);
|
|
791
|
+
}
|
|
613
792
|
}
|
|
614
793
|
|
|
615
|
-
var fingerprint = _fingerprintRequest(req, bodyBytes);
|
|
794
|
+
var fingerprint = _fingerprintRequest(req, bodyBytes, opts.store);
|
|
616
795
|
|
|
617
796
|
var cached = null;
|
|
618
797
|
try { cached = opts.store.get(key); }
|
|
@@ -743,9 +922,43 @@ function _redactKey(key) {
|
|
|
743
922
|
return key.slice(0, 4) + "..." + key.slice(-2) + " (len=" + key.length + ")"; // allow:raw-byte-literal — log-redaction prefix/suffix lengths
|
|
744
923
|
}
|
|
745
924
|
|
|
925
|
+
/**
|
|
926
|
+
* @primitive b.middleware.idempotencyKey.resealMigrate
|
|
927
|
+
* @signature b.middleware.idempotencyKey.resealMigrate(store)
|
|
928
|
+
* @since 0.9.58
|
|
929
|
+
* @related b.middleware.idempotencyKey.dbStore
|
|
930
|
+
*
|
|
931
|
+
* One-shot operator helper that walks a dbStore's table and reseals
|
|
932
|
+
* every row under the AAD-bound envelope shape introduced in v0.9.58
|
|
933
|
+
* (CRYPTO-1). Existing v0.9.15-v0.9.57 rows continue to read on a
|
|
934
|
+
* per-row basis (unsealRow auto-detects shape) so a deploy without
|
|
935
|
+
* this call is correct, but operators who want to upgrade in bulk
|
|
936
|
+
* call this once after upgrading.
|
|
937
|
+
*
|
|
938
|
+
* Returns `{ migrated, skipped, reason }`. `migrated` counts rows
|
|
939
|
+
* rewritten with AAD-bound ciphertext; `skipped` counts rows already
|
|
940
|
+
* AAD-shaped or that failed unseal under the current key (those rows
|
|
941
|
+
* stay in place and surface via the standard
|
|
942
|
+
* `idempotency.unseal_failed` audit on next read). `reason` is null
|
|
943
|
+
* on success; populated when the store doesn't support migration
|
|
944
|
+
* (in-memory store, custom operator-supplied store, etc.).
|
|
945
|
+
*
|
|
946
|
+
* @example
|
|
947
|
+
* var store = b.middleware.idempotencyKey.dbStore({ db: myDb });
|
|
948
|
+
* var info = b.middleware.idempotencyKey.resealMigrate(store);
|
|
949
|
+
* logger.info("idempotency migration", info);
|
|
950
|
+
*/
|
|
951
|
+
function resealMigrate(store) {
|
|
952
|
+
if (!store || typeof store.resealMigrate !== "function") {
|
|
953
|
+
return { migrated: 0, skipped: 0, reason: "store-does-not-support-reseal" };
|
|
954
|
+
}
|
|
955
|
+
return store.resealMigrate();
|
|
956
|
+
}
|
|
957
|
+
|
|
746
958
|
module.exports = create;
|
|
747
959
|
module.exports.create = create;
|
|
748
960
|
module.exports.memoryStore = memoryStore;
|
|
749
961
|
module.exports.dbStore = dbStore;
|
|
962
|
+
module.exports.resealMigrate = resealMigrate;
|
|
750
963
|
module.exports.DEFAULT_METHODS = DEFAULT_METHODS;
|
|
751
964
|
module.exports.IdempotencyError = IdempotencyError;
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
var framework_error = require("../framework-error");
|
|
27
27
|
var validateOpts = require("../validate-opts");
|
|
28
28
|
var requestHelpers = require("../request-helpers");
|
|
29
|
+
var safeUrl = require("../safe-url");
|
|
30
|
+
var nodeCrypto = require("node:crypto");
|
|
29
31
|
|
|
30
32
|
var H = requestHelpers.HTTP_STATUS;
|
|
31
33
|
|
|
@@ -36,6 +38,15 @@ var ProtectedResourceMetadataError = framework_error.defineClass(
|
|
|
36
38
|
|
|
37
39
|
var ALLOWED_BEARER_METHODS = ["header", "body", "query"];
|
|
38
40
|
var ALLOWED_DPOP_ALGS = ["ES256", "ES384", "RS256", "PS256", "EdDSA", "ML-DSA-65", "ML-DSA-87"];
|
|
41
|
+
// RFC 9728 §3.2 — signed_metadata signing algs. PQC-first per the
|
|
42
|
+
// framework's hard rule §2 (ML-DSA-* preferred); classical algs
|
|
43
|
+
// available for backwards-interop with relying parties without PQC
|
|
44
|
+
// libraries on hand.
|
|
45
|
+
var ALLOWED_SIGNED_METADATA_ALGS = ["ML-DSA-87", "ML-DSA-65", "EdDSA", "ES256", "ES384", "PS256", "PS384"];
|
|
46
|
+
|
|
47
|
+
function _b64url(buf) {
|
|
48
|
+
return Buffer.from(buf).toString("base64url");
|
|
49
|
+
}
|
|
39
50
|
|
|
40
51
|
/**
|
|
41
52
|
* @primitive b.middleware.protectedResourceMetadata
|
|
@@ -83,12 +94,30 @@ function create(opts) {
|
|
|
83
94
|
"middleware/protected-resource-metadata/no-as",
|
|
84
95
|
"authorizationServers must be a non-empty array of issuer URLs");
|
|
85
96
|
}
|
|
97
|
+
// AUTH-17 — RFC 9728 §3 + RFC 8414 §3.1: authorizationServers entries
|
|
98
|
+
// are issuer URLs and MUST be https://. Pre-v0.9.x only required
|
|
99
|
+
// non-empty string, so an operator typo could ship `http://idp.test`
|
|
100
|
+
// (or, worse, `javascript:` / `data:`) to clients via the well-known
|
|
101
|
+
// document. allowHttp opts.allowHttp passes the framework's
|
|
102
|
+
// safe-url loopback exception through (matches b.auth.oauth's
|
|
103
|
+
// _validateUrl shape).
|
|
104
|
+
var allowHttp = opts.allowHttp === true;
|
|
86
105
|
opts.authorizationServers.forEach(function (u, i) {
|
|
87
106
|
if (typeof u !== "string" || u.length === 0) {
|
|
88
107
|
throw new ProtectedResourceMetadataError(
|
|
89
108
|
"middleware/protected-resource-metadata/bad-as",
|
|
90
109
|
"authorizationServers[" + i + "] must be a non-empty string");
|
|
91
110
|
}
|
|
111
|
+
try {
|
|
112
|
+
safeUrl.parse(u, {
|
|
113
|
+
allowedProtocols: allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
|
|
114
|
+
});
|
|
115
|
+
} catch (_e) {
|
|
116
|
+
throw new ProtectedResourceMetadataError(
|
|
117
|
+
"middleware/protected-resource-metadata/bad-as-url",
|
|
118
|
+
"authorizationServers[" + i + "] = '" + u + "' is not a valid " +
|
|
119
|
+
(allowHttp ? "http(s)" : "https") + " URL (RFC 9728 §3 / RFC 8414 §3.1)");
|
|
120
|
+
}
|
|
92
121
|
});
|
|
93
122
|
|
|
94
123
|
var bearerMethods = opts.bearerMethodsSupported || ["header"];
|
|
@@ -127,8 +156,73 @@ function create(opts) {
|
|
|
127
156
|
if (opts.dpopBoundAccessTokensRequired === true) doc.dpop_bound_access_tokens_required = true;
|
|
128
157
|
if (opts.mtlsBoundAccessTokensRequired === true) doc.tls_client_certificate_bound_access_tokens = true;
|
|
129
158
|
|
|
159
|
+
// AUTH-18 — RFC 9728 §3.2 signed_metadata. Operators with an
|
|
160
|
+
// anti-tamper requirement pass `signMetadata: { key, alg, kid }`;
|
|
161
|
+
// the middleware emits `application/jwt` carrying the JWS-signed
|
|
162
|
+
// metadata. Default output remains cleartext `application/json`.
|
|
163
|
+
var signedJwt = null;
|
|
164
|
+
var signedDoc = null;
|
|
165
|
+
if (opts.signMetadata) {
|
|
166
|
+
var sm = opts.signMetadata;
|
|
167
|
+
if (!sm || typeof sm !== "object") {
|
|
168
|
+
throw new ProtectedResourceMetadataError(
|
|
169
|
+
"middleware/protected-resource-metadata/bad-sign",
|
|
170
|
+
"signMetadata must be an object { key, alg, kid? }");
|
|
171
|
+
}
|
|
172
|
+
if (!sm.alg || ALLOWED_SIGNED_METADATA_ALGS.indexOf(sm.alg) === -1) {
|
|
173
|
+
throw new ProtectedResourceMetadataError(
|
|
174
|
+
"middleware/protected-resource-metadata/bad-sign-alg",
|
|
175
|
+
"signMetadata.alg '" + sm.alg + "' not in allowlist: " +
|
|
176
|
+
ALLOWED_SIGNED_METADATA_ALGS.join(", "));
|
|
177
|
+
}
|
|
178
|
+
if (!sm.key) {
|
|
179
|
+
throw new ProtectedResourceMetadataError(
|
|
180
|
+
"middleware/protected-resource-metadata/bad-sign-key",
|
|
181
|
+
"signMetadata.key is required (KeyObject, PEM string/Buffer, or JWK object)");
|
|
182
|
+
}
|
|
183
|
+
var signingKey;
|
|
184
|
+
try {
|
|
185
|
+
if (sm.key instanceof nodeCrypto.KeyObject) {
|
|
186
|
+
signingKey = sm.key;
|
|
187
|
+
} else if (typeof sm.key === "string" || Buffer.isBuffer(sm.key)) {
|
|
188
|
+
signingKey = nodeCrypto.createPrivateKey({ key: sm.key, format: "pem" });
|
|
189
|
+
} else if (typeof sm.key === "object") {
|
|
190
|
+
signingKey = nodeCrypto.createPrivateKey({ key: sm.key, format: "jwk" });
|
|
191
|
+
} else {
|
|
192
|
+
throw new ProtectedResourceMetadataError(
|
|
193
|
+
"middleware/protected-resource-metadata/bad-sign-key",
|
|
194
|
+
"signMetadata.key must be KeyObject, PEM string/Buffer, or JWK object");
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e instanceof ProtectedResourceMetadataError) throw e;
|
|
198
|
+
throw new ProtectedResourceMetadataError(
|
|
199
|
+
"middleware/protected-resource-metadata/bad-sign-key",
|
|
200
|
+
"signMetadata.key parse failed: " + ((e && e.message) || String(e)));
|
|
201
|
+
}
|
|
202
|
+
// RFC 9728 §3.2 — signed_metadata is a JWS carrying the same
|
|
203
|
+
// metadata claims as the cleartext document plus iss + sub
|
|
204
|
+
// (resource URI) for identification at consume-side.
|
|
205
|
+
signedDoc = Object.assign({}, doc, { iss: opts.resource, sub: opts.resource });
|
|
206
|
+
var jwsHeader = { alg: sm.alg, typ: "oauth-protected-resource+jwt" };
|
|
207
|
+
if (sm.kid) jwsHeader.kid = sm.kid;
|
|
208
|
+
var headerEnc = _b64url(JSON.stringify(jwsHeader));
|
|
209
|
+
var payloadEnc = _b64url(JSON.stringify(signedDoc));
|
|
210
|
+
var input = headerEnc + "." + payloadEnc;
|
|
211
|
+
// PQC algs (ML-DSA-*) + EdDSA pass null hash; ES* / PS* / RS* use
|
|
212
|
+
// their RFC 7518 hash + dsaEncoding shape.
|
|
213
|
+
var signParams = { key: signingKey };
|
|
214
|
+
var signAlgo = null;
|
|
215
|
+
if (sm.alg === "ES256") { signAlgo = "sha256"; signParams.dsaEncoding = "ieee-p1363"; }
|
|
216
|
+
else if (sm.alg === "ES384") { signAlgo = "sha384"; signParams.dsaEncoding = "ieee-p1363"; }
|
|
217
|
+
else if (sm.alg === "PS256") { signAlgo = "sha256"; signParams.padding = nodeCrypto.constants.RSA_PKCS1_PSS_PADDING; signParams.saltLength = 32; } // allow:raw-byte-literal — RFC 7518 PS256 salt
|
|
218
|
+
else if (sm.alg === "PS384") { signAlgo = "sha384"; signParams.padding = nodeCrypto.constants.RSA_PKCS1_PSS_PADDING; signParams.saltLength = 48; } // allow:raw-byte-literal — RFC 7518 PS384 salt
|
|
219
|
+
var sig = nodeCrypto.sign(signAlgo, Buffer.from(input, "ascii"), signParams);
|
|
220
|
+
signedJwt = input + "." + _b64url(sig);
|
|
221
|
+
}
|
|
222
|
+
|
|
130
223
|
var bodyText = JSON.stringify(doc);
|
|
131
224
|
var bodyBytes = Buffer.byteLength(bodyText, "utf8");
|
|
225
|
+
var signedBytes = signedJwt ? Buffer.byteLength(signedJwt, "utf8") : 0;
|
|
132
226
|
|
|
133
227
|
function middleware(req, res, next) {
|
|
134
228
|
if (req.url !== path && req.url.split("?")[0] !== path) {
|
|
@@ -143,6 +237,22 @@ function create(opts) {
|
|
|
143
237
|
res.end();
|
|
144
238
|
return;
|
|
145
239
|
}
|
|
240
|
+
// RFC 9728 §3.2 — operators that wired signMetadata serve the JWS
|
|
241
|
+
// form when the client advertises Accept: application/jwt (or via
|
|
242
|
+
// the *.jwt path suffix). The cleartext document is still served
|
|
243
|
+
// on the default path / Accept: application/json.
|
|
244
|
+
var accept = (req.headers && req.headers.accept) || "";
|
|
245
|
+
var wantsSigned = signedJwt && (accept.indexOf("application/jwt") !== -1);
|
|
246
|
+
if (wantsSigned) {
|
|
247
|
+
res.writeHead(H.OK, {
|
|
248
|
+
"Content-Type": "application/jwt",
|
|
249
|
+
"Content-Length": String(signedBytes),
|
|
250
|
+
"Cache-Control": "public, max-age=3600",
|
|
251
|
+
});
|
|
252
|
+
if (req.method === "HEAD") { res.end(); return; }
|
|
253
|
+
res.end(signedJwt);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
146
256
|
res.writeHead(H.OK, {
|
|
147
257
|
"Content-Type": "application/json",
|
|
148
258
|
"Content-Length": String(bodyBytes),
|
|
@@ -152,8 +262,9 @@ function create(opts) {
|
|
|
152
262
|
res.end(bodyText);
|
|
153
263
|
}
|
|
154
264
|
|
|
155
|
-
middleware.document
|
|
156
|
-
middleware.
|
|
265
|
+
middleware.document = doc;
|
|
266
|
+
middleware.signedMetadata = signedJwt;
|
|
267
|
+
middleware.path = path;
|
|
157
268
|
return middleware;
|
|
158
269
|
}
|
|
159
270
|
|
|
@@ -162,4 +273,5 @@ module.exports = {
|
|
|
162
273
|
ProtectedResourceMetadataError: ProtectedResourceMetadataError,
|
|
163
274
|
ALLOWED_BEARER_METHODS: ALLOWED_BEARER_METHODS,
|
|
164
275
|
ALLOWED_DPOP_ALGS: ALLOWED_DPOP_ALGS,
|
|
276
|
+
ALLOWED_SIGNED_METADATA_ALGS: ALLOWED_SIGNED_METADATA_ALGS,
|
|
165
277
|
};
|
|
@@ -127,6 +127,13 @@ var DEFAULT_MAX_TTL_MS = C.TIME.hours(24);
|
|
|
127
127
|
var DEFAULT_MIN_TTL_MS = C.TIME.seconds(60);
|
|
128
128
|
var DEFAULT_STALE_WINDOW = C.TIME.hours(6);
|
|
129
129
|
var DEFAULT_PROFILE = "strict";
|
|
130
|
+
// BUG-1 / MAIL-26 — CWE-400/770. Bound the cache so a hostile peer
|
|
131
|
+
// that can drive query-name selection (e.g. inbound SMTP forwarding
|
|
132
|
+
// DKIM `s=` / `d=` tag-controlled lookups) cannot inflate the Map to
|
|
133
|
+
// OOM. Default 5000 entries: a parsed-response object ~100 bytes ×
|
|
134
|
+
// 5000 ≈ 500 KiB, several orders below operator-relevant memory
|
|
135
|
+
// pressure. LRU eviction picks the oldest accessed entry on overflow.
|
|
136
|
+
var DEFAULT_MAX_CACHE_ENTRIES = 5000; // allow:raw-byte-literal — cache-entry count, not a byte/time value
|
|
130
137
|
|
|
131
138
|
var QTYPE_BY_NAME = Object.freeze({
|
|
132
139
|
A: 1,
|
|
@@ -200,9 +207,33 @@ function create(opts) {
|
|
|
200
207
|
throw new ResolverError("resolver/bad-input",
|
|
201
208
|
"create: serveStale must be a non-negative finite number or false");
|
|
202
209
|
}
|
|
210
|
+
var maxCacheEntries = typeof opts.maxCacheEntries === "number"
|
|
211
|
+
? opts.maxCacheEntries : DEFAULT_MAX_CACHE_ENTRIES;
|
|
212
|
+
if (!isFinite(maxCacheEntries) || maxCacheEntries < 1 ||
|
|
213
|
+
Math.floor(maxCacheEntries) !== maxCacheEntries) {
|
|
214
|
+
throw new ResolverError("resolver/bad-input",
|
|
215
|
+
"create: maxCacheEntries must be a positive integer");
|
|
216
|
+
}
|
|
203
217
|
|
|
204
218
|
var cache = new Map(); // key → { response, parsed, ttl, expiresAt, staleUntil }
|
|
205
219
|
|
|
220
|
+
// CWE-400/770 / BUG-1 — LRU eviction on insert when the cache is at
|
|
221
|
+
// capacity. v8 Map preserves insertion order; oldest key is the
|
|
222
|
+
// first entry returned by Map.keys().next().
|
|
223
|
+
function _evictIfFull() {
|
|
224
|
+
while (cache.size >= maxCacheEntries) {
|
|
225
|
+
var oldest = cache.keys().next();
|
|
226
|
+
if (oldest.done) break;
|
|
227
|
+
cache.delete(oldest.value);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Touching a hit moves it to the LRU tail — delete-then-set keeps
|
|
231
|
+
// active queries hot under cache pressure.
|
|
232
|
+
function _touch(key, entry) {
|
|
233
|
+
cache.delete(key);
|
|
234
|
+
cache.set(key, entry);
|
|
235
|
+
}
|
|
236
|
+
|
|
206
237
|
function _key(name, qtype) {
|
|
207
238
|
return name.toLowerCase() + "|" + qtype;
|
|
208
239
|
}
|
|
@@ -244,6 +275,7 @@ function create(opts) {
|
|
|
244
275
|
"query: validate: true but cached response was AD=0 for " +
|
|
245
276
|
name + "/" + qtype);
|
|
246
277
|
}
|
|
278
|
+
_touch(key, hit); // LRU bump
|
|
247
279
|
return _result(hit.parsed, hit.ttl, true, false, hit.validated);
|
|
248
280
|
}
|
|
249
281
|
|
|
@@ -297,6 +329,7 @@ function create(opts) {
|
|
|
297
329
|
var ttlMs = Math.max(minTtlMs, Math.min(maxTtlMs, rrTtl * C.TIME.seconds(1)));
|
|
298
330
|
var expiresAt = now + ttlMs;
|
|
299
331
|
var staleUntil = serveStale > 0 ? expiresAt + serveStale : expiresAt;
|
|
332
|
+
_evictIfFull();
|
|
300
333
|
cache.set(key, {
|
|
301
334
|
parsed: parsed,
|
|
302
335
|
ttl: ttlMs,
|