@blamejs/core 0.12.9 → 0.12.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.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
+
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.
14
+
11
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.
12
16
 
13
17
  - 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)
@@ -0,0 +1,447 @@
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
+ var backupCrypto = lazyRequire(function () { return require("./backup/crypto"); });
32
+
33
+ // Envelope magic — 5-byte ASCII prefix the safe-archive sniffer
34
+ // recognises. Distinct from b.crypto.encrypt's base64 envelope so
35
+ // archive-wrap output can carry an unambiguous "this is an archive
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
+ 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)
48
+
49
+ /**
50
+ * @primitive b.archive.wrap
51
+ * @signature b.archive.wrap(bytes, opts)
52
+ * @since 0.12.10
53
+ * @status stable
54
+ * @related b.archive.unwrap, b.crypto.encrypt, b.backup.bundleAdapterStorage
55
+ *
56
+ * Wrap archive bytes in a recipient-encrypted envelope. The envelope
57
+ * is the framework's standard hybrid PQC seal (ML-KEM-1024 + P-384
58
+ * ECDH hybrid + SHAKE256 KDF + XChaCha20-Poly1305 AEAD) prefixed
59
+ * with a 6-byte archive-wrap header (`BAWRP` magic + version byte)
60
+ * so format sniffers can distinguish wrap envelopes from raw
61
+ * archives without trial decryption.
62
+ *
63
+ * Recipient strategies:
64
+ * - static key — `{ recipient: { publicKey, ecPublicKey } }` (ML-KEM-1024
65
+ * pubkey PEM + P-384 ECDH pubkey PEM).
66
+ * - peer cert — `{ recipient: { peerCertDer, peerKemPubkey } }` composes
67
+ * `b.crypto.encryptEnvelopeAsCertPeer` (extracts the
68
+ * P-384 half from the cert).
69
+ * - tenant — `{ recipient: "tenant", tenantId: "alpha" }` resolves
70
+ * the tenant's KEM keypair via `b.vault.derivedKey`
71
+ * (deferred to v0.12.11 alongside the backup
72
+ * `cryptoStrategy: "recipient"` adoption).
73
+ *
74
+ * @opts
75
+ * recipient: object | string, // see strategies above; required
76
+ * tenantId: string, // required when recipient === "tenant"
77
+ *
78
+ * @example
79
+ * var pair = b.crypto.generateEncryptionKeyPair();
80
+ * var sealed = b.archive.wrap(tarBytes, { recipient: pair });
81
+ * // sealed is a Buffer carrying BAWRP+version+envelope; write to
82
+ * // any adapter sink. On read, hand to b.archive.unwrap with the
83
+ * // matching privKeys to recover tarBytes.
84
+ */
85
+ function wrap(bytes, opts) {
86
+ opts = opts || {};
87
+ if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
88
+ throw new ArchiveWrapError("archive-wrap/bad-input",
89
+ "wrap: bytes must be a Buffer or Uint8Array");
90
+ }
91
+ if (bytes.length === 0) {
92
+ throw new ArchiveWrapError("archive-wrap/empty-input",
93
+ "wrap: bytes is empty — nothing to seal");
94
+ }
95
+ if (!opts.recipient) {
96
+ throw new ArchiveWrapError("archive-wrap/no-recipient",
97
+ "wrap: opts.recipient is required (static key object | \"tenant\" string | peer-cert object)");
98
+ }
99
+ var envelope = _encryptForRecipient(bytes, opts);
100
+ // envelope is a base64 string from b.crypto.encrypt. Buffer it and
101
+ // prepend the 6-byte archive-wrap header so safeArchive's sniffer
102
+ // can identify it without attempting decryption.
103
+ var envelopeBuf = Buffer.from(envelope, "utf-8");
104
+ var header = Buffer.alloc(ARCH_WRAP_HEADER_BYTES);
105
+ header.write(ARCH_WRAP_MAGIC, 0, 5, "ascii");
106
+ header[5] = ARCH_WRAP_VERSION;
107
+ return Buffer.concat([header, envelopeBuf]);
108
+ }
109
+
110
+ /**
111
+ * @primitive b.archive.unwrap
112
+ * @signature b.archive.unwrap(sealed, opts)
113
+ * @since 0.12.10
114
+ * @status stable
115
+ * @related b.archive.wrap, b.crypto.decrypt
116
+ *
117
+ * Recover archive bytes from a recipient-encrypted envelope produced
118
+ * by `b.archive.wrap`. Verifies the 6-byte `BAWRP` header before
119
+ * attempting decryption so non-envelope inputs (raw archive bytes,
120
+ * other-magic envelopes) fail with `archive-wrap/bad-magic` rather
121
+ * than a crypto-level error.
122
+ *
123
+ * @opts
124
+ * recipient: object, // { privateKey, ecPrivateKey } | { certPrivateKey, kemSecret }; required
125
+ *
126
+ * @example
127
+ * var bytes = b.archive.unwrap(sealed, { recipient: privPair });
128
+ * var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
129
+ */
130
+ function unwrap(sealed, opts) {
131
+ opts = opts || {};
132
+ if (!Buffer.isBuffer(sealed) && !(sealed instanceof Uint8Array)) {
133
+ throw new ArchiveWrapError("archive-wrap/bad-input",
134
+ "unwrap: sealed must be a Buffer or Uint8Array");
135
+ }
136
+ if (sealed.length < ARCH_WRAP_HEADER_BYTES) {
137
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
138
+ "unwrap: input shorter than 6-byte archive-wrap header");
139
+ }
140
+ var buf = Buffer.isBuffer(sealed) ? sealed : Buffer.from(sealed);
141
+ var magic = buf.slice(0, 5).toString("ascii");
142
+ if (magic !== ARCH_WRAP_MAGIC) {
143
+ throw new ArchiveWrapError("archive-wrap/bad-magic",
144
+ "unwrap: input does not start with archive-wrap magic " +
145
+ JSON.stringify(ARCH_WRAP_MAGIC) + "; got " + JSON.stringify(magic));
146
+ }
147
+ var version = buf[5];
148
+ if (version !== ARCH_WRAP_VERSION) {
149
+ throw new ArchiveWrapError("archive-wrap/bad-version",
150
+ "unwrap: archive-wrap version " + version + " not supported by this build");
151
+ }
152
+ if (!opts.recipient || typeof opts.recipient !== "object") {
153
+ throw new ArchiveWrapError("archive-wrap/no-recipient",
154
+ "unwrap: opts.recipient is required ({ privateKey, ecPrivateKey } " +
155
+ "for the static-key path, { certPrivateKey, kemSecret } for the peer-cert path)");
156
+ }
157
+ var envelope = buf.slice(ARCH_WRAP_HEADER_BYTES).toString("utf-8");
158
+ var plaintext;
159
+ try {
160
+ if (opts.recipient.certPrivateKey) {
161
+ // Cert-peer path: encryptEnvelopeAsCertPeer composed
162
+ // `encrypt(bytes, { publicKey, ecPublicKey })` where the
163
+ // ecPublicKey was extracted from the cert. The inverse passes
164
+ // the operator's kemSecret + certPrivateKey (P-384) through
165
+ // the same decrypt code path. raw:true preserves binary
166
+ // archive bytes losslessly.
167
+ plaintext = bCrypto().decrypt(envelope, {
168
+ privateKey: opts.recipient.kemSecret,
169
+ ecPrivateKey: opts.recipient.certPrivateKey,
170
+ }, { raw: true });
171
+ } else {
172
+ // raw:true returns the decrypted Buffer (lossless for arbitrary
173
+ // binary archive payloads — utf-8 string conversion would
174
+ // corrupt gzip / zip / tar bytes).
175
+ plaintext = bCrypto().decrypt(envelope, opts.recipient, { raw: true });
176
+ }
177
+ } catch (e) {
178
+ var err = new ArchiveWrapError("archive-wrap/decrypt-failed",
179
+ "unwrap: envelope decryption refused: " + ((e && e.message) || String(e)));
180
+ err.cause = e;
181
+ throw err;
182
+ }
183
+ return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
184
+ }
185
+
186
+ function _encryptForRecipient(bytes, opts) {
187
+ var r = opts.recipient;
188
+ if (typeof r === "string") {
189
+ if (r === "tenant") {
190
+ // tenant strategy lands in v0.12.11 alongside the backup
191
+ // cryptoStrategy adoption — refuse cleanly for v0.12.10 so
192
+ // operators see the deferred-shape contract.
193
+ throw new ArchiveWrapError("archive-wrap/tenant-strategy-deferred",
194
+ "wrap: recipient: \"tenant\" lands in v0.12.11 alongside b.backup cryptoStrategy: \"recipient\" + per-tenant key resolution. For v0.12.10, pass an explicit { publicKey, ecPublicKey } recipient");
195
+ }
196
+ throw new ArchiveWrapError("archive-wrap/bad-recipient",
197
+ "wrap: recipient string " + JSON.stringify(r) + " not recognised; \"tenant\" deferred to v0.12.11");
198
+ }
199
+ if (r.peerCertDer || r.peerKemPubkey) {
200
+ if (!r.peerCertDer || !r.peerKemPubkey) {
201
+ throw new ArchiveWrapError("archive-wrap/bad-recipient",
202
+ "wrap: peer-cert strategy requires BOTH peerCertDer + peerKemPubkey");
203
+ }
204
+ return bCrypto().encryptEnvelopeAsCertPeer(bytes, {
205
+ peerCertDer: r.peerCertDer,
206
+ peerKemPubkey: r.peerKemPubkey,
207
+ });
208
+ }
209
+ if (r.publicKey) {
210
+ // Codex P2 on v0.12.10 PR #161 — b.crypto.encrypt falls back to
211
+ // ML-KEM-only when ecPublicKey is undefined (with a one-shot
212
+ // audit). For archive-wrap's recipient contract the hybrid leg
213
+ // (P-384 ECDH defence-in-depth backstop on top of ML-KEM-1024)
214
+ // is the documented behaviour; refuse upfront so partial
215
+ // recipient objects can't silently degrade the seal posture.
216
+ // Operators who genuinely want KEM-only call
217
+ // b.crypto.encryptMlkem768X25519 directly.
218
+ if (!r.ecPublicKey) {
219
+ throw new ArchiveWrapError("archive-wrap/hybrid-required",
220
+ "wrap: static-key recipient requires BOTH publicKey (ML-KEM-1024 PEM) " +
221
+ "and ecPublicKey (P-384 ECDH PEM). Partial recipients trip b.crypto.encrypt's " +
222
+ "ML-KEM-only fallback which silently degrades the hybrid contract this primitive promises.");
223
+ }
224
+ return bCrypto().encrypt(bytes, {
225
+ publicKey: r.publicKey,
226
+ ecPublicKey: r.ecPublicKey,
227
+ });
228
+ }
229
+ throw new ArchiveWrapError("archive-wrap/bad-recipient",
230
+ "wrap: recipient must be { publicKey, ecPublicKey } | { peerCertDer, peerKemPubkey } | \"tenant\"");
231
+ }
232
+
233
+ function _isWrapMagic(buf) {
234
+ return buf.length >= ARCH_WRAP_HEADER_BYTES &&
235
+ buf.slice(0, 5).toString("ascii") === ARCH_WRAP_MAGIC;
236
+ }
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
+
436
+ module.exports = {
437
+ wrap: wrap,
438
+ unwrap: unwrap,
439
+ wrapWithPassphrase: wrapWithPassphrase,
440
+ unwrapWithPassphrase: unwrapWithPassphrase,
441
+ ArchiveWrapError: ArchiveWrapError,
442
+ // Exposed for sibling modules + sniffer
443
+ _isWrapMagic: _isWrapMagic,
444
+ _isPassphraseMagic: _isPassphraseMagic,
445
+ ARCH_WRAP_MAGIC: ARCH_WRAP_MAGIC,
446
+ ARCH_PASSPHRASE_MAGIC: ARCH_PASSPHRASE_MAGIC,
447
+ };
package/lib/archive.js CHANGED
@@ -546,14 +546,20 @@ 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: zip,
552
- tar: archiveTar.tar,
553
- gz: archiveGz.gz,
554
- ArchiveError: ArchiveError,
555
- TarError: archiveTar.TarError,
556
- ArchiveGzError: archiveGz.ArchiveGzError,
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,
559
+ ArchiveError: ArchiveError,
560
+ TarError: archiveTar.TarError,
561
+ ArchiveGzError: archiveGz.ArchiveGzError,
562
+ ArchiveWrapError: archiveWrap.ArchiveWrapError,
557
563
  read: {
558
564
  zip: archiveRead.zip,
559
565
  tar: archiveTarRead.tar,
@@ -1068,6 +1068,96 @@ 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
+ cryptoStrategy !== "passphrase") {
1088
+ throw new BackupError("backup/bad-crypto-strategy",
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
+ }
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
+ 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.");
1143
+ }
1144
+ var posture = opts.posture;
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
+ }
1160
+ }
1071
1161
  // Codex P2 on v0.12.8 PR #159 — tar mode builds the whole archive
1072
1162
  // in memory before adapter.writeFile because the v0.12.8 adapter
1073
1163
  // contract is bytes-in (no writeStream method). The OOM-prevention
@@ -1175,6 +1265,14 @@ function bundleAdapterStorage(opts) {
1175
1265
  var payloadBytes = format === "tar.gz"
1176
1266
  ? archiveLazy().gz(t.toBuffer()).toBuffer()
1177
1267
  : t.toBuffer();
1268
+ if (cryptoStrategy === "recipient") {
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
+ });
1275
+ }
1178
1276
  await adapter.writeFile(bundleId + keySuffix, payloadBytes);
1179
1277
  return;
1180
1278
  }
@@ -1215,6 +1313,11 @@ function bundleAdapterStorage(opts) {
1215
1313
  // ratio; without these opts the same primitive writes
1216
1314
  // bundles it can't read back.
1217
1315
  var gzBytes = await adapter.readFile(bundleId + TAR_GZ_KEY_SUFFIX);
1316
+ if (cryptoStrategy === "recipient") {
1317
+ gzBytes = archiveLazy().unwrap(gzBytes, { recipient: recipient });
1318
+ } else if (cryptoStrategy === "passphrase") {
1319
+ gzBytes = await archiveLazy().unwrapWithPassphrase(gzBytes, { passphrase: passphrase });
1320
+ }
1218
1321
  var gzReader = archiveLazy().read.gz(archiveAdaptersLazy().buffer(gzBytes), {
1219
1322
  maxDecompressedBytes: maxBundleBytes,
1220
1323
  maxExpansionRatio: 0,
@@ -1225,6 +1328,11 @@ function bundleAdapterStorage(opts) {
1225
1328
  }
1226
1329
  if (hasTar) {
1227
1330
  var tarBytes = await adapter.readFile(bundleId + TAR_KEY_SUFFIX);
1331
+ if (cryptoStrategy === "recipient") {
1332
+ tarBytes = archiveLazy().unwrap(tarBytes, { recipient: recipient });
1333
+ } else if (cryptoStrategy === "passphrase") {
1334
+ tarBytes = await archiveLazy().unwrapWithPassphrase(tarBytes, { passphrase: passphrase });
1335
+ }
1228
1336
  var reader = archiveLazy().read.tar(archiveAdaptersLazy().buffer(tarBytes));
1229
1337
  await reader.extract({ destination: destDir });
1230
1338
  return;
@@ -1341,7 +1449,15 @@ bundleAdapterStorage.fsAdapter = function (fsOpts) {
1341
1449
  async writeFile(key, bytes) {
1342
1450
  var path = _keyPath(key);
1343
1451
  atomicFile.ensureDir(nodePath.dirname(path));
1344
- nodeFs.writeFileSync(path, bytes);
1452
+ // mode 0o600 matches the v0.12.9 directory-format readback
1453
+ // discipline — backup payloads carry operator-owned bytes
1454
+ // (potentially PHI / PCI / GDPR-scoped); owner-only is the
1455
+ // strict posture. wx is not set here because writeFile is
1456
+ // the storage primitive (operators legitimately rewrite the
1457
+ // same key, e.g. resuming a multipart upload); upper layers
1458
+ // (writeBundle's `bundle-exists` check) enforce no-overwrite
1459
+ // at the bundle level.
1460
+ nodeFs.writeFileSync(path, bytes, { mode: 0o600 });
1345
1461
  },
1346
1462
  async readFile(key) {
1347
1463
  var path = _keyPath(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:f5a3cd7a-802d-4f55-ba59-958f474f38a0",
5
+ "serialNumber": "urn:uuid:56823451-d226-477b-92e0-bcb8d1c6e9b5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-23T17:58:07.192Z",
8
+ "timestamp": "2026-05-23T19:58:15.284Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.12.9",
22
+ "bom-ref": "@blamejs/core@0.12.11",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.9",
25
+ "version": "0.12.11",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.12.9",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.11",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.12.9",
57
+ "ref": "@blamejs/core@0.12.11",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]