@blamejs/blamejs-shop 0.2.8 → 0.2.10
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 +4 -0
- package/lib/asset-manifest.json +9 -5
- package/lib/storefront.js +19 -18
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +2 -2
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/lib/agent-tenant.js +30 -0
- package/lib/vendor/blamejs/lib/archive-read.js +53 -40
- package/lib/vendor/blamejs/lib/archive-wrap.js +100 -32
- package/lib/vendor/blamejs/lib/cose.js +84 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.20.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.21.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.22.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +50 -7
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +10 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cose.test.js +42 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.10 (2026-05-27) — **Accessibility and mobile polish — the cookie bar stops covering the page, and forms behave on phones.** A round of default-theme refinements focused on the cookie consent bar, mobile ergonomics, and accessibility. The consent bar previously floated over page content — on a product page or checkout it could sit on top of the price, variant picker, and the add-to-cart / continue-to-payment button on a first visit. It is now a compact bottom bar that reserves its own space (so nothing important is ever hidden under it), scrolls within itself if the text is long, and receives keyboard focus when it appears. Alongside it: mobile form inputs no longer trigger iOS zoom-on-focus, the header touch targets meet the 44px minimum, inline text links are underlined, and a consistency pass tightened button shapes, table-label typography, and small-screen layout. **Changed:** *Visual consistency pass* — Action buttons share a single corner radius (primary and secondary no longer differ in shape), uppercase micro-labels use the monospace face consistently across tables and forms, the catalog grid shows two columns on small phones instead of one, the hero stat row breathes on narrow screens, and a few dark-theme borders/placeholders that read muddy were moved onto the semantic token palette. **Fixed:** *Cookie consent bar no longer covers page content* — On a first visit the consent bar could overlap the product buy box and the checkout form (the price, variant selector, and primary button). It is now a compact bottom-docked bar that reserves matching space at the foot of the page until a choice is made, caps its height and scrolls internally for long text, and moves keyboard focus to its first control when shown — so the page's primary action is never hidden and the bar is reachable without tabbing the whole page. · *Mobile form inputs no longer zoom on iOS* — Text inputs, selects, and textareas render at a 16px minimum on touch devices, so mobile Safari no longer zooms the viewport when a field is focused (which previously left the page zoomed-in and pannable mid-checkout). · *Header touch targets meet the 44px minimum on mobile* — The account and cart controls in the header — the two primary controls on a phone — are now at least 44×44px. · *Clearer link and prompt-glyph affordances* — Inline text links in body copy are underlined (no longer distinguished by color alone), and the terminal prompt glyph on the dark accent bands renders in full contrast.
|
|
12
|
+
|
|
13
|
+
- v0.2.9 (2026-05-27) — **Storefront read pages stay fast for every visitor — the cart badge updates client-side.** Restores full edge-render speed for returning and signed-in shoppers. Every storefront read page (home, product listing, search, blog) now renders at the edge for all visitors again; the nav cart badge renders server-side and a tiny client island corrects the number from a new lightweight endpoint. The result: the fast cached page AND an accurate cart count, without sending a shopper-with-a-cart through the slower origin on every page. **Added:** *`GET /cart/count` + a cart-count island* — A new endpoint returns the current session's cart line count as JSON (`{"count": N}`), cached `no-store`, with no product hydration. A small deferred same-origin script (`assets/js/cart-count.js`) reads it on load and updates the nav badge text and its aria-label. A visitor with JavaScript off keeps the server-rendered badge. The script is a same-origin external file, so the existing `script-src 'self'` policy already permits it — no CSP change required. **Fixed:** *Fast read pages for shoppers who have a session* — A visitor carrying a cart or sign-in cookie is once again served the edge-rendered storefront pages instead of being routed to the origin container, so the home page and other read pages load quickly for returning visitors, not just first-time guests. The nav cart count stays correct via the island below.
|
|
14
|
+
|
|
11
15
|
- v0.2.8 (2026-05-27) — **A complete shipping address at checkout, and an accurate cart count on every page.** Two storefront fixes. Checkout now collects a real, shippable address — a street line, an optional apt/suite line, and a city — alongside the region, postal code, and country it already took; a signed-in customer's default saved address pre-fills the form, the address shows on the order confirmation, and the order export carries it so fulfilment has everything it needs. Separately, the navigation cart badge now shows the correct item count on the catalog, product, search, and blog pages — not only on the cart and account pages — by rendering those pages for a shopper-with-a-cart on the path that can read their session. **Changed:** *Checkout collects a complete shipping address* — The shipping step now takes a street address (line 1, plus an optional apartment / suite / unit line) and a city in addition to the region, postal code, and country it already collected, so a placed order carries an address you can actually ship to. Street line and city are marked required for the common physical-goods path; the server treats them as optional so a digital-only order still completes. A signed-in customer's default saved address pre-fills the form. · *Shipping address on the confirmation page and in the order export* — The order confirmation now shows the ship-to address, and the order CSV / NDJSON export gains `shipping_line1`, `shipping_line2`, and `shipping_city` columns (inserted after `customer_name`) so a fulfilment handoff has the full address. Consumers that key on the header name are unaffected; a consumer that keys on absolute column position should re-map to the 27-column layout. **Fixed:** *Cart count is correct on every page* — The nav cart badge previously read zero on edge-rendered pages (home, product, search, blog) for a shopper who had items in their cart, while the cart and account pages showed the real count. A request that carries a session cookie is now rendered on the path that can unseal the session and read the cart, so the badge is consistent everywhere. Guests — who have no cart — still get the fast cached render with a count of zero, which is correct for them.
|
|
12
16
|
|
|
13
17
|
- v0.2.7 (2026-05-27) — **Default-theme brand polish — broader favicon support and a larger logo.** Small refinements to the bundled default theme's branding. Every page head now advertises a PNG favicon and an Apple touch icon alongside the existing SVG favicon, so the site icon renders correctly on Safari / iOS home screens and on browsers that don't support SVG favicons, plus a theme-color so the mobile browser chrome matches the dark canvas. The header and footer logo are rendered larger for more presence. These affect the default theme only; a custom theme passed via the theme primitive is unchanged. **Changed:** *Fuller favicon + icon metadata* — The page head now includes a PNG favicon and an `apple-touch-icon` (Apple home-screen icons require PNG — SVG favicons aren't used there) in addition to the SVG favicon, plus a `theme-color` meta for the mobile browser UI. Emitted on every page across both the edge and container render paths. · *Larger header and footer logo* — The default theme renders the brand logo at a larger size in the header and footer for clearer presence.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.10",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-FgqvXcZygYkJjjOtXAeu9xi6AtYqkchPnJKcS0OeHm8lynhd9r4RfdpoT7P7j7/l",
|
|
6
6
|
"fingerprinted": "css/admin.afd7b964c1f6fe1b.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-989Th+84GsswrtPWc4g7DImIYKE/nUSgpZcQMeYxhPN1tg3lrgBhjQNKHNqwz7kb",
|
|
10
|
+
"fingerprinted": "css/main.4504ec0c542c3225.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
|
14
14
|
"fingerprinted": "js/announcement.59eab4872d2f3b4c.js"
|
|
15
15
|
},
|
|
16
|
+
"js/cart-count.js": {
|
|
17
|
+
"integrity": "sha384-K/rkm//Dzg8nuOfpaeenJvLKKl+6DEuvuJi1LLgd46BK+dd1HYmU8R7/gHHjtEsr",
|
|
18
|
+
"fingerprinted": "js/cart-count.bfc2eb434b19c00a.js"
|
|
19
|
+
},
|
|
16
20
|
"js/consent.js": {
|
|
17
|
-
"integrity": "sha384-
|
|
18
|
-
"fingerprinted": "js/consent.
|
|
21
|
+
"integrity": "sha384-XY3GHA5QDj/Nri03ZO1t7nsr9RGepZuanY45CTpFQnnTorQnygbtAVG4vVGfkNIP",
|
|
22
|
+
"fingerprinted": "js/consent.59060d7723d050b2.js"
|
|
19
23
|
},
|
|
20
24
|
"js/passkey-login.js": {
|
|
21
25
|
"integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
|
package/lib/storefront.js
CHANGED
|
@@ -266,6 +266,7 @@ var LAYOUT =
|
|
|
266
266
|
" </footer>\n" +
|
|
267
267
|
CONSENT_BANNER +
|
|
268
268
|
"RAW_CONSENT_SCRIPT" +
|
|
269
|
+
"RAW_CART_COUNT_SCRIPT" +
|
|
269
270
|
"RAW_ANNOUNCEMENT_SCRIPT" +
|
|
270
271
|
"</body>\n" +
|
|
271
272
|
"</html>\n";
|
|
@@ -621,6 +622,7 @@ function _wrap(opts) {
|
|
|
621
622
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
622
623
|
.replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
|
|
623
624
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
625
|
+
.replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
|
|
624
626
|
.replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
|
|
625
627
|
.replace("RAW_CURRENCY_SWITCHER", switcherHtml)
|
|
626
628
|
.replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
|
|
@@ -5848,15 +5850,7 @@ function mount(router, deps) {
|
|
|
5848
5850
|
}
|
|
5849
5851
|
}
|
|
5850
5852
|
|
|
5851
|
-
var
|
|
5852
|
-
var cartCount = 0;
|
|
5853
|
-
if (sid) {
|
|
5854
|
-
var c = await deps.cart.bySession(sid);
|
|
5855
|
-
if (c) {
|
|
5856
|
-
var lines = await deps.cart.listLines(c.id);
|
|
5857
|
-
cartCount = lines.length;
|
|
5858
|
-
}
|
|
5859
|
-
}
|
|
5853
|
+
var cartCount = await _cartCountForReq(req);
|
|
5860
5854
|
var ccy = await _currencyForReq(req);
|
|
5861
5855
|
_send(res, 200, renderSearch(Object.assign({
|
|
5862
5856
|
q: q,
|
|
@@ -5886,15 +5880,7 @@ function mount(router, deps) {
|
|
|
5886
5880
|
// variant-select interaction we don't ship yet.
|
|
5887
5881
|
var media = await deps.catalog.media.listForProduct(product.id);
|
|
5888
5882
|
// Render cart count from the current session's cart, if any.
|
|
5889
|
-
var
|
|
5890
|
-
var cartCount = 0;
|
|
5891
|
-
if (sid) {
|
|
5892
|
-
var c = await deps.cart.bySession(sid);
|
|
5893
|
-
if (c) {
|
|
5894
|
-
var lines = await deps.cart.listLines(c.id);
|
|
5895
|
-
cartCount = lines.length;
|
|
5896
|
-
}
|
|
5897
|
-
}
|
|
5883
|
+
var cartCount = await _cartCountForReq(req);
|
|
5898
5884
|
// Published reviews aggregate + list. A failed read (e.g. the
|
|
5899
5885
|
// reviews table not yet migrated) degrades to the empty state
|
|
5900
5886
|
// rather than 500-ing the whole PDP — reviews are supplementary
|
|
@@ -6298,6 +6284,21 @@ function mount(router, deps) {
|
|
|
6298
6284
|
});
|
|
6299
6285
|
}
|
|
6300
6286
|
|
|
6287
|
+
// Nav cart-badge count for the cart-count island. Every storefront
|
|
6288
|
+
// chrome page server-renders the badge (0 on the cookie-less edge-cached
|
|
6289
|
+
// render); the island fetches this on load and corrects the number for a
|
|
6290
|
+
// visitor whose sealed `shop_sid` the edge can't read. JSON only — one
|
|
6291
|
+
// session lookup, no product hydration — and never cached (a per-session
|
|
6292
|
+
// value must not land in a shared cache).
|
|
6293
|
+
router.get("/cart/count", async function (req, res) {
|
|
6294
|
+
var count = await _cartCountForReq(req);
|
|
6295
|
+
res.status(200);
|
|
6296
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
6297
|
+
res.setHeader && res.setHeader("cache-control", "no-store");
|
|
6298
|
+
var payload = JSON.stringify({ count: count });
|
|
6299
|
+
return res.end ? res.end(payload) : res.send(payload);
|
|
6300
|
+
});
|
|
6301
|
+
|
|
6301
6302
|
router.get("/cart", async function (req, res) {
|
|
6302
6303
|
var ccy = await _currencyForReq(req);
|
|
6303
6304
|
var sid = _readSidCookie(req);
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.13.
|
|
7
|
-
"tag": "v0.13.
|
|
6
|
+
"version": "0.13.22",
|
|
7
|
+
"tag": "v0.13.22",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"server": "lib/vendor/blamejs/"
|
|
14
14
|
},
|
|
15
15
|
"bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
|
|
16
|
-
"bundledAt": "2026-05-
|
|
16
|
+
"bundledAt": "2026-05-28"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.22 (2026-05-27) — **`b.archive.read.zip.fromTrustedStream` reads a ZIP from a Readable — no longer an experimental stub.** fromTrustedStream was an experimental stub whose inspect / entries / extract methods threw, forcing callers to buffer the stream themselves and use the random-access reader. It now works, with the same shape as the tar trusted-stream reader: pass b.archive.adapters.trustedStream(readable) and the bytes are collected into a size-capped buffer (1 GiB hard ceiling) and read through the same bomb-cap, path-traversal, and entry-type decode as the random-access reader — so bombPolicy, guardProfile, entryTypePolicy, and audit all apply, and inspect / entries / extract / extractEntries all return data. This is a bounded-memory reader (the archive is held in memory under the ceiling), not zero-buffer streaming; a future forward-inflate walker shared with the tar reader would lift the ceiling. **Added:** *`b.archive.read.zip.fromTrustedStream` now reads — `inspect` / `entries` / `extract` / `extractEntries`* — The ZIP trusted-stream reader is implemented (was an experimental stub that threw). Pass `b.archive.adapters.trustedStream(readable)` to read a ZIP straight from a Node Readable without buffering it yourself. The stream is collected into a size-capped buffer (1 GiB ceiling, matching `b.archive.read.tar`'s trusted-stream reader) and decoded through the same adversarial-safe path as the random-access reader, so `bombPolicy` / `guardProfile` / `entryTypePolicy` / `audit` are honored on decode. Adversarial archives remain fully bomb-capped; "trusted" refers only to the source-size bound. A non-trusted-stream adapter is refused with `archive-read/bad-adapter`.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
11
17
|
- 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
18
|
|
|
13
19
|
- 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.
|
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.13.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.13.22",
|
|
4
|
+
"createdAt": "2026-05-27T23:32:55.833Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -992,6 +992,10 @@
|
|
|
992
992
|
"type": "function",
|
|
993
993
|
"arity": 1
|
|
994
994
|
},
|
|
995
|
+
"derivedKey": {
|
|
996
|
+
"type": "function",
|
|
997
|
+
"arity": 2
|
|
998
|
+
},
|
|
995
999
|
"guards": {
|
|
996
1000
|
"type": "object",
|
|
997
1001
|
"members": {
|
|
@@ -13663,6 +13667,10 @@
|
|
|
13663
13667
|
"type": "function",
|
|
13664
13668
|
"arity": 2
|
|
13665
13669
|
},
|
|
13670
|
+
"exportKey": {
|
|
13671
|
+
"type": "function",
|
|
13672
|
+
"arity": 2
|
|
13673
|
+
},
|
|
13666
13674
|
"importKey": {
|
|
13667
13675
|
"type": "function",
|
|
13668
13676
|
"arity": 1
|
|
@@ -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: {
|
|
@@ -51,6 +51,8 @@ var ArchiveReadError = defineClass("ArchiveReadError", { alwaysPermanent: true }
|
|
|
51
51
|
var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
|
|
52
52
|
var guardArchive = lazyRequire(function () { return require("./guard-archive"); });
|
|
53
53
|
var safeDecompress = lazyRequire(function () { return require("./safe-decompress"); });
|
|
54
|
+
var safeBuffer = lazyRequire(function () { return require("./safe-buffer"); });
|
|
55
|
+
var archiveAdapters = lazyRequire(function () { return require("./archive-adapters"); });
|
|
54
56
|
|
|
55
57
|
// ---- Wire-format constants ------------------------------------------------
|
|
56
58
|
// Aligned with the write-side `lib/archive.js`. APPNOTE.TXT § references
|
|
@@ -775,29 +777,31 @@ function zip(adapter, opts) {
|
|
|
775
777
|
* @status experimental
|
|
776
778
|
* @related b.archive.read.zip, b.archive.adapters.trustedStream
|
|
777
779
|
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
780
|
+
* ZIP reader for a Readable source — pass `b.archive.adapters.trustedStream(readable)`
|
|
781
|
+
* instead of buffering the stream yourself. The bytes are collected
|
|
782
|
+
* into a size-capped buffer (1 GiB hard ceiling, like the tar
|
|
783
|
+
* trusted-stream reader) and then read through the same bomb-cap /
|
|
784
|
+
* path-traversal / entry-policy decode as the random-access reader, so
|
|
785
|
+
* `bombPolicy`, `guardProfile`, `entryTypePolicy`, and `audit` all
|
|
786
|
+
* apply. "Trusted" means the source size is bounded by the operator —
|
|
787
|
+
* the collection ceiling is the only guard against an unbounded
|
|
788
|
+
* producer; adversarial archives are still fully bomb-capped on decode.
|
|
783
789
|
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
787
|
-
* are accepted but not yet honored. Re-opens when a streaming
|
|
788
|
-
* consumer needs it. Until then, collect the stream into a buffer and
|
|
789
|
-
* use the random-access reader, which is the supported path for both
|
|
790
|
-
* trusted round-trip verification and adversarial input.
|
|
790
|
+
* The collection ceiling means this is not zero-buffer streaming (the
|
|
791
|
+
* whole archive is held in memory, capped); a future bounded-memory
|
|
792
|
+
* forward-inflate walker would lift that, shared with the tar reader.
|
|
791
793
|
*
|
|
792
794
|
* @opts
|
|
793
|
-
* bombPolicy: {
|
|
794
|
-
*
|
|
795
|
+
* bombPolicy: { maxEntries, maxEntryDecompressedBytes,
|
|
796
|
+
* maxTotalDecompressedBytes, maxExpansionRatio },
|
|
797
|
+
* entryTypePolicy: { ... },
|
|
798
|
+
* guardProfile: "strict" | "balanced" | "permissive",
|
|
799
|
+
* audit: b.audit,
|
|
795
800
|
*
|
|
796
801
|
* @example
|
|
797
|
-
*
|
|
798
|
-
* var bytes = await someStreamToBuffer(producedZipStream);
|
|
799
|
-
* var reader = b.archive.read.zip(b.archive.adapters.buffer(bytes));
|
|
802
|
+
* var reader = b.archive.read.zip.fromTrustedStream(b.archive.adapters.trustedStream(readable));
|
|
800
803
|
* var entries = await reader.inspect();
|
|
804
|
+
* void entries;
|
|
801
805
|
*/
|
|
802
806
|
function fromTrustedStream(adapter, opts) {
|
|
803
807
|
if (!adapter || adapter.kind !== "trusted-sequential") {
|
|
@@ -805,36 +809,45 @@ function fromTrustedStream(adapter, opts) {
|
|
|
805
809
|
"fromTrustedStream: adapter must come from b.archive.adapters.trustedStream(readable)");
|
|
806
810
|
}
|
|
807
811
|
opts = opts || {};
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
812
|
+
|
|
813
|
+
// Collect the Readable into a size-capped buffer once (tar-parity:
|
|
814
|
+
// boundedChunkCollector with a 1 GiB ceiling), then delegate to the
|
|
815
|
+
// random-access reader so the full bomb-cap / guard / audit decode
|
|
816
|
+
// applies. Lazy + memoized — construction stays cheap and the stream
|
|
817
|
+
// is consumed only on the first method call.
|
|
818
|
+
var readerPromise = null;
|
|
819
|
+
function _reader() {
|
|
820
|
+
if (!readerPromise) {
|
|
821
|
+
readerPromise = (async function () {
|
|
822
|
+
var collector = safeBuffer().boundedChunkCollector({
|
|
823
|
+
maxBytes: C.BYTES.gib(1),
|
|
824
|
+
errorClass: ArchiveReadError,
|
|
825
|
+
sizeCode: "archive-read/trusted-stream-too-large",
|
|
826
|
+
});
|
|
827
|
+
for await (var chunk of adapter.readable) { collector.push(chunk); }
|
|
828
|
+
return zip(archiveAdapters().buffer(collector.result()), opts);
|
|
829
|
+
})();
|
|
830
|
+
}
|
|
831
|
+
return readerPromise;
|
|
821
832
|
}
|
|
822
833
|
|
|
834
|
+
async function inspect() { return (await _reader()).inspect(); }
|
|
823
835
|
async function* entries() {
|
|
824
|
-
|
|
825
|
-
|
|
836
|
+
var r = await _reader();
|
|
837
|
+
for await (var e of r.entries()) { yield e; }
|
|
826
838
|
}
|
|
827
|
-
|
|
828
|
-
async function
|
|
829
|
-
|
|
830
|
-
|
|
839
|
+
async function extract(extractOpts) { return (await _reader()).extract(extractOpts); }
|
|
840
|
+
async function* extractEntries(extractOpts) {
|
|
841
|
+
var r = await _reader();
|
|
842
|
+
for await (var e of r.extractEntries(extractOpts)) { yield e; }
|
|
831
843
|
}
|
|
832
844
|
|
|
833
845
|
return {
|
|
834
|
-
kind:
|
|
835
|
-
inspect:
|
|
836
|
-
entries:
|
|
837
|
-
extract:
|
|
846
|
+
kind: "zip-trusted-sequential",
|
|
847
|
+
inspect: inspect,
|
|
848
|
+
entries: entries,
|
|
849
|
+
extract: extract,
|
|
850
|
+
extractEntries: extractEntries,
|
|
838
851
|
};
|
|
839
852
|
}
|
|
840
853
|
|
|
@@ -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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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" }`
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
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] =
|
|
107
|
-
return Buffer.concat([header,
|
|
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,
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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\"
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
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\"");
|
|
@@ -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,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.20",
|
|
4
|
+
"date": "2026-05-27",
|
|
5
|
+
"headline": "`b.archive.wrap` can seal an archive for a tenant with no key-pair to manage — `recipient: \"tenant\"`",
|
|
6
|
+
"summary": "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.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.archive.wrap` / `b.archive.unwrap` `recipient: \"tenant\"` — per-tenant archive sealing, no key-pair",
|
|
13
|
+
"body": "`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."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.agent.tenant.derivedKey(tenantId, purpose)` — direct per-tenant key derivation",
|
|
17
|
+
"body": "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."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.21",
|
|
4
|
+
"date": "2026-05-27",
|
|
5
|
+
"headline": "`b.cose.exportKey` — serialize a public key as a COSE_Key, the inverse of `b.cose.importKey`",
|
|
6
|
+
"summary": "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.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.cose.exportKey(keyObject, { alg?, kid? })` — KeyObject → COSE_Key (RFC 9052 §7)",
|
|
13
|
+
"body": "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."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.22",
|
|
4
|
+
"date": "2026-05-27",
|
|
5
|
+
"headline": "`b.archive.read.zip.fromTrustedStream` reads a ZIP from a Readable — no longer an experimental stub",
|
|
6
|
+
"summary": "fromTrustedStream was an experimental stub whose inspect / entries / extract methods threw, forcing callers to buffer the stream themselves and use the random-access reader. It now works, with the same shape as the tar trusted-stream reader: pass b.archive.adapters.trustedStream(readable) and the bytes are collected into a size-capped buffer (1 GiB hard ceiling) and read through the same bomb-cap, path-traversal, and entry-type decode as the random-access reader — so bombPolicy, guardProfile, entryTypePolicy, and audit all apply, and inspect / entries / extract / extractEntries all return data. This is a bounded-memory reader (the archive is held in memory under the ceiling), not zero-buffer streaming; a future forward-inflate walker shared with the tar reader would lift the ceiling.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.archive.read.zip.fromTrustedStream` now reads — `inspect` / `entries` / `extract` / `extractEntries`",
|
|
13
|
+
"body": "The ZIP trusted-stream reader is implemented (was an experimental stub that threw). Pass `b.archive.adapters.trustedStream(readable)` to read a ZIP straight from a Node Readable without buffering it yourself. The stream is collected into a size-capped buffer (1 GiB ceiling, matching `b.archive.read.tar`'s trusted-stream reader) and decoded through the same adversarial-safe path as the random-access reader, so `bombPolicy` / `guardProfile` / `entryTypePolicy` / `audit` are honored on decode. Adversarial archives remain fully bomb-capped; \"trusted\" refers only to the source-size bound. A non-trusted-stream adapter is refused with `archive-read/bad-adapter`."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -222,6 +222,51 @@ async function testSafeArchiveRefusesTrustedStreamSource() {
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
async function testFromTrustedStreamRoundTrip() {
|
|
226
|
+
var nodeStream = require("node:stream");
|
|
227
|
+
var z = b.archive.zip();
|
|
228
|
+
z.addFile("readme.txt", "trusted stream readme\n");
|
|
229
|
+
z.addFile("data/n.csv", "a,b\n1,2\n");
|
|
230
|
+
var bytes = z.toBuffer();
|
|
231
|
+
|
|
232
|
+
// inspect over a Readable via the trusted-stream adapter (no manual
|
|
233
|
+
// buffering by the caller — the reader collects + decodes).
|
|
234
|
+
var reader = b.archive.read.zip.fromTrustedStream(
|
|
235
|
+
b.archive.adapters.trustedStream(nodeStream.Readable.from(bytes)));
|
|
236
|
+
check("fromTrustedStream: kind tag", reader.kind === "zip-trusted-sequential");
|
|
237
|
+
var entries = await reader.inspect();
|
|
238
|
+
check("fromTrustedStream: inspect sees both entries", entries.length === 2);
|
|
239
|
+
check("fromTrustedStream: entry name round-trips",
|
|
240
|
+
entries.some(function (e) { return e.name === "readme.txt"; }));
|
|
241
|
+
|
|
242
|
+
// extractEntries over a fresh stream recovers the bytes.
|
|
243
|
+
var reader2 = b.archive.read.zip.fromTrustedStream(
|
|
244
|
+
b.archive.adapters.trustedStream(nodeStream.Readable.from(bytes)));
|
|
245
|
+
var got = {};
|
|
246
|
+
for await (var ent of reader2.extractEntries()) {
|
|
247
|
+
got[ent.name] = Buffer.isBuffer(ent.bytes) ? ent.bytes.toString("utf8") : null;
|
|
248
|
+
}
|
|
249
|
+
check("fromTrustedStream: extractEntries recovers content",
|
|
250
|
+
got["readme.txt"] === "trusted stream readme\n" && got["data/n.csv"] === "a,b\n1,2\n");
|
|
251
|
+
|
|
252
|
+
// bombPolicy is honored through the delegated decode — a tiny
|
|
253
|
+
// per-entry cap refuses the archive.
|
|
254
|
+
var reader3 = b.archive.read.zip.fromTrustedStream(
|
|
255
|
+
b.archive.adapters.trustedStream(nodeStream.Readable.from(bytes)),
|
|
256
|
+
{ bombPolicy: { maxEntryDecompressedBytes: 4 } });
|
|
257
|
+
var bombErr = null;
|
|
258
|
+
try { await reader3.inspect(); for await (var _e of reader3.extractEntries()) { void _e; } }
|
|
259
|
+
catch (e) { bombErr = e; }
|
|
260
|
+
check("fromTrustedStream: bombPolicy applies on decode", bombErr !== null);
|
|
261
|
+
|
|
262
|
+
// bad adapter (random-access) refused.
|
|
263
|
+
var badErr = null;
|
|
264
|
+
try { b.archive.read.zip.fromTrustedStream(b.archive.adapters.buffer(bytes)); }
|
|
265
|
+
catch (e) { badErr = e; }
|
|
266
|
+
check("fromTrustedStream: non-trusted-stream adapter refused",
|
|
267
|
+
badErr && /bad-adapter/.test(badErr.code || badErr.message));
|
|
268
|
+
}
|
|
269
|
+
|
|
225
270
|
async function testGuardArchiveInspect() {
|
|
226
271
|
var z = b.archive.zip();
|
|
227
272
|
z.addFile("safe.txt", "safe");
|
|
@@ -359,6 +404,7 @@ async function run() {
|
|
|
359
404
|
testVerifyExtractionPathWindowsHazards();
|
|
360
405
|
await testExtractRefusesOverwrite();
|
|
361
406
|
await testSafeArchiveRefusesTrustedStreamSource();
|
|
407
|
+
await testFromTrustedStreamRoundTrip();
|
|
362
408
|
await testGuardArchiveInspect();
|
|
363
409
|
await testBundleAdapterStorageRoundTrip();
|
|
364
410
|
}
|
|
@@ -82,13 +82,56 @@ async function testWrapRequiresRecipient() {
|
|
|
82
82
|
refused && /no-recipient/.test(refused.code || refused.message));
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
async function
|
|
86
|
-
var
|
|
85
|
+
async function testTenantStrategyRoundTrip() {
|
|
86
|
+
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-aw-tenant-"));
|
|
87
87
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
await helpers.setupVaultOnly(tmpDir);
|
|
89
|
+
var src = Buffer.from("per-tenant sealed archive bytes ".repeat(40));
|
|
90
|
+
|
|
91
|
+
// wrap → unwrap round-trips for the same tenant, no key-pair managed.
|
|
92
|
+
var sealed = b.archive.wrap(src, { recipient: "tenant", tenantId: "alpha" });
|
|
93
|
+
check("archive.wrap tenant: BAWRP magic",
|
|
94
|
+
sealed.slice(0, 5).toString("ascii") === "BAWRP");
|
|
95
|
+
check("archive.wrap tenant: version byte is 0x02 (tenant)",
|
|
96
|
+
sealed[5] === 0x02);
|
|
97
|
+
var recovered = b.archive.unwrap(sealed, { recipient: "tenant", tenantId: "alpha" });
|
|
98
|
+
check("archive.wrap tenant: round-trips losslessly", recovered.equals(src));
|
|
99
|
+
// recipient may be omitted on unwrap (version byte selects the path).
|
|
100
|
+
var recovered2 = b.archive.unwrap(sealed, { tenantId: "alpha" });
|
|
101
|
+
check("archive.wrap tenant: unwrap works with tenantId alone", recovered2.equals(src));
|
|
102
|
+
|
|
103
|
+
// Cross-tenant isolation — a different tenant's derived key (and AAD)
|
|
104
|
+
// cannot open tenant alpha's envelope.
|
|
105
|
+
var crossErr = null;
|
|
106
|
+
try { b.archive.unwrap(sealed, { recipient: "tenant", tenantId: "beta" }); }
|
|
107
|
+
catch (e) { crossErr = e; }
|
|
108
|
+
check("archive.wrap tenant: another tenant cannot decrypt",
|
|
109
|
+
crossErr && /decrypt-failed/.test(crossErr.code || crossErr.message));
|
|
110
|
+
|
|
111
|
+
// Missing tenantId on wrap throws a clear config error.
|
|
112
|
+
var noIdErr = null;
|
|
113
|
+
try { b.archive.wrap(src, { recipient: "tenant" }); }
|
|
114
|
+
catch (e) { noIdErr = e; }
|
|
115
|
+
check("archive.wrap tenant: missing tenantId throws no-tenant-id",
|
|
116
|
+
noIdErr && /no-tenant-id/.test(noIdErr.code || noIdErr.message));
|
|
117
|
+
|
|
118
|
+
// Passing a key-pair recipient to a tenant envelope is refused.
|
|
119
|
+
var mismatchErr = null;
|
|
120
|
+
try { b.archive.unwrap(sealed, { recipient: { privateKey: "x", ecPrivateKey: "y" } }); }
|
|
121
|
+
catch (e) { mismatchErr = e; }
|
|
122
|
+
check("archive.wrap tenant: key-pair recipient on tenant envelope refused",
|
|
123
|
+
mismatchErr && /recipient-mismatch/.test(mismatchErr.code || mismatchErr.message));
|
|
124
|
+
|
|
125
|
+
// Determinism — re-wrapping the same bytes for the same tenant yields
|
|
126
|
+
// a DIFFERENT envelope (fresh nonce) that still opens to the same plaintext.
|
|
127
|
+
var sealed2 = b.archive.wrap(src, { recipient: "tenant", tenantId: "alpha" });
|
|
128
|
+
check("archive.wrap tenant: fresh nonce per wrap (envelopes differ)",
|
|
129
|
+
!sealed2.equals(sealed));
|
|
130
|
+
check("archive.wrap tenant: second envelope also round-trips",
|
|
131
|
+
b.archive.unwrap(sealed2, { tenantId: "alpha" }).equals(src));
|
|
132
|
+
} finally {
|
|
133
|
+
helpers.teardownVaultOnly(tmpDir);
|
|
134
|
+
}
|
|
92
135
|
}
|
|
93
136
|
|
|
94
137
|
async function testBackupRecipientRoundTrip() {
|
|
@@ -159,7 +202,7 @@ async function run() {
|
|
|
159
202
|
await testWrapRefusesWrongKey();
|
|
160
203
|
await testWrapRefusesPartialStaticRecipient();
|
|
161
204
|
await testWrapRequiresRecipient();
|
|
162
|
-
await
|
|
205
|
+
await testTenantStrategyRoundTrip();
|
|
163
206
|
await testBackupRecipientRoundTrip();
|
|
164
207
|
await testBackupRecipientStrategyRequiresKeys();
|
|
165
208
|
await testBackupRecipientDirectoryRefused();
|
|
@@ -2387,6 +2387,16 @@ async function testNoDuplicateCodeBlocks() {
|
|
|
2387
2387
|
],
|
|
2388
2388
|
reason: "v0.12.48 / v0.12.51 / v0.12.52 / v0.12.53 / v0.13.x — Buffer-coercion guard (`if (Buffer.isBuffer(x)) return x; if (x instanceof Uint8Array) return Buffer.from(x); ...`) repeats across byte-string-consuming primitives. The throw-on-unknown variants (cose / mdoc / dnssec / dane / tsa) each raise a MODULE-LOCAL typed error code naming the local argument; the JSON-fallback variants (eat._toBuf serializing a CBOR/EAT claims payload, worm._toBytes serializing a record to hash) instead JSON.stringify a non-bytes value. The duplicated prefix is the symptom; the cause is that JS can't throw a caller-namespaced ErrorClass (or choose the domain's serialization) without the local closure. Same documented exception as the v0.12.7 require-non-empty-string cluster — the per-domain error code / serialization is the divergence the dup detector can't see.",
|
|
2389
2389
|
},
|
|
2390
|
+
{
|
|
2391
|
+
mode: "family-subset",
|
|
2392
|
+
files: [
|
|
2393
|
+
"lib/cose.js:importKey",
|
|
2394
|
+
"lib/cose.js:exportKey",
|
|
2395
|
+
"lib/did.js:_jwkToKey",
|
|
2396
|
+
"lib/network-dnssec.js:_jwkKey",
|
|
2397
|
+
],
|
|
2398
|
+
reason: "v0.13.20 — EC/OKP JWK-coordinate handling (`kty` + `crv` switch over P-256/P-384/P-521 + Ed25519, base64url x/y, `createPublicKey({ format: 'jwk' })`) is coincidentally similar across three unrelated wire formats: cose.importKey/exportKey map a COSE_Key (RFC 9052 §7 — INTEGER crv ids -1/-2/-3 and labels) to/from a KeyObject; did._jwkToKey resolves a W3C DID verification method's JWK; network-dnssec._jwkKey reconstructs a DNSKEY's public key. The curve identifiers, the surrounding map shape, and the direction (import builds a key from a map, export emits a map from a key) all differ per spec — extracting a shared helper would couple three independent standards on a syntactic accident. The shingle is the JWK-coordinate idiom, not behaviour; same documented exception as the v0.12.40 signature-verify-preamble cluster.",
|
|
2399
|
+
},
|
|
2390
2400
|
{
|
|
2391
2401
|
mode: "family-subset",
|
|
2392
2402
|
files: [
|
|
@@ -247,6 +247,47 @@ async function testImportKey() {
|
|
|
247
247
|
check("importKey: secp256k1 refused (no ES256K binding)", secpBad && secpBad.code === "cose/unsupported-key");
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
async function testExportKey() {
|
|
251
|
+
// EC2 P-256 → COSE_Key bytes → decode → importKey round-trips, and
|
|
252
|
+
// the re-imported key verifies a COSE_Sign1 the original signed.
|
|
253
|
+
var bytes = b.cose.exportKey(EC.publicKey, { alg: "ES256", kid: "key-1" });
|
|
254
|
+
check("exportKey: returns CBOR bytes", Buffer.isBuffer(bytes));
|
|
255
|
+
var map = b.cbor.decode(bytes);
|
|
256
|
+
check("exportKey: kty=EC2 (2)", map.get(1) === 2);
|
|
257
|
+
check("exportKey: crv=P-256 (1)", map.get(-1) === 1);
|
|
258
|
+
check("exportKey: x matches the JWK", map.get(-2).toString("base64url") === EC.publicKey.export({ format: "jwk" }).x);
|
|
259
|
+
check("exportKey: alg label 3 = ES256 (-7)", map.get(3) === -7);
|
|
260
|
+
check("exportKey: kid label 2 = 'key-1'", Buffer.isBuffer(map.get(2)) && map.get(2).toString("utf8") === "key-1");
|
|
261
|
+
var reimported = b.cose.importKey(map);
|
|
262
|
+
var att = await b.cose.sign(Buffer.from("rt"), { alg: "ES256", privateKey: EC.privateKey });
|
|
263
|
+
check("exportKey → importKey: round-trips for verification",
|
|
264
|
+
(await b.cose.verify(att, { algorithms: ["ES256"], publicKey: reimported })).payload.toString() === "rt");
|
|
265
|
+
|
|
266
|
+
// OKP Ed25519 round-trip.
|
|
267
|
+
var edBytes = b.cose.exportKey(ED.publicKey);
|
|
268
|
+
var edMap = b.cbor.decode(edBytes);
|
|
269
|
+
check("exportKey: OKP kty=1 + crv Ed25519 (6)", edMap.get(1) === 1 && edMap.get(-1) === 6);
|
|
270
|
+
check("exportKey: OKP re-imports to ed25519",
|
|
271
|
+
b.cose.importKey(edMap).asymmetricKeyType === "ed25519");
|
|
272
|
+
|
|
273
|
+
// A private key exports its PUBLIC half (never the secret).
|
|
274
|
+
var fromPriv = b.cbor.decode(b.cose.exportKey(EC.privateKey));
|
|
275
|
+
check("exportKey: private key exports the public coordinates",
|
|
276
|
+
fromPriv.get(-2).toString("base64url") === EC.publicKey.export({ format: "jwk" }).x);
|
|
277
|
+
|
|
278
|
+
// Unsupported curve / key type / bad opts refused.
|
|
279
|
+
var k1 = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "secp256k1" });
|
|
280
|
+
var secpBad = null;
|
|
281
|
+
try { b.cose.exportKey(k1.publicKey); } catch (e) { secpBad = e; }
|
|
282
|
+
check("exportKey: secp256k1 refused", secpBad && secpBad.code === "cose/unsupported-key");
|
|
283
|
+
var notKey = null;
|
|
284
|
+
try { b.cose.exportKey({ not: "a key" }); } catch (e) { notKey = e; }
|
|
285
|
+
check("exportKey: non-KeyObject refused", notKey && notKey.code === "cose/bad-key");
|
|
286
|
+
var badAlg = null;
|
|
287
|
+
try { b.cose.exportKey(EC.publicKey, { alg: "NOPE" }); } catch (e) { badAlg = e; }
|
|
288
|
+
check("exportKey: unknown alg refused", badAlg && badAlg.code === "cose/unknown-alg");
|
|
289
|
+
}
|
|
290
|
+
|
|
250
291
|
async function run() {
|
|
251
292
|
testSurface();
|
|
252
293
|
testEncrypt0();
|
|
@@ -258,6 +299,7 @@ async function run() {
|
|
|
258
299
|
await testValidation();
|
|
259
300
|
await testDetachedPayload();
|
|
260
301
|
await testImportKey();
|
|
302
|
+
await testExportKey();
|
|
261
303
|
testMac0();
|
|
262
304
|
}
|
|
263
305
|
|
package/package.json
CHANGED