@blamejs/core 0.14.26 → 0.15.0

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.
Files changed (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
package/lib/api-key.js CHANGED
@@ -43,6 +43,7 @@ var cluster = require("./cluster");
43
43
  var cryptoField = require("./crypto-field");
44
44
  var requestHelpers = require("./request-helpers");
45
45
  var validateOpts = require("./validate-opts");
46
+ var sql = require("./sql");
46
47
  var C = require("./constants");
47
48
  var numericChecks = require("./numeric-checks");
48
49
  var { ApiKeyError } = require("./framework-error");
@@ -53,16 +54,28 @@ function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
53
54
 
54
55
  var _err = ApiKeyError.factory;
55
56
 
56
- var TABLE = "_blamejs_api_keys";
57
- // Pre-quoted form for SQL interpolation. Defense-in-depth: even though
58
- // our constant is bare-identifier-shaped, every interpolation site uses
59
- // the wrapped form so a future rename to a reserved-word or
60
- // whitespace-bearing name would still resolve correctly.
61
- var Q_TABLE = '"' + TABLE + '"';
62
-
63
- // Column order used for INSERT kept as a constant so the placeholders
64
- // list and the values list stay in sync. Must match _blamejs_api_keys'
65
- // schema in db.js (single-node) and framework-schema.js (cluster mode).
57
+ // Logical framework table name. Self-mapped in LOCAL_TO_EXTERNAL, so it is
58
+ // passed BARE to b.sql: clusterStorage.execute rewrites it to the configured
59
+ // prefix and placeholderizes the `?` markers, so one query text runs against
60
+ // the local SQLite single-node backend and the operator's external DB in
61
+ // cluster mode.
62
+ var TABLE = "_blamejs_api_keys"; // allow:hand-rolled-sql bare logical name, passed to b.sql for clusterStorage rewrite
63
+
64
+ // b.sql opts for every _blamejs_api_keys statement: thread the ACTIVE backend
65
+ // dialect (clusterStorage.dialect() "sqlite" single-node, "postgres" |
66
+ // "mysql" in cluster mode) so the emitted identifier quoting + dialect idioms
67
+ // match the backend the SQL dispatches to. Defaulting to "sqlite" works on
68
+ // Postgres only by accident (both double-quote identifiers) and emits the
69
+ // wrong quoting on MySQL, so this is the canonical resolver threaded into
70
+ // b.sql. clusterStorage.execute still rewrites the bare table name +
71
+ // translates `?` placeholders at dispatch; this controls only the builder-
72
+ // side quoting + idiom selection. The table name stays BARE (no quoteName)
73
+ // so clusterStorage's prefix rewrite still fires.
74
+ function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
75
+
76
+ // Column order used for INSERT — kept as a constant so the column list and
77
+ // the row object stay in sync. Must match _blamejs_api_keys' schema in
78
+ // db.js (single-node) and framework-schema.js (cluster mode).
66
79
  var COLS = [
67
80
  "id", "namespace", "ownerId", "ownerIdHash", "secretHash",
68
81
  "secondarySecretHash", "secondaryExpiresAt",
@@ -305,10 +318,11 @@ function create(opts) {
305
318
  );
306
319
  }
307
320
 
308
- function _selectAll() {
309
- return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
310
- "secondarySecretHash, secondaryExpiresAt, " +
311
- "scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
321
+ // Fresh SELECT builder over the full column set. BARE logical table name
322
+ // (_blamejs_api_keys) clusterStorage rewrites it to the configured
323
+ // prefix and placeholderizes. Callers chain the WHERE family + .toSql().
324
+ function _selectBuilder() {
325
+ return sql.select(TABLE, _sqlOpts()).columns(COLS); // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
312
326
  }
313
327
 
314
328
  function _scrubRecord(row) {
@@ -369,14 +383,13 @@ function create(opts) {
369
383
  lastUsedAt: null,
370
384
  prefix: prefix,
371
385
  });
372
- var values = COLS.map(function (c) { return sealed[c]; });
373
- var placeholders = COLS.map(function () { return "?"; }).join(", ");
374
- var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
375
-
376
- await clusterStorage.execute(
377
- "INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
378
- values
379
- );
386
+ var insertRow = {};
387
+ for (var ci = 0; ci < COLS.length; ci++) insertRow[COLS[ci]] = sealed[COLS[ci]];
388
+ var insertBuilt = sql.insert(TABLE, _sqlOpts()) // allow:hand-rolled-sql bare logical name for clusterStorage rewrite
389
+ .columns(COLS)
390
+ .values(insertRow)
391
+ .toSql();
392
+ await clusterStorage.execute(insertBuilt.sql, insertBuilt.params);
380
393
 
381
394
  _emit("apikey.issue", {
382
395
  actor: _actor(issueOpts, issueOpts.ownerId),
@@ -402,10 +415,8 @@ function create(opts) {
402
415
  if (parsed.prefix !== prefix || parsed.namespace !== namespace) return null;
403
416
 
404
417
  var compositeId = _composedId(namespace, parsed.idHex);
405
- var row = await clusterStorage.executeOne(
406
- _selectAll() + " WHERE id = ?",
407
- [compositeId]
408
- );
418
+ var verifyBuilt = _selectBuilder().where("id", compositeId).toSql();
419
+ var row = await clusterStorage.executeOne(verifyBuilt.sql, verifyBuilt.params);
409
420
  if (!row) {
410
421
  if (auditFailures) {
411
422
  _emit("apikey.verify", {
@@ -474,10 +485,11 @@ function create(opts) {
474
485
 
475
486
  if (trackLastUsedAt && cluster.isLeader()) {
476
487
  try {
477
- await clusterStorage.execute(
478
- "UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
479
- [nowMs, compositeId]
480
- );
488
+ var touchBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
489
+ .set({ lastUsedAt: nowMs })
490
+ .where("id", compositeId)
491
+ .toSql();
492
+ await clusterStorage.execute(touchBuilt.sql, touchBuilt.params);
481
493
  } catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
482
494
  }
483
495
 
@@ -501,10 +513,12 @@ function create(opts) {
501
513
  if (typeof idHex !== "string" || idHex.length === 0) return false;
502
514
  var compositeId = _composedId(namespace, idHex);
503
515
  var nowMs = clock();
504
- var result = await clusterStorage.execute(
505
- "UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
506
- [nowMs, compositeId]
507
- );
516
+ var revokeBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
517
+ .set({ revokedAt: nowMs })
518
+ .where("id", compositeId)
519
+ .whereNull("revokedAt")
520
+ .toSql();
521
+ var result = await clusterStorage.execute(revokeBuilt.sql, revokeBuilt.params);
508
522
  var changed = (result.rowCount || 0) > 0;
509
523
  if (changed) {
510
524
  _emit("apikey.revoke", {
@@ -542,10 +556,8 @@ function create(opts) {
542
556
  }
543
557
 
544
558
  var compositeId = _composedId(namespace, idHex);
545
- var existing = await clusterStorage.executeOne(
546
- _selectAll() + " WHERE id = ?",
547
- [compositeId]
548
- );
559
+ var rotateSelBuilt = _selectBuilder().where("id", compositeId).toSql();
560
+ var existing = await clusterStorage.executeOne(rotateSelBuilt.sql, rotateSelBuilt.params);
549
561
  if (!existing) {
550
562
  throw _err("NOT_FOUND", "apiKey.rotate: id '" + idHex + "' not found in namespace '" + namespace + "'");
551
563
  }
@@ -558,19 +570,27 @@ function create(opts) {
558
570
 
559
571
  if (gracePeriodMs > 0) {
560
572
  // Move current hash → secondary slot, install new hash as primary.
561
- await clusterStorage.execute(
562
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
563
- "secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
564
- [newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
565
- );
573
+ var graceBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
574
+ .set({
575
+ secretHash: newHash,
576
+ secondarySecretHash: existing.secretHash,
577
+ secondaryExpiresAt: nowMs + gracePeriodMs,
578
+ })
579
+ .where("id", compositeId)
580
+ .toSql();
581
+ await clusterStorage.execute(graceBuilt.sql, graceBuilt.params);
566
582
  } else {
567
583
  // Hard cutover — old secret stops working immediately. Clears
568
- // any prior secondary slot too.
569
- await clusterStorage.execute(
570
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
571
- "secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
572
- [newHash, compositeId]
573
- );
584
+ // any prior secondary slot too (bound NULL via the set map).
585
+ var cutoverBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
586
+ .set({
587
+ secretHash: newHash,
588
+ secondarySecretHash: null,
589
+ secondaryExpiresAt: null,
590
+ })
591
+ .where("id", compositeId)
592
+ .toSql();
593
+ await clusterStorage.execute(cutoverBuilt.sql, cutoverBuilt.params);
574
594
  }
575
595
 
576
596
  _emit("apikey.rotate", {
@@ -598,17 +618,23 @@ function create(opts) {
598
618
  var lookup = cryptoField.lookupHash(TABLE, "ownerId", ownerId);
599
619
  if (!lookup) {
600
620
  throw _err("MISCONFIGURED",
601
- "_blamejs_api_keys schema is missing the ownerIdHash derived hash — framework misconfigured");
621
+ TABLE + " schema is missing the ownerIdHash derived hash — framework misconfigured");
602
622
  }
603
- var sql = _selectAll() + " WHERE namespace = ? AND ownerIdHash = ?";
604
- var params = [namespace, lookup.value];
605
- if (!includeRevoked) sql += " AND revokedAt IS NULL";
623
+ var listQb = _selectBuilder()
624
+ .where("namespace", namespace)
625
+ .where("ownerIdHash", lookup.value);
626
+ if (!includeRevoked) listQb.whereNull("revokedAt");
606
627
  if (!includeExpired) {
607
- sql += " AND (expiresAt IS NULL OR expiresAt >= ?)";
608
- params.push(clock());
628
+ var nowForExpiry = clock();
629
+ // (expiresAt IS NULL OR expiresAt >= now) — an OR group ANDed onto
630
+ // the chain so the optional clause keeps its own precedence.
631
+ listQb.whereGroup(function (g) {
632
+ g.whereNull("expiresAt").orWhereOp("expiresAt", ">=", nowForExpiry);
633
+ });
609
634
  }
610
- sql += " ORDER BY createdAt DESC";
611
- var rows = await clusterStorage.execute(sql, params);
635
+ listQb.orderBy("createdAt", "desc");
636
+ var listBuilt = listQb.toSql();
637
+ var rows = await clusterStorage.execute(listBuilt.sql, listBuilt.params);
612
638
  var list = (rows.rows || []).map(_scrubRecord);
613
639
  _emitEvent("apikey.list", 1, { namespace: namespace, count: list.length });
614
640
  // Read-access audit: "who listed whose keys at time T" — gated by
@@ -635,10 +661,8 @@ function create(opts) {
635
661
  async function getById(idHex, getOpts) {
636
662
  if (typeof idHex !== "string" || idHex.length === 0) return null;
637
663
  var compositeId = _composedId(namespace, idHex);
638
- var row = await clusterStorage.executeOne(
639
- _selectAll() + " WHERE id = ?",
640
- [compositeId]
641
- );
664
+ var getBuilt = _selectBuilder().where("id", compositeId).toSql();
665
+ var row = await clusterStorage.executeOne(getBuilt.sql, getBuilt.params);
642
666
  var record = _scrubRecord(row);
643
667
  _emitEvent("apikey.get", 1,
644
668
  { namespace: namespace, found: record !== null });
@@ -659,13 +683,24 @@ function create(opts) {
659
683
  // Compliance auditors expect "key X was purged at time T" — a count-
660
684
  // only audit is too coarse for forensic reconstruction. Cost is one
661
685
  // extra round-trip per purge call which runs on a schedule (not
662
- // request-rate), so the cost is irrelevant.
663
- var idRows = await clusterStorage.execute(
664
- "SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
665
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
666
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
667
- [namespace, threshold, threshold]
668
- );
686
+ // request-rate), so the cost is irrelevant. The purge predicate
687
+ // (namespace match + an OR of the two "past-threshold" age groups) is
688
+ // applied identically to the SELECT and the DELETE via _applyPurgeWhere.
689
+ function _applyPurgeWhere(qb) {
690
+ return qb
691
+ .where("namespace", namespace)
692
+ .whereGroup(function (g) {
693
+ g.whereGroup(function (a) {
694
+ a.whereNotNull("revokedAt").where("revokedAt", "<", threshold);
695
+ }).orWhereGroup(function (b2) {
696
+ b2.whereNotNull("expiresAt").where("expiresAt", "<", threshold);
697
+ });
698
+ });
699
+ }
700
+ var purgeSelBuilt = _applyPurgeWhere(
701
+ sql.select(TABLE, _sqlOpts()).columns(["id"]) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
702
+ ).toSql();
703
+ var idRows = await clusterStorage.execute(purgeSelBuilt.sql, purgeSelBuilt.params);
669
704
  var purgedCompositeIds = (idRows.rows || []).map(function (r) { return r.id; });
670
705
 
671
706
  if (purgedCompositeIds.length === 0) {
@@ -673,12 +708,10 @@ function create(opts) {
673
708
  return 0;
674
709
  }
675
710
 
676
- var result = await clusterStorage.execute(
677
- "DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
678
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
679
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
680
- [namespace, threshold, threshold]
681
- );
711
+ var purgeDelBuilt = _applyPurgeWhere(
712
+ sql.delete(TABLE, _sqlOpts()) // allow:hand-rolled-sql bare logical name for clusterStorage rewrite
713
+ ).toSql();
714
+ var result = await clusterStorage.execute(purgeDelBuilt.sql, purgeDelBuilt.params);
682
715
  var count = result.rowCount || purgedCompositeIds.length;
683
716
 
684
717
  _emit("apikey.purge", {
@@ -167,6 +167,30 @@ function fsyncDir(dirPath) {
167
167
  function _fsync(fd) { return fsync(fd); }
168
168
  function _fsyncDir(dirPath) { return fsyncDir(dirPath); }
169
169
 
170
+ // Exclusive, no-follow create of the sibling temp file that every
171
+ // atomic write stages bytes into before the rename. CWE-377
172
+ // (insecure temporary file) / CWE-59 (symlink-following): the legacy
173
+ // "w" flag is O_WRONLY|O_CREAT|O_TRUNC — it happily opens (and
174
+ // truncates, or writes through) a file an attacker pre-created at the
175
+ // temp path, including a symlink pointing at a victim file the process
176
+ // can write but the attacker can't. O_EXCL makes the open fail with
177
+ // EEXIST if anything already exists at tmpPath, so a planted file /
178
+ // symlink / FIFO is refused instead of followed; O_NOFOLLOW rejects a
179
+ // symlink in the final path component on platforms that define it
180
+ // (Windows leaves it undefined, hence the `|| 0`). The temp name
181
+ // already carries a CSPRNG token (generateToken), so EEXIST is a
182
+ // hostile-collision signal, not a benign retry. The fd is returned for
183
+ // the caller to write + fsync; mode is applied at create time so the
184
+ // bytes are never world-readable even briefly.
185
+ function _openExclTemp(tmpPath, fileMode) {
186
+ return nodeFs.openSync(
187
+ tmpPath,
188
+ nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
189
+ nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0),
190
+ fileMode
191
+ );
192
+ }
193
+
170
194
  /**
171
195
  * @primitive b.atomicFile.ensureDir
172
196
  * @signature b.atomicFile.ensureDir(dirPath, mode)
@@ -392,6 +416,34 @@ function conflictPath(originalPath, opts) {
392
416
  * );
393
417
  * // → { bytesWritten: 7, hash: "<sha3-512 hex>" }
394
418
  */
419
+ // Synchronous bounded sleep (writeSync is a sync primitive, so no await).
420
+ // Uses Atomics.wait on a throwaway shared buffer; falls back to a short spin
421
+ // if SharedArrayBuffer is unavailable.
422
+ function _sleepSync(ms) {
423
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); return; }
424
+ catch (_e) { /* fall through to spin */ }
425
+ var end = Date.now() + ms;
426
+ while (Date.now() < end) { /* spin */ }
427
+ }
428
+
429
+ // Atomic rename with a bounded retry on Windows-transient lock errors. On
430
+ // Windows a rename target is briefly held by AV / the search indexer / a
431
+ // file-sync client (Dropbox, OneDrive), surfacing as EPERM / EACCES / EBUSY
432
+ // even though the freshly-written temp file is fine; the lock clears in a few
433
+ // ms. POSIX rename is atomic and never hits this, so the first attempt
434
+ // succeeds there. Surface the error if it is not transient or persists.
435
+ function _renameWithRetry(from, to) {
436
+ var delays = [0, 5, 15, 40, 100];
437
+ for (var i = 0; i < delays.length; i += 1) {
438
+ if (delays[i] > 0) _sleepSync(delays[i]);
439
+ try { nodeFs.renameSync(from, to); return; }
440
+ catch (e) {
441
+ var transient = e && (e.code === "EPERM" || e.code === "EACCES" || e.code === "EBUSY");
442
+ if (!transient || i === delays.length - 1) throw e;
443
+ }
444
+ }
445
+ }
446
+
395
447
  function writeSync(filepath, data, opts) {
396
448
  opts = Object.assign({}, DEFAULTS, opts || {});
397
449
  var buf = safeBuffer.toBuffer(data, {
@@ -406,7 +458,7 @@ function writeSync(filepath, data, opts) {
406
458
  var tmpPath = filepath + ".tmp-" + generateToken(C.BYTES.bytes(8));
407
459
  var renamed = false;
408
460
  try {
409
- var fd = nodeFs.openSync(tmpPath, "w", opts.fileMode);
461
+ var fd = _openExclTemp(tmpPath, opts.fileMode);
410
462
  try {
411
463
  var pos = 0;
412
464
  while (pos < buf.length) {
@@ -416,7 +468,7 @@ function writeSync(filepath, data, opts) {
416
468
  } finally {
417
469
  try { nodeFs.closeSync(fd); } catch (_e) { /* already closed? */ }
418
470
  }
419
- nodeFs.renameSync(tmpPath, filepath);
471
+ _renameWithRetry(tmpPath, filepath);
420
472
  renamed = true;
421
473
  _fsyncDir(dir);
422
474
  } finally {
@@ -530,7 +582,7 @@ async function write(filepath, data, opts) {
530
582
  var tmpPath = filepath + ".tmp-" + generateToken(C.BYTES.bytes(8));
531
583
  var renamed = false;
532
584
  try {
533
- var fd = nodeFs.openSync(tmpPath, "w", opts.fileMode);
585
+ var fd = _openExclTemp(tmpPath, opts.fileMode);
534
586
  try {
535
587
  var pos = 0;
536
588
  while (pos < buf.length) {
@@ -659,9 +711,15 @@ function _readSyncCore(filepath, opts) {
659
711
  // can't swap the file between size-check and read because the fd
660
712
  // is anchored to the original inode. ENOENT surfaces from open()
661
713
  // rather than the previous existsSync() pre-check.
714
+ //
715
+ // The third argument pins an owner-only mode (0o600). The flag is
716
+ // read-only ("r" → O_RDONLY, no O_CREAT) so the mode is inert on
717
+ // disk, but specifying it keeps this open out of the insecure-temp-
718
+ // file class (CWE-377): the read can never create a world/group-
719
+ // accessible file even when `filepath` is rooted under a temp dir.
662
720
  var fd;
663
721
  try {
664
- fd = nodeFs.openSync(filepath, "r");
722
+ fd = nodeFs.openSync(filepath, "r", 0o600);
665
723
  } catch (openErr) {
666
724
  if (openErr && openErr.code === "ENOENT") {
667
725
  var e = new AtomicFileError("file not found: " + filepath, "atomic-file/not-found");
@@ -35,8 +35,23 @@
35
35
  */
36
36
  var canonicalJson = require("./canonical-json");
37
37
  var C = require("./constants");
38
+ var clusterStorage = require("./cluster-storage");
39
+ var frameworkSchema = require("./framework-schema");
40
+ var sql = require("./sql");
38
41
  var { sha3Hash } = require("./crypto");
39
42
 
43
+ // b.sql opts for the chain read SQL these primitives compose. The reader
44
+ // (queryAllAsync / queryOneAsync, normally clusterStorage.execute*) rewrites
45
+ // the bare framework table name + translates `?` placeholders at dispatch,
46
+ // but the IDENTIFIER QUOTING + ORDER-BY column reference are baked into the
47
+ // b.sql output at build time — so they must carry the ACTIVE backend dialect
48
+ // (clusterStorage.dialect() — "sqlite" single-node, "postgres" | "mysql" in
49
+ // cluster mode). Defaulting to "sqlite" double-quotes `monotonicCounter`,
50
+ // which MySQL reads as a STRING LITERAL: `ORDER BY '<constant>'` imposes no
51
+ // ordering, so verifyChain walks the rows out of order and falsely reports a
52
+ // chain break. Backtick-quoting on MySQL makes it an identifier again.
53
+ function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
54
+
40
55
  // SHA3-512 outputs 64 bytes; routed through C.BYTES so the file's byte
41
56
  // arithmetic has one source of truth. Hex-encoded width is twice the
42
57
  // byte count.
@@ -140,11 +155,20 @@ function computeRowHash(prevHash, rowFields, nonce) {
140
155
  * // → { prevHash: "<128-char hex>", counter: 4217 }
141
156
  */
142
157
  async function getChainTip(queryOneAsync, tableName) {
143
- var row = await queryOneAsync(
144
- 'SELECT rowHash, monotonicCounter FROM "' + tableName + '" ' +
145
- "ORDER BY monotonicCounter DESC LIMIT 1"
146
- );
158
+ // Emit a BARE logical table name — the operator-supplied reader routes
159
+ // through clusterStorage, which rewrites bare framework names to the
160
+ // configured-prefix form and placeholderizes. b.sql quotes the camelCase
161
+ // columns + runs the output validator.
162
+ var built = sql.select(tableName, _sqlOpts())
163
+ .columns(["rowHash", "monotonicCounter"])
164
+ .orderBy("monotonicCounter", "desc")
165
+ .limit(1)
166
+ .toSql();
167
+ var row = await queryOneAsync(built.sql, built.params);
147
168
  if (!row) return { prevHash: ZERO_HASH, counter: 0 };
169
+ // Normalize driver shape (Postgres returns BIGINT monotonicCounter as a
170
+ // string) so callers get a numeric counter on every backend.
171
+ frameworkSchema.coerceRow(row);
148
172
  return { prevHash: row.rowHash, counter: row.monotonicCounter };
149
173
  }
150
174
 
@@ -186,10 +210,15 @@ async function verifyChain(queryAllAsync, tableName, opts) {
186
210
  if (tableName === "audit_log") {
187
211
  var anchor;
188
212
  try {
189
- anchor = await queryAllAsync(
190
- "SELECT lastPurgedCounter, lastPurgedRowHash FROM _blamejs_audit_purge_anchor " +
191
- "WHERE scope = 'audit'"
192
- );
213
+ // External-only table whose LOGICAL name IS the `_blamejs_`-prefixed
214
+ // name (self-mapped in LOCAL_TO_EXTERNAL), passed bare so the reader's
215
+ // clusterStorage rewrites it; the 'audit' scope binds as a ? param.
216
+ // allow:hand-rolled-sql — bare logical key.
217
+ var anchorBuilt = sql.select("_blamejs_audit_purge_anchor", _sqlOpts()) // allow:hand-rolled-sql
218
+ .columns(["lastPurgedCounter", "lastPurgedRowHash"])
219
+ .where("scope", "audit")
220
+ .toSql();
221
+ anchor = await queryAllAsync(anchorBuilt.sql, anchorBuilt.params);
193
222
  } catch (_e) {
194
223
  // Anchor table may not exist on a deployment that has never been
195
224
  // through a purge. Treat as no anchor.
@@ -201,9 +230,16 @@ async function verifyChain(queryAllAsync, tableName, opts) {
201
230
  }
202
231
  }
203
232
 
204
- var rows = await queryAllAsync(
205
- 'SELECT * FROM "' + tableName + '" ORDER BY monotonicCounter ASC'
206
- );
233
+ var rowsBuilt = sql.select(tableName, _sqlOpts())
234
+ .orderBy("monotonicCounter", "asc")
235
+ .toSql();
236
+ var rows = await queryAllAsync(rowsBuilt.sql, rowsBuilt.params);
237
+ // Normalize driver shape before hashing: node-postgres returns BIGINT
238
+ // columns (recordedAt / monotonicCounter) as strings, which would hash
239
+ // differently from the numbers the chain-writer signed — the chain only
240
+ // verified on SQLite without this. coerceRow makes the recompute
241
+ // type-stable across backends (no-op on already-numeric SQLite rows).
242
+ rows = frameworkSchema.coerceRows(rows);
207
243
  if (skipBeforeCounter > 0) {
208
244
  rows = rows.filter(function (r) {
209
245
  return Number(r.monotonicCounter) > skipBeforeCounter;
package/lib/audit-sign.js CHANGED
@@ -63,6 +63,7 @@ var nodePath = require("node:path");
63
63
  var nodeCrypto = require("node:crypto");
64
64
  var atomicFile = require("./atomic-file");
65
65
  var { sha3Hash } = require("./crypto");
66
+ var frameworkFiles = require("./framework-files");
66
67
  var { defineClass } = require("./framework-error");
67
68
  var { boot } = require("./log");
68
69
  var safeBuffer = require("./safe-buffer");
@@ -118,11 +119,73 @@ var log = boot("audit-sign");
118
119
  function resolvePaths(dataDir) {
119
120
  return {
120
121
  dataDir: dataDir,
121
- plaintext: nodePath.join(dataDir, "audit-sign.key"),
122
- sealed: nodePath.join(dataDir, "audit-sign.key.sealed"),
122
+ plaintext: nodePath.join(dataDir, frameworkFiles.fileName("auditSignKey")),
123
+ sealed: nodePath.join(dataDir, frameworkFiles.fileName("auditSignKey") + ".sealed"),
124
+ // Unsealed registry of rotated-out PUBLIC keys (public keys are not
125
+ // secret). It lets verify-time code (b.audit.verifyCheckpoints) resolve
126
+ // the public key for a checkpoint signed under a now-rotated key WITHOUT
127
+ // the old passphrase, so a rotation does not strand historical checkpoints.
128
+ publicHistory: nodePath.join(dataDir, "audit-sign.pubkeys.json"),
123
129
  };
124
130
  }
125
131
 
132
+ // Append a rotated-out public key to the unsealed public-key history. Public
133
+ // keys carry no secret, so storing them in the clear is safe and is what
134
+ // makes passphrase-free historical verification possible. De-duplicated by
135
+ // fingerprint; best-effort (a write failure must not abort the rotation, the
136
+ // sealed private-key history is the durable archive of record).
137
+ function _appendPublicHistory(entry) {
138
+ if (!paths || !paths.publicHistory) return;
139
+ var list = [];
140
+ try {
141
+ if (nodeFs.existsSync(paths.publicHistory)) {
142
+ var parsed = safeJson.parse(atomicFile.readSync(paths.publicHistory));
143
+ if (Array.isArray(parsed)) list = parsed;
144
+ }
145
+ } catch (_e) { list = []; } // corrupt registry — rebuild from this entry
146
+ for (var i = 0; i < list.length; i += 1) {
147
+ if (list[i] && list[i].fingerprint === entry.fingerprint) return; // already recorded
148
+ }
149
+ list.push(entry);
150
+ try {
151
+ atomicFile.writeSync(paths.publicHistory, JSON.stringify(list, null, 2), { fileMode: 0o600 });
152
+ } catch (_e) { /* best-effort */ }
153
+ }
154
+
155
+ /**
156
+ * @primitive b.auditSign.getPublicKeyByFingerprint
157
+ * @signature b.auditSign.getPublicKeyByFingerprint(fingerprint)
158
+ * @since 0.14.29
159
+ * @status stable
160
+ * @related b.auditSign.getPublicKey, b.auditSign.verify, b.auditSign.rotateSigningKey
161
+ *
162
+ * Resolve the audit-signing public key (SPKI PEM) for a fingerprint: the
163
+ * live key, or a rotated-out key recorded in the unsealed public-key history
164
+ * that `rotateSigningKey` maintains. Returns `null` when no key matches. Only
165
+ * public material is consulted, so no passphrase is needed - this is what
166
+ * lets `b.audit.verifyCheckpoints` verify a checkpoint signed under a
167
+ * now-rotated key without stranding history.
168
+ *
169
+ * @example
170
+ * var pem = b.auditSign.getPublicKeyByFingerprint(checkpoint.publicKeyFingerprint);
171
+ * // -> "-----BEGIN PUBLIC KEY-----\n..." (or null if the key is unknown)
172
+ */
173
+ function getPublicKeyByFingerprint(fp) {
174
+ _requireInit();
175
+ if (fp === keys.fingerprint) return keys.publicKey;
176
+ if (!paths || !paths.publicHistory || !nodeFs.existsSync(paths.publicHistory)) return null;
177
+ var list;
178
+ try { list = safeJson.parse(atomicFile.readSync(paths.publicHistory)); }
179
+ catch (_e) { return null; }
180
+ if (!Array.isArray(list)) return null;
181
+ for (var i = 0; i < list.length; i += 1) {
182
+ if (list[i] && list[i].fingerprint === fp && typeof list[i].publicKey === "string") {
183
+ return list[i].publicKey;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
126
189
  function _computeFingerprint(publicKeyPem) {
127
190
  return sha3Hash(publicKeyPem);
128
191
  }
@@ -677,6 +740,17 @@ async function rotateSigningKey(rotOpts) {
677
740
  catch (_e) { /* history copy is best-effort */ }
678
741
  }
679
742
 
743
+ // Record the rotated-out PUBLIC key (unsealed) so b.audit.verifyCheckpoints
744
+ // can verify a checkpoint signed under it after rotation without the old
745
+ // passphrase. Without this the public key only lives inside the sealed
746
+ // history archive and verification of pre-rotation checkpoints is stranded.
747
+ _appendPublicHistory({
748
+ fingerprint: prevFingerprint,
749
+ publicKey: prevPublicKey,
750
+ algorithm: prevAlgorithm,
751
+ rotatedAt: new Date().toISOString(),
752
+ });
753
+
680
754
  // Persist the new keypair through the same path as boot — sealed
681
755
  // mode re-wraps with the operator's passphrase; plaintext mode
682
756
  // writes JSON. We don't accept a passphrase override here; the
@@ -740,6 +814,7 @@ module.exports = {
740
814
  reSignAll: reSignAll,
741
815
  getPublicKey: getPublicKey,
742
816
  getPublicKeyFingerprint: getPublicKeyFingerprint,
817
+ getPublicKeyByFingerprint: getPublicKeyByFingerprint,
743
818
  getMode: getMode,
744
819
  getAlgorithm: getAlgorithm,
745
820
  DEFAULT_SIGNING_ALG: DEFAULT_SIGNING_ALG,