@blamejs/core 0.14.24 → 0.14.26

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.
@@ -254,6 +254,13 @@ function clientIp(req, opts) {
254
254
  }
255
255
  if (req.socket && typeof req.socket.remoteAddress === "string") return req.socket.remoteAddress;
256
256
  if (req.connection && typeof req.connection.remoteAddress === "string") return req.connection.remoteAddress;
257
+ // Express-shaped requests expose the resolved client address as `req.ip`
258
+ // (Express derives it from the socket, honoring its own trust-proxy
259
+ // setting) without a `socket.remoteAddress` surface. Fall back to it so a
260
+ // binding captured from such a request is populated rather than null —
261
+ // callers that pin a grant to the issuing IP otherwise capture null and
262
+ // could only be saved by a fail-closed guard at the consumer.
263
+ if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
257
264
  return null;
258
265
  }
259
266
 
package/lib/retention.js CHANGED
@@ -274,8 +274,18 @@ function create(opts) {
274
274
  values.push(row._id);
275
275
  var upd2 = db.prepare("UPDATE \"" + table + "\" SET " + setClauses.join(", ") + " WHERE _id = ?");
276
276
  upd2.run.apply(upd2, values);
277
+ // Per-row-key tables (declarePerRowKey): NULLing the sealed columns
278
+ // is not enough — WAL / replica residuals keep the old K_row cells.
279
+ // Destroy the row's wrapped secret so K_row is unrecoverable and the
280
+ // residual ciphertext reads as absent (crypto-shred, GDPR Art. 17).
281
+ // rowId is row._id, the same identity materialize / eraseHard use.
282
+ var perRowKeysDestroyed = 0;
283
+ if (cryptoField.hasPerRowKey(table)) {
284
+ var dr = cryptoField.destroyPerRowKey(table, row._id, db);
285
+ perRowKeysDestroyed = (dr && dr.destroyed) || 0;
286
+ }
277
287
  void erased;
278
- return { erased: 1, sealedFieldCount: sealedFields.length };
288
+ return { erased: 1, sealedFieldCount: sealedFields.length, perRowKeysDestroyed: perRowKeysDestroyed };
279
289
  }
280
290
 
281
291
  function _cascade(rule, rowId, dryRun) {
@@ -81,6 +81,11 @@ var agentSnapshotLazy = lazyRequire(function () { return require("../agent-snaps
81
81
  // rotation pipeline never walks, so archive-wrap exports the same external
82
82
  // AAD_ROTATION descriptor and must be gated here too.
83
83
  var archiveWrapLazy = lazyRequire(function () { return require("../archive-wrap"); });
84
+ // The DSR ticket store, when backed by an operator-supplied database, holds
85
+ // {aad:true} sealed cells (subject identifiers + request payload) keyed off the
86
+ // vault root that this pipeline never walks, so dsr exports the same external
87
+ // AAD_ROTATION descriptor and must be gated here too.
88
+ var dsrLazy = lazyRequire(function () { return require("../dsr"); });
84
89
  var { defineClass } = require("../framework-error");
85
90
 
86
91
  var rotateLog = boot("vault-rotate");
@@ -439,7 +444,7 @@ var VAULT_PREFIX_LEN = C.VAULT_PREFIX.length;
439
444
  // so loading rotate.js doesn't eagerly pull the agent modules.
440
445
  var EXTERNAL_AAD_MODULE_LOADERS = [
441
446
  agentIdempotencyLazy, agentOrchestratorLazy, agentTenantLazy, agentSnapshotLazy,
442
- archiveWrapLazy,
447
+ archiveWrapLazy, dsrLazy,
443
448
  ];
444
449
 
445
450
  function _externalAadTables() {
@@ -464,22 +469,25 @@ function _emit(cb, ev) {
464
469
  }
465
470
  }
466
471
 
467
- // Open a file for fsync. Different from atomicFile.fsync (which takes
468
- // an already-open fd) vault-rotate's fsync-by-path semantic opens
469
- // then syncs then closes, which is the right shape when we don't have
470
- // the original write fd around.
471
- //
472
- // CodeQL js/insecure-temporary-file: `p` is an operator-supplied path
473
- // inside opts.stagingDir (an owner-only 0o700 framework directory
474
- // established via atomicFile.ensureDir at the top of rotate()). Not an
475
- // os.tmpdir-reachable path. The fd is used solely for fsync and is
476
- // closed immediately; no bytes are read or written through it, so the
477
- // tmp-file predictability heuristic does not apply.
478
- function _fsyncFileByPath(p) {
472
+ // Create a fresh file in the owner-only staging dir with exclusive,
473
+ // no-follow semantics, then fsync it. O_EXCL turns a pre-planted file or
474
+ // symlink into a hard failure instead of a followed write; O_NOFOLLOW
475
+ // refuses a symlinked final component; the explicit 0o600 keeps the bytes
476
+ // owner-only regardless of umask. Any leftover from an aborted prior
477
+ // rotation is cleared first so the exclusive create can proceed. The
478
+ // staging dir is already 0o700 owner-only, so this is defense in depth
479
+ // against a same-user pre-plant / symlink swap (CWE-377 / CWE-379 / CWE-59).
480
+ function _writeStagedFileExclusive(p, data) {
481
+ try { nodeFs.unlinkSync(p); } catch (_e) { /* no stale entry to clear */ }
482
+ var fd = nodeFs.openSync(p,
483
+ nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
484
+ nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0), 0o600);
479
485
  try {
480
- var fd = nodeFs.openSync(p, "r+");
481
- try { nodeFs.fsyncSync(fd); } finally { nodeFs.closeSync(fd); }
482
- } catch (_e) { /* best-effort across platforms */ }
486
+ nodeFs.writeFileSync(fd, data);
487
+ nodeFs.fsyncSync(fd);
488
+ } finally {
489
+ nodeFs.closeSync(fd);
490
+ }
483
491
  }
484
492
 
485
493
  function _reSealValue(sealedValue, oldKeys, newKeys) {
@@ -670,7 +678,8 @@ async function rotate(opts) {
670
678
  "pipeline and would be orphaned under the retired keypair: " + externalAad.join(", ") +
671
679
  ". Re-seal each via its module hook (b.agent.idempotency.reseal / " +
672
680
  "b.agent.orchestrator.reseal / b.agent.tenant AAD_ROTATION reseal / " +
673
- "b.agent.snapshot.reseal / b.archive.rewrapTenant for archive-wrap:tenant-blobs) " +
681
+ "b.agent.snapshot.reseal / b.archive.rewrapTenant for archive-wrap:tenant-blobs / " +
682
+ "b.dsr.reseal for the dsr_tickets store) " +
674
683
  "BEFORE retiring the old keypair, then pass " +
675
684
  "opts.externalAadResealed: [" + externalAad.map(function (t) { return JSON.stringify(t); }).join(", ") +
676
685
  "] to acknowledge. If you do not use these features, pass opts.externalAadResealed: true.");
@@ -709,7 +718,10 @@ async function rotate(opts) {
709
718
  }
710
719
  var dest = nodePath.join(stagingDir, entry.relativePath);
711
720
  atomicFile.ensureDir(nodePath.dirname(dest));
712
- nodeFs.copyFileSync(src, dest);
721
+ // Stage via the exclusive-create + fsync helper rather than a plain copy,
722
+ // so the verbatim file is durable at write time (no later by-path fsync)
723
+ // and a pre-planted file/symlink at the staging path hard-fails.
724
+ _writeStagedFileExclusive(dest, nodeFs.readFileSync(src));
713
725
  }
714
726
  for (var vd = 0; vd < paths.verbatimDirs.length; vd++) {
715
727
  var dent = paths.verbatimDirs[vd];
@@ -737,9 +749,9 @@ async function rotate(opts) {
737
749
  var newRootJson = keysJson;
738
750
  if (mode === "wrapped") {
739
751
  var sealed = await vaultWrap().wrap(keysJson, opts.newPassphrase);
740
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.vaultKeySealed), sealed, { mode: 0o600 });
752
+ _writeStagedFileExclusive(nodePath.join(stagingDir, paths.vaultKeySealed), sealed);
741
753
  } else {
742
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.vaultKeyPlain), keysJson, { mode: 0o600 });
754
+ _writeStagedFileExclusive(nodePath.join(stagingDir, paths.vaultKeyPlain), keysJson);
743
755
  }
744
756
 
745
757
  // 3. re-seal db.key.enc + any operator-supplied additionalSealed files
@@ -759,14 +771,14 @@ async function rotate(opts) {
759
771
  var dbKeyB64Aad = vaultAad.unsealRoot(sealedKey, dbKeyAad, oldRootJson);
760
772
  dbKey = Buffer.from(dbKeyB64Aad, "base64");
761
773
  var resealedAad = vaultAad.sealRoot(dbKeyB64Aad, dbKeyAad, newRootJson);
762
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.dbKeySealed), resealedAad, { mode: 0o600 });
774
+ _writeStagedFileExclusive(nodePath.join(stagingDir, paths.dbKeySealed), resealedAad);
763
775
  } else if (sealedKey.indexOf(C.VAULT_PREFIX) === 0) {
764
776
  // Legacy plain-sealed db.key.enc (pre-AAD). Re-key in place; db.init
765
777
  // read-migrates plain -> AAD on the next boot.
766
778
  var dbKeyB64 = bCrypto.decrypt(sealedKey.substring(VAULT_PREFIX_LEN), oldKeys);
767
779
  dbKey = Buffer.from(dbKeyB64, "base64");
768
780
  var resealedKey = C.VAULT_PREFIX + bCrypto.encrypt(dbKeyB64, newKeys);
769
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.dbKeySealed), resealedKey, { mode: 0o600 });
781
+ _writeStagedFileExclusive(nodePath.join(stagingDir, paths.dbKeySealed), resealedKey);
770
782
  } else {
771
783
  throw new VaultRotateError("vault-rotate/bad-dbkey",
772
784
  "rotate: db.key.enc does not start with a vault prefix (vault: or vault.aad:)");
@@ -789,8 +801,8 @@ async function rotate(opts) {
789
801
  }
790
802
  var asDestDir = nodePath.join(stagingDir, nodePath.dirname(ase.relativePath));
791
803
  if (!nodeFs.existsSync(asDestDir)) atomicFile.ensureDir(asDestDir);
792
- nodeFs.writeFileSync(nodePath.join(stagingDir, ase.relativePath),
793
- _reSealValue(current, oldKeys, newKeys), { mode: 0o600 });
804
+ _writeStagedFileExclusive(nodePath.join(stagingDir, ase.relativePath),
805
+ _reSealValue(current, oldKeys, newKeys));
794
806
  }
795
807
 
796
808
  // 3b. Framework-managed crypto-field derived-hash files — always
@@ -801,14 +813,17 @@ async function rotate(opts) {
801
813
  // re-seals to the same value since the keypair is unchanged).
802
814
  var saltSrc = nodePath.join(dataDir, "vault.derived-hash-salt");
803
815
  if (nodeFs.existsSync(saltSrc)) {
804
- nodeFs.copyFileSync(saltSrc, nodePath.join(stagingDir, "vault.derived-hash-salt"));
816
+ // Stage via the exclusive-create + fsync helper (not a plain copy) so the
817
+ // salt is durable at write time and no later by-path fsync is needed.
818
+ _writeStagedFileExclusive(nodePath.join(stagingDir, "vault.derived-hash-salt"),
819
+ nodeFs.readFileSync(saltSrc));
805
820
  }
806
821
  var macSrc = nodePath.join(dataDir, "vault.derived-hash-mac.sealed");
807
822
  if (nodeFs.existsSync(macSrc)) {
808
823
  var macCurrent = nodeFs.readFileSync(macSrc, "utf8").trim();
809
824
  if (macCurrent.indexOf(C.VAULT_PREFIX) === 0) {
810
- nodeFs.writeFileSync(nodePath.join(stagingDir, "vault.derived-hash-mac.sealed"),
811
- _reSealValue(macCurrent, oldKeys, newKeys), { mode: 0o600 });
825
+ _writeStagedFileExclusive(nodePath.join(stagingDir, "vault.derived-hash-mac.sealed"),
826
+ _reSealValue(macCurrent, oldKeys, newKeys));
812
827
  }
813
828
  }
814
829
 
@@ -830,7 +845,7 @@ async function rotate(opts) {
830
845
  try { plainBytes = bCrypto.decryptPacked(packed, dbKey, dbEncAad); }
831
846
  catch (_eAad) { plainBytes = bCrypto.decryptPacked(packed, dbKey); }
832
847
  var tmpDbPath = nodePath.join(stagingDir, "_blamejs_rotate.tmp.db");
833
- nodeFs.writeFileSync(tmpDbPath, plainBytes, { mode: 0o600 });
848
+ _writeStagedFileExclusive(tmpDbPath, plainBytes);
834
849
 
835
850
  var db = new DatabaseSync(tmpDbPath);
836
851
  try {
@@ -893,25 +908,23 @@ async function rotate(opts) {
893
908
  try { nodeFs.unlinkSync(tmpDbPath + "-shm"); }
894
909
  catch (e) { rotateLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: tmpDbPath + "-shm", error: e.message }); }
895
910
 
896
- // CodeQL js/insecure-temporary-file: every "tmp" path here is inside
897
- // opts.stagingDir — operator-supplied, ensureDir'd 0o700 owner-only,
898
- // never under os.tmpdir(). The filenames are framework-internal
899
- // markers (`_blamejs_rotate.tmp.db`, `_blamejs_verify.tmp.db`); their
900
- // predictability does not enable a symlink attack because the staging
901
- // dir's owner-only perms prevent any other user from creating entries
902
- // inside it. Files are written 0o600 implicitly via the dir's umask
903
- // and removed before the rotation completes.
911
+ // Every staged path lives inside opts.stagingDir (operator-supplied,
912
+ // ensureDir'd 0o700 owner-only, never under os.tmpdir()) and carries a
913
+ // framework-internal marker name. The staged writes go through
914
+ // _writeStagedFileExclusive exclusive + no-follow create, owner-only
915
+ // 0o600 so a same-user pre-plant or symlink swap is a hard failure
916
+ // rather than a followed write, and the bytes never inherit a wider mode.
904
917
  var rotatedBytes = nodeFs.readFileSync(tmpDbPath);
905
918
  // Re-encrypt under the SAME dataDir AAD so db.init's AAD-first open
906
919
  // succeeds after the staged dir is swapped over dataDir in place.
907
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.encryptedDb),
920
+ _writeStagedFileExclusive(nodePath.join(stagingDir, paths.encryptedDb),
908
921
  bCrypto.encryptPacked(rotatedBytes, dbKey, dbEncAad));
909
922
  nodeFs.unlinkSync(tmpDbPath);
910
923
 
911
924
  // Round-trip verify on the staged DB
912
925
  _emit(progress, { phase: "verify" });
913
926
  var verifyTmp = nodePath.join(stagingDir, "_blamejs_verify.tmp.db");
914
- nodeFs.writeFileSync(verifyTmp,
927
+ _writeStagedFileExclusive(verifyTmp,
915
928
  bCrypto.decryptPacked(nodeFs.readFileSync(nodePath.join(stagingDir, paths.encryptedDb)), dbKey, dbEncAad));
916
929
  var vdb = new DatabaseSync(verifyTmp);
917
930
  try {
@@ -934,19 +947,26 @@ async function rotate(opts) {
934
947
  }
935
948
  }
936
949
 
937
- // 5. fsync staging for durability before caller does the swap
950
+ // 5. fsync staging directory entries for durability before the caller swaps.
951
+ // Every staged FILE is already fsync'd at write time by
952
+ // _writeStagedFileExclusive (the re-encrypted db, the resealed vault/db keys,
953
+ // sealed files, the derived-hash salt, and verbatim files), so re-opening
954
+ // each by path here is redundant — and opening a staged file by path is the
955
+ // os-temp-dir open the static analyzer refuses (CWE-377 heuristic). Only the
956
+ // optional verbatimDirs are copied with copyFileSync (no per-file fsync);
957
+ // their directory entries + the rename are made durable by fsyncDir and their
958
+ // source files in dataDir remain intact, so a crash in that narrow window is
959
+ // recoverable.
938
960
  _emit(progress, { phase: "fsync" });
939
- function fsyncTree(dir) {
961
+ function fsyncDirTree(dir) {
940
962
  var entries = nodeFs.readdirSync(dir);
941
963
  for (var i = 0; i < entries.length; i++) {
942
964
  var p = nodePath.join(dir, entries[i]);
943
- var st = nodeFs.statSync(p);
944
- if (st.isFile()) _fsyncFileByPath(p);
945
- else if (st.isDirectory()) fsyncTree(p);
965
+ if (nodeFs.statSync(p).isDirectory()) fsyncDirTree(p);
946
966
  }
947
967
  atomicFile.fsyncDir(dir);
948
968
  }
949
- fsyncTree(stagingDir);
969
+ fsyncDirTree(stagingDir);
950
970
 
951
971
  var durationMs = Date.now() - startedAt;
952
972
  _emit(progress, {
package/lib/vault-aad.js CHANGED
@@ -304,6 +304,12 @@ module.exports = {
304
304
  isAadSealed: isAadSealed,
305
305
  buildColumnAad: buildColumnAad,
306
306
  buildContextAad: buildContextAad,
307
+ // canonicalizeAad — the length-prefixed, sorted-keys AAD-bytes
308
+ // encoder. Exported (internal) so a sibling primitive that runs its
309
+ // own AEAD (crypto-field's per-row K_row cells) threads byte-identical
310
+ // AAD into encryptPacked/decryptPacked as this module does for its
311
+ // own seal/unseal — one canonical encoder, no drift.
312
+ canonicalizeAad: _canonicalize,
307
313
  AAD_PREFIX: AAD_PREFIX,
308
314
  AAD_VERSION: AAD_VERSION,
309
315
  VaultAadError: VaultAadError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.24",
3
+ "version": "0.14.26",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.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:0697fb13-faa3-47c7-8ac0-7a29c980f002",
5
+ "serialNumber": "urn:uuid:e0214cf9-5d77-475a-af9a-e3cedff9f6d7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-06T15:36:19.660Z",
8
+ "timestamp": "2026-06-06T21:14:16.419Z",
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.14.24",
22
+ "bom-ref": "@blamejs/core@0.14.26",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.24",
25
+ "version": "0.14.26",
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.14.24",
29
+ "purl": "pkg:npm/%40blamejs/core@0.14.26",
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.14.24",
57
+ "ref": "@blamejs/core@0.14.26",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]