@blamejs/blamejs-shop 0.0.114 → 0.0.116
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 +2 -2
- package/lib/vendor/blamejs/lib/backup/index.js +99 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.21.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-rewrap-bundle.test.js +233 -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.116 (2026-05-24) — **Top-level Worker exception catcher — convert Cloudflare 1101 to a clean 503 warming page.** An uncaught exception in the Worker's fetch handler used to propagate to the Cloudflare runtime, which served the generic `Error 1101 — Worker threw exception` page (cf-branded interstitial referencing wrangler tail). Visitors saw a Cloudflare error instead of the framework's render. Every fetch invocation now runs inside `_withTopLevelCatch`: catches every escape, logs to `console.error` (so wrangler tail / Logpush still see the underlying cause), and serves the existing 503 warming-up page with the framework's security headers + a `Retry-After: 10` hint. Cold-start blips during deploys, sporadic D1 / R2 binding hiccups, and any other exception that would have surfaced as 1101 now serve a branded, refresh-aware page that respects the operator's CSP / Permissions-Policy / HSTS posture. **Added:** *Top-level Worker exception catcher (`_withTopLevelCatch`)* — Wraps every `fetch` invocation. On exception: logs the stack via `console.error` (Worker substrate's observability sink — surfaces in `wrangler tail` + Logpush), then returns 503 with the existing `_warmingHtml(request.url, 8)` page + the framework's full security-headers set + `Retry-After: 10` + `Cache-Control: no-store`. Cloudflare's generic 1101 page never reaches the visitor; the cf-managed `Please enable cookies` interstitial only fires when CF's edge layer (Bot Fight Mode / challenge platform) intervenes BEFORE the Worker runs, not from Worker-thrown exceptions.
|
|
12
|
+
|
|
13
|
+
- v0.0.115 (2026-05-23) — **Drop `crossorigin` from the CSS preload Link header — fixes browser preload-credentials-mismatch warning.** Browsers were warning `A preload for '/assets/themes/default/css/main.css?v=0.0.0' is found, but is not used because the request credentials mode does not match. Consider taking a look at crossorigin attribute.` Root cause: the Early Hints / `Link: rel=preload` header carried `crossorigin`, but the matching `<link rel="stylesheet">` tag in the rendered HTML did not. The browser treats the preload as a CORS request, the stylesheet as a same-origin request, and the two don't share a connection — the preloaded byte stream is discarded and the stylesheet is re-fetched from scratch. The asset is genuinely same-origin (`/assets/...`), so `crossorigin` was always wrong on the preload side; removing it lets the preload and stylesheet share the request credentials mode and the browser reuses the preloaded bytes. **Fixed:** *`_earlyHintsLink` emits the CSS preload without `crossorigin`* — `worker/index.js#_earlyHintsLink` now emits `</assets/themes/default/css/main.css?v=...>; rel=preload; as=style` — no `crossorigin` token. The asset is same-origin, the stylesheet `<link>` tag carries no `crossorigin` attribute, and the browser's preload-credentials-match check passes. Devtools no longer warns `request credentials mode does not match`. Operators with per-route extras (hero image preload on PDP, etc.) inherit the same shape — extras already shipped without `crossorigin`.
|
|
14
|
+
|
|
11
15
|
- v0.0.114 (2026-05-23) — **Catalog mirror — close out the last 7 detectors + `matchOn: "basename"` runner support.** Closes the codebase-patterns catalog gap against vendored blamejs. The seven detectors held back from the v0.0.113 bulk port — six because the splice tool couldn't roundtrip their regex literals (embedded double-quotes / complex character classes) and one (`release-named-test-file`) that needed `matchOn: "basename"` support in the runner — are now hand-ported. Shop's catalog grows from 114 to 121 detectors, with seven covering archive-wrap PQC hybrid contracts, SQL transaction-wrapper extraction, the two `Map.prototype.getOrInsertComputed` Node-26 migration variants, PQC AlgorithmIdentifier `NULL` parameters anti-pattern, release-named test-file refusal, and `safeDecompress` paired-cap discipline. One legitimate `map-has-then-set` site in `lib/analytics.js#byCurrency` surfaced through the port and is kept with an inline `allow:` marker documenting the Node-26 floor-bump migration target. **Added:** *Seven new codebase-patterns detectors complete the catalog mirror* — `archive-wrap-recipient-missing-ec-half` (hybrid PQC `recipient: { publicKey: ... }` must also carry `ecPublicKey:` — uses `requires:` companion-check to exempt files where the matching `ecPublicKey` lives elsewhere), `inline-sql-transaction-wrapper` (BEGIN/COMMIT/ROLLBACK try/catch boilerplate routes through `dbSchema.runInTransaction`), `map-get-or-insert-pre-node-26` + `map-has-then-set-pre-node-26` (both variants of the two-step Map insert-if-absent pattern that Node 26 collapses to `Map.prototype.getOrInsertComputed`), `pqc-algid-with-null-params` (RFC 9909/9881/9936 — PQC AlgorithmIdentifier parameters field is ABSENT, not `NULL`), `release-named-test-file` (refuses `v0-8-41-additions.test.js` / `slot-19-enhancements.test.js` / `batch-N.test.js` shapes — uses the new `matchOn: "basename"` runner feature), `safedecompress-omits-max-compressed-bytes` (every `safeDecompress({ maxOutputBytes })` call MUST also name `maxCompressedBytes` so the caps stay aligned with operator intent — uses `requires:` companion-check). · *`matchOn: "basename"` runner support* — `_scan(regex, scope, { matchOn: "basename" })` matches the regex against each file's basename rather than file contents. Used by detectors that police naming conventions (e.g. release-named-test-file). The feature mirrors the runner contract in vendored blamejs's catalog so future basename-policing detectors can be ported verbatim. **Changed:** *`lib/analytics.js#byCurrency` carries an inline `allow:map-has-then-set-pre-node-26` marker* — The `if (!byCurrency.has(cur)) { ...byCurrency.set(cur, {...}); }` insert-if-absent in the per-currency aggregate loop migrates cleanly to `Map.prototype.getOrInsertComputed` when the framework's `engines.node` floor bumps to Node 26 (eligible Oct 2026 per the LTS calendar). The detector now exists so a fresh occurrence post-this-patch trips pre-merge; the existing site is allowed with a reason that includes the migration target.
|
|
12
16
|
|
|
13
17
|
- v0.0.113 (2026-05-23) — **Catalog mirror — 66 new codebase-patterns detectors ported from vendored blamejs.** The codebase-patterns detector catalog grows from 48 to 114 — 66 new detectors ported verbatim from `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`. The full vendored set covers archive-handling traps, framework-helper composition (the `inline-*` family), Node-version-conditional patterns, SBOM derivation traps, mail-server TLS upgrade hygiene, mountinfo bind-detection invariants, and dozens of other bug classes the framework has surfaced through audits. Every new detector scopes `shop` (lib + worker) so reinventions are caught anywhere in the application surface. Vendor allowlist entries referencing internal blamejs paths (e.g. `lib/crypto.js`) are kept verbatim — shop's `_walk` skips the vendor tree, so those entries are harmless no-ops and preserve the operator-readable reason text. Three pre-existing inline reinventions surfaced through the port and are kept with inline `allow:` markers documenting why the framework helper can't compose — the existing `TypeError` message contract requires the literal field name (`/timeoutMs/`, `/opts.version/`) which `validateOpts.*`'s `code`-first error shape doesn't carry. Eight detector blocks (six with regex literals containing literal `"` characters that the splice tool can't roundtrip, plus `release-named-test-file` whose `matchOn: "basename"` directive shop's runner doesn't honor yet, plus `inline-base64url-three-replace` already ported in 0.0.112) defer to a follow-up. **Added:** *Mirror 66 new codebase-patterns detectors from vendored blamejs* — Categories: archive-handling (extract-overwrite-without-refusal, gz-without-safedecompress, read-gz-without-self-authored-budget, tar-walker-without-truncation-check, wrap-recipient-missing-ec-half — except wrap-recipient which deferred to follow-up); the `inline-*` family of framework-helper composition reminders (40+ detectors covering aggregate-issues, assert-no-char-threats, audit-emit-wrapper, audit-shape-validation, bad-input-issue-result, batch-positive-int-validation, buffer-byte-equality-loop, build-guard-gate-forwarder, char-strip-policy-cascade, codepoint-class-table, compliance-posture-lookup, crlf-string-test, default-resolution-cascade, detect-char-threats, emit-event-wrapper, extract-bytes-as-text, flush-timer-scheduler, hex-string-validator, iso8601-millisecond-strip, issue-validator-entry, log-via-or-fallback, migration-filename-regex, numeric-bounds-cascade, object-store-http-request, observability-shape-validation, optional-* validators, profile-builder-forwarder, redis-client-opts-forwarding, require-* validators, resolve-profile-and-posture, rule-pack-loader, sql-identifier-regex, trailing-hspace-strip); SBOM derivation traps (toplevel-ref-by-slash-heuristic, bom-ref enum-rank-without-validation, etc.); compliance/posture coverage (compliance-posture-coverage-drift); cred-store + mailstore + mountinfo invariants; PQC + dot-stuff regex shapes; CONDSTORE + BDAT mail-server invariants. Full list visible by diffing `test/layer-0-primitives/codebase-patterns.test.js` against the v0.0.112 baseline. · *Worker `b` adapter exposes `b.validateOpts`* — `worker/b.js` now re-exports the `validate-opts` leaf module so future Worker primitives can compose `b.validateOpts.optionalPositiveFinite` / `b.validateOpts.optionalFunction` / `b.validateOpts.requireNonEmptyString` etc. against the same one-source-of-truth as lib-side primitives. Adds the leaf to the Worker bundle but doesn't bind a public namespace change. **Changed:** *Three inline-validation sites carry per-line `allow:` markers* — `lib/externaldb-d1.js#_validateOpts` (two lines: `timeoutMs`, `fetch`), `lib/r2-bridge.js#_validateOpts` (one line: `timeoutMs`), and `worker/render/search.js#renderSearch` (one line: `opts.version`) keep the literal `TypeError` shape with the labelled message because each is paired with a test contract that asserts the field name appears in the message (`assert.throws(..., /timeoutMs/)`). The framework's `validateOpts.*` helper produces a `code`-first `TypeError` (`TypeError: validate-opts/bad-positive-finite`) whose message doesn't carry the label; routing through it would silently strip the labelled-field contract from the test. Marker explains the constraint at the source.
|
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.21",
|
|
7
|
+
"tag": "v0.12.21",
|
|
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.21 (2026-05-24) — **`bundleAdapterStorage.rewrapBundle(bundleId, opts)` — key rotation without restore + rewrite of inner archive bytes.** `storage.rewrapBundle(bundleId, opts?)` rotates a bundle's wrap envelope under a new recipient keypair or passphrase WITHOUT touching the inner tar / tar.gz bytes. Operators rotating a compromised keypair, migrating to a new HSM, or refreshing passphrases on a HIPAA-posture repository previously had to `readBundle` → write to a stage dir → `writeBundle` under the new key — three byte-walks of the bundle payload, two filesystem touches, transient plaintext on disk. rewrapBundle does it as unwrap + rewrap in memory: zero disk plaintext, one round-trip through the wrap layer, the gzipped tar archive bytes inside the envelope are never inflated. Cross-kind rotation (recipient ↔ passphrase) is refused — that's a separate migration the operator configures with explicit cryptoStrategy switch. **Added:** *`storage.rewrapBundle(bundleId, opts?)` — in-place envelope rotation* — Unwraps the bundle under the old key (storage's configured recipient/passphrase OR `opts.oldRecipient` / `opts.oldPassphrase`), re-wraps under the new key (`opts.newRecipient` / `opts.newPassphrase`), writes the rewrapped bytes back to the same storage key. Returns `{ bundleId, oldEnvelopeKind, newEnvelopeKind, bytesRewritten }`. Plaintext bundles refused with `backup/no-envelope-to-rewrap`; cross-kind rotation refused with `backup/no-new-recipient` / `backup/no-new-passphrase`. The inner archive bytes (the gz-compressed tar payload) are never decompressed or re-encoded — rewrap is a wrap-layer-only operation. **Security:** *Zero plaintext on disk during rotation* — The inner tar bytes flow only through memory — old-envelope unwrap → new-envelope wrap → adapter writeFile. Operators previously rotating via readBundle + writeBundle wrote plaintext archive bytes to a temporary stage directory; rewrapBundle removes that exposure window entirely. Matches the operator-side discipline that backup payloads should never land on disk in plaintext form during steady-state operations.
|
|
12
|
+
|
|
11
13
|
- v0.12.20 (2026-05-24) — **`bundleAdapterStorage.verifyAllBundles(opts)` — bounded-parallel batch integrity walk with stopOnFirstFailure short-circuit.** Batch wrapper over the v0.12.19 verifyBundle primitive. `storage.verifyAllBundles(opts?)` iterates `listBundles()` + walks each bundle with a bounded-parallel pool. Returns `{ total, ok, failed, results }` where `results` carries every per-bundle verifyBundle output (including bundleId, format, envelopeKind, entryCount, errors). `opts.concurrency` defaults to 4 (gentle on the storage backend); `opts.stopOnFirstFailure` short-circuits the walk when an unhealthy bundle is found (default off — operators want the full health report). `opts.recipient` / `opts.passphrase` forwarded to every per-bundle verify call. Operators wiring a periodic cron job over a backup repository now have a single primitive to call instead of hand-rolling the listBundles loop. **Added:** *`storage.verifyAllBundles(opts?)` — batch integrity walk* — Iterates `listBundles()` + calls `verifyBundle(bundleId, opts)` on each. Bounded-parallel fan-out (default 4 workers; `opts.concurrency` raises or lowers); each worker pulls from a shared queue + the warm-up keeps `concurrency` workers in flight until the queue drains. Returns the aggregate `{ total, ok, failed, results }` with `results` sorted by bundleId so the report is stable across runs regardless of completion order. `opts.stopOnFirstFailure` short-circuits the walk when the first unhealthy bundle is found — useful for fast-fail CI gates that don't need to enumerate every failure.
|
|
12
14
|
|
|
13
15
|
- v0.12.19 (2026-05-24) — **`bundleAdapterStorage.verifyBundle(bundleId)` — bundle integrity check without restore + `b.safeArchive.inspect` tar.gz support.** `storage.verifyBundle(bundleId, opts?)` walks a bundle without restoring it: confirms the payload exists, the envelope (if any) decrypts under the supplied key, and the inner tar / tar.gz walker enumerates every entry without writing to disk. Returns `{ ok, format, envelopeKind, entryCount, errors }` — operators wanting periodic health-check of a backup repository call verifyBundle across `listBundles()` and aggregate `ok === false` results. Composes the bundle's known format directly through the unwrap → gunzip → tar pipeline (skips safeArchive's auto-sniff because the bundle's format is already known from bundleInfo). `b.safeArchive.inspect` separately gains `format: "tar.gz"` dispatch — operators with a known tar.gz payload now get entry enumeration without extracting. **Added:** *`storage.verifyBundle(bundleId, opts?)` — integrity check without restore* — Composes bundleInfo for format/envelope detection + the unwrap → gunzip → tar walker chain directly. opts.recipient / opts.passphrase override the storage's configured keys (useful for verifying a bundle under a different key set than the storage was opened with — e.g. verifying that a key rotation candidate can still read a bundle). Wrong key / corrupted payload returns `ok: false` with a typed error code in the errors array rather than throwing. Directory format reports ok=true based on manifest existence + readability (the inspect walker doesn't apply). · *`b.safeArchive.inspect` accepts `format: "tar.gz"`* — Mirrors the v0.12.9 extract surface: operators handed a `.tar.gz` payload can now enumerate entries without extracting. Composes `b.archive.read.gz` + `.asTar()` internally so the same bomb caps apply (1 GiB output / 100× ratio default) to inspect calls.
|
|
@@ -1532,6 +1532,105 @@ function bundleAdapterStorage(opts) {
|
|
|
1532
1532
|
};
|
|
1533
1533
|
}
|
|
1534
1534
|
},
|
|
1535
|
+
// rewrapBundle(bundleId, opts) — v0.12.21 key rotation
|
|
1536
|
+
// without restore + rewrite. Unwraps the bundle under the old
|
|
1537
|
+
// key + re-wraps under the new key. Inner tar / tar.gz bytes
|
|
1538
|
+
// are never decompressed or rewritten. Operators rotating
|
|
1539
|
+
// recipient keypairs avoid the full restore-to-disk → rewrite
|
|
1540
|
+
// cycle. Returns `{ bundleId, oldEnvelopeKind, newEnvelopeKind,
|
|
1541
|
+
// bytesRewritten }`. Refuses cross-kind rotation (recipient ↔
|
|
1542
|
+
// passphrase) — that's a separate migration the operator
|
|
1543
|
+
// configures explicitly.
|
|
1544
|
+
async rewrapBundle(bundleId, rwOpts) {
|
|
1545
|
+
rwOpts = rwOpts || {};
|
|
1546
|
+
_ensureBundleId(bundleId);
|
|
1547
|
+
var info = await this.bundleInfo(bundleId);
|
|
1548
|
+
if (info.format !== "tar" && info.format !== "tar.gz") {
|
|
1549
|
+
throw new BackupError("backup/format-not-wrappable",
|
|
1550
|
+
"rewrapBundle: '" + bundleId + "' has format=" + JSON.stringify(info.format) +
|
|
1551
|
+
" — only tar / tar.gz bundles carry wrap envelopes");
|
|
1552
|
+
}
|
|
1553
|
+
var rwKeySuffix = info.format === "tar.gz" ? TAR_GZ_KEY_SUFFIX : TAR_KEY_SUFFIX;
|
|
1554
|
+
var sealed = await adapter.readFile(bundleId + rwKeySuffix);
|
|
1555
|
+
var envelopeKind = info.envelopeKind;
|
|
1556
|
+
// Codex P2 on v0.12.21 PR #172 — when the adapter has no
|
|
1557
|
+
// readPartial capability, bundleInfo returns envelopeKind:
|
|
1558
|
+
// "unknown" rather than risk a full payload load. For
|
|
1559
|
+
// rewrap, we already have to load the payload (to unwrap),
|
|
1560
|
+
// so fall back to a sniffEnvelope on the loaded sealed
|
|
1561
|
+
// bytes — fixes the regression where adapters satisfying
|
|
1562
|
+
// the minimum contract couldn't use rewrapBundle.
|
|
1563
|
+
if (envelopeKind === "unknown") {
|
|
1564
|
+
envelopeKind = archiveLazy().sniffEnvelope(sealed);
|
|
1565
|
+
}
|
|
1566
|
+
if (envelopeKind !== "recipient" && envelopeKind !== "passphrase") {
|
|
1567
|
+
throw new BackupError("backup/no-envelope-to-rewrap",
|
|
1568
|
+
"rewrapBundle: '" + bundleId + "' carries envelopeKind=" +
|
|
1569
|
+
JSON.stringify(envelopeKind) + " — nothing to rewrap");
|
|
1570
|
+
}
|
|
1571
|
+
// Override the info.envelopeKind we use below so the
|
|
1572
|
+
// dispatch + return shape reflect the actual envelope we
|
|
1573
|
+
// detected (matters when bundleInfo returned "unknown").
|
|
1574
|
+
info = Object.assign({}, info, { envelopeKind: envelopeKind });
|
|
1575
|
+
var inner;
|
|
1576
|
+
if (info.envelopeKind === "recipient") {
|
|
1577
|
+
var oldRcp = rwOpts.oldRecipient !== undefined ? rwOpts.oldRecipient : recipient;
|
|
1578
|
+
if (!oldRcp) {
|
|
1579
|
+
throw new BackupError("backup/no-old-recipient",
|
|
1580
|
+
"rewrapBundle: opts.oldRecipient (or the storage's configured recipient) is required to unwrap");
|
|
1581
|
+
}
|
|
1582
|
+
if (!rwOpts.newRecipient || typeof rwOpts.newRecipient !== "object") {
|
|
1583
|
+
throw new BackupError("backup/no-new-recipient",
|
|
1584
|
+
"rewrapBundle: opts.newRecipient is required to re-seal under the rotated key");
|
|
1585
|
+
}
|
|
1586
|
+
inner = archiveLazy().unwrap(sealed, { recipient: oldRcp });
|
|
1587
|
+
var resealed = archiveLazy().wrap(inner, { recipient: rwOpts.newRecipient });
|
|
1588
|
+
await adapter.writeFile(bundleId + rwKeySuffix, resealed);
|
|
1589
|
+
return {
|
|
1590
|
+
bundleId: bundleId,
|
|
1591
|
+
oldEnvelopeKind: "recipient",
|
|
1592
|
+
newEnvelopeKind: "recipient",
|
|
1593
|
+
bytesRewritten: resealed.length,
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
// passphrase
|
|
1597
|
+
var oldPass = rwOpts.oldPassphrase !== undefined ? rwOpts.oldPassphrase : passphrase;
|
|
1598
|
+
if (typeof oldPass !== "string" && !Buffer.isBuffer(oldPass)) {
|
|
1599
|
+
throw new BackupError("backup/no-old-passphrase",
|
|
1600
|
+
"rewrapBundle: opts.oldPassphrase (or the storage's configured passphrase) is required to unwrap");
|
|
1601
|
+
}
|
|
1602
|
+
if (typeof rwOpts.newPassphrase !== "string" && !Buffer.isBuffer(rwOpts.newPassphrase)) {
|
|
1603
|
+
throw new BackupError("backup/no-new-passphrase",
|
|
1604
|
+
"rewrapBundle: opts.newPassphrase is required (string or Buffer) to re-seal");
|
|
1605
|
+
}
|
|
1606
|
+
inner = await archiveLazy().unwrapWithPassphrase(sealed, { passphrase: oldPass });
|
|
1607
|
+
// Codex P1 on v0.12.21 PR #172 — preserve the storage's
|
|
1608
|
+
// configured entropy floor across rewrap. The
|
|
1609
|
+
// writeBundle path raises the floor to 128 bits under
|
|
1610
|
+
// HIPAA / PCI-DSS postures (per
|
|
1611
|
+
// BACKUP_ENCRYPTION_REQUIRED_POSTURES); rewrapBundle MUST
|
|
1612
|
+
// apply the same floor so a rotated passphrase that
|
|
1613
|
+
// writeBundle would refuse can't slip through. The
|
|
1614
|
+
// operator's explicit rwOpts.passphraseMinEntropyBits
|
|
1615
|
+
// can raise the floor further but cannot lower it.
|
|
1616
|
+
var effectiveFloor = passphraseMinEntropyBits; // storage's posture-effective floor
|
|
1617
|
+
if (typeof rwOpts.passphraseMinEntropyBits === "number" &&
|
|
1618
|
+
Number.isFinite(rwOpts.passphraseMinEntropyBits) &&
|
|
1619
|
+
rwOpts.passphraseMinEntropyBits > effectiveFloor) {
|
|
1620
|
+
effectiveFloor = Math.floor(rwOpts.passphraseMinEntropyBits);
|
|
1621
|
+
}
|
|
1622
|
+
var resealedP = await archiveLazy().wrapWithPassphrase(inner, {
|
|
1623
|
+
passphrase: rwOpts.newPassphrase,
|
|
1624
|
+
minEntropyBits: effectiveFloor,
|
|
1625
|
+
});
|
|
1626
|
+
await adapter.writeFile(bundleId + rwKeySuffix, resealedP);
|
|
1627
|
+
return {
|
|
1628
|
+
bundleId: bundleId,
|
|
1629
|
+
oldEnvelopeKind: "passphrase",
|
|
1630
|
+
newEnvelopeKind: "passphrase",
|
|
1631
|
+
bytesRewritten: resealedP.length,
|
|
1632
|
+
};
|
|
1633
|
+
},
|
|
1535
1634
|
// verifyAllBundles(opts?) — v0.12.20 batch integrity check.
|
|
1536
1635
|
// Iterates listBundles() + calls verifyBundle on each. Returns
|
|
1537
1636
|
// `{ total, ok, failed, results }` where `results` is an array
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.21",
|
|
4
|
+
"date": "2026-05-24",
|
|
5
|
+
"headline": "`bundleAdapterStorage.rewrapBundle(bundleId, opts)` — key rotation without restore + rewrite of inner archive bytes",
|
|
6
|
+
"summary": "`storage.rewrapBundle(bundleId, opts?)` rotates a bundle's wrap envelope under a new recipient keypair or passphrase WITHOUT touching the inner tar / tar.gz bytes. Operators rotating a compromised keypair, migrating to a new HSM, or refreshing passphrases on a HIPAA-posture repository previously had to `readBundle` → write to a stage dir → `writeBundle` under the new key — three byte-walks of the bundle payload, two filesystem touches, transient plaintext on disk. rewrapBundle does it as unwrap + rewrap in memory: zero disk plaintext, one round-trip through the wrap layer, the gzipped tar archive bytes inside the envelope are never inflated. Cross-kind rotation (recipient ↔ passphrase) is refused — that's a separate migration the operator configures with explicit cryptoStrategy switch.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`storage.rewrapBundle(bundleId, opts?)` — in-place envelope rotation",
|
|
13
|
+
"body": "Unwraps the bundle under the old key (storage's configured recipient/passphrase OR `opts.oldRecipient` / `opts.oldPassphrase`), re-wraps under the new key (`opts.newRecipient` / `opts.newPassphrase`), writes the rewrapped bytes back to the same storage key. Returns `{ bundleId, oldEnvelopeKind, newEnvelopeKind, bytesRewritten }`. Plaintext bundles refused with `backup/no-envelope-to-rewrap`; cross-kind rotation refused with `backup/no-new-recipient` / `backup/no-new-passphrase`. The inner archive bytes (the gz-compressed tar payload) are never decompressed or re-encoded — rewrap is a wrap-layer-only operation."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Security",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "Zero plaintext on disk during rotation",
|
|
22
|
+
"body": "The inner tar bytes flow only through memory — old-envelope unwrap → new-envelope wrap → adapter writeFile. Operators previously rotating via readBundle + writeBundle wrote plaintext archive bytes to a temporary stage directory; rewrapBundle removes that exposure window entirely. Matches the operator-side discipline that backup payloads should never land on disk in plaintext form during steady-state operations."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — bundleAdapterStorage.rewrapBundle key rotation without
|
|
4
|
+
* restore/rewrite of inner archive bytes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
var fs = require("node:fs");
|
|
8
|
+
var path = require("node:path");
|
|
9
|
+
var os = require("node:os");
|
|
10
|
+
var b = require("../../index");
|
|
11
|
+
var helpers = require("../helpers");
|
|
12
|
+
var check = helpers.check;
|
|
13
|
+
|
|
14
|
+
async function testRewrapRecipientRotation() {
|
|
15
|
+
var oldPair = b.crypto.generateEncryptionKeyPair();
|
|
16
|
+
var newPair = b.crypto.generateEncryptionKeyPair();
|
|
17
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "rw-src-"));
|
|
18
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "rw-dest-"));
|
|
19
|
+
var verify = path.join(os.tmpdir(), "rw-v-" + Date.now());
|
|
20
|
+
try {
|
|
21
|
+
fs.writeFileSync(path.join(src, "a.json"), "{\"v\":1}", { mode: 0o600 });
|
|
22
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
23
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
24
|
+
format: "tar.gz",
|
|
25
|
+
cryptoStrategy: "recipient",
|
|
26
|
+
recipient: oldPair,
|
|
27
|
+
});
|
|
28
|
+
var bid = "2026-05-24T06-00-00-000Z-aabbccdd";
|
|
29
|
+
await storage.writeBundle(bid, src);
|
|
30
|
+
var rw = await storage.rewrapBundle(bid, { newRecipient: newPair });
|
|
31
|
+
check("rewrapBundle: returns oldEnvelopeKind + newEnvelopeKind",
|
|
32
|
+
rw.oldEnvelopeKind === "recipient" && rw.newEnvelopeKind === "recipient");
|
|
33
|
+
check("rewrapBundle: bytesRewritten > 0", rw.bytesRewritten > 0);
|
|
34
|
+
// Open a fresh storage with newPair + restore
|
|
35
|
+
var rotated = b.backup.bundleAdapterStorage({
|
|
36
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
37
|
+
format: "tar.gz",
|
|
38
|
+
cryptoStrategy: "recipient",
|
|
39
|
+
recipient: newPair,
|
|
40
|
+
});
|
|
41
|
+
await rotated.readBundle(bid, verify);
|
|
42
|
+
check("rewrapBundle: bundle restores under newRecipient after rotation",
|
|
43
|
+
fs.readFileSync(path.join(verify, "a.json"), "utf-8") === "{\"v\":1}");
|
|
44
|
+
} finally {
|
|
45
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
46
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
47
|
+
try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function testRewrapPassphraseRotation() {
|
|
52
|
+
var oldPass = "aLongCorrectHorseBatteryStaple9876!Phrase";
|
|
53
|
+
var newPass = "completelyDifferentPassphraseEvenLonger123!@#";
|
|
54
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "rw-p-src-"));
|
|
55
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "rw-p-dest-"));
|
|
56
|
+
var verify = path.join(os.tmpdir(), "rw-p-v-" + Date.now());
|
|
57
|
+
try {
|
|
58
|
+
fs.writeFileSync(path.join(src, "a.json"), "{\"v\":2}", { mode: 0o600 });
|
|
59
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
60
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
61
|
+
format: "tar.gz",
|
|
62
|
+
cryptoStrategy: "passphrase",
|
|
63
|
+
passphrase: oldPass,
|
|
64
|
+
});
|
|
65
|
+
var bid = "2026-05-24T06-30-00-000Z-eeffaabb";
|
|
66
|
+
await storage.writeBundle(bid, src);
|
|
67
|
+
var rw = await storage.rewrapBundle(bid, { newPassphrase: newPass });
|
|
68
|
+
check("rewrapBundle: passphrase rotation reports passphrase envelope",
|
|
69
|
+
rw.oldEnvelopeKind === "passphrase" && rw.newEnvelopeKind === "passphrase");
|
|
70
|
+
var rotated = b.backup.bundleAdapterStorage({
|
|
71
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
72
|
+
format: "tar.gz",
|
|
73
|
+
cryptoStrategy: "passphrase",
|
|
74
|
+
passphrase: newPass,
|
|
75
|
+
});
|
|
76
|
+
await rotated.readBundle(bid, verify);
|
|
77
|
+
check("rewrapBundle: bundle restores under newPassphrase after rotation",
|
|
78
|
+
fs.readFileSync(path.join(verify, "a.json"), "utf-8") === "{\"v\":2}");
|
|
79
|
+
} finally {
|
|
80
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
81
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
82
|
+
try { fs.rmSync(verify, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function testRewrapRefusesPlaintextBundle() {
|
|
87
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "rw-pt-src-"));
|
|
88
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "rw-pt-dest-"));
|
|
89
|
+
try {
|
|
90
|
+
fs.writeFileSync(path.join(src, "a"), "x", { mode: 0o600 });
|
|
91
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
92
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
93
|
+
format: "tar.gz",
|
|
94
|
+
});
|
|
95
|
+
var bid = "2026-05-24T06-45-00-000Z-99887766";
|
|
96
|
+
await storage.writeBundle(bid, src);
|
|
97
|
+
var refused = null;
|
|
98
|
+
try {
|
|
99
|
+
await storage.rewrapBundle(bid, {
|
|
100
|
+
newRecipient: b.crypto.generateEncryptionKeyPair(),
|
|
101
|
+
});
|
|
102
|
+
} catch (e) { refused = e; }
|
|
103
|
+
check("rewrapBundle: plaintext bundle refused with no-envelope-to-rewrap",
|
|
104
|
+
refused && /no-envelope-to-rewrap/.test(refused.code || refused.message));
|
|
105
|
+
} finally {
|
|
106
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
107
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function testRewrapHipaaPreservesEntropyFloor() {
|
|
112
|
+
// Codex P1 on v0.12.21 PR #172 — under HIPAA posture, the
|
|
113
|
+
// storage's effective entropy floor is 128 bits. rewrapBundle
|
|
114
|
+
// must enforce that floor regardless of what
|
|
115
|
+
// opts.passphraseMinEntropyBits says — otherwise a rotation to
|
|
116
|
+
// a weak passphrase that writeBundle would refuse slips through.
|
|
117
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "rw-hipaa-src-"));
|
|
118
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "rw-hipaa-dest-"));
|
|
119
|
+
try {
|
|
120
|
+
fs.writeFileSync(path.join(src, "phi.json"), "{\"id\":42}", { mode: 0o600 });
|
|
121
|
+
var strongPass = "aLongCorrectHorseBatteryStaple9876!Phrase"; // ~227 bits
|
|
122
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
123
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
124
|
+
format: "tar.gz",
|
|
125
|
+
cryptoStrategy: "passphrase",
|
|
126
|
+
passphrase: strongPass,
|
|
127
|
+
posture: "hipaa",
|
|
128
|
+
});
|
|
129
|
+
var bid = "2026-05-24T07-30-00-000Z-bbbb1111";
|
|
130
|
+
await storage.writeBundle(bid, src);
|
|
131
|
+
// Try rotating to a passphrase that's ~100 bits — passes the
|
|
132
|
+
// default 80-bit floor but should be refused under HIPAA's
|
|
133
|
+
// 128-bit floor.
|
|
134
|
+
var weakPass = "lowercaseonlyword123"; // 20 chars, lower+digit alphabet=36 → ~103 bits — above 80, below 128
|
|
135
|
+
var refused = null;
|
|
136
|
+
try {
|
|
137
|
+
await storage.rewrapBundle(bid, { newPassphrase: weakPass });
|
|
138
|
+
} catch (e) { refused = e; }
|
|
139
|
+
check("rewrapBundle: HIPAA posture's 128-bit entropy floor enforced across rotation",
|
|
140
|
+
refused && /weak-passphrase/.test(refused.code || refused.message));
|
|
141
|
+
} finally {
|
|
142
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
143
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function testRewrapWithLegacyAdapter() {
|
|
148
|
+
// Codex P2 on v0.12.21 PR #172 — adapters without readPartial
|
|
149
|
+
// get envelopeKind: "unknown" from bundleInfo. rewrapBundle
|
|
150
|
+
// must fall back to sniffing the loaded sealed bytes rather
|
|
151
|
+
// than refusing with no-envelope-to-rewrap.
|
|
152
|
+
var pair = b.crypto.generateEncryptionKeyPair();
|
|
153
|
+
var newPair = b.crypto.generateEncryptionKeyPair();
|
|
154
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "rw-leg-src-"));
|
|
155
|
+
var fullDest = fs.mkdtempSync(path.join(os.tmpdir(), "rw-leg-dest-"));
|
|
156
|
+
try {
|
|
157
|
+
fs.writeFileSync(path.join(src, "a"), "x", { mode: 0o600 });
|
|
158
|
+
// Bootstrap with the full fsAdapter to get a valid bundle.
|
|
159
|
+
var bootstrap = b.backup.bundleAdapterStorage({
|
|
160
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: fullDest }),
|
|
161
|
+
format: "tar.gz",
|
|
162
|
+
cryptoStrategy: "recipient",
|
|
163
|
+
recipient: pair,
|
|
164
|
+
});
|
|
165
|
+
var bid = "2026-05-24T07-45-00-000Z-cccc2222";
|
|
166
|
+
await bootstrap.writeBundle(bid, src);
|
|
167
|
+
// Re-wrap the bundle bytes through a legacy adapter that
|
|
168
|
+
// exposes only the minimum contract (no readPartial / statKey).
|
|
169
|
+
var fullAdapter = b.backup.bundleAdapterStorage.fsAdapter({ root: fullDest });
|
|
170
|
+
var legacyAdapter = {
|
|
171
|
+
writeFile: fullAdapter.writeFile,
|
|
172
|
+
readFile: fullAdapter.readFile,
|
|
173
|
+
listKeys: fullAdapter.listKeys,
|
|
174
|
+
deleteKey: fullAdapter.deleteKey,
|
|
175
|
+
hasKey: fullAdapter.hasKey,
|
|
176
|
+
// Intentionally omit readPartial + statKey.
|
|
177
|
+
};
|
|
178
|
+
var legacyStorage = b.backup.bundleAdapterStorage({
|
|
179
|
+
adapter: legacyAdapter,
|
|
180
|
+
format: "tar.gz",
|
|
181
|
+
cryptoStrategy: "recipient",
|
|
182
|
+
recipient: pair,
|
|
183
|
+
});
|
|
184
|
+
var rw = await legacyStorage.rewrapBundle(bid, { newRecipient: newPair });
|
|
185
|
+
check("rewrapBundle: legacy adapter (no readPartial) succeeds via fallback sniff",
|
|
186
|
+
rw.oldEnvelopeKind === "recipient" && rw.newEnvelopeKind === "recipient");
|
|
187
|
+
} finally {
|
|
188
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
189
|
+
try { fs.rmSync(fullDest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function testRewrapRefusesMissingNewRecipient() {
|
|
194
|
+
var oldPair = b.crypto.generateEncryptionKeyPair();
|
|
195
|
+
var src = fs.mkdtempSync(path.join(os.tmpdir(), "rw-nr-src-"));
|
|
196
|
+
var dest = fs.mkdtempSync(path.join(os.tmpdir(), "rw-nr-dest-"));
|
|
197
|
+
try {
|
|
198
|
+
fs.writeFileSync(path.join(src, "a"), "x", { mode: 0o600 });
|
|
199
|
+
var storage = b.backup.bundleAdapterStorage({
|
|
200
|
+
adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: dest }),
|
|
201
|
+
format: "tar.gz",
|
|
202
|
+
cryptoStrategy: "recipient",
|
|
203
|
+
recipient: oldPair,
|
|
204
|
+
});
|
|
205
|
+
var bid = "2026-05-24T07-00-00-000Z-aabbcc01";
|
|
206
|
+
await storage.writeBundle(bid, src);
|
|
207
|
+
var refused = null;
|
|
208
|
+
try { await storage.rewrapBundle(bid, {}); } catch (e) { refused = e; }
|
|
209
|
+
check("rewrapBundle: missing newRecipient refused upfront",
|
|
210
|
+
refused && /no-new-recipient/.test(refused.code || refused.message));
|
|
211
|
+
} finally {
|
|
212
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
213
|
+
try { fs.rmSync(dest, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function run() {
|
|
218
|
+
await testRewrapRecipientRotation();
|
|
219
|
+
await testRewrapPassphraseRotation();
|
|
220
|
+
await testRewrapRefusesPlaintextBundle();
|
|
221
|
+
await testRewrapRefusesMissingNewRecipient();
|
|
222
|
+
await testRewrapHipaaPreservesEntropyFloor();
|
|
223
|
+
await testRewrapWithLegacyAdapter();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { run: run };
|
|
227
|
+
|
|
228
|
+
if (require.main === module) {
|
|
229
|
+
run().then(
|
|
230
|
+
function () { console.log("[backup-rewrap-bundle] OK — " + helpers.getChecks() + " checks passed"); },
|
|
231
|
+
function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
|
|
232
|
+
);
|
|
233
|
+
}
|
package/package.json
CHANGED