@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.
@@ -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
+ };
@@ -66,12 +66,49 @@ var log = boot("vault");
66
66
 
67
67
  function resolvePaths(dataDir) {
68
68
  return {
69
- dataDir: dataDir,
70
- plaintext: path.join(dataDir, "vault.key"),
71
- sealed: path.join(dataDir, "vault.key.sealed"),
69
+ dataDir: dataDir,
70
+ plaintext: path.join(dataDir, "vault.key"),
71
+ sealed: path.join(dataDir, "vault.key.sealed"),
72
+ derivedHashSalt: path.join(dataDir, "vault.derived-hash-salt"),
72
73
  };
73
74
  }
74
75
 
76
+ // derivedHashSalt — per-deployment salt for crypto-field
77
+ // derivedHashes (D-H1). Pre-v0.8.42 the deterministic
78
+ // sha3(namespace + plaintext) shape allowed cross-deployment
79
+ // rainbow + cross-table correlation; binding a 32-byte
80
+ // per-deployment salt closes that class without breaking
81
+ // indexed-lookup determinism inside one deployment. The salt
82
+ // persists across vault rotations (different file from vault.key)
83
+ // so existing derivedHash columns survive a passphrase change.
84
+ function _readOrCreateDerivedHashSalt() {
85
+ if (!paths) {
86
+ throw new VaultError("vault/not-initialized",
87
+ "vault.derivedHashSalt() requires init()");
88
+ }
89
+ if (fs.existsSync(paths.derivedHashSalt)) {
90
+ var raw = atomicFile.readSync(paths.derivedHashSalt);
91
+ if (raw.length !== 32) { // allow:raw-byte-literal — 32-byte (256-bit) salt
92
+ throw new VaultError("vault/derived-hash-salt-corrupted",
93
+ "vault.derived-hash-salt must be exactly 32 bytes; got " + raw.length);
94
+ }
95
+ return raw;
96
+ }
97
+ var nodeCrypto = require("node:crypto");
98
+ var salt = nodeCrypto.randomBytes(32); // allow:raw-byte-literal — 32-byte salt
99
+ atomicFile.writeSync(paths.derivedHashSalt, salt, { fileMode: 0o600 });
100
+ log("generated per-deployment derivedHash salt at " + paths.derivedHashSalt);
101
+ return salt;
102
+ }
103
+
104
+ var _cachedDerivedHashSalt = null;
105
+ function getDerivedHashSalt() {
106
+ if (_cachedDerivedHashSalt === null) {
107
+ _cachedDerivedHashSalt = _readOrCreateDerivedHashSalt();
108
+ }
109
+ return _cachedDerivedHashSalt;
110
+ }
111
+
75
112
  // ---- Init dispatch ----
76
113
 
77
114
  async function init(opts) {
@@ -294,10 +331,34 @@ var vaultAad = require("../vault-aad");
294
331
 
295
332
  var sealPemFileModule = require("./seal-pem-file");
296
333
 
334
+ // _zeroizeAndReplace — best-effort secureZero of prior in-memory keys
335
+ // before a swap. V8 strings can't be reliably overwritten (string
336
+ // interning + GC managed), so the pre-swap pass converts each PEM
337
+ // string to a Buffer, secureZeros the Buffer, and rebinds the
338
+ // property to "ZEROED" before the new keys land. The string copy
339
+ // inside V8 may still linger until GC; this just removes the
340
+ // largest-window heap copy (the ones held by `keys`).
341
+ function _zeroizeAndReplace(replacement) {
342
+ if (!keys) { keys = replacement; return; }
343
+ Object.keys(keys).forEach(function (k) {
344
+ var v = keys[k];
345
+ if (typeof v === "string" && v.length > 0) {
346
+ try {
347
+ var buf = Buffer.from(v, "utf8");
348
+ safeBuffer.secureZero(buf);
349
+ } catch (_e) { /* best-effort */ }
350
+ keys[k] = "ZEROED";
351
+ }
352
+ });
353
+ keys = replacement;
354
+ }
355
+
297
356
  module.exports = {
298
357
  init: init,
299
358
  seal: seal,
300
359
  unseal: unseal,
360
+ getDerivedHashSalt: getDerivedHashSalt,
361
+ _zeroizeAndReplace: _zeroizeAndReplace,
301
362
  aad: vaultAad,
302
363
  getKeysJson: getKeysJson,
303
364
  getCurrentPassphrase: getCurrentPassphrase,
@@ -76,7 +76,12 @@ var SealPemFileError = defineClass("SealPemFileError", { alwaysPermanent: true }
76
76
  // 2-second worst-case re-seal latency — negligible against the
77
77
  // renewal cadence. Operators with sub-second-sensitive use cases
78
78
  // override via opts.pollInterval.
79
- var DEFAULT_POLL_MS = C.TIME.seconds(2);
79
+ // H6 #6 — fs.watchFile default cadence reduced from 2s to 500ms so a
80
+ // fast renewal-then-revert (mtime bump then second bump within ~2s)
81
+ // doesn't sneak past the watcher. Operators with extremely-quiet
82
+ // renewal cycles can override via opts.pollInterval; the cost of
83
+ // 500ms polling on an idle PEM file is ~2 stat() syscalls/sec.
84
+ var DEFAULT_POLL_MS = 500; // allow:raw-time-literal — 500ms watchFile cadence (sub-second)
80
85
 
81
86
  // PEM files are tiny — 4 KiB for an ECDSA key, ~8 KiB for a 4096-bit
82
87
  // RSA key, ~64 KiB for a long cert chain. Cap at 1 MiB so an operator
@@ -148,7 +153,28 @@ function sealPemFile(opts) {
148
153
  // marker create and marker remove, the marker remains on disk
149
154
  // and _recoverIfNeeded() detects it on the next start().
150
155
  var markerPath = destination + ".rewriting";
151
- atomicFile.ensureDir(path.dirname(destination));
156
+ var destDir = path.dirname(destination);
157
+ atomicFile.ensureDir(destDir);
158
+ // H6 #4 — assert parent-dir mode. If the directory is world-
159
+ // writable, an attacker can swap the destination file or the
160
+ // .rewriting marker between our writeFileSync and the atomic
161
+ // rename. Refuse on group-/other-writable parent dirs (POSIX
162
+ // mode bits 0o022). On Windows the stat mode is synthetic;
163
+ // skip the check there.
164
+ if (process.platform !== "win32") {
165
+ try {
166
+ var dirStat = fs.statSync(destDir);
167
+ if ((dirStat.mode & 0o022) !== 0) { // allow:raw-byte-literal — POSIX mode mask
168
+ throw new SealPemFileError("seal-pem-file/parent-dir-writable",
169
+ "destination parent dir '" + destDir + "' is group/other-writable " +
170
+ "(mode " + (dirStat.mode & 0o777).toString(8) + // allow:raw-byte-literal — POSIX mode mask
171
+ ") — refuse to seal; chmod 0700 the dir");
172
+ }
173
+ } catch (e) {
174
+ if (e && e.code === "seal-pem-file/parent-dir-writable") throw e;
175
+ // stat itself failing is not fatal — the writeFileSync below will surface it.
176
+ }
177
+ }
152
178
  var sealed = vault().seal(plaintextBytes);
153
179
  fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 }); // allow:raw-byte-literal — POSIX file mode
154
180
  try {
@@ -158,6 +184,16 @@ function sealPemFile(opts) {
158
184
  throw e;
159
185
  }
160
186
  try { fs.unlinkSync(markerPath); } catch (_e) { /* marker cleanup best-effort */ }
187
+ // H6 #5 — fsync the destination directory so the rename + marker
188
+ // unlink survive a power loss. Crash + backup-snapshot edge case:
189
+ // without dir-fsync, a journaled fs may have the new file inode
190
+ // but not the directory entry update by the time the snapshot
191
+ // reads.
192
+ try {
193
+ var dirFd = fs.openSync(destDir, "r");
194
+ try { fs.fsyncSync(dirFd); }
195
+ finally { fs.closeSync(dirFd); }
196
+ } catch (_e) { /* dir fsync best-effort — Windows / non-POSIX may refuse */ }
161
197
  }
162
198
 
163
199
  function _resealNow(actor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.40",
3
+ "version": "0.8.42",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:7c951f5a-156e-49be-91c0-8a5a420faf2c",
5
+ "serialNumber": "urn:uuid:6b316a2e-756a-4aa8-90a8-b6c414dbdc39",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T16:35:12.500Z",
8
+ "timestamp": "2026-05-07T17:47:29.804Z",
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.40",
22
+ "bom-ref": "@blamejs/core@0.8.42",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.40",
25
+ "version": "0.8.42",
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.40",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.42",
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.40",
57
+ "ref": "@blamejs/core@0.8.42",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]