@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.
@@ -58,6 +58,8 @@ var guardTenantId = require("./guard-tenant-id");
58
58
  var bCrypto = require("./crypto");
59
59
  var agentAudit = require("./agent-audit");
60
60
  var safeJson = require("./safe-json");
61
+ var vaultAad = require("./vault-aad");
62
+ var validateOpts = require("./validate-opts");
61
63
 
62
64
  var audit = lazyRequire(function () { return require("./audit"); });
63
65
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
@@ -115,8 +117,12 @@ var CROSS_TENANT_ADMIN_SCOPE = "framework-cross-tenant-admin";
115
117
 
116
118
  // Per-tenant key derivation domain separators. NIST SP 800-108 r1 §5.1
117
119
  // KDF-in-Counter shape — fixed "label" + tenantId-as-salt + purpose-as-
118
- // info. Operator passphrase rotation produces a fresh master, breaking
119
- // every prior tenant ciphertext (operator intent: rotation = re-seal).
120
+ // info. The root secret is the vault master keypair (SHA3-512 of
121
+ // b.vault.getKeysJson()); rotating the vault keypair changes the root,
122
+ // so every tnt-v1: cell sealed under the old root must be re-sealed
123
+ // under the new root. That migration is NOT automatic — it runs via the
124
+ // AAD_ROTATION reseal hook (see `reseal` below), which the vault-key
125
+ // rotation pipeline composes per the explicit-root primitive contract.
120
126
  var TENANT_KDF_LABEL = "blamejs.agent.tenant/v1";
121
127
  // 32 bytes — XChaCha20-Poly1305 key length. Distinct from the audit
122
128
  // truncation buffer so future key-length bumps don't have to chase a
@@ -391,20 +397,32 @@ function _check(ctx, actor, agentTenantId) {
391
397
  // rootKey is SHA3-512(vault.getKeysJson()). Same derivation `b.vault.aad`
392
398
  // uses internally — the vault's master keypair PEM is the secret KDK.
393
399
  // Rotating the vault passphrase / keypair (b.vaultRotate.rotate)
394
- // changes rootKey, which changes every derived tenant key — operator
395
- // intent for rotation IS re-seal.
400
+ // changes rootKey, which changes every derived tenant key — so every
401
+ // prior tnt-v1: cell must be re-sealed old-root -> new-root. The
402
+ // `reseal` hook below (eager-registered via AAD_ROTATION) performs that
403
+ // migration; it uses the explicit-root variant of the tenant-key
404
+ // derivation (rootKeysJson arg) to decrypt under the old root and
405
+ // re-encrypt under the new one within one process.
396
406
  //
397
407
  // `derivedKey` returns a hex-encoded 32-byte key (64 chars) to keep
398
408
  // the wire shape compatible with prior callers. Internal callers that
399
409
  // need the raw key use `_tenantFieldKey` directly.
400
410
 
401
- function _vaultRootBytes() {
402
- // Vault.getKeysJson() throws when the vault hasn't been init'd. That
403
- // is the right secure-by-default posture: tenant-derived keys cannot
404
- // be produced before the operator has bootstrapped the vault. The
405
- // error reaches the caller (sealField / register) so an operator
406
- // mis-ordering boot (start agents before vault.init) sees a clear
407
- // refusal rather than getting weakened-but-deterministic keys.
411
+ function _vaultRootBytes(rootKeysJson) {
412
+ // rootKeysJson lets the vault-key rotation pipeline derive the per-
413
+ // tenant key under a SPECIFIC vault root (old or new keypair) within
414
+ // one process mirrors vault-aad._deriveKey's explicit-root arg. When
415
+ // omitted it reads the live singleton via vault().getKeysJson().
416
+ //
417
+ // getKeysJson() throws when the vault hasn't been init'd. That is the
418
+ // right secure-by-default posture: tenant-derived keys cannot be
419
+ // produced before the operator has bootstrapped the vault. The error
420
+ // reaches the caller (sealField / register) so an operator mis-ordering
421
+ // boot (start agents before vault.init) sees a clear refusal rather
422
+ // than getting weakened-but-deterministic keys.
423
+ if (typeof rootKeysJson === "string" && rootKeysJson.length > 0) {
424
+ return Buffer.from(bCrypto.sha3Hash(rootKeysJson), "hex");
425
+ }
408
426
  var keysJson;
409
427
  try { keysJson = vault().getKeysJson(); }
410
428
  catch (e) {
@@ -415,7 +433,7 @@ function _vaultRootBytes() {
415
433
  return Buffer.from(bCrypto.sha3Hash(keysJson), "hex");
416
434
  }
417
435
 
418
- function _deriveTenantKeyBytes(tenantId, purpose) {
436
+ function _deriveTenantKeyBytes(tenantId, purpose, rootKeysJson) {
419
437
  guardTenantId.validate(tenantId);
420
438
  if (typeof purpose !== "string" || purpose.length === 0) {
421
439
  throw new AgentTenantError("agent-tenant/bad-purpose",
@@ -424,8 +442,9 @@ function _deriveTenantKeyBytes(tenantId, purpose) {
424
442
  // Domain-separated KDF input. NUL separators between fields prevent
425
443
  // (label, tenantId="x\0y", purpose="z") colliding with
426
444
  // (label, tenantId="x", purpose="y\0z") — same byte concatenation,
427
- // different logical context.
428
- var rootBytes = _vaultRootBytes();
445
+ // different logical context. rootKeysJson (optional) pins the vault
446
+ // root for the rotation reseal path; default is the live singleton.
447
+ var rootBytes = _vaultRootBytes(rootKeysJson);
429
448
  var input = Buffer.concat([
430
449
  Buffer.from(TENANT_KDF_LABEL, "utf8"),
431
450
  Buffer.from([0x00]),
@@ -455,7 +474,11 @@ function _deriveTenantKeyBytes(tenantId, purpose) {
455
474
  * `derivedKey(t, "archive-wrap")` is recoverable later from the same
456
475
  * tenant + purpose with no key escrow. Rotating the vault
457
476
  * (`b.vaultRotate.rotate`) changes the root and therefore every
458
- * derived key by design, rotation intent is re-seal.
477
+ * derived key, so every cell sealed under the old root must be
478
+ * re-sealed under the new one. That migration runs through the
479
+ * module's `reseal` hook (eager-registered via `AAD_ROTATION`), not
480
+ * silently on the next read — a value sealed under the old root does
481
+ * not decrypt under the new root until the rotation pipeline walks it.
459
482
  *
460
483
  * Throws if the vault has not been initialized (keys cannot be derived
461
484
  * before bootstrap) or if `purpose` is empty. This is the same
@@ -552,12 +575,14 @@ function _auditFor(ctx, tenantId) {
552
575
 
553
576
  var TENANT_FIELD_PREFIX = "tnt-v1:";
554
577
 
555
- function _tenantFieldKey(tenantId, table) {
578
+ function _tenantFieldKey(tenantId, table, rootKeysJson) {
556
579
  // 32-byte symmetric key for XChaCha20-Poly1305. _deriveTenantKeyBytes
557
580
  // returns the raw key bound to the vault master + tenantId + purpose
558
581
  // — see the commentary above _derivedKey for the threat
559
582
  // model that drove this away from public-input-only derivation.
560
- return _deriveTenantKeyBytes(tenantId, "cryptoField:" + table);
583
+ // rootKeysJson (optional) pins a specific vault root for the rotation
584
+ // reseal path; default is the live singleton.
585
+ return _deriveTenantKeyBytes(tenantId, "cryptoField:" + table, rootKeysJson);
561
586
  }
562
587
 
563
588
  function _tenantFieldAad(tenantId, table, field) {
@@ -602,6 +627,25 @@ function _unsealField(tenantId, table, field, ciphertext) {
602
627
  return plain.toString("utf8");
603
628
  }
604
629
 
630
+ // Explicit-root re-seal of a single tnt-v1: cell, old root -> new root,
631
+ // under the SAME (tenantId, table, field) AAD. Decrypts with the key
632
+ // derived from oldRootJson and re-encrypts under newRootJson — the
633
+ // XChaCha20-Poly1305 tag refuses any cell that wasn't sealed under the
634
+ // old root + this AAD (CWE-345 / CWE-441). Values without the prefix
635
+ // (plaintext columns, already-rotated cells) pass through untouched.
636
+ function _resealTenantCell(tenantId, table, field, ciphertext, oldRootJson, newRootJson) {
637
+ if (typeof ciphertext !== "string" || ciphertext.indexOf(TENANT_FIELD_PREFIX) !== 0) {
638
+ return ciphertext;
639
+ }
640
+ var packed = Buffer.from(ciphertext.slice(TENANT_FIELD_PREFIX.length), "base64");
641
+ var aad = _tenantFieldAad(tenantId, table, field);
642
+ var oldKey = _tenantFieldKey(tenantId, table, oldRootJson);
643
+ var plain = bCrypto.decryptPacked(packed, oldKey, aad);
644
+ var newKey = _tenantFieldKey(tenantId, table, newRootJson);
645
+ var reSealed = bCrypto.encryptPacked(plain, newKey, aad);
646
+ return TENANT_FIELD_PREFIX + reSealed.toString("base64");
647
+ }
648
+
605
649
  function _sealRowForTenant(tenantId, table, row) {
606
650
  // Adopts the existing b.cryptoField table schema (sealedFields) but
607
651
  // routes each field through the per-tenant AEAD instead of the
@@ -663,6 +707,113 @@ function _unsealRowForTenant(ctx, tenantId, table, row) {
663
707
  return out;
664
708
  }
665
709
 
710
+ // ---- Vault-key rotation: re-seal hook -------------------------------------
711
+ //
712
+ // Two root-derived families live in this module, both keyed off the vault
713
+ // master keypair (SHA3-512 of b.vault.getKeysJson()):
714
+ //
715
+ // 1. The registry table "agent_tenant_registry" — a b.cryptoField
716
+ // {aad:true} table whose `metadata` column holds vault.aad: cells.
717
+ // Re-sealed old-root -> new-root via vaultAad.resealRoot, with the
718
+ // AAD tuple rebuilt by cryptoField._aadParts (the SAME builder the
719
+ // seal side uses — single source of truth, no drift).
720
+ //
721
+ // 2. The tnt-v1: per-tenant sealed cells written by sealField /
722
+ // sealRowForTenant. Re-sealed via _resealTenantCell, which derives
723
+ // the per-tenant XChaCha20-Poly1305 key under each root explicitly
724
+ // and re-binds the (tenantId|table|field) AAD.
725
+ //
726
+ // Both descriptors export a `reseal({ store, oldRootJson, newRootJson })`
727
+ // hook. The vault-key rotation pipeline eager-registers every module's
728
+ // AAD_ROTATION descriptor(s) and calls each reseal with the operator-
729
+ // supplied backing store; without this hook, a keypair rotation would
730
+ // orphan every prior cell (decryptable under neither root). oldRootJson /
731
+ // newRootJson are b.vault.getKeysJson() output for the two keypairs.
732
+
733
+ var REGISTRY_SCHEMA_VERSION = "1";
734
+
735
+ // Rebuild the registry's AAD tuple via the cryptoField seal-side builder
736
+ // so the rotate side can never drift from the seal side. The schema
737
+ // shape cryptoField._aadParts reads is { rowIdField, schemaVersion }.
738
+ function _registryAadFor(row) {
739
+ return cryptoField()._aadParts(
740
+ { rowIdField: "tenantId", schemaVersion: REGISTRY_SCHEMA_VERSION },
741
+ SEAL_TABLE, "metadata", row);
742
+ }
743
+
744
+ // Re-seal the registry table's vault.aad: metadata cells. `store` is the
745
+ // operator's backing store for SEAL_TABLE, exposing list() ->
746
+ // [{ tenantId, metadata, ... }] and set(tenantId, row). Each metadata
747
+ // cell that is vault.aad-sealed is re-sealed old-root -> new-root under
748
+ // the SAME (table, tenantId, "metadata", schemaVersion) AAD.
749
+ function _resealRegistry(args) {
750
+ var store = args && args.store;
751
+ validateOpts.requireMethods(store, ["list", "set"],
752
+ "reseal: store for the '" + SEAL_TABLE + "' table",
753
+ AgentTenantError, "agent-tenant/bad-reseal-store");
754
+ validateOpts.requireNonEmptyString(args.oldRootJson,
755
+ "reseal: oldRootJson (b.vault.getKeysJson output)", AgentTenantError, "agent-tenant/bad-reseal-root");
756
+ validateOpts.requireNonEmptyString(args.newRootJson,
757
+ "reseal: newRootJson (b.vault.getKeysJson output)", AgentTenantError, "agent-tenant/bad-reseal-root");
758
+ return Promise.resolve(store.list()).then(function (rows) {
759
+ rows = Array.isArray(rows) ? rows : [];
760
+ var resealed = 0;
761
+ var chain = Promise.resolve();
762
+ rows.forEach(function (row) {
763
+ if (!row || row.tenantId == null) return;
764
+ var cell = row.metadata;
765
+ if (!vaultAad.isAadSealed(cell)) return; // plaintext / already-migrated
766
+ var aad = _registryAadFor(row);
767
+ var next = vaultAad.resealRoot(cell, aad, args.oldRootJson, args.newRootJson);
768
+ var updated = Object.assign({}, row, { metadata: next });
769
+ resealed += 1;
770
+ chain = chain.then(function () { return store.set(row.tenantId, updated); });
771
+ });
772
+ return chain.then(function () {
773
+ return { table: SEAL_TABLE, resealed: resealed };
774
+ });
775
+ });
776
+ }
777
+
778
+ // Re-seal the tnt-v1: per-tenant sealed cells. `store` is the operator's
779
+ // backing store for every table that carries tnt-v1: columns, exposing
780
+ // list() -> [{ tenantId, table, field, value, _id? }] (one entry per
781
+ // sealed cell, carrying the context needed to rebuild AAD + key) and
782
+ // write(cell, newValue) to persist the re-sealed value. Plaintext /
783
+ // already-rotated values pass through untouched.
784
+ function _resealTenantCells(args) {
785
+ var store = args && args.store;
786
+ validateOpts.requireMethods(store, ["list", "write"],
787
+ "reseal: store for tnt-v1: cells",
788
+ AgentTenantError, "agent-tenant/bad-reseal-store");
789
+ validateOpts.requireNonEmptyString(args.oldRootJson,
790
+ "reseal: oldRootJson (b.vault.getKeysJson output)", AgentTenantError, "agent-tenant/bad-reseal-root");
791
+ validateOpts.requireNonEmptyString(args.newRootJson,
792
+ "reseal: newRootJson (b.vault.getKeysJson output)", AgentTenantError, "agent-tenant/bad-reseal-root");
793
+ return Promise.resolve(store.list()).then(function (cells) {
794
+ cells = Array.isArray(cells) ? cells : [];
795
+ var resealed = 0;
796
+ var chain = Promise.resolve();
797
+ cells.forEach(function (cell) {
798
+ if (!cell || cell.tenantId == null ||
799
+ typeof cell.table !== "string" || typeof cell.field !== "string") {
800
+ return;
801
+ }
802
+ var value = cell.value;
803
+ if (typeof value !== "string" || value.indexOf(TENANT_FIELD_PREFIX) !== 0) {
804
+ return; // not a tnt-v1: cell — leave untouched
805
+ }
806
+ var next = _resealTenantCell(cell.tenantId, cell.table, cell.field,
807
+ value, args.oldRootJson, args.newRootJson);
808
+ resealed += 1;
809
+ chain = chain.then(function () { return store.write(cell, next); });
810
+ });
811
+ return chain.then(function () {
812
+ return { table: TENANT_FIELD_PREFIX, resealed: resealed };
813
+ });
814
+ });
815
+ }
816
+
666
817
  // ---- Destroy preconditions ------------------------------------------------
667
818
 
668
819
  function _checkDestroyPreconditions(args, tenantId) {
@@ -703,11 +854,36 @@ function _inMemoryBackend() {
703
854
  };
704
855
  }
705
856
 
857
+ // Vault-key rotation descriptors — the rotation pipeline eager-registers
858
+ // these and calls each reseal({ store, oldRootJson, newRootJson }) to
859
+ // re-seal this module's two root-derived families old-root -> new-root.
860
+ // backend: "external" — the cells live in the operator's backing store
861
+ // (the registry backend + the operator's tenant data tables), not in the
862
+ // framework's at-rest SQLite, so the rotation pipeline cannot walk them
863
+ // from the data directory alone; the operator supplies the store.
864
+ var AAD_ROTATION = [
865
+ {
866
+ table: SEAL_TABLE,
867
+ rowIdField: "tenantId",
868
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
869
+ backend: "external",
870
+ reseal: _resealRegistry,
871
+ },
872
+ {
873
+ table: TENANT_FIELD_PREFIX, // prefix family, not a single SQL table
874
+ rowIdField: "tenantId",
875
+ schemaVersion: "v1", // tnt-v1: ciphertext shape version
876
+ backend: "external",
877
+ reseal: _resealTenantCells,
878
+ },
879
+ ];
880
+
706
881
  module.exports = {
707
882
  create: create,
708
883
  derivedKey: _derivedKey,
709
884
  CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
710
885
  AgentTenantError: AgentTenantError,
886
+ AAD_ROTATION: AAD_ROTATION,
711
887
  guards: {
712
888
  tenantId: guardTenantId,
713
889
  },
@@ -20,11 +20,32 @@
20
20
  * seal keyed by the vault root (no key-pair to manage; unwrap
21
21
  * re-derives from the tenant id). b.backup's `cryptoStrategy:
22
22
  * "recipient"` consumes the same substrate.
23
+ *
24
+ * Vault rotation and the tenant strategy: a `recipient: "tenant"`
25
+ * envelope is keyed by the vault root (`b.agent.tenant.derivedKey`),
26
+ * which changes whenever the vault keypair / passphrase rotates
27
+ * (`b.vaultRotate.rotate`). The rotation pipeline re-seals values it
28
+ * can WALK — sealed DB columns + the framework's sealed key files. It
29
+ * CANNOT walk tenant archive blobs: they are opaque bytes the operator
30
+ * placed in files / object-storage / backups outside any store the
31
+ * pipeline indexes. After a rotation, an old tenant envelope no longer
32
+ * opens (its key was derived from the OLD root) — a data-loss class
33
+ * (CWE-325 missing cryptographic step / CWE-665 improper
34
+ * initialization of the new-root key) if the operator assumed rotation
35
+ * re-sealed them. The operator must enumerate every tenant blob
36
+ * location and re-wrap each one old-root -> new-root with
37
+ * `b.archive.rewrapTenant` BEFORE retiring the old vault keypair (keep
38
+ * the old keypair JSON until the migration completes — it is the only
39
+ * material that can open the old envelopes). Re-open this gap to a
40
+ * framework-side index only if operators surface demand for the
41
+ * framework to track blob locations; today the operator owns the
42
+ * inventory because blob placement is operator-controlled.
23
43
  */
24
44
 
25
45
  var C = require("./constants");
26
46
  var lazyRequire = require("./lazy-require");
27
47
  var { defineClass } = require("./framework-error");
48
+ var validateOpts = require("./validate-opts");
28
49
 
29
50
  var ArchiveWrapError = defineClass("ArchiveWrapError", { alwaysPermanent: true });
30
51
 
@@ -84,7 +105,11 @@ var ARCH_PASSPHRASE_HEADER_BYTES = C.BYTES.bytes(7);
84
105
  * AAD so one tenant's envelope cannot open under
85
106
  * another's key. No recipient key-pair to manage;
86
107
  * `unwrap` re-derives from the same `tenantId`. Requires
87
- * an initialized vault.
108
+ * an initialized vault. The derived key tracks the vault
109
+ * root: after a vault rotation the operator must re-wrap
110
+ * each stored tenant blob old-root -> new-root via
111
+ * `b.archive.rewrapTenant` (the rotation pipeline does
112
+ * not walk operator-placed blobs).
88
113
  *
89
114
  * @opts
90
115
  * recipient: object | string, // see strategies above; required
@@ -239,10 +264,207 @@ function _tenantKey(tenantId) {
239
264
  // AAD context-binds the symmetric envelope to the tenant: the Poly1305
240
265
  // tag covers this, so a tenant-A envelope cannot be decrypted under
241
266
  // tenant-B's key even if an attacker swaps headers between envelopes.
267
+ // Independent of the vault root, so rotation re-wrap reuses the SAME
268
+ // AAD — only the derived key changes old-root -> new-root.
242
269
  function _tenantAad(tenantId) {
243
270
  return Buffer.from("archive-wrap|tenant|" + tenantId, "utf8");
244
271
  }
245
272
 
273
+ // Domain-separation constants for the explicit-root fallback derivation.
274
+ // MUST stay byte-identical to b.agent.tenant's _deriveTenantKeyBytes
275
+ // (TENANT_KDF_LABEL / TENANT_KEY_BYTES) so a key derived here under the
276
+ // LIVE root equals agentTenant().derivedKey(tenantId, "archive-wrap").
277
+ // The fallback only runs when the agent-tenant build predates the
278
+ // explicit-root export; the live-root path (_tenantKey) always uses
279
+ // agent-tenant directly.
280
+ var TENANT_KDF_LABEL = "blamejs.agent.tenant/v1";
281
+ var TENANT_KEY_BYTES = C.BYTES.bytes(32);
282
+
283
+ // Resolve a tenant's archive-wrap key under an EXPLICIT vault root
284
+ // (the serialized keys JSON of the old OR new keypair) rather than the
285
+ // live singleton — the rotation re-wrap path must straddle two roots in
286
+ // one process. Prefers b.agent.tenant's explicit-root export so the
287
+ // derivation stays single-sourced; falls back to a byte-identical local
288
+ // derivation when running against an agent-tenant build that predates
289
+ // that export (coordination escape hatch, removed once the floor moves).
290
+ function _tenantKeyWithRoot(tenantId, rootKeysJson) {
291
+ if (typeof tenantId !== "string" || tenantId.length === 0) {
292
+ throw new ArchiveWrapError("archive-wrap/no-tenant-id",
293
+ "rewrapTenant: tenantId is required (a non-empty string)");
294
+ }
295
+ if (typeof rootKeysJson !== "string" || rootKeysJson.length === 0) {
296
+ throw new ArchiveWrapError("archive-wrap/bad-root",
297
+ "rewrapTenant: root keys JSON is required (b.vault.getKeysJson() output for the old/new keypair)");
298
+ }
299
+ var at = agentTenant();
300
+ if (typeof at.derivedKeyWithRoot === "function") {
301
+ return Buffer.from(at.derivedKeyWithRoot(tenantId, TENANT_KEY_PURPOSE, rootKeysJson), "hex");
302
+ }
303
+ // Byte-identical fallback: SHAKE256(label || 0x00 || SHA3-512(rootKeysJson)
304
+ // || 0x00 || tenantId || 0x00 || purpose, 32). Matches the agent-tenant
305
+ // KDF construction exactly so the live-root and explicit-root keys agree.
306
+ var rootBytes = Buffer.from(bCrypto().sha3Hash(rootKeysJson), "hex");
307
+ var input = Buffer.concat([
308
+ Buffer.from(TENANT_KDF_LABEL, "utf8"),
309
+ Buffer.from([0x00]),
310
+ rootBytes,
311
+ Buffer.from([0x00]),
312
+ Buffer.from(tenantId, "utf8"),
313
+ Buffer.from([0x00]),
314
+ Buffer.from(TENANT_KEY_PURPOSE, "utf8"),
315
+ ]);
316
+ return bCrypto().kdf(input, TENANT_KEY_BYTES);
317
+ }
318
+
319
+ /**
320
+ * @primitive b.archive.rewrapTenant
321
+ * @signature b.archive.rewrapTenant(opts)
322
+ * @since 0.14.12
323
+ * @status stable
324
+ * @related b.archive.wrap, b.archive.unwrap, b.agent.tenant.derivedKey, b.vaultRotate.rotate
325
+ *
326
+ * Re-wrap a single `recipient: "tenant"` archive blob from the old
327
+ * vault root to the new one after a vault rotation. The tenant
328
+ * strategy keys each envelope off the vault root
329
+ * (`b.agent.tenant.derivedKey`); rotating the vault keypair changes
330
+ * that root, so envelopes sealed under the old root no longer open.
331
+ *
332
+ * The vault rotation pipeline (`b.vaultRotate.rotate`) re-seals every
333
+ * value it can WALK — sealed DB columns and the framework's sealed key
334
+ * files. It cannot reach tenant archive blobs: those are opaque bytes
335
+ * the operator placed in files / object-storage / backups outside any
336
+ * store the framework indexes. The framework does not track blob
337
+ * locations, so the operator enumerates them and calls this primitive
338
+ * once per blob, supplying both the old and new keypair JSON.
339
+ *
340
+ * The re-wrap unwraps under the old-root tenant key, then re-wraps
341
+ * under the new-root tenant key with the SAME tenant-bound AAD — the
342
+ * plaintext archive bytes are recovered in memory and immediately
343
+ * re-sealed; the AEAD tag on the new envelope binds the same
344
+ * `tenantId`, so cross-tenant replay is refused exactly as on a fresh
345
+ * `b.archive.wrap`.
346
+ *
347
+ * Run this BEFORE retiring the old vault keypair: the old keypair JSON
348
+ * is the only material that can open the old envelopes (CWE-325 —
349
+ * skipping it strands the blobs; CWE-665 — re-keying under the wrong
350
+ * root yields an unopenable envelope). Refuses any non-tenant envelope
351
+ * (recipient / passphrase magic) so a key-pair or passphrase blob is
352
+ * never silently mis-routed through the tenant key path.
353
+ *
354
+ * @opts
355
+ * blob: Buffer | Uint8Array, // a tenant (BAWRP v2) envelope; required
356
+ * oldRootJson: string, // b.vault.getKeysJson() of the OLD keypair; required
357
+ * newRootJson: string, // b.vault.getKeysJson() of the NEW keypair; required
358
+ * tenantId: string, // the tenant the blob was sealed for; required
359
+ *
360
+ * @example
361
+ * var oldRoot = oldKeys; // captured before rotation
362
+ * var newKeys = b.vault.getKeysJson();
363
+ * // operator enumerates blob locations (framework does not index them):
364
+ * for (var loc of operatorBlobInventory) {
365
+ * var rewrapped = b.archive.rewrapTenant({
366
+ * blob: fs.readFileSync(loc),
367
+ * oldRootJson: oldRoot,
368
+ * newRootJson: newKeys,
369
+ * tenantId: "alpha",
370
+ * });
371
+ * fs.writeFileSync(loc, rewrapped);
372
+ * }
373
+ */
374
+ function rewrapTenant(opts) {
375
+ opts = opts || {};
376
+ var blob = opts.blob;
377
+ if (!Buffer.isBuffer(blob) && !(blob instanceof Uint8Array)) {
378
+ throw new ArchiveWrapError("archive-wrap/bad-input",
379
+ "rewrapTenant: opts.blob must be a Buffer or Uint8Array");
380
+ }
381
+ var buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
382
+ if (buf.length < ARCH_WRAP_HEADER_BYTES) {
383
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
384
+ "rewrapTenant: blob shorter than 6-byte archive-wrap header");
385
+ }
386
+ var magic = buf.slice(0, 5).toString("ascii");
387
+ if (magic !== ARCH_WRAP_MAGIC) {
388
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
389
+ "rewrapTenant: blob does not start with archive-wrap magic " +
390
+ JSON.stringify(ARCH_WRAP_MAGIC) + "; got " + JSON.stringify(magic) +
391
+ " (only recipient: \"tenant\" envelopes are root-keyed; key-pair / passphrase " +
392
+ "envelopes are re-keyed by re-encrypting to a new recipient, not by rewrapTenant)");
393
+ }
394
+ if (buf[5] !== ARCH_WRAP_VERSION_TENANT) {
395
+ throw new ArchiveWrapError("archive-wrap/not-tenant-envelope",
396
+ "rewrapTenant: blob is not a recipient: \"tenant\" envelope (version byte " + buf[5] +
397
+ ", expected " + ARCH_WRAP_VERSION_TENANT + "). Key-pair / peer-cert envelopes are not " +
398
+ "root-keyed — re-encrypt them to a fresh recipient instead.");
399
+ }
400
+ var aad = _tenantAad(opts.tenantId);
401
+ var oldKey = _tenantKeyWithRoot(opts.tenantId, opts.oldRootJson);
402
+ var packedBody = buf.slice(ARCH_WRAP_HEADER_BYTES);
403
+ var plaintext;
404
+ try {
405
+ plaintext = bCrypto().decryptPacked(packedBody, oldKey, aad);
406
+ } catch (e) {
407
+ var derr = new ArchiveWrapError("archive-wrap/decrypt-failed",
408
+ "rewrapTenant: blob did not open under the OLD root + tenantId (wrong tenantId, " +
409
+ "wrong old keypair, or already re-wrapped?): " + ((e && e.message) || String(e)));
410
+ derr.cause = e;
411
+ throw derr;
412
+ }
413
+ var newKey = _tenantKeyWithRoot(opts.tenantId, opts.newRootJson);
414
+ var rePacked = bCrypto().encryptPacked(Buffer.from(plaintext), newKey, aad);
415
+ var header = Buffer.alloc(ARCH_WRAP_HEADER_BYTES);
416
+ header.write(ARCH_WRAP_MAGIC, 0, 5, "ascii");
417
+ header[5] = ARCH_WRAP_VERSION_TENANT;
418
+ return Buffer.concat([header, rePacked]);
419
+ }
420
+
421
+ // AAD_ROTATION descriptor — lets the vault-key rotation pipeline
422
+ // discover this module's re-seal hook (eager-register / detect-and-
423
+ // refuse). Unlike DB-column AAD modules, tenant archive blobs live
424
+ // OUTSIDE any store the pipeline walks (operator-placed files / object-
425
+ // storage / backups), so `backend: "external"`: the framework cannot
426
+ // enumerate them. `reseal` iterates an OPERATOR-supplied backing store
427
+ // — an object exposing `list()` (returns `[{ id, blob, tenantId }]`)
428
+ // and `put(id, blob)` — re-wrapping every entry old-root -> new-root
429
+ // via rewrapTenant and writing the result back. The operator owns the
430
+ // inventory; the framework owns the per-blob crypto.
431
+ function _resealExternal(args) {
432
+ args = args || {};
433
+ var store = args.store;
434
+ validateOpts.requireMethods(store, ["list", "put"],
435
+ "AAD_ROTATION.reseal: opts.store (tenant archive blobs are operator-placed; the framework cannot enumerate them)",
436
+ ArchiveWrapError, "archive-wrap/bad-store");
437
+ validateOpts.requireNonEmptyString(args.oldRootJson,
438
+ "AAD_ROTATION.reseal: oldRootJson (b.vault.getKeysJson() output)", ArchiveWrapError, "archive-wrap/bad-root");
439
+ validateOpts.requireNonEmptyString(args.newRootJson,
440
+ "AAD_ROTATION.reseal: newRootJson (b.vault.getKeysJson() output)", ArchiveWrapError, "archive-wrap/bad-root");
441
+ var entries = store.list();
442
+ if (!Array.isArray(entries)) {
443
+ throw new ArchiveWrapError("archive-wrap/bad-store",
444
+ "AAD_ROTATION.reseal: store.list() must return an array of { id, blob, tenantId }");
445
+ }
446
+ var resealed = 0;
447
+ for (var i = 0; i < entries.length; i += 1) {
448
+ var e = entries[i];
449
+ if (!e || typeof e.id === "undefined" ||
450
+ (!Buffer.isBuffer(e.blob) && !(e.blob instanceof Uint8Array)) ||
451
+ typeof e.tenantId !== "string") {
452
+ throw new ArchiveWrapError("archive-wrap/bad-store-entry",
453
+ "AAD_ROTATION.reseal: every store entry must be { id, blob: Buffer, tenantId: string }; " +
454
+ "entry index " + i + " is malformed");
455
+ }
456
+ var rewrapped = rewrapTenant({
457
+ blob: e.blob,
458
+ oldRootJson: args.oldRootJson,
459
+ newRootJson: args.newRootJson,
460
+ tenantId: e.tenantId,
461
+ });
462
+ store.put(e.id, rewrapped);
463
+ resealed += 1;
464
+ }
465
+ return { table: "archive-wrap:tenant-blobs", resealed: resealed };
466
+ }
467
+
246
468
  // Returns { version, body } so wrap() can stamp the right version byte:
247
469
  // hybrid-KEM recipients use ARCH_WRAP_VERSION with a base64 envelope
248
470
  // body; the tenant strategy uses ARCH_WRAP_VERSION_TENANT with a
@@ -561,6 +783,7 @@ function _estimatePassphraseEntropyBits(passphrase) {
561
783
  module.exports = {
562
784
  wrap: wrap,
563
785
  unwrap: unwrap,
786
+ rewrapTenant: rewrapTenant,
564
787
  wrapWithPassphrase: wrapWithPassphrase,
565
788
  unwrapWithPassphrase: unwrapWithPassphrase,
566
789
  sniffEnvelope: sniffEnvelope,
@@ -570,4 +793,14 @@ module.exports = {
570
793
  _isPassphraseMagic: _isPassphraseMagic,
571
794
  ARCH_WRAP_MAGIC: ARCH_WRAP_MAGIC,
572
795
  ARCH_PASSPHRASE_MAGIC: ARCH_PASSPHRASE_MAGIC,
796
+ // Vault-rotation re-seal hook. backend: "external" — tenant archive
797
+ // blobs live outside any store the rotation pipeline walks, so the
798
+ // operator supplies the backing store to reseal().
799
+ AAD_ROTATION: {
800
+ table: "archive-wrap:tenant-blobs",
801
+ rowIdField: "id",
802
+ schemaVersion: "1",
803
+ backend: "external",
804
+ reseal: _resealExternal,
805
+ },
573
806
  };
package/lib/archive.js CHANGED
@@ -554,6 +554,7 @@ module.exports = {
554
554
  gz: archiveGz.gz,
555
555
  wrap: archiveWrap.wrap,
556
556
  unwrap: archiveWrap.unwrap,
557
+ rewrapTenant: archiveWrap.rewrapTenant,
557
558
  wrapWithPassphrase: archiveWrap.wrapWithPassphrase,
558
559
  unwrapWithPassphrase: archiveWrap.unwrapWithPassphrase,
559
560
  sniffEnvelope: archiveWrap.sniffEnvelope,