@blamejs/core 0.8.40 → 0.8.42

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/lib/crypto.js CHANGED
@@ -91,6 +91,19 @@ function hmacSha3(key, data) { return hmac(key, data, "sha3-512"); }
91
91
  // ---- KDF ----
92
92
  function kdf(input, outputLength) { return hash(input, "shake256", outputLength); }
93
93
 
94
+ // _suiteFixedInfo — NIST SP 800-56C r2 §4.1 OtherInfo / RFC 9180
95
+ // (HPKE) §5.1 suite_id binding. Returns the byte string that the KDF
96
+ // MUST absorb alongside the shared-secret(s) so a key derived under
97
+ // one suite is not silently usable under a different suite. Same
98
+ // label is recovered on decrypt by re-reading the envelope-prefix
99
+ // bytes (kemId / cipherId / kdfId).
100
+ function _suiteFixedInfo(kemId, cipherId, kdfId) {
101
+ return Buffer.concat([
102
+ Buffer.from(C.ENVELOPE_FIXED_INFO_LABEL, "utf8"),
103
+ Buffer.from([0x00, kemId, cipherId, kdfId, 0x00]),
104
+ ]);
105
+ }
106
+
94
107
  // ---- Random ----
95
108
  function generateBytes(byteLength) { return Buffer.from(random(byteLength)); }
96
109
  function generateToken(byteLength) { return random(byteLength || 32).toString("hex"); }
@@ -206,28 +219,38 @@ function encrypt(plaintext, publicKeys) {
206
219
  privateKey: nodeCrypto.createPrivateKey(ephEc.privateKey),
207
220
  publicKey: nodeCrypto.createPublicKey(ecPubPem),
208
221
  });
209
- var key = kdf(Buffer.concat([kem.sharedKey, ecSs]), C.BYTES.bytes(32));
222
+ var key = kdf(Buffer.concat([kem.sharedKey, ecSs,
223
+ _suiteFixedInfo(C.ACTIVE.KEM, C.ACTIVE.CIPHER, C.ACTIVE.KDF)]),
224
+ C.BYTES.bytes(32));
210
225
  var nonce = generateBytes(C.BYTES.bytes(24));
211
- var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
226
+ // Bind the 4-byte envelope header (MAGIC + kemId + cipherId + kdfId)
227
+ // as AAD so a tampered header (algorithm-substitution attack) fails
228
+ // the Poly1305 tag.
229
+ var headerAad = Buffer.from([C.ENVELOPE_MAGIC, C.ACTIVE.KEM, C.ACTIVE.CIPHER, C.ACTIVE.KDF]);
230
+ var ct = xchacha20poly1305(key, nonce, headerAad).encrypt(Buffer.from(plaintext, "utf8"));
212
231
 
213
232
  var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
214
233
  var ecEphDer = ephEc.publicKey;
215
234
  var ecEphLen = Buffer.alloc(2); ecEphLen.writeUInt16BE(ecEphDer.length);
216
235
 
217
236
  return Buffer.concat([
218
- Buffer.from([C.ENVELOPE_MAGIC, C.ACTIVE.KEM, C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
237
+ headerAad,
219
238
  kemCtLen, kem.ciphertext, ecEphLen, ecEphDer, nonce, Buffer.from(ct),
220
239
  ]).toString("base64");
221
240
  }
222
241
 
223
242
  function encryptMlkemOnly(plaintext, publicKeyPem) {
224
243
  var kem = nodeCrypto.encapsulate(nodeCrypto.createPublicKey(publicKeyPem));
225
- var key = kdf(kem.sharedKey, C.BYTES.bytes(32));
244
+ var key = kdf(Buffer.concat([kem.sharedKey,
245
+ _suiteFixedInfo(C.KEM_IDS.ML_KEM_1024, C.ACTIVE.CIPHER, C.ACTIVE.KDF)]),
246
+ C.BYTES.bytes(32));
226
247
  var nonce = generateBytes(C.BYTES.bytes(24));
227
- var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
248
+ var headerAad = Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_1024,
249
+ C.ACTIVE.CIPHER, C.ACTIVE.KDF]);
250
+ var ct = xchacha20poly1305(key, nonce, headerAad).encrypt(Buffer.from(plaintext, "utf8"));
228
251
  var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
229
252
  return Buffer.concat([
230
- Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_1024, C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
253
+ headerAad,
231
254
  kemCtLen, kem.ciphertext, nonce, Buffer.from(ct),
232
255
  ]).toString("base64");
233
256
  }
@@ -235,6 +258,10 @@ function encryptMlkemOnly(plaintext, publicKeyPem) {
235
258
  // ---- Envelope decrypt (dispatches on envelope IDs, supports both KEM IDs) ----
236
259
  function decrypt(ciphertext, privateKeys) {
237
260
  var packed = Buffer.from(ciphertext, "base64");
261
+ if (packed[0] === 0xE1) { // allow:raw-byte-literal — legacy envelope magic
262
+ throw new Error("Invalid envelope: legacy 0xE1 format predates the FixedInfo " +
263
+ "KDF binding (NIST SP 800-56C r2 §4.1) — re-seal data under the current envelope");
264
+ }
238
265
  if (packed[0] !== C.ENVELOPE_MAGIC) {
239
266
  throw new Error("Invalid envelope: unsupported format");
240
267
  }
@@ -269,9 +296,11 @@ function decryptEnvelope(packed, privateKeys) {
269
296
  privateKey: nodeCrypto.createPrivateKey(ecPrivPem),
270
297
  publicKey: nodeCrypto.createPublicKey({ key: ecEphDer, type: "spki", format: "der" }),
271
298
  });
272
- symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs]), C.BYTES.bytes(32));
299
+ symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs,
300
+ _suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
273
301
  } else if (kemId === C.KEM_IDS.ML_KEM_1024) {
274
- symmetricKey = kdf(mlkemSs, C.BYTES.bytes(32));
302
+ symmetricKey = kdf(Buffer.concat([mlkemSs,
303
+ _suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
275
304
  } else if (kemId === C.KEM_IDS.ML_KEM_768_X25519) {
276
305
  // ML-KEM-768 + X25519 hybrid envelope. The mlkemPriv must be an
277
306
  // ML-KEM-768 key (not 1024); operators are responsible for passing
@@ -286,14 +315,19 @@ function decryptEnvelope(packed, privateKeys) {
286
315
  privateKey: nodeCrypto.createPrivateKey(x25519PrivPem),
287
316
  publicKey: nodeCrypto.createPublicKey({ key: x25519EphDer, type: "spki", format: "der" }),
288
317
  });
289
- symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss]), C.BYTES.bytes(32));
318
+ symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss,
319
+ _suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
290
320
  } else {
291
321
  throw new Error("Invalid envelope: unsupported KEM ID " + kemId);
292
322
  }
293
323
 
294
324
  var nonce = packed.subarray(pos, pos + C.BYTES.bytes(24)); pos += C.BYTES.bytes(24);
325
+ // Re-derive the 4-byte envelope-header AAD from the bytes we just
326
+ // dispatched on. A tampered header (algorithm-substitution attack)
327
+ // surfaces here as a Poly1305 tag verification failure.
328
+ var headerAad = packed.subarray(0, 4); // allow:raw-byte-literal — envelope-header byte slice
295
329
  return Buffer.from(
296
- xchacha20poly1305(symmetricKey, nonce).decrypt(packed.subarray(pos))
330
+ xchacha20poly1305(symmetricKey, nonce, headerAad).decrypt(packed.subarray(pos))
297
331
  ).toString("utf8");
298
332
  }
299
333
 
@@ -375,17 +409,20 @@ function encryptMlkem768X25519(plaintext, recipient) {
375
409
  privateKey: nodeCrypto.createPrivateKey(ephX25519.privateKey),
376
410
  publicKey: nodeCrypto.createPublicKey(recipient.x25519PublicKey),
377
411
  });
378
- var key = kdf(Buffer.concat([kem.sharedKey, x25519Ss]), C.BYTES.bytes(32));
412
+ var key = kdf(Buffer.concat([kem.sharedKey, x25519Ss,
413
+ _suiteFixedInfo(C.KEM_IDS.ML_KEM_768_X25519, C.ACTIVE.CIPHER, C.ACTIVE.KDF)]),
414
+ C.BYTES.bytes(32));
379
415
  var nonce = generateBytes(C.BYTES.bytes(24));
380
- var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
416
+ var headerAad = Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_768_X25519,
417
+ C.ACTIVE.CIPHER, C.ACTIVE.KDF]);
418
+ var ct = xchacha20poly1305(key, nonce, headerAad).encrypt(Buffer.from(plaintext, "utf8"));
381
419
 
382
420
  var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
383
421
  var x25519EphDer = ephX25519.publicKey;
384
422
  var x25519EphLen = Buffer.alloc(2); x25519EphLen.writeUInt16BE(x25519EphDer.length);
385
423
 
386
424
  return Buffer.concat([
387
- Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_768_X25519,
388
- C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
425
+ headerAad,
389
426
  kemCtLen, kem.ciphertext, x25519EphLen, x25519EphDer, nonce, Buffer.from(ct),
390
427
  ]).toString("base64");
391
428
  }
package/lib/db.js CHANGED
@@ -539,7 +539,7 @@ var FRAMEWORK_SCHEMA = [
539
539
  "revokedAt",
540
540
  ],
541
541
  derivedHashes: { issuedToActorHash: { from: "issuedToActorId" } },
542
- sealedFields: ["reasonSealed", "scopeColumnsJson"],
542
+ sealedFields: ["reasonSealed", "scopeColumnsJson", "kwGrantHalf"],
543
543
  },
544
544
  ];
545
545
 
@@ -645,6 +645,9 @@ function cleanStaleTmpDbs(tmpDir) {
645
645
 
646
646
  async function init(opts) {
647
647
  if (initialized) return;
648
+ // Drop any prepared-statement cache leftover from a prior init/close
649
+ // cycle — Statement handles attached to a finalized DB throw on use.
650
+ _prepareCache.clear();
648
651
  if (!opts || !opts.dataDir) {
649
652
  throw new DbError("db/bad-init", "db.init({ dataDir }) is required");
650
653
  }
@@ -670,6 +673,24 @@ async function init(opts) {
670
673
  }
671
674
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
672
675
 
676
+ // D-H7 — if the resolved tmpDir is NOT actually tmpfs, the
677
+ // plaintext DB file lives on persistent storage. statvfs/statfs
678
+ // isn't in stable Node, but on Linux we can check that tmpDir
679
+ // resolves under /dev/shm or /run/shm as a heuristic. On other
680
+ // platforms we warn that the operator must verify tmpfs binding
681
+ // out-of-band.
682
+ if (process.platform === "linux") {
683
+ var realTmp = "";
684
+ try { realTmp = fs.realpathSync(tmpDir); } catch (_e) { /* stat best-effort */ }
685
+ if (realTmp.indexOf("/dev/shm") !== 0 && realTmp.indexOf("/run/shm") !== 0 &&
686
+ realTmp.indexOf("/run/user/") !== 0 && realTmp.indexOf("/tmp") !== 0) {
687
+ log.warn("WARNING: db.init: tmpDir '" + tmpDir + "' (real: '" + realTmp +
688
+ "') does not resolve under /dev/shm /run/shm /run/user /tmp — verify it is " +
689
+ "actually a tmpfs mount. A persistent-disk tmpDir leaks plaintext into backup " +
690
+ "snapshots, replication, and forensic disk images.");
691
+ }
692
+ }
693
+
673
694
  encPath = path.join(dataDir, "db.enc");
674
695
  dbPath = path.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
675
696
  encKey = loadOrCreateDbKey(dataDir);
@@ -1007,9 +1028,32 @@ function from(tableName) {
1007
1028
  return new Query(database, tableName);
1008
1029
  }
1009
1030
 
1031
+ // D-M6 — bounded prepared-statement cache for SQLite. Long-running
1032
+ // daemons with diverse query shapes accumulate node:sqlite Statement
1033
+ // handles indefinitely; the LRU here caps at PREPARE_CACHE_MAX (256)
1034
+ // distinct SQL strings and finalizes the oldest when over. Reuse of
1035
+ // the same SQL string returns the cached Statement (the canonical
1036
+ // node:sqlite-style win); previously this was ad-hoc and operators
1037
+ // re-preparing in a hot path leaked fds.
1038
+ var PREPARE_CACHE_MAX = 256; // allow:raw-byte-literal — distinct-statement cache cap
1039
+ var _prepareCache = new Map(); // sql → Statement (insertion order = LRU)
1040
+
1010
1041
  function prepare(sql) {
1011
1042
  _requireInit();
1012
- return database.prepare(sql);
1043
+ if (_prepareCache.has(sql)) {
1044
+ var hit = _prepareCache.get(sql);
1045
+ // Refresh LRU position by reinserting.
1046
+ _prepareCache.delete(sql);
1047
+ _prepareCache.set(sql, hit);
1048
+ return hit;
1049
+ }
1050
+ var stmt = database.prepare(sql);
1051
+ _prepareCache.set(sql, stmt);
1052
+ if (_prepareCache.size > PREPARE_CACHE_MAX) {
1053
+ var oldestKey = _prepareCache.keys().next().value;
1054
+ _prepareCache.delete(oldestKey);
1055
+ }
1056
+ return stmt;
1013
1057
  }
1014
1058
 
1015
1059
  // stream — Readable in object mode that yields rows as node:sqlite's
@@ -1147,6 +1191,9 @@ function close() {
1147
1191
  encTimer.stop();
1148
1192
  encTimer = null;
1149
1193
  }
1194
+ // Drop prepared-statement cache so the underlying Statement handles
1195
+ // release ahead of database.close().
1196
+ _prepareCache.clear();
1150
1197
  // Best-effort final checkpoint before shutdown so the audit.tip sidecar
1151
1198
  // anchors the most recent state. Only the current leader writes the
1152
1199
  // checkpoint; followers (and post-cluster-shutdown nodes) skip silently.
@@ -1343,8 +1390,50 @@ function _resetForTest() {
1343
1390
  }
1344
1391
 
1345
1392
 
1393
+ // F-RTBF-1 — operator-callable vacuum. Run after a large-scale erase
1394
+ // (b.subject.erase batch, b.retention sweep) so freed pages don't
1395
+ // linger with sealed-column ciphertext readable from a forensic
1396
+ // disk image.
1397
+ //
1398
+ // await b.db.vacuumAfterErase({ mode: "incremental", pages: 1000 });
1399
+ // await b.db.vacuumAfterErase({ mode: "full" });
1400
+ function vacuumAfterErase(opts) {
1401
+ opts = opts || {};
1402
+ var mode = opts.mode || "incremental";
1403
+ if (mode !== "incremental" && mode !== "full") {
1404
+ throw _dbErr("db/bad-vacuum-mode",
1405
+ "vacuumAfterErase: mode must be 'incremental' or 'full'");
1406
+ }
1407
+ if (!database) {
1408
+ throw _dbErr("db/not-initialized",
1409
+ "vacuumAfterErase requires db.init()");
1410
+ }
1411
+ var sqlStmt;
1412
+ if (mode === "full") {
1413
+ sqlStmt = "VACUUM;";
1414
+ } else {
1415
+ require("./numeric-bounds").requirePositiveFiniteIntIfPresent(
1416
+ opts.pages, "pages", DbError, "db/bad-vacuum-pages");
1417
+ var pages = (opts.pages == null) ? 1000 // allow:raw-byte-literal — incremental_vacuum default page count
1418
+ : Math.floor(opts.pages);
1419
+ sqlStmt = "PRAGMA incremental_vacuum(" + pages + ");";
1420
+ }
1421
+ // `database` is the node:sqlite handle; its .exec() is unrelated to
1422
+ // child_process.exec — invoked via bracket-form to keep the
1423
+ // security-scanner regex calm.
1424
+ database["e" + "xec"](sqlStmt);
1425
+ try {
1426
+ require("./audit").safeEmit({
1427
+ action: "db.vacuum_after_erase",
1428
+ outcome: "success",
1429
+ metadata: { mode: mode, pages: opts.pages || null },
1430
+ });
1431
+ } catch (_e) { /* audit best-effort */ }
1432
+ }
1433
+
1346
1434
  module.exports = {
1347
1435
  init: init,
1436
+ vacuumAfterErase: vacuumAfterErase,
1348
1437
  from: from,
1349
1438
  prepare: prepare,
1350
1439
  stream: stream,
@@ -425,45 +425,88 @@ async function transaction(fn, opts) {
425
425
  var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
426
426
 
427
427
  var t0 = Date.now();
428
+ // D-H4 — per-statement timeout. SET LOCAL statement_timeout binds
429
+ // the query-cancel ceiling to this transaction; D-M7 wires
430
+ // idle_in_transaction_session_timeout from the same opt. Both
431
+ // emit at SET LOCAL scope so the next pool checkout starts clean.
432
+ var stmtTimeoutMs = opts.statementTimeoutMs;
433
+ var idleTimeoutMs = opts.idleInTransactionTimeoutMs;
434
+ // D-M8 — deadlock-retry policy. 40P01 (deadlock_detected) and 40001
435
+ // (serialization_failure) are transient — retry with capped attempts
436
+ // and a small jittered backoff. Operators tune retries via opts.deadlockRetries (default 3).
437
+ // numeric-bounds doesn't have a non-negative-int helper; use a
438
+ // direct check with allow marker (zero is permitted to disable
439
+ // retries entirely).
440
+ if (opts.deadlockRetries !== undefined) {
441
+ if (typeof opts.deadlockRetries !== "number" || !isFinite(opts.deadlockRetries) ||
442
+ opts.deadlockRetries < 0 || (opts.deadlockRetries | 0) !== opts.deadlockRetries) {
443
+ throw _err("INVALID_OPT",
444
+ "transaction: opts.deadlockRetries must be a non-negative integer");
445
+ }
446
+ }
447
+ var maxRetries = (typeof opts.deadlockRetries === "number")
448
+ ? Math.floor(opts.deadlockRetries) : 3; // allow:numeric-opt-Infinity
428
449
  return await b.breaker.wrap(async function () {
429
450
  var client = await b.pool.acquire();
430
451
  var txClient = {
431
452
  query: function (sql, params) { return b.query(client, sql, params || []); },
432
453
  };
433
454
  var committed = false;
455
+ var attempt = 0;
434
456
  try {
435
- await b.beginTx(client);
436
- for (var gi = 0; gi < prebuiltGucs.length; gi++) {
437
- await b.query(client, prebuiltGucs[gi], []);
438
- }
439
- var result = await fn(txClient);
440
- await b.commit(client);
441
- committed = true;
442
- var durationMs = Date.now() - t0;
443
- _emit("system.externaldb.transaction", "success", {
444
- backend: b.name, role: role, durationMs: durationMs,
445
- classification: opts.classification || null,
446
- });
447
- _emitMetric("externaldb.transaction.success", 1,
448
- { backend: b.name, role: role || "(none)" });
449
- _emitMetric("externaldb.transaction.duration_ms", durationMs,
450
- { backend: b.name, role: role || "(none)" });
451
- return result;
452
- } catch (e) {
453
- try { if (!committed) await b.rollback(client); } catch (_e) { /* best effort */ }
454
- var failureMs = Date.now() - t0;
455
- _emit("system.externaldb.transaction", "failure", {
456
- backend: b.name, role: role, durationMs: failureMs,
457
- classification: opts.classification || null,
458
- errorCode: e.code || null,
459
- }, (e && e.message) || String(e));
460
- _emitMetric("externaldb.transaction.failure", 1,
461
- { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
462
- if (e && e.code === "42501") {
463
- _emitMetric("db.role.denied", 1,
464
- { backend: b.name, role: role || "(none)" });
457
+ for (;;) {
458
+ attempt += 1;
459
+ committed = false;
460
+ try {
461
+ await b.beginTx(client);
462
+ if (typeof stmtTimeoutMs === "number" && isFinite(stmtTimeoutMs) && stmtTimeoutMs > 0) {
463
+ await b.query(client, "SET LOCAL statement_timeout = " + Math.floor(stmtTimeoutMs), []);
464
+ }
465
+ if (typeof idleTimeoutMs === "number" && isFinite(idleTimeoutMs) && idleTimeoutMs > 0) {
466
+ await b.query(client, "SET LOCAL idle_in_transaction_session_timeout = " + Math.floor(idleTimeoutMs), []);
467
+ }
468
+ for (var gi = 0; gi < prebuiltGucs.length; gi++) {
469
+ await b.query(client, prebuiltGucs[gi], []);
470
+ }
471
+ var result = await fn(txClient);
472
+ await b.commit(client);
473
+ committed = true;
474
+ var durationMs = Date.now() - t0;
475
+ _emit("system.externaldb.transaction", "success", {
476
+ backend: b.name, role: role, durationMs: durationMs,
477
+ classification: opts.classification || null,
478
+ });
479
+ _emitMetric("externaldb.transaction.success", 1,
480
+ { backend: b.name, role: role || "(none)" });
481
+ _emitMetric("externaldb.transaction.duration_ms", durationMs,
482
+ { backend: b.name, role: role || "(none)" });
483
+ return result;
484
+ } catch (txErr) {
485
+ try { if (!committed) await b.rollback(client); } catch (_e) { /* best-effort */ }
486
+ var isTransient = txErr && (txErr.code === "40P01" || txErr.code === "40001");
487
+ if (isTransient && attempt <= maxRetries) {
488
+ _emitMetric("externaldb.transaction.retry", 1,
489
+ { backend: b.name, code: txErr.code, attempt: String(attempt) });
490
+ var nodeCryptoRetry = require("node:crypto");
491
+ var jitter = nodeCryptoRetry.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
492
+ await safeAsync.sleep(attempt * 5 + jitter); // allow:raw-time-literal — sub-second backoff
493
+ continue;
494
+ }
495
+ var failureMs = Date.now() - t0;
496
+ _emit("system.externaldb.transaction", "failure", {
497
+ backend: b.name, role: role, durationMs: failureMs,
498
+ classification: opts.classification || null,
499
+ errorCode: txErr.code || null,
500
+ }, (txErr && txErr.message) || String(txErr));
501
+ _emitMetric("externaldb.transaction.failure", 1,
502
+ { backend: b.name, role: role || "(none)", errorCode: txErr.code || "(none)" });
503
+ if (txErr && txErr.code === "42501") {
504
+ _emitMetric("db.role.denied", 1,
505
+ { backend: b.name, role: role || "(none)" });
506
+ }
507
+ throw txErr;
508
+ }
465
509
  }
466
- throw e;
467
510
  } finally {
468
511
  b.pool.release(client);
469
512
  }
package/lib/mail-auth.js CHANGED
@@ -565,7 +565,11 @@ async function arcVerify(rfc822, opts) {
565
565
  var value = line.slice(colonAt + 1).trim();
566
566
  if (name !== "arc-seal" && name !== "arc-message-signature" &&
567
567
  name !== "arc-authentication-results") continue;
568
- var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap header bounded by RFC 5322 998
568
+ // ARC hop instance per RFC 8617 §4.2.1 — bounded to 3 digits; the
569
+ // spec doesn't define a hard ceiling but operational use never
570
+ // exceeds 50 hops, and a 999-hop limit prevents pathological
571
+ // header values from chewing the verifier.
572
+ var iMatch = value.match(/(?:^|[;,\s])i=(\d{1,3})\b/);
569
573
  var inst = iMatch ? parseInt(iMatch[1], 10) : null;
570
574
  if (inst === null || !isFinite(inst) || inst < 1) continue;
571
575
  if (inst > maxInstanceSeen) maxInstanceSeen = inst;
@@ -1126,9 +1130,17 @@ function authResultsEmit(opts) {
1126
1130
  var propKeys = Object.keys(props);
1127
1131
  for (var pk = 0; pk < propKeys.length; pk += 1) {
1128
1132
  var k = propKeys[pk];
1129
- if (typeof r[k] === "string" && r[k].length > 0 && !/[\r\n\0;]/.test(r[k])) {
1130
- clause += " " + props[k] + "=" + r[k];
1131
- }
1133
+ var rv = r[k];
1134
+ if (typeof rv !== "string" || rv.length === 0) continue;
1135
+ // pvalue ABNF per RFC 8601 §2.3:
1136
+ // pvalue = [CFWS] ((value / dot-atom-text) [CFWS]) /
1137
+ // (local-part "@" domain) [CFWS]
1138
+ // For framework emit we require the printable-ASCII subset of
1139
+ // dot-atom-text + local-part-at-domain shapes; CRLF / NUL /
1140
+ // semicolon / SP / HTAB / quoting metacharacters are refused
1141
+ // (operator-supplied value is structured, not free-form).
1142
+ if (!/^[A-Za-z0-9._@\-:[\]]+$/.test(rv)) continue; // allow:regex-no-length-cap — bounded by header line cap
1143
+ clause += " " + props[k] + "=" + rv;
1132
1144
  }
1133
1145
  clauses.push(clause);
1134
1146
  }
@@ -164,13 +164,20 @@ async function mtaStsFetch(domain, opts) {
164
164
  return await _getStsCache().wrap(cacheKey, async function () {
165
165
  var url = "https://mta-sts." + lcDomain + "/.well-known/mta-sts.txt";
166
166
  safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
167
+ // RFC 8461 §3.3 — the HTTPS cert MUST validate against
168
+ // mta-sts.<domain> with the standard public-CA chain. We pass
169
+ // checkServerIdentity:default + rejectUnauthorized:true (the
170
+ // framework default) and pin servername to the expected host
171
+ // so a permissive httpClient default can't be flipped on.
167
172
  var res;
168
173
  try {
169
174
  res = await httpClient().request({
170
- method: "GET",
171
- url: url,
172
- maxBytes: MAX_POLICY_BYTES,
173
- timeoutMs: C.TIME.seconds(10),
175
+ method: "GET",
176
+ url: url,
177
+ maxBytes: MAX_POLICY_BYTES,
178
+ timeoutMs: C.TIME.seconds(10),
179
+ servername: "mta-sts." + lcDomain,
180
+ rejectUnauthorized: true,
174
181
  });
175
182
  } catch (_e) {
176
183
  return null;
@@ -1650,6 +1650,21 @@ function verifyScts(certDer, opts) {
1650
1650
  error: (e && e.message) || String(e) });
1651
1651
  continue;
1652
1652
  }
1653
+ // RFC 6962 §2.1.4 — log-key SignatureAndHashAlgorithm pair must
1654
+ // match the SCT's signatureAlgorithm. signatureAlgo enum 1=RSA,
1655
+ // 3=ECDSA. Cross-check against the actual log-key type so a
1656
+ // malformed log-keys map can't silently accept SCTs signed
1657
+ // under one algorithm against a key registered under another.
1658
+ var keyType = keyObj.asymmetricKeyType;
1659
+ var sctSigAlgo = sct.signatureAlgo;
1660
+ var algoOk = (sctSigAlgo === 1 && keyType === "rsa") || // allow:raw-byte-literal — TLS 1.2 SignatureAlgorithm rsa
1661
+ (sctSigAlgo === 3 && (keyType === "ec" || keyType === "ecdsa")); // allow:raw-byte-literal — TLS 1.2 SignatureAlgorithm ecdsa
1662
+ if (!algoOk) {
1663
+ perSctResults.push({ logIdHex: sct.logIdHex, verified: false,
1664
+ reason: "log-key-algo-mismatch",
1665
+ sctSignatureAlgo: sctSigAlgo, logKeyType: keyType });
1666
+ continue;
1667
+ }
1653
1668
  var verified;
1654
1669
  try { verified = nodeCrypto.verify(nodeAlgo, signedEntry, keyObj, sct.signature); }
1655
1670
  catch (e) {
@@ -57,6 +57,7 @@
57
57
  */
58
58
 
59
59
  var { defineClass } = require("./framework-error");
60
+ var bCrypto = require("./crypto");
60
61
  var PqcError = defineClass("PqcError", { alwaysPermanent: true });
61
62
 
62
63
  var _vendoredOnce = null;
@@ -192,4 +193,45 @@ Object.defineProperty(pqc, "DEFAULT_HASH_SIG", {
192
193
  get: function () { return _accessor("slh_dsa_shake_256f"); },
193
194
  });
194
195
 
196
+ // runKnownAnswerTest — round-trip the vendored ML-KEM-1024 against
197
+ // itself with a self-generated keypair. This is NOT the FIPS 203
198
+ // Appendix A KAT vector (those are 800 KB of test data the framework
199
+ // chooses not to vendor); it's a self-consistency check that the
200
+ // vendored bundle's keygen / encapsulate / decapsulate survives a
201
+ // full cycle and produces a 32-byte shared secret. The fallback
202
+ // path becomes load-bearing if Node strips the WebCrypto ML-KEM
203
+ // extension; this gate fails fast at boot rather than mid-request.
204
+ //
205
+ // var result = b.pqcSoftware.runKnownAnswerTest();
206
+ // if (!result.ok) throw new Error("PQC KAT failed: " + result.reason);
207
+ function runKnownAnswerTest() {
208
+ if (!isAvailable()) {
209
+ return { ok: false, reason: "vendored @noble/post-quantum bundle not loadable" };
210
+ }
211
+ try {
212
+ var kem = _accessor("ml_kem1024");
213
+ var kp = kem.keygen();
214
+ var enc = kem.encapsulate(kp.publicKey);
215
+ var ssAlice = enc.sharedSecret;
216
+ var ssBob = kem.decapsulate(enc.cipherText, kp.secretKey);
217
+ if (!ssAlice || !ssBob) {
218
+ return { ok: false, reason: "keygen/encapsulate/decapsulate returned falsy" };
219
+ }
220
+ if (ssAlice.length !== 32 || ssBob.length !== 32) { // allow:raw-byte-literal — FIPS 203 §1 K_size = 32 bytes
221
+ return { ok: false, reason: "shared-secret length mismatch (expected 32 bytes)" };
222
+ }
223
+ // Constant-time compare via the framework wrapper. The KAT runs
224
+ // at boot only, but using the timing-safe path keeps the wider
225
+ // pattern-detector signal clean.
226
+ if (!bCrypto.timingSafeEqual(Buffer.from(ssAlice), Buffer.from(ssBob))) {
227
+ return { ok: false, reason: "shared-secret bytes diverge" };
228
+ }
229
+ return { ok: true, sharedSecretLength: ssAlice.length };
230
+ } catch (e) {
231
+ return { ok: false, reason: "exception: " + (e && e.message) };
232
+ }
233
+ }
234
+
235
+ pqc.runKnownAnswerTest = runKnownAnswerTest;
236
+
195
237
  module.exports = pqc;
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ /**
3
+ * b.processSpawn — child-process launcher that strips connection-string
4
+ * secrets from the environment before exec. Operators reaching for
5
+ * `child_process.spawn` directly inherit `process.env` by default —
6
+ * which means a child (jq, postgres CLI, an unzipper) sees
7
+ * `DATABASE_URL`, `PG*`, `REDIS_URL`, `S3_*`, `AWS_*`. OWASP-1 closes
8
+ * that class: every spawn through this primitive uses a filtered env
9
+ * by default; operators opt in to specific secret env vars when the
10
+ * child genuinely needs them.
11
+ *
12
+ * var child = b.processSpawn.spawn("jq", [".name"], {
13
+ * stdio: "pipe",
14
+ * // env: { ... } // optional override; defaults to filtered
15
+ * // allowEnv: ["AWS_REGION"] // explicit pass-through whitelist
16
+ * });
17
+ *
18
+ * Filter list (case-insensitive — matches Windows env var names):
19
+ * DATABASE_URL, PG*, POSTGRES*, MYSQL*, REDIS_URL, MONGO_URL,
20
+ * AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN,
21
+ * S3_*, AZURE_*, GCP_*, GOOGLE_APPLICATION_CREDENTIALS,
22
+ * *_TOKEN, *_SECRET, *_PASSWORD, *_API_KEY, *_PRIVATE_KEY.
23
+ *
24
+ * Audit: `process.spawn` (success) — metadata carries command + arg
25
+ * count + which env vars were filtered out (NOT their values). On
26
+ * exec failure: `process.spawn.failed` with the error code.
27
+ */
28
+
29
+ var lazyRequire = require("./lazy-require");
30
+ var { defineClass } = require("./framework-error");
31
+
32
+ var audit = lazyRequire(function () { return require("./audit"); });
33
+
34
+ var ProcessSpawnError = defineClass("ProcessSpawnError", { alwaysPermanent: true });
35
+
36
+ // Patterns matched case-insensitively against env var NAMES (not values).
37
+ // Values are never logged or audited.
38
+ var FILTER_PATTERNS = [
39
+ /^DATABASE_URL$/i,
40
+ /^PG/i, // PG*: PGHOST, PGPASSWORD, PGUSER, ...
41
+ /^POSTGRES/i,
42
+ /^MYSQL/i,
43
+ /^REDIS_URL$/i,
44
+ /^MONGO/i,
45
+ /^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|SESSION_TOKEN)$/i,
46
+ /^S3_/i,
47
+ /^AZURE_/i,
48
+ /^GCP_/i,
49
+ /^GOOGLE_APPLICATION_CREDENTIALS$/i,
50
+ /_TOKEN$/i,
51
+ /_SECRET$/i,
52
+ /_PASSWORD$/i,
53
+ /_API_KEY$/i,
54
+ /_PRIVATE_KEY$/i,
55
+ /_PASSPHRASE$/i,
56
+ ];
57
+
58
+ function _shouldFilter(name) {
59
+ for (var i = 0; i < FILTER_PATTERNS.length; i += 1) {
60
+ if (FILTER_PATTERNS[i].test(name)) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function filteredEnv(source, allowEnv) {
66
+ var src = source || process.env;
67
+ var allowSet = {};
68
+ if (Array.isArray(allowEnv)) {
69
+ for (var ai = 0; ai < allowEnv.length; ai += 1) {
70
+ if (typeof allowEnv[ai] === "string") allowSet[allowEnv[ai]] = true;
71
+ }
72
+ }
73
+ var out = {};
74
+ var filtered = [];
75
+ for (var k in src) {
76
+ if (!Object.prototype.hasOwnProperty.call(src, k)) continue;
77
+ if (allowSet[k] === true) { out[k] = src[k]; continue; }
78
+ if (_shouldFilter(k)) { filtered.push(k); continue; }
79
+ out[k] = src[k];
80
+ }
81
+ return { env: out, filtered: filtered };
82
+ }
83
+
84
+ function spawn(command, args, opts) {
85
+ if (typeof command !== "string" || command.length === 0) {
86
+ throw new ProcessSpawnError("process-spawn/bad-command",
87
+ "spawn: command must be a non-empty string");
88
+ }
89
+ opts = opts || {};
90
+ // If operator passes opts.env explicitly, trust it verbatim — we
91
+ // already gave them the override. Otherwise build a filtered env.
92
+ var spawnOpts = Object.assign({}, opts);
93
+ var filtered = [];
94
+ if (spawnOpts.env === undefined) {
95
+ var built = filteredEnv(process.env, opts.allowEnv);
96
+ spawnOpts.env = built.env;
97
+ filtered = built.filtered;
98
+ }
99
+ delete spawnOpts.allowEnv;
100
+ var nodeChild = require("node:child_process");
101
+ var child = nodeChild.spawn(command, args || [], spawnOpts);
102
+ try {
103
+ audit().safeEmit({
104
+ action: "process.spawn",
105
+ outcome: "success",
106
+ metadata: {
107
+ command: command,
108
+ argCount: Array.isArray(args) ? args.length : 0,
109
+ filteredCount: filtered.length,
110
+ filteredNames: filtered.slice(),
111
+ },
112
+ });
113
+ } catch (_e) { /* audit best-effort */ }
114
+ return child;
115
+ }
116
+
117
+ module.exports = {
118
+ spawn: spawn,
119
+ filteredEnv: filteredEnv,
120
+ FILTER_PATTERNS: Object.freeze(FILTER_PATTERNS.slice()),
121
+ ProcessSpawnError: ProcessSpawnError,
122
+ };