@blamejs/core 0.14.11 → 0.14.13

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.
@@ -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
  };
package/lib/vault-aad.js CHANGED
@@ -147,13 +147,17 @@ function buildContextAad(parts) {
147
147
  // 32 bytes). Constant-domain prefix prevents key collision with other
148
148
  // uses of the vault root.
149
149
 
150
- function _deriveKey(aadBytes) {
151
- var keysJson = vault().getKeysJson();
152
- // The vault keys JSON includes the active keypair PEMs. We hash the
153
- // whole serialized form to get a stable per-vault root secret
154
- // this is a deterministic derivation; rotating vault keys produces
155
- // a different root and breaks all prior AAD-sealed values (operator
156
- // intent: rotation = re-seal).
150
+ function _deriveKey(aadBytes, rootKeysJson) {
151
+ // rootKeysJson lets the vault-key rotation pipeline derive the per-row
152
+ // key under a SPECIFIC vault root (old or new keypair) within one
153
+ // process; when omitted it uses the live singleton. The keys JSON
154
+ // includes the active keypair PEMs hashing the whole serialized form
155
+ // gives a stable per-vault root secret. Rotating vault keys produces a
156
+ // different root, so prior AAD-sealed values must be re-sealed (the
157
+ // rotation pipeline walks them via sealRoot/unsealRoot/resealRoot).
158
+ var keysJson = (typeof rootKeysJson === "string" && rootKeysJson.length > 0)
159
+ ? rootKeysJson
160
+ : vault().getKeysJson();
157
161
  var rootHash = bCrypto().sha3Hash(keysJson);
158
162
  var prefix = Buffer.from("vault.aad/v1/", "utf8");
159
163
  var rootBuf = Buffer.from(rootHash, "hex");
@@ -161,7 +165,7 @@ function _deriveKey(aadBytes) {
161
165
  return bCrypto().kdf(input, C.BYTES.bytes(32));
162
166
  }
163
167
 
164
- function seal(plaintext, aadParts) {
168
+ function _seal(plaintext, aadParts, rootKeysJson, suppressAudit) {
165
169
  if (plaintext == null) {
166
170
  throw new VaultAadError("vault-aad/bad-input",
167
171
  "seal: plaintext is required (use null/undefined-stripping at the call site)");
@@ -176,26 +180,32 @@ function seal(plaintext, aadParts) {
176
180
  "seal: value is already AAD-sealed (refuses to double-seal)");
177
181
  }
178
182
  var aadBytes = _canonicalize(aadParts);
179
- var key = _deriveKey(aadBytes);
183
+ var key = _deriveKey(aadBytes, rootKeysJson);
180
184
  var ptBuf = Buffer.from(plaintext, "utf8");
181
185
  var packed = bCrypto().encryptPacked(ptBuf, key, aadBytes);
182
186
 
183
- try {
184
- audit().safeEmit({
185
- action: "vault.aad.sealed",
186
- outcome: "success",
187
- actor: null,
188
- metadata: {
189
- aadKeys: Object.keys(aadParts).sort(), // allow:bare-canonicalize-walk — audit-emit metadata, not for signing
190
- bytes: ptBuf.length,
191
- },
192
- });
193
- } catch (_e) { /* drop-silent */ }
187
+ if (!suppressAudit) {
188
+ try {
189
+ audit().safeEmit({
190
+ action: "vault.aad.sealed",
191
+ outcome: "success",
192
+ actor: null,
193
+ metadata: {
194
+ aadKeys: Object.keys(aadParts).sort(), // allow:bare-canonicalize-walk — audit-emit metadata, not for signing
195
+ bytes: ptBuf.length,
196
+ },
197
+ });
198
+ } catch (_e) { /* drop-silent */ }
199
+ }
194
200
 
195
201
  return AAD_PREFIX + packed.toString("base64");
196
202
  }
197
203
 
198
- function unseal(value, aadParts) {
204
+ function seal(plaintext, aadParts) {
205
+ return _seal(plaintext, aadParts, undefined, false);
206
+ }
207
+
208
+ function _unseal(value, aadParts, rootKeysJson, suppressAudit) {
199
209
  if (value == null || typeof value !== "string") {
200
210
  throw new VaultAadError("vault-aad/bad-input",
201
211
  "unseal: value must be a non-empty string");
@@ -205,7 +215,7 @@ function unseal(value, aadParts) {
205
215
  "unseal: value is not AAD-sealed (missing " + JSON.stringify(AAD_PREFIX) + " prefix)");
206
216
  }
207
217
  var aadBytes = _canonicalize(aadParts);
208
- var key = _deriveKey(aadBytes);
218
+ var key = _deriveKey(aadBytes, rootKeysJson);
209
219
  var packed;
210
220
  try { packed = Buffer.from(value.slice(AAD_PREFIX.length), "base64"); }
211
221
  catch (e) {
@@ -215,17 +225,19 @@ function unseal(value, aadParts) {
215
225
  var pt;
216
226
  try { pt = bCrypto().decryptPacked(packed, key, aadBytes); }
217
227
  catch (e) {
218
- try {
219
- audit().safeEmit({
220
- action: "vault.aad.unseal_failed",
221
- outcome: "denied",
222
- actor: null,
223
- metadata: {
224
- aadKeys: Object.keys(aadParts).sort(), // allow:bare-canonicalize-walk — audit-emit metadata, not for signing
225
- reason: e.message,
226
- },
227
- });
228
- } catch (_e) { /* drop-silent */ }
228
+ if (!suppressAudit) {
229
+ try {
230
+ audit().safeEmit({
231
+ action: "vault.aad.unseal_failed",
232
+ outcome: "denied",
233
+ actor: null,
234
+ metadata: {
235
+ aadKeys: Object.keys(aadParts).sort(), // allow:bare-canonicalize-walk — audit-emit metadata, not for signing
236
+ reason: e.message,
237
+ },
238
+ });
239
+ } catch (_e) { /* drop-silent */ }
240
+ }
229
241
  throw new VaultAadError("vault-aad/aead-mismatch",
230
242
  "unseal: AEAD authentication failed — value may have been tampered, " +
231
243
  "copied from a different row, or sealed under different AAD");
@@ -233,6 +245,10 @@ function unseal(value, aadParts) {
233
245
  return pt.toString("utf8");
234
246
  }
235
247
 
248
+ function unseal(value, aadParts) {
249
+ return _unseal(value, aadParts, undefined, false);
250
+ }
251
+
236
252
  function isAadSealed(value) {
237
253
  return typeof value === "string" && value.indexOf(AAD_PREFIX) === 0;
238
254
  }
@@ -246,10 +262,45 @@ function reseal(value, fromAad, toAad) {
246
262
  return seal(plaintext, toAad);
247
263
  }
248
264
 
265
+ // ---- explicit-root variants (vault-key rotation pipeline) ----
266
+ //
267
+ // The rotation pipeline must decrypt a cell under the OLD vault root and
268
+ // re-encrypt it under the NEW root within one process — the live-singleton
269
+ // _deriveKey cannot straddle two keypairs. These take the serialized vault
270
+ // keys JSON (b.vault.getKeysJson() output) for a specific root; the AAD
271
+ // tuple is unchanged, only the root differs. Per-cell audit is suppressed
272
+ // (the rotation pipeline has its own progress + verify reporting).
273
+
274
+ function sealRoot(plaintext, aadParts, rootKeysJson) {
275
+ if (typeof rootKeysJson !== "string" || rootKeysJson.length === 0) {
276
+ throw new VaultAadError("vault-aad/bad-root", "sealRoot: rootKeysJson (vault keys JSON) is required");
277
+ }
278
+ return _seal(plaintext, aadParts, rootKeysJson, true);
279
+ }
280
+
281
+ function unsealRoot(value, aadParts, rootKeysJson) {
282
+ if (typeof rootKeysJson !== "string" || rootKeysJson.length === 0) {
283
+ throw new VaultAadError("vault-aad/bad-root", "unsealRoot: rootKeysJson (vault keys JSON) is required");
284
+ }
285
+ return _unseal(value, aadParts, rootKeysJson, true);
286
+ }
287
+
288
+ // Re-seal a value from the old root to the new root under the SAME AAD
289
+ // tuple: authenticate under the old root (throws aead-mismatch if the
290
+ // value was not sealed under oldRootJson + aadParts), then re-encrypt
291
+ // under the new root. The rotation pipeline composes this per cell.
292
+ function resealRoot(value, aadParts, oldRootJson, newRootJson) {
293
+ var plaintext = unsealRoot(value, aadParts, oldRootJson);
294
+ return sealRoot(plaintext, aadParts, newRootJson);
295
+ }
296
+
249
297
  module.exports = {
250
298
  seal: seal,
251
299
  unseal: unseal,
252
300
  reseal: reseal,
301
+ sealRoot: sealRoot,
302
+ unsealRoot: unsealRoot,
303
+ resealRoot: resealRoot,
253
304
  isAadSealed: isAadSealed,
254
305
  buildColumnAad: buildColumnAad,
255
306
  buildContextAad: buildContextAad,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.11",
3
+ "version": "0.14.13",
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:51d93d1b-aac7-4b5a-b94b-b60595c0eba0",
5
+ "serialNumber": "urn:uuid:0632934d-fe4a-4185-bb87-eef8617ef0a0",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-31T14:46:52.063Z",
8
+ "timestamp": "2026-05-31T19:38:16.822Z",
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.11",
22
+ "bom-ref": "@blamejs/core@0.14.13",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.11",
25
+ "version": "0.14.13",
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.11",
29
+ "purl": "pkg:npm/%40blamejs/core@0.14.13",
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.11",
57
+ "ref": "@blamejs/core@0.14.13",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]