@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.
- package/CHANGELOG.md +951 -893
- package/index.js +30 -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 +67 -5
- package/lib/circuit-breaker.js +10 -2
- 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-imap-command.js +335 -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-smtp-command.js +58 -3
- 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 +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- 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 +130 -10
- 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/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 +168 -17
- 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
package/lib/middleware/dpop.js
CHANGED
|
@@ -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 () {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|