@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/CHANGELOG.md +2 -0
- package/index.js +4 -0
- package/lib/audit-tools.js +23 -5
- package/lib/audit.js +2 -0
- package/lib/auth/password.js +51 -7
- package/lib/backup/index.js +63 -0
- package/lib/canonical-json.js +35 -7
- package/lib/config.js +118 -7
- package/lib/constants.js +10 -1
- package/lib/crypto-field.js +17 -5
- package/lib/crypto.js +51 -14
- package/lib/db.js +91 -2
- package/lib/external-db.js +74 -31
- package/lib/mail-auth.js +16 -4
- package/lib/network-smtp-policy.js +11 -4
- package/lib/network-tls.js +15 -0
- package/lib/pqc-software.js +42 -0
- package/lib/process-spawn.js +122 -0
- package/lib/resource-access-lock.js +116 -0
- package/lib/vault/index.js +64 -3
- package/lib/vault/seal-pem-file.js +38 -2
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/external-db.js
CHANGED
|
@@ -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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
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:
|
|
171
|
-
url:
|
|
172
|
-
maxBytes:
|
|
173
|
-
timeoutMs:
|
|
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;
|
package/lib/network-tls.js
CHANGED
|
@@ -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) {
|
package/lib/pqc-software.js
CHANGED
|
@@ -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
|
+
};
|