@blamejs/core 0.14.11 → 0.14.12

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/lib/cluster.js CHANGED
@@ -52,6 +52,7 @@ var safeAsync = require("./safe-async");
52
52
  var safeJson = require("./safe-json");
53
53
  var safeSql = require("./safe-sql");
54
54
  var safeUrl = require("./safe-url");
55
+ var validateOpts = require("./validate-opts");
55
56
  var { FrameworkError, ClusterError } = require("./framework-error");
56
57
 
57
58
  // Lazy: vault → db → cluster forms a load-time chain, and external-db is
@@ -99,6 +100,15 @@ var configuredDialect = null;
99
100
  // (or external observer) can resolve "where is the current leader?"
100
101
  // via cluster.currentLeader() / cluster.discoveryHandler().
101
102
  var configuredEndpoint = null;
103
+ // Operator declaration that this node's vault keypair legitimately
104
+ // changed via a key rotation (b.vault.rotate). When set, a fingerprint
105
+ // that differs from the canonical cluster-state row is ADOPTED (the row
106
+ // advances to the new fingerprint + bumps the rotation epoch) instead
107
+ // of FATAL-refusing boot. Unset (the default) keeps the strict
108
+ // drift-refusal posture for the UNexpected mismatch. See
109
+ // _checkVaultKeyConsistency for the consistency model.
110
+ var configuredAcceptRotation = false;
111
+ var configuredExpectedVaultKeyFp = null;
102
112
 
103
113
  var log = boot("cluster");
104
114
 
@@ -142,6 +152,16 @@ function _emitTransition(kind, detail) {
142
152
  * outside `leader` / `follower`, and on a chain or vault-key mismatch
143
153
  * that would let this node corrupt cluster state.
144
154
  *
155
+ * After a vault-key rotation (`b.vault.rotate`) the public-key
156
+ * fingerprint changes, so the canonical cluster-state row no longer
157
+ * matches and every node would otherwise refuse boot with
158
+ * `VAULT_KEY_DRIFT`. Pass `acceptVaultKeyRotation: true` to declare the
159
+ * change legitimate: the node advances the canonical fingerprint and
160
+ * bumps a rotation epoch instead of refusing. `expectedVaultKeyFp`
161
+ * narrows the acceptance to a single blessed fingerprint so a typo'd /
162
+ * stale key file is still caught. The strict cross-node drift refusal
163
+ * stays in force whenever the rotation is NOT declared.
164
+ *
145
165
  * @opts
146
166
  * nodeId: string, // required; stable identity
147
167
  * role: "leader"|"follower",
@@ -152,6 +172,11 @@ function _emitTransition(kind, detail) {
152
172
  * provider: object, // custom election provider
153
173
  * externalDbBackend: object, // required when no custom provider
154
174
  * dialect: "postgres"|"sqlite"|"mysql",
175
+ * acceptVaultKeyRotation: boolean, // adopt a rotated vault-key
176
+ * // fingerprint instead of
177
+ * // refusing boot on mismatch
178
+ * expectedVaultKeyFp: string, // optional; bless ONLY this
179
+ * // post-rotation fingerprint
155
180
  * onTransition: function (event),
156
181
  *
157
182
  * @example
@@ -227,6 +252,31 @@ async function init(opts) {
227
252
  configuredEndpoint = null;
228
253
  }
229
254
 
255
+ // Vault-key rotation acceptance (config-time tier: THROW on bad
256
+ // input). acceptVaultKeyRotation is a boolean declaration; an
257
+ // expectedVaultKeyFp without it is a misconfiguration (the operator
258
+ // blessed a fingerprint but never enabled adoption).
259
+ validateOpts.optionalBoolean(opts.acceptVaultKeyRotation,
260
+ "cluster.init({ acceptVaultKeyRotation })", ClusterError, "INVALID_CONFIG");
261
+ configuredAcceptRotation = opts.acceptVaultKeyRotation === true;
262
+ if (opts.expectedVaultKeyFp !== undefined) {
263
+ if (typeof opts.expectedVaultKeyFp !== "string" ||
264
+ !/^[0-9a-f]{128}$/.test(opts.expectedVaultKeyFp)) {
265
+ throw _err("INVALID_CONFIG",
266
+ "cluster.init({ expectedVaultKeyFp }) must be a 128-char " +
267
+ "lowercase-hex SHA3-512 fingerprint (b.vault rotation output)", true);
268
+ }
269
+ if (!configuredAcceptRotation) {
270
+ throw _err("INVALID_CONFIG",
271
+ "cluster.init({ expectedVaultKeyFp }) requires " +
272
+ "acceptVaultKeyRotation: true — blessing a fingerprint without " +
273
+ "enabling adoption has no effect", true);
274
+ }
275
+ configuredExpectedVaultKeyFp = opts.expectedVaultKeyFp;
276
+ } else {
277
+ configuredExpectedVaultKeyFp = null;
278
+ }
279
+
230
280
  if (typeof opts.onTransition === "function") {
231
281
  transitionHandlers.push(opts.onTransition);
232
282
  }
@@ -433,6 +483,49 @@ function _vaultKeyFingerprint() {
433
483
  keys.ecPublicKey);
434
484
  }
435
485
 
486
+ // Idempotent migration for the rotationEpoch column. cluster-provider-db
487
+ // ensureSchema creates _blamejs_cluster_state without it (the column was
488
+ // added when rotation-epoch acceptance landed); ADD COLUMN here keeps the
489
+ // path version-agnostic the same way the provider migrates the leader
490
+ // row's `endpoint` column. The only expected failure is "column already
491
+ // exists," which is swallowed. SQLite / MySQL don't take a DEFAULT on a
492
+ // non-constant, so the column is nullable and treated as epoch 0 when
493
+ // absent on legacy rows.
494
+ async function _ensureRotationEpochColumn() {
495
+ try {
496
+ await externalDb().query(
497
+ "ALTER TABLE _blamejs_cluster_state ADD COLUMN rotationEpoch BIGINT",
498
+ [],
499
+ { backend: configuredExternalDbBackend }
500
+ );
501
+ } catch (_e) { /* column already exists (or table absent — caught upstream) */ }
502
+ }
503
+
504
+ // Consistency model (CWE-345 binding-integrity for sealed columns):
505
+ //
506
+ // Every node fingerprints its vault PUBLIC keys (SHA3-512, one-way) and
507
+ // the cluster agrees on ONE canonical fingerprint stored in
508
+ // _blamejs_cluster_state. A node holding a different key seals new
509
+ // writes the rest of the cluster can't unseal — silent corruption — so
510
+ // an UNDECLARED mismatch fails closed (FATAL: VAULT_KEY_DRIFT).
511
+ //
512
+ // A vault-key rotation (b.vault.rotate, lib/vault/rotate.js) legitimately
513
+ // changes the public-key fingerprint on EVERY node. The rotation only
514
+ // re-seals the local dataDir; it does not touch the external coordination
515
+ // row, so the canonical fingerprint goes stale and every node would
516
+ // refuse boot. acceptVaultKeyRotation: true is the operator's signed-off
517
+ // declaration "this change is a rotation, not drift": the booting node
518
+ // ADVANCES the canonical row to its own fingerprint and bumps a
519
+ // monotonic rotationEpoch. expectedVaultKeyFp narrows the adoption to a
520
+ // single blessed fingerprint so a stale / wrong key file is still
521
+ // refused. The strict refusal is unchanged when no rotation is declared,
522
+ // which is exactly the cross-node drift case the check defends.
523
+ //
524
+ // The epoch is observability + a future-replay guard, not an auth
525
+ // boundary — the auth boundary is the operator's deliberate opt
526
+ // (acceptVaultKeyRotation) plus the optional fingerprint allowlist. A
527
+ // forged row can only ever cost a single declared boot, and a genuinely
528
+ // different key would still fail every sealed read.
436
529
  async function _checkVaultKeyConsistency() {
437
530
  var localFp = _vaultKeyFingerprint();
438
531
  if (localFp === null) {
@@ -469,10 +562,15 @@ async function _checkVaultKeyConsistency() {
469
562
  throw e;
470
563
  }
471
564
 
565
+ // Bring the rotationEpoch column into existence (idempotent). The INSERT
566
+ // above already proved the table is present, so a real ALTER failure
567
+ // here is "column exists" and is swallowed.
568
+ await _ensureRotationEpochColumn();
569
+
472
570
  // Read whatever fingerprint is canonical (ours if first boot,
473
571
  // someone else's if we lost the race or are joining an existing cluster).
474
572
  var rows = await externalDb().query(
475
- "SELECT vaultKeyFp, recordedByNode, recordedAt FROM _blamejs_cluster_state " +
573
+ "SELECT vaultKeyFp, recordedByNode, recordedAt, rotationEpoch FROM _blamejs_cluster_state " +
476
574
  "WHERE scope = 'state'",
477
575
  [],
478
576
  { backend: configuredExternalDbBackend }
@@ -486,22 +584,92 @@ async function _checkVaultKeyConsistency() {
486
584
  true);
487
585
  }
488
586
  var canonical = rows.rows[0];
587
+ var fpPrefix = C.BYTES.bytes(16);
489
588
  if (canonical.vaultKeyFp !== localFp) {
490
- var fpPrefix = C.BYTES.bytes(16);
491
- throw _err("VAULT_KEY_DRIFT",
492
- "FATAL: vault-key drift detected. " +
493
- "local node: " + nodeId +
494
- "; local fingerprint: " + localFp.slice(0, fpPrefix) + "…" +
495
- "; canonical recorded by: " + canonical.recordedByNode +
496
- "; canonical fingerprint: " + canonical.vaultKeyFp.slice(0, fpPrefix) + "…" +
497
- ". This node holds a DIFFERENT vault key than the rest of the " +
498
- "cluster. Sealed-column writes from this node would be unreadable " +
499
- "by the others (and vice versa). Restore the same vault key file " +
500
- "before booting this node into the cluster.",
501
- true);
589
+ // Mismatch. Two readings: a legitimate vault-key rotation the
590
+ // operator has declared, or genuine cross-node drift. Without the
591
+ // declaration, always fail closed — sealed-column corruption is the
592
+ // worse outcome.
593
+ if (!configuredAcceptRotation) {
594
+ throw _err("VAULT_KEY_DRIFT",
595
+ "FATAL: vault-key drift detected. " +
596
+ "local node: " + nodeId +
597
+ "; local fingerprint: " + localFp.slice(0, fpPrefix) + "…" +
598
+ "; canonical recorded by: " + canonical.recordedByNode +
599
+ "; canonical fingerprint: " + canonical.vaultKeyFp.slice(0, fpPrefix) + "…" +
600
+ ". This node holds a DIFFERENT vault key than the rest of the " +
601
+ "cluster. Sealed-column writes from this node would be unreadable " +
602
+ "by the others (and vice versa). If the key changed via " +
603
+ "b.vault.rotate, re-init with acceptVaultKeyRotation: true to " +
604
+ "advance the cluster's recorded fingerprint; otherwise restore the " +
605
+ "same vault key file before booting this node into the cluster.",
606
+ true);
607
+ }
608
+ // Rotation declared. If the operator blessed a specific fingerprint,
609
+ // the LOCAL key must match it — this rejects a stale / wrong key file
610
+ // that happens to differ from canonical for the wrong reason.
611
+ if (configuredExpectedVaultKeyFp && configuredExpectedVaultKeyFp !== localFp) {
612
+ throw _err("VAULT_KEY_ROTATION_MISMATCH",
613
+ "FATAL: acceptVaultKeyRotation is set but this node's vault-key " +
614
+ "fingerprint does not match the blessed expectedVaultKeyFp. " +
615
+ "local node: " + nodeId +
616
+ "; local fingerprint: " + localFp.slice(0, fpPrefix) + "…" +
617
+ "; expected fingerprint: " + configuredExpectedVaultKeyFp.slice(0, fpPrefix) + "…" +
618
+ ". This node is NOT holding the rotated key the operator approved. " +
619
+ "Restore the post-rotation vault key file (or correct " +
620
+ "expectedVaultKeyFp) before booting this node into the cluster.",
621
+ true);
622
+ }
623
+ // Adopt: advance the canonical row to the new fingerprint and bump
624
+ // the monotonic rotation epoch. The UPDATE is gated on the OLD
625
+ // fingerprint so two nodes adopting concurrently converge on a single
626
+ // advance (the loser's WHERE matches nothing and it re-reads the
627
+ // already-advanced row below).
628
+ var priorEpoch = (canonical.rotationEpoch != null) ? Number(canonical.rotationEpoch) : 0;
629
+ if (!isFinite(priorEpoch) || priorEpoch < 0) priorEpoch = 0;
630
+ var nextEpoch = priorEpoch + 1;
631
+ await externalDb().query(
632
+ "UPDATE _blamejs_cluster_state SET " +
633
+ " vaultKeyFp = " + (ph ? "$1" : "?") + ", " +
634
+ " recordedAt = " + (ph ? "$2" : "?") + ", " +
635
+ " recordedByNode = " + (ph ? "$3" : "?") + ", " +
636
+ " rotationEpoch = " + (ph ? "$4" : "?") + " " +
637
+ "WHERE scope = 'state' AND vaultKeyFp = " + (ph ? "$5" : "?"),
638
+ [localFp, nowMs, nodeId, nextEpoch, canonical.vaultKeyFp],
639
+ { backend: configuredExternalDbBackend }
640
+ );
641
+ // Re-read so the post-adopt state reflects whoever actually won the
642
+ // advance (this node, or a peer that adopted the SAME rotated key a
643
+ // beat earlier). A surviving mismatch here means the row now carries a
644
+ // fingerprint that is neither the old one nor ours — a real drift that
645
+ // the rotation declaration does not cover, so fail closed.
646
+ var after = await externalDb().query(
647
+ "SELECT vaultKeyFp, recordedByNode, rotationEpoch FROM _blamejs_cluster_state " +
648
+ "WHERE scope = 'state'",
649
+ [],
650
+ { backend: configuredExternalDbBackend }
651
+ );
652
+ var post = (after.rows && after.rows[0]) || canonical;
653
+ if (post.vaultKeyFp !== localFp) {
654
+ throw _err("VAULT_KEY_DRIFT",
655
+ "FATAL: vault-key drift detected after rotation-accept. " +
656
+ "local node: " + nodeId +
657
+ "; local fingerprint: " + localFp.slice(0, fpPrefix) + "…" +
658
+ "; canonical fingerprint: " + post.vaultKeyFp.slice(0, fpPrefix) + "…" +
659
+ ". A concurrent node advanced the cluster to a DIFFERENT key than " +
660
+ "this node holds — the declared rotation does not cover this " +
661
+ "fingerprint. Restore the agreed post-rotation vault key file.",
662
+ true);
663
+ }
664
+ log("cluster vault-key rotation accepted (fingerprint " +
665
+ localFp.slice(0, fpPrefix) + "… epoch " +
666
+ (post.rotationEpoch != null ? Number(post.rotationEpoch) : nextEpoch) +
667
+ ", recorded by " + post.recordedByNode + ")");
668
+ return;
502
669
  }
503
670
  log("cluster vault-key consistency ok (fingerprint " +
504
- localFp.slice(0, C.BYTES.bytes(16)) + "… recorded by " + canonical.recordedByNode + ")");
671
+ localFp.slice(0, fpPrefix) + "… recorded by " + canonical.recordedByNode +
672
+ (canonical.rotationEpoch != null ? ", epoch " + Number(canonical.rotationEpoch) : "") + ")");
505
673
  }
506
674
 
507
675
  async function _tryAcquire() {
@@ -967,6 +1135,8 @@ async function shutdown() {
967
1135
  configuredExternalDbBackend = null;
968
1136
  configuredDialect = null;
969
1137
  configuredEndpoint = null;
1138
+ configuredAcceptRotation = false;
1139
+ configuredExpectedVaultKeyFp = null;
970
1140
  transitionHandlers = [];
971
1141
  // nodeId is preserved post-shutdown so audit metadata still reflects
972
1142
  // who this process was; cleared only by _resetForTest.
@@ -988,6 +1158,8 @@ function _resetForTest() {
988
1158
  configuredExternalDbBackend = null;
989
1159
  configuredDialect = null;
990
1160
  configuredEndpoint = null;
1161
+ configuredAcceptRotation = false;
1162
+ configuredExpectedVaultKeyFp = null;
991
1163
  transitionHandlers = [];
992
1164
  }
993
1165
 
@@ -1105,6 +1105,11 @@ module.exports = {
1105
1105
  getSealedFields: getSealedFields,
1106
1106
  sealRow: sealRow,
1107
1107
  unsealRow: unsealRow,
1108
+ // _aadParts — the column-AAD builder the seal/unseal path uses. Exported
1109
+ // (internal) so the vault-key rotation pipeline reconstructs the IDENTICAL
1110
+ // AAD tuple a cell was sealed under — one source of truth, no drift
1111
+ // between the seal side and the rotate side.
1112
+ _aadParts: _aadParts,
1108
1113
  // Doc-shaped aliases — operators / tests preparing a JS document
1109
1114
  // object (vs. a SQL row) reach for sealDoc / unsealDoc naming. Same
1110
1115
  // function, identical shape, returns a new object (input untouched).
package/lib/db.js CHANGED
@@ -1318,6 +1318,15 @@ async function init(opts) {
1318
1318
  derivedHashes: t.derivedHashes,
1319
1319
  hashNamespaces: t.hashNamespaces,
1320
1320
  derivedHashMode: t.derivedHashMode,
1321
+ // AAD-binding metadata MUST pass through — without it a schema that
1322
+ // declares { aad: true } registers as a plain table, so its cells
1323
+ // seal under vault: (not vault.aad:) and the vault-key rotation
1324
+ // pipeline cannot reconstruct their AAD. registerTable defaults these
1325
+ // (aad:false / rowIdField:"id" / schemaVersion:"1") so non-AAD tables
1326
+ // are unaffected.
1327
+ aad: t.aad,
1328
+ rowIdField: t.rowIdField,
1329
+ schemaVersion: t.schemaVersion,
1321
1330
  });
1322
1331
  tableMetadata[t.name] = {
1323
1332
  primaryKey: _normalizePk(t),
@@ -3161,6 +3170,12 @@ module.exports = {
3161
3170
  // (plain mode) or when the plaintext DB doesn't exist.
3162
3171
  flushToDisk: encryptToDisk,
3163
3172
  snapshot: snapshot,
3173
+ // Internal AAD constructors, exported so the vault-key rotation
3174
+ // pipeline (lib/vault/rotate.js) re-seals db.enc / db.key.enc under the
3175
+ // SAME deployment-bound AAD this module writes them with — single source
3176
+ // of truth for the wire-format literals (no duplicated constants).
3177
+ _dbEncAad: _dbEncAad,
3178
+ _dbKeyAad: _dbKeyAad,
3164
3179
  // integrityCheck — runs PRAGMA integrity_check against the live db
3165
3180
  // and returns "ok" on success, an array of corruption lines
3166
3181
  // otherwise. Operators wire this into a periodic monitor or a
@@ -190,6 +190,29 @@ function requireObject(opts, callerLabel, errorClass, code) {
190
190
  return opts;
191
191
  }
192
192
 
193
+ // requireMethods — validate an injected dependency exposes the named
194
+ // methods. Collapses the repeated `if (!obj || typeof obj.fn !==
195
+ // "function" || ...) throw` injected-store / exporter / backend guards
196
+ // (b.agent.*.reseal stores, b.dsr / b.outbox create() backends, etc.)
197
+ // into one definition. Throws on null / non-object / any missing-or-
198
+ // non-function method; returns obj on success.
199
+ function requireMethods(obj, methods, callerLabel, errorClass, code) {
200
+ var label = callerLabel || "dependency";
201
+ if (!obj || typeof obj !== "object") {
202
+ _throw(errorClass, code, label + " must be an object exposing { " +
203
+ methods.join(", ") + " }, got " + (obj === null ? "null" : typeof obj),
204
+ "validate-opts/bad-methods-object");
205
+ }
206
+ for (var i = 0; i < methods.length; i += 1) {
207
+ if (typeof obj[methods[i]] !== "function") {
208
+ _throw(errorClass, code, label + " must expose a " + methods[i] +
209
+ "() method (requires { " + methods.join(", ") + " })",
210
+ "validate-opts/missing-method");
211
+ }
212
+ }
213
+ return obj;
214
+ }
215
+
193
216
  function optionalNonEmptyString(value, label, errorClass, code) {
194
217
  if (value === undefined || value === null) return value;
195
218
  if (typeof value !== "string" || value.length === 0) {
@@ -376,6 +399,7 @@ module.exports.optionalPlainObject = optionalPlainObject;
376
399
  module.exports.requireNonEmptyString = requireNonEmptyString;
377
400
  module.exports.observabilityShape = observabilityShape;
378
401
  module.exports.requireObject = requireObject;
402
+ module.exports.requireMethods = requireMethods;
379
403
  module.exports.applyDefaults = applyDefaults;
380
404
  module.exports.makeAuditEmitter = makeAuditEmitter;
381
405
  module.exports.makeNamespacedEmitters = makeNamespacedEmitters;
@@ -56,6 +56,7 @@ var safeSql = require("../safe-sql");
56
56
  var C = require("../constants");
57
57
  var cryptoField = require("../crypto-field");
58
58
  var bCrypto = require("../crypto");
59
+ var vaultAad = require("../vault-aad");
59
60
  var dbSchema = require("../db-schema");
60
61
  var lazyRequire = require("../lazy-require");
61
62
  var { boot } = require("../log");
@@ -63,6 +64,23 @@ var numericBounds = require("../numeric-bounds");
63
64
  var safeJson = require("../safe-json");
64
65
  var validateOpts = require("../validate-opts");
65
66
  var vaultWrap = lazyRequire(function () { return require("./wrap"); });
67
+ // lazyRequire (named dbModuleLazy to match the canonical binding in
68
+ // lib/backup/index.js and to avoid shadowing the local SQLite handle `db`
69
+ // inside rotate()): the db at-rest AAD constructors live in lib/db.js.
70
+ var dbModuleLazy = lazyRequire(function () { return require("../db"); });
71
+ // Framework AAD modules whose stores live outside db.enc — lazyRequire'd
72
+ // at top-of-file (deferred, never inline in a function body) so rotate's
73
+ // detect-and-refuse can read each module's AAD_ROTATION descriptor without
74
+ // eagerly loading them at require time.
75
+ var agentIdempotencyLazy = lazyRequire(function () { return require("../agent-idempotency"); });
76
+ var agentOrchestratorLazy = lazyRequire(function () { return require("../agent-orchestrator"); });
77
+ var agentTenantLazy = lazyRequire(function () { return require("../agent-tenant"); });
78
+ var agentSnapshotLazy = lazyRequire(function () { return require("../agent-snapshot"); });
79
+ // Tenant archive blobs (recipient: "tenant") are keyed off the vault root but
80
+ // live in operator-placed storage (files / object stores / backups) the
81
+ // rotation pipeline never walks, so archive-wrap exports the same external
82
+ // AAD_ROTATION descriptor and must be gated here too.
83
+ var archiveWrapLazy = lazyRequire(function () { return require("../archive-wrap"); });
66
84
  var { defineClass } = require("../framework-error");
67
85
 
68
86
  var rotateLog = boot("vault-rotate");
@@ -254,6 +272,10 @@ function verify(opts) {
254
272
  var keys = opts.keys;
255
273
  var db = opts.db;
256
274
  var oldKeys = opts.oldKeys || null;
275
+ // Serialized roots for AAD-cell verification — match getKeysJson() so an
276
+ // AAD cell sealed under the new root opens here.
277
+ var keysJson = JSON.stringify(keys, null, 2);
278
+ var oldKeysJson = oldKeys ? JSON.stringify(oldKeys, null, 2) : null;
257
279
  numericBounds.requirePositiveFiniteIntIfPresent(opts.sampleMin,
258
280
  "verify: sampleMin", VaultRotateError, "vault-rotate/bad-opt");
259
281
  var sampleMin = opts.sampleMin !== undefined
@@ -308,7 +330,29 @@ function verify(opts) {
308
330
  for (var sf = 0; sf < schema.sealedFields.length; sf++) {
309
331
  var col = schema.sealedFields[sf];
310
332
  var v = row[col];
311
- if (typeof v !== "string" || v.indexOf(VAULT_PREFIX) !== 0) continue;
333
+ if (typeof v !== "string") continue;
334
+
335
+ if (vaultAad.isAadSealed(v)) {
336
+ // AAD cell: reconstruct the seal-side AAD (cryptoField._aadParts)
337
+ // and verify under the new root; flag a regression if it still
338
+ // opens under the old root (rotation didn't take effect).
339
+ var aad = cryptoField._aadParts(schema, table, col, row);
340
+ try { vaultAad.unsealRoot(v, aad, keysJson); }
341
+ catch (e) {
342
+ rowFailed = true;
343
+ failures.push({ table: table, column: col, _id: row._id, error: (e && e.message) || String(e) });
344
+ }
345
+ if (oldKeysJson && !foundOldFail) {
346
+ try {
347
+ vaultAad.unsealRoot(v, aad, oldKeysJson);
348
+ regressions.push({ table: table, column: col, _id: row._id,
349
+ error: "old keys still decrypt this AAD value — rotation did not take effect" });
350
+ } catch (_e) { foundOldFail = true; }
351
+ }
352
+ continue;
353
+ }
354
+
355
+ if (v.indexOf(VAULT_PREFIX) !== 0) continue;
312
356
  var payload = v.substring(VAULT_PREFIX.length);
313
357
 
314
358
  try { bCrypto.decrypt(payload, keys); }
@@ -379,6 +423,41 @@ function verify(opts) {
379
423
  var ROW_BATCH_SIZE_DEFAULT = 0x3E8;
380
424
  var VAULT_PREFIX_LEN = C.VAULT_PREFIX.length;
381
425
 
426
+ // db.enc / db.key.enc AAD constructors come from the module that OWNS the
427
+ // db at-rest format (lib/db.js _dbEncAad / _dbKeyAad), not re-declared
428
+ // here — one source of truth for the wire-format literals so a rotation
429
+ // re-seal binds the SAME deployment AAD db.init expects on next open.
430
+
431
+ // Framework modules that seal AAD cells on operator-supplied (external)
432
+ // stores this pipeline never reaches (it only walks db.enc). Each exports
433
+ // an AAD_ROTATION descriptor + a reseal hook to rotate its store
434
+ // out-of-band. rotate() REFUSES a keypair rotation unless the operator
435
+ // acknowledges (opts.externalAadResealed) each has been re-sealed —
436
+ // otherwise those cells silently orphan under the retired root (CWE-320).
437
+ // Only the module PATHS live here; the table / backend metadata lives in
438
+ // each module's AAD_ROTATION export (single source of truth). lazyRequire
439
+ // so loading rotate.js doesn't eagerly pull the agent modules.
440
+ var EXTERNAL_AAD_MODULE_LOADERS = [
441
+ agentIdempotencyLazy, agentOrchestratorLazy, agentTenantLazy, agentSnapshotLazy,
442
+ archiveWrapLazy,
443
+ ];
444
+
445
+ function _externalAadTables() {
446
+ var tables = [];
447
+ for (var i = 0; i < EXTERNAL_AAD_MODULE_LOADERS.length; i += 1) {
448
+ var mod;
449
+ try { mod = EXTERNAL_AAD_MODULE_LOADERS[i](); }
450
+ catch (_e) { continue; } // module unavailable in this process — skip
451
+ var desc = mod && mod.AAD_ROTATION;
452
+ if (!desc) continue;
453
+ var list = Array.isArray(desc) ? desc : [desc];
454
+ for (var j = 0; j < list.length; j += 1) {
455
+ if (list[j] && list[j].backend === "external" && list[j].table) tables.push(list[j].table);
456
+ }
457
+ }
458
+ return tables;
459
+ }
460
+
382
461
  function _emit(cb, ev) {
383
462
  if (typeof cb === "function") {
384
463
  try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
@@ -444,7 +523,7 @@ function _walkAndReSeal(node, oldKeys, newKeys) {
444
523
 
445
524
  function _runStmt(db, sql) { db.prepare(sql).run(); }
446
525
 
447
- function _rotateColumn(db, table, column, oldKeys, newKeys, batchSize, progress) {
526
+ function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
448
527
  // Identifiers reach SQL through safeSql.quoteIdentifier — runs
449
528
  // validateIdentifier (rejects bad shape / reserved words /
450
529
  // sqlite_-prefix) + emits the dialect-correct quoted form.
@@ -453,8 +532,18 @@ function _rotateColumn(db, table, column, oldKeys, newKeys, batchSize, progress)
453
532
  var total = db.prepare("SELECT COUNT(*) AS n FROM " + qt + " WHERE " + qc + " IS NOT NULL").get().n;
454
533
  if (total === 0) return 0;
455
534
 
535
+ // AAD-bound tables (registerTable({aad:true})) seal each cell under a
536
+ // (table, rowId, column, schemaVersion) tuple. Rotation reads the
537
+ // rowIdField value and reconstructs the IDENTICAL AAD via the seal-side
538
+ // builder cryptoField._aadParts (one source of truth), then re-seals
539
+ // old-root -> new-root. Plain (non-AAD) cells use the plain-vault reseal.
540
+ var aadMode = !!(schema && schema.aad);
541
+ var rowIdField = aadMode ? schema.rowIdField : null;
542
+ var needRid = aadMode && rowIdField && rowIdField !== "_id";
543
+ var qrid = needRid ? safeSql.quoteIdentifier(rowIdField, "sqlite") : null;
544
+
456
545
  var sel = db.prepare(
457
- "SELECT _id, " + qc + " AS v FROM " + qt +
546
+ "SELECT _id, " + qc + " AS v" + (qrid ? ", " + qrid + " AS rid" : "") + " FROM " + qt +
458
547
  " WHERE " + qc + " IS NOT NULL AND _id > ? ORDER BY _id LIMIT ?"
459
548
  );
460
549
  var upd = db.prepare("UPDATE " + qt + " SET " + qc + " = ? WHERE _id = ?");
@@ -468,8 +557,19 @@ function _rotateColumn(db, table, column, oldKeys, newKeys, batchSize, progress)
468
557
  dbSchema.runInTransaction(db, function () {
469
558
  for (var i = 0; i < rows.length; i++) {
470
559
  var row = rows[i];
471
- if (typeof row.v === "string" && row.v.indexOf(C.VAULT_PREFIX) === 0) {
472
- upd.run(_reSealValue(row.v, oldKeys, newKeys), row._id);
560
+ if (typeof row.v !== "string") continue;
561
+ if (aadMode && vaultAad.isAadSealed(row.v)) {
562
+ // Rebuild the exact AAD the seal side used. cryptoField._aadParts
563
+ // reads row[schema.rowIdField]; feed it the rowIdField value we
564
+ // selected (rid, or _id when rowIdField IS _id).
565
+ var rowForAad = {};
566
+ rowForAad[rowIdField] = needRid ? row.rid : row._id;
567
+ var aad = cryptoField._aadParts(schema, table, column, rowForAad);
568
+ upd.run(vaultAad.resealRoot(row.v, aad, roots.oldRootJson, roots.newRootJson), row._id);
569
+ } else if (row.v.indexOf(C.VAULT_PREFIX) === 0) {
570
+ // Plain vault: cell (non-AAD table, or a legacy pre-AAD cell in
571
+ // an AAD table that the next sealRow upgrades).
572
+ upd.run(_reSealValue(row.v, roots.oldKeys, roots.newKeys), row._id);
473
573
  }
474
574
  }
475
575
  });
@@ -555,6 +655,27 @@ async function rotate(opts) {
555
655
  throw new VaultRotateError("vault-rotate/no-passphrase",
556
656
  "rotate: wrapped mode requires opts.newPassphrase (Buffer)");
557
657
  }
658
+ // Detect-and-refuse: AAD-bound state on operator-supplied stores is NOT
659
+ // reached by this pipeline (it walks only db.enc). Refuse unless the
660
+ // operator acknowledges each such store has been re-sealed via its
661
+ // module hook — otherwise a keypair rotation silently orphans them.
662
+ var externalAad = _externalAadTables();
663
+ if (externalAad.length > 0) {
664
+ var ack = opts.externalAadResealed;
665
+ var acknowledged = ack === true ||
666
+ (Array.isArray(ack) && externalAad.every(function (t) { return ack.indexOf(t) !== -1; }));
667
+ if (!acknowledged) {
668
+ throw new VaultRotateError("vault-rotate/external-aad-unresealed",
669
+ "rotate: AAD-bound state on operator-supplied stores is not reached by this " +
670
+ "pipeline and would be orphaned under the retired keypair: " + externalAad.join(", ") +
671
+ ". Re-seal each via its module hook (b.agent.idempotency.reseal / " +
672
+ "b.agent.orchestrator.reseal / b.agent.tenant AAD_ROTATION reseal / " +
673
+ "b.agent.snapshot.reseal / b.archive.rewrapTenant for archive-wrap:tenant-blobs) " +
674
+ "BEFORE retiring the old keypair, then pass " +
675
+ "opts.externalAadResealed: [" + externalAad.map(function (t) { return JSON.stringify(t); }).join(", ") +
676
+ "] to acknowledge. If you do not use these features, pass opts.externalAadResealed: true.");
677
+ }
678
+ }
558
679
  var rowBatchSize = opts.rowBatchSize || ROW_BATCH_SIZE_DEFAULT;
559
680
  var progress = opts.progressCallback;
560
681
  var warnings = [];
@@ -608,6 +729,12 @@ async function rotate(opts) {
608
729
  // 2. write new vault key
609
730
  _emit(progress, { phase: "write_vault_key" });
610
731
  var keysJson = JSON.stringify(newKeys, null, 2);
732
+ // Serialized roots for the explicit-root AAD reseal path. These match
733
+ // b.vault.getKeysJson() EXACTLY (JSON.stringify(keys, null, 2)) so an
734
+ // AAD cell re-sealed under newRootJson here unseals once the new keypair
735
+ // is live after the atomic swap.
736
+ var oldRootJson = JSON.stringify(oldKeys, null, 2);
737
+ var newRootJson = keysJson;
611
738
  if (mode === "wrapped") {
612
739
  var sealed = await vaultWrap().wrap(keysJson, opts.newPassphrase);
613
740
  nodeFs.writeFileSync(nodePath.join(stagingDir, paths.vaultKeySealed), sealed, { mode: 0o600 });
@@ -621,14 +748,29 @@ async function rotate(opts) {
621
748
  var dbKey = null;
622
749
  if (nodeFs.existsSync(dbKeySealedPath)) {
623
750
  var sealedKey = nodeFs.readFileSync(dbKeySealedPath, "utf8").trim();
624
- if (sealedKey.indexOf(C.VAULT_PREFIX) !== 0) {
751
+ if (vaultAad.isAadSealed(sealedKey)) {
752
+ // AAD-bound db.key.enc (db.js since v0.14.7): unseal under the OLD
753
+ // root with the deployment-context AAD, then re-emit under the NEW
754
+ // root with the SAME context (an in-place swap keeps dataDir +
755
+ // keyPath, so source and target AAD match). The vault.aad: shape is
756
+ // preserved — a plain-vault re-emit would strip the deployment-
757
+ // substitution binding (CWE-345 / CWE-441).
758
+ var dbKeyAad = dbModuleLazy()._dbKeyAad(dataDir, dbKeySealedPath);
759
+ var dbKeyB64Aad = vaultAad.unsealRoot(sealedKey, dbKeyAad, oldRootJson);
760
+ dbKey = Buffer.from(dbKeyB64Aad, "base64");
761
+ var resealedAad = vaultAad.sealRoot(dbKeyB64Aad, dbKeyAad, newRootJson);
762
+ nodeFs.writeFileSync(nodePath.join(stagingDir, paths.dbKeySealed), resealedAad, { mode: 0o600 });
763
+ } else if (sealedKey.indexOf(C.VAULT_PREFIX) === 0) {
764
+ // Legacy plain-sealed db.key.enc (pre-AAD). Re-key in place; db.init
765
+ // read-migrates plain -> AAD on the next boot.
766
+ var dbKeyB64 = bCrypto.decrypt(sealedKey.substring(VAULT_PREFIX_LEN), oldKeys);
767
+ dbKey = Buffer.from(dbKeyB64, "base64");
768
+ var resealedKey = C.VAULT_PREFIX + bCrypto.encrypt(dbKeyB64, newKeys);
769
+ nodeFs.writeFileSync(nodePath.join(stagingDir, paths.dbKeySealed), resealedKey, { mode: 0o600 });
770
+ } else {
625
771
  throw new VaultRotateError("vault-rotate/bad-dbkey",
626
- "rotate: db.key.enc does not start with the vault prefix");
772
+ "rotate: db.key.enc does not start with a vault prefix (vault: or vault.aad:)");
627
773
  }
628
- var dbKeyB64 = bCrypto.decrypt(sealedKey.substring(VAULT_PREFIX_LEN), oldKeys);
629
- dbKey = Buffer.from(dbKeyB64, "base64");
630
- var resealedKey = C.VAULT_PREFIX + bCrypto.encrypt(dbKeyB64, newKeys);
631
- nodeFs.writeFileSync(nodePath.join(stagingDir, paths.dbKeySealed), resealedKey, { mode: 0o600 });
632
774
  }
633
775
  for (var as = 0; as < paths.additionalSealed.length; as++) {
634
776
  var ase = paths.additionalSealed[as];
@@ -679,7 +821,14 @@ async function rotate(opts) {
679
821
 
680
822
  if (nodeFs.existsSync(encDbPath) && dbKey) {
681
823
  var packed = nodeFs.readFileSync(encDbPath);
682
- var plainBytes = bCrypto.decryptPacked(packed, dbKey);
824
+ // db.enc is XChaCha20-Poly1305-sealed AAD-bound to its dataDir
825
+ // (db.js _dbEncAad). Read with the dataDir AAD; retry without AAD for
826
+ // pre-AAD envelopes (mirrors db.js:765-768). The in-place swap keeps
827
+ // the same dataDir, so this AAD is reused on the re-encrypt below.
828
+ var dbEncAad = dbModuleLazy()._dbEncAad(dataDir);
829
+ var plainBytes;
830
+ try { plainBytes = bCrypto.decryptPacked(packed, dbKey, dbEncAad); }
831
+ catch (_eAad) { plainBytes = bCrypto.decryptPacked(packed, dbKey); }
683
832
  var tmpDbPath = nodePath.join(stagingDir, "_blamejs_rotate.tmp.db");
684
833
  nodeFs.writeFileSync(tmpDbPath, plainBytes, { mode: 0o600 });
685
834
 
@@ -698,6 +847,11 @@ async function rotate(opts) {
698
847
  "WHERE type='table' AND name NOT LIKE 'sqlite_%'"
699
848
  ).all().map(function (r) { return r.name; });
700
849
 
850
+ // Serialized roots threaded to the AAD reseal path; oldRootJson /
851
+ // newRootJson match b.vault.getKeysJson() so rotated AAD cells unseal
852
+ // once the new keypair is live after the swap.
853
+ var roots = { oldKeys: oldKeys, newKeys: newKeys, oldRootJson: oldRootJson, newRootJson: newRootJson };
854
+
701
855
  for (var ti = 0; ti < tablesToRotate.length; ti++) {
702
856
  var table = tablesToRotate[ti];
703
857
  var tableExists = db.prepare(
@@ -717,7 +871,7 @@ async function rotate(opts) {
717
871
  for (var sc = 0; sc < schema.sealedFields.length; sc++) {
718
872
  var col = schema.sealedFields[sc];
719
873
  if (!liveColSet[col]) continue;
720
- tableRows += _rotateColumn(db, table, col, oldKeys, newKeys, rowBatchSize, progress);
874
+ tableRows += _rotateColumn(db, table, col, schema, roots, rowBatchSize, progress);
721
875
  }
722
876
  }
723
877
  tableRows += _rotateOverflow(db, table, oldKeys, newKeys, rowBatchSize, progress, warnings);
@@ -748,15 +902,17 @@ async function rotate(opts) {
748
902
  // inside it. Files are written 0o600 implicitly via the dir's umask
749
903
  // and removed before the rotation completes.
750
904
  var rotatedBytes = nodeFs.readFileSync(tmpDbPath);
905
+ // Re-encrypt under the SAME dataDir AAD so db.init's AAD-first open
906
+ // succeeds after the staged dir is swapped over dataDir in place.
751
907
  nodeFs.writeFileSync(nodePath.join(stagingDir, paths.encryptedDb),
752
- bCrypto.encryptPacked(rotatedBytes, dbKey));
908
+ bCrypto.encryptPacked(rotatedBytes, dbKey, dbEncAad));
753
909
  nodeFs.unlinkSync(tmpDbPath);
754
910
 
755
911
  // Round-trip verify on the staged DB
756
912
  _emit(progress, { phase: "verify" });
757
913
  var verifyTmp = nodePath.join(stagingDir, "_blamejs_verify.tmp.db");
758
914
  nodeFs.writeFileSync(verifyTmp,
759
- bCrypto.decryptPacked(nodeFs.readFileSync(nodePath.join(stagingDir, paths.encryptedDb)), dbKey));
915
+ bCrypto.decryptPacked(nodeFs.readFileSync(nodePath.join(stagingDir, paths.encryptedDb)), dbKey, dbEncAad));
760
916
  var vdb = new DatabaseSync(verifyTmp);
761
917
  try {
762
918
  verifyResult = verify({ keys: newKeys, db: vdb, oldKeys: oldKeys });
@@ -819,4 +975,8 @@ module.exports = {
819
975
  DEFAULT_VERIFY_SAMPLE_MIN: DEFAULT_VERIFY_SAMPLE_MIN,
820
976
  DEFAULT_VERIFY_SAMPLE_FRAC: DEFAULT_VERIFY_SAMPLE_FRAC,
821
977
  ROW_BATCH_SIZE_DEFAULT: ROW_BATCH_SIZE_DEFAULT,
978
+ // Exposed for the rotation-gate coverage test: every lib module that exports
979
+ // an external AAD_ROTATION descriptor must be reachable here, or a keypair
980
+ // rotation silently orphans its store.
981
+ _externalAadTables: _externalAadTables,
822
982
  };