@blamejs/core 0.9.46 → 0.10.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. package/sbom.cdx.json +6 -6
@@ -75,6 +75,7 @@ function _nonceManager(rotateSec) {
75
75
  var rotateMs = C.TIME.seconds(rotateSec);
76
76
  var current = null;
77
77
  var previous = null;
78
+ var shutdown = false;
78
79
  function _fresh() {
79
80
  return {
80
81
  nonce: bCrypto.generateBytes(DPOP_NONCE_BYTES).toString("base64url"),
@@ -93,11 +94,43 @@ function _nonceManager(rotateSec) {
93
94
  }
94
95
  }
95
96
  return {
96
- issue: function () { _maybeRotate(); return current.nonce; },
97
+ issue: function () {
98
+ if (shutdown) return null;
99
+ _maybeRotate();
100
+ return current.nonce;
101
+ },
97
102
  accepts: function (n) {
103
+ if (shutdown) return false;
98
104
  _maybeRotate();
99
105
  if (typeof n !== "string" || n.length === 0) return false;
100
- return (current && n === current.nonce) || (previous && n === previous.nonce);
106
+ // Constant-time compare so server-issued nonce probing can't
107
+ // narrow the rolling-pair bytes via response-timing — matches
108
+ // the timingSafeEqual discipline on the DPoP-proof nonce.
109
+ if (current && bCrypto.timingSafeEqual(n, current.nonce)) return true;
110
+ if (previous && bCrypto.timingSafeEqual(n, previous.nonce)) return true;
111
+ return false;
112
+ },
113
+ // AUTH-36 — hot-reload coexistence. Operators redeploying without
114
+ // a clean process restart need a way to drain in-flight clients
115
+ // before swapping the middleware instance. shutdown() returns no
116
+ // fresh nonces and refuses every presented nonce, so the
117
+ // surrounding middleware emits 401 + use_dpop_nonce on the old
118
+ // instance and the new instance owns the trust anchor cleanly.
119
+ shutdown: function () { shutdown = true; current = null; previous = null; },
120
+ // revoke() — rotate both rolling-pair slots, invalidating every
121
+ // outstanding nonce immediately. Useful after a suspected nonce
122
+ // leak. Distinct from shutdown(): the manager keeps serving fresh
123
+ // nonces afterwards.
124
+ revoke: function () {
125
+ previous = null;
126
+ current = _fresh();
127
+ },
128
+ _state: function () {
129
+ return {
130
+ shutdown: shutdown,
131
+ current: current ? current.nonce : null,
132
+ previous: previous ? previous.nonce : null,
133
+ };
101
134
  },
102
135
  };
103
136
  }
@@ -229,7 +262,7 @@ function create(opts) {
229
262
 
230
263
  function _freshNonce() { return nonceMgr ? nonceMgr.issue() : null; }
231
264
 
232
- return async function dpopMiddleware(req, res, next) {
265
+ var middleware = async function dpopMiddleware(req, res, next) {
233
266
  var proofHeader = req.headers && req.headers.dpop;
234
267
  if (typeof proofHeader !== "string" || proofHeader.length === 0) {
235
268
  return _writeUnauthorized(res,
@@ -241,6 +274,18 @@ function create(opts) {
241
274
  return _writeUnauthorized(res, "invalid_dpop_proof",
242
275
  "multiple DPoP headers are not allowed");
243
276
  }
277
+ // AUTH-15 — RFC 9449 §4.1 single-value invariant. node:http
278
+ // collapses repeated headers into a comma-joined string when the
279
+ // client ships `DPoP: proof1, DPoP: proof2`; the Array.isArray
280
+ // check above catches the multi-value array shape but a
281
+ // comma-joined string slips past. Refuse explicitly so a buggy /
282
+ // hostile client can't smuggle two proofs past the verifier (the
283
+ // verify() call below would only see the first one, leaving the
284
+ // second unprocessed).
285
+ if (proofHeader.indexOf(",") !== -1) {
286
+ return _writeUnauthorized(res, "invalid_dpop_proof",
287
+ "multiple DPoP proofs in one header value are not allowed");
288
+ }
244
289
 
245
290
  var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
246
291
  if (!htu) {
@@ -340,6 +385,16 @@ function create(opts) {
340
385
  }
341
386
  return next();
342
387
  };
388
+
389
+ // AUTH-36 — surface the nonce manager's lifecycle hooks on the
390
+ // returned middleware so hot-reload deploys can drain in-flight
391
+ // clients before swapping instances. shutdown() refuses every
392
+ // subsequent proof + issues no fresh nonces; revoke() rotates the
393
+ // rolling pair without disabling the manager (useful after a
394
+ // suspected nonce leak). Both are no-ops when requireNonce is off.
395
+ middleware.shutdown = function () { if (nonceMgr) nonceMgr.shutdown(); };
396
+ middleware.revoke = function () { if (nonceMgr) nonceMgr.revoke(); };
397
+ return middleware;
343
398
  }
344
399
 
345
400
  module.exports = {
@@ -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 (since 0.9.15) both can be opted out:**
164
+ * **Defense-in-depth defaults every option below ships on by default:**
165
165
  *
166
- * - `hashKeys: true` — operator-supplied keys are sha3-512
167
- * namespace-hashed via `b.crypto.namespaceHash("idempotency-key",
168
- * key)` before insert/lookup. The `k` column carries the hash, not
169
- * the raw key. Operator keys often carry PII (order numbers,
170
- * emails, vendor prefixes); the DB never sees them.
171
- * - `seal: true` `headers` and `body` columns are sealed via
172
- * `b.cryptoField.sealRow` (vault-managed key, AEAD envelope) so a
173
- * DB dump leaks neither cached response bodies nor headers.
174
- * Requires `b.vault.init(...)` to have run; falls back to plain-
175
- * text with a one-shot audit warning when vault isn't ready, so
176
- * test-fixture / boot-script callers still work.
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
- // Decryption failed (key rotation gap / corrupt envelope).
318
- // Treat as miss + drop the row so the handler runs fresh
319
- // and we capture a re-sealable replacement.
320
- stmtDeleteStale.run(_k(rawKey), row.expires_at);
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
- headersObj = safeJson.parse(liveRow.headers, { maxBytes: 4 * 1024 * 1024 }); // allow:raw-byte-literal — 4 MiB headers ceiling
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 sha3-256. Per the draft §4.3,
392
- // a key+body mismatch is a client-side mistake; our fingerprint
393
- // covers method + path so a client reusing a key across different
394
- // endpoints is also caught. Body hash uses SHA3-256 to match the
395
- // framework's PQC-first crypto posture (SHA-256 is fine for
396
- // collision-resistance here but we use SHA3 for codebase
397
- // uniformity).
398
- var hash = nodeCrypto.createHash("sha3-256");
399
- hash.update((req.method || "GET") + "\n");
400
- hash.update((req.url || "/") + "\n");
401
- if (bodyBytes && bodyBytes.length > 0) {
402
- hash.update(bodyBytes);
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 hash.digest("hex");
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 degrades the
599
- // fingerprint to method+path. Emit a warning so the audit log
600
- // surfaces the misconfiguration. (Genuinely empty POST bodies
601
- // also trip this acceptable cost; the audit field captures the
602
- // distinction via `hasRawBody`/`hasParsedBody`.)
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;