@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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - v0.8.42 (2026-05-07) — DB hardening + H6 vault-PEM sub-issues + OWASP-1: `b.cryptoField.derivedHashes` now binds a per-deployment 32-byte salt (persisted at `<dataDir>/vault.derived-hash-salt`) so the same plaintext produces different hashes across deployments (D-H1, HIPAA Safe Harbor §164.514(b)(2)(i) defense). `_blamejs_break_glass_grants.kwGrantHalf` is now sealed under the vault key (D-H8). `b.externalDb.transaction({statementTimeoutMs, idleInTransactionTimeoutMs, deadlockRetries})` enforces SET-LOCAL Postgres timeouts and auto-retries 40P01/40001 with jittered backoff (D-H4 / D-M7 / D-M8). Boot-time warning when SQLite tmpfs path doesn't resolve under /dev/shm /run/shm /run/user /tmp (D-H7). `b.db.prepare` now caches Statement handles (LRU 256, cleared on init/close) so long-running daemons don't leak fds (D-M6). New: `b.db.vacuumAfterErase({mode, pages})` runs `VACUUM` / `PRAGMA incremental_vacuum` after large erasures (F-RTBF-1). `__erasedAt` now coarse-bucketed to 1-day floor (F-RTBF-4) to remove the sub-day forensic timing fingerprint. `b.auditTools.withRecordedAtIso(row)` surfaces ISO-8601 alongside Unix-ms (F-AUD-4) without disturbing the chain-hash canonical form. New `b.processSpawn.spawn(command, args, {allowEnv})` strips `DATABASE_URL` / `PG*` / `AWS_*` / `*_API_KEY` / `*_SECRET` / `*_TOKEN` etc. from the child env by default (OWASP-1). H6 sub-issues #4-#6: vault.sealPemFile asserts parent-dir mode 0o755 or stricter, fsyncs the destination directory after rename, and reduced fs.watchFile cadence from 2s to 500ms.
12
+ - 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
13
  - 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
14
  - 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
15
  - 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,8 @@ 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");
231
+ var processSpawn = require("./lib/process-spawn");
230
232
  var credentialHash = require("./lib/credential-hash");
231
233
  var permissions = require("./lib/permissions");
232
234
  var cache = require("./lib/cache");
@@ -404,6 +406,8 @@ module.exports = {
404
406
  webhook: webhook,
405
407
  apiKey: apiKey,
406
408
  honeytoken: honeytoken,
409
+ resourceAccessLock: resourceAccessLock,
410
+ processSpawn: processSpawn,
407
411
  credentialHash: credentialHash,
408
412
  permissions: permissions,
409
413
  cache: cache,
@@ -146,6 +146,23 @@ function _rowToWireForm(row) {
146
146
  return out;
147
147
  }
148
148
 
149
+ // F-AUD-4 — operator-facing wire helper that surfaces recordedAt as
150
+ // ISO-8601 / RFC 3339 alongside the existing Unix-ms integer.
151
+ // Auditors comparing rows against external SIEM events expect ISO
152
+ // with explicit Z; the framework's primary ms storage stays
153
+ // unchanged AND _rowToWireForm (which the chain-hash canonicalizes
154
+ // over) doesn't change its bytes — so chain verify continues to
155
+ // match. Operators call this on retrieved rows for export.
156
+ function withRecordedAtIso(row) {
157
+ if (!row) return row;
158
+ var out = Object.assign({}, row);
159
+ if (typeof row.recordedAt === "number" || typeof row.recordedAt === "bigint") {
160
+ var ms = typeof row.recordedAt === "bigint" ? Number(row.recordedAt) : row.recordedAt;
161
+ if (isFinite(ms)) out.recordedAtIso = new Date(ms).toISOString();
162
+ }
163
+ return out;
164
+ }
165
+
149
166
  function _wireFormToRow(wire) {
150
167
  var out = {};
151
168
  var keys = Object.keys(wire);
@@ -734,11 +751,12 @@ async function forensicSnapshot(opts) {
734
751
  }
735
752
 
736
753
  module.exports = {
737
- archive: archive,
738
- exportSlice: exportSlice,
739
- forensicSnapshot: forensicSnapshot,
740
- verifyBundle: verifyBundle,
741
- purge: purge,
754
+ archive: archive,
755
+ exportSlice: exportSlice,
756
+ forensicSnapshot: forensicSnapshot,
757
+ verifyBundle: verifyBundle,
758
+ purge: purge,
759
+ withRecordedAtIso: withRecordedAtIso,
742
760
  BUNDLE_FORMAT: BUNDLE_FORMAT,
743
761
  KIND_ARCHIVE: KIND_ARCHIVE,
744
762
  KIND_EXPORT: KIND_EXPORT,
package/lib/audit.js CHANGED
@@ -250,6 +250,8 @@ 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)
254
+ "process", // b.processSpawn (process.spawn / process.spawn.failed)
253
255
  ];
254
256
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
255
257
 
@@ -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
- return await argon2.hash(plain, {
564
- type: argon2.argon2id,
565
- memoryCost: p.memoryCost,
566
- timeCost: p.timeCost,
567
- parallelism: p.parallelism,
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,
@@ -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
  };
@@ -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") return String(value);
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) return value.toISOString();
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' or 'reject'; got " +
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
- module.exports = { stringify: stringify, sortKeys: sortKeys };
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: value,
117
- get: function (key) { return value[key]; },
118
- has: function (key) { return Object.prototype.hasOwnProperty.call(value, key); },
119
- redacted: redactedView,
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: create,
125
- ConfigError: ConfigError,
126
- coerce: coerce,
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
- var ENVELOPE_MAGIC = 0xE1;
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,
@@ -19,7 +19,7 @@
19
19
  */
20
20
  var vault = require("./vault");
21
21
  var { sha3Hash } = require("./crypto");
22
- var { HASH_PREFIX, VAULT_PREFIX } = require("./constants");
22
+ var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
23
23
 
24
24
  // Per-table registry, populated by db.init()
25
25
  var schemas = Object.create(null);
@@ -67,7 +67,8 @@ function computeDerived(table, sourceField, sourceValue) {
67
67
  if (spec.from === sourceField) {
68
68
  var ns = namespaceFor(table, sourceField, s.hashNamespaces);
69
69
  var normalized = spec.normalize ? spec.normalize(sourceValue) : String(sourceValue);
70
- return { field: derivedField, value: sha3Hash(ns + normalized) };
70
+ var saltHex = vault.getDerivedHashSalt().toString("hex");
71
+ return { field: derivedField, value: sha3Hash(saltHex + ns + normalized) };
71
72
  }
72
73
  }
73
74
  return null;
@@ -92,7 +93,8 @@ function sealRow(table, row) {
92
93
  var plain = String(raw).startsWith(VAULT_PREFIX) ? vault.unseal(raw) : raw;
93
94
  var ns = namespaceFor(table, spec.from, s.hashNamespaces);
94
95
  var normalized = spec.normalize ? spec.normalize(plain) : String(plain);
95
- out[derivedField] = sha3Hash(ns + normalized);
96
+ var saltHex2 = vault.getDerivedHashSalt().toString("hex");
97
+ out[derivedField] = sha3Hash(saltHex2 + ns + normalized);
96
98
  }
97
99
  }
98
100
 
@@ -178,7 +180,16 @@ function eraseRow(table, row) {
178
180
  out[derivedField] = null;
179
181
  }
180
182
  }
181
- out.__erasedAt = Date.now();
183
+ // F-RTBF-4 — `__erasedAt` was previously a plaintext UTC ms integer.
184
+ // That value alone fingerprints the erasure event (audit-log
185
+ // exfiltration + cross-tenant correlation: "this row was erased
186
+ // 2.3s before that one"). Bucket the timestamp to a 1-day floor so
187
+ // the event still surfaces "erased before / after this date" for
188
+ // operational use without leaking sub-day timing. Operators who
189
+ // genuinely need the precise instant pull the audit-chain row
190
+ // (which is itself sealed under the audit-sign keypair).
191
+ var dayMs = TIME.days(1);
192
+ out.__erasedAt = Math.floor(Date.now() / dayMs) * dayMs;
182
193
  return out;
183
194
  }
184
195
 
@@ -197,7 +208,8 @@ function lookupHash(table, field, value) {
197
208
  if (spec.from === field) {
198
209
  var ns = namespaceFor(table, field, s.hashNamespaces);
199
210
  var normalized = spec.normalize ? spec.normalize(value) : String(value);
200
- return { field: derivedField, value: sha3Hash(ns + normalized) };
211
+ var saltHex = vault.getDerivedHashSalt().toString("hex");
212
+ return { field: derivedField, value: sha3Hash(saltHex + ns + normalized) };
201
213
  }
202
214
  }
203
215
  return null;