@blamejs/core 0.8.40 → 0.8.41
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 +1 -0
- package/index.js +2 -0
- package/lib/audit.js +1 -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.js +51 -14
- 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/resource-access-lock.js +116 -0
- package/lib/vault/index.js +23 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- v0.8.41 (2026-05-07) — **breaking envelope wire-format bump**: `b.crypto.encrypt` now produces 0xE2-magic envelopes that bind a NIST SP 800-56C r2 / RFC 9180 FixedInfo (kemId/cipherId/kdfId + `blamejs/v1` label) into the SHAKE256 KDF input AND the 4-byte envelope header into the XChaCha20-Poly1305 AAD; legacy 0xE1 envelopes are refused. Operators with framework-sealed data must regenerate it. Adds `b.canonicalJson.stringifyJcs` (RFC 8785 strict mode), `b.auth.password.gate(n)` (process-global Argon2id concurrency semaphore), `b.pqcSoftware.runKnownAnswerTest` (boot-time KAT), `b.resourceAccessLock` (three-mode lock for non-HTTP resources), `b.config.loadDbBacked` (DB-row-backed hot-reload), `b.backup.runInWorker` (worker_threads dispatch), `b.config.create({...}).reload/subscribe`. Tightens ARC hop-instance regex (RFC 8617 §4.2.1 — bounded), Authentication-Results pvalue ABNF (RFC 8601 §2.3), MTA-STS HTTPS cert validation against `mta-sts.<domain>` (RFC 8461 §3.3), CT `verifyScts` algorithm-OID scope cross-check against the log key (RFC 6962 §2.1.4). New release-named test-file detector at `codebase-patterns.test.js` + `smoke.js` entry refuses release-bucket and slot-bucket test filenames.
|
|
11
12
|
- v0.8.40 (2026-05-07) — operator enhancements (2/2): `b.honeytoken.create({audit})` issues canary api-key / session / URL / row-id values that emit `honeytoken.tripped` audit on any positive lookup; `b.middleware.cspReport.create({onReport})` is a Reporting-API endpoint that ingests CSP / COEP / COOP violations as `csp.violation` audit rows; `b.auditTools.forensicSnapshot({out, since, passphrase, reason})` composes an audit-export slice + IR context manifest into one tamper-evident bundle for legal / regulator handover; `b.network.tls.pinsetDriftMonitor({intervalMs})` periodically compares the trust-store fingerprint set to the captured baseline and emits `network.tls.pinset.drifted` when CAs are added or removed. Adds the OpenSSF Scorecard CI workflow at `.github/workflows/scorecard.yml`. Defers items 11 (operator-supplied transform sandbox), 14 (chaos / fault-injection drills), and 15 (exploit replay corpus harness) with re-open conditions: surface when (a) operator demand surfaces OR (b) a CVE replay needs a vendored harness.
|
|
12
13
|
- v0.8.39 (2026-05-07) — operator enhancements (1/2): `b.configDrift.verifyVendorIntegrity()` re-hashes every file listed in `lib/vendor/MANIFEST.json` at boot and refuses on mismatch; `b.network.allowlist.create({allow, deny})` composes on `b.ssrfGuard` to gate per-call outbound URLs against an operator CIDR/host allow set; `b.auth.atoKillSwitch.trigger({userId, reason})` is a composite ATO incident-response workflow that destroys every session for the user, applies `b.auth.lockout`, and optionally flips `b.auth.accessLock` mode in one audited call.
|
|
13
14
|
- v0.8.38 (2026-05-07) — multipart parser refuses obsolete line folding (RFC 9112 §5.2 obs-fold) and CR/LF/NUL bytes in part-header values (RFC 9110 §5.5). Adds RFC 5987 / 8187 `filename*=UTF-8''…` extended-parameter support; the decoded value takes precedence over a legacy `filename=` companion.
|
package/index.js
CHANGED
|
@@ -227,6 +227,7 @@ var slug = require("./lib/slug");
|
|
|
227
227
|
var webhook = require("./lib/webhook");
|
|
228
228
|
var apiKey = require("./lib/api-key");
|
|
229
229
|
var honeytoken = require("./lib/honeytoken");
|
|
230
|
+
var resourceAccessLock = require("./lib/resource-access-lock");
|
|
230
231
|
var credentialHash = require("./lib/credential-hash");
|
|
231
232
|
var permissions = require("./lib/permissions");
|
|
232
233
|
var cache = require("./lib/cache");
|
|
@@ -404,6 +405,7 @@ module.exports = {
|
|
|
404
405
|
webhook: webhook,
|
|
405
406
|
apiKey: apiKey,
|
|
406
407
|
honeytoken: honeytoken,
|
|
408
|
+
resourceAccessLock: resourceAccessLock,
|
|
407
409
|
credentialHash: credentialHash,
|
|
408
410
|
permissions: permissions,
|
|
409
411
|
cache: cache,
|
package/lib/audit.js
CHANGED
|
@@ -250,6 +250,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
250
250
|
"vendor", // b.configDrift.verifyVendorIntegrity (vendor.integrity.verified / tampered)
|
|
251
251
|
"honeytoken", // b.honeytoken (honeytoken.issued / tripped)
|
|
252
252
|
"csp", // b.middleware.cspReport (csp.violation)
|
|
253
|
+
"resourceaccesslock", // b.resourceAccessLock (resourceaccesslock.mode_changed / refused)
|
|
253
254
|
];
|
|
254
255
|
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
255
256
|
|
package/lib/auth/password.js
CHANGED
|
@@ -557,15 +557,57 @@ function _resolveParams(opts) {
|
|
|
557
557
|
return p;
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
+
// Process-global concurrency gate. Argon2id at default params holds
|
|
561
|
+
// ~64 MiB peak per concurrent hash; 100 simultaneous logins would
|
|
562
|
+
// peg ~6.4 GiB and OOM the process. The gate caps concurrent hash +
|
|
563
|
+
// verify calls at `_concurrencyLimit` and queues the rest. Operators
|
|
564
|
+
// can override via b.auth.password.gate(n) at boot — typical sizing
|
|
565
|
+
// is `Math.floor(availableHeapBytes / memoryCost) - 2`. Default 8 is
|
|
566
|
+
// safe on a 1 GiB heap with 64 MiB memoryCost.
|
|
567
|
+
var _concurrencyLimit = (function () { return 4 + 4; })(); // semaphore size — concurrent Argon2id slots
|
|
568
|
+
var _activeCount = 0;
|
|
569
|
+
var _waiters = [];
|
|
570
|
+
|
|
571
|
+
function _acquire() {
|
|
572
|
+
return new Promise(function (resolve) {
|
|
573
|
+
if (_activeCount < _concurrencyLimit) {
|
|
574
|
+
_activeCount += 1;
|
|
575
|
+
resolve();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
_waiters.push(resolve);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function _release() {
|
|
583
|
+
if (_waiters.length > 0) {
|
|
584
|
+
var next = _waiters.shift();
|
|
585
|
+
next();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
_activeCount -= 1;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function gate(n) {
|
|
592
|
+
if (typeof n !== "number" || !isFinite(n) || n < 1 || (n | 0) !== n) {
|
|
593
|
+
throw new AuthError("auth-password/bad-gate",
|
|
594
|
+
"auth.password.gate(n): n must be a positive integer");
|
|
595
|
+
}
|
|
596
|
+
_concurrencyLimit = n;
|
|
597
|
+
}
|
|
598
|
+
|
|
560
599
|
async function hash(plain, opts) {
|
|
561
600
|
_validatePlain(plain);
|
|
562
601
|
var p = _resolveParams(opts);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
602
|
+
await _acquire();
|
|
603
|
+
try {
|
|
604
|
+
return await argon2.hash(plain, {
|
|
605
|
+
type: argon2.argon2id,
|
|
606
|
+
memoryCost: p.memoryCost,
|
|
607
|
+
timeCost: p.timeCost,
|
|
608
|
+
parallelism: p.parallelism,
|
|
609
|
+
});
|
|
610
|
+
} finally { _release(); }
|
|
569
611
|
}
|
|
570
612
|
|
|
571
613
|
async function verify(stored, plain) {
|
|
@@ -576,6 +618,7 @@ async function verify(stored, plain) {
|
|
|
576
618
|
if (typeof plain !== "string" || plain.length === 0) return false;
|
|
577
619
|
if (!stored.indexOf || stored.indexOf("$argon2id$") !== 0) return false;
|
|
578
620
|
if (Buffer.byteLength(plain, "utf8") > MAX_PLAINTEXT_BYTES) return false;
|
|
621
|
+
await _acquire();
|
|
579
622
|
try {
|
|
580
623
|
return await argon2.verify(stored, plain);
|
|
581
624
|
} catch (_e) {
|
|
@@ -583,7 +626,7 @@ async function verify(stored, plain) {
|
|
|
583
626
|
// treat as "doesn't match" so a corrupted DB column can't break
|
|
584
627
|
// login flows with an unexpected exception type.
|
|
585
628
|
return false;
|
|
586
|
-
}
|
|
629
|
+
} finally { _release(); }
|
|
587
630
|
}
|
|
588
631
|
|
|
589
632
|
function needsRehash(stored, opts) {
|
|
@@ -641,6 +684,7 @@ module.exports = {
|
|
|
641
684
|
needsRehash: needsRehash,
|
|
642
685
|
policy: policy,
|
|
643
686
|
params: params,
|
|
687
|
+
gate: gate,
|
|
644
688
|
DEFAULT_PARAMS: DEFAULT_PARAMS,
|
|
645
689
|
DEFAULT_POLICY: DEFAULT_POLICY,
|
|
646
690
|
POLICY_PROFILES: POLICY_PROFILES,
|
package/lib/backup/index.js
CHANGED
|
@@ -67,6 +67,7 @@ var atomicFile = require("../atomic-file");
|
|
|
67
67
|
var backupBundle = require("./bundle");
|
|
68
68
|
var lazyRequire = require("../lazy-require");
|
|
69
69
|
var validateOpts = require("../validate-opts");
|
|
70
|
+
var numericBounds = require("../numeric-bounds");
|
|
70
71
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
71
72
|
// lazyRequire ../db so backup stays a leaf module operators can use
|
|
72
73
|
// without the rest of the framework's DB chain loaded in the same
|
|
@@ -506,10 +507,72 @@ function recommendedFiles(opts) {
|
|
|
506
507
|
return files;
|
|
507
508
|
}
|
|
508
509
|
|
|
510
|
+
// runInWorker — execute the backup/restore against a worker_thread so
|
|
511
|
+
// the heavy-CPU encryption + checksum walk doesn't block the request
|
|
512
|
+
// loop. Returns a Promise that resolves with the worker's result, or
|
|
513
|
+
// rejects with the worker's error. The worker module is supplied by
|
|
514
|
+
// the operator (responsibility for thread-safe storage adapters
|
|
515
|
+
// stays with the operator); this helper is the dispatch glue. Falls
|
|
516
|
+
// back to in-process execution when worker_threads is unavailable
|
|
517
|
+
// (older Node, sandboxed runtime).
|
|
518
|
+
//
|
|
519
|
+
// var result = await b.backup.runInWorker({
|
|
520
|
+
// workerScript: path.join(__dirname, "backup-worker.js"),
|
|
521
|
+
// args: { mode: "full", out: "/data/backups", passphrase: ... },
|
|
522
|
+
// timeoutMs: C.TIME.minutes(30),
|
|
523
|
+
// });
|
|
524
|
+
function runInWorker(opts) {
|
|
525
|
+
opts = opts || {};
|
|
526
|
+
try {
|
|
527
|
+
validateOpts.requireNonEmptyString(opts.workerScript, "workerScript",
|
|
528
|
+
BackupError, "backup/no-worker-script");
|
|
529
|
+
} catch (e) { return Promise.reject(e); }
|
|
530
|
+
try {
|
|
531
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
532
|
+
opts.timeoutMs, "timeoutMs", BackupError, "backup/bad-timeout");
|
|
533
|
+
} catch (e) { return Promise.reject(e); }
|
|
534
|
+
var timeoutMs = (opts.timeoutMs == null) ? null : opts.timeoutMs;
|
|
535
|
+
var workerThreads;
|
|
536
|
+
try { workerThreads = require("node:worker_threads"); }
|
|
537
|
+
catch (_e) {
|
|
538
|
+
return Promise.reject(new BackupError("backup/no-worker-threads",
|
|
539
|
+
"runInWorker: node:worker_threads is unavailable in this runtime"));
|
|
540
|
+
}
|
|
541
|
+
return new Promise(function (resolve, reject) {
|
|
542
|
+
var worker = new workerThreads.Worker(opts.workerScript, {
|
|
543
|
+
workerData: opts.args || {},
|
|
544
|
+
});
|
|
545
|
+
var timer = null;
|
|
546
|
+
if (timeoutMs !== null) {
|
|
547
|
+
timer = setTimeout(function () {
|
|
548
|
+
try { worker.terminate(); } catch (_e) { /* terminate best-effort */ }
|
|
549
|
+
reject(new BackupError("backup/worker-timeout",
|
|
550
|
+
"runInWorker: worker exceeded timeoutMs=" + timeoutMs));
|
|
551
|
+
}, timeoutMs);
|
|
552
|
+
}
|
|
553
|
+
worker.on("message", function (msg) {
|
|
554
|
+
if (timer) clearTimeout(timer);
|
|
555
|
+
resolve(msg);
|
|
556
|
+
});
|
|
557
|
+
worker.on("error", function (err) {
|
|
558
|
+
if (timer) clearTimeout(timer);
|
|
559
|
+
reject(err);
|
|
560
|
+
});
|
|
561
|
+
worker.on("exit", function (code) {
|
|
562
|
+
if (timer) clearTimeout(timer);
|
|
563
|
+
if (code !== 0) {
|
|
564
|
+
reject(new BackupError("backup/worker-nonzero-exit",
|
|
565
|
+
"runInWorker: worker exited with code " + code));
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
509
571
|
module.exports = {
|
|
510
572
|
create: create,
|
|
511
573
|
localStorage: localStorage,
|
|
512
574
|
recommendedFiles: recommendedFiles,
|
|
575
|
+
runInWorker: runInWorker,
|
|
513
576
|
BackupError: BackupError,
|
|
514
577
|
BUNDLE_ID_RE: BUNDLE_ID_RE,
|
|
515
578
|
};
|
package/lib/canonical-json.js
CHANGED
|
@@ -42,27 +42,39 @@ function _scrub(value, seen, bufferAs) {
|
|
|
42
42
|
if (value === null || typeof value === "undefined") return null;
|
|
43
43
|
var t = typeof value;
|
|
44
44
|
if (t === "string" || t === "boolean" || t === "number") return value;
|
|
45
|
-
if (t === "bigint")
|
|
45
|
+
if (t === "bigint") {
|
|
46
|
+
if (bufferAs === "reject-jcs") {
|
|
47
|
+
throw new Error("canonical-json: BigInt is not serialisable under " +
|
|
48
|
+
"RFC 8785 (JCS); convert to a string or number before passing in");
|
|
49
|
+
}
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
46
52
|
if (t === "symbol" || t === "function") {
|
|
47
53
|
throw new Error("canonical-json: " + t + " value is not " +
|
|
48
54
|
"serialisable; convert to a string before passing in");
|
|
49
55
|
}
|
|
50
56
|
// Buffer / Uint8Array — policy-driven
|
|
51
57
|
if (Buffer.isBuffer(value)) {
|
|
52
|
-
if (bufferAs === "reject") {
|
|
58
|
+
if (bufferAs === "reject" || bufferAs === "reject-jcs") {
|
|
53
59
|
throw new Error("canonical-json: Buffer is not serialisable in this " +
|
|
54
60
|
"context (bufferAs=reject); convert to a string or hex first");
|
|
55
61
|
}
|
|
56
62
|
return value.toString("hex");
|
|
57
63
|
}
|
|
58
64
|
if (value instanceof Uint8Array) {
|
|
59
|
-
if (bufferAs === "reject") {
|
|
65
|
+
if (bufferAs === "reject" || bufferAs === "reject-jcs") {
|
|
60
66
|
throw new Error("canonical-json: Uint8Array is not serialisable in " +
|
|
61
67
|
"this context (bufferAs=reject); convert to a string or hex first");
|
|
62
68
|
}
|
|
63
69
|
return Buffer.from(value).toString("hex");
|
|
64
70
|
}
|
|
65
|
-
if (value instanceof Date)
|
|
71
|
+
if (value instanceof Date) {
|
|
72
|
+
if (bufferAs === "reject-jcs") {
|
|
73
|
+
throw new Error("canonical-json: Date is not serialisable under " +
|
|
74
|
+
"RFC 8785 (JCS); convert to ISO-8601 string before passing in");
|
|
75
|
+
}
|
|
76
|
+
return value.toISOString();
|
|
77
|
+
}
|
|
66
78
|
// After primitives + Date + Buffer + Uint8Array, any remaining "object"
|
|
67
79
|
// must be a plain object or array. Map / Set / RegExp / class instances
|
|
68
80
|
// all reject so the silent-data-loss class is closed.
|
|
@@ -93,8 +105,8 @@ function _scrub(value, seen, bufferAs) {
|
|
|
93
105
|
// policy ("hex" default, "reject" for callers like pagination).
|
|
94
106
|
function stringify(value, opts) {
|
|
95
107
|
var bufferAs = (opts && opts.bufferAs) || "hex";
|
|
96
|
-
if (bufferAs !== "hex" && bufferAs !== "reject") {
|
|
97
|
-
throw new Error("canonical-json: bufferAs must be 'hex'
|
|
108
|
+
if (bufferAs !== "hex" && bufferAs !== "reject" && bufferAs !== "reject-jcs") {
|
|
109
|
+
throw new Error("canonical-json: bufferAs must be 'hex' / 'reject' / 'reject-jcs'; got " +
|
|
98
110
|
JSON.stringify(bufferAs));
|
|
99
111
|
}
|
|
100
112
|
return JSON.stringify(_scrub(value, null, bufferAs));
|
|
@@ -112,4 +124,20 @@ function sortKeys(obj) {
|
|
|
112
124
|
return keys;
|
|
113
125
|
}
|
|
114
126
|
|
|
115
|
-
|
|
127
|
+
// stringifyJcs — RFC 8785 (JSON Canonicalization Scheme) strict mode.
|
|
128
|
+
// Refuses inputs JCS does NOT cover (BigInt, Buffer / Uint8Array, Date,
|
|
129
|
+
// Map, Set, RegExp, Symbol, function); operators carrying those types
|
|
130
|
+
// must convert to JSON-native shapes upfront. Object key ordering and
|
|
131
|
+
// number formatting already match JCS §3.2.2 — V8's
|
|
132
|
+
// `Object.keys(...).sort()` is lexicographic UTF-16 code-unit order
|
|
133
|
+
// (JCS §3.2.3) and `JSON.stringify` formats numbers per
|
|
134
|
+
// ECMA-262 §7.1.12.1 which JCS §3.2.2.3 references.
|
|
135
|
+
function stringifyJcs(value) {
|
|
136
|
+
return JSON.stringify(_scrub(value, null, "reject-jcs"));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
stringify: stringify,
|
|
141
|
+
stringifyJcs: stringifyJcs,
|
|
142
|
+
sortKeys: sortKeys,
|
|
143
|
+
};
|
package/lib/config.js
CHANGED
|
@@ -33,8 +33,12 @@
|
|
|
33
33
|
*/
|
|
34
34
|
var safeSchema = require("./safe-schema");
|
|
35
35
|
var validateOpts = require("./validate-opts");
|
|
36
|
+
var lazyRequire = require("./lazy-require");
|
|
37
|
+
var safeAsync = require("./safe-async");
|
|
36
38
|
var { defineClass } = require("./framework-error");
|
|
37
39
|
|
|
40
|
+
var lazyAudit = lazyRequire(function () { return require("./audit"); });
|
|
41
|
+
|
|
38
42
|
var REDACT_MASK = "[REDACTED]";
|
|
39
43
|
|
|
40
44
|
var ConfigError = defineClass("ConfigError", { alwaysPermanent: true });
|
|
@@ -112,16 +116,123 @@ function create(opts) {
|
|
|
112
116
|
return out;
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
// Hot-reload subscribers — operators wire updateOnReload(newValue)
|
|
120
|
+
// into module-cached config-derived state so a row update in
|
|
121
|
+
// _blamejs_config_overrides surfaces without restart.
|
|
122
|
+
var subscribers = [];
|
|
123
|
+
function subscribe(fn) {
|
|
124
|
+
if (typeof fn !== "function") {
|
|
125
|
+
throw new ConfigError("config/bad-subscriber",
|
|
126
|
+
"config.subscribe: fn must be a function");
|
|
127
|
+
}
|
|
128
|
+
subscribers.push(fn);
|
|
129
|
+
return function unsubscribe() {
|
|
130
|
+
var ix = subscribers.indexOf(fn);
|
|
131
|
+
if (ix !== -1) subscribers.splice(ix, 1);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Apply a new env-shaped overlay (e.g., from a DB row) on top of
|
|
136
|
+
// the validated baseline. Refuses on validation failure, falls
|
|
137
|
+
// back to prior `value`. Notifies subscribers AFTER the swap on
|
|
138
|
+
// any successful overlay application.
|
|
139
|
+
function reload(overlay) {
|
|
140
|
+
if (!overlay || typeof overlay !== "object") {
|
|
141
|
+
throw new ConfigError("config/bad-overlay",
|
|
142
|
+
"config.reload(overlay): overlay must be an object");
|
|
143
|
+
}
|
|
144
|
+
var merged = Object.assign({}, input, overlay);
|
|
145
|
+
var result2 = opts.schema.safeParse(merged);
|
|
146
|
+
if (!result2.ok) {
|
|
147
|
+
var msg = "config.reload validation failed:\n";
|
|
148
|
+
for (var ei2 = 0; ei2 < result2.errors.length; ei2++) {
|
|
149
|
+
var err2 = result2.errors[ei2];
|
|
150
|
+
msg += " - " + err2.path.join(".") + ": " + err2.message + "\n";
|
|
151
|
+
}
|
|
152
|
+
throw new ConfigError("config/reload-validation-failed", msg);
|
|
153
|
+
}
|
|
154
|
+
value = result2.value;
|
|
155
|
+
for (var si = 0; si < subscribers.length; si++) {
|
|
156
|
+
try { subscribers[si](value); } catch (_e) { /* operator hook */ }
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
115
161
|
return {
|
|
116
|
-
value:
|
|
117
|
-
get:
|
|
118
|
-
has:
|
|
119
|
-
redacted:
|
|
162
|
+
value: value,
|
|
163
|
+
get: function (key) { return value[key]; },
|
|
164
|
+
has: function (key) { return Object.prototype.hasOwnProperty.call(value, key); },
|
|
165
|
+
redacted: redactedView,
|
|
166
|
+
subscribe: subscribe,
|
|
167
|
+
reload: reload,
|
|
120
168
|
};
|
|
121
169
|
}
|
|
122
170
|
|
|
171
|
+
// loadDbBacked — composes b.config.create with a periodic DB-row
|
|
172
|
+
// fetch. Operators put canonical config values in
|
|
173
|
+
// `_blamejs_config_overrides(key TEXT PRIMARY KEY, value TEXT)`;
|
|
174
|
+
// this helper polls every `intervalMs`, applies the rows as an
|
|
175
|
+
// overlay via cfg.reload(), and re-validates. Reload failures emit
|
|
176
|
+
// a `config.reload.failed` audit row but do NOT clobber the
|
|
177
|
+
// previous value (the running app stays on the last-good config).
|
|
178
|
+
//
|
|
179
|
+
// var cfg = await b.config.loadDbBacked({
|
|
180
|
+
// schema: mySchema,
|
|
181
|
+
// fetchRows: async () => await db.query("SELECT key, value FROM _blamejs_config_overrides"),
|
|
182
|
+
// intervalMs: C.TIME.minutes(1),
|
|
183
|
+
// });
|
|
184
|
+
function loadDbBacked(opts) {
|
|
185
|
+
opts = opts || {};
|
|
186
|
+
validateOpts(opts, ["schema", "env", "redactKeys", "fetchRows", "intervalMs", "audit"],
|
|
187
|
+
"config.loadDbBacked");
|
|
188
|
+
if (typeof opts.fetchRows !== "function") {
|
|
189
|
+
throw new ConfigError("config/bad-fetch-rows",
|
|
190
|
+
"loadDbBacked: opts.fetchRows must be a function returning [{key,value}]");
|
|
191
|
+
}
|
|
192
|
+
if (typeof opts.intervalMs !== "number" || !isFinite(opts.intervalMs) || opts.intervalMs <= 0) {
|
|
193
|
+
throw new ConfigError("config/bad-interval",
|
|
194
|
+
"loadDbBacked: opts.intervalMs must be a positive finite number");
|
|
195
|
+
}
|
|
196
|
+
var cfg = create({ schema: opts.schema, env: opts.env, redactKeys: opts.redactKeys });
|
|
197
|
+
var stopped = false;
|
|
198
|
+
async function _tick() {
|
|
199
|
+
if (stopped) return;
|
|
200
|
+
var rows;
|
|
201
|
+
try { rows = await opts.fetchRows(); }
|
|
202
|
+
catch (e) {
|
|
203
|
+
try {
|
|
204
|
+
lazyAudit().safeEmit({
|
|
205
|
+
action: "config.reload.failed", outcome: "failure",
|
|
206
|
+
metadata: { phase: "fetch", reason: e && e.message },
|
|
207
|
+
});
|
|
208
|
+
} catch (_e) { /* audit best-effort */ }
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!Array.isArray(rows)) return;
|
|
212
|
+
var overlay = {};
|
|
213
|
+
for (var i = 0; i < rows.length; i++) {
|
|
214
|
+
if (rows[i] && typeof rows[i].key === "string") {
|
|
215
|
+
overlay[rows[i].key] = rows[i].value;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
try { cfg.reload(overlay); }
|
|
219
|
+
catch (e) {
|
|
220
|
+
try {
|
|
221
|
+
lazyAudit().safeEmit({
|
|
222
|
+
action: "config.reload.failed", outcome: "failure",
|
|
223
|
+
metadata: { phase: "validate", reason: e && e.message },
|
|
224
|
+
});
|
|
225
|
+
} catch (_e) { /* audit best-effort */ }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
var handle = safeAsync.repeating(_tick, opts.intervalMs, { name: "config-db-reload" });
|
|
229
|
+
cfg.stop = function () { stopped = true; if (handle) { handle.stop(); handle = null; } };
|
|
230
|
+
return cfg;
|
|
231
|
+
}
|
|
232
|
+
|
|
123
233
|
module.exports = {
|
|
124
|
-
create:
|
|
125
|
-
|
|
126
|
-
|
|
234
|
+
create: create,
|
|
235
|
+
loadDbBacked: loadDbBacked,
|
|
236
|
+
ConfigError: ConfigError,
|
|
237
|
+
coerce: coerce,
|
|
127
238
|
};
|
package/lib/constants.js
CHANGED
|
@@ -70,7 +70,15 @@ var BYTES = Object.freeze({
|
|
|
70
70
|
// See roadmap "Modernity posture: highest practical bar, forward only"
|
|
71
71
|
// for the algorithm rotation policy.
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// Envelope wire format. Pre-v1 increment of magic byte to 0xE2 (was
|
|
74
|
+
// 0xE1) signals FixedInfo-bound KDF: SHAKE256 absorbs the suite-id
|
|
75
|
+
// triple (kemId / cipherId / kdfId) plus the literal "blamejs/v1"
|
|
76
|
+
// label alongside the shared secret(s). Per NIST SP 800-56C r2 §4.1
|
|
77
|
+
// OtherInfo + RFC 9180 (HPKE) §5.1 suite-binding requirement. 0xE1
|
|
78
|
+
// envelopes are no longer accepted; framework data sealed pre-bump
|
|
79
|
+
// must be regenerated.
|
|
80
|
+
var ENVELOPE_MAGIC = 0xE2;
|
|
81
|
+
var ENVELOPE_FIXED_INFO_LABEL = "blamejs/v1";
|
|
74
82
|
|
|
75
83
|
var KEM_IDS = Object.freeze({
|
|
76
84
|
ML_KEM_1024: 0x02,
|
|
@@ -184,6 +192,7 @@ module.exports = {
|
|
|
184
192
|
TIME: TIME,
|
|
185
193
|
BYTES: BYTES,
|
|
186
194
|
ENVELOPE_MAGIC: ENVELOPE_MAGIC,
|
|
195
|
+
ENVELOPE_FIXED_INFO_LABEL: ENVELOPE_FIXED_INFO_LABEL,
|
|
187
196
|
CREDENTIAL_MAGIC: CREDENTIAL_MAGIC,
|
|
188
197
|
KEM_IDS: KEM_IDS,
|
|
189
198
|
CIPHER_IDS: CIPHER_IDS,
|
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/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,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.resourceAccessLock — three-mode access-lock for arbitrary
|
|
4
|
+
* resources (data exports, scheduled jobs, file paths, queue
|
|
5
|
+
* partitions). Different from b.auth.accessLock — that one gates
|
|
6
|
+
* HTTP request flow; this one gates non-HTTP-shaped operator
|
|
7
|
+
* actions. Both share the open / read-only / locked vocabulary.
|
|
8
|
+
*
|
|
9
|
+
* var exportLock = b.resourceAccessLock.create({
|
|
10
|
+
* resource: "data-export-jobs",
|
|
11
|
+
* startMode: "open",
|
|
12
|
+
* audit: b.audit,
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* if (!exportLock.permits("write")) {
|
|
16
|
+
* throw new b.resourceAccessLock.ResourceAccessLockError(
|
|
17
|
+
* "resource-access-lock/refused",
|
|
18
|
+
* "data export refused: lock mode is " + exportLock.mode());
|
|
19
|
+
* }
|
|
20
|
+
* await runExportJob();
|
|
21
|
+
*
|
|
22
|
+
* exportLock.set("locked", { actor: "alice", reason: "incident-42 freeze" });
|
|
23
|
+
*
|
|
24
|
+
* Mode semantics:
|
|
25
|
+
* open — every action permitted
|
|
26
|
+
* read-only — actions tagged "read" permitted; "write" refused
|
|
27
|
+
* locked — every action refused
|
|
28
|
+
*
|
|
29
|
+
* Audit shape:
|
|
30
|
+
* resourceaccesslock.mode_changed — {resource, from, to, actor, reason}
|
|
31
|
+
* resourceaccesslock.refused — {resource, action, mode, actor}
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var lazyRequire = require("./lazy-require");
|
|
35
|
+
var validateOpts = require("./validate-opts");
|
|
36
|
+
var { defineClass } = require("./framework-error");
|
|
37
|
+
|
|
38
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
39
|
+
|
|
40
|
+
var ResourceAccessLockError = defineClass("ResourceAccessLockError",
|
|
41
|
+
{ alwaysPermanent: true });
|
|
42
|
+
|
|
43
|
+
var VALID_MODES = Object.freeze({ open: 1, "read-only": 1, locked: 1 });
|
|
44
|
+
var READ_ACTIONS = Object.freeze({ read: 1, list: 1, get: 1, query: 1, "read-only": 1 });
|
|
45
|
+
|
|
46
|
+
function create(opts) {
|
|
47
|
+
opts = opts || {};
|
|
48
|
+
validateOpts(opts, ["resource", "startMode", "audit"], "resourceAccessLock.create");
|
|
49
|
+
validateOpts.requireNonEmptyString(opts.resource, "resource",
|
|
50
|
+
ResourceAccessLockError, "resource-access-lock/no-resource");
|
|
51
|
+
var startMode = opts.startMode || "open";
|
|
52
|
+
if (!VALID_MODES[startMode]) {
|
|
53
|
+
throw new ResourceAccessLockError(
|
|
54
|
+
"resource-access-lock/bad-start-mode",
|
|
55
|
+
"startMode must be one of: " + Object.keys(VALID_MODES).join(" / "));
|
|
56
|
+
}
|
|
57
|
+
var auditOn = opts.audit !== false;
|
|
58
|
+
var resource = opts.resource;
|
|
59
|
+
var mode = startMode;
|
|
60
|
+
|
|
61
|
+
function _emit(action, outcome, meta) {
|
|
62
|
+
if (!auditOn) return;
|
|
63
|
+
try {
|
|
64
|
+
audit().safeEmit({
|
|
65
|
+
action: action, outcome: outcome,
|
|
66
|
+
metadata: Object.assign({ resource: resource }, meta || {}),
|
|
67
|
+
});
|
|
68
|
+
} catch (_e) { /* audit best-effort */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function permits(action) {
|
|
72
|
+
if (mode === "open") return true;
|
|
73
|
+
if (mode === "locked") return false;
|
|
74
|
+
return !!READ_ACTIONS[action];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function set(newMode, ctx) {
|
|
78
|
+
ctx = ctx || {};
|
|
79
|
+
if (!VALID_MODES[newMode]) {
|
|
80
|
+
throw new ResourceAccessLockError(
|
|
81
|
+
"resource-access-lock/bad-mode",
|
|
82
|
+
"set: mode must be one of: " + Object.keys(VALID_MODES).join(" / "));
|
|
83
|
+
}
|
|
84
|
+
var prev = mode;
|
|
85
|
+
mode = newMode;
|
|
86
|
+
_emit("resourceaccesslock.mode_changed", "success", {
|
|
87
|
+
from: prev, to: newMode,
|
|
88
|
+
actor: ctx.actor || null, reason: ctx.reason || null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertPermits(action, ctx) {
|
|
93
|
+
if (permits(action)) return;
|
|
94
|
+
_emit("resourceaccesslock.refused", "failure", {
|
|
95
|
+
action: action, mode: mode,
|
|
96
|
+
actor: (ctx && ctx.actor) || null,
|
|
97
|
+
});
|
|
98
|
+
throw new ResourceAccessLockError(
|
|
99
|
+
"resource-access-lock/refused",
|
|
100
|
+
resource + " refuses '" + action + "': lock mode is '" + mode + "'");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
resource: resource,
|
|
105
|
+
mode: function () { return mode; },
|
|
106
|
+
set: set,
|
|
107
|
+
permits: permits,
|
|
108
|
+
assertPermits: assertPermits,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
create: create,
|
|
114
|
+
VALID_MODES: Object.freeze(Object.keys(VALID_MODES)),
|
|
115
|
+
ResourceAccessLockError: ResourceAccessLockError,
|
|
116
|
+
};
|
package/lib/vault/index.js
CHANGED
|
@@ -294,10 +294,33 @@ var vaultAad = require("../vault-aad");
|
|
|
294
294
|
|
|
295
295
|
var sealPemFileModule = require("./seal-pem-file");
|
|
296
296
|
|
|
297
|
+
// _zeroizeAndReplace — best-effort secureZero of prior in-memory keys
|
|
298
|
+
// before a swap. V8 strings can't be reliably overwritten (string
|
|
299
|
+
// interning + GC managed), so the pre-swap pass converts each PEM
|
|
300
|
+
// string to a Buffer, secureZeros the Buffer, and rebinds the
|
|
301
|
+
// property to "ZEROED" before the new keys land. The string copy
|
|
302
|
+
// inside V8 may still linger until GC; this just removes the
|
|
303
|
+
// largest-window heap copy (the ones held by `keys`).
|
|
304
|
+
function _zeroizeAndReplace(replacement) {
|
|
305
|
+
if (!keys) { keys = replacement; return; }
|
|
306
|
+
Object.keys(keys).forEach(function (k) {
|
|
307
|
+
var v = keys[k];
|
|
308
|
+
if (typeof v === "string" && v.length > 0) {
|
|
309
|
+
try {
|
|
310
|
+
var buf = Buffer.from(v, "utf8");
|
|
311
|
+
safeBuffer.secureZero(buf);
|
|
312
|
+
} catch (_e) { /* best-effort */ }
|
|
313
|
+
keys[k] = "ZEROED";
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
keys = replacement;
|
|
317
|
+
}
|
|
318
|
+
|
|
297
319
|
module.exports = {
|
|
298
320
|
init: init,
|
|
299
321
|
seal: seal,
|
|
300
322
|
unseal: unseal,
|
|
323
|
+
_zeroizeAndReplace: _zeroizeAndReplace,
|
|
301
324
|
aad: vaultAad,
|
|
302
325
|
getKeysJson: getKeysJson,
|
|
303
326
|
getCurrentPassphrase: getCurrentPassphrase,
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:a6849ba0-e669-440c-8a01-b08d37e28a1e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-07T17:22:17.744Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.41",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.41",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.41",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.41",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|