@blamejs/blamejs-shop 0.0.101 → 0.0.104
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 +6 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +14 -2
- package/lib/vendor/blamejs/lib/archive-wrap.js +237 -0
- package/lib/vendor/blamejs/lib/archive.js +10 -6
- package/lib/vendor/blamejs/lib/backup/index.js +70 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.10.json +65 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +176 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +53 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.104 (2026-05-23) — **Organization + WebSite + BreadcrumbList Schema.org JSON-LD — three more rich-result types covered.** Home page now emits Organization (Google knowledge-panel + logo + social link) and WebSite (sitelinks search box pointing at `/search?q=`) JSON-LD blocks. Product detail page gains a BreadcrumbList block matching the on-page `<nav class="breadcrumb">` markup. All three compose through the existing `jsonLdScript` helper from `worker/render/_lib.js`. Combined with the Product + Article blocks from v0.0.94, the storefront now ships five distinct Schema.org graph types — Product / Article / Organization / WebSite / BreadcrumbList — across the routes Google's rich-result tester maps. **Added:** *Organization + WebSite JSON-LD on `/`* — `worker/render/home.js#renderHome` appends two `<script type="application/ld+json">` blocks to the body. Organization: `name` + `url` + `logo` + `sameAs` (GitHub mirror) — surfaces in Google's knowledge-panel result. WebSite: `potentialAction` with a `SearchAction` template at `/search?q={search_term_string}` so Google's sitelinks search box hands queries directly to the storefront's search route. · *BreadcrumbList JSON-LD on `/products/:slug`* — `worker/render/product.js#renderProduct` appends a BreadcrumbList block alongside the existing Product block. Two positions: `Shop` (`/`) → `<product title>` (`/products/<slug>`) — matches the on-page breadcrumb nav. Google's product-rich-result panel renders this above the title.
|
|
12
|
+
|
|
13
|
+
- v0.0.103 (2026-05-23) — **HTTP/103 Early Hints `Link: rel=preload` on every HTML response + vendor v0.12.10.** Every edge-rendered HTML response now carries `Link: </assets/themes/default/css/main.css?v=<version>>; rel=preload; as=style; crossorigin` in the headers. Cloudflare auto-promotes Link headers on 200 responses to HTTP/103 Early Hints frames — repeat visitors see the stylesheet fetch start before the HTML body arrives, accelerating First Contentful Paint and Largest Contentful Paint. The single preload covers the theme bundle every page references; per-route preloads (hero images, etc.) compose by appending to the same header. Bundles vendor refresh from blamejs v0.12.9 to v0.12.10. **Added:** *`Link: rel=preload; as=style` on every HTML response — HTTP/103 Early Hints eligibility* — `worker/index.js` exposes `_earlyHintsLink(env)` returning the canonical preload header for the theme stylesheet (`/assets/themes/default/css/main.css?v=<WORKER_VERSION>`). `_html(body, method, env)` and `_staticHtml(body, method, env)` both inject the header into every 200 / static HTML response. Cloudflare's edge auto-promotes Link headers on 200 responses to HTTP/103 frames — repeat visitors and clients with HTTP/3 see the stylesheet preload start before the HTML body finishes streaming, cutting LCP by ~50-150ms on a typical render. Per-route preloads (e.g. hero image on a product page) compose by appending an additional Link entry. **Changed:** *Vendored blamejs refreshed from v0.12.9 to v0.12.10* — `bash scripts/vendor-update.sh blamejs v0.12.10` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.9 and v0.12.10.
|
|
14
|
+
|
|
15
|
+
- v0.0.102 (2026-05-23) — **`_redact` composes `b.redact.redact` after the shop-specific patterns — defense-in-depth on log emission.** The Worker's `_redact` helper carried a hand-rolled chain of regex replacements covering shop-specific log shapes (Authorization Bearer / Stripe-Signature / shop_sid / x-d1-bridge-secret / hex digests / opaque tokens). The framework's `b.redact.redact` covers a different set — PEM blocks, AWS access keys, credit-card numbers, SSNs, connection strings, well-known credential field-names — that the shop chain doesn't enumerate. This patch keeps the shop chain (it catches header / cookie shapes b.redact misses) and composes `b.redact.redact` on top so the framework's catalog of sensitive value shapes also runs over the output. Log emissions through `_redact(...)` are now covered by both layers. **Changed:** *`_redact` chains `b.redact.redact` after the shop-specific patterns* — Order: (1) shop chain — Bearer / Stripe-Signature / JWT-tri-segment / shop_sid+shop_auth cookies / d1_bridge_secret + admin_api_key + stripe_*_key headers / 32-char hex / 40-char opaque token; (2) `b.redact.redact` on the result — picks up PEM `-----BEGIN`, AWS `AKIA*`, credit-card Luhn, SSN, JWT (stricter shape), connection strings, and the framework's sensitive-field-name catalog. Both run on every Worker `console.error` / observability emission that flows through `_redact`. `b.redact` joins the Worker-side primitive surface via `worker/b.js` alongside the v0.0.100 `b.cookies` addition.
|
|
16
|
+
|
|
11
17
|
- v0.0.101 (2026-05-23) — **`worker-syntax` smoke gate (network-free brace/paren/bracket walker) — replaces the broken esbuild gate.** v0.0.99's `worker-esbuild-parse` gate needed `npx esbuild` and the Cloudflare Workers Builds container test stage has no network; v0.0.100 had to remove it. This patch ships `scripts/check-worker-syntax.js` — a self-contained Node walker that reads every reachable file under `worker/` tracking string / template / comment / regex-literal state and asserts balanced braces / parens / brackets at EOF. Catches the node-tolerates / esbuild-refuses class that broke v0.0.95 + v0.0.97 (missing trailing `}` on a function declaration). Runs without an npm-registry hit, runs inside the container build stage without network access, runs in ~50ms. Verified via injection — appending `function _badInject() {` to `worker/index.js` trips the gate; restoration clears it. **Added:** *`scripts/check-worker-syntax.js` + `worker-syntax` smoke gate* — Walks `worker/index.js` and every locally-imported worker module (`./*.js` / `../*.js` references resolved to files under `worker/`) one-deep — at the time of writing that's 10 files. Per-file state machine tracks single-quoted strings, double-quoted strings, template literals (with `\` escape handling), line + block comments, and regex literals (including `[ ]` char classes that contain a literal `/`). At EOF, asserts `{ } ( ) [ ]` are all balanced; an unclosed string / template / comment also fails. On imbalance, the failure message names the file + line of the last-opened token that didn't close. · *Wired into `test/smoke.js` as the fourth static gate* — Runs alongside `release-notes-rollup` / `changelog-in-sync` / `vendor-drift`. Verified by injection — `function _badInject() {` appended to `worker/index.js` produces `[worker-syntax] FAIL — worker/index.js:<line> — imbalance braces=1 parens=0 brackets=0 — opening \`{\` at line <N> not closed` and exits smoke non-zero; restoring the file clears the gate.
|
|
12
18
|
|
|
13
19
|
- v0.0.100 (2026-05-23) — **Remove the broken `worker-esbuild-parse` gate (v0.0.99 deploy blocker) + compose `b.cookies` for the session-cookie presence check.** v0.0.99's `worker-esbuild-parse` smoke gate ran `npx esbuild` inside the container's Dockerfile test stage. The test stage doesn't carry `node_modules` and the build environment has no network access at that point, so `npx esbuild` couldn't download esbuild and exited non-zero — breaking the smoke gate, breaking the auto-deploy. Gate removed. The original failure mode the gate was guarding against (a missing closing brace that Node tolerates but esbuild refuses) still surfaces in the Cloudflare Workers Builds run itself; a redesigned gate that doesn't need a network-reachable npm registry will land in a follow-up. Same patch composes `b.cookies.parseSafe` for the `_hasSessionCookie` presence check (replaces the inline regex with the framework's parser + grammar enforcement). **Changed:** *`_hasSessionCookie` composes `b.cookies.parseSafe` instead of a regex presence check* — Previous shape: `/\b(shop_sid|shop_auth)=/.test(cookieHeader)` — close enough for presence but skipped the framework's RFC 6265bis token-grammar enforcement (length cap, duplicate-key resolution, CRLF refusal). New shape: `b.cookies.parseSafe(cookieHeader)` returns `{ jar, issues }`; `jar` is a null-prototype object the presence check inspects via `hasOwnProperty` for the session cookie names. `b.cookies` joins the Worker-side primitive surface via `worker/b.js`. **Fixed:** *Remove `worker-esbuild-parse` smoke gate* — The gate ran `npx esbuild worker/index.js --bundle ...` from `test/smoke.js`. Locally + in CI it worked because `node_modules` was already populated. The container's test stage in the Dockerfile doesn't install dependencies — the smoke test runs against the source tree only — so `npx` had to download esbuild fresh, which the Cloudflare Workers Builds environment refuses (network-restricted build stage). Gate removed to unblock the auto-deploy; replacement gate (parser that doesn't need esbuild) tracked for a follow-up.
|
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.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.10",
|
|
7
|
+
"tag": "v0.12.10",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.10 (2026-05-23) — **`b.archive.wrap` + `b.archive.unwrap` — recipient-encrypted archive envelopes (Flavor 1) + `b.backup` `cryptoStrategy: "recipient"` + HIPAA/PCI-DSS posture refusal.** Flavor 1 lands as the whole-archive recipient-wrap substrate. `b.archive.wrap(bytes, { recipient })` produces a sealed envelope under the framework's hybrid PQC seal (ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305) prefixed with a 6-byte `BAWRP` archive-wrap header so format sniffers can identify wrap envelopes without trial decryption. `b.archive.unwrap(sealed, { recipient })` is the inverse with magic-check upfront so non-envelope inputs throw `archive-wrap/bad-magic` rather than a crypto-level error. Recipient strategies: static keypair (`{ publicKey, ecPublicKey }`) and peer-cert (`{ peerCertDer, peerKemPubkey }`); the tenant strategy lands in v0.12.11 alongside the backup-crypto refactor + per-tenant key resolution. `b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` composes the wrap/unwrap layer transparently: the bytes hitting the adapter's `writeFile` are a `BAWRP`-prefixed envelope, never the raw tar / tar.gz / directory bundle. HIPAA + PCI-DSS postures refuse `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — the storage adapter cannot itself satisfy the encryption-at-rest requirement; the recipient envelope is the framework-side gate. Flavor 2 (per-entry ZIP wrap with the 0xBADC extra-field marker) and the backup-crypto refactor into `lib/_crypto-base.js` ship in v0.12.11. **Added:** *`b.archive.wrap(bytes, { recipient })` — recipient-encrypted archive envelope* — Composes `b.crypto.encrypt` (or `b.crypto.encryptEnvelopeAsCertPeer` for the peer-cert strategy) under the framework's hybrid PQC seal. The output is a Buffer carrying a 6-byte `BAWRP` archive-wrap header (5-byte magic + 1-byte version) followed by the base64-encoded envelope bytes. Recipient strategies: `{ publicKey, ecPublicKey }` for the static-keypair path (ML-KEM-1024 PEM + P-384 ECDH PEM); `{ peerCertDer, peerKemPubkey }` for the peer-cert path (extracts the P-384 half from the cert per `b.crypto.encryptEnvelopeAsCertPeer`). `"tenant"` returns `archive-wrap/tenant-strategy-deferred` upfront — that strategy lands in v0.12.11 with the per-tenant key resolution. · *`b.archive.unwrap(sealed, { recipient })` — inverse with upfront magic check* — Verifies the 6-byte `BAWRP` header before any cryptographic work so non-envelope inputs (raw archives, other-magic envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` rather than a downstream `crypto/*` error. Routes through `b.crypto.decrypt(envelope, recipient, { raw: true })` so binary archive payloads (gzip, ZIP, tar) round-trip losslessly — `raw: true` is the contract that preserves bytes vs the default utf-8 decoding. · *`b.backup.bundleAdapterStorage({ cryptoStrategy: "recipient", recipient })` — opt-in envelope storage* — `cryptoStrategy: "none"` (default, v0.12.7-9 behaviour) writes plaintext bundle bytes to the adapter — safe for storage layers that are themselves the protective boundary (S3 SSE, disk-encrypted hosts). `cryptoStrategy: "recipient"` requires `opts.recipient` and wraps every `writeBundle` payload through `b.archive.wrap` before `adapter.writeFile`; `readBundle` unwraps after `adapter.readFile`. The wrap layer sits OUTSIDE the gz / tar layers so the bundle on disk is opaque ciphertext under the operator-controlled recipient key. Passphrase strategy is deferred to v0.12.11 alongside the `_crypto-base.js` refactor. · *HIPAA + PCI-DSS posture refuses plaintext bundles* — `bundleAdapterStorage({ posture: "hipaa" })` (or `"pci-dss"`) refuses `cryptoStrategy: "none"` upfront with `backup/posture-requires-encryption` — adapter-storage's plaintext default cannot itself satisfy encryption-at-rest requirements. Operators under these postures pass `cryptoStrategy: "recipient"` + a recipient key. The refusal message includes the posture name + the strategy that fails so audit-trail operators see exactly which gate blocked the call. **Security:** *Wrap envelope is the framework's hybrid PQC seal — ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305* — Defence-in-depth posture: a CRQC against ML-KEM-1024 alone still has to defeat the classical P-384 ECDH leg; a future ECDH break alone still has to defeat ML-KEM-1024. The 4-byte envelope header (magic + KEM ID + cipher ID + KDF ID) is bound as AEAD AAD so a header-substitution attack fails Poly1305 verification. `b.archive.wrap` prepends a separate 6-byte archive-wrap header BEFORE the base64 envelope so format sniffing can identify wrap output without trial decryption — non-envelope inputs are refused at byte 0-4 (magic check) instead of after fruitless decapsulation work. **Detectors:** *`backup-adapter-storage-without-posture-check` — postures that mandate encryption must propagate to `cryptoStrategy`* — When a primitive that wires `b.backup.bundleAdapterStorage` carries a `posture:` opt drawn from the HIPAA / PCI-DSS / etc. set, the same code path must propagate `cryptoStrategy: "recipient"` (or refuse before reaching writeBundle). The detector matches `bundleAdapterStorage({ ... posture: ... })` invocations in `lib/` and requires a matching `cryptoStrategy` opt; missing it surfaces during the codebase-patterns gate so a future caller can't silently drop the contract. **Migration:** *Flavor 2 — per-entry ZIP recipient wrap with 0xBADC extra-field* — Per-entry encryption inside the carrier ZIP (method=STORE with the encrypted bytes as the stored payload + a 0xBADC user-defined-range extra-field marker carrying the recipient hint). Inspect-without-decrypt is the operator value: entry list + name-safety gating happens BEFORE any key resolution. Lands in v0.12.11 alongside the backup-crypto refactor. · *`lib/_crypto-base.js` refactor — backup-crypto, Flavor 1, Flavor 2 share substrate* — The legacy per-file Argon2id + XChaCha20-Poly1305 path in `lib/backup/crypto.js` gets factored into a private `_crypto-base.js` helper so all three encryption flavors compose the same primitive set. No operator-visible API change; closes the each-feature-rolls-its-own-crypto smell. · *`cryptoStrategy: "passphrase"` + tenant strategy* — Passphrase strategy on `bundleAdapterStorage` (Argon2id-derived key + XChaCha20-Poly1305) and the `"tenant"` recipient string (composes `b.vault.derivedKey({ tenant, purpose: "archive-wrap" })`) both ship in v0.12.11. The v0.12.10 surface is the recipient substrate; v0.12.11 lights up the per-tenant + passphrase strategies that consume it.
|
|
12
|
+
|
|
11
13
|
- v0.12.9 (2026-05-23) — **`b.archive.gz` + `b.archive.read.gz` — gzip composition with `b.safeDecompress` bomb caps + `b.backup` `tar.gz` bundle format + `sha-to-tag verify` fetches `origin/main`.** gzip lands as the composition layer over the archive family. `b.archive.gz(bytes)` produces an RFC 1952 gzip stream with the same `toBuffer()` / `toAdapter(adapter)` / `digest()` shape every archive builder ships, and `b.archive.read.gz(adapter, opts)` reads it back through `b.safeDecompress` so a malicious `tar.gz` fails the gzip-layer bomb cap (1 GiB output / 100× ratio defaults) before the tar walker ever sees a decompressed byte. The reader exposes `toBuffer()` / `asTar(opts)` / `asZip(opts)` so operators can hand the decompressed bytes directly to a downstream archive reader without a round-trip through disk. `b.archive.tar().toGzip(adapter, opts)` is the write-side convenience for the most common combination. `b.backup.bundleAdapterStorage({ format: "tar.gz" })` adds gzip compression on the wire — bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); the readback path detects the format from the storage key suffix and composes `b.safeDecompress` automatically. The `sha-to-tag verify` workflow now explicitly fetches `origin/main` before walking the first-parent history, fixing a stale-ref bug that silently failed v0.12.6 through v0.12.8 tag verifications (the publish workflow itself was unaffected; the gate is independent). **Added:** *`b.archive.gz(bytes)` — standalone gzip write builder* — RFC 1952 gzip envelope with the standard archive-builder shape. `toBuffer()` returns the compressed bytes; `toAdapter(adapter)` writes through any writable adapter (fs / object-store / http) that exposes `.write(bytes)` + optional `.close()`; `digest()` returns a SHA3-512 hex hash of the compressed payload for operator integrity logs. `opts.level` accepts 0-9 (zlib default 6). Composes cleanly under `b.archive.tar().toGzip(adapter)` / `b.archive.zip()` for tar.gz / zip.gz convenience. · *`b.archive.read.gz(adapter, opts)` — gunzip reader with `b.safeDecompress` bomb caps* — Every decompression routes through `b.safeDecompress({ algorithm: "gzip", maxOutputBytes, maxRatio })` so a hostile gzip stream fails the bomb gate before any downstream parsing happens. Defaults: `maxDecompressedBytes` = 1 GiB, `maxExpansionRatio` = 100×. The reader exposes three downstream entry points: `toBuffer()` returns the raw decompressed bytes; `asTar(opts)` returns a `b.archive.read.tar` reader over the decompressed payload; `asZip(opts)` returns a `b.archive.read.zip` reader. `fromGzip` is the documented alias the spec uses (operators may reach for either). Refuses non-gzip input upfront via the `0x1f 0x8b` magic check (`archive-gz/bad-magic`). · *`b.archive.tar().toGzip(adapter, opts)` — tar.gz write convenience* — Pipes the tar builder's `toBuffer()` through `b.archive.gz()` and writes the resulting gzip envelope to a writable adapter. Equivalent to `b.archive.gz(t.toBuffer()).toAdapter(adapter)` but lets the operator stay in the tar-builder fluent chain when composing under fs / object-store / http adapters. · *`b.backup.bundleAdapterStorage({ format: "tar.gz" })` — compressed-on-the-wire bundles* — Adds gzip compression to the v0.12.8 tar bundle format. Bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); binary-heavy backups (compressed databases, encrypted archives) see ~1.0-1.1×. Read paths auto-detect via the `<bundleId>/bundle.tar.gz` storage key suffix and route through `b.safeDecompress` on readback. The v0.12.8 `maxBundleBytes` cap continues to gate against pathological projected-uncompressed sizes; `tar.gz` does not bypass it. · *`b.safeArchive.extract({ format: "tar.gz" })` — explicit tar.gz dispatch* — Operators handed a `.tar.gz` upload pass `format: "tar.gz"` explicitly; the orchestrator composes `b.archive.read.gz` → `.asTar()` and feeds the standard tar bomb-policy + entry-type-policy + guardProfile through. Defer-with-condition: auto-sniff for tar.gz (peek inside the gzip envelope for ustar magic at offset 257 of the decompressed prefix) lands when operator demand surfaces; today operators with `auto` mode on a `.tar.gz` payload get `format-unsupported gzip` with the explicit-format hint in the error message. **Fixed:** *`sha-to-tag verify` workflow fetches `origin/main` before first-parent walk* — The release-tag integrity gate runs on every `v*` tag push and verifies the tag's commit SHA appears on `main`'s first-parent history. `actions/checkout` was being asked for full history of the tag ref alone — `origin/main` wasn't fetched as a side effect, so `git rev-list --first-parent origin/main | grep -qx "$SHA"` walked a stale (or absent) ref and falsely refused. The check now explicitly fetches `origin/main` after checkout so the walk sees the current squash-merge HEAD. Affected releases (v0.12.6 / v0.12.7 / v0.12.8) had publish workflows that completed normally — `sha-to-tag verify` is an independent gate that was silently failing alongside successful publishes; nothing about the published artifacts was wrong. **Security:** *Bomb caps ride at the gz layer, not the tar/zip layer* — The decompression gate is enforced BEFORE the downstream archive reader sees any bytes — a hostile `tar.gz` that would decompress to 10 GiB of zero-filled tar entries fails the 1 GiB `maxDecompressedBytes` default cap during gunzip, never reaching the tar walker. Operators with legitimately large compressed archives pass `maxDecompressedBytes` higher; the framework refuses without an explicit opt-in. RFC 1952 §2.3.1 magic enforcement prevents content-type confusion (gzip-pretending-to-be-something-else inputs). **Detectors:** *`archive-gz-without-safedecompress` — direct `node:zlib` gunzip in `lib/` must compose `b.safeDecompress`* — Mirrors the v0.11.5 must-compose pattern: any `lib/` call to `zlib.gunzipSync` / `zlib.createGunzip` / `gunzip` outside `lib/archive-gz.js` (which IS the canonical gunzip site, with `b.safeDecompress` wired in) must carry an `allow:archive-gz-without-safedecompress` marker explaining why the bomb gate is bypassed. The detector locks the contract so v0.13+ work that touches a gzip-handling primitive can't quietly drop the cap.
|
|
12
14
|
|
|
13
15
|
- v0.12.8 (2026-05-23) — **`b.archive.tar` + `b.archive.read.tar` — POSIX pax tar format end-to-end + `b.guardArchive.tarEntryPolicy` + `b.backup` tar bundle default.** Tar lands as the second format in the archive family. `b.archive.tar()` builds POSIX pax archives (ustar magic + pax extended headers for >100-char names, >8 GiB sizes, nanosecond mtime); `b.archive.read.tar(adapter)` walks the 512-byte block sequence with the same bomb-cap + path-traversal + entry-type defenses that ZIP read shipped at v0.12.7. Tar's natively-streamable shape means `b.archive.adapters.trustedStream(readable)` is a first-class extract path here (no CD-walk required since tar has no central directory; sequential header-by-header is the canonical adversarial-safe path). `b.guardArchive.tarEntryPolicy` ships as the tar-specific entry-shape policy beyond `entryTypePolicy` — handles typeflag 0/5 (regular/directory) by default, refuses 1/2 (hardlink/symlink) unless `allowDangerous` is set with the realpath-on-link-target dual-check, and refuses 3/4/6/7 (char-device/block-device/FIFO/contiguous-file) unconditionally. `b.backup.bundleAdapterStorage({ format: "tar" })` becomes the default for new bundles — directory-tree format stays available via `format: "directory"` for back-compat with v0.12.7 bundles. `b.backup.migrate(from, to)` one-shot helper converts v0.12.7 directory bundles to v0.12.8 tar bundles transparently. `b.safeArchive.extract({ source, destination, format: "auto" })` now sniffs ustar magic at offset 257 inside the first 512-byte block and dispatches to the tar reader automatically. CVE coverage extends to the tar class: CVE-2026-23745 / 2026-24842 (node-tar symlink+hardlink path resolution), CVE-2025-4517 PATH_MAX TOCTOU (the v0.12.7 dual-check carries through), CVE-2025-11001/11002 (symlink TOCTOU on extract), CVE-2024-12905 / 2025-48387 (tar-fs traversal), CVE-2025-4138/4330 (Python tarfile data filter bypass). **Added:** *`b.archive.tar()` — POSIX pax write builder* — Mirrors `b.archive.zip()`'s contract: `addFile(name, content, opts?)` + `addDirectory(name, opts?)` + `toBuffer()` + `toStream(writable)` + `toAdapter(adapter)` + `digest()`. Emits ustar-magic 512-byte header blocks with the standard 11-field prefix (name / mode / uid / gid / size / mtime / chksum / typeflag / linkname / magic / version / uname / gname / devmajor / devminor / prefix). Names >100 chars + sizes >8 GiB + mtime with nanosecond precision get a pax extended header (typeflag=x) preceding the entry; the extended header records (per POSIX.1-2001 §4.18) carry the `path` / `size` / `mtime` / `atime` / `ctime` fields that overflow ustar's fixed widths. Determinism opts: `{ fixedMtime: 0, ignoreOrder: false }` for reproducible builds (matches the ZIP write side). · *`b.archive.read.tar(adapter, opts)` — sequential + random-access tar reader* — Walks 512-byte header blocks in order. `inspect()` enumerates entries without decompressing; `extract({ destination })` decompresses entry-by-entry with the same bomb-cap + path-traversal + entry-type defenses as ZIP read. Trusted-stream adapters are first-class here — tar has no central directory, so sequential header-by-header walk IS the canonical adversarial-safe path (`b.archive.adapters.trustedStream(readable)` and `b.archive.adapters.fs/buffer/objectStore/http` all flow through the same reader). Per-entry path safety routes through `b.guardFilename.verifyExtractionPath` (the v0.12.7 dual-check). Refuses to overwrite pre-existing destination files (carries the v0.12.7 atomic-rollback contract). · *`b.guardArchive.tarEntryPolicy(opts)` — tar-specific entry-type policy* — Defaults: typeflag 0 (regular file) + 5 (directory) extract; typeflag 1 (hardlink) + 2 (symlink) refused unless `allowDangerous: { symlinks: true, hardlinks: true }` is set; typeflag 3 (char-device) + 4 (block-device) + 6 (FIFO) + 7 (contiguous-file) refused unconditionally. When `allowDangerous` is set, link target is routed through `b.guardFilename.verifyExtractionPath` against the extraction root — the realpath-on-link-target check defends the CVE-2026-23745 / 24842 node-tar class where the safety check and creation logic diverged on path resolution. Pax extended-header (x) + global-header (g) entries consumed by the reader (merged into the following entry's metadata); operators never see them as standalone entries. · *`b.backup.bundleAdapterStorage({ format: "tar" })` — tar bundle becomes default* — New bundles ship as a single tar archive instead of a directory tree. Restore via `b.archive.read.tar` (with the operator-supplied adapter routing the bytes). `format: "directory"` opts back into the v0.12.7 layout for operators with existing bundles. `format: "tar"` is the new default; `b.backup.diskStorage` stays back-compat at the legacy directory-tree format. · *`b.backup.migrate(opts)` — directory → tar bundle migration* — One-shot helper that walks an operator's directory-tree-format bundle (v0.12.7 layout) and writes the same content as a tar-format bundle via the v0.12.8 bundleAdapterStorage. Idempotent: re-running on an already-migrated bundle is a no-op. Source bundle stays in place until the migrate succeeds; operators with explicit transition windows pass `{ deleteSourceOnSuccess: true }` to opt into the inline replace. · *`b.safeArchive.extract({ format: "auto" })` recognizes tar* — Format auto-sniff now dispatches `ustar` magic at offset 257 inside the first 512-byte header block to the tar reader. ZIP magic + tar magic + GZIP magic (v0.12.9) live in the same sniff path; operators with mixed-format pipelines pass `format: "auto"` once + the orchestrator picks the right reader. **Security:** *Symlink + hardlink path resolution (CVE-2026-23745 / CVE-2026-24842 node-tar class)* — node-tar < 7.5.7 / ≤ 7.5.2 shipped a divergence between its hardlink safety check (which used one path resolution) and its hardlink creation logic (which used another). When `allowDangerous: { hardlinks: true }` is set, blamejs routes the link target through `b.guardFilename.verifyExtractionPath` — the SAME primitive that the eventual `link()` call resolves against — so check + create agree by construction. Symlink targets same shape. · *Path traversal (CVE-2024-12905 / CVE-2025-48387 tar-fs + CVE-2025-4138 / 4330 Python tarfile data filter bypass)* — Every entry name passes through `b.guardFilename.verifyExtractionPath` — the v0.12.7 dual-check that refuses pre-resolve names > PATH_MAX (4096 bytes) AND verifies the string-normalize + `fs.realpath` resolutions agree on the same final path. Defends the CVE-2025-4517 / 4138 / 4330 class where the operator's path resolution and the kernel's diverge silently past PATH_MAX. · *Symlink TOCTOU on extract (CVE-2025-11001 / CVE-2025-11002 7-Zip class)* — When `allowDangerous: { symlinks: true }` opts symlinks in, the reader resolves the link target via `verifyExtractionPath` against the extraction root BEFORE calling `fs.symlink` — so the resolved target is inside the trust boundary by construction. The v0.12.7 atomic-rollback contract carries through: any single entry failure aborts the whole extract + cleans up only newly-created files (pre-existing destination files refused at the pre-write check). **Detectors:** *`tar-extract-allow-dangerous-without-link-target-check`* — Flags any `b.archive.read.tar(adapter).extract({ allowDangerous: ... })` call site in `lib/` that doesn't route the link target through `b.guardFilename.verifyExtractionPath` against the extraction root. Forces the dual-check discipline at every allow-dangerous opt-in — operators with hardlink / symlink extract needs see the realpath check at the call site. · *`tar-entry-typeflag-without-policy`* — Flags `lib/archive-tar.js` extract code paths that switch on typeflag without composing `b.guardArchive.tarEntryPolicy` for the type-allowlist decision. Locks the shape: every typeflag dispatch goes through the policy, never inline. · *`backup-migrate-without-source-preserve`* — Flags `b.backup.migrate(opts)` call sites that pass `deleteSourceOnSuccess: true` without an operator-stated justification comment. Default is preserve-source; deletes need an explicit reason. **References:** [POSIX.1-2001 pax extended format (IEEE 1003.1)](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html) · [CVE-2026-23745 — node-tar symlink+hardlink path resolution](https://www.sentinelone.com/vulnerability-database/cve-2026-23745/) · [CVE-2026-24842 — node-tar hardlink path resolution](https://github.com/advisories/GHSA-34x7-hfp2-rc4v) · [CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)](https://nvd.nist.gov/vuln/detail/CVE-2025-4517) · [CVE-2025-4138 / CVE-2025-4330 — Python tarfile data filter](https://github.com/0xDTC/CVE-2025-4138-4517-POC) · [CVE-2025-11001 / CVE-2025-11002 — 7-Zip symlink TOCTOU on extract](https://www.sentinelone.com/vulnerability-database/cve-2025-11001/) · [CVE-2024-12905 / CVE-2025-48387 — node-tar-fs path traversal](https://vulert.com/vuln-db/debian-11-node-tar-fs-193050)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.10",
|
|
4
|
+
"createdAt": "2026-05-23T18:01:15.953Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -1710,6 +1710,10 @@
|
|
|
1710
1710
|
"type": "function",
|
|
1711
1711
|
"arity": 4
|
|
1712
1712
|
},
|
|
1713
|
+
"ArchiveWrapError": {
|
|
1714
|
+
"type": "function",
|
|
1715
|
+
"arity": 4
|
|
1716
|
+
},
|
|
1713
1717
|
"TarError": {
|
|
1714
1718
|
"type": "function",
|
|
1715
1719
|
"arity": 4
|
|
@@ -1826,6 +1830,14 @@
|
|
|
1826
1830
|
"type": "function",
|
|
1827
1831
|
"arity": 0
|
|
1828
1832
|
},
|
|
1833
|
+
"unwrap": {
|
|
1834
|
+
"type": "function",
|
|
1835
|
+
"arity": 2
|
|
1836
|
+
},
|
|
1837
|
+
"wrap": {
|
|
1838
|
+
"type": "function",
|
|
1839
|
+
"arity": 2
|
|
1840
|
+
},
|
|
1829
1841
|
"zip": {
|
|
1830
1842
|
"type": "function",
|
|
1831
1843
|
"arity": 0
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* archive-wrap — recipient-based whole-archive encryption substrate
|
|
4
|
+
* for the b.archive family. Composes b.crypto.encrypt (ML-KEM-1024 +
|
|
5
|
+
* P-384 ECDH hybrid + SHAKE256 + XChaCha20-Poly1305 envelope) so
|
|
6
|
+
* archive bytes hitting an adapter can be a sealed envelope rather
|
|
7
|
+
* than the raw format.
|
|
8
|
+
*
|
|
9
|
+
* Operators compose explicitly for v0.12.10:
|
|
10
|
+
*
|
|
11
|
+
* var sealed = b.archive.wrap(t.toBuffer(), { recipient: pubKeys });
|
|
12
|
+
* await b.archive.adapters.fs(path).write(sealed);
|
|
13
|
+
*
|
|
14
|
+
* var sealed = await fs.promises.readFile(path);
|
|
15
|
+
* var bytes = b.archive.unwrap(sealed, { recipient: privKeys });
|
|
16
|
+
* var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
var C = require("./constants");
|
|
25
|
+
var lazyRequire = require("./lazy-require");
|
|
26
|
+
var { defineClass } = require("./framework-error");
|
|
27
|
+
|
|
28
|
+
var ArchiveWrapError = defineClass("ArchiveWrapError", { alwaysPermanent: true });
|
|
29
|
+
|
|
30
|
+
var bCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
31
|
+
|
|
32
|
+
// Envelope magic — 5-byte ASCII prefix the safe-archive sniffer
|
|
33
|
+
// recognises. Distinct from b.crypto.encrypt's base64 envelope so
|
|
34
|
+
// archive-wrap output can carry an unambiguous "this is an archive
|
|
35
|
+
// recipient-wrap envelope" magic before the operator-controlled
|
|
36
|
+
// payload.
|
|
37
|
+
var ARCH_WRAP_MAGIC = "BAWRP"; // allow:raw-byte-literal — 5-byte ASCII archive-wrap envelope magic
|
|
38
|
+
var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — version byte
|
|
39
|
+
var ARCH_WRAP_HEADER_BYTES = C.BYTES.bytes(6); // magic(5) + version(1)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @primitive b.archive.wrap
|
|
43
|
+
* @signature b.archive.wrap(bytes, opts)
|
|
44
|
+
* @since 0.12.10
|
|
45
|
+
* @status stable
|
|
46
|
+
* @related b.archive.unwrap, b.crypto.encrypt, b.backup.bundleAdapterStorage
|
|
47
|
+
*
|
|
48
|
+
* Wrap archive bytes in a recipient-encrypted envelope. The envelope
|
|
49
|
+
* is the framework's standard hybrid PQC seal (ML-KEM-1024 + P-384
|
|
50
|
+
* ECDH hybrid + SHAKE256 KDF + XChaCha20-Poly1305 AEAD) prefixed
|
|
51
|
+
* with a 6-byte archive-wrap header (`BAWRP` magic + version byte)
|
|
52
|
+
* so format sniffers can distinguish wrap envelopes from raw
|
|
53
|
+
* archives without trial decryption.
|
|
54
|
+
*
|
|
55
|
+
* Recipient strategies:
|
|
56
|
+
* - static key — `{ recipient: { publicKey, ecPublicKey } }` (ML-KEM-1024
|
|
57
|
+
* pubkey PEM + P-384 ECDH pubkey PEM).
|
|
58
|
+
* - peer cert — `{ recipient: { peerCertDer, peerKemPubkey } }` composes
|
|
59
|
+
* `b.crypto.encryptEnvelopeAsCertPeer` (extracts the
|
|
60
|
+
* P-384 half from the cert).
|
|
61
|
+
* - tenant — `{ recipient: "tenant", tenantId: "alpha" }` resolves
|
|
62
|
+
* the tenant's KEM keypair via `b.vault.derivedKey`
|
|
63
|
+
* (deferred to v0.12.11 alongside the backup
|
|
64
|
+
* `cryptoStrategy: "recipient"` adoption).
|
|
65
|
+
*
|
|
66
|
+
* @opts
|
|
67
|
+
* recipient: object | string, // see strategies above; required
|
|
68
|
+
* tenantId: string, // required when recipient === "tenant"
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* var pair = b.crypto.generateEncryptionKeyPair();
|
|
72
|
+
* var sealed = b.archive.wrap(tarBytes, { recipient: pair });
|
|
73
|
+
* // sealed is a Buffer carrying BAWRP+version+envelope; write to
|
|
74
|
+
* // any adapter sink. On read, hand to b.archive.unwrap with the
|
|
75
|
+
* // matching privKeys to recover tarBytes.
|
|
76
|
+
*/
|
|
77
|
+
function wrap(bytes, opts) {
|
|
78
|
+
opts = opts || {};
|
|
79
|
+
if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
|
|
80
|
+
throw new ArchiveWrapError("archive-wrap/bad-input",
|
|
81
|
+
"wrap: bytes must be a Buffer or Uint8Array");
|
|
82
|
+
}
|
|
83
|
+
if (bytes.length === 0) {
|
|
84
|
+
throw new ArchiveWrapError("archive-wrap/empty-input",
|
|
85
|
+
"wrap: bytes is empty — nothing to seal");
|
|
86
|
+
}
|
|
87
|
+
if (!opts.recipient) {
|
|
88
|
+
throw new ArchiveWrapError("archive-wrap/no-recipient",
|
|
89
|
+
"wrap: opts.recipient is required (static key object | \"tenant\" string | peer-cert object)");
|
|
90
|
+
}
|
|
91
|
+
var envelope = _encryptForRecipient(bytes, opts);
|
|
92
|
+
// envelope is a base64 string from b.crypto.encrypt. Buffer it and
|
|
93
|
+
// prepend the 6-byte archive-wrap header so safeArchive's sniffer
|
|
94
|
+
// can identify it without attempting decryption.
|
|
95
|
+
var envelopeBuf = Buffer.from(envelope, "utf-8");
|
|
96
|
+
var header = Buffer.alloc(ARCH_WRAP_HEADER_BYTES);
|
|
97
|
+
header.write(ARCH_WRAP_MAGIC, 0, 5, "ascii");
|
|
98
|
+
header[5] = ARCH_WRAP_VERSION;
|
|
99
|
+
return Buffer.concat([header, envelopeBuf]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @primitive b.archive.unwrap
|
|
104
|
+
* @signature b.archive.unwrap(sealed, opts)
|
|
105
|
+
* @since 0.12.10
|
|
106
|
+
* @status stable
|
|
107
|
+
* @related b.archive.wrap, b.crypto.decrypt
|
|
108
|
+
*
|
|
109
|
+
* Recover archive bytes from a recipient-encrypted envelope produced
|
|
110
|
+
* by `b.archive.wrap`. Verifies the 6-byte `BAWRP` header before
|
|
111
|
+
* attempting decryption so non-envelope inputs (raw archive bytes,
|
|
112
|
+
* other-magic envelopes) fail with `archive-wrap/bad-magic` rather
|
|
113
|
+
* than a crypto-level error.
|
|
114
|
+
*
|
|
115
|
+
* @opts
|
|
116
|
+
* recipient: object, // { privateKey, ecPrivateKey } | { certPrivateKey, kemSecret }; required
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* var bytes = b.archive.unwrap(sealed, { recipient: privPair });
|
|
120
|
+
* var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
|
|
121
|
+
*/
|
|
122
|
+
function unwrap(sealed, opts) {
|
|
123
|
+
opts = opts || {};
|
|
124
|
+
if (!Buffer.isBuffer(sealed) && !(sealed instanceof Uint8Array)) {
|
|
125
|
+
throw new ArchiveWrapError("archive-wrap/bad-input",
|
|
126
|
+
"unwrap: sealed must be a Buffer or Uint8Array");
|
|
127
|
+
}
|
|
128
|
+
if (sealed.length < ARCH_WRAP_HEADER_BYTES) {
|
|
129
|
+
throw new ArchiveWrapError("archive-wrap/bad-magic",
|
|
130
|
+
"unwrap: input shorter than 6-byte archive-wrap header");
|
|
131
|
+
}
|
|
132
|
+
var buf = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed);
|
|
133
|
+
var magic = buf.slice(0, 5).toString("ascii");
|
|
134
|
+
if (magic !== ARCH_WRAP_MAGIC) {
|
|
135
|
+
throw new ArchiveWrapError("archive-wrap/bad-magic",
|
|
136
|
+
"unwrap: input does not start with archive-wrap magic " +
|
|
137
|
+
JSON.stringify(ARCH_WRAP_MAGIC) + "; got " + JSON.stringify(magic));
|
|
138
|
+
}
|
|
139
|
+
var version = buf[5];
|
|
140
|
+
if (version !== ARCH_WRAP_VERSION) {
|
|
141
|
+
throw new ArchiveWrapError("archive-wrap/bad-version",
|
|
142
|
+
"unwrap: archive-wrap version " + version + " not supported by this build");
|
|
143
|
+
}
|
|
144
|
+
if (!opts.recipient || typeof opts.recipient !== "object") {
|
|
145
|
+
throw new ArchiveWrapError("archive-wrap/no-recipient",
|
|
146
|
+
"unwrap: opts.recipient is required ({ privateKey, ecPrivateKey } " +
|
|
147
|
+
"for the static-key path, { certPrivateKey, kemSecret } for the peer-cert path)");
|
|
148
|
+
}
|
|
149
|
+
var envelope = buf.slice(ARCH_WRAP_HEADER_BYTES).toString("utf-8");
|
|
150
|
+
var plaintext;
|
|
151
|
+
try {
|
|
152
|
+
if (opts.recipient.certPrivateKey) {
|
|
153
|
+
// Cert-peer path: encryptEnvelopeAsCertPeer composed
|
|
154
|
+
// `encrypt(bytes, { publicKey, ecPublicKey })` where the
|
|
155
|
+
// ecPublicKey was extracted from the cert. The inverse passes
|
|
156
|
+
// the operator's kemSecret + certPrivateKey (P-384) through
|
|
157
|
+
// the same decrypt code path. raw:true preserves binary
|
|
158
|
+
// archive bytes losslessly.
|
|
159
|
+
plaintext = bCrypto().decrypt(envelope, {
|
|
160
|
+
privateKey: opts.recipient.kemSecret,
|
|
161
|
+
ecPrivateKey: opts.recipient.certPrivateKey,
|
|
162
|
+
}, { raw: true });
|
|
163
|
+
} else {
|
|
164
|
+
// raw:true returns the decrypted Buffer (lossless for arbitrary
|
|
165
|
+
// binary archive payloads — utf-8 string conversion would
|
|
166
|
+
// corrupt gzip / zip / tar bytes).
|
|
167
|
+
plaintext = bCrypto().decrypt(envelope, opts.recipient, { raw: true });
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
var err = new ArchiveWrapError("archive-wrap/decrypt-failed",
|
|
171
|
+
"unwrap: envelope decryption refused: " + ((e && e.message) || String(e)));
|
|
172
|
+
err.cause = e;
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _encryptForRecipient(bytes, opts) {
|
|
179
|
+
var r = opts.recipient;
|
|
180
|
+
if (typeof r === "string") {
|
|
181
|
+
if (r === "tenant") {
|
|
182
|
+
// tenant strategy lands in v0.12.11 alongside the backup
|
|
183
|
+
// cryptoStrategy adoption — refuse cleanly for v0.12.10 so
|
|
184
|
+
// operators see the deferred-shape contract.
|
|
185
|
+
throw new ArchiveWrapError("archive-wrap/tenant-strategy-deferred",
|
|
186
|
+
"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");
|
|
187
|
+
}
|
|
188
|
+
throw new ArchiveWrapError("archive-wrap/bad-recipient",
|
|
189
|
+
"wrap: recipient string " + JSON.stringify(r) + " not recognised; \"tenant\" deferred to v0.12.11");
|
|
190
|
+
}
|
|
191
|
+
if (r.peerCertDer || r.peerKemPubkey) {
|
|
192
|
+
if (!r.peerCertDer || !r.peerKemPubkey) {
|
|
193
|
+
throw new ArchiveWrapError("archive-wrap/bad-recipient",
|
|
194
|
+
"wrap: peer-cert strategy requires BOTH peerCertDer + peerKemPubkey");
|
|
195
|
+
}
|
|
196
|
+
return bCrypto().encryptEnvelopeAsCertPeer(bytes, {
|
|
197
|
+
peerCertDer: r.peerCertDer,
|
|
198
|
+
peerKemPubkey: r.peerKemPubkey,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (r.publicKey) {
|
|
202
|
+
// Codex P2 on v0.12.10 PR #161 — b.crypto.encrypt falls back to
|
|
203
|
+
// ML-KEM-only when ecPublicKey is undefined (with a one-shot
|
|
204
|
+
// audit). For archive-wrap's recipient contract the hybrid leg
|
|
205
|
+
// (P-384 ECDH defence-in-depth backstop on top of ML-KEM-1024)
|
|
206
|
+
// is the documented behaviour; refuse upfront so partial
|
|
207
|
+
// recipient objects can't silently degrade the seal posture.
|
|
208
|
+
// Operators who genuinely want KEM-only call
|
|
209
|
+
// b.crypto.encryptMlkem768X25519 directly.
|
|
210
|
+
if (!r.ecPublicKey) {
|
|
211
|
+
throw new ArchiveWrapError("archive-wrap/hybrid-required",
|
|
212
|
+
"wrap: static-key recipient requires BOTH publicKey (ML-KEM-1024 PEM) " +
|
|
213
|
+
"and ecPublicKey (P-384 ECDH PEM). Partial recipients trip b.crypto.encrypt's " +
|
|
214
|
+
"ML-KEM-only fallback which silently degrades the hybrid contract this primitive promises.");
|
|
215
|
+
}
|
|
216
|
+
return bCrypto().encrypt(bytes, {
|
|
217
|
+
publicKey: r.publicKey,
|
|
218
|
+
ecPublicKey: r.ecPublicKey,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
throw new ArchiveWrapError("archive-wrap/bad-recipient",
|
|
222
|
+
"wrap: recipient must be { publicKey, ecPublicKey } | { peerCertDer, peerKemPubkey } | \"tenant\"");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _isWrapMagic(buf) {
|
|
226
|
+
return buf.length >= ARCH_WRAP_HEADER_BYTES &&
|
|
227
|
+
buf.slice(0, 5).toString("ascii") === ARCH_WRAP_MAGIC;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
wrap: wrap,
|
|
232
|
+
unwrap: unwrap,
|
|
233
|
+
ArchiveWrapError: ArchiveWrapError,
|
|
234
|
+
// Exposed for sibling modules + sniffer
|
|
235
|
+
_isWrapMagic: _isWrapMagic,
|
|
236
|
+
ARCH_WRAP_MAGIC: ARCH_WRAP_MAGIC,
|
|
237
|
+
};
|
|
@@ -546,14 +546,18 @@ var archiveRead = require("./archive-read");
|
|
|
546
546
|
var archiveTar = require("./archive-tar");
|
|
547
547
|
var archiveTarRead = require("./archive-tar-read");
|
|
548
548
|
var archiveGz = require("./archive-gz");
|
|
549
|
+
var archiveWrap = require("./archive-wrap");
|
|
549
550
|
|
|
550
551
|
module.exports = {
|
|
551
|
-
zip:
|
|
552
|
-
tar:
|
|
553
|
-
gz:
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
552
|
+
zip: zip,
|
|
553
|
+
tar: archiveTar.tar,
|
|
554
|
+
gz: archiveGz.gz,
|
|
555
|
+
wrap: archiveWrap.wrap,
|
|
556
|
+
unwrap: archiveWrap.unwrap,
|
|
557
|
+
ArchiveError: ArchiveError,
|
|
558
|
+
TarError: archiveTar.TarError,
|
|
559
|
+
ArchiveGzError: archiveGz.ArchiveGzError,
|
|
560
|
+
ArchiveWrapError: archiveWrap.ArchiveWrapError,
|
|
557
561
|
read: {
|
|
558
562
|
zip: archiveRead.zip,
|
|
559
563
|
tar: archiveTarRead.tar,
|
|
@@ -1068,6 +1068,58 @@ function bundleAdapterStorage(opts) {
|
|
|
1068
1068
|
throw new BackupError("backup/bad-format",
|
|
1069
1069
|
"bundleAdapterStorage: format must be \"tar\" (default) | \"tar.gz\" (v0.12.9 compressed) | \"directory\" (legacy v0.12.7)");
|
|
1070
1070
|
}
|
|
1071
|
+
// v0.12.10 — cryptoStrategy gates whether the bundle bytes are
|
|
1072
|
+
// wrapped in a recipient envelope before adapter.writeFile.
|
|
1073
|
+
// "none" — plaintext bundle (v0.12.7-9 behaviour). Safe
|
|
1074
|
+
// for adapter-encrypted storage (S3 SSE,
|
|
1075
|
+
// disk-encrypted hosts) where the storage layer
|
|
1076
|
+
// itself is the protective boundary.
|
|
1077
|
+
// "recipient" — composes b.archive.wrap on write +
|
|
1078
|
+
// b.archive.unwrap on read. Operator supplies
|
|
1079
|
+
// `recipient: { publicKey, ecPublicKey }` (or
|
|
1080
|
+
// a peer-cert recipient); the bundle bytes
|
|
1081
|
+
// hitting the adapter are an opaque envelope.
|
|
1082
|
+
// HIPAA / PCI-DSS postures (per
|
|
1083
|
+
// BACKUP_ENCRYPTION_REQUIRED_POSTURES) REFUSE
|
|
1084
|
+
// "none" + require "recipient".
|
|
1085
|
+
var cryptoStrategy = opts.cryptoStrategy || "none";
|
|
1086
|
+
if (cryptoStrategy !== "none" && cryptoStrategy !== "recipient") {
|
|
1087
|
+
throw new BackupError("backup/bad-crypto-strategy",
|
|
1088
|
+
"bundleAdapterStorage: cryptoStrategy must be \"none\" (default — adapter-encrypted storage) " +
|
|
1089
|
+
"or \"recipient\" (v0.12.10 — wraps bundle bytes in a hybrid PQC envelope before writeFile). " +
|
|
1090
|
+
"Passphrase strategy is deferred to v0.12.11.");
|
|
1091
|
+
}
|
|
1092
|
+
var recipient = opts.recipient;
|
|
1093
|
+
if (cryptoStrategy === "recipient" && (!recipient || typeof recipient !== "object")) {
|
|
1094
|
+
throw new BackupError("backup/no-recipient",
|
|
1095
|
+
"bundleAdapterStorage: cryptoStrategy: \"recipient\" requires opts.recipient " +
|
|
1096
|
+
"({ publicKey, ecPublicKey } for the hybrid PQC envelope OR { peerCertDer, peerKemPubkey } " +
|
|
1097
|
+
"for the peer-cert envelope)");
|
|
1098
|
+
}
|
|
1099
|
+
// Codex P1 on v0.12.10 PR #161 — the wrap layer composes only
|
|
1100
|
+
// with the tar / tar.gz writeBundle branches. Pairing recipient
|
|
1101
|
+
// strategy with format: "directory" would silently write plaintext
|
|
1102
|
+
// per-file payloads because the directory branch doesn't apply
|
|
1103
|
+
// the envelope. Refuse the combination upfront so operators see
|
|
1104
|
+
// the contract gap rather than discover it via disk inspection.
|
|
1105
|
+
// Per-file recipient encryption for directory format is a v0.12.11
|
|
1106
|
+
// follow-up alongside the _crypto-base.js refactor.
|
|
1107
|
+
if (cryptoStrategy === "recipient" && format === "directory") {
|
|
1108
|
+
throw new BackupError("backup/recipient-strategy-needs-bundled-format",
|
|
1109
|
+
"bundleAdapterStorage: cryptoStrategy: \"recipient\" requires format: \"tar\" or \"tar.gz\". " +
|
|
1110
|
+
"Directory format writes per-file plaintext to the adapter — the wrap layer composes only " +
|
|
1111
|
+
"with tar / tar.gz bundles in v0.12.10. Per-file recipient encryption is a v0.12.11 follow-up.");
|
|
1112
|
+
}
|
|
1113
|
+
var posture = opts.posture;
|
|
1114
|
+
if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1 &&
|
|
1115
|
+
cryptoStrategy === "none") {
|
|
1116
|
+
throw new BackupError("backup/posture-requires-encryption",
|
|
1117
|
+
"bundleAdapterStorage: posture=" + JSON.stringify(posture) +
|
|
1118
|
+
" requires cryptoStrategy: \"recipient\" (the adapter-storage layer cannot itself " +
|
|
1119
|
+
"satisfy HIPAA / PCI-DSS encryption-at-rest with cryptoStrategy: \"none\"). " +
|
|
1120
|
+
"The recipient+directory combination is refused separately so operators don't slip " +
|
|
1121
|
+
"plaintext per-file payloads past the posture gate.");
|
|
1122
|
+
}
|
|
1071
1123
|
// Codex P2 on v0.12.8 PR #159 — tar mode builds the whole archive
|
|
1072
1124
|
// in memory before adapter.writeFile because the v0.12.8 adapter
|
|
1073
1125
|
// contract is bytes-in (no writeStream method). The OOM-prevention
|
|
@@ -1175,6 +1227,9 @@ function bundleAdapterStorage(opts) {
|
|
|
1175
1227
|
var payloadBytes = format === "tar.gz"
|
|
1176
1228
|
? archiveLazy().gz(t.toBuffer()).toBuffer()
|
|
1177
1229
|
: t.toBuffer();
|
|
1230
|
+
if (cryptoStrategy === "recipient") {
|
|
1231
|
+
payloadBytes = archiveLazy().wrap(payloadBytes, { recipient: recipient });
|
|
1232
|
+
}
|
|
1178
1233
|
await adapter.writeFile(bundleId + keySuffix, payloadBytes);
|
|
1179
1234
|
return;
|
|
1180
1235
|
}
|
|
@@ -1215,6 +1270,9 @@ function bundleAdapterStorage(opts) {
|
|
|
1215
1270
|
// ratio; without these opts the same primitive writes
|
|
1216
1271
|
// bundles it can't read back.
|
|
1217
1272
|
var gzBytes = await adapter.readFile(bundleId + TAR_GZ_KEY_SUFFIX);
|
|
1273
|
+
if (cryptoStrategy === "recipient") {
|
|
1274
|
+
gzBytes = archiveLazy().unwrap(gzBytes, { recipient: recipient });
|
|
1275
|
+
}
|
|
1218
1276
|
var gzReader = archiveLazy().read.gz(archiveAdaptersLazy().buffer(gzBytes), {
|
|
1219
1277
|
maxDecompressedBytes: maxBundleBytes,
|
|
1220
1278
|
maxExpansionRatio: 0,
|
|
@@ -1225,6 +1283,9 @@ function bundleAdapterStorage(opts) {
|
|
|
1225
1283
|
}
|
|
1226
1284
|
if (hasTar) {
|
|
1227
1285
|
var tarBytes = await adapter.readFile(bundleId + TAR_KEY_SUFFIX);
|
|
1286
|
+
if (cryptoStrategy === "recipient") {
|
|
1287
|
+
tarBytes = archiveLazy().unwrap(tarBytes, { recipient: recipient });
|
|
1288
|
+
}
|
|
1228
1289
|
var reader = archiveLazy().read.tar(archiveAdaptersLazy().buffer(tarBytes));
|
|
1229
1290
|
await reader.extract({ destination: destDir });
|
|
1230
1291
|
return;
|
|
@@ -1341,7 +1402,15 @@ bundleAdapterStorage.fsAdapter = function (fsOpts) {
|
|
|
1341
1402
|
async writeFile(key, bytes) {
|
|
1342
1403
|
var path = _keyPath(key);
|
|
1343
1404
|
atomicFile.ensureDir(nodePath.dirname(path));
|
|
1344
|
-
|
|
1405
|
+
// mode 0o600 matches the v0.12.9 directory-format readback
|
|
1406
|
+
// discipline — backup payloads carry operator-owned bytes
|
|
1407
|
+
// (potentially PHI / PCI / GDPR-scoped); owner-only is the
|
|
1408
|
+
// strict posture. wx is not set here because writeFile is
|
|
1409
|
+
// the storage primitive (operators legitimately rewrite the
|
|
1410
|
+
// same key, e.g. resuming a multipart upload); upper layers
|
|
1411
|
+
// (writeBundle's `bundle-exists` check) enforce no-overwrite
|
|
1412
|
+
// at the bundle level.
|
|
1413
|
+
nodeFs.writeFileSync(path, bytes, { mode: 0o600 });
|
|
1345
1414
|
},
|
|
1346
1415
|
async readFile(key) {
|
|
1347
1416
|
var path = _keyPath(key);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.10",
|
|
4
|
+
"date": "2026-05-23",
|
|
5
|
+
"headline": "`b.archive.wrap` + `b.archive.unwrap` — recipient-encrypted archive envelopes (Flavor 1) + `b.backup` `cryptoStrategy: \"recipient\"` + HIPAA/PCI-DSS posture refusal",
|
|
6
|
+
"summary": "Flavor 1 lands as the whole-archive recipient-wrap substrate. `b.archive.wrap(bytes, { recipient })` produces a sealed envelope under the framework's hybrid PQC seal (ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305) prefixed with a 6-byte `BAWRP` archive-wrap header so format sniffers can identify wrap envelopes without trial decryption. `b.archive.unwrap(sealed, { recipient })` is the inverse with magic-check upfront so non-envelope inputs throw `archive-wrap/bad-magic` rather than a crypto-level error. Recipient strategies: static keypair (`{ publicKey, ecPublicKey }`) and peer-cert (`{ peerCertDer, peerKemPubkey }`); the tenant strategy lands in v0.12.11 alongside the backup-crypto refactor + per-tenant key resolution. `b.backup.bundleAdapterStorage({ cryptoStrategy: \"recipient\", recipient })` composes the wrap/unwrap layer transparently: the bytes hitting the adapter's `writeFile` are a `BAWRP`-prefixed envelope, never the raw tar / tar.gz / directory bundle. HIPAA + PCI-DSS postures refuse `cryptoStrategy: \"none\"` upfront with `backup/posture-requires-encryption` — the storage adapter cannot itself satisfy the encryption-at-rest requirement; the recipient envelope is the framework-side gate. Flavor 2 (per-entry ZIP wrap with the 0xBADC extra-field marker) and the backup-crypto refactor into `lib/_crypto-base.js` ship in v0.12.11.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.archive.wrap(bytes, { recipient })` — recipient-encrypted archive envelope",
|
|
13
|
+
"body": "Composes `b.crypto.encrypt` (or `b.crypto.encryptEnvelopeAsCertPeer` for the peer-cert strategy) under the framework's hybrid PQC seal. The output is a Buffer carrying a 6-byte `BAWRP` archive-wrap header (5-byte magic + 1-byte version) followed by the base64-encoded envelope bytes. Recipient strategies: `{ publicKey, ecPublicKey }` for the static-keypair path (ML-KEM-1024 PEM + P-384 ECDH PEM); `{ peerCertDer, peerKemPubkey }` for the peer-cert path (extracts the P-384 half from the cert per `b.crypto.encryptEnvelopeAsCertPeer`). `\"tenant\"` returns `archive-wrap/tenant-strategy-deferred` upfront — that strategy lands in v0.12.11 with the per-tenant key resolution."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.archive.unwrap(sealed, { recipient })` — inverse with upfront magic check",
|
|
17
|
+
"body": "Verifies the 6-byte `BAWRP` header before any cryptographic work so non-envelope inputs (raw archives, other-magic envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` rather than a downstream `crypto/*` error. Routes through `b.crypto.decrypt(envelope, recipient, { raw: true })` so binary archive payloads (gzip, ZIP, tar) round-trip losslessly — `raw: true` is the contract that preserves bytes vs the default utf-8 decoding."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.backup.bundleAdapterStorage({ cryptoStrategy: \"recipient\", recipient })` — opt-in envelope storage",
|
|
21
|
+
"body": "`cryptoStrategy: \"none\"` (default, v0.12.7-9 behaviour) writes plaintext bundle bytes to the adapter — safe for storage layers that are themselves the protective boundary (S3 SSE, disk-encrypted hosts). `cryptoStrategy: \"recipient\"` requires `opts.recipient` and wraps every `writeBundle` payload through `b.archive.wrap` before `adapter.writeFile`; `readBundle` unwraps after `adapter.readFile`. The wrap layer sits OUTSIDE the gz / tar layers so the bundle on disk is opaque ciphertext under the operator-controlled recipient key. Passphrase strategy is deferred to v0.12.11 alongside the `_crypto-base.js` refactor."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "HIPAA + PCI-DSS posture refuses plaintext bundles",
|
|
25
|
+
"body": "`bundleAdapterStorage({ posture: \"hipaa\" })` (or `\"pci-dss\"`) refuses `cryptoStrategy: \"none\"` upfront with `backup/posture-requires-encryption` — adapter-storage's plaintext default cannot itself satisfy encryption-at-rest requirements. Operators under these postures pass `cryptoStrategy: \"recipient\"` + a recipient key. The refusal message includes the posture name + the strategy that fails so audit-trail operators see exactly which gate blocked the call."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"heading": "Security",
|
|
31
|
+
"items": [
|
|
32
|
+
{
|
|
33
|
+
"title": "Wrap envelope is the framework's hybrid PQC seal — ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305",
|
|
34
|
+
"body": "Defence-in-depth posture: a CRQC against ML-KEM-1024 alone still has to defeat the classical P-384 ECDH leg; a future ECDH break alone still has to defeat ML-KEM-1024. The 4-byte envelope header (magic + KEM ID + cipher ID + KDF ID) is bound as AEAD AAD so a header-substitution attack fails Poly1305 verification. `b.archive.wrap` prepends a separate 6-byte archive-wrap header BEFORE the base64 envelope so format sniffing can identify wrap output without trial decryption — non-envelope inputs are refused at byte 0-4 (magic check) instead of after fruitless decapsulation work."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"heading": "Detectors",
|
|
40
|
+
"items": [
|
|
41
|
+
{
|
|
42
|
+
"title": "`backup-adapter-storage-without-posture-check` — postures that mandate encryption must propagate to `cryptoStrategy`",
|
|
43
|
+
"body": "When a primitive that wires `b.backup.bundleAdapterStorage` carries a `posture:` opt drawn from the HIPAA / PCI-DSS / etc. set, the same code path must propagate `cryptoStrategy: \"recipient\"` (or refuse before reaching writeBundle). The detector matches `bundleAdapterStorage({ ... posture: ... })` invocations in `lib/` and requires a matching `cryptoStrategy` opt; missing it surfaces during the codebase-patterns gate so a future caller can't silently drop the contract."
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"heading": "Migration",
|
|
49
|
+
"items": [
|
|
50
|
+
{
|
|
51
|
+
"title": "Flavor 2 — per-entry ZIP recipient wrap with 0xBADC extra-field",
|
|
52
|
+
"body": "Per-entry encryption inside the carrier ZIP (method=STORE with the encrypted bytes as the stored payload + a 0xBADC user-defined-range extra-field marker carrying the recipient hint). Inspect-without-decrypt is the operator value: entry list + name-safety gating happens BEFORE any key resolution. Lands in v0.12.11 alongside the backup-crypto refactor."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"title": "`lib/_crypto-base.js` refactor — backup-crypto, Flavor 1, Flavor 2 share substrate",
|
|
56
|
+
"body": "The legacy per-file Argon2id + XChaCha20-Poly1305 path in `lib/backup/crypto.js` gets factored into a private `_crypto-base.js` helper so all three encryption flavors compose the same primitive set. No operator-visible API change; closes the each-feature-rolls-its-own-crypto smell."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"title": "`cryptoStrategy: \"passphrase\"` + tenant strategy",
|
|
60
|
+
"body": "Passphrase strategy on `bundleAdapterStorage` (Argon2id-derived key + XChaCha20-Poly1305) and the `\"tenant\"` recipient string (composes `b.vault.derivedKey({ tenant, purpose: \"archive-wrap\" })`) both ship in v0.12.11. The v0.12.10 surface is the recipient substrate; v0.12.11 lights up the per-tenant + passphrase strategies that consume it."
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.archive.wrap + b.archive.unwrap + bundleAdapterStorage
|
|
4
|
+
* cryptoStrategy: "recipient" + posture-enforced encryption.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
var fs = require("node:fs");
|
|
8
|
+
var path = require("node:path");
|
|
9
|
+
var os = require("node:os");
|
|
10
|
+
var b = require("../../index");
|
|
11
|
+
var helpers = require("../helpers");
|
|
12
|
+
var check = helpers.check;
|
|
13
|
+
|
|
14
|
+
async function testWrapUnwrapRoundTrip() {
|
|
15
|
+
var pair = b.crypto.generateEncryptionKeyPair();
|
|
16
|
+
var src = Buffer.from("opaque archive bytes ".repeat(50));
|
|
17
|
+
var sealed = b.archive.wrap(src, { recipient: pair });
|
|
18
|
+
check("archive.wrap: output carries BAWRP magic",
|
|
19
|
+
sealed.slice(0, 5).toString("ascii") === "BAWRP");
|
|
20
|
+
check("archive.wrap: output version byte present",
|
|
21
|
+
sealed[5] === 0x01);
|
|
22
|
+
var recovered = b.archive.unwrap(sealed, { recipient: pair });
|
|
23
|
+
check("archive.wrap → unwrap: bytes round-trip losslessly",
|
|
24
|
+
recovered.equals(src));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function testWrapRefusesBadMagic() {
|
|
28
|
+
var notSealed = Buffer.from("this is not a wrap envelope");
|
|
29
|
+
var refused = null;
|
|
30
|
+
try {
|
|
31
|
+
b.archive.unwrap(notSealed, { recipient: b.crypto.generateEncryptionKeyPair() });
|
|
32
|
+
} catch (e) { refused = e; }
|
|
33
|
+
check("archive.unwrap: non-BAWRP input refused with bad-magic",
|
|
34
|
+
refused && /bad-magic/.test(refused.code || refused.message));
|
|
35
|
+
check("archive.unwrap: refusal is a b.archive.ArchiveWrapError",
|
|
36
|
+
refused instanceof b.archive.ArchiveWrapError);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function testWrapRefusesWrongKey() {
|
|
40
|
+
var sender = b.crypto.generateEncryptionKeyPair();
|
|
41
|
+
var attacker = b.crypto.generateEncryptionKeyPair();
|
|
42
|
+
var sealed = b.archive.wrap(Buffer.from("PHI"), { recipient: sender });
|
|
43
|
+
var refused = null;
|
|
44
|
+
try { b.archive.unwrap(sealed, { recipient: attacker }); } catch (e) { refused = e; }
|
|
45
|
+
check("archive.unwrap: wrong recipient key refused",
|
|
46
|
+
refused && /decrypt-failed/.test(refused.code || refused.message));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function testWrapRefusesPartialStaticRecipient() {
|
|
50
|
+
// Codex P2 on v0.12.10 PR #161 — partial recipient ({ publicKey }
|
|
51
|
+
// alone) silently triggered b.crypto.encrypt's ML-KEM-only
|
|
52
|
+
// fallback, degrading the documented hybrid contract.
|
|
53
|
+
var pair = b.crypto.generateEncryptionKeyPair();
|
|
54
|
+
var refused = null;
|
|
55
|
+
try {
|
|
56
|
+
b.archive.wrap(Buffer.from("bytes"), { recipient: { publicKey: pair.publicKey } });
|
|
57
|
+
} catch (e) { refused = e; }
|
|
58
|
+
check("archive.wrap: refuses partial static recipient (missing ecPublicKey)",
|
|
59
|
+
refused && /hybrid-required/.test(refused.code || refused.message));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function testBackupRecipientDirectoryRefused() {
|
|
63
|
+
// Codex P1 on v0.12.10 PR #161 — recipient strategy + directory
|
|
64
|
+
// format would write plaintext per-file; refuse upfront.
|
|
65
|
+
var refused = null;
|
|
66
|
+
try {
|
|
67
|
+
b.backup.bundleAdapterStorage({
|
|
68
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: os.tmpdir() }),
|
|
69
|
+
format: "directory",
|
|
70
|
+
cryptoStrategy: "recipient",
|
|
71
|
+
recipient: b.crypto.generateEncryptionKeyPair(),
|
|
72
|
+
});
|
|
73
|
+
} catch (e) { refused = e; }
|
|
74
|
+
check("backup: recipient + directory format refused upfront",
|
|
75
|
+
refused && /recipient-strategy-needs-bundled-format/.test(refused.code || refused.message));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function testWrapRequiresRecipient() {
|
|
79
|
+
var refused = null;
|
|
80
|
+
try { b.archive.wrap(Buffer.from("bytes"), {}); } catch (e) { refused = e; }
|
|
81
|
+
check("archive.wrap: missing recipient refused upfront",
|
|
82
|
+
refused && /no-recipient/.test(refused.code || refused.message));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function testTenantStrategyDeferred() {
|
|
86
|
+
var refused = null;
|
|
87
|
+
try {
|
|
88
|
+
b.archive.wrap(Buffer.from("bytes"), { recipient: "tenant", tenantId: "alpha" });
|
|
89
|
+
} catch (e) { refused = e; }
|
|
90
|
+
check("archive.wrap: tenant strategy deferred to v0.12.11 with explicit message",
|
|
91
|
+
refused && /tenant-strategy-deferred/.test(refused.code || refused.message));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function testBackupRecipientRoundTrip() {
|
|
95
|
+
var pair = b.crypto.generateEncryptionKeyPair();
|
|
96
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-wrap-src-"));
|
|
97
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-wrap-dest-"));
|
|
98
|
+
var verify = path.join(os.tmpdir(), "bjs-wrap-verify-" + Date.now());
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(path.join(src, "phi.json"), "{\"patient\":42,\"dx\":\"redacted\"}");
|
|
101
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
102
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
103
|
+
format: "tar.gz",
|
|
104
|
+
cryptoStrategy: "recipient",
|
|
105
|
+
recipient: pair,
|
|
106
|
+
});
|
|
107
|
+
var bundleId = "2026-05-23T18-15-00-000Z-bbbbbbbb";
|
|
108
|
+
await storage.writeBundle(bundleId, src);
|
|
109
|
+
var sealed = fs.readFileSync(path.join(dest, bundleId, "bundle.tar.gz"));
|
|
110
|
+
check("backup recipient: bundle on disk carries BAWRP magic (not gzip)",
|
|
111
|
+
sealed.slice(0, 5).toString("ascii") === "BAWRP");
|
|
112
|
+
await storage.readBundle(bundleId, verify);
|
|
113
|
+
check("backup recipient: phi.json round-trips after unwrap+gunzip+untar",
|
|
114
|
+
fs.readFileSync(path.join(verify, "phi.json"), "utf-8") === "{\"patient\":42,\"dx\":\"redacted\"}");
|
|
115
|
+
} finally {
|
|
116
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
117
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
118
|
+
try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function testBackupRecipientStrategyRequiresKeys() {
|
|
123
|
+
var refused = null;
|
|
124
|
+
try {
|
|
125
|
+
b.backup.bundleAdapterStorage({
|
|
126
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: os.tmpdir() }),
|
|
127
|
+
cryptoStrategy: "recipient",
|
|
128
|
+
});
|
|
129
|
+
} catch (e) { refused = e; }
|
|
130
|
+
check("backup: cryptoStrategy: recipient without keys refused upfront",
|
|
131
|
+
refused && /no-recipient/.test(refused.code || refused.message));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function testBackupPostureRefusesPlaintext() {
|
|
135
|
+
var refused = null;
|
|
136
|
+
try {
|
|
137
|
+
b.backup.bundleAdapterStorage({
|
|
138
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: os.tmpdir() }),
|
|
139
|
+
posture: "hipaa",
|
|
140
|
+
cryptoStrategy: "none",
|
|
141
|
+
});
|
|
142
|
+
} catch (e) { refused = e; }
|
|
143
|
+
check("backup: HIPAA posture refuses cryptoStrategy: none",
|
|
144
|
+
refused && /posture-requires-encryption/.test(refused.code || refused.message));
|
|
145
|
+
var refused2 = null;
|
|
146
|
+
try {
|
|
147
|
+
b.backup.bundleAdapterStorage({
|
|
148
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: os.tmpdir() }),
|
|
149
|
+
posture: "pci-dss",
|
|
150
|
+
});
|
|
151
|
+
} catch (e) { refused2 = e; }
|
|
152
|
+
check("backup: PCI-DSS posture refuses default cryptoStrategy",
|
|
153
|
+
refused2 && /posture-requires-encryption/.test(refused2.code || refused2.message));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function run() {
|
|
157
|
+
await testWrapUnwrapRoundTrip();
|
|
158
|
+
await testWrapRefusesBadMagic();
|
|
159
|
+
await testWrapRefusesWrongKey();
|
|
160
|
+
await testWrapRefusesPartialStaticRecipient();
|
|
161
|
+
await testWrapRequiresRecipient();
|
|
162
|
+
await testTenantStrategyDeferred();
|
|
163
|
+
await testBackupRecipientRoundTrip();
|
|
164
|
+
await testBackupRecipientStrategyRequiresKeys();
|
|
165
|
+
await testBackupRecipientDirectoryRefused();
|
|
166
|
+
await testBackupPostureRefusesPlaintext();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { run: run };
|
|
170
|
+
|
|
171
|
+
if (require.main === module) {
|
|
172
|
+
run().then(
|
|
173
|
+
function () { console.log("[archive-wrap] OK — " + helpers.getChecks() + " checks passed"); },
|
|
174
|
+
function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -5615,6 +5615,59 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
5615
5615
|
reason: "Codex P1 on v0.12.7 PR #158 — archive-read.extract used renameSync to atomically place each decompressed entry at its canonical destination + tracked written[].path for catch-block cleanup. When the destination directory was non-empty, the rename silently overwrote operator files; on extract abort, the cleanup deleted them. Fix: refuse upfront if destination path exists, force operators to use a fresh / empty subtree. Detector locks the shape: any extract code that tracks resolvedPath for catch-block cleanup MUST carry a `destination-exists` refusal in the same file.",
|
|
5616
5616
|
},
|
|
5617
5617
|
|
|
5618
|
+
{
|
|
5619
|
+
// Codex P2 on v0.12.10 PR #161 — partial recipient objects
|
|
5620
|
+
// ({ publicKey } alone) silently triggered b.crypto.encrypt's
|
|
5621
|
+
// ML-KEM-only fallback, degrading archive-wrap's documented
|
|
5622
|
+
// hybrid contract (ML-KEM-1024 + P-384 ECDH). Detector locks
|
|
5623
|
+
// the shape: any caller that constructs a recipient with
|
|
5624
|
+
// `publicKey:` MUST also carry `ecPublicKey:` in the same
|
|
5625
|
+
// object literal OR carry an `allow:archive-wrap-partial-recipient`
|
|
5626
|
+
// marker explaining why KEM-only is intentional (typically:
|
|
5627
|
+
// the operator explicitly opted into b.crypto.encryptMlkemOnly).
|
|
5628
|
+
id: "archive-wrap-recipient-missing-ec-half",
|
|
5629
|
+
primitive: "static-key recipients for b.archive.wrap / bundleAdapterStorage `recipient:` opt MUST carry BOTH publicKey (ML-KEM-1024 PEM) AND ecPublicKey (P-384 ECDH PEM). Partial recipients trip b.crypto.encrypt's ML-KEM-only fallback which silently degrades the hybrid defense-in-depth contract this surface promises.",
|
|
5630
|
+
// File-scoped: ANY recipient: { publicKey: ... } object literal
|
|
5631
|
+
// in lib/ MUST also include ecPublicKey in the same object.
|
|
5632
|
+
// Fires only when ` publicKey: ` appears inside a `recipient: {`
|
|
5633
|
+
// bracket; the codebase patterns walker is line-based so this
|
|
5634
|
+
// is approximate but catches the obvious smell.
|
|
5635
|
+
regex: /recipient:\s*\{\s*[^}]*publicKey:/,
|
|
5636
|
+
requires: /ecPublicKey|allow:archive-wrap-partial-recipient/,
|
|
5637
|
+
skipCommentLines: true,
|
|
5638
|
+
allowlist: [
|
|
5639
|
+
// archive-wrap.js IS the runtime-refusal site for partial
|
|
5640
|
+
// recipients (throws archive-wrap/hybrid-required); it
|
|
5641
|
+
// references partial-recipient shapes in error messages.
|
|
5642
|
+
"lib/archive-wrap.js",
|
|
5643
|
+
],
|
|
5644
|
+
reason: "Codex P2 on v0.12.10 PR #161 — archive-wrap's recipient contract is hybrid PQC by design. Partial recipient objects degrade to KEM-only with only a one-shot audit. Detector locks the static-side gate so library code composing wrap/unwrap can't silently drop the ECDH leg.",
|
|
5645
|
+
},
|
|
5646
|
+
|
|
5647
|
+
{
|
|
5648
|
+
// v0.12.10 — when bundleAdapterStorage carries a posture that
|
|
5649
|
+
// mandates encryption-at-rest (HIPAA / PCI-DSS / similar), the
|
|
5650
|
+
// same call-site MUST propagate cryptoStrategy: "recipient"
|
|
5651
|
+
// (or refuse upstream) — the storage adapter alone cannot
|
|
5652
|
+
// satisfy the regulatory contract. The library-internal refusal
|
|
5653
|
+
// at `backup/posture-requires-encryption` is the runtime gate;
|
|
5654
|
+
// this detector locks the shape at the static-analysis layer
|
|
5655
|
+
// so any future caller that drops cryptoStrategy from a
|
|
5656
|
+
// posture-bearing call surfaces during codebase-patterns.
|
|
5657
|
+
id: "backup-adapter-storage-without-posture-check",
|
|
5658
|
+
primitive: "any bundleAdapterStorage({ ... posture: ... }) call site that names a posture from the HIPAA / PCI-DSS / etc. set MUST also pass cryptoStrategy. The library-side refusal exists; the detector exists so the contract can't drift silently when a primitive composes bundleAdapterStorage indirectly.",
|
|
5659
|
+
regex: /bundleAdapterStorage\s*\([^)]*posture:/,
|
|
5660
|
+
requires: /cryptoStrategy|allow:backup-adapter-storage-without-posture-check/,
|
|
5661
|
+
skipCommentLines: true,
|
|
5662
|
+
allowlist: [
|
|
5663
|
+
// backup/index.js IS the primitive — the runtime refusal lives
|
|
5664
|
+
// there. Self-allowed so the detector doesn't flag the
|
|
5665
|
+
// refusal-emitting code itself.
|
|
5666
|
+
"lib/backup/index.js",
|
|
5667
|
+
],
|
|
5668
|
+
reason: "v0.12.10 — Flavor 1 recipient wrap lands as bundleAdapterStorage's cryptoStrategy: \"recipient\". HIPAA + PCI-DSS postures refuse cryptoStrategy: \"none\" at runtime; this detector adds the static-side gate so a primitive composing bundleAdapterStorage with a posture opt can't accidentally drop the cryptoStrategy propagation. Future Flavor 2 (per-entry, v0.12.11) extends the same contract.",
|
|
5669
|
+
},
|
|
5670
|
+
|
|
5618
5671
|
{
|
|
5619
5672
|
// Codex P1 + P2 on v0.12.9 PR #160 — backup readBundle's
|
|
5620
5673
|
// tar.gz restore path inherited archive.read.gz defaults (1 GiB
|
package/package.json
CHANGED