@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.
@@ -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
  };
@@ -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 () { /* defer to 'close' for reconnect */ });
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
- function _wrapResJson(res, sessionKey) {
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
- var ek = body._ek, ct = body._ct, ts = body._ts, nonce = body._nonce;
307
- if (typeof ek !== "string" || typeof ct !== "string" ||
308
- typeof ts !== "number" || typeof nonce !== "string") {
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
- // Nonce check + insert atomically. Loser of the insert race
321
- // (= already-seen nonce within the replay window) is a replay.
322
- // Hash the nonce before storage so a leaked DB / table dump
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
- for (var ki = 0; ki < keypairs.length; ki++) {
346
- try {
347
- var sessionKeyB64 = crypto.decrypt(ek, keypairs[ki]);
348
- var candidate = Buffer.from(sessionKeyB64, "base64");
349
- if (candidate.length === SESSION_KEY_BYTES) {
350
- sessionKey = candidate;
351
- break;
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
- } catch (_e) { /* try next keypair */ }
354
- }
355
- if (!sessionKey) {
356
- _emitFailure(req, "tag");
357
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
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 encryptRequest(payload) {
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 { encryptRequest: encryptRequest };
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 clientCtx = client({ pubkey: opts.pubkey, maxDecryptedBytes: maxDecryptedBytes });
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
- if (opts.permissions !== undefined && opts.permissions !== null) {
117
- if (typeof opts.permissions !== "object" ||
118
- typeof opts.permissions.dbRoleFor !== "function") {
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
- if (opts.queue !== undefined && opts.queue !== null) {
158
- if (typeof opts.queue !== "object" || typeof opts.queue.enqueue !== "function") {
159
- throw _err("BAD_OPT", "notify.create: queue must be a b.queue-shaped handle (enqueue fn)");
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