@blamejs/core 0.7.0 → 0.7.4
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 +8 -0
- package/README.md +2 -2
- package/index.js +2 -0
- package/lib/api-key.js +1 -10
- package/lib/cache-redis.js +1 -11
- package/lib/cache.js +11 -16
- package/lib/file-upload.js +933 -0
- package/lib/framework-error.js +13 -0
- package/lib/log-stream-syslog.js +1 -1
- package/lib/middleware/api-encrypt.js +415 -52
- package/lib/middleware/db-role-for.js +3 -8
- package/lib/notify.js +3 -5
- package/lib/object-store/azure-blob.js +42 -5
- package/lib/object-store/gcs.js +47 -7
- package/lib/object-store/sigv4.js +55 -7
- package/lib/pubsub-redis.js +1 -11
- package/lib/queue-redis.js +1 -8
- package/lib/redis-client.js +30 -0
- package/lib/request-helpers.js +21 -17
- package/lib/seeders.js +5 -17
- package/lib/static.js +699 -114
- package/lib/validate-opts.js +49 -0
- package/lib/webhook.js +3 -6
- package/lib/websocket.js +38 -6
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/framework-error.js
CHANGED
|
@@ -172,6 +172,17 @@ var TestingError = defineClass("TestingError", { alwaysPermane
|
|
|
172
172
|
// "account is currently locked" condition is NOT an error — recordFailure
|
|
173
173
|
// returns { locked: true, lockedUntil } so the caller decides the response.
|
|
174
174
|
var LockoutError = defineClass("LockoutError", { alwaysPermanent: true });
|
|
175
|
+
// FileUploadError is alwaysPermanent: chunk-hash mismatch / oversized
|
|
176
|
+
// chunk / oversized total file / manifest verification failure are all
|
|
177
|
+
// caller-shape errors that won't succeed on retry. Operators wrap the
|
|
178
|
+
// route handler with their own retry policy if they want client-side
|
|
179
|
+
// resumability.
|
|
180
|
+
var FileUploadError = defineClass("FileUploadError", { alwaysPermanent: true });
|
|
181
|
+
// StaticServeError covers the download-side surface of staticServe.create.
|
|
182
|
+
// withStatusCode: true so the framework can translate to operator-meaningful
|
|
183
|
+
// HTTP responses (403 permission_denied, 404 not_found, 412 precondition_failed,
|
|
184
|
+
// 416 range_not_satisfiable, 429 quota_exceeded, 451 retention_blocked).
|
|
185
|
+
var StaticServeError = defineClass("StaticServeError", { withStatusCode: true });
|
|
175
186
|
|
|
176
187
|
module.exports = {
|
|
177
188
|
FrameworkError: FrameworkError,
|
|
@@ -199,4 +210,6 @@ module.exports = {
|
|
|
199
210
|
NotifyError: NotifyError,
|
|
200
211
|
TestingError: TestingError,
|
|
201
212
|
LockoutError: LockoutError,
|
|
213
|
+
FileUploadError: FileUploadError,
|
|
214
|
+
StaticServeError: StaticServeError,
|
|
202
215
|
};
|
package/lib/log-stream-syslog.js
CHANGED
|
@@ -220,7 +220,7 @@ function create(config) {
|
|
|
220
220
|
sock = net.connect(connectOpts, onConnect);
|
|
221
221
|
}
|
|
222
222
|
sock.unref && sock.unref();
|
|
223
|
-
sock.on("error", function () { /*
|
|
223
|
+
sock.on("error", function () { /* reconnect handled in the close listener */ });
|
|
224
224
|
sock.on("close", function () {
|
|
225
225
|
sockReady = false;
|
|
226
226
|
connecting = false;
|
|
@@ -116,6 +116,16 @@ var DEFAULT_REPLAY_WINDOW_MS = C.TIME.minutes(5);
|
|
|
116
116
|
var DEFAULT_CONTENT_TYPES = ["application/json"];
|
|
117
117
|
var SESSION_KEY_BYTES = C.BYTES.bytes(32);
|
|
118
118
|
var REQUEST_NONCE_BYTES = C.BYTES.bytes(16);
|
|
119
|
+
var DEFAULT_SESSION_TTL_MS = C.TIME.minutes(15);
|
|
120
|
+
// 1024 ≈ "a session with a thousand response rotations" — round-number
|
|
121
|
+
// kibi-aligned default; operators raise this for chat / streaming sessions
|
|
122
|
+
// or lower it for strict per-key forward-secrecy postures.
|
|
123
|
+
var DEFAULT_SESSION_MAX_RESPONSES = 0x400;
|
|
124
|
+
// SID format: UUID-shaped string. Operators with their own session-id
|
|
125
|
+
// vocabulary subscribe to the same shape (cluster-storage / cache backends
|
|
126
|
+
// already index on string keys).
|
|
127
|
+
var SID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
128
|
+
var SID_MAX_LENGTH = C.BYTES.bytes(64);
|
|
119
129
|
|
|
120
130
|
function _err(code, message, statusCode) {
|
|
121
131
|
return new ApiEncryptError(code, message, true, statusCode || 400);
|
|
@@ -161,6 +171,57 @@ function _resolveKeypairs(opts) {
|
|
|
161
171
|
|
|
162
172
|
var HTTP_STATUS = requestHelpers.HTTP_STATUS;
|
|
163
173
|
|
|
174
|
+
// _defaultSessionStore — in-memory session table for single-process
|
|
175
|
+
// deployments. Operators with multi-replica deploys pass an
|
|
176
|
+
// operator-supplied store (b.cache.create({ backend: "cluster" }) or any
|
|
177
|
+
// `{ get, set, delete }`-shaped handle). Per-replica isolation in default
|
|
178
|
+
// mode means sticky sessions; the limit is documented in the wiki.
|
|
179
|
+
//
|
|
180
|
+
// .get(sid) → row | null
|
|
181
|
+
// .set(sid, row, { ttlMs }) → void
|
|
182
|
+
// .delete(sid) → void
|
|
183
|
+
//
|
|
184
|
+
// Each row stores:
|
|
185
|
+
// { sessionKey: Buffer, lastReqCtr: int, responsesEmitted: int,
|
|
186
|
+
// createdAt: ms, expiresAt: ms, lastUsedAt: ms }
|
|
187
|
+
function _defaultSessionStore() {
|
|
188
|
+
var rows = new Map();
|
|
189
|
+
return {
|
|
190
|
+
get: function (sid) {
|
|
191
|
+
var row = rows.get(sid);
|
|
192
|
+
if (!row) return null;
|
|
193
|
+
if (Date.now() > row.expiresAt) {
|
|
194
|
+
rows.delete(sid);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return row;
|
|
198
|
+
},
|
|
199
|
+
set: function (sid, row /* opts */) {
|
|
200
|
+
rows.set(sid, row);
|
|
201
|
+
},
|
|
202
|
+
delete: function (sid) {
|
|
203
|
+
rows.delete(sid);
|
|
204
|
+
},
|
|
205
|
+
purgeExpired: function () {
|
|
206
|
+
var now = Date.now();
|
|
207
|
+
var purged = 0;
|
|
208
|
+
rows.forEach(function (row, sid) {
|
|
209
|
+
if (now > row.expiresAt) { rows.delete(sid); purged += 1; }
|
|
210
|
+
});
|
|
211
|
+
return purged;
|
|
212
|
+
},
|
|
213
|
+
size: function () { return rows.size; },
|
|
214
|
+
close: function () { rows.clear(); },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _validSid(sid) {
|
|
219
|
+
return typeof sid === "string" &&
|
|
220
|
+
sid.length > 0 &&
|
|
221
|
+
sid.length <= SID_MAX_LENGTH &&
|
|
222
|
+
SID_RE.test(sid);
|
|
223
|
+
}
|
|
224
|
+
|
|
164
225
|
function _writeRejection(res, code, body) {
|
|
165
226
|
if (res.headersSent || res.writableEnded) return;
|
|
166
227
|
if (typeof res.writeHead === "function") {
|
|
@@ -177,6 +238,9 @@ function create(opts) {
|
|
|
177
238
|
"keypair", "keypairs", "replayWindowMs", "pruneIntervalMs",
|
|
178
239
|
"nonceStore", "exemptPaths", "contentTypes", "audit",
|
|
179
240
|
"maxDecryptedBytes", "trustProxy",
|
|
241
|
+
// Per-session keying mode (opt-in; per-request stays default).
|
|
242
|
+
"keying", "sessionStore", "sessionTtlMs", "sessionMaxResponses",
|
|
243
|
+
"observability",
|
|
180
244
|
], "middleware.apiEncrypt");
|
|
181
245
|
var keypairs = _resolveKeypairs(opts);
|
|
182
246
|
var activeKeypair = keypairs[0];
|
|
@@ -210,6 +274,70 @@ function create(opts) {
|
|
|
210
274
|
var trustProxy = opts.trustProxy === true;
|
|
211
275
|
var lastPruneAt = 0;
|
|
212
276
|
|
|
277
|
+
// ---- per-session keying opts ----
|
|
278
|
+
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
279
|
+
if (keying !== "per-request" && keying !== "per-session") {
|
|
280
|
+
throw _err("BAD_OPT",
|
|
281
|
+
"apiEncrypt: keying must be 'per-request' (default) or 'per-session', got " +
|
|
282
|
+
JSON.stringify(opts.keying), 500);
|
|
283
|
+
}
|
|
284
|
+
var sessionTtlMs = opts.sessionTtlMs != null ? opts.sessionTtlMs : DEFAULT_SESSION_TTL_MS;
|
|
285
|
+
var sessionMaxResponses = opts.sessionMaxResponses != null
|
|
286
|
+
? opts.sessionMaxResponses : DEFAULT_SESSION_MAX_RESPONSES;
|
|
287
|
+
if (typeof sessionTtlMs !== "number" || !isFinite(sessionTtlMs) || sessionTtlMs <= 0) {
|
|
288
|
+
throw _err("BAD_OPT",
|
|
289
|
+
"apiEncrypt: sessionTtlMs must be a positive finite number (ms), got " +
|
|
290
|
+
JSON.stringify(opts.sessionTtlMs), 500);
|
|
291
|
+
}
|
|
292
|
+
if (typeof sessionMaxResponses !== "number" || !isFinite(sessionMaxResponses) ||
|
|
293
|
+
sessionMaxResponses <= 0 || Math.floor(sessionMaxResponses) !== sessionMaxResponses) {
|
|
294
|
+
throw _err("BAD_OPT",
|
|
295
|
+
"apiEncrypt: sessionMaxResponses must be a positive finite integer, got " +
|
|
296
|
+
JSON.stringify(opts.sessionMaxResponses), 500);
|
|
297
|
+
}
|
|
298
|
+
// sessionStore — duck-typed handle exposing { get, set, delete }. The
|
|
299
|
+
// helper optionalObjectWithMethod only checks one method; here we need
|
|
300
|
+
// three. Inline shape kept; not a generic enough pattern to warrant a
|
|
301
|
+
// separate helper.
|
|
302
|
+
if (opts.sessionStore !== undefined && opts.sessionStore !== null) {
|
|
303
|
+
var ss = opts.sessionStore;
|
|
304
|
+
var ssOk = typeof ss === "object" &&
|
|
305
|
+
typeof ss.get === "function" &&
|
|
306
|
+
typeof ss.set === "function" &&
|
|
307
|
+
typeof ss.delete === "function";
|
|
308
|
+
if (!ssOk) {
|
|
309
|
+
throw _err("BAD_OPT",
|
|
310
|
+
"apiEncrypt: sessionStore must expose { get(sid), set(sid, row, opts?), delete(sid) } " +
|
|
311
|
+
"(b.cache.create() is shape-compatible)", 500);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
var sessionStore = (keying === "per-session" && opts.sessionStore)
|
|
315
|
+
? opts.sessionStore
|
|
316
|
+
: (keying === "per-session" ? _defaultSessionStore() : null);
|
|
317
|
+
// Observability tap — per-session emits counters for sessions
|
|
318
|
+
// established / replay-rejected / expired / rotated. Per-request mode
|
|
319
|
+
// ignores this opt; the existing events.API_ENCRYPT_FAILURE channel
|
|
320
|
+
// already carries failure shape there.
|
|
321
|
+
validateOpts.observabilityShape(opts.observability,
|
|
322
|
+
"apiEncrypt", ApiEncryptError, "BAD_OPT");
|
|
323
|
+
var observabilityHandle = opts.observability || null;
|
|
324
|
+
function _emitObs(name, value, labels) {
|
|
325
|
+
if (observabilityHandle) {
|
|
326
|
+
observabilityHandle.safeEvent(name, value, labels || {});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function _emitSessionAudit(action, info) {
|
|
330
|
+
if (!auditOn) return;
|
|
331
|
+
try {
|
|
332
|
+
audit().safeEmit({
|
|
333
|
+
action: action, outcome: info.outcome || "success",
|
|
334
|
+
metadata: info.metadata || {},
|
|
335
|
+
actor: info.actor || null,
|
|
336
|
+
requestId: info.requestId || null,
|
|
337
|
+
});
|
|
338
|
+
} catch (_e) { /* audit best-effort */ }
|
|
339
|
+
}
|
|
340
|
+
|
|
213
341
|
function _isExempt(req) {
|
|
214
342
|
var p = req.pathname || (req.url || "/").split("?")[0];
|
|
215
343
|
for (var i = 0; i < exemptPaths.length; i++) {
|
|
@@ -267,13 +395,21 @@ function create(opts) {
|
|
|
267
395
|
});
|
|
268
396
|
}
|
|
269
397
|
|
|
270
|
-
|
|
398
|
+
// _wrapResJson — install res.json that encrypts the response with the
|
|
399
|
+
// session key. In per-request mode the response is `{ _ct }`; in
|
|
400
|
+
// per-session mode it carries `{ _ct, _sid, _ctr }` so the client can
|
|
401
|
+
// detect tampered / replayed responses with a monotonic counter check.
|
|
402
|
+
function _wrapResJson(res, sessionKey, sessionCtx) {
|
|
271
403
|
var origJson = res.json;
|
|
272
404
|
res.json = function (data) {
|
|
273
405
|
try {
|
|
274
406
|
var ptBuf = Buffer.from(JSON.stringify(data), "utf8");
|
|
275
407
|
var ctBuf = crypto.encryptPacked(ptBuf, sessionKey);
|
|
276
408
|
var encrypted = { _ct: ctBuf.toString("base64") };
|
|
409
|
+
if (sessionCtx) {
|
|
410
|
+
encrypted._sid = sessionCtx.sid;
|
|
411
|
+
encrypted._ctr = sessionCtx.responseCtr;
|
|
412
|
+
}
|
|
277
413
|
if (typeof origJson === "function") {
|
|
278
414
|
return origJson.call(res, encrypted);
|
|
279
415
|
}
|
|
@@ -294,6 +430,19 @@ function create(opts) {
|
|
|
294
430
|
};
|
|
295
431
|
}
|
|
296
432
|
|
|
433
|
+
// _decryptEkToSessionKey — try every keypair in order; returns the
|
|
434
|
+
// 32-byte sessionKey buffer or null on AEAD failure across all keypairs.
|
|
435
|
+
function _decryptEkToSessionKey(ek) {
|
|
436
|
+
for (var ki = 0; ki < keypairs.length; ki++) {
|
|
437
|
+
try {
|
|
438
|
+
var sessionKeyB64 = crypto.decrypt(ek, keypairs[ki]);
|
|
439
|
+
var candidate = Buffer.from(sessionKeyB64, "base64");
|
|
440
|
+
if (candidate.length === SESSION_KEY_BYTES) return candidate;
|
|
441
|
+
} catch (_e) { /* try next keypair */ }
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
297
446
|
async function middleware(req, res, next) {
|
|
298
447
|
if (_isExempt(req)) return next();
|
|
299
448
|
if (!_matchesContentType(req)) return next();
|
|
@@ -303,58 +452,158 @@ function create(opts) {
|
|
|
303
452
|
_emitFailure(req, "shape");
|
|
304
453
|
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
305
454
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
455
|
+
|
|
456
|
+
var now = Date.now();
|
|
457
|
+
var ct = body._ct, ts = body._ts;
|
|
458
|
+
if (typeof ct !== "string" || typeof ts !== "number") {
|
|
309
459
|
_emitFailure(req, "shape");
|
|
310
460
|
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
311
461
|
}
|
|
312
|
-
|
|
313
|
-
// Replay window — must be within ±replayWindowMs of server clock.
|
|
314
|
-
var now = Date.now();
|
|
315
462
|
if (Math.abs(now - ts) > replayWindowMs) {
|
|
316
463
|
_emitFailure(req, "stale");
|
|
317
464
|
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
318
465
|
}
|
|
319
466
|
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
// doesn't reveal the original 16-byte client nonces (the spec's
|
|
324
|
-
// "sealed nonce hash"). SHA3 is deterministic so PRIMARY KEY
|
|
325
|
-
// conflict detection still works.
|
|
326
|
-
var nonceHash = crypto.sha3Hash(nonce, "hex");
|
|
327
|
-
var expireAt = now + replayWindowMs;
|
|
328
|
-
var freshNonce;
|
|
329
|
-
try { freshNonce = await nonceStore.checkAndInsert(nonceHash, expireAt); }
|
|
330
|
-
catch (_e) {
|
|
331
|
-
_emitFailure(req, "nonce-store-error");
|
|
332
|
-
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
|
|
333
|
-
}
|
|
334
|
-
if (!freshNonce) {
|
|
335
|
-
_emitFailure(req, "replay");
|
|
336
|
-
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Decrypt _ek → session key. During a rotation overlap window,
|
|
340
|
-
// some clients still hold the previous server pubkey — try each
|
|
341
|
-
// keypair in order. The active keypair is keypairs[0]; older
|
|
342
|
-
// rotated-out keypairs follow. AEAD failure on every keypair =
|
|
343
|
-
// genuine bad ciphertext.
|
|
467
|
+
// Per-request OR per-session bootstrap path: shape includes _ek + _nonce.
|
|
468
|
+
// Per-session subsequent path: shape includes _sid + _ctr (no _ek).
|
|
469
|
+
var ek = body._ek, nonce = body._nonce, sid = body._sid, ctr = body._ctr;
|
|
344
470
|
var sessionKey = null;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
471
|
+
var sessionCtx = null; // null = per-request mode response shape
|
|
472
|
+
var session = null;
|
|
473
|
+
|
|
474
|
+
if (typeof ek === "string" && typeof nonce === "string") {
|
|
475
|
+
// ---- Bootstrap path (per-request mode OR first request of session) ----
|
|
476
|
+
var nonceHash = crypto.sha3Hash(nonce, "hex");
|
|
477
|
+
var expireAt = now + replayWindowMs;
|
|
478
|
+
var freshNonce;
|
|
479
|
+
try { freshNonce = await nonceStore.checkAndInsert(nonceHash, expireAt); }
|
|
480
|
+
catch (_e) {
|
|
481
|
+
_emitFailure(req, "nonce-store-error");
|
|
482
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
|
|
483
|
+
}
|
|
484
|
+
if (!freshNonce) {
|
|
485
|
+
_emitFailure(req, "replay");
|
|
486
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
487
|
+
}
|
|
488
|
+
sessionKey = _decryptEkToSessionKey(ek);
|
|
489
|
+
if (!sessionKey) {
|
|
490
|
+
_emitFailure(req, "tag");
|
|
491
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
492
|
+
}
|
|
493
|
+
if (keying === "per-session") {
|
|
494
|
+
if (!_validSid(sid)) {
|
|
495
|
+
_emitFailure(req, "shape");
|
|
496
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
352
497
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
498
|
+
if (typeof ctr !== "number" || !isFinite(ctr) || ctr < 0 || Math.floor(ctr) !== ctr) {
|
|
499
|
+
_emitFailure(req, "shape");
|
|
500
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
501
|
+
}
|
|
502
|
+
// Bootstrap a new session row keyed by sid.
|
|
503
|
+
session = {
|
|
504
|
+
sessionKey: sessionKey,
|
|
505
|
+
lastReqCtr: ctr,
|
|
506
|
+
responsesEmitted: 0,
|
|
507
|
+
createdAt: now,
|
|
508
|
+
lastUsedAt: now,
|
|
509
|
+
expiresAt: now + sessionTtlMs,
|
|
510
|
+
};
|
|
511
|
+
try { await sessionStore.set(sid, session, { ttlMs: sessionTtlMs }); }
|
|
512
|
+
catch (_e) {
|
|
513
|
+
_emitFailure(req, "session-store-error");
|
|
514
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
515
|
+
}
|
|
516
|
+
_emitObs("apiEncrypt.session.created", 1, { mode: "per-session" });
|
|
517
|
+
_emitSessionAudit("apiEncrypt.session.created", {
|
|
518
|
+
actor: requestHelpers.extractActorContext(req),
|
|
519
|
+
metadata: { sid: sid, expiresAt: session.expiresAt },
|
|
520
|
+
requestId: req.requestId || null,
|
|
521
|
+
});
|
|
522
|
+
sessionCtx = { sid: sid, responseCtr: 1 };
|
|
523
|
+
session.responsesEmitted = 1;
|
|
524
|
+
}
|
|
525
|
+
} else if (keying === "per-session" &&
|
|
526
|
+
typeof sid === "string" && typeof ctr === "number") {
|
|
527
|
+
// ---- Per-session subsequent-request path ----
|
|
528
|
+
if (!_validSid(sid)) {
|
|
529
|
+
_emitFailure(req, "shape");
|
|
530
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
531
|
+
}
|
|
532
|
+
if (!isFinite(ctr) || ctr < 0 || Math.floor(ctr) !== ctr) {
|
|
533
|
+
_emitFailure(req, "shape");
|
|
534
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
535
|
+
}
|
|
536
|
+
try { session = await sessionStore.get(sid); }
|
|
537
|
+
catch (_e) {
|
|
538
|
+
_emitFailure(req, "session-store-error");
|
|
539
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
540
|
+
}
|
|
541
|
+
if (!session) {
|
|
542
|
+
_emitObs("apiEncrypt.session.unknown", 1, {});
|
|
543
|
+
_emitFailure(req, "session-unknown");
|
|
544
|
+
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
|
|
545
|
+
}
|
|
546
|
+
if (now > session.expiresAt) {
|
|
547
|
+
try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
|
|
548
|
+
_emitObs("apiEncrypt.session.expired", 1, {});
|
|
549
|
+
_emitSessionAudit("apiEncrypt.session.expired", {
|
|
550
|
+
outcome: "denied",
|
|
551
|
+
actor: requestHelpers.extractActorContext(req),
|
|
552
|
+
metadata: { sid: sid, reason: "ttl_exceeded" },
|
|
553
|
+
requestId: req.requestId || null,
|
|
554
|
+
});
|
|
555
|
+
_emitFailure(req, "session-expired");
|
|
556
|
+
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
|
|
557
|
+
}
|
|
558
|
+
if (session.responsesEmitted >= sessionMaxResponses) {
|
|
559
|
+
try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
|
|
560
|
+
_emitObs("apiEncrypt.session.rotated", 1, { reason: "max_responses" });
|
|
561
|
+
_emitSessionAudit("apiEncrypt.session.rotated", {
|
|
562
|
+
actor: requestHelpers.extractActorContext(req),
|
|
563
|
+
metadata: { sid: sid, reason: "max_responses_exceeded",
|
|
564
|
+
responsesEmitted: session.responsesEmitted },
|
|
565
|
+
requestId: req.requestId || null,
|
|
566
|
+
});
|
|
567
|
+
_emitFailure(req, "session-rotation-required");
|
|
568
|
+
return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
|
|
569
|
+
}
|
|
570
|
+
// Replay defense: counter MUST strictly increase.
|
|
571
|
+
if (ctr <= session.lastReqCtr) {
|
|
572
|
+
_emitObs("apiEncrypt.session.replay_rejected", 1, {});
|
|
573
|
+
_emitSessionAudit("apiEncrypt.session.replay_rejected", {
|
|
574
|
+
outcome: "denied",
|
|
575
|
+
actor: requestHelpers.extractActorContext(req),
|
|
576
|
+
metadata: { sid: sid, receivedCtr: ctr, lastSeen: session.lastReqCtr },
|
|
577
|
+
requestId: req.requestId || null,
|
|
578
|
+
});
|
|
579
|
+
_emitFailure(req, "counter-replay");
|
|
580
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
581
|
+
}
|
|
582
|
+
sessionKey = session.sessionKey;
|
|
583
|
+
if (Buffer.isBuffer(sessionKey) === false) {
|
|
584
|
+
// Operator-supplied store may have JSON-serialised the buffer.
|
|
585
|
+
// Accept hex / base64 / Uint8Array and coerce.
|
|
586
|
+
if (typeof sessionKey === "string") {
|
|
587
|
+
sessionKey = Buffer.from(sessionKey, "base64");
|
|
588
|
+
} else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
|
|
589
|
+
sessionKey = Buffer.from(sessionKey.data);
|
|
590
|
+
} else if (sessionKey instanceof Uint8Array) {
|
|
591
|
+
sessionKey = Buffer.from(sessionKey);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
|
|
595
|
+
_emitFailure(req, "session-store-error");
|
|
596
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
|
|
597
|
+
}
|
|
598
|
+
session.lastReqCtr = ctr;
|
|
599
|
+
session.lastUsedAt = now;
|
|
600
|
+
session.responsesEmitted += 1;
|
|
601
|
+
try { await sessionStore.set(sid, session, { ttlMs: session.expiresAt - now }); }
|
|
602
|
+
catch (_e) { /* best-effort — request still proceeds */ }
|
|
603
|
+
sessionCtx = { sid: sid, responseCtr: session.responsesEmitted };
|
|
604
|
+
} else {
|
|
605
|
+
_emitFailure(req, "shape");
|
|
606
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
358
607
|
}
|
|
359
608
|
|
|
360
609
|
// Decrypt _ct → cleartext payload bytes → JSON object.
|
|
@@ -373,8 +622,9 @@ function create(opts) {
|
|
|
373
622
|
// data (e.g. send a follow-up encrypted SSE event).
|
|
374
623
|
req.body = clearObj;
|
|
375
624
|
req.apiEncryptSessionKey = sessionKey;
|
|
625
|
+
if (sessionCtx) req.apiEncryptSession = { sid: sessionCtx.sid };
|
|
376
626
|
|
|
377
|
-
_wrapResJson(res, sessionKey);
|
|
627
|
+
_wrapResJson(res, sessionKey, sessionCtx);
|
|
378
628
|
_maybePrune();
|
|
379
629
|
|
|
380
630
|
return next();
|
|
@@ -403,7 +653,11 @@ function create(opts) {
|
|
|
403
653
|
middleware.publishPublicKey = publishPublicKey;
|
|
404
654
|
middleware.close = function () {
|
|
405
655
|
if (typeof nonceStore.close === "function") nonceStore.close();
|
|
656
|
+
if (sessionStore && typeof sessionStore.close === "function") sessionStore.close();
|
|
406
657
|
};
|
|
658
|
+
// Expose for tests / operator dashboards. Counts are 0 in per-request mode.
|
|
659
|
+
middleware.sessionStore = sessionStore;
|
|
660
|
+
middleware.keying = keying;
|
|
407
661
|
|
|
408
662
|
return middleware;
|
|
409
663
|
}
|
|
@@ -417,7 +671,7 @@ function create(opts) {
|
|
|
417
671
|
|
|
418
672
|
function client(opts) {
|
|
419
673
|
opts = opts || {};
|
|
420
|
-
validateOpts(opts, ["pubkey", "maxDecryptedBytes"], "middleware.apiEncrypt.client");
|
|
674
|
+
validateOpts(opts, ["pubkey", "maxDecryptedBytes", "keying"], "middleware.apiEncrypt.client");
|
|
421
675
|
if (!opts.pubkey || typeof opts.pubkey !== "object") {
|
|
422
676
|
throw _err("CLIENT_INVALID_PUBKEY",
|
|
423
677
|
"apiEncrypt.client: opts.pubkey is required ({ publicKey, ecPublicKey })", 500);
|
|
@@ -431,8 +685,87 @@ function client(opts) {
|
|
|
431
685
|
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
432
686
|
? opts.maxDecryptedBytes
|
|
433
687
|
: C.BYTES.mib(4);
|
|
688
|
+
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
689
|
+
if (keying !== "per-request" && keying !== "per-session") {
|
|
690
|
+
throw _err("CLIENT_BAD_OPT",
|
|
691
|
+
"apiEncrypt.client: keying must be 'per-request' (default) or 'per-session', got " +
|
|
692
|
+
JSON.stringify(opts.keying), 500);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (keying === "per-request") {
|
|
696
|
+
return { encryptRequest: _encryptPerRequest, keying: keying };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Per-session: stateful client. encryptRequest mutates internal counter.
|
|
700
|
+
// First call sends the bootstrap envelope; subsequent calls omit _ek/_nonce
|
|
701
|
+
// and increment the counter. Operator can call resetSession() to force a
|
|
702
|
+
// new bootstrap (e.g. after server returns "session-expired").
|
|
703
|
+
var perSessionKey = null;
|
|
704
|
+
var perSessionSid = null;
|
|
705
|
+
var perSessionReqCtr = 0;
|
|
706
|
+
var perSessionLastResCtr = 0;
|
|
707
|
+
|
|
708
|
+
function _resetSession() {
|
|
709
|
+
perSessionKey = crypto.generateBytes(SESSION_KEY_BYTES);
|
|
710
|
+
perSessionSid = _generateUuidV4();
|
|
711
|
+
perSessionReqCtr = 0;
|
|
712
|
+
perSessionLastResCtr = 0;
|
|
713
|
+
}
|
|
434
714
|
|
|
435
|
-
function
|
|
715
|
+
function _decryptPerSessionResponse(responseBody) {
|
|
716
|
+
if (!responseBody || typeof responseBody !== "object" ||
|
|
717
|
+
typeof responseBody._ct !== "string") {
|
|
718
|
+
throw _err("CLIENT_RESPONSE_SHAPE",
|
|
719
|
+
"apiEncrypt.client: response missing _ct field");
|
|
720
|
+
}
|
|
721
|
+
if (typeof responseBody._sid !== "string" || responseBody._sid !== perSessionSid) {
|
|
722
|
+
throw _err("CLIENT_RESPONSE_SID",
|
|
723
|
+
"apiEncrypt.client: response sid does not match opened session");
|
|
724
|
+
}
|
|
725
|
+
if (typeof responseBody._ctr !== "number" || responseBody._ctr <= perSessionLastResCtr) {
|
|
726
|
+
throw _err("CLIENT_RESPONSE_REPLAY",
|
|
727
|
+
"apiEncrypt.client: response counter is not strictly increasing " +
|
|
728
|
+
"(got " + responseBody._ctr + ", lastSeen " + perSessionLastResCtr + ")");
|
|
729
|
+
}
|
|
730
|
+
perSessionLastResCtr = responseBody._ctr;
|
|
731
|
+
var resCtBuf = Buffer.from(responseBody._ct, "base64");
|
|
732
|
+
var resPtBuf = crypto.decryptPacked(resCtBuf, perSessionKey);
|
|
733
|
+
return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function _encryptPerSession(payload) {
|
|
737
|
+
if (payload === undefined) payload = null;
|
|
738
|
+
if (!perSessionKey) _resetSession();
|
|
739
|
+
var ts = Date.now();
|
|
740
|
+
var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
|
|
741
|
+
var ctBuf = crypto.encryptPacked(ptBuf, perSessionKey);
|
|
742
|
+
perSessionReqCtr += 1;
|
|
743
|
+
var body;
|
|
744
|
+
if (perSessionReqCtr === 1) {
|
|
745
|
+
// Bootstrap envelope — full _ek + _nonce; server stores sid → sessionKey.
|
|
746
|
+
var ek = crypto.encrypt(perSessionKey.toString("base64"), pubkey);
|
|
747
|
+
var nonce = crypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
|
|
748
|
+
body = {
|
|
749
|
+
_ek: ek,
|
|
750
|
+
_ct: ctBuf.toString("base64"),
|
|
751
|
+
_ts: ts,
|
|
752
|
+
_nonce: nonce,
|
|
753
|
+
_sid: perSessionSid,
|
|
754
|
+
_ctr: perSessionReqCtr,
|
|
755
|
+
};
|
|
756
|
+
} else {
|
|
757
|
+
// Subsequent — sid + ctr only. KEM material amortized across the session.
|
|
758
|
+
body = {
|
|
759
|
+
_ct: ctBuf.toString("base64"),
|
|
760
|
+
_ts: ts,
|
|
761
|
+
_sid: perSessionSid,
|
|
762
|
+
_ctr: perSessionReqCtr,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return { body: body, decryptResponse: _decryptPerSessionResponse };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function _encryptPerRequest(payload) {
|
|
436
769
|
if (payload === undefined) payload = null;
|
|
437
770
|
var sessionKey = crypto.generateBytes(SESSION_KEY_BYTES);
|
|
438
771
|
var ek = crypto.encrypt(sessionKey.toString("base64"), pubkey);
|
|
@@ -447,9 +780,6 @@ function client(opts) {
|
|
|
447
780
|
_ts: ts,
|
|
448
781
|
_nonce: requestNonce,
|
|
449
782
|
},
|
|
450
|
-
// Captured-closure decrypt — safe to pass back to the caller.
|
|
451
|
-
// sessionKey lives only in this closure; once the closure goes
|
|
452
|
-
// out of scope it can be garbage-collected.
|
|
453
783
|
decryptResponse: function (responseBody) {
|
|
454
784
|
if (!responseBody || typeof responseBody !== "object" ||
|
|
455
785
|
typeof responseBody._ct !== "string") {
|
|
@@ -463,7 +793,35 @@ function client(opts) {
|
|
|
463
793
|
};
|
|
464
794
|
}
|
|
465
795
|
|
|
466
|
-
return {
|
|
796
|
+
return {
|
|
797
|
+
encryptRequest: _encryptPerSession,
|
|
798
|
+
resetSession: _resetSession,
|
|
799
|
+
sessionInfo: function () {
|
|
800
|
+
return {
|
|
801
|
+
sid: perSessionSid,
|
|
802
|
+
reqCtr: perSessionReqCtr,
|
|
803
|
+
lastResCtr: perSessionLastResCtr,
|
|
804
|
+
};
|
|
805
|
+
},
|
|
806
|
+
keying: keying,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// _generateUuidV4 — UUID v4 from 16 random bytes, formatted dash-separated.
|
|
811
|
+
// Used for client-side session-id generation in per-session keying.
|
|
812
|
+
// Slice offsets are RFC 4122 UUID hex-byte boundaries (`xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx`)
|
|
813
|
+
// — protocol-fixed values, not byte sizes. allow:raw-byte-literal
|
|
814
|
+
function _generateUuidV4() {
|
|
815
|
+
var b = crypto.generateBytes(16); // allow:raw-byte-literal — UUID is exactly 16 bytes
|
|
816
|
+
// Set version (4) and variant (10x) bits per RFC 4122.
|
|
817
|
+
b[6] = (b[6] & 0x0f) | 0x40;
|
|
818
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
819
|
+
var hex = b.toString("hex");
|
|
820
|
+
return hex.slice(0, 8) + "-" + // allow:raw-byte-literal — RFC 4122 hex offsets
|
|
821
|
+
hex.slice(8, 12) + "-" + // allow:raw-byte-literal
|
|
822
|
+
hex.slice(12, 16) + "-" + // allow:raw-byte-literal
|
|
823
|
+
hex.slice(16, 20) + "-" + // allow:raw-byte-literal
|
|
824
|
+
hex.slice(20, 32); // allow:raw-byte-literal
|
|
467
825
|
}
|
|
468
826
|
|
|
469
827
|
// ---- Server-to-server convenience ----
|
|
@@ -491,7 +849,7 @@ function client(opts) {
|
|
|
491
849
|
function httpClientEncrypted(opts) {
|
|
492
850
|
opts = opts || {};
|
|
493
851
|
validateOpts(opts, [
|
|
494
|
-
"pubkey", "baseUrl", "headers", "method", "maxDecryptedBytes",
|
|
852
|
+
"pubkey", "baseUrl", "headers", "method", "maxDecryptedBytes", "keying",
|
|
495
853
|
], "middleware.apiEncrypt.httpClient");
|
|
496
854
|
if (!opts.pubkey) {
|
|
497
855
|
throw _err("CLIENT_INVALID_PUBKEY",
|
|
@@ -500,7 +858,12 @@ function httpClientEncrypted(opts) {
|
|
|
500
858
|
var maxDecryptedBytes = opts.maxDecryptedBytes != null
|
|
501
859
|
? opts.maxDecryptedBytes
|
|
502
860
|
: C.BYTES.mib(4);
|
|
503
|
-
var
|
|
861
|
+
var keying = opts.keying != null ? opts.keying : "per-request";
|
|
862
|
+
var clientCtx = client({
|
|
863
|
+
pubkey: opts.pubkey,
|
|
864
|
+
maxDecryptedBytes: maxDecryptedBytes,
|
|
865
|
+
keying: keying,
|
|
866
|
+
});
|
|
504
867
|
var baseUrl = opts.baseUrl ? String(opts.baseUrl).replace(/\/$/, "") : "";
|
|
505
868
|
var defaultHdrs = opts.headers || {};
|
|
506
869
|
var defaultMethod = opts.method || "POST";
|
|
@@ -113,14 +113,9 @@ function create(opts) {
|
|
|
113
113
|
|
|
114
114
|
validateOpts.optionalFunction(opts.resolve, "middleware.dbRoleFor: resolve", DbRoleForError, "db-role-for/bad-opt");
|
|
115
115
|
validateOpts.optionalFunction(opts.responder, "middleware.dbRoleFor: responder", DbRoleForError, "db-role-for/bad-opt");
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
throw _err("db-role-for/bad-opt",
|
|
120
|
-
"middleware.dbRoleFor: permissions must be a b.permissions instance " +
|
|
121
|
-
"(missing dbRoleFor method)");
|
|
122
|
-
}
|
|
123
|
-
}
|
|
116
|
+
validateOpts.optionalObjectWithMethod(opts.permissions, "dbRoleFor",
|
|
117
|
+
"middleware.dbRoleFor: permissions", DbRoleForError, "db-role-for/bad-opt",
|
|
118
|
+
"must be a b.permissions instance (missing dbRoleFor method)");
|
|
124
119
|
if (opts.defaultRole !== undefined && opts.defaultRole !== null) {
|
|
125
120
|
if (typeof opts.defaultRole !== "string" || opts.defaultRole.length === 0) {
|
|
126
121
|
throw _err("db-role-for/bad-opt",
|
package/lib/notify.js
CHANGED
|
@@ -154,11 +154,9 @@ function _validateCreateOpts(opts) {
|
|
|
154
154
|
typeof opts.defaultBreaker !== "object") {
|
|
155
155
|
throw _err("BAD_OPT", "notify.create: defaultBreaker must be a b.retry.CircuitBreaker opts object");
|
|
156
156
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
157
|
+
validateOpts.optionalObjectWithMethod(opts.queue, "enqueue",
|
|
158
|
+
"notify.create: queue", NotifyError, "BAD_OPT",
|
|
159
|
+
"must be a b.queue-shaped handle (enqueue fn)");
|
|
162
160
|
validateOpts.optionalFunction(opts.clock, "notify.create: clock", NotifyError);
|
|
163
161
|
}
|
|
164
162
|
|