@blamejs/blamejs-shop 0.3.40 → 0.3.41
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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +14 -3
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +8 -0
- package/lib/vendor/blamejs/README.md +8 -5
- package/lib/vendor/blamejs/SECURITY.md +7 -0
- package/lib/vendor/blamejs/api-snapshot.json +266 -2
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
- package/lib/vendor/blamejs/index.js +7 -1
- package/lib/vendor/blamejs/lib/agent-idempotency.js +113 -0
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +108 -0
- package/lib/vendor/blamejs/lib/agent-snapshot.js +137 -0
- package/lib/vendor/blamejs/lib/agent-tenant.js +193 -17
- package/lib/vendor/blamejs/lib/ai-input.js +167 -3
- package/lib/vendor/blamejs/lib/ai-output.js +463 -0
- package/lib/vendor/blamejs/lib/ai-prompt.js +304 -0
- package/lib/vendor/blamejs/lib/archive-wrap.js +234 -1
- package/lib/vendor/blamejs/lib/archive.js +1 -0
- package/lib/vendor/blamejs/lib/audit.js +3 -0
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +47 -28
- package/lib/vendor/blamejs/lib/cluster.js +186 -14
- package/lib/vendor/blamejs/lib/codepoint-class.js +18 -0
- package/lib/vendor/blamejs/lib/compliance-ai-act.js +446 -0
- package/lib/vendor/blamejs/lib/consent.js +104 -8
- package/lib/vendor/blamejs/lib/content-credentials.js +851 -41
- package/lib/vendor/blamejs/lib/crypto-field.js +5 -0
- package/lib/vendor/blamejs/lib/db.js +15 -0
- package/lib/vendor/blamejs/lib/framework-error.js +21 -0
- package/lib/vendor/blamejs/lib/mail-srs.js +122 -19
- package/lib/vendor/blamejs/lib/privacy.js +168 -0
- package/lib/vendor/blamejs/lib/safe-archive.js +196 -136
- package/lib/vendor/blamejs/lib/validate-opts.js +24 -0
- package/lib/vendor/blamejs/lib/vault/rotate.js +175 -15
- package/lib/vendor/blamejs/lib/vault-aad.js +84 -33
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.11.json +72 -0
- package/lib/vendor/blamejs/release-notes/v0.14.12.json +95 -0
- package/lib/vendor/blamejs/release-notes/v0.14.13.json +52 -0
- package/lib/vendor/blamejs/release-notes/v0.14.14.json +31 -0
- package/lib/vendor/blamejs/test/00-primitives.js +9 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +103 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +91 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +186 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +140 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-input.test.js +59 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-output.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-prompt.test.js +133 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +6 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +176 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-vault-rotation.test.js +243 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +130 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/consent-purposes.test.js +70 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +289 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-srs.test.js +61 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/privacy-vendor-review.test.js +69 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate-aad.test.js +158 -0
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.14.12",
|
|
4
|
+
"date": "2026-05-31",
|
|
5
|
+
"headline": "Vault key rotation re-seals AAD-bound storage under the new root instead of silently orphaning it",
|
|
6
|
+
"summary": "Every AAD-sealed cell derives its key from the live vault root, so rotating the vault keypair changes those keys. `b.vaultRotate.rotate` previously re-sealed only legacy `vault:`-prefixed cells in `db.enc` and skipped `vault.aad:` cells, AAD-bound at-rest files, and operator-supplied AAD stores — leaving them encrypted under the retired keypair while still returning a success result and a passing round-trip verify, so the loss was invisible until the old keypair was discarded and the cells became permanently undecryptable. Rotation now re-seals `db.enc` (preserving its dataDir-bound AAD), `db.key.enc` (location-bound), every `{ aad: true }` table column, and the overflow store under the new root; refuses up front with a fail-closed error when operator-supplied AAD stores (agent idempotency / orchestrator / tenant / snapshot) are reachable unless each has been re-sealed via its module hook and explicitly acknowledged; and the round-trip verify now decrypts AAD-sealed cells under the new root and treats any cell that still opens under the old root as a regression. New explicit-root `b.vault.aad` seal / unseal / reseal primitives carry a cell from the old root to the new one while preserving its AAD tuple; `b.archive.rewrapTenant` re-wraps tenant-scoped archive envelopes; and `b.cluster` can adopt a rotated vault-key fingerprint instead of partitioning the membership during a rolling rotation.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.vault.aad.sealRoot` / `unsealRoot` / `resealRoot`",
|
|
13
|
+
"body": "Explicit-root variants of the AAD seal / unseal that take a root-keypair JSON (`b.vault.getKeysJson()` output) instead of reading the live vault singleton. `resealRoot(value, aadParts, oldRootJson, newRootJson)` opens a cell under the old root and re-seals it under the new one while preserving the same AAD tuple (`table` / `rowId` / `column` / `schemaVersion`), which is what lets a rotation worker move AAD-bound state across a keypair change without altering the bound context. The default-root `b.vault.aad.seal` / `unseal` behaviour is unchanged."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "Per-store AAD re-seal hooks on the agent primitives",
|
|
17
|
+
"body": "`b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, and the `b.agent.tenant` registry / tenant-cell reseal paths re-seal that module's AAD-bound rows from an old root to a new root over the operator's own store. Each module also exposes an `AAD_ROTATION` descriptor naming the store the rotation pipeline cannot reach on its own, so an operator can enumerate exactly what to re-seal before a rotation."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "`b.archive.rewrapTenant`",
|
|
21
|
+
"body": "Re-wraps a `recipient: \"tenant\"` archive envelope from an old vault root to a new one for a given `tenantId`, so a keypair rotation does not strand tenant-scoped archives. Opens the blob under the old root + tenantId, refuses a blob that is not a tenant-recipient envelope or that does not open under the supplied old root, and emits a fresh envelope bound to the new root. This is offered alongside the documented re-export path (decrypt with the old keypair, re-archive with the new one) for operators who hold the envelope but not the source."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"title": "Cluster vault-key rotation acceptance",
|
|
25
|
+
"body": "A vault-key rotation changes the public-key fingerprint recorded in the canonical cluster-state row, which a peer would otherwise report as `VAULT_KEY_DRIFT`. `b.cluster` configuration gains `acceptVaultKeyRotation: true` to declare the change legitimate — the node adopts the rotated fingerprint and bumps a rotation epoch instead of refusing — and an optional `expectedVaultKeyFp` that narrows acceptance to a single blessed post-rotation fingerprint. The drift guard stays in force whenever a rotation is not declared; supplying `expectedVaultKeyFp` without `acceptVaultKeyRotation` is rejected at configuration time as a misconfiguration."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"heading": "Changed",
|
|
31
|
+
"items": [
|
|
32
|
+
{
|
|
33
|
+
"title": "`b.vaultRotate.rotate` refuses when reachable AAD stores are not acknowledged",
|
|
34
|
+
"body": "Because the rotation pipeline walks only `db.enc` and cannot introspect an operator's own AAD-backed stores, it now detects which AAD-store modules are loadable and throws `vault-rotate/external-aad-unresealed` unless `opts.externalAadResealed` is either `true` (you do not use those features) or an array naming every detected store (you have re-sealed each via its hook). This converts a path that previously discarded data and reported success into a fail-closed gate. The error names each store and the hook to call."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"heading": "Fixed",
|
|
40
|
+
"items": [
|
|
41
|
+
{
|
|
42
|
+
"title": "Rotation re-seals `vault.aad:` cells and AAD-bound at-rest files",
|
|
43
|
+
"body": "`db.enc` is re-written bound to its dataDir-scoped AAD (it was previously re-written un-bound, silently stripping the at-rest AAD binding on every rotation), `db.key.enc` retains its location-bound AAD, and every `{ aad: true }` table column plus the overflow store is re-sealed under the new root. Previously only `vault:`-prefixed cells were carried across, so AAD-sealed data was left encrypted under the retired keypair and lost once it was discarded."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"title": "Round-trip verify no longer reports a false success",
|
|
47
|
+
"body": "`b.vaultRotate.verify` now samples and decrypts AAD-sealed cells under the new root and treats any cell that still decrypts under the old root as a regression, so an incomplete rotation fails verification instead of passing it. The prior verify checked only `vault:` cells and therefore reported `ok` even when AAD-sealed cells had been orphaned."
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"heading": "Security",
|
|
53
|
+
"items": [
|
|
54
|
+
{
|
|
55
|
+
"title": "A vault key rotation can no longer silently destroy encrypted data",
|
|
56
|
+
"body": "The orphaning path lost agent idempotency / orchestrator / tenant / snapshot state, `{ aad: true }` columns, and tenant archives with no error and a passing verify; the data became unrecoverable the moment the old keypair was retired. Rotation is now fail-closed end to end: it re-seals what it can reach, refuses to proceed past what it cannot until you acknowledge it, and verifies the result under the new root. If you performed a rotation on v0.14.11 or earlier and still hold the retired keypair, re-seal the affected cells under the current root with the explicit-root primitives before discarding it."
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"heading": "Detectors",
|
|
62
|
+
"items": [
|
|
63
|
+
{
|
|
64
|
+
"title": "AAD-backed store modules must expose a rotation reseal path",
|
|
65
|
+
"body": "A new check flags a module that registers an external `{ aad: true }` store but does not expose an `AAD_ROTATION` descriptor and reseal hook, which would leave its state unreachable by the rotation pipeline."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"title": "A root-keyed seal family must ship its reseal",
|
|
69
|
+
"body": "A new check flags adding a `sealRoot` / `unsealRoot` pair without the matching `resealRoot`, since without it a rotated cell cannot be carried from the old root to the new one."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"title": "Live-root AAD seals need a reseal path",
|
|
73
|
+
"body": "A new check flags a primitive that AAD-seals under the live vault root without a way to re-seal that state under a new root during rotation."
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"title": "Tenant archive re-wrapping must compose `b.archive.rewrapTenant`",
|
|
77
|
+
"body": "A new check flags tenant-scoped archive re-wrapping that opens and re-seals a tenant envelope by hand instead of routing through `b.archive.rewrapTenant`."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"title": "Cluster vault-key drift needs the rotation-epoch accept gate",
|
|
81
|
+
"body": "A new check flags a cluster vault-key fingerprint comparison that hard-rejects a mismatch without honouring the `acceptVaultKeyRotation` epoch window."
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"heading": "Migration",
|
|
87
|
+
"items": [
|
|
88
|
+
{
|
|
89
|
+
"title": "Re-seal operator AAD stores before rotating",
|
|
90
|
+
"body": "Before calling `b.vaultRotate.rotate`, re-seal each AAD-backed store you use via its hook (`b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, the `b.agent.tenant` `AAD_ROTATION` reseal paths) with the old and new root JSON, re-wrap tenant archives with `b.archive.rewrapTenant`, then pass `opts.externalAadResealed` as an array naming each re-sealed store. If you use none of these features, pass `opts.externalAadResealed: true`. Declare the rotation to each cluster node with `acceptVaultKeyRotation: true` so the membership adopts the new fingerprint rather than reporting drift."
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.14.13",
|
|
4
|
+
"date": "2026-05-31",
|
|
5
|
+
"headline": "Close advertised-but-missing surface: SRS1 chained forwarding, DCQL array-wildcard claim paths, and in-memory safe-archive extraction",
|
|
6
|
+
"summary": "Three primitives advertised a capability in their documentation or card but refused or omitted it at runtime; this release implements each. b.mail.srs gains srs1Rewrite for the SRS1 double-forward (and multi-hop) case — previously the @intro described SRS1 and create() threw, pointing at a function that was never exported. b.safeArchive gains extractToMemory, the in-memory counterpart to extract for read-only / serverless filesystems — previously the card advertised in-memory extraction but the orchestrator required a destination directory. b.auth.oid4vp.matchDcql now honours a null claims-path segment as the array wildcard the OpenID4VP DCQL spec defines, rather than refusing it as unsupported while the card advertised DCQL. A stale version-pinned wording in a safe-archive error message is corrected. Every change is additive or message-only — no existing caller changes behaviour.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.mail.srs` SRS1 chained forwarding — `srs1Rewrite`",
|
|
13
|
+
"body": "`b.mail.srs.create(...)` now returns `srs1Rewrite` alongside `rewrite` / `reverse`. `srs1Rewrite(srsAddress)` chains an already-SRS0 (or SRS1) envelope-from for a further forwarding hop: it keeps the original SRS0 body verbatim, prepends the SRS0 originator's domain, and binds the pair with this forwarder's own HMAC-SHA-256 tag — no new timestamp, no repeated original local-part — emitting `SRS1=tag=originator==<SRS0-body>@thisForwarder`. `reverse()` now detects an SRS1 address, verifies this hop's tag and forwarder-domain binding, and unwraps exactly one hop back to the originator's SRS0 so a multi-hop bounce routes straight to the forwarder that can recover the original sender. Typed failure modes: `srs/not-srs0` (input not SRS-encoded), `srs/malformed` (missing the `==` separator), `srs/bad-tag` (tampered), `srs/too-long` (chain exceeds the RFC 5321 256-octet path limit). Implements the Sender Rewriting Scheme SRS1 wire format; the second-hop SPF rationale is RFC 7208 §2.4."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.safeArchive.extractToMemory` — in-memory safe extraction",
|
|
17
|
+
"body": "An async generator counterpart to `b.safeArchive.extract` for read-only / serverless filesystems: it resolves the source, sniffs the format, auto-unwraps recipient (`BAWRP`) / passphrase (`BAWPP`) envelopes, and dispatches to the zip / tar / tar.gz reader's in-memory `extractEntries()`, yielding `{ name, bytes, size }` per regular-file entry without ever writing to disk. It takes no `destination`. Every defense the disk path runs applies unchanged: the zip-bomb caps (entry-count / per-entry / total / expansion-ratio), the `b.guardArchive` metadata cascade (Zip-Slip / path-traversal / symlink-escape / encrypted-entry refusal, CVE-2025-3445 class), and the entry-type policy. The disk-only realpath-agreement check (CVE-2025-4517 PATH_MAX TOCTOU defense) is intentionally absent — there is no extraction root — so the archive-level name refusals carry containment. Trusted-stream sources are refused upfront (the adversarial-safe central-directory walk needs random access). gzip magic per RFC 1952 §2.3.1."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"heading": "Fixed",
|
|
23
|
+
"items": [
|
|
24
|
+
{
|
|
25
|
+
"title": "OID4VP DCQL `null` claim-path segment now resolves the array wildcard",
|
|
26
|
+
"body": "`b.auth.oid4vp.matchDcql` previously threw `auth-oid4vp/null-path-segment-not-supported` for a `null` claims-path segment while the namespace card advertised DCQL — under-disclosing a legitimate presentation (CWE-863). Per OpenID4VP 1.0 §7.1.1 a `null` segment selects all elements of the array at that depth; the matcher now recurses over array elements with existence semantics (with DCQL value-matching applied to any selected leaf), composed to arbitrary depth. A `null` segment on a non-array node — like an integer index into a non-array, or a string key into an array — is a clean non-match, not a thrown error, because the matcher walks holder credential data rather than operator config. String and integer claim paths are byte-identical to before; only queries that previously threw now succeed or fail cleanly."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"title": "safe-archive trusted-stream refusal message no longer cites a stale version",
|
|
30
|
+
"body": "The thrown `safe-archive/trusted-stream-unsupported` message and its comment claimed trusted-stream extraction was \"deferred to v0.12.8 / when the v0.12.8 sequential extract path lands.\" That path shipped long ago — `b.archive.read.zip.fromTrustedStream` and the tar sequential mode exist — so the message now points at them as present capabilities and drops the version-pinned wording. The error code is unchanged."
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"heading": "Detectors",
|
|
36
|
+
"items": [
|
|
37
|
+
{
|
|
38
|
+
"title": "A primitive may not advertise a capability and then throw an unimplemented stub",
|
|
39
|
+
"body": "A new check flags a bare `not yet supported` / `operator demand TBD` / `not supported in v1` refusal in a lib throw string (comments excluded). A defer is only complete with a written re-open condition; the SRS1 and DCQL stubs that this release implements both carried this bare-defer shape, and the detector keeps it from re-entering."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"title": "DCQL `null` path segments must recurse, never refuse",
|
|
43
|
+
"body": "A new check flags the `null path segment not supported` refusal shape in `lib/auth/oid4vp.js`, so the spec-mandated array wildcard cannot be re-stubbed."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"title": "`extractToMemory` must stay disk-free",
|
|
47
|
+
"body": "A new check flags any `writeFileSync` / `renameSync` / `mkdirSync` / `createWriteStream` inside the `extractToMemory` generator body, so the read-only / serverless contract cannot regress into a disk write."
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.14.14",
|
|
4
|
+
"date": "2026-05-31",
|
|
5
|
+
"headline": "Recognized consent purposes with lawful-basis gating, and a new b.privacy namespace for annual EdTech vendor-review attestations",
|
|
6
|
+
"summary": "Closes the student-data gap where an educational-only consent purpose and an annual third-party vendor-review report were described but never implemented. b.consent gains a recognized-purpose vocabulary: a purpose value matching a recognized key carries lawful-basis constraints that grant() enforces, and the named educational-only purpose (FERPA's school-official exception and California's SOPIPA) refuses a legitimate_interests lawful basis. The new b.privacy namespace ships vendorReview(), a builder for the dated, clause-by-clause annual EdTech third-party / processor review FERPA and SOPIPA expect a school or district to keep — it computes whether every required clause (no targeted advertising, no commercial profiling, no sale of student data, deletion on request, school-official designation, and so on) is attested, names the gaps, and stamps a 365-day re-review clock. Free-form consent purposes keep working unchanged, so the vocabulary is opt-in and additive.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.consent` recognized-purpose vocabulary + lawful-basis gating",
|
|
13
|
+
"body": "`b.consent.recognizedPurpose(name)` looks up a recognized purpose and `b.consent.listPurposes()` enumerates them. When a `grant({ purpose })` value matches a recognized key, `grant()` enforces that purpose's lawful-basis constraints; the `educational-only` purpose forbids a `legitimate_interests` basis (FERPA 34 CFR 99.31(a)(1) school-official exception; California SOPIPA Cal. B&P 22584; FTC school-authorized COPPA consent 16 CFR 312.5(c)(10)) and marks the data commercial-use-prohibited. The commercial-use prohibition is an operator trust-boundary obligation — `isGranted()` does not re-derive it. Any purpose value NOT in the vocabulary stays free-form and unconstrained, so existing callers are unaffected; the hash-chain column set is unchanged, so `b.consent.verify()` over existing rows is unaffected."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "`b.privacy.vendorReview` — annual EdTech vendor-review attestation",
|
|
17
|
+
"body": "A new `b.privacy` namespace whose `vendorReview(opts)` builds the dated third-party / processor review a FERPA school-official arrangement and California SOPIPA expect for every vendor that touches student data. The operator supplies a boolean attestation per clause (educational-purpose-only, no-targeted-advertising, no-commercial-profiling, no-sale-of-student-data, security-safeguards, deletion-on-request, sub-processor-currency, breach-notification, school-official-designation, directory-information-handling); `vendorReview` validates the shape, computes whether every required clause is attested (`attested`) and which are not (`gaps`), and stamps `reviewedAt` plus a 365-day `nextReviewDueAt` re-review clock. `b.privacy.listVendorReviewClauses()` returns the clause set with citations. Operator-feeds-metadata: the frozen report is not framework-persisted — compose it into your retention / audit / export sink. A best-effort `privacy.vendor_review.recorded` audit event fires when an audit sink is wired."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"heading": "Detectors",
|
|
23
|
+
"items": [
|
|
24
|
+
{
|
|
25
|
+
"title": "A gated consent purpose must go through `b.consent`",
|
|
26
|
+
"body": "A new check flags any lib code that mints a consent row with a hardcoded `educational-only` purpose literal without composing the recognized-purpose vocabulary — which would record the value while never enforcing its FERPA / SOPIPA lawful-basis constraint."
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -10279,6 +10279,9 @@ async function testVaultRotateRotateEndToEnd() {
|
|
|
10279
10279
|
oldKeys: oldKeys, newKeys: newKeys,
|
|
10280
10280
|
dataDir: dataDir, stagingDir: stagingDir,
|
|
10281
10281
|
mode: "plaintext",
|
|
10282
|
+
// This fixture seals only db.enc-backed columns; no operator-supplied
|
|
10283
|
+
// AAD stores are in play, so the rotation has nothing external to re-seal.
|
|
10284
|
+
externalAadResealed: true,
|
|
10282
10285
|
progressCallback: function (e) { progressEvents.push(e.phase); },
|
|
10283
10286
|
});
|
|
10284
10287
|
|
|
@@ -10307,7 +10310,12 @@ async function testVaultRotateRotateEndToEnd() {
|
|
|
10307
10310
|
// Staged db.enc should decrypt with dbKey, contain the same rows, and
|
|
10308
10311
|
// every email/name column should now be sealed under newKeys.
|
|
10309
10312
|
var stagedPacked = fs.readFileSync(path.join(stagingDir, "db.enc"));
|
|
10310
|
-
|
|
10313
|
+
// Rotation re-writes db.enc bound to the dataDir-scoped AAD (matching the
|
|
10314
|
+
// at-rest format db.js writes), even when the input snapshot was a legacy
|
|
10315
|
+
// un-bound envelope — so verification must supply the same AAD. The binding
|
|
10316
|
+
// tracks the final dataDir (where db.js reopens it after the staging swap),
|
|
10317
|
+
// not the staging path.
|
|
10318
|
+
var stagedPlain = b.crypto.decryptPacked(stagedPacked, dbKey, b.db._dbEncAad(dataDir));
|
|
10311
10319
|
var verifyDbPath = path.join(dir, "verify.db");
|
|
10312
10320
|
fs.writeFileSync(verifyDbPath, stagedPlain);
|
|
10313
10321
|
var vdb = new DatabaseSync(verifyDbPath);
|
|
@@ -290,8 +290,111 @@ async function testAtRestSealingWithVault() {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
async function testAadRotationDescriptor() {
|
|
294
|
+
// The descriptor every {aad:true} external-store module exports so an
|
|
295
|
+
// operator can rotate the backing store out-of-band (the in-tree
|
|
296
|
+
// b.vaultRotate.rotate pipeline can't reach an operator-supplied store).
|
|
297
|
+
var d = b.agent.idempotency.AAD_ROTATION;
|
|
298
|
+
check("AAD_ROTATION present", d && typeof d === "object");
|
|
299
|
+
check("AAD_ROTATION.table", d.table === "agent_idempotency");
|
|
300
|
+
check("AAD_ROTATION.rowIdField", d.rowIdField === "keyHash");
|
|
301
|
+
check("AAD_ROTATION.schemaVersion", d.schemaVersion === "1");
|
|
302
|
+
check("AAD_ROTATION.backend external", d.backend === "external");
|
|
303
|
+
check("AAD_ROTATION.reseal is fn", typeof d.reseal === "function");
|
|
304
|
+
check("reseal exported directly", typeof b.agent.idempotency.reseal === "function");
|
|
305
|
+
|
|
306
|
+
// reseal validates its config/entry-point inputs synchronously — THROW on
|
|
307
|
+
// a missing root or a store that can't enumerate + write back, before any
|
|
308
|
+
// async store work begins.
|
|
309
|
+
var threwRoot = null;
|
|
310
|
+
try { b.agent.idempotency.reseal({ store: { listAll: function () { return []; }, putResealed: function () {} } }); }
|
|
311
|
+
catch (e) { threwRoot = e; }
|
|
312
|
+
check("reseal refuses missing roots",
|
|
313
|
+
threwRoot && (threwRoot.code || "").indexOf("agent-idempotency/bad-root") !== -1);
|
|
314
|
+
var threwStore = null;
|
|
315
|
+
try { b.agent.idempotency.reseal({ oldRootJson: "{}", newRootJson: "{}", store: { listAll: function () { return []; } } }); }
|
|
316
|
+
catch (e) { threwStore = e; }
|
|
317
|
+
check("reseal refuses store without putResealed",
|
|
318
|
+
threwStore && (threwStore.code || "").indexOf("agent-idempotency/bad-reseal-store") !== -1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function testResealRotatesSealedCellsAcrossRoots() {
|
|
322
|
+
// End-to-end: seal a cached-result row under vault root A, capture the
|
|
323
|
+
// sealed cell, derive a second root B, reseal A->B out-of-band, and
|
|
324
|
+
// confirm the cell now opens under B (and no longer under A) — the AAD
|
|
325
|
+
// tuple is reconstructed via the SAME builder the seal side used, so the
|
|
326
|
+
// re-sealed value round-trips.
|
|
327
|
+
var os = require("node:os");
|
|
328
|
+
var path = require("node:path");
|
|
329
|
+
var fs = require("node:fs");
|
|
330
|
+
var dirA = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-idem-rotA-"));
|
|
331
|
+
await b.vault.init({ mode: "plaintext", dataDir: dirA });
|
|
332
|
+
var rootA = b.vault.getKeysJson();
|
|
333
|
+
var SECRET = "rotate-payload-9007199254740993";
|
|
334
|
+
var backend = {
|
|
335
|
+
_m: Object.create(null),
|
|
336
|
+
async get(m, a, h) { return this._m[m + "\0" + a + "\0" + h] || null; },
|
|
337
|
+
async put(m, a, h, row) { this._m[m + "\0" + a + "\0" + h] = row; },
|
|
338
|
+
async delete(m, a, h) { delete this._m[m + "\0" + a + "\0" + h]; },
|
|
339
|
+
// Out-of-band reseal contract: enumerate every stored row, then write
|
|
340
|
+
// each back addressed by its stored keyHash identity (the raw key is
|
|
341
|
+
// never persisted — only its namespaced keyHash is).
|
|
342
|
+
listAll() { return Object.keys(this._m).map(function (k) { return this._m[k]; }.bind(this)); },
|
|
343
|
+
putResealed(row) {
|
|
344
|
+
for (var k in this._m) {
|
|
345
|
+
if (this._m[k].keyHash === row.keyHash && this._m[k].method === row.method) {
|
|
346
|
+
this._m[k] = row;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
var idem = b.agent.idempotency.create({ store: backend });
|
|
353
|
+
await idem.put("move", "u-rot", "jmap-rot-1", { payload: SECRET });
|
|
354
|
+
var stored = backend.listAll();
|
|
355
|
+
check("rotate: one sealed row stored", stored.length === 1);
|
|
356
|
+
var beforeCell = stored[0].resultBlob;
|
|
357
|
+
check("rotate: cell is AAD-sealed before rotation",
|
|
358
|
+
typeof beforeCell === "string" && b.vault.aad.isAadSealed(beforeCell));
|
|
359
|
+
|
|
360
|
+
// Derive a second, distinct vault root B (fresh keypair in a new dir).
|
|
361
|
+
b.vault._resetForTest();
|
|
362
|
+
var dirB = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-idem-rotB-"));
|
|
363
|
+
await b.vault.init({ mode: "plaintext", dataDir: dirB });
|
|
364
|
+
var rootB = b.vault.getKeysJson();
|
|
365
|
+
check("rotate: roots differ", rootA !== rootB);
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
var r = await b.agent.idempotency.reseal({ store: backend, oldRootJson: rootA, newRootJson: rootB });
|
|
369
|
+
check("rotate: reseal reports table", r.table === "agent_idempotency");
|
|
370
|
+
check("rotate: reseal counted the row", r.resealed === 1);
|
|
371
|
+
|
|
372
|
+
var afterCell = backend.listAll()[0].resultBlob;
|
|
373
|
+
check("rotate: cell changed after reseal", afterCell !== beforeCell);
|
|
374
|
+
check("rotate: cell still AAD-sealed", b.vault.aad.isAadSealed(afterCell));
|
|
375
|
+
|
|
376
|
+
// Reconstruct the exact AAD the seal side used and verify the cell now
|
|
377
|
+
// opens under B but not under A.
|
|
378
|
+
var schema = b.cryptoField.getSchema("agent_idempotency");
|
|
379
|
+
var aad = b.cryptoField._aadParts(schema, "agent_idempotency", "resultBlob", backend.listAll()[0]);
|
|
380
|
+
var openedB = b.vault.aad.unsealRoot(afterCell, aad, rootB);
|
|
381
|
+
check("rotate: resealed cell opens under NEW root",
|
|
382
|
+
typeof openedB === "string" && openedB.indexOf(SECRET) !== -1);
|
|
383
|
+
var openedUnderOld = null;
|
|
384
|
+
try { b.vault.aad.unsealRoot(afterCell, aad, rootA); }
|
|
385
|
+
catch (e) { openedUnderOld = e; }
|
|
386
|
+
check("rotate: resealed cell no longer opens under OLD root", openedUnderOld !== null);
|
|
387
|
+
} finally {
|
|
388
|
+
b.vault._resetForTest();
|
|
389
|
+
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch (_e) {}
|
|
390
|
+
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch (_e) {}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
293
394
|
async function run() {
|
|
294
395
|
testSurface();
|
|
396
|
+
await testAadRotationDescriptor();
|
|
397
|
+
await testResealRotatesSealedCellsAcrossRoots();
|
|
295
398
|
await testAtRestSealingWithVault();
|
|
296
399
|
await testBasicGetPut();
|
|
297
400
|
await testInMemoryBackendBounded();
|
|
@@ -429,8 +429,99 @@ async function testRegistryRowSealedAtRest() {
|
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
async function testAadRotationDescriptor() {
|
|
433
|
+
var d = b.agent.orchestrator.AAD_ROTATION;
|
|
434
|
+
check("AAD_ROTATION present", d && typeof d === "object");
|
|
435
|
+
check("AAD_ROTATION.table", d.table === "agent_orchestrator_registry");
|
|
436
|
+
check("AAD_ROTATION.rowIdField", d.rowIdField === "name");
|
|
437
|
+
check("AAD_ROTATION.schemaVersion", d.schemaVersion === "1");
|
|
438
|
+
check("AAD_ROTATION.backend external", d.backend === "external");
|
|
439
|
+
check("AAD_ROTATION.reseal is fn", typeof d.reseal === "function");
|
|
440
|
+
check("reseal exported directly", typeof b.agent.orchestrator.reseal === "function");
|
|
441
|
+
|
|
442
|
+
await expectRejection("reseal refuses missing roots",
|
|
443
|
+
Promise.resolve().then(function () {
|
|
444
|
+
return b.agent.orchestrator.reseal({ store: { list: function () { return []; }, set: function () {} } });
|
|
445
|
+
}),
|
|
446
|
+
"agent-orchestrator/bad-root");
|
|
447
|
+
await expectRejection("reseal refuses store without list/set",
|
|
448
|
+
Promise.resolve().then(function () {
|
|
449
|
+
return b.agent.orchestrator.reseal({ oldRootJson: "{}", newRootJson: "{}", store: { list: function () { return []; } } });
|
|
450
|
+
}),
|
|
451
|
+
"agent-orchestrator/bad-reseal-store");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function testResealRotatesRegistryRowsAcrossRoots() {
|
|
455
|
+
// Seal a registry row (tenantId + metadata) under vault root A, derive a
|
|
456
|
+
// distinct root B, reseal the operator backend A->B out-of-band, and
|
|
457
|
+
// confirm the sealed cells now open under B (and no longer under A). The
|
|
458
|
+
// AAD tuple is rebuilt via cryptoField._aadParts — the same builder the
|
|
459
|
+
// seal side used — so the re-sealed values round-trip.
|
|
460
|
+
var os = helpers.os;
|
|
461
|
+
var path = helpers.path;
|
|
462
|
+
var fs = helpers.fs;
|
|
463
|
+
var dirA = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-orch-rotA-"));
|
|
464
|
+
await helpers.setupVaultOnly(dirA);
|
|
465
|
+
var rootA = b.vault.getKeysJson();
|
|
466
|
+
var backend = {
|
|
467
|
+
_m: Object.create(null),
|
|
468
|
+
async get(n) { return this._m[n] || null; },
|
|
469
|
+
async set(n, row) { this._m[n] = row; },
|
|
470
|
+
async delete(n) { delete this._m[n]; },
|
|
471
|
+
async list() { return Object.values(this._m); },
|
|
472
|
+
};
|
|
473
|
+
var orch = b.agent.orchestrator.create({ backend: backend });
|
|
474
|
+
await orch.register("svc-rot", { kind: "mail", handle: function () {} }, {
|
|
475
|
+
agentKind: "mail", tenantId: "acme-corp", metadata: { endpoint: "https://internal-host:9000" },
|
|
476
|
+
});
|
|
477
|
+
var beforeTenant = backend._m["svc-rot"].tenantId;
|
|
478
|
+
var beforeMeta = backend._m["svc-rot"].metadata;
|
|
479
|
+
check("orch rotate: tenantId AAD-sealed before",
|
|
480
|
+
typeof beforeTenant === "string" && b.vault.aad.isAadSealed(beforeTenant));
|
|
481
|
+
check("orch rotate: metadata AAD-sealed before",
|
|
482
|
+
typeof beforeMeta === "string" && b.vault.aad.isAadSealed(beforeMeta));
|
|
483
|
+
|
|
484
|
+
// Derive a second, distinct vault root B.
|
|
485
|
+
b.vault._resetForTest();
|
|
486
|
+
if (b.agent.orchestrator._resetForTest) b.agent.orchestrator._resetForTest();
|
|
487
|
+
var dirB = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-orch-rotB-"));
|
|
488
|
+
await helpers.setupVaultOnly(dirB);
|
|
489
|
+
var rootB = b.vault.getKeysJson();
|
|
490
|
+
check("orch rotate: roots differ", rootA !== rootB);
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
var r = await b.agent.orchestrator.reseal({ store: backend, oldRootJson: rootA, newRootJson: rootB });
|
|
494
|
+
check("orch rotate: reseal reports table", r.table === "agent_orchestrator_registry");
|
|
495
|
+
check("orch rotate: reseal counted the row", r.resealed === 1);
|
|
496
|
+
|
|
497
|
+
var afterTenant = backend._m["svc-rot"].tenantId;
|
|
498
|
+
var afterMeta = backend._m["svc-rot"].metadata;
|
|
499
|
+
check("orch rotate: tenantId cell changed", afterTenant !== beforeTenant);
|
|
500
|
+
check("orch rotate: metadata cell changed", afterMeta !== beforeMeta);
|
|
501
|
+
|
|
502
|
+
var schema = b.cryptoField.getSchema("agent_orchestrator_registry");
|
|
503
|
+
var tenantAad = b.cryptoField._aadParts(schema, "agent_orchestrator_registry", "tenantId", backend._m["svc-rot"]);
|
|
504
|
+
var openedTenant = b.vault.aad.unsealRoot(afterTenant, tenantAad, rootB);
|
|
505
|
+
check("orch rotate: tenantId opens under NEW root", openedTenant === "acme-corp");
|
|
506
|
+
var openedUnderOld = null;
|
|
507
|
+
try { b.vault.aad.unsealRoot(afterTenant, tenantAad, rootA); }
|
|
508
|
+
catch (e) { openedUnderOld = e; }
|
|
509
|
+
check("orch rotate: tenantId no longer opens under OLD root", openedUnderOld !== null);
|
|
510
|
+
|
|
511
|
+
var metaAad = b.cryptoField._aadParts(schema, "agent_orchestrator_registry", "metadata", backend._m["svc-rot"]);
|
|
512
|
+
var openedMeta = b.vault.aad.unsealRoot(afterMeta, metaAad, rootB);
|
|
513
|
+
check("orch rotate: metadata opens under NEW root",
|
|
514
|
+
typeof openedMeta === "string" && openedMeta.indexOf("internal-host") !== -1);
|
|
515
|
+
} finally {
|
|
516
|
+
helpers.teardownVaultOnly(dirB);
|
|
517
|
+
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch (_e) {}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
432
521
|
async function run() {
|
|
433
522
|
testSurface();
|
|
523
|
+
await testAadRotationDescriptor();
|
|
524
|
+
await testResealRotatesRegistryRowsAcrossRoots();
|
|
434
525
|
await testRegisterLookupUnregister();
|
|
435
526
|
await testList();
|
|
436
527
|
await testTenantScopeRegistryReads();
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
var fs = require("fs");
|
|
4
|
+
var os = require("os");
|
|
5
|
+
var path = require("path");
|
|
3
6
|
var helpers = require("../helpers");
|
|
4
7
|
var b = helpers.b;
|
|
5
8
|
var check = helpers.check;
|
|
@@ -296,6 +299,188 @@ async function testRestoreRequireHandlersRefuses() {
|
|
|
296
299
|
"agent-snapshot/no-restore-handlers");
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
// ---- reseal (vault-key rotation) -----------------------------------------
|
|
303
|
+
//
|
|
304
|
+
// reseal composes b.vault.aad.resealRoot, which only re-keys a real
|
|
305
|
+
// `vault.aad:` blob — so these cases use the live b.vault.aad sealer
|
|
306
|
+
// (omit the `sealer` opt so _resolveSealer picks up vault().aad) under a
|
|
307
|
+
// setupVaultOnly fixture. The signer stays a fake so the test doesn't
|
|
308
|
+
// need the full audit-sign passphrase boot.
|
|
309
|
+
|
|
310
|
+
function _vaultBackedSnapshot(backend) {
|
|
311
|
+
return b.agent.snapshot.create({
|
|
312
|
+
orchestrator: _fakeOrch(),
|
|
313
|
+
backend: backend,
|
|
314
|
+
signer: _fakeSigner(),
|
|
315
|
+
// sealer omitted on purpose → live b.vault.aad.
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function testResealRotationSurface() {
|
|
320
|
+
var desc = b.agent.snapshot.AAD_ROTATION;
|
|
321
|
+
check("AAD_ROTATION exported", desc && typeof desc === "object");
|
|
322
|
+
check("AAD_ROTATION.table", desc.table === "agent.snapshot");
|
|
323
|
+
check("AAD_ROTATION.rowIdField", desc.rowIdField === "snapshotId");
|
|
324
|
+
check("AAD_ROTATION.schemaVersion", desc.schemaVersion === String(b.agent.snapshot.SCHEMA_VERSION));
|
|
325
|
+
check("AAD_ROTATION.backend external", desc.backend === "external");
|
|
326
|
+
check("AAD_ROTATION.reseal is fn", typeof desc.reseal === "function");
|
|
327
|
+
check("reseal exported on module", typeof b.agent.snapshot.reseal === "function");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function testResealBadInputRefused() {
|
|
331
|
+
await expectRejection("reseal refuses missing backend",
|
|
332
|
+
b.agent.snapshot.reseal({ oldRootJson: "a", newRootJson: "b" }),
|
|
333
|
+
"agent-snapshot/bad-backend");
|
|
334
|
+
await expectRejection("reseal refuses missing oldRootJson",
|
|
335
|
+
b.agent.snapshot.reseal({ backend: _fakeBackend(), newRootJson: "b" }),
|
|
336
|
+
"agent-snapshot/bad-root");
|
|
337
|
+
await expectRejection("reseal refuses missing newRootJson",
|
|
338
|
+
b.agent.snapshot.reseal({ backend: _fakeBackend(), oldRootJson: "a" }),
|
|
339
|
+
"agent-snapshot/bad-root");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function testResealRekeysUnderNewRoot() {
|
|
343
|
+
var backend = _fakeBackend();
|
|
344
|
+
var snapshot = _vaultBackedSnapshot(backend);
|
|
345
|
+
var snap = await snapshot.takeSnapshot({ sagas: [{ id: "s1" }] });
|
|
346
|
+
await snapshot.persist(snap);
|
|
347
|
+
|
|
348
|
+
var oldRootJson = b.vault.getKeysJson();
|
|
349
|
+
// A distinct, keys-shaped root: resealRoot hashes the whole serialized
|
|
350
|
+
// form, so any byte-distinct JSON is a different vault root.
|
|
351
|
+
var newRootJson = JSON.stringify(
|
|
352
|
+
Object.assign(JSON.parse(oldRootJson), { _rotationTestRoot: "v2" }));
|
|
353
|
+
|
|
354
|
+
var before = await backend.get(snap.snapshotId);
|
|
355
|
+
var beforeSealed = before.sealed;
|
|
356
|
+
var prefix = b.agent.snapshot.SEALED_PREFIX;
|
|
357
|
+
check("pre-reseal: wrapper carries the prefix", beforeSealed.indexOf(prefix) === 0);
|
|
358
|
+
var innerBefore = beforeSealed.slice(prefix.length);
|
|
359
|
+
check("pre-reseal: inner blob is a vault.aad: value",
|
|
360
|
+
b.vault.aad.isAadSealed(innerBefore));
|
|
361
|
+
|
|
362
|
+
var result = await b.agent.snapshot.reseal({
|
|
363
|
+
backend: backend,
|
|
364
|
+
oldRootJson: oldRootJson,
|
|
365
|
+
newRootJson: newRootJson,
|
|
366
|
+
});
|
|
367
|
+
check("reseal: table echoed", result.table === "agent.snapshot");
|
|
368
|
+
check("reseal: 1 row re-keyed", result.resealed === 1);
|
|
369
|
+
|
|
370
|
+
var after = await backend.get(snap.snapshotId);
|
|
371
|
+
check("post-reseal: prefix preserved", after.sealed.indexOf(prefix) === 0);
|
|
372
|
+
check("post-reseal: ciphertext changed", after.sealed !== beforeSealed);
|
|
373
|
+
check("post-reseal: decorative wrapper preserved",
|
|
374
|
+
after.snapshotId === snap.snapshotId && after.takenAt === before.takenAt);
|
|
375
|
+
|
|
376
|
+
// The AAD the cell was sealed under — rebuilt the SAME way the module
|
|
377
|
+
// does, via the column-shaped tuple.
|
|
378
|
+
var aad = {
|
|
379
|
+
table: "agent.snapshot",
|
|
380
|
+
rowId: snap.snapshotId,
|
|
381
|
+
column: "envelope",
|
|
382
|
+
schemaVersion: String(b.agent.snapshot.SCHEMA_VERSION),
|
|
383
|
+
};
|
|
384
|
+
var innerAfter = after.sealed.slice(prefix.length);
|
|
385
|
+
// Re-keyed blob opens under the NEW root + the SAME AAD, yielding the
|
|
386
|
+
// original signed envelope JSON.
|
|
387
|
+
var reopened = b.vault.aad.unsealRoot(innerAfter, aad, newRootJson);
|
|
388
|
+
var env = JSON.parse(reopened);
|
|
389
|
+
check("post-reseal: envelope decrypts under new root",
|
|
390
|
+
env.snapshotId === snap.snapshotId);
|
|
391
|
+
|
|
392
|
+
// ...and is now undecryptable under the OLD root (the AEAD tag is
|
|
393
|
+
// keyed off the new root).
|
|
394
|
+
var oldRootFails = false;
|
|
395
|
+
try { b.vault.aad.unsealRoot(innerAfter, aad, oldRootJson); }
|
|
396
|
+
catch (_e) { oldRootFails = true; }
|
|
397
|
+
check("post-reseal: old root no longer opens the cell", oldRootFails);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function testResealSkipsPlaintextRows() {
|
|
401
|
+
// A bare-envelope row (no `sealed` wrapper) — what the allowPlaintext
|
|
402
|
+
// path writes, and what a legacy plaintext snapshot looks like — has no
|
|
403
|
+
// AAD-sealed blob to re-key, so reseal skips it and re-keys only the
|
|
404
|
+
// sealed rows. Inject the plaintext row directly so the skip branch is
|
|
405
|
+
// exercised regardless of whether the live vault would otherwise seal.
|
|
406
|
+
var backend = _fakeBackend();
|
|
407
|
+
var sealedSnap = _vaultBackedSnapshot(backend);
|
|
408
|
+
var ss = await sealedSnap.takeSnapshot({});
|
|
409
|
+
await sealedSnap.persist(ss);
|
|
410
|
+
|
|
411
|
+
// Plaintext row: the bare envelope, snapshotId-keyed, no `sealed`.
|
|
412
|
+
var plainSnap = await sealedSnap.takeSnapshot({});
|
|
413
|
+
await backend.put(plainSnap.snapshotId, plainSnap);
|
|
414
|
+
|
|
415
|
+
var oldRootJson = b.vault.getKeysJson();
|
|
416
|
+
var newRootJson = JSON.stringify(
|
|
417
|
+
Object.assign(JSON.parse(oldRootJson), { _rotationTestRoot: "v3" }));
|
|
418
|
+
var result = await b.agent.snapshot.reseal({
|
|
419
|
+
backend: backend,
|
|
420
|
+
oldRootJson: oldRootJson,
|
|
421
|
+
newRootJson: newRootJson,
|
|
422
|
+
});
|
|
423
|
+
check("reseal: only the sealed row counted (plaintext skipped)",
|
|
424
|
+
result.resealed === 1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function testResealRefusesNonVaultSealer() {
|
|
428
|
+
// A row sealed by a custom KMS sealer carries a non-vault.aad: inner
|
|
429
|
+
// blob; reseal can't re-key it via resealRoot, so it refuses rather
|
|
430
|
+
// than silently no-op (the operator must re-key through their KMS).
|
|
431
|
+
var backend = _fakeBackend();
|
|
432
|
+
var kms = b.agent.snapshot.create({
|
|
433
|
+
orchestrator: _fakeOrch(),
|
|
434
|
+
backend: backend,
|
|
435
|
+
signer: _fakeSigner(),
|
|
436
|
+
sealer: _fakeSealer(), // AES-GCM fake → not a vault.aad: blob
|
|
437
|
+
});
|
|
438
|
+
var snap = await kms.takeSnapshot({});
|
|
439
|
+
await kms.persist(snap);
|
|
440
|
+
var oldRootJson = b.vault.getKeysJson();
|
|
441
|
+
var newRootJson = JSON.stringify(
|
|
442
|
+
Object.assign(JSON.parse(oldRootJson), { _rotationTestRoot: "v5" }));
|
|
443
|
+
await expectRejection("reseal refuses a non-vault-sealed row",
|
|
444
|
+
b.agent.snapshot.reseal({
|
|
445
|
+
backend: backend,
|
|
446
|
+
oldRootJson: oldRootJson,
|
|
447
|
+
newRootJson: newRootJson,
|
|
448
|
+
}),
|
|
449
|
+
"agent-snapshot/not-vault-sealed");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function testResealDescriptorMapsStore() {
|
|
453
|
+
// The AAD_ROTATION.reseal adapter maps the pipeline's generic `store`
|
|
454
|
+
// term onto this module's backend.
|
|
455
|
+
var backend = _fakeBackend();
|
|
456
|
+
var snapshot = _vaultBackedSnapshot(backend);
|
|
457
|
+
await snapshot.persist(await snapshot.takeSnapshot({}));
|
|
458
|
+
var oldRootJson = b.vault.getKeysJson();
|
|
459
|
+
var newRootJson = JSON.stringify(
|
|
460
|
+
Object.assign(JSON.parse(oldRootJson), { _rotationTestRoot: "v4" }));
|
|
461
|
+
var result = await b.agent.snapshot.AAD_ROTATION.reseal({
|
|
462
|
+
store: backend,
|
|
463
|
+
oldRootJson: oldRootJson,
|
|
464
|
+
newRootJson: newRootJson,
|
|
465
|
+
});
|
|
466
|
+
check("AAD_ROTATION.reseal: re-keyed via store alias", result.resealed === 1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function runReseal() {
|
|
470
|
+
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-snapshot-reseal-"));
|
|
471
|
+
await helpers.setupVaultOnly(tmpDir);
|
|
472
|
+
try {
|
|
473
|
+
testResealRotationSurface();
|
|
474
|
+
await testResealBadInputRefused();
|
|
475
|
+
await testResealRekeysUnderNewRoot();
|
|
476
|
+
await testResealSkipsPlaintextRows();
|
|
477
|
+
await testResealRefusesNonVaultSealer();
|
|
478
|
+
await testResealDescriptorMapsStore();
|
|
479
|
+
} finally {
|
|
480
|
+
helpers.teardownVaultOnly(tmpDir);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
299
484
|
async function run() {
|
|
300
485
|
testSurface();
|
|
301
486
|
await testCreateRequiresOrchestrator();
|
|
@@ -313,6 +498,7 @@ async function run() {
|
|
|
313
498
|
await testGc();
|
|
314
499
|
await testRestoreHandlersInvoked();
|
|
315
500
|
await testRestoreRequireHandlersRefuses();
|
|
501
|
+
await runReseal();
|
|
316
502
|
}
|
|
317
503
|
|
|
318
504
|
module.exports = { run: run };
|