@blamejs/blamejs-shop 0.0.108 → 0.0.110
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/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/lib/archive-wrap.js +219 -9
- package/lib/vendor/blamejs/lib/archive.js +7 -5
- package/lib/vendor/blamejs/lib/backup/index.js +72 -25
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.11.json +39 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap-passphrase.test.js +207 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +22 -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.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.110 (2026-05-23) — **Unblock Cloudflare production deploys + vendor blamejs v0.12.11 + vendor-drift demoted to warning.** Production Cloudflare Workers deploys have been failing on every push since v0.0.102. Root cause: the container image excludes `worker/` via `.dockerignore` (the worker is deployed separately by `wrangler deploy`, the container only ships the long-running backend), but `test/smoke.js` runs inside the container build's `RUN node test/smoke.js` step and calls the `worker-syntax` gate, which tries to read `worker/index.js` and crashes with `ENOENT`. The Docker build exits 1, `wrangler deploy` aborts, and the production worker is left frozen at the last successful build. The fix: `scripts/check-worker-syntax.js` now skips with a no-op stderr message and exits 0 when its entry file isn't present in the current build context — outside the container (host smoke / CI / npm-publish) the entry is always present and the strict scan runs unchanged. Vendor refresh: blamejs v0.12.11 (carries upstream's matching disciplines). Vendor-drift gate demoted from failure to warning: drift surfaces on stderr (still visible in CI logs + operator terminal) but no longer blocks `npm test` — the committed vendor tree is the source of truth, and operators don't need to refresh on every blamejs release before they can ship an unrelated patch. **Changed:** *`vendor-update.sh --check` warns instead of failing on drift* — Drift between vendored blamejs and the latest upstream tag now surfaces on stderr as `[vendor-check] WARNING — vendored vA.B.C, latest vX.Y.Z` and exits 0. The committed vendor tree is the source of truth — there's no integrity loss from an older-but-still-supported vendor version, and forcing a refresh on every minor blamejs release before any unrelated patch could ship was friction without a corresponding safety win. CI logs and the operator's terminal still display the warning prominently. · *Vendor refresh: blamejs v0.12.10 → v0.12.11* — Standard shallow-clone refresh via `scripts/vendor-update.sh blamejs v0.12.11`. No code changes outside `lib/vendor/blamejs/`. **Fixed:** *Worker-syntax gate skips gracefully when `worker/index.js` is absent* — `scripts/check-worker-syntax.js` now checks for the existence of its entry file before scanning. When the file is missing (Cloudflare container build context, where `worker/` is excluded by `.dockerignore`), the gate logs `[worker-syntax] SKIPPED — <entry> not present in this build context (no worker tree to scan)` to stderr and exits 0. When the file IS present (host smoke runs, CI runners, npm-publish workflow, edge-render checks) the strict balance-walker scan runs unchanged and a missing trailing `}` still fails the build. Unblocks production deploys that have been failing since v0.0.102.
|
|
12
|
+
|
|
13
|
+
- v0.0.109 (2026-05-23) — **Blog-table-resilient D1 queries + tighter `edge-handler-catch-returns-null` regex.** Live probing surfaced that `/feed.xml` (503), `/sitemap.xml` (503), and `/blog` (500) all fail when the live D1 doesn't have the `blog_articles` table — migration `0189` ships with the blogArticles primitive (v0.0.75) but hadn't been applied to the live deployment yet. The four blog-touching queries in `worker/data/catalog.js` now treat `"no such table"` errors as "no published articles" and return empty rows, so the dependent edge routes degrade gracefully (feed renders no items, sitemap renders only the product + static URLs, /blog shows the empty-state). The `edge-handler-catch-returns-null` detector also tightens — the previous regex spanned 400 chars after the catch's opening `{`, which reached across function boundaries into the next function's guard-clause `return null;` and tripped a false positive. The new regex uses `[^}]` to stay inside the catch body. **Fixed:** *Blog-table-resilient queries in `worker/data/catalog.js`* — `listPublishedBlogSlugs`, `listBlogArticles`, `getBlogArticleBySlug`, and `recentBlogArticles` now catch `"no such table"` errors from D1 and return empty rows / null. Operators who haven't applied migration `0189_blog_articles.sql` (or who deploy the worker before the migrations land) get a clean degradation instead of `/feed.xml` 503 / `/sitemap.xml` 503 / `/blog` 500. Non-table-missing errors still propagate to the caller's `_edgeError` for the canonical 5xx render. · *`edge-handler-catch-returns-null` regex tightened — no more cross-function false positives* — Previous regex: `catch\s*\(\s*[\w$]+\s*\)\s*\{[\s\S]{0,400}?return\s+null\s*;` — the `[\s\S]` matches anything including the catch's closing `}`, so a small catch body followed shortly by a function-guard `return null;` was matching as if the null were inside the catch. New regex: `catch\s*\(\s*[\w$]+\s*\)\s*\{[^}]{0,400}return\s+null\s*;` — `[^}]` keeps the match inside the catch body. Catches still flagged for injection-tested anti-pattern (`catch (e) { return null; }`); the blog-query data layer's guard-clause `return null` outside any catch is no longer flagged. · *Blog-query refactor — null-return moves outside the catch body* — `getBlogArticleBySlug` now uses a `var row = null` outer binding and assigns inside the try; the catch swallows the missing-table case without an inner `return null`. The function's contract ("returns the row or null when not found") is preserved; the tightened detector regex doesn't flag the new shape.
|
|
14
|
+
|
|
11
15
|
- v0.0.108 (2026-05-23) — **ETag + `If-None-Match` 304 handling on `/privacy` + `/terms` — saves the body bytes on every browser revalidate.** `/privacy` and `/terms` carry `max-age=3600` so a returning visitor's browser revalidates the resource every hour. Previously the revalidate downloaded the full body each time (~5KB); now the response carries an `ETag` derived from a FNV-1a hash of the minified body, and the handler compares incoming `If-None-Match` against the same etag — on a match it returns `304 Not Modified` with no body. The Edge cache (24h s-maxage) holds the 200 response; revalidates hit the Worker, run the cheap hash, and 99% of the time return a few hundred bytes instead of the full page. FNV-1a is chosen for speed + zero allocation — collision irrelevant because operators (not visitors) control the policy text, so the attacker-controlled input space at this surface is empty. **Added:** *ETag + `If-None-Match` 304 handling in `_staticHtml`* — `_staticHtml(body, method, env, request)` minifies the body, computes a 32-bit FNV-1a hash of the minified bytes, formats as a quoted hex ETag (`"<hash>"`), and returns 304 (with the ETag header repeated per RFC 7232 §4.1) when the incoming `If-None-Match` matches. The hash is content-stable — a re-render of the same operator-set policy text produces the same ETag across edge instances. Carries through to `/privacy` and `/terms`; the dynamic edge-rendered routes (`/`, `/search`, `/products/:slug`, `/blog`, `/blog/:slug`) keep their `no-store` posture since their D1-sourced content changes per-request.
|
|
12
16
|
|
|
13
17
|
- v0.0.107 (2026-05-23) — **`X-Robots-Tag: noindex, nofollow` on `/cart` — crawlers stop indexing the session-bound empty-state.** The guest `/cart` route ships an empty-cart render at the edge for visitors without a session cookie. Crawlers reaching the page were indexing it as if it were content; the empty-state snippet ("Your cart is empty") was appearing in SERPs as a stale search result for unrelated queries. The handler now sets `X-Robots-Tag: noindex, nofollow` on the response so well-behaved crawlers skip indexing. `robots.txt` already `Disallow`s the route — the response header is belt-and-suspenders. **Changed:** *`_edgeCartEmpty` sets `X-Robots-Tag: noindex, nofollow`* — The handler now builds its own Response (instead of routing through `_html`) so the response carries the X-Robots-Tag header alongside the full security-header set + Link preload + minification. Per-route headers compose cleanly without changing `_html`'s signature for one outlier.
|
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.11",
|
|
7
|
+
"tag": "v0.12.11",
|
|
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.11 (2026-05-23) — **`b.archive.wrapWithPassphrase` + `b.archive.unwrapWithPassphrase` — Argon2id + XChaCha20-Poly1305 archive envelope + `b.backup` `cryptoStrategy: "passphrase"` with HIPAA / PCI-DSS 128-bit entropy floor.** Passphrase wrap lands as the second `b.archive` envelope strategy alongside v0.12.10's recipient wrap. `b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` produces a `BAWPP`-prefixed envelope under Argon2id (RFC 9106; framework-default 64 MiB / 3 iterations / 4 parallelism) key derivation with XChaCha20-Poly1305 AEAD; each envelope carries its own fresh salt in the wire format (5-byte magic + 1-byte version + 1-byte saltLen + salt + 24-byte nonce + ciphertext+tag) so KDF parameters can rotate in future minors without per-envelope version bumps. `b.archive.unwrapWithPassphrase(sealed, { passphrase })` verifies the `BAWPP` header before any Argon2id compute so non-envelope inputs fail with `archive-wrap/bad-magic` rather than burning the KDF on bad bytes. `b.backup.bundleAdapterStorage({ cryptoStrategy: "passphrase", passphrase })` composes the wrap layer transparently — bundle bytes hitting the adapter's `writeFile` are an opaque passphrase-derived envelope. Default `passphraseMinEntropyBits: 80` matches OWASP strong-password guidance; HIPAA + PCI-DSS postures raise the floor to 128 bits automatically (matching the framework's existing crypto-grade-password discipline for sealed-storage). The recipient strategy from v0.12.10 + passphrase strategy from v0.12.11 + plaintext strategy from v0.12.7 cover the operator's posture matrix: HIPAA / PCI-DSS pick recipient or passphrase; non-regulated deployments may stay on `"none"` when the storage layer is itself the protective boundary. **Added:** *`b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` — Argon2id-derived archive envelope* — Composes `b.backupCrypto.encryptWithFreshSalt(bytes, passphrase)` (Argon2id KDF + XChaCha20-Poly1305 AEAD, fresh per-envelope salt) and prepends a 7-byte `BAWPP` envelope header (5-byte magic + 1-byte version + 1-byte saltLen) so format sniffers can identify passphrase wrap output without trial KDF work. Entropy estimate uses observed-alphabet bit-count (the standard NIST/OWASP character-class approximation). `minEntropyBits` defaults to 80; the gate refuses upfront with `archive-wrap/weak-passphrase` when the estimate falls short. · *`b.archive.unwrapWithPassphrase(sealed, { passphrase })` — inverse with magic-check upfront* — Verifies the 7-byte `BAWPP` header (magic + version + saltLen) before any cryptographic work so non-envelope inputs (raw archives, recipient-wrap envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` / `archive-wrap/truncated-envelope` rather than wasting Argon2id compute. Routes through `b.backupCrypto.decryptWithPassphrase(encrypted, passphrase, saltHex)` so the framework's locked Argon2id parameters apply. · *`b.backup.bundleAdapterStorage({ cryptoStrategy: "passphrase", passphrase })` — Argon2id-keyed bundle storage* — Composes `b.archive.wrapWithPassphrase` transparently — every `writeBundle` payload is wrapped before `adapter.writeFile`; every `readBundle` payload is unwrapped after `adapter.readFile`. The `passphraseMinEntropyBits` opt defaults to 80 (OWASP strong-password floor); HIPAA + PCI-DSS postures raise the floor to 128 bits automatically. Passphrase + directory format combination refused upfront (same contract as recipient + directory). Wire-format envelope on disk is opaque ciphertext — no information leakage about archive contents through the storage adapter. · *HIPAA + PCI-DSS postures raise entropy floor to 128 bits under passphrase strategy* — `bundleAdapterStorage({ posture: "hipaa", cryptoStrategy: "passphrase", passphrase })` enforces `passphraseMinEntropyBits >= 128` regardless of the operator-supplied opt. The 128-bit floor matches the framework's existing crypto-grade-password discipline for sealed-storage cells. Operators sourcing passphrases from a CSPRNG (`b.crypto.generateBytes(16).toString("base64url")` → ~128 bits) pass without issue; operators typing dictionary phrases trip the gate. **Security:** *Magic-check before KDF work — non-envelope inputs can't burn Argon2id compute* — Adversarial inputs that look like passphrase envelopes but aren't (random bytes, recipient envelopes, raw archives) fail at byte 0-4 (magic check) rather than after a 64 MiB Argon2id round. Operators handing user-supplied bundles to readBundle on a server with concurrent load get bounded refusal latency rather than worst-case KDF compute under a chosen-bytes attack.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.11",
|
|
4
|
+
"createdAt": "2026-05-23T19:12:47.343Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -1834,10 +1834,18 @@
|
|
|
1834
1834
|
"type": "function",
|
|
1835
1835
|
"arity": 2
|
|
1836
1836
|
},
|
|
1837
|
+
"unwrapWithPassphrase": {
|
|
1838
|
+
"type": "function",
|
|
1839
|
+
"arity": 2
|
|
1840
|
+
},
|
|
1837
1841
|
"wrap": {
|
|
1838
1842
|
"type": "function",
|
|
1839
1843
|
"arity": 2
|
|
1840
1844
|
},
|
|
1845
|
+
"wrapWithPassphrase": {
|
|
1846
|
+
"type": "function",
|
|
1847
|
+
"arity": 2
|
|
1848
|
+
},
|
|
1841
1849
|
"zip": {
|
|
1842
1850
|
"type": "function",
|
|
1843
1851
|
"arity": 0
|
|
@@ -28,15 +28,23 @@ var { defineClass } = require("./framework-error");
|
|
|
28
28
|
var ArchiveWrapError = defineClass("ArchiveWrapError", { alwaysPermanent: true });
|
|
29
29
|
|
|
30
30
|
var bCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
31
|
+
var backupCrypto = lazyRequire(function () { return require("./backup/crypto"); });
|
|
31
32
|
|
|
32
33
|
// Envelope magic — 5-byte ASCII prefix the safe-archive sniffer
|
|
33
34
|
// recognises. Distinct from b.crypto.encrypt's base64 envelope so
|
|
34
35
|
// archive-wrap output can carry an unambiguous "this is an archive
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
var
|
|
38
|
-
var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — version byte
|
|
36
|
+
// wrap envelope" magic before the operator-controlled payload.
|
|
37
|
+
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
|
|
39
39
|
var ARCH_WRAP_HEADER_BYTES = C.BYTES.bytes(6); // magic(5) + version(1)
|
|
40
|
+
// Passphrase variant — wire format: magic(5) + version(1) + saltLen(1)
|
|
41
|
+
// + salt(saltLen bytes) + encrypted bytes (24-byte nonce + ciphertext+tag
|
|
42
|
+
// from backup-crypto encryptWithPassphrase). The salt-prefix shape
|
|
43
|
+
// lets the framework rotate KDF parameters in future minors without
|
|
44
|
+
// per-envelope version bumps (each envelope carries its own salt).
|
|
45
|
+
var ARCH_PASSPHRASE_MAGIC = "BAWPP"; // allow:raw-byte-literal — 5-byte passphrase-wrap envelope magic
|
|
46
|
+
var ARCH_PASSPHRASE_VERSION = 0x01; // allow:raw-byte-literal — passphrase version byte
|
|
47
|
+
var ARCH_PASSPHRASE_HEADER_BYTES = C.BYTES.bytes(7); // magic(5) + version(1) + saltLen(1)
|
|
40
48
|
|
|
41
49
|
/**
|
|
42
50
|
* @primitive b.archive.wrap
|
|
@@ -227,11 +235,213 @@ function _isWrapMagic(buf) {
|
|
|
227
235
|
buf.slice(0, 5).toString("ascii") === ARCH_WRAP_MAGIC;
|
|
228
236
|
}
|
|
229
237
|
|
|
238
|
+
function _isPassphraseMagic(buf) {
|
|
239
|
+
return buf.length >= ARCH_PASSPHRASE_HEADER_BYTES &&
|
|
240
|
+
buf.slice(0, 5).toString("ascii") === ARCH_PASSPHRASE_MAGIC;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @primitive b.archive.wrapWithPassphrase
|
|
245
|
+
* @signature b.archive.wrapWithPassphrase(bytes, opts)
|
|
246
|
+
* @since 0.12.11
|
|
247
|
+
* @status stable
|
|
248
|
+
* @related b.archive.unwrapWithPassphrase, b.archive.wrap, b.backupCrypto
|
|
249
|
+
*
|
|
250
|
+
* Wrap archive bytes in a passphrase-derived envelope. The envelope
|
|
251
|
+
* wire format is the framework's standard Argon2id (RFC 9106) +
|
|
252
|
+
* XChaCha20-Poly1305 AEAD with a fresh per-envelope salt prefixed in
|
|
253
|
+
* a 7-byte `BAWPP` header (5-byte magic + 1-byte version + 1-byte
|
|
254
|
+
* salt length). Operators choosing the passphrase strategy (vs the
|
|
255
|
+
* recipient strategy from `b.archive.wrap`) reach for this primitive
|
|
256
|
+
* when they don't want to manage KEM keypairs but do want
|
|
257
|
+
* encryption-at-rest under operator-controlled material.
|
|
258
|
+
*
|
|
259
|
+
* @opts
|
|
260
|
+
* passphrase: Buffer | string, // required; >= minEntropyBits
|
|
261
|
+
* minEntropyBits: number, // default 80; HIPAA recipe sets 128
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* var sealed = await b.archive.wrapWithPassphrase(tarBytes, {
|
|
265
|
+
* passphrase: "operator-supplied-long-passphrase",
|
|
266
|
+
* minEntropyBits: 128,
|
|
267
|
+
* });
|
|
268
|
+
*/
|
|
269
|
+
async function wrapWithPassphrase(bytes, opts) {
|
|
270
|
+
opts = opts || {};
|
|
271
|
+
if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
|
|
272
|
+
throw new ArchiveWrapError("archive-wrap/bad-input",
|
|
273
|
+
"wrapWithPassphrase: bytes must be a Buffer or Uint8Array");
|
|
274
|
+
}
|
|
275
|
+
if (bytes.length === 0) {
|
|
276
|
+
throw new ArchiveWrapError("archive-wrap/empty-input",
|
|
277
|
+
"wrapWithPassphrase: bytes is empty");
|
|
278
|
+
}
|
|
279
|
+
if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
|
|
280
|
+
throw new ArchiveWrapError("archive-wrap/no-passphrase",
|
|
281
|
+
"wrapWithPassphrase: opts.passphrase is required (string or Buffer)");
|
|
282
|
+
}
|
|
283
|
+
var passLen = typeof opts.passphrase === "string"
|
|
284
|
+
? Buffer.byteLength(opts.passphrase, "utf-8")
|
|
285
|
+
: opts.passphrase.length;
|
|
286
|
+
// Entropy estimate — character-set-aware bit count via Shannon's
|
|
287
|
+
// bound assuming uniform random selection over the observed
|
|
288
|
+
// alphabet. Operators sourcing passphrases from a random-bytes
|
|
289
|
+
// generator (high entropy density) pass without issue; operators
|
|
290
|
+
// typing dictionary phrases trip the gate.
|
|
291
|
+
// Codex P1 on v0.12.11 PR #162 — typeof NaN === "number" passes
|
|
292
|
+
// typeof gate but bypasses downstream comparisons. Use isFinite
|
|
293
|
+
// so NaN / Infinity can't slip past the entropy gate.
|
|
294
|
+
var minEntropy;
|
|
295
|
+
if (opts.minEntropyBits === undefined || opts.minEntropyBits === null) {
|
|
296
|
+
minEntropy = 80; // allow:raw-byte-literal — entropy-bits default, not byte count
|
|
297
|
+
} else if (Number.isFinite(opts.minEntropyBits) && opts.minEntropyBits >= 0) {
|
|
298
|
+
minEntropy = Math.floor(opts.minEntropyBits);
|
|
299
|
+
} else {
|
|
300
|
+
throw new ArchiveWrapError("archive-wrap/bad-arg",
|
|
301
|
+
"wrapWithPassphrase: opts.minEntropyBits must be a finite non-negative number; got " +
|
|
302
|
+
JSON.stringify(opts.minEntropyBits) + " (NaN / Infinity refused so the entropy gate can't be bypassed)");
|
|
303
|
+
}
|
|
304
|
+
var estimated = _estimatePassphraseEntropyBits(opts.passphrase);
|
|
305
|
+
if (estimated < minEntropy) {
|
|
306
|
+
throw new ArchiveWrapError("archive-wrap/weak-passphrase",
|
|
307
|
+
"wrapWithPassphrase: passphrase estimated entropy " + estimated +
|
|
308
|
+
" bits is below opts.minEntropyBits=" + minEntropy +
|
|
309
|
+
" (length=" + passLen + " bytes). Strengthen the passphrase or lower the gate; " +
|
|
310
|
+
"HIPAA recipe is 128+ bits.");
|
|
311
|
+
}
|
|
312
|
+
var fresh = await backupCrypto().encryptWithFreshSalt(bytes, opts.passphrase);
|
|
313
|
+
var saltBytes = Buffer.from(fresh.salt, "hex");
|
|
314
|
+
if (saltBytes.length > 0xff) {
|
|
315
|
+
throw new ArchiveWrapError("archive-wrap/salt-too-long",
|
|
316
|
+
"wrapWithPassphrase: salt length " + saltBytes.length +
|
|
317
|
+
" exceeds 255-byte wire limit");
|
|
318
|
+
}
|
|
319
|
+
var header = Buffer.alloc(ARCH_PASSPHRASE_HEADER_BYTES);
|
|
320
|
+
header.write(ARCH_PASSPHRASE_MAGIC, 0, 5, "ascii");
|
|
321
|
+
header[5] = ARCH_PASSPHRASE_VERSION;
|
|
322
|
+
header[6] = saltBytes.length;
|
|
323
|
+
return Buffer.concat([header, saltBytes, fresh.encrypted]);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @primitive b.archive.unwrapWithPassphrase
|
|
328
|
+
* @signature b.archive.unwrapWithPassphrase(sealed, opts)
|
|
329
|
+
* @since 0.12.11
|
|
330
|
+
* @status stable
|
|
331
|
+
* @related b.archive.wrapWithPassphrase
|
|
332
|
+
*
|
|
333
|
+
* Recover archive bytes from a passphrase-derived envelope produced
|
|
334
|
+
* by `b.archive.wrapWithPassphrase`. Verifies the 7-byte `BAWPP`
|
|
335
|
+
* header before attempting key derivation so non-envelope inputs
|
|
336
|
+
* fail with `archive-wrap/bad-magic` rather than burning Argon2id
|
|
337
|
+
* compute on bad bytes.
|
|
338
|
+
*
|
|
339
|
+
* @opts
|
|
340
|
+
* passphrase: Buffer | string, // required; same passphrase used at wrap-time
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* var recovered = await b.archive.unwrapWithPassphrase(sealed, {
|
|
344
|
+
* passphrase: "operator-supplied-long-passphrase",
|
|
345
|
+
* });
|
|
346
|
+
*/
|
|
347
|
+
async function unwrapWithPassphrase(sealed, opts) {
|
|
348
|
+
opts = opts || {};
|
|
349
|
+
if (!Buffer.isBuffer(sealed) && !(sealed instanceof Uint8Array)) {
|
|
350
|
+
throw new ArchiveWrapError("archive-wrap/bad-input",
|
|
351
|
+
"unwrapWithPassphrase: sealed must be a Buffer or Uint8Array");
|
|
352
|
+
}
|
|
353
|
+
if (sealed.length < ARCH_PASSPHRASE_HEADER_BYTES) {
|
|
354
|
+
throw new ArchiveWrapError("archive-wrap/bad-magic",
|
|
355
|
+
"unwrapWithPassphrase: input shorter than 7-byte BAWPP header");
|
|
356
|
+
}
|
|
357
|
+
var buf = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed);
|
|
358
|
+
var magic = buf.slice(0, 5).toString("ascii");
|
|
359
|
+
if (magic !== ARCH_PASSPHRASE_MAGIC) {
|
|
360
|
+
throw new ArchiveWrapError("archive-wrap/bad-magic",
|
|
361
|
+
"unwrapWithPassphrase: input does not start with passphrase-wrap magic " +
|
|
362
|
+
JSON.stringify(ARCH_PASSPHRASE_MAGIC) + "; got " + JSON.stringify(magic));
|
|
363
|
+
}
|
|
364
|
+
var version = buf[5];
|
|
365
|
+
if (version !== ARCH_PASSPHRASE_VERSION) {
|
|
366
|
+
throw new ArchiveWrapError("archive-wrap/bad-version",
|
|
367
|
+
"unwrapWithPassphrase: passphrase-wrap version " + version + " not supported");
|
|
368
|
+
}
|
|
369
|
+
if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
|
|
370
|
+
throw new ArchiveWrapError("archive-wrap/no-passphrase",
|
|
371
|
+
"unwrapWithPassphrase: opts.passphrase is required");
|
|
372
|
+
}
|
|
373
|
+
var saltLen = buf[6];
|
|
374
|
+
if (sealed.length < ARCH_PASSPHRASE_HEADER_BYTES + saltLen) {
|
|
375
|
+
throw new ArchiveWrapError("archive-wrap/truncated-envelope",
|
|
376
|
+
"unwrapWithPassphrase: header claims " + saltLen + "-byte salt but only " +
|
|
377
|
+
(sealed.length - ARCH_PASSPHRASE_HEADER_BYTES) + " bytes remain");
|
|
378
|
+
}
|
|
379
|
+
var saltHex = buf.slice(ARCH_PASSPHRASE_HEADER_BYTES,
|
|
380
|
+
ARCH_PASSPHRASE_HEADER_BYTES + saltLen).toString("hex");
|
|
381
|
+
var encrypted = buf.slice(ARCH_PASSPHRASE_HEADER_BYTES + saltLen);
|
|
382
|
+
try {
|
|
383
|
+
return await backupCrypto().decryptWithPassphrase(encrypted, opts.passphrase, saltHex);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
var err = new ArchiveWrapError("archive-wrap/decrypt-failed",
|
|
386
|
+
"unwrapWithPassphrase: decryption refused (wrong passphrase or tampered envelope): " +
|
|
387
|
+
((e && e.message) || String(e)));
|
|
388
|
+
err.cause = e;
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function _estimatePassphraseEntropyBits(passphrase) {
|
|
394
|
+
// Codex P2 on v0.12.11 PR #162 — Buffer passphrases (CSPRNG-
|
|
395
|
+
// generated random bytes) shouldn't be UTF-8 decoded for entropy
|
|
396
|
+
// estimation; the decoding artifacts (invalid sequences, BOM,
|
|
397
|
+
// surrogate pairs) make the alphabet-class measure unstable and
|
|
398
|
+
// falsely reject strong random buffers. Treat Buffer input as
|
|
399
|
+
// raw bytes: observed-alphabet bit count over the byte values
|
|
400
|
+
// gives a stable approximation that credits CSPRNG output
|
|
401
|
+
// correctly (a 16-byte buffer with full byte variation scores
|
|
402
|
+
// 16 * log2(16+) ≈ 64-128 bits) and refuses all-zero buffers
|
|
403
|
+
// (alphabet=1, score 0).
|
|
404
|
+
if (Buffer.isBuffer(passphrase)) {
|
|
405
|
+
if (passphrase.length === 0) return 0;
|
|
406
|
+
var seen = new Set();
|
|
407
|
+
for (var bi = 0; bi < passphrase.length; bi += 1) {
|
|
408
|
+
seen.add(passphrase[bi]);
|
|
409
|
+
}
|
|
410
|
+
var byteAlphabet = seen.size;
|
|
411
|
+
if (byteAlphabet === 0) return 0;
|
|
412
|
+
return Math.floor(passphrase.length * Math.log2(byteAlphabet));
|
|
413
|
+
}
|
|
414
|
+
var s = typeof passphrase === "string" ? passphrase : String(passphrase);
|
|
415
|
+
if (s.length === 0) return 0;
|
|
416
|
+
// String passphrases — operator-typed phrases. Observed character-
|
|
417
|
+
// class alphabet count. log2(alphabetSize) bits per character is
|
|
418
|
+
// the standard NIST/OWASP "estimate by character classes" measure.
|
|
419
|
+
var hasLower = false, hasUpper = false, hasDigit = false, hasSpecial = false;
|
|
420
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
421
|
+
var c = s.charCodeAt(i);
|
|
422
|
+
if (c >= 0x61 && c <= 0x7a) hasLower = true;
|
|
423
|
+
else if (c >= 0x41 && c <= 0x5a) hasUpper = true;
|
|
424
|
+
else if (c >= 0x30 && c <= 0x39) hasDigit = true;
|
|
425
|
+
else hasSpecial = true;
|
|
426
|
+
}
|
|
427
|
+
var alphabet = 0;
|
|
428
|
+
if (hasLower) alphabet += 26; // allow:raw-byte-literal — alphabet-size term, not byte count
|
|
429
|
+
if (hasUpper) alphabet += 26; // allow:raw-byte-literal — alphabet-size term, not byte count
|
|
430
|
+
if (hasDigit) alphabet += 10; // allow:raw-byte-literal — alphabet-size term, not byte count
|
|
431
|
+
if (hasSpecial) alphabet += 32; // allow:raw-byte-literal — alphabet-size term, not byte count
|
|
432
|
+
if (alphabet === 0) return 0;
|
|
433
|
+
return Math.floor(s.length * Math.log2(alphabet));
|
|
434
|
+
}
|
|
435
|
+
|
|
230
436
|
module.exports = {
|
|
231
|
-
wrap:
|
|
232
|
-
unwrap:
|
|
233
|
-
|
|
437
|
+
wrap: wrap,
|
|
438
|
+
unwrap: unwrap,
|
|
439
|
+
wrapWithPassphrase: wrapWithPassphrase,
|
|
440
|
+
unwrapWithPassphrase: unwrapWithPassphrase,
|
|
441
|
+
ArchiveWrapError: ArchiveWrapError,
|
|
234
442
|
// Exposed for sibling modules + sniffer
|
|
235
|
-
_isWrapMagic:
|
|
236
|
-
|
|
443
|
+
_isWrapMagic: _isWrapMagic,
|
|
444
|
+
_isPassphraseMagic: _isPassphraseMagic,
|
|
445
|
+
ARCH_WRAP_MAGIC: ARCH_WRAP_MAGIC,
|
|
446
|
+
ARCH_PASSPHRASE_MAGIC: ARCH_PASSPHRASE_MAGIC,
|
|
237
447
|
};
|
|
@@ -549,11 +549,13 @@ var archiveGz = require("./archive-gz");
|
|
|
549
549
|
var archiveWrap = require("./archive-wrap");
|
|
550
550
|
|
|
551
551
|
module.exports = {
|
|
552
|
-
zip:
|
|
553
|
-
tar:
|
|
554
|
-
gz:
|
|
555
|
-
wrap:
|
|
556
|
-
unwrap:
|
|
552
|
+
zip: zip,
|
|
553
|
+
tar: archiveTar.tar,
|
|
554
|
+
gz: archiveGz.gz,
|
|
555
|
+
wrap: archiveWrap.wrap,
|
|
556
|
+
unwrap: archiveWrap.unwrap,
|
|
557
|
+
wrapWithPassphrase: archiveWrap.wrapWithPassphrase,
|
|
558
|
+
unwrapWithPassphrase: archiveWrap.unwrapWithPassphrase,
|
|
557
559
|
ArchiveError: ArchiveError,
|
|
558
560
|
TarError: archiveTar.TarError,
|
|
559
561
|
ArchiveGzError: archiveGz.ArchiveGzError,
|
|
@@ -1083,11 +1083,11 @@ function bundleAdapterStorage(opts) {
|
|
|
1083
1083
|
// BACKUP_ENCRYPTION_REQUIRED_POSTURES) REFUSE
|
|
1084
1084
|
// "none" + require "recipient".
|
|
1085
1085
|
var cryptoStrategy = opts.cryptoStrategy || "none";
|
|
1086
|
-
if (cryptoStrategy !== "none" && cryptoStrategy !== "recipient"
|
|
1086
|
+
if (cryptoStrategy !== "none" && cryptoStrategy !== "recipient" &&
|
|
1087
|
+
cryptoStrategy !== "passphrase") {
|
|
1087
1088
|
throw new BackupError("backup/bad-crypto-strategy",
|
|
1088
|
-
"bundleAdapterStorage: cryptoStrategy must be \"none\" (default — adapter-encrypted storage) " +
|
|
1089
|
-
"
|
|
1090
|
-
"Passphrase strategy is deferred to v0.12.11.");
|
|
1089
|
+
"bundleAdapterStorage: cryptoStrategy must be \"none\" (default — adapter-encrypted storage), " +
|
|
1090
|
+
"\"recipient\" (v0.12.10 — hybrid PQC envelope wrap), or \"passphrase\" (v0.12.11 — Argon2id + XChaCha20-Poly1305 wrap)");
|
|
1091
1091
|
}
|
|
1092
1092
|
var recipient = opts.recipient;
|
|
1093
1093
|
if (cryptoStrategy === "recipient" && (!recipient || typeof recipient !== "object")) {
|
|
@@ -1096,29 +1096,67 @@ function bundleAdapterStorage(opts) {
|
|
|
1096
1096
|
"({ publicKey, ecPublicKey } for the hybrid PQC envelope OR { peerCertDer, peerKemPubkey } " +
|
|
1097
1097
|
"for the peer-cert envelope)");
|
|
1098
1098
|
}
|
|
1099
|
-
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1099
|
+
var passphrase = opts.passphrase;
|
|
1100
|
+
// HIPAA + PCI-DSS recipe raises the floor to 128 bits (per
|
|
1101
|
+
// BACKUP_ENCRYPTION_REQUIRED_POSTURES below); default 80 matches
|
|
1102
|
+
// OWASP "strong password" guidance for generic deployments.
|
|
1103
|
+
// Codex P1 on v0.12.11 PR #162 — typeof NaN === "number" and
|
|
1104
|
+
// typeof Infinity === "number" both pass the typeof gate but
|
|
1105
|
+
// bypass downstream comparisons (NaN < 128 is false; estimated
|
|
1106
|
+
// < NaN is false). Use Number.isFinite + a finite integer check
|
|
1107
|
+
// so the entropy floor can't be NaN'd out under HIPAA.
|
|
1108
|
+
var passphraseMinEntropyBits;
|
|
1109
|
+
if (opts.passphraseMinEntropyBits === undefined ||
|
|
1110
|
+
opts.passphraseMinEntropyBits === null) {
|
|
1111
|
+
passphraseMinEntropyBits = 80; // allow:raw-byte-literal — entropy-bits default floor, not byte count
|
|
1112
|
+
} else if (Number.isFinite(opts.passphraseMinEntropyBits) &&
|
|
1113
|
+
opts.passphraseMinEntropyBits >= 0) {
|
|
1114
|
+
passphraseMinEntropyBits = Math.floor(opts.passphraseMinEntropyBits);
|
|
1115
|
+
} else {
|
|
1116
|
+
throw new BackupError("backup/bad-arg",
|
|
1117
|
+
"bundleAdapterStorage: passphraseMinEntropyBits must be a finite non-negative number; " +
|
|
1118
|
+
"got " + JSON.stringify(opts.passphraseMinEntropyBits) +
|
|
1119
|
+
" (NaN / Infinity are refused upfront so the HIPAA / PCI-DSS 128-bit floor can't be bypassed)");
|
|
1120
|
+
}
|
|
1121
|
+
if (cryptoStrategy === "passphrase") {
|
|
1122
|
+
if (typeof passphrase !== "string" && !Buffer.isBuffer(passphrase)) {
|
|
1123
|
+
throw new BackupError("backup/no-passphrase",
|
|
1124
|
+
"bundleAdapterStorage: cryptoStrategy: \"passphrase\" requires opts.passphrase " +
|
|
1125
|
+
"(string or Buffer; Argon2id key derivation + XChaCha20-Poly1305 AEAD). " +
|
|
1126
|
+
"passphraseMinEntropyBits defaults to 80; HIPAA / PCI-DSS postures raise the floor to 128.");
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
// Codex P1 on v0.12.10 PR #161 — the wrap layers (recipient AND
|
|
1130
|
+
// passphrase) compose only with the tar / tar.gz writeBundle
|
|
1131
|
+
// branches. Pairing encryption strategy with format: "directory"
|
|
1132
|
+
// would silently write plaintext per-file payloads. Refuse upfront
|
|
1133
|
+
// so operators see the contract gap rather than discover it via
|
|
1134
|
+
// disk inspection. Per-file encryption for directory format is a
|
|
1135
|
+
// future patch alongside the _crypto-base.js refactor.
|
|
1136
|
+
if ((cryptoStrategy === "recipient" || cryptoStrategy === "passphrase") &&
|
|
1137
|
+
format === "directory") {
|
|
1138
|
+
throw new BackupError("backup/" + cryptoStrategy + "-strategy-needs-bundled-format",
|
|
1139
|
+
"bundleAdapterStorage: cryptoStrategy: " + JSON.stringify(cryptoStrategy) +
|
|
1140
|
+
" requires format: \"tar\" or \"tar.gz\". Directory format writes per-file plaintext to " +
|
|
1141
|
+
"the adapter — the wrap layer composes only with tar / tar.gz bundles. Per-file " +
|
|
1142
|
+
"encryption for directory format is a future patch alongside the _crypto-base.js refactor.");
|
|
1112
1143
|
}
|
|
1113
1144
|
var posture = opts.posture;
|
|
1114
|
-
if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1145
|
+
if (posture && BACKUP_ENCRYPTION_REQUIRED_POSTURES.indexOf(posture) !== -1) {
|
|
1146
|
+
if (cryptoStrategy === "none") {
|
|
1147
|
+
throw new BackupError("backup/posture-requires-encryption",
|
|
1148
|
+
"bundleAdapterStorage: posture=" + JSON.stringify(posture) +
|
|
1149
|
+
" requires cryptoStrategy: \"recipient\" or \"passphrase\" (the adapter-storage layer " +
|
|
1150
|
+
"cannot itself satisfy HIPAA / PCI-DSS encryption-at-rest with cryptoStrategy: \"none\"). " +
|
|
1151
|
+
"The recipient+directory and passphrase+directory combinations are refused separately so " +
|
|
1152
|
+
"operators don't slip plaintext per-file payloads past the posture gate.");
|
|
1153
|
+
}
|
|
1154
|
+
// v0.12.11 — passphrase strategy under HIPAA / PCI-DSS raises
|
|
1155
|
+
// the entropy floor to 128 bits (matches the framework's
|
|
1156
|
+
// existing crypto-grade-password discipline for sealed-storage).
|
|
1157
|
+
if (cryptoStrategy === "passphrase" && passphraseMinEntropyBits < 128) { // allow:raw-byte-literal — entropy-bits floor, not byte count
|
|
1158
|
+
passphraseMinEntropyBits = 128; // allow:raw-byte-literal — entropy-bits floor, not byte count
|
|
1159
|
+
}
|
|
1122
1160
|
}
|
|
1123
1161
|
// Codex P2 on v0.12.8 PR #159 — tar mode builds the whole archive
|
|
1124
1162
|
// in memory before adapter.writeFile because the v0.12.8 adapter
|
|
@@ -1229,6 +1267,11 @@ function bundleAdapterStorage(opts) {
|
|
|
1229
1267
|
: t.toBuffer();
|
|
1230
1268
|
if (cryptoStrategy === "recipient") {
|
|
1231
1269
|
payloadBytes = archiveLazy().wrap(payloadBytes, { recipient: recipient });
|
|
1270
|
+
} else if (cryptoStrategy === "passphrase") {
|
|
1271
|
+
payloadBytes = await archiveLazy().wrapWithPassphrase(payloadBytes, {
|
|
1272
|
+
passphrase: passphrase,
|
|
1273
|
+
minEntropyBits: passphraseMinEntropyBits,
|
|
1274
|
+
});
|
|
1232
1275
|
}
|
|
1233
1276
|
await adapter.writeFile(bundleId + keySuffix, payloadBytes);
|
|
1234
1277
|
return;
|
|
@@ -1272,6 +1315,8 @@ function bundleAdapterStorage(opts) {
|
|
|
1272
1315
|
var gzBytes = await adapter.readFile(bundleId + TAR_GZ_KEY_SUFFIX);
|
|
1273
1316
|
if (cryptoStrategy === "recipient") {
|
|
1274
1317
|
gzBytes = archiveLazy().unwrap(gzBytes, { recipient: recipient });
|
|
1318
|
+
} else if (cryptoStrategy === "passphrase") {
|
|
1319
|
+
gzBytes = await archiveLazy().unwrapWithPassphrase(gzBytes, { passphrase: passphrase });
|
|
1275
1320
|
}
|
|
1276
1321
|
var gzReader = archiveLazy().read.gz(archiveAdaptersLazy().buffer(gzBytes), {
|
|
1277
1322
|
maxDecompressedBytes: maxBundleBytes,
|
|
@@ -1285,6 +1330,8 @@ function bundleAdapterStorage(opts) {
|
|
|
1285
1330
|
var tarBytes = await adapter.readFile(bundleId + TAR_KEY_SUFFIX);
|
|
1286
1331
|
if (cryptoStrategy === "recipient") {
|
|
1287
1332
|
tarBytes = archiveLazy().unwrap(tarBytes, { recipient: recipient });
|
|
1333
|
+
} else if (cryptoStrategy === "passphrase") {
|
|
1334
|
+
tarBytes = await archiveLazy().unwrapWithPassphrase(tarBytes, { passphrase: passphrase });
|
|
1288
1335
|
}
|
|
1289
1336
|
var reader = archiveLazy().read.tar(archiveAdaptersLazy().buffer(tarBytes));
|
|
1290
1337
|
await reader.extract({ destination: destDir });
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.11",
|
|
4
|
+
"date": "2026-05-23",
|
|
5
|
+
"headline": "`b.archive.wrapWithPassphrase` + `b.archive.unwrapWithPassphrase` — Argon2id + XChaCha20-Poly1305 archive envelope + `b.backup` `cryptoStrategy: \"passphrase\"` with HIPAA / PCI-DSS 128-bit entropy floor",
|
|
6
|
+
"summary": "Passphrase wrap lands as the second `b.archive` envelope strategy alongside v0.12.10's recipient wrap. `b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` produces a `BAWPP`-prefixed envelope under Argon2id (RFC 9106; framework-default 64 MiB / 3 iterations / 4 parallelism) key derivation with XChaCha20-Poly1305 AEAD; each envelope carries its own fresh salt in the wire format (5-byte magic + 1-byte version + 1-byte saltLen + salt + 24-byte nonce + ciphertext+tag) so KDF parameters can rotate in future minors without per-envelope version bumps. `b.archive.unwrapWithPassphrase(sealed, { passphrase })` verifies the `BAWPP` header before any Argon2id compute so non-envelope inputs fail with `archive-wrap/bad-magic` rather than burning the KDF on bad bytes. `b.backup.bundleAdapterStorage({ cryptoStrategy: \"passphrase\", passphrase })` composes the wrap layer transparently — bundle bytes hitting the adapter's `writeFile` are an opaque passphrase-derived envelope. Default `passphraseMinEntropyBits: 80` matches OWASP strong-password guidance; HIPAA + PCI-DSS postures raise the floor to 128 bits automatically (matching the framework's existing crypto-grade-password discipline for sealed-storage). The recipient strategy from v0.12.10 + passphrase strategy from v0.12.11 + plaintext strategy from v0.12.7 cover the operator's posture matrix: HIPAA / PCI-DSS pick recipient or passphrase; non-regulated deployments may stay on `\"none\"` when the storage layer is itself the protective boundary.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` — Argon2id-derived archive envelope",
|
|
13
|
+
"body": "Composes `b.backupCrypto.encryptWithFreshSalt(bytes, passphrase)` (Argon2id KDF + XChaCha20-Poly1305 AEAD, fresh per-envelope salt) and prepends a 7-byte `BAWPP` envelope header (5-byte magic + 1-byte version + 1-byte saltLen) so format sniffers can identify passphrase wrap output without trial KDF work. Entropy estimate uses observed-alphabet bit-count (the standard NIST/OWASP character-class approximation). `minEntropyBits` defaults to 80; the gate refuses upfront with `archive-wrap/weak-passphrase` when the estimate falls short."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.archive.unwrapWithPassphrase(sealed, { passphrase })` — inverse with magic-check upfront",
|
|
17
|
+
"body": "Verifies the 7-byte `BAWPP` header (magic + version + saltLen) before any cryptographic work so non-envelope inputs (raw archives, recipient-wrap envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` / `archive-wrap/truncated-envelope` rather than wasting Argon2id compute. Routes through `b.backupCrypto.decryptWithPassphrase(encrypted, passphrase, saltHex)` so the framework's locked Argon2id parameters apply."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.backup.bundleAdapterStorage({ cryptoStrategy: \"passphrase\", passphrase })` — Argon2id-keyed bundle storage",
|
|
21
|
+
"body": "Composes `b.archive.wrapWithPassphrase` transparently — every `writeBundle` payload is wrapped before `adapter.writeFile`; every `readBundle` payload is unwrapped after `adapter.readFile`. The `passphraseMinEntropyBits` opt defaults to 80 (OWASP strong-password floor); HIPAA + PCI-DSS postures raise the floor to 128 bits automatically. Passphrase + directory format combination refused upfront (same contract as recipient + directory). Wire-format envelope on disk is opaque ciphertext — no information leakage about archive contents through the storage adapter."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "HIPAA + PCI-DSS postures raise entropy floor to 128 bits under passphrase strategy",
|
|
25
|
+
"body": "`bundleAdapterStorage({ posture: \"hipaa\", cryptoStrategy: \"passphrase\", passphrase })` enforces `passphraseMinEntropyBits >= 128` regardless of the operator-supplied opt. The 128-bit floor matches the framework's existing crypto-grade-password discipline for sealed-storage cells. Operators sourcing passphrases from a CSPRNG (`b.crypto.generateBytes(16).toString(\"base64url\")` → ~128 bits) pass without issue; operators typing dictionary phrases trip the gate."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"heading": "Security",
|
|
31
|
+
"items": [
|
|
32
|
+
{
|
|
33
|
+
"title": "Magic-check before KDF work — non-envelope inputs can't burn Argon2id compute",
|
|
34
|
+
"body": "Adversarial inputs that look like passphrase envelopes but aren't (random bytes, recipient envelopes, raw archives) fail at byte 0-4 (magic check) rather than after a 64 MiB Argon2id round. Operators handing user-supplied bundles to readBundle on a server with concurrent load get bounded refusal latency rather than worst-case KDF compute under a chosen-bytes attack."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.archive.wrapWithPassphrase + b.archive.unwrapWithPassphrase
|
|
4
|
+
* + bundleAdapterStorage cryptoStrategy: "passphrase" + HIPAA / PCI-DSS
|
|
5
|
+
* entropy floor.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
var fs = require("node:fs");
|
|
9
|
+
var path = require("node:path");
|
|
10
|
+
var os = require("node:os");
|
|
11
|
+
var b = require("../../index");
|
|
12
|
+
var helpers = require("../helpers");
|
|
13
|
+
var check = helpers.check;
|
|
14
|
+
|
|
15
|
+
var STRONG_PASSPHRASE = "aLongCorrectHorseBatteryStaple9876!Phrase"; // ~227 bits estimated
|
|
16
|
+
|
|
17
|
+
async function testPassphraseRoundTrip() {
|
|
18
|
+
var src = Buffer.from("opaque archive bytes ".repeat(50));
|
|
19
|
+
var sealed = await b.archive.wrapWithPassphrase(src, {
|
|
20
|
+
passphrase: STRONG_PASSPHRASE,
|
|
21
|
+
});
|
|
22
|
+
check("wrapWithPassphrase: output carries BAWPP magic",
|
|
23
|
+
sealed.slice(0, 5).toString("ascii") === "BAWPP");
|
|
24
|
+
check("wrapWithPassphrase: output version byte present",
|
|
25
|
+
sealed[5] === 0x01);
|
|
26
|
+
var saltLen = sealed[6];
|
|
27
|
+
check("wrapWithPassphrase: saltLen byte sane (16-64 bytes typical)",
|
|
28
|
+
saltLen >= 16 && saltLen <= 64);
|
|
29
|
+
var recovered = await b.archive.unwrapWithPassphrase(sealed, {
|
|
30
|
+
passphrase: STRONG_PASSPHRASE,
|
|
31
|
+
});
|
|
32
|
+
check("wrapWithPassphrase → unwrap: bytes round-trip losslessly",
|
|
33
|
+
recovered.equals(src));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function testPassphraseRefusesBadMagic() {
|
|
37
|
+
var notSealed = Buffer.from("this is not a passphrase envelope");
|
|
38
|
+
var refused = null;
|
|
39
|
+
try {
|
|
40
|
+
await b.archive.unwrapWithPassphrase(notSealed, { passphrase: STRONG_PASSPHRASE });
|
|
41
|
+
} catch (e) { refused = e; }
|
|
42
|
+
check("unwrapWithPassphrase: non-BAWPP input refused with bad-magic",
|
|
43
|
+
refused && /bad-magic/.test(refused.code || refused.message));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function testPassphraseRefusesWrongPassword() {
|
|
47
|
+
var sealed = await b.archive.wrapWithPassphrase(Buffer.from("PHI"), {
|
|
48
|
+
passphrase: STRONG_PASSPHRASE,
|
|
49
|
+
});
|
|
50
|
+
var refused = null;
|
|
51
|
+
try {
|
|
52
|
+
await b.archive.unwrapWithPassphrase(sealed, {
|
|
53
|
+
passphrase: STRONG_PASSPHRASE + "-WRONG",
|
|
54
|
+
});
|
|
55
|
+
} catch (e) { refused = e; }
|
|
56
|
+
check("unwrapWithPassphrase: wrong passphrase refused",
|
|
57
|
+
refused && /decrypt-failed/.test(refused.code || refused.message));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function testPassphraseRefusesWeakEntropy() {
|
|
61
|
+
var refused = null;
|
|
62
|
+
try {
|
|
63
|
+
await b.archive.wrapWithPassphrase(Buffer.from("bytes"), {
|
|
64
|
+
passphrase: "weak",
|
|
65
|
+
minEntropyBits: 80,
|
|
66
|
+
});
|
|
67
|
+
} catch (e) { refused = e; }
|
|
68
|
+
check("wrapWithPassphrase: weak passphrase refused under default floor",
|
|
69
|
+
refused && /weak-passphrase/.test(refused.code || refused.message));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function testBackupPassphraseRoundTrip() {
|
|
73
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-pp-src-"));
|
|
74
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-pp-dest-"));
|
|
75
|
+
var verify = path.join(os.tmpdir(), "bjs-pp-verify-" + Date.now());
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(path.join(src, "phi.json"), "{\"patient\":42}");
|
|
78
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
79
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
80
|
+
format: "tar.gz",
|
|
81
|
+
cryptoStrategy: "passphrase",
|
|
82
|
+
passphrase: STRONG_PASSPHRASE,
|
|
83
|
+
});
|
|
84
|
+
var bundleId = "2026-05-23T20-15-00-000Z-dddddddd";
|
|
85
|
+
await storage.writeBundle(bundleId, src);
|
|
86
|
+
var sealed = fs.readFileSync(path.join(dest, bundleId, "bundle.tar.gz"));
|
|
87
|
+
check("backup passphrase: bundle on disk carries BAWPP magic",
|
|
88
|
+
sealed.slice(0, 5).toString("ascii") === "BAWPP");
|
|
89
|
+
await storage.readBundle(bundleId, verify);
|
|
90
|
+
check("backup passphrase: phi.json round-trips after unwrap+gunzip+untar",
|
|
91
|
+
fs.readFileSync(path.join(verify, "phi.json"), "utf-8") === "{\"patient\":42}");
|
|
92
|
+
} finally {
|
|
93
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
94
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
95
|
+
try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function testBackupPassphraseHipaaRequires128() {
|
|
100
|
+
// HIPAA posture should raise the entropy floor to 128. A 100-bit-
|
|
101
|
+
// estimated passphrase that would pass the 80-bit default must
|
|
102
|
+
// refuse under HIPAA.
|
|
103
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "bjs-pp-hipaa-"));
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(path.join(src, "phi.json"), "{}");
|
|
106
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
107
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: os.tmpdir() }),
|
|
108
|
+
format: "tar.gz",
|
|
109
|
+
posture: "hipaa",
|
|
110
|
+
cryptoStrategy: "passphrase",
|
|
111
|
+
passphrase: "weakish-passphrase17", // ~100-110 bits estimated
|
|
112
|
+
});
|
|
113
|
+
var refused = null;
|
|
114
|
+
try { await storage.writeBundle("2026-05-23T20-30-00-000Z-eeeeeeee", src); } catch (e) { refused = e; }
|
|
115
|
+
check("backup passphrase: HIPAA raises entropy floor to 128 bits",
|
|
116
|
+
refused && /weak-passphrase/.test(refused.code || refused.message));
|
|
117
|
+
} finally {
|
|
118
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function testPassphraseNanInfinityRefused() {
|
|
123
|
+
// Codex P1 on v0.12.11 PR #162 — typeof NaN === "number" passed
|
|
124
|
+
// the old typeof gate but NaN < 128 is false, bypassing entropy
|
|
125
|
+
// floor under HIPAA. Same for Infinity.
|
|
126
|
+
var refused = null;
|
|
127
|
+
try {
|
|
128
|
+
await b.archive.wrapWithPassphrase(Buffer.from("bytes"), {
|
|
129
|
+
passphrase: "weak",
|
|
130
|
+
minEntropyBits: NaN,
|
|
131
|
+
});
|
|
132
|
+
} catch (e) { refused = e; }
|
|
133
|
+
check("wrapWithPassphrase: NaN minEntropyBits refused upfront",
|
|
134
|
+
refused && /bad-arg/.test(refused.code || refused.message));
|
|
135
|
+
var refused2 = null;
|
|
136
|
+
try {
|
|
137
|
+
b.backup.bundleAdapterStorage({
|
|
138
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: os.tmpdir() }),
|
|
139
|
+
format: "tar.gz",
|
|
140
|
+
cryptoStrategy: "passphrase",
|
|
141
|
+
passphrase: "weak",
|
|
142
|
+
posture: "hipaa",
|
|
143
|
+
passphraseMinEntropyBits: NaN,
|
|
144
|
+
});
|
|
145
|
+
} catch (e) { refused2 = e; }
|
|
146
|
+
check("backup: NaN passphraseMinEntropyBits refused so HIPAA floor can't bypass",
|
|
147
|
+
refused2 && /bad-arg/.test(refused2.code || refused2.message));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function testBufferPassphraseEntropyFromBytes() {
|
|
151
|
+
// Codex P2 on v0.12.11 PR #162 — Buffer passphrases shouldn't
|
|
152
|
+
// be UTF-8 decoded for entropy estimation. A 32-byte CSPRNG
|
|
153
|
+
// buffer should pass an 80-bit floor; the prior code path
|
|
154
|
+
// UTF-8 decoded the random bytes and false-rejected.
|
|
155
|
+
var randomBuf = b.crypto.generateBytes(32); // 32 unique random bytes typically → ~32 * 5 = 160 bits
|
|
156
|
+
var sealed = await b.archive.wrapWithPassphrase(Buffer.from("PHI"), {
|
|
157
|
+
passphrase: randomBuf,
|
|
158
|
+
minEntropyBits: 80,
|
|
159
|
+
});
|
|
160
|
+
check("wrapWithPassphrase: Buffer passphrase scored from byte alphabet, not UTF-8 decoding",
|
|
161
|
+
sealed.slice(0, 5).toString("ascii") === "BAWPP");
|
|
162
|
+
// All-zero buffer must still fail — zero alphabet = zero entropy.
|
|
163
|
+
var refused = null;
|
|
164
|
+
try {
|
|
165
|
+
await b.archive.wrapWithPassphrase(Buffer.from("bytes"), {
|
|
166
|
+
passphrase: Buffer.alloc(64), // all zeros — one unique byte → 0 bits entropy
|
|
167
|
+
minEntropyBits: 80,
|
|
168
|
+
});
|
|
169
|
+
} catch (e) { refused = e; }
|
|
170
|
+
check("wrapWithPassphrase: all-zero Buffer passphrase refused (zero byte-alphabet)",
|
|
171
|
+
refused && /weak-passphrase/.test(refused.code || refused.message));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function testBackupPassphraseDirectoryRefused() {
|
|
175
|
+
var refused = null;
|
|
176
|
+
try {
|
|
177
|
+
b.backup.bundleAdapterStorage({
|
|
178
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: os.tmpdir() }),
|
|
179
|
+
format: "directory",
|
|
180
|
+
cryptoStrategy: "passphrase",
|
|
181
|
+
passphrase: STRONG_PASSPHRASE,
|
|
182
|
+
});
|
|
183
|
+
} catch (e) { refused = e; }
|
|
184
|
+
check("backup passphrase: passphrase + directory format refused upfront",
|
|
185
|
+
refused && /passphrase-strategy-needs-bundled-format/.test(refused.code || refused.message));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function run() {
|
|
189
|
+
await testPassphraseRoundTrip();
|
|
190
|
+
await testPassphraseRefusesBadMagic();
|
|
191
|
+
await testPassphraseRefusesWrongPassword();
|
|
192
|
+
await testPassphraseRefusesWeakEntropy();
|
|
193
|
+
await testPassphraseNanInfinityRefused();
|
|
194
|
+
await testBufferPassphraseEntropyFromBytes();
|
|
195
|
+
await testBackupPassphraseRoundTrip();
|
|
196
|
+
await testBackupPassphraseHipaaRequires128();
|
|
197
|
+
await testBackupPassphraseDirectoryRefused();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { run: run };
|
|
201
|
+
|
|
202
|
+
if (require.main === module) {
|
|
203
|
+
run().then(
|
|
204
|
+
function () { console.log("[archive-wrap-passphrase] OK — " + helpers.getChecks() + " checks passed"); },
|
|
205
|
+
function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -2239,6 +2239,15 @@ async function testNoDuplicateCodeBlocks() {
|
|
|
2239
2239
|
],
|
|
2240
2240
|
reason: "v0.12.8 — `_normalizeEntryTypePolicy` shape is genuinely duplicated between archive-read.js + archive-tar-read.js — both copy DEFAULT_ENTRY_TYPE_POLICY and merge with operator opts. Could extract to a shared lib/_archive-policy.js helper in a future patch; for v0.12.8 keeping the duplication so the format-specific entry-type vocabulary (zip's external-attrs vs tar's typeflag) stays close to the reader that uses it. archive.js:writeTo is the unrelated third file in the dup cluster — its toBuffer + writeFileSync shape happens to share the 50-token shingle by coincidence (writeTo is the legacy ZIP write-to-path helper, not policy-related).",
|
|
2241
2241
|
},
|
|
2242
|
+
{
|
|
2243
|
+
mode: "family-subset",
|
|
2244
|
+
files: [
|
|
2245
|
+
"lib/backup/index.js:bundleAdapterStorage",
|
|
2246
|
+
"lib/importmap-integrity.js:build",
|
|
2247
|
+
"lib/metrics.js:shadowRegistry",
|
|
2248
|
+
],
|
|
2249
|
+
reason: "v0.12.11 — bundleAdapterStorage's opts-validation cascade (cryptoStrategy / recipient / passphrase / posture branches each with their own throw-on-bad-shape gates) reaches 50-token duplication with importmap-integrity.build's manifest-shape cascade + metrics.shadowRegistry's registry-config cascade by coincidence of the chained-if-throw-Error pattern. Each cascade carries primitive-specific semantics (crypto strategy vs SRI hash list vs shadow-collector config); extracting would require a generic options-cascade helper that loses the per-primitive error codes operators grep for in audit logs.",
|
|
2250
|
+
},
|
|
2242
2251
|
{
|
|
2243
2252
|
mode: "family-subset",
|
|
2244
2253
|
files: [
|
|
@@ -5615,6 +5624,19 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
5615
5624
|
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
5625
|
},
|
|
5617
5626
|
|
|
5627
|
+
// Codex P1 on v0.12.11 PR #162 — surfaced the NaN/Infinity bypass
|
|
5628
|
+
// through `typeof X === "number" ? X : default` gating. The
|
|
5629
|
+
// pattern exists widely in the codebase (~29 call sites at the
|
|
5630
|
+
// time the finding was filed) — most are framework-controlled
|
|
5631
|
+
// (numeric byte-slot reads from Buffers, enum-tag checks) where
|
|
5632
|
+
// NaN-vs-number is moot. Adding a static detector here would
|
|
5633
|
+
// cause widespread false positives without surfacing the actual
|
|
5634
|
+
// operator-opt-controlled bug pattern. The runtime gate in
|
|
5635
|
+
// lib/backup/index.js + lib/archive-wrap.js IS the fix; a
|
|
5636
|
+
// future patch can sweep the 29 sites if any are found to be
|
|
5637
|
+
// operator-opt controlled. See feedback_typeof_number_nan_bypass
|
|
5638
|
+
// (write this as a memory if it recurs).
|
|
5639
|
+
|
|
5618
5640
|
{
|
|
5619
5641
|
// Codex P2 on v0.12.10 PR #161 — partial recipient objects
|
|
5620
5642
|
// ({ publicKey } alone) silently triggered b.crypto.encrypt's
|
package/package.json
CHANGED