@blamejs/core 0.13.19 → 0.13.21

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/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.21 (2026-05-27) — **`b.cose.exportKey` — serialize a public key as a COSE_Key, the inverse of `b.cose.importKey`.** b.cose could import a COSE_Key (RFC 9052 §7) into a node:crypto key for verification, but had no way to produce one — so a key used with b.cose.sign could not be shipped to a verifier in COSE form without hand-building the CBOR map. b.cose.exportKey(keyObject, opts?) closes the round-trip: it serializes an EC2 (P-256 / P-384 / P-521) or OKP (Ed25519) public key as the CBOR-encoded COSE_Key map, with optional alg and kid common parameters. A private key has its public half exported; unsupported curves / key types are refused rather than emitting a COSE_Key no verifier here would accept. The bytes round-trip through b.cose.importKey, and feed the mdoc MSO / COSE_Key header / SCITT / C2PA verification-key paths. **Added:** *`b.cose.exportKey(keyObject, { alg?, kid? })` — KeyObject → COSE_Key (RFC 9052 §7)* — Serialize a `node:crypto` public key as the CBOR-encoded COSE_Key map — the inverse of `b.cose.importKey`. Supports EC2 (P-256 / P-384 / P-521) and OKP (Ed25519), the same key types `b.cose.verify` accepts; `opts.alg` (e.g. `"ES256"`) and `opts.kid` populate the COSE_Key alg (label 3) and kid (label 2) common parameters. A private key exports its public half; unsupported curves / key types throw rather than producing a COSE_Key no verifier would accept. `b.cose.importKey(b.cbor.decode(exportKey(k)))` round-trips, so a key signed with `b.cose.sign` can be shipped to a verifier as bytes — the mdoc MSO / COSE_Key header / SCITT / C2PA verification-key paths.
12
+
13
+ - v0.13.20 (2026-05-27) — **`b.archive.wrap` can seal an archive for a tenant with no key-pair to manage — `recipient: "tenant"`.** b.archive.wrap previously sealed only to an explicit hybrid-PQC key-pair or a peer certificate; the documented recipient: "tenant" strategy threw. It now works: pass { recipient: "tenant", tenantId } and the archive is sealed under a deterministic per-tenant key derived from the vault root (SHAKE256 KDF) with XChaCha20-Poly1305, the tenant id mixed into the AEAD additional-authenticated-data so one tenant's envelope cannot be opened under another tenant's key. There is no recipient key-pair for the operator to generate, store, or rotate — b.archive.unwrap re-derives the key from the same tenantId. Rotating the vault re-keys every tenant (rotation intent is re-seal). The derivation is exposed directly as b.agent.tenant.derivedKey(tenantId, purpose) for operators who need the raw per-tenant key for their own AEAD. Requires an initialized vault. **Added:** *`b.archive.wrap` / `b.archive.unwrap` `recipient: "tenant"` — per-tenant archive sealing, no key-pair* — `b.archive.wrap(bytes, { recipient: "tenant", tenantId })` seals under a deterministic per-tenant key derived from the vault root with XChaCha20-Poly1305 (draft-irtf-cfrg-xchacha-03) and a SHAKE256 KDF (FIPS 202); the tenant id is bound into the AEAD AAD so a tenant-A envelope cannot decrypt under tenant-B's key even if an attacker swaps envelope headers. `b.archive.unwrap(sealed, { recipient: "tenant", tenantId })` (or just `{ tenantId }`) re-derives the key and recovers the bytes — no recipient key-pair to manage. The tenant envelope carries a distinct version byte so it is never fed to the hybrid-KEM decrypt path. The static-key and peer-cert recipient strategies are unchanged. · *`b.agent.tenant.derivedKey(tenantId, purpose)` — direct per-tenant key derivation* — The deterministic, domain-separated per-tenant key derivation (vault root + tenantId + purpose, SHAKE256, NUL-separated) is now exported at the module level, returning a 64-char hex key. Previously reachable only as a method on a created tenant manager; operators who need the raw key for their own AEAD can now call it directly. Throws if the vault is not initialized.
14
+
11
15
  - v0.13.19 (2026-05-27) — **`auditTools` export / archive / forensic-snapshot can return the bundle as bytes — no output directory for serverless / read-only filesystems.** b.auditTools.exportSlice, b.auditTools.archive, and b.auditTools.forensicSnapshot required an `out` directory to write the encrypted bundle (rows.enc + optional checkpoint.enc + manifest.json), which is unusable on a read-only or ephemeral serverless filesystem. Each now accepts `returnBytes: true` instead of `out` and returns the bundle as an in-memory `{ filename: Buffer }` map — ready to stream to object storage or over the wire with no filesystem access. `out` and `returnBytes` are mutually exclusive. The on-disk path is unchanged. The bundle's encryption (XChaCha20-Poly1305 + Argon2id), chain-proof material, and manifest checksums are identical to the written bundle, so an in-memory bundle written to disk verifies exactly as one produced by the `out` path. **Added:** *`returnBytes` on `auditTools.exportSlice` / `archive` / `forensicSnapshot` — in-memory bundles* — Pass `returnBytes: true` (and omit `out`) to get the encrypted audit bundle as an in-memory `{ filename: Buffer }` map instead of a directory write — the read-only / serverless path. `exportSlice` / `archive` return `{ manifest, files, rowCount, range }`; `forensicSnapshot` returns `{ ...manifest, files }` where `files` carries the slice's `rows.enc` + `manifest.json` plus the `forensic-snapshot.json` incident wrapper. The encryption, chain proof, and manifest checksums match the on-disk bundle byte-for-byte, so the bytes verify with `verifyBundle` once written out. `out` and `returnBytes` are mutually exclusive (passing both throws). **Fixed:** *`auditTools.forensicSnapshot` now honors the `since` window instead of capturing the entire audit history* — `forensicSnapshot` passed its `since` bound to the slice exporter under the wrong option name, so the time filter was silently dropped and the snapshot bundled every audit row regardless of `since`. The window is now applied — a snapshot scoped to an incident window contains only that window's rows. The snapshot manifest's `auditSliceFile` field, previously always undefined, now records the slice location.
12
16
 
13
17
  - v0.13.18 (2026-05-27) — **`bodyParser` multipart can buffer uploads in memory — no tmp directory for serverless / read-only filesystems.** The multipart/form-data sub-parser previously streamed every file part to a tmp directory on disk (os.tmpdir() by default), which fails on a read-only or ephemeral serverless filesystem. A new multipart.storage option selects where file parts land: "disk" (default, unchanged — req.files[].path points at a tmp file cleaned up on response end) or "memory" (req.files[].buffer holds the assembled bytes, with no filesystem access at all). Both modes enforce the same per-file (fileSize), per-field, and total-request (totalSize) caps, so memory mode adds no new memory-exhaustion surface. The file object shape is stable across both modes — disk sets path with buffer null, memory sets buffer with path null — so a handler branches on whichever is non-null. An invalid storage value is rejected when the middleware is constructed. **Added:** *`bodyParser` multipart `storage: "memory"` — buffer uploads in RAM instead of a tmp directory* — `b.middleware.bodyParser({ multipart: { storage: "memory" } })` buffers each uploaded file part in memory and exposes it as `req.files[].buffer` (a Buffer), with no `os.tmpdir()` write and no tmp-file cleanup — the read-only / serverless path. The default `storage: "disk"` is unchanged: file parts stream to a tmp file, `req.files[].path` points at it, and it is removed when the response finishes. Both modes apply the existing `fileSize` / per-field `maxBytes` / `totalSize` caps and SHA3-512 hash each part during streaming, so memory mode is bounded by the same limits and adds no new DoS surface. The `req.files[]` shape is stable across modes (disk: `path` set, `buffer` null; memory: `buffer` set, `path` null). A `storage` value other than `"disk"` or `"memory"` throws a `TypeError` at construction.
package/README.md CHANGED
@@ -141,7 +141,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
141
141
  - **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
142
142
  - **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
143
143
  - **Binary codec** — `b.cbor` bounded deterministic CBOR (RFC 8949 §4.2): depth/size caps, indefinite-length + reserved-info + tag + duplicate-key refusal, `requireDeterministic` canonical-form check; the in-tree substrate under COSE / CWT / SCITT / WebAuthn attestation
144
- - **COSE messages** — `b.cose` the full RFC 9052 message-type set over `b.cbor`: COSE_Sign1 sign/verify (attached or detached payload), COSE_Encrypt0 single-recipient AEAD, COSE_Mac0 shared-key HMAC (mac0/macVerify0), plus `importKey` (COSE_Key → KeyObject). Signatures use classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; AEAD ChaCha20/Poly1305 default (AES-GCM opt-in); the signed-statement substrate under SCITT / CWT / mdoc / C2PA
144
+ - **COSE messages** — `b.cose` the full RFC 9052 message-type set over `b.cbor`: COSE_Sign1 sign/verify (attached or detached payload), COSE_Encrypt0 single-recipient AEAD, COSE_Mac0 shared-key HMAC (mac0/macVerify0), plus `importKey` (COSE_Key → KeyObject) and `exportKey` (KeyObject → COSE_Key, the inverse — ship a verification key as RFC 9052 §7 bytes). Signatures use classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; AEAD ChaCha20/Poly1305 default (AES-GCM opt-in); the signed-statement substrate under SCITT / CWT / mdoc / C2PA
145
145
  - **CBOR Web Token** — `b.cwt` CWT sign/verify (RFC 8392) over `b.cose`: standard-claim mapping (iss/sub/aud/exp/nbf/iat/cti) + `exp`/`nbf` clock-skew enforcement + `iss`/`aud` matching; the CBOR-native JWT for constrained / IoT / FIDO / verifiable-credential contexts
146
146
  - **Entity Attestation Token** — `b.eat` EAT sign/verify (RFC 9711) over `b.cwt`: device + software attestation claims (ueid / oemid / hwmodel / measurements / submods) with verifier-nonce freshness binding, `dbgstat` debug-status policy, and `eat_profile` pinning
147
147
  - **SCITT signed statements** — `b.scitt` sign/verify a signed, attributable claim about an artifact (signed SBOM, build attestation, release approval) over `b.cose`: the issuer + subject bind in the integrity-protected CWT_Claims header (RFC 9597); verification refuses any statement missing the iss/sub binding. The issuer side, on finalized RFCs; the transparency receipt (COSE Receipts draft) opts in on publication
@@ -228,7 +228,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
228
228
  - **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`)
229
229
  - **CSV** — RFC 4180 with Excel formula-injection prevention (`b.csv`)
230
230
  - **IDs + slugs** — RFC 9562 UUID v4 + v7 (`b.uuid`); URL-safe slugs (`b.slug`)
231
- - **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`)
231
+ - **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`); recipient-sealed envelopes — hybrid-PQC key-pair, peer certificate, or per-tenant key with no key-pair to manage (`b.archive.wrap({ recipient: "tenant", tenantId })`)
232
232
  - **Pagination + forms** — HMAC-signed cursor pagination (`b.pagination`); HTML form rendering + validation + CSRF (`b.forms`)
233
233
 
234
234
  ### Production
@@ -386,6 +386,35 @@ function _deriveTenantKeyBytes(tenantId, purpose) {
386
386
  return bCrypto.kdf(input, TENANT_KEY_BYTES);
387
387
  }
388
388
 
389
+ /**
390
+ * @primitive b.agent.tenant.derivedKey
391
+ * @signature b.agent.tenant.derivedKey(tenantId, purpose)
392
+ * @since 0.9.25
393
+ * @status stable
394
+ * @compliance hipaa, pci-dss, gdpr, soc2
395
+ * @related b.agent.tenant.create, b.archive.wrap, b.vault
396
+ *
397
+ * Derive a deterministic, domain-separated 32-byte key for a tenant
398
+ * and a named purpose, returned as a 64-char hex string. The key is a
399
+ * SHAKE256 KDF over the vault root (the master keypair PEM hashed),
400
+ * the `tenantId`, and the `purpose`, with NUL separators so distinct
401
+ * `(tenantId, purpose)` pairs cannot collide. The same inputs always
402
+ * produce the same key, so a value sealed under
403
+ * `derivedKey(t, "archive-wrap")` is recoverable later from the same
404
+ * tenant + purpose with no key escrow. Rotating the vault
405
+ * (`b.vaultRotate.rotate`) changes the root and therefore every
406
+ * derived key — by design, rotation intent is re-seal.
407
+ *
408
+ * Throws if the vault has not been initialized (keys cannot be derived
409
+ * before bootstrap) or if `purpose` is empty. This is the same
410
+ * derivation the per-tenant `sealField` / archive `recipient: "tenant"`
411
+ * paths use internally; call it directly when you need the raw key for
412
+ * your own AEAD.
413
+ *
414
+ * @example
415
+ * var key = b.agent.tenant.derivedKey("acme-corp", "archive-wrap");
416
+ * // → "9f3c…" (64 hex chars; deterministic per tenant + purpose)
417
+ */
389
418
  function _derivedKey(tenantId, purpose) {
390
419
  // Public API — returns hex so the existing wire shape (operators
391
420
  // storing the derived key string in their DB) is unchanged. Internal
@@ -624,6 +653,7 @@ function _inMemoryBackend() {
624
653
 
625
654
  module.exports = {
626
655
  create: create,
656
+ derivedKey: _derivedKey,
627
657
  CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
628
658
  AgentTenantError: AgentTenantError,
629
659
  guards: {
@@ -15,10 +15,11 @@
15
15
  * var bytes = b.archive.unwrap(sealed, { recipient: privKeys });
16
16
  * var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
17
17
  *
18
- * Builder-fluent composition (`tarBuilder.toAdapter(s3, { wrap: ... })`)
19
- * + per-entry ZIP wrap (Flavor 2) land in v0.12.11 alongside the
20
- * backup-crypto refactor; this patch ships the recipient substrate
21
- * + the b.backup `cryptoStrategy: "recipient"` opt that consumes it.
18
+ * Three recipient strategies: a static hybrid-PQC key-pair, a peer
19
+ * certificate, and `"tenant"` a deterministic per-tenant symmetric
20
+ * seal keyed by the vault root (no key-pair to manage; unwrap
21
+ * re-derives from the tenant id). b.backup's `cryptoStrategy:
22
+ * "recipient"` consumes the same substrate.
22
23
  */
23
24
 
24
25
  var C = require("./constants");
@@ -29,13 +30,23 @@ var ArchiveWrapError = defineClass("ArchiveWrapError", { alwaysPermanent: true }
29
30
 
30
31
  var bCrypto = lazyRequire(function () { return require("./crypto"); });
31
32
  var backupCrypto = lazyRequire(function () { return require("./backup/crypto"); });
33
+ var agentTenant = lazyRequire(function () { return require("./agent-tenant"); });
32
34
 
33
35
  // Envelope magic — 5-byte ASCII prefix the safe-archive sniffer
34
36
  // recognises. Distinct from b.crypto.encrypt's base64 envelope so
35
37
  // archive-wrap output can carry an unambiguous "this is an archive
36
38
  // wrap envelope" magic before the operator-controlled payload.
37
39
  var ARCH_WRAP_MAGIC = "BAWRP"; // allow:raw-byte-literal — 5-byte ASCII archive-wrap recipient envelope magic
38
- var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — recipient version byte
40
+ var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — recipient version byte (hybrid-KEM envelope)
41
+ // Tenant strategy uses the same BAWRP magic with a distinct version
42
+ // byte: the body is a symmetric XChaCha20-Poly1305 packed ciphertext
43
+ // (b.crypto.encryptPacked) keyed by the tenant's vault-derived key,
44
+ // not a hybrid-KEM envelope. unwrap dispatches on the version byte so
45
+ // a tenant envelope is never fed to the KEM decrypt path.
46
+ var ARCH_WRAP_VERSION_TENANT = 0x02; // allow:raw-byte-literal — tenant symmetric-seal version byte
47
+ // Purpose label for the per-tenant key derivation (domain-separates
48
+ // the archive-wrap key from a tenant's seal / audit / session keys).
49
+ var TENANT_KEY_PURPOSE = "archive-wrap";
39
50
  var ARCH_WRAP_HEADER_BYTES = C.BYTES.bytes(6); // magic(5) + version(1)
40
51
  // Passphrase variant — wire format: magic(5) + version(1) + saltLen(1)
41
52
  // + salt(saltLen bytes) + encrypted bytes (24-byte nonce + ciphertext+tag
@@ -66,10 +77,14 @@ var ARCH_PASSPHRASE_HEADER_BYTES = C.BYTES.bytes(7);
66
77
  * - peer cert — `{ recipient: { peerCertDer, peerKemPubkey } }` composes
67
78
  * `b.crypto.encryptEnvelopeAsCertPeer` (extracts the
68
79
  * P-384 half from the cert).
69
- * - tenant — `{ recipient: "tenant", tenantId: "alpha" }` resolves
70
- * the tenant's KEM keypair via `b.vault.derivedKey`
71
- * (deferred to v0.12.11 alongside the backup
72
- * `cryptoStrategy: "recipient"` adoption).
80
+ * - tenant — `{ recipient: "tenant", tenantId: "alpha" }` seals
81
+ * under a deterministic per-tenant key derived from the
82
+ * vault root (`b.agent.tenant.derivedKey`) with
83
+ * XChaCha20-Poly1305, the tenant id mixed into the AEAD
84
+ * AAD so one tenant's envelope cannot open under
85
+ * another's key. No recipient key-pair to manage;
86
+ * `unwrap` re-derives from the same `tenantId`. Requires
87
+ * an initialized vault.
73
88
  *
74
89
  * @opts
75
90
  * recipient: object | string, // see strategies above; required
@@ -96,15 +111,16 @@ function wrap(bytes, opts) {
96
111
  throw new ArchiveWrapError("archive-wrap/no-recipient",
97
112
  "wrap: opts.recipient is required (static key object | \"tenant\" string | peer-cert object)");
98
113
  }
99
- var envelope = _encryptForRecipient(bytes, opts);
100
- // envelope is a base64 string from b.crypto.encrypt. Buffer it and
101
- // prepend the 6-byte archive-wrap header so safeArchive's sniffer
102
- // can identify it without attempting decryption.
103
- var envelopeBuf = Buffer.from(envelope, "utf-8");
114
+ var enc = _encryptForRecipient(bytes, opts);
115
+ // enc.body is the envelope bytes (base64 KEM envelope for static /
116
+ // peer-cert recipients, symmetric packed ciphertext for tenant).
117
+ // Prepend the 6-byte archive-wrap header stamped with the strategy's
118
+ // version byte so safeArchive's sniffer + unwrap can identify the
119
+ // envelope (and pick the right decrypt path) without trial decryption.
104
120
  var header = Buffer.alloc(ARCH_WRAP_HEADER_BYTES);
105
121
  header.write(ARCH_WRAP_MAGIC, 0, 5, "ascii");
106
- header[5] = ARCH_WRAP_VERSION;
107
- return Buffer.concat([header, envelopeBuf]);
122
+ header[5] = enc.version;
123
+ return Buffer.concat([header, enc.body]);
108
124
  }
109
125
 
110
126
  /**
@@ -121,11 +137,14 @@ function wrap(bytes, opts) {
121
137
  * than a crypto-level error.
122
138
  *
123
139
  * @opts
124
- * recipient: object, // { privateKey, ecPrivateKey } | { certPrivateKey, kemSecret }; required
140
+ * recipient: object | "tenant", // { privateKey, ecPrivateKey } | { certPrivateKey, kemSecret } | "tenant"
141
+ * tenantId: string, // required when the envelope was sealed with recipient: "tenant"
125
142
  *
126
143
  * @example
127
144
  * var bytes = b.archive.unwrap(sealed, { recipient: privPair });
128
145
  * var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
146
+ * // tenant envelope:
147
+ * var t = b.archive.unwrap(sealedForTenant, { recipient: "tenant", tenantId: "alpha" });
129
148
  */
130
149
  function unwrap(sealed, opts) {
131
150
  opts = opts || {};
@@ -145,6 +164,27 @@ function unwrap(sealed, opts) {
145
164
  JSON.stringify(ARCH_WRAP_MAGIC) + "; got " + JSON.stringify(magic));
146
165
  }
147
166
  var version = buf[5];
167
+ // Tenant strategy: symmetric packed ciphertext keyed by the
168
+ // vault-derived per-tenant key. Re-derive from opts.tenantId and
169
+ // decrypt under the same tenant-bound AAD that wrap sealed with.
170
+ if (version === ARCH_WRAP_VERSION_TENANT) {
171
+ if (opts.recipient !== undefined && opts.recipient !== "tenant") {
172
+ throw new ArchiveWrapError("archive-wrap/recipient-mismatch",
173
+ "unwrap: this envelope was sealed with recipient: \"tenant\" — pass opts.tenantId " +
174
+ "(and either omit opts.recipient or set it to \"tenant\"), not a key-pair recipient");
175
+ }
176
+ var tenantKey = _tenantKey(opts.tenantId);
177
+ var packedBody = buf.slice(ARCH_WRAP_HEADER_BYTES);
178
+ try {
179
+ return bCrypto().decryptPacked(packedBody, tenantKey, _tenantAad(opts.tenantId));
180
+ } catch (e) {
181
+ var terr = new ArchiveWrapError("archive-wrap/decrypt-failed",
182
+ "unwrap: tenant envelope decryption refused (wrong tenantId or rotated vault?): " +
183
+ ((e && e.message) || String(e)));
184
+ terr.cause = e;
185
+ throw terr;
186
+ }
187
+ }
148
188
  if (version !== ARCH_WRAP_VERSION) {
149
189
  throw new ArchiveWrapError("archive-wrap/bad-version",
150
190
  "unwrap: archive-wrap version " + version + " not supported by this build");
@@ -152,7 +192,8 @@ function unwrap(sealed, opts) {
152
192
  if (!opts.recipient || typeof opts.recipient !== "object") {
153
193
  throw new ArchiveWrapError("archive-wrap/no-recipient",
154
194
  "unwrap: opts.recipient is required ({ privateKey, ecPrivateKey } " +
155
- "for the static-key path, { certPrivateKey, kemSecret } for the peer-cert path)");
195
+ "for the static-key path, { certPrivateKey, kemSecret } for the peer-cert path, " +
196
+ "or \"tenant\" + opts.tenantId for the tenant path)");
156
197
  }
157
198
  var envelope = buf.slice(ARCH_WRAP_HEADER_BYTES).toString("utf-8");
158
199
  var plaintext;
@@ -183,28 +224,52 @@ function unwrap(sealed, opts) {
183
224
  return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
184
225
  }
185
226
 
227
+ // Resolve a tenant's deterministic 32-byte archive-wrap key from the
228
+ // vault root. Throws a clear archive-wrap error (rather than a deep
229
+ // agent-tenant one) when tenantId is missing; the vault-not-initialized
230
+ // case surfaces from agentTenant.derivedKey unchanged.
231
+ function _tenantKey(tenantId) {
232
+ if (typeof tenantId !== "string" || tenantId.length === 0) {
233
+ throw new ArchiveWrapError("archive-wrap/no-tenant-id",
234
+ "recipient: \"tenant\" requires opts.tenantId (a non-empty string)");
235
+ }
236
+ return Buffer.from(agentTenant().derivedKey(tenantId, TENANT_KEY_PURPOSE), "hex");
237
+ }
238
+
239
+ // AAD context-binds the symmetric envelope to the tenant: the Poly1305
240
+ // tag covers this, so a tenant-A envelope cannot be decrypted under
241
+ // tenant-B's key even if an attacker swaps headers between envelopes.
242
+ function _tenantAad(tenantId) {
243
+ return Buffer.from("archive-wrap|tenant|" + tenantId, "utf8");
244
+ }
245
+
246
+ // Returns { version, body } so wrap() can stamp the right version byte:
247
+ // hybrid-KEM recipients use ARCH_WRAP_VERSION with a base64 envelope
248
+ // body; the tenant strategy uses ARCH_WRAP_VERSION_TENANT with a
249
+ // symmetric packed-ciphertext body.
186
250
  function _encryptForRecipient(bytes, opts) {
187
251
  var r = opts.recipient;
188
252
  if (typeof r === "string") {
189
253
  if (r === "tenant") {
190
- // tenant strategy lands in v0.12.11 alongside the backup
191
- // cryptoStrategy adoption refuse cleanly for v0.12.10 so
192
- // operators see the deferred-shape contract.
193
- throw new ArchiveWrapError("archive-wrap/tenant-strategy-deferred",
194
- "wrap: recipient: \"tenant\" lands in v0.12.11 alongside b.backup cryptoStrategy: \"recipient\" + per-tenant key resolution. For v0.12.10, pass an explicit { publicKey, ecPublicKey } recipient");
254
+ var tenantKey = _tenantKey(opts.tenantId);
255
+ var packed = bCrypto().encryptPacked(Buffer.from(bytes), tenantKey, _tenantAad(opts.tenantId));
256
+ return { version: ARCH_WRAP_VERSION_TENANT, body: packed };
195
257
  }
196
258
  throw new ArchiveWrapError("archive-wrap/bad-recipient",
197
- "wrap: recipient string " + JSON.stringify(r) + " not recognised; \"tenant\" deferred to v0.12.11");
259
+ "wrap: recipient string " + JSON.stringify(r) + " not recognised; the only string recipient is \"tenant\" (with opts.tenantId)");
198
260
  }
199
261
  if (r.peerCertDer || r.peerKemPubkey) {
200
262
  if (!r.peerCertDer || !r.peerKemPubkey) {
201
263
  throw new ArchiveWrapError("archive-wrap/bad-recipient",
202
264
  "wrap: peer-cert strategy requires BOTH peerCertDer + peerKemPubkey");
203
265
  }
204
- return bCrypto().encryptEnvelopeAsCertPeer(bytes, {
205
- peerCertDer: r.peerCertDer,
206
- peerKemPubkey: r.peerKemPubkey,
207
- });
266
+ return {
267
+ version: ARCH_WRAP_VERSION,
268
+ body: Buffer.from(bCrypto().encryptEnvelopeAsCertPeer(bytes, {
269
+ peerCertDer: r.peerCertDer,
270
+ peerKemPubkey: r.peerKemPubkey,
271
+ }), "utf-8"),
272
+ };
208
273
  }
209
274
  if (r.publicKey) {
210
275
  // Codex P2 on v0.12.10 PR #161 — b.crypto.encrypt falls back to
@@ -221,10 +286,13 @@ function _encryptForRecipient(bytes, opts) {
221
286
  "and ecPublicKey (P-384 ECDH PEM). Partial recipients trip b.crypto.encrypt's " +
222
287
  "ML-KEM-only fallback which silently degrades the hybrid contract this primitive promises.");
223
288
  }
224
- return bCrypto().encrypt(bytes, {
225
- publicKey: r.publicKey,
226
- ecPublicKey: r.ecPublicKey,
227
- });
289
+ return {
290
+ version: ARCH_WRAP_VERSION,
291
+ body: Buffer.from(bCrypto().encrypt(bytes, {
292
+ publicKey: r.publicKey,
293
+ ecPublicKey: r.ecPublicKey,
294
+ }), "utf-8"),
295
+ };
228
296
  }
229
297
  throw new ArchiveWrapError("archive-wrap/bad-recipient",
230
298
  "wrap: recipient must be { publicKey, ecPublicKey } | { peerCertDer, peerKemPubkey } | \"tenant\"");
package/lib/cose.js CHANGED
@@ -743,9 +743,15 @@ function macVerify0(coseMac0, opts) {
743
743
  // secp256k1 key be verified under ES256, breaking the COSE alg/curve
744
744
  // binding (RFC 9053). Re-add with an explicit ES256K algorithm.
745
745
  var COSE_EC2_CRV = { 1: "P-256", 2: "P-384", 3: "P-521" };
746
+ // Reverse of COSE_EC2_CRV — JWK crv name → COSE EC2 curve id, for exportKey.
747
+ var COSE_EC2_CRV_ID = { "P-256": 1, "P-384": 2, "P-521": 3 };
746
748
  var COSE_KTY_OKP = 1;
747
749
  var COSE_KTY_EC2 = 2;
748
750
  var COSE_OKP_ED25519 = 6; // allow:raw-byte-literal — COSE OKP Ed25519 crv id (RFC 9053)
751
+ // COSE_Key common-parameter labels (RFC 9052 §7.1): 1=kty, 2=kid, 3=alg.
752
+ var COSE_KEY_LABEL_KTY = 1;
753
+ var COSE_KEY_LABEL_KID = 2;
754
+ var COSE_KEY_LABEL_ALG = 3;
749
755
 
750
756
  function _coseKeyBytes(v, what) {
751
757
  if (Buffer.isBuffer(v)) return v;
@@ -804,6 +810,83 @@ function importKey(coseKey) {
804
810
  catch (e) { throw new CoseError("cose/bad-cose-key", "cose.importKey: could not import COSE_Key: " + ((e && e.message) || e)); }
805
811
  }
806
812
 
813
+ /**
814
+ * @primitive b.cose.exportKey
815
+ * @signature b.cose.exportKey(keyObject, opts?)
816
+ * @since 0.13.20
817
+ * @status stable
818
+ * @related b.cose.importKey, b.cose.verify, b.cbor.encode
819
+ *
820
+ * Serialize a <code>node:crypto</code> public key as a COSE_Key
821
+ * (RFC 9052 §7) — the CBOR-map, integer-labelled form embedded in an
822
+ * mdoc MSO, a COSE_Key header, or a SCITT / C2PA verification-key
823
+ * field. The inverse of <code>b.cose.importKey</code>: a key signed
824
+ * with <code>b.cose.sign</code> can be shipped to a verifier as bytes
825
+ * and re-imported. Returns the CBOR-encoded bytes (like the other COSE
826
+ * producers); pass the decoded map to <code>importKey</code> to round-trip.
827
+ *
828
+ * Accepts the same key types <code>importKey</code> / <code>verify</code>
829
+ * understand: EC2 (P-256 / P-384 / P-521) and OKP (Ed25519). A private
830
+ * key has its public half exported. Other curves / key types are
831
+ * refused rather than emitting a COSE_Key no verifier here would accept.
832
+ *
833
+ * @opts
834
+ * alg: string, // optional COSE alg label (e.g. "ES256") → COSE_Key label 3
835
+ * kid: Buffer | string, // optional key id → COSE_Key label 2 (bstr; string encoded UTF-8)
836
+ *
837
+ * @example
838
+ * var bytes = b.cose.exportKey(pubKey, { alg: "ES256", kid: "key-1" });
839
+ * var key = b.cose.importKey(b.cbor.decode(bytes)); // round-trips
840
+ */
841
+ function exportKey(keyObject, opts) {
842
+ opts = opts || {};
843
+ if (!keyObject || typeof keyObject.export !== "function" || typeof keyObject.type !== "string") {
844
+ throw new CoseError("cose/bad-key", "cose.exportKey: expected a node:crypto KeyObject");
845
+ }
846
+ // A private key exports its public half — a COSE_Key here is a
847
+ // verification key, never the secret.
848
+ var pub = keyObject.type === "private" ? nodeCrypto.createPublicKey(keyObject) : keyObject;
849
+ var jwk;
850
+ try { jwk = pub.export({ format: "jwk" }); }
851
+ catch (e) { throw new CoseError("cose/bad-key", "cose.exportKey: could not export key to JWK: " + ((e && e.message) || e)); }
852
+
853
+ var coseKey = new Map();
854
+ if (jwk.kty === "OKP") {
855
+ if (jwk.crv !== "Ed25519") {
856
+ throw new CoseError("cose/unsupported-key", "cose.exportKey: only OKP curve Ed25519 is supported (got " + jwk.crv + ")");
857
+ }
858
+ coseKey.set(COSE_KEY_LABEL_KTY, COSE_KTY_OKP);
859
+ coseKey.set(-1, COSE_OKP_ED25519);
860
+ coseKey.set(-2, Buffer.from(jwk.x, "base64url"));
861
+ } else if (jwk.kty === "EC") {
862
+ var crvId = COSE_EC2_CRV_ID[jwk.crv];
863
+ if (!crvId) {
864
+ throw new CoseError("cose/unsupported-key", "cose.exportKey: unsupported EC curve " + jwk.crv + " (only P-256 / P-384 / P-521)");
865
+ }
866
+ coseKey.set(COSE_KEY_LABEL_KTY, COSE_KTY_EC2);
867
+ coseKey.set(-1, crvId);
868
+ coseKey.set(-2, Buffer.from(jwk.x, "base64url"));
869
+ coseKey.set(-3, Buffer.from(jwk.y, "base64url"));
870
+ } else {
871
+ throw new CoseError("cose/unsupported-key", "cose.exportKey: kty must be OKP or EC (got " + jwk.kty + ")");
872
+ }
873
+
874
+ if (opts.kid !== undefined) {
875
+ var kid = Buffer.isBuffer(opts.kid) ? opts.kid
876
+ : (typeof opts.kid === "string" ? Buffer.from(opts.kid, "utf8") : null);
877
+ if (!kid) throw new CoseError("cose/bad-kid", "cose.exportKey: opts.kid must be a Buffer or string");
878
+ coseKey.set(COSE_KEY_LABEL_KID, kid);
879
+ }
880
+ if (opts.alg !== undefined) {
881
+ var algId = ALG_NAME_TO_ID[opts.alg];
882
+ if (algId === undefined) {
883
+ throw new CoseError("cose/unknown-alg", "cose.exportKey: opts.alg '" + opts.alg + "' not recognized");
884
+ }
885
+ coseKey.set(COSE_KEY_LABEL_ALG, algId);
886
+ }
887
+ return cbor.encode(coseKey);
888
+ }
889
+
807
890
  module.exports = {
808
891
  sign: sign,
809
892
  verify: verify,
@@ -812,6 +895,7 @@ module.exports = {
812
895
  mac0: mac0,
813
896
  macVerify0: macVerify0,
814
897
  importKey: importKey,
898
+ exportKey: exportKey,
815
899
  ALGORITHMS: ALG_NAME_TO_ID,
816
900
  MAC_ALGORITHMS: HMAC_NAME_TO_ID,
817
901
  COSE_MAC0_TAG: COSE_MAC0_TAG,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.19",
3
+ "version": "0.13.21",
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:bcf724d0-38ac-437e-86b3-47da5e8b72dc",
5
+ "serialNumber": "urn:uuid:28562fda-52a1-4d96-ad78-e3c056763938",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-27T21:23:48.501Z",
8
+ "timestamp": "2026-05-27T23:30:24.926Z",
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.13.19",
22
+ "bom-ref": "@blamejs/core@0.13.21",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.19",
25
+ "version": "0.13.21",
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.13.19",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.21",
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.13.19",
57
+ "ref": "@blamejs/core@0.13.21",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]