@blamejs/blamejs-shop 0.0.115 → 0.0.117
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.117 (2026-05-24) — **Fix Add to Cart — `container.fetch` was auto-following 303 redirects and dropping `Set-Cookie`.** Add to Cart appeared to do nothing for visitors: the form POST returned 200 with the empty-cart page instead of the expected 303 + Set-Cookie + Location: /cart. Root cause: `_forwardToContainer` constructed the container-side Request with the Workers default `redirect: "follow"`, which auto-follows the storefront's 303 inside the Worker. The auto-followed GET /cart strips the `Set-Cookie: shop_sid=...` header from the intermediate 303 response — so the session cookie never reaches the visitor's browser, the subsequent (visible to the visitor) /cart GET arrives without the session, and the cart-line lookup keys on a fresh empty cart. End result for the visitor: the line WAS persisted to the container's session store, but every subsequent page load creates a new sessionless cart and shows it empty. Fix: `_attemptRequest` constructs the container-side Request with `redirect: "manual"` so the 303 + Set-Cookie chain passes through verbatim to the visitor's browser, which carries the cookie forward through the natural redirect navigation. Same bug affected `/cart/lines/<id>/update`, `/cart/lines/<id>/remove`, `/newsletter`, and any other server-side 303-with-cookie pattern routed through the container — all of those are fixed by the single line change. **Fixed:** *Cart-mutation 303 redirects preserve their `Set-Cookie` end-to-end* — `worker/index.js#_forwardToContainer` now constructs the container-side Request with `redirect: "manual"`. The storefront's POST handlers (`cart/lines`, `cart/lines/<id>/update`, `cart/lines/<id>/remove`, `newsletter`, future checkout submits) all reply with 303 + `Location` + `Set-Cookie: shop_sid=...` on the same response. Manual redirect handling passes that triple verbatim to the visitor's browser, which carries the cookie through the redirect navigation. Add to Cart now persists across the redirect; subsequent /cart loads show the line; the visitor sees what they expected to see when they clicked the button.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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`.
|
|
12
16
|
|
|
13
17
|
- 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.
|
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