@cosmicdrift/kumiko-framework 0.288.0 → 0.289.0
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/package.json +4 -4
- package/src/changes.json +57 -0
- package/src/compliance/__tests__/sub-processors.test.ts +10 -0
- package/src/compliance/sub-processors.ts +14 -1
- package/src/crypto/__tests__/kek-source.test.ts +298 -0
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kek-source.ts +189 -0
- package/src/crypto/kms-wiring.ts +20 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +191 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
- package/src/db/event-store-executor-context.ts +79 -0
- package/src/db/event-store-executor-write.ts +42 -5
- package/src/db/index.ts +1 -0
- package/src/db/tenant-db.ts +8 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -4
- package/src/engine/extensions/storage-provider.ts +17 -2
- package/src/engine/extensions/tenant-data.ts +9 -0
- package/src/engine/factories.ts +2 -0
- package/src/engine/screen-helpers.ts +17 -0
- package/src/errors/classes.ts +24 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/member-resolution.ts +12 -0
- package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
- package/src/event-store/provenance-append.ts +9 -1
- package/src/files/__tests__/file-handle.test.ts +28 -1
- package/src/files/file-handle.ts +26 -7
- package/src/files/index.ts +1 -1
- package/src/i18n/required-surface-keys.ts +24 -12
- package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
- package/src/pipeline/dispatch-shared.ts +14 -13
- package/src/pipeline/dispatch-stream.ts +8 -3
- package/src/pipeline/dispatch-write.ts +3 -2
- package/src/pipeline/event-dispatcher.ts +18 -5
- package/src/pipeline/member-read-only-transaction.ts +2 -6
- package/src/stack/table-helpers.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.289.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.289.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.289.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.289.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,61 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.289.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "appendProvenanceEvent enforces event.tenantId === db.tenantId",
|
|
6
|
+
"detail": "`appendProvenanceEvent(db, event)` checked the event type namespace but wrote `event.tenantId` unverified while granting itself the framework-fixed unsafeRaw reason internally. Since `createLLMProviderForTenant(ctx, tenantId, …)` takes the tenantId as an argument, that made the entry point a cross-tenant write door with no escapeHatch declaration behind it.\nThe check now runs after `unsafeRawForDeclaredStep` — so a holder not built by `createTenantDb` still fails closed with `InternalError` first — and before the savepoint, so a rejected append leaves no row. It is unconditional, including `mode: \"system\"`: `crossTenantRebinders` keeps the original tenantId and only flips the mode, so a system-scoped db must not append provenance for a foreign tenant either.\n`appendProvenanceEvent` deliberately keeps `withUnsafeRawGrant` + `unsafeRawForDeclaredStep` instead of resolving the runner through the ungated `tenantDbRunner`, because that grant path is the choke point the member-read lock in kumiko-framework#2927 hooks into.",
|
|
7
|
+
"migration": "appendProvenanceEvent rejects with AccessDeniedError when event.tenantId does not match the tenantId of the passed TenantDb. Provenance for a foreign tenant has no path left through this entry point — a caller that needs it declares its own escapeHatch path instead. Blast radius checked: every known call site in kumiko-enterprise passes event.user.tenantId through, so no call site changes."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.289.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Boot fails when an anonymous-accessible handler declares no rateLimit",
|
|
13
|
+
"detail": "validateAnonymousRateLimit (packages/framework/src/engine/boot-validator/entity-handler.ts) previously returned early when a handler declared no rateLimit at all, so an anonymous, internet-facing handler with zero throttling passed boot silently while one with a merely wrong bucket (rateLimit.per=\"user\") was rejected. The check now runs for every handler whose access.roles includes \"anonymous\": it still skips openToAll handlers (they never admit anonymous) and rateLimit: { disabled: true, reason } declarations, but a handler with access.roles including \"anonymous\" and no rateLimit at all now throws at boot, naming the handler.",
|
|
14
|
+
"migration": "Every handler whose access.roles includes \"anonymous\" needs a rateLimit declaration: either rateLimit: { per: \"ip\" | \"ip+handler\", limit, windowSeconds } (per must not be \"user\"/\"user+handler\" — anonymous callers share user.id=\"anonymous\", so a user-keyed bucket is a single global tap), or the documented exception rateLimit: { disabled: true, reason: \"...\" } for a handler that must not be rate-limited. This PR fixes the eight bundled-features handlers that tripped this at authoring time: compliance-profiles:query:sub-processors, template-resolver:query:by-slug, template-resolver:query:by-tenant, managed-pages:query:by-slug, managed-pages:query:by-tenant-published, managed-pages:query:branding, auth-email-password:query:signup-registration-status, seo:query:config."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.289.0",
|
|
18
|
+
"type": "breaking",
|
|
19
|
+
"title": "ctx.db.unsafeRaw is denied under ctx.queryAsMember",
|
|
20
|
+
"migration": "Query handlers reached through ctx.queryAsMember can no longer call ctx.db.unsafeRaw — not even for reads. Move them to the ctx.db query APIs, or stop calling them via queryAsMember. Consequence for kumiko-enterprise: ai-call provenance writes under queryAsMember now fail closed; withProvenance swallows the error and counts provenance_drop_total."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.289.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "Drawer gains a modal prop for non-modal side panels",
|
|
26
|
+
"detail": "Drawer accepts modal (default true, behavior unchanged). With modal={false} the overlay and the focus trap are gone, so the page behind stays interactive and its text can be selected and copied; Escape still closes, a click beside the panel does not."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.289.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "Entity graph traversal helpers for cross-tenant ownership changes",
|
|
32
|
+
"detail": "physicalColumnName is now exported from @cosmicdrift/kumiko-framework/db (was framework-internal). storageKeyStemPrefix (files/file-handle.ts) gives the prefix that covers a stored file's key plus every derive()d variant. StorageProviderHookCtx (EXT_STORAGE_PROVIDER destroy hooks) gains db: DbRunner — the tenant-destroy stage runner already carried it for this hook family unconditionally, this only widens the declared type to match. TenantDataHookCtx (EXT_TENANT_DATA destroy hooks) gains fileProviderResolver and log, threaded through from the same stage runner. Built for the tenant-handover bundled feature (kumiko-framework#3035); no behavior change for existing hook implementations that don't read the new fields."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.289.0",
|
|
36
|
+
"type": "fix",
|
|
37
|
+
"title": "EventDispatcher gains drain() for race-free test teardown",
|
|
38
|
+
"detail": "New `EventDispatcher.drain(): Promise<void>` waits out a pass already in flight (the timer tick or LISTEN/NOTIFY wake-up a suite's own `.start()` triggered) without touching the timer or the LISTEN subscription — unlike `stop()`, the dispatcher keeps running afterwards. `stack/table-helpers.ts`'s `resetEventStore` now calls it before truncating `kumiko_events` and the other framework tables, so a beforeEach/afterEach reset can no longer race a pass this same dispatcher started (kumiko-framework#3017): every suite that calls `resetEventStore` after starting the dispatcher (directly or via `jobs`) inherits the fix automatically. No behavior change for suites that never start the dispatcher — `drain()` is then a no-op, since nothing is ever in flight."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.289.0",
|
|
42
|
+
"type": "improvement",
|
|
43
|
+
"title": "Event-store executor writes accept a declarative `expect:` precondition",
|
|
44
|
+
"detail": "`expect: { field: value }` on an executor write applies the change only if those fields still hold at write time, turning a check-then-act sequence into one step. Values are scalars (string/number/boolean/null) compared with strict equality; several fields mean AND, and the first mismatch names its field. A failed precondition raises the new `PreconditionFailedError` instead of writing.\nThe precondition fields and the `version` used for the append are read in one query, so a concurrent writer cannot slip between the check and the append — reading them separately reproduced a ~40% failure rate under real concurrency. The version is taken from the event stream itself, not from the projection row: a projection `version` is only in step for rows the executor wrote, so a row seeded directly would otherwise be checked against a predecessor that never existed."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"version": "0.289.0",
|
|
48
|
+
"type": "improvement",
|
|
49
|
+
"title": "PLATFORM_KEK can now be resolved from a Key Manager ciphertext at boot",
|
|
50
|
+
"detail": "New `resolvePlatformKeks` (packages/framework/src/crypto/kek-source.ts) decrypts `PLATFORM_KEK_CIPHERTEXT` / `PLATFORM_KEK_PREVIOUS_CIPHERTEXT` through Scaleway Key Manager when the matching plaintext (`PLATFORM_KEK` / `PLATFORM_KEK_PREVIOUS`) is absent, so the KEK no longer has to sit in the pod env in the clear. Each slot resolves independently: a plaintext value always wins over its ciphertext sibling with no request made, which keeps a KEK rollback bootable without a network call. `resolveKmsWiringAsync` and `requireKmsWiringAsync` wrap the existing sync `resolveKmsWiring` / `requireKmsWiring` with this resolution step; the sync functions and every other exported signature are unchanged. `PLATFORM_KEK` plaintext stays fully valid and keeps precedence — this is purely additive. Boot now logs one line per configured slot naming the source it used (`source=plaintext-env`, `source=key-manager keyId=… region=…`, and explicitly `(ciphertext present and ignored)` when a leftover plaintext wins), because a half-finished cutover otherwise boots green and looks exactly like a finished one. The line carries no key material and no token; `options.log` redirects it, the default is `console.info`."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"version": "0.289.0",
|
|
54
|
+
"type": "breaking",
|
|
55
|
+
"title": "requiredKeysFromScreen reads section.groups — group field labels and group titles are i18n-required",
|
|
56
|
+
"detail": "`requiredKeysFromScreen` (packages/framework/src/i18n/required-surface-keys.ts) read only `section.fields` in all six `EditFieldsSection` branches (`entityEdit`, `actionForm`, `secretMint` mint + confirm layout, `configEdit`, `projectionDetail`), so a field declared through `section.groups` never reached the required-key set and `groups[].title` was never required at all — while `computeEditViewModel` translates a group title exactly like a section title. `validateI18nSurfaceKeys` therefore let a screen boot with untranslated group field labels and group titles, the one error class that guard exists for.\nThe new `sectionFieldSpecs` in packages/framework/src/engine/screen-helpers.ts returns the union of `section.fields` and `section.groups[].fields` (a union, unlike the boot-validator's either-or `flattenFieldsOrGroups`, which runs after the fields-XOR-groups check) and is used at all six branches; each branch now also pushes every `groups[].title` alongside `section.title`, honoring the same `treatDotFormAsKey` option. The `writeForm` branch in `projectionDetail` is unchanged — `EditWriteFormSection` has no `groups`.",
|
|
57
|
+
"migration": "The i18n boot guard now demands translations it silently skipped before. An app whose entityEdit/actionForm/secretMint/configEdit/projectionDetail screens declare fields through `layout.sections[].groups` can newly fail boot with `required translation key missing: \"<feature>:entity:<entity>:field:<name>\"`. Add the missing field-label keys (or a `fieldLabels` override, where that screen type supports one) for every field declared under `groups[].fields`. Colon-form `groups[].title` values are now required too — add the key, or keep the title as literal display text (no colon, no `i18nKey()`) if it is not meant to be translated. Dot-form group titles behave exactly like dot-form section titles: only required when the caller passes `treatDotFormAsKey`."
|
|
58
|
+
},
|
|
2
59
|
{
|
|
3
60
|
"version": "0.287.0",
|
|
4
61
|
"type": "improvement",
|
|
@@ -113,6 +113,16 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
|
|
|
113
113
|
"sccRequired": false,
|
|
114
114
|
"status": "active",
|
|
115
115
|
},
|
|
116
|
+
{
|
|
117
|
+
"appliesTo": [
|
|
118
|
+
"all-tiers",
|
|
119
|
+
],
|
|
120
|
+
"name": "Scaleway SAS",
|
|
121
|
+
"optInOnly": false,
|
|
122
|
+
"region": "EU (France)",
|
|
123
|
+
"sccRequired": false,
|
|
124
|
+
"status": "active",
|
|
125
|
+
},
|
|
116
126
|
]
|
|
117
127
|
`);
|
|
118
128
|
});
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// Plattform-Betrieb einsetzt. Wird oeffentlich exposed unter
|
|
5
5
|
// /api/compliance/sub-processors (JSON, Sprint 1)
|
|
6
6
|
// /api/compliance/sub-processors.rss (RSS, Sprint 1)
|
|
7
|
-
//
|
|
7
|
+
//
|
|
8
|
+
// Separate from the public marketing overview (kumiko.rocks/subprocessors),
|
|
9
|
+
// which is compiled independently from templates/legal/_shared/subprocessors/
|
|
10
|
+
// catalog.*.md. The two lists are not synced.
|
|
8
11
|
//
|
|
9
12
|
// Tenant-Admins muessen ueber Add/Change/Remove informiert werden mit
|
|
10
13
|
// Lead-Time aus dem Compliance-Profile (typisch 30d). Cron-Job kommt
|
|
@@ -132,6 +135,16 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
|
|
|
132
135
|
appliesTo: ["all-tiers"],
|
|
133
136
|
status: "active",
|
|
134
137
|
},
|
|
138
|
+
{
|
|
139
|
+
name: "Scaleway SAS",
|
|
140
|
+
purpose:
|
|
141
|
+
"Offsite Backup Mirror (Object Storage); planned: platform key custody (Key Manager) and Transactional Email",
|
|
142
|
+
region: "EU (France)",
|
|
143
|
+
dpa: "https://www.scaleway.com/en/contracts/",
|
|
144
|
+
addedAt: "2026-09-18",
|
|
145
|
+
appliesTo: ["all-tiers"],
|
|
146
|
+
status: "active",
|
|
147
|
+
},
|
|
135
148
|
];
|
|
136
149
|
|
|
137
150
|
/**
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type KekSourceEnv, resolvePlatformKeks } from "../kek-source";
|
|
3
|
+
import { buildPgKmsOptions } from "../kms-wiring";
|
|
4
|
+
|
|
5
|
+
const TOKEN = "scw-secret-token";
|
|
6
|
+
const CIPHERTEXT_A = Buffer.from("ciphertext-a").toString("base64");
|
|
7
|
+
const CIPHERTEXT_B = Buffer.from("ciphertext-b").toString("base64");
|
|
8
|
+
const PLAINTEXT_A = Buffer.alloc(32, 1).toString("base64");
|
|
9
|
+
|
|
10
|
+
type FetchCall = { readonly url: string; readonly init: RequestInit };
|
|
11
|
+
|
|
12
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
13
|
+
return new Response(JSON.stringify(body), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { "content-type": "application/json" },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function trackedFetch(responses: ReadonlyArray<Response>): {
|
|
20
|
+
readonly fetch: typeof globalThis.fetch;
|
|
21
|
+
readonly calls: FetchCall[];
|
|
22
|
+
} {
|
|
23
|
+
const calls: FetchCall[] = [];
|
|
24
|
+
let index = 0;
|
|
25
|
+
const fetch = (async (url: string | URL, init?: RequestInit) => {
|
|
26
|
+
calls.push({ url: String(url), init: init ?? {} });
|
|
27
|
+
const response = responses[index];
|
|
28
|
+
index++;
|
|
29
|
+
if (!response) throw new Error("trackedFetch: no more responses queued");
|
|
30
|
+
return response;
|
|
31
|
+
}) as typeof globalThis.fetch;
|
|
32
|
+
return { fetch, calls };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("resolvePlatformKeks", () => {
|
|
36
|
+
test("passes through unchanged when PLATFORM_KEK is set, without ever calling fetch", async () => {
|
|
37
|
+
const { fetch, calls } = trackedFetch([]);
|
|
38
|
+
const env: KekSourceEnv = { PLATFORM_KEK: PLAINTEXT_A };
|
|
39
|
+
|
|
40
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
41
|
+
|
|
42
|
+
expect(result).toBe(env);
|
|
43
|
+
expect(calls.length).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("decrypts a lone ciphertext, posting the key id in the path and the token in X-Auth-Token", async () => {
|
|
47
|
+
const { fetch, calls } = trackedFetch([jsonResponse(200, { plaintext: PLAINTEXT_A })]);
|
|
48
|
+
const env: KekSourceEnv = {
|
|
49
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
50
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
51
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
55
|
+
|
|
56
|
+
expect(result.PLATFORM_KEK).toBe(PLAINTEXT_A);
|
|
57
|
+
expect(calls.length).toBe(1);
|
|
58
|
+
expect(calls[0]?.url).toContain("/keys/key-1/decrypt");
|
|
59
|
+
expect(new Headers(calls[0]?.init.headers).get("X-Auth-Token")).toBe(TOKEN);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("decrypts both ciphertexts with two distinct requests", async () => {
|
|
63
|
+
const { fetch, calls } = trackedFetch([
|
|
64
|
+
jsonResponse(200, { plaintext: "active-plaintext" }),
|
|
65
|
+
jsonResponse(200, { plaintext: "previous-plaintext" }),
|
|
66
|
+
]);
|
|
67
|
+
const env: KekSourceEnv = {
|
|
68
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
69
|
+
PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B,
|
|
70
|
+
PLATFORM_KEK_PREVIOUS_VERSION: "1",
|
|
71
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
72
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
76
|
+
|
|
77
|
+
expect(result.PLATFORM_KEK).toBe("active-plaintext");
|
|
78
|
+
expect(result.PLATFORM_KEK_PREVIOUS).toBe("previous-plaintext");
|
|
79
|
+
expect(calls.length).toBe(2);
|
|
80
|
+
const bodies = calls.map((call) => JSON.parse(String(call.init.body)).ciphertext);
|
|
81
|
+
expect(bodies).toEqual([CIPHERTEXT_A, CIPHERTEXT_B]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws without the token in the message when a ciphertext has no key id", async () => {
|
|
85
|
+
const { fetch } = trackedFetch([]);
|
|
86
|
+
const env: KekSourceEnv = {
|
|
87
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
88
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await resolvePlatformKeks(env, { fetch });
|
|
93
|
+
throw new Error("unreachable");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
expect(error).toBeInstanceOf(Error);
|
|
96
|
+
const message = error instanceof Error ? error.message : "";
|
|
97
|
+
expect(message).toMatch(/all-or-none/);
|
|
98
|
+
expect(message).not.toContain(TOKEN);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("throws without the token in the message when a ciphertext has no token", async () => {
|
|
103
|
+
const { fetch } = trackedFetch([]);
|
|
104
|
+
const env: KekSourceEnv = {
|
|
105
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
106
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
107
|
+
PLATFORM_KEK_KMS_TOKEN: undefined,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/all-or-none/);
|
|
111
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.not.toThrow(new RegExp(TOKEN));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("retries a network failure and succeeds on the next attempt", async () => {
|
|
115
|
+
const calls: string[] = [];
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
const fetch = (async (url: string | URL) => {
|
|
118
|
+
calls.push(String(url));
|
|
119
|
+
attempt++;
|
|
120
|
+
if (attempt === 1) throw new TypeError("fetch failed");
|
|
121
|
+
return jsonResponse(200, { plaintext: PLAINTEXT_A });
|
|
122
|
+
}) as typeof globalThis.fetch;
|
|
123
|
+
const env: KekSourceEnv = {
|
|
124
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
125
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
126
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
130
|
+
|
|
131
|
+
expect(result.PLATFORM_KEK).toBe(PLAINTEXT_A);
|
|
132
|
+
expect(calls.length).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("gives up after the last attempt when every call fails at the network level", async () => {
|
|
136
|
+
const calls: string[] = [];
|
|
137
|
+
const fetch = (async (url: string | URL): Promise<Response> => {
|
|
138
|
+
calls.push(String(url));
|
|
139
|
+
throw new TypeError("fetch failed");
|
|
140
|
+
}) as typeof globalThis.fetch;
|
|
141
|
+
const env: KekSourceEnv = {
|
|
142
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
143
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
144
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/network error or timeout/);
|
|
148
|
+
expect(calls.length).toBe(3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("rejects a previous ciphertext with no previous version", async () => {
|
|
152
|
+
const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "previous-plaintext" })]);
|
|
153
|
+
const env: KekSourceEnv = {
|
|
154
|
+
PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B,
|
|
155
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
156
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(
|
|
160
|
+
/PLATFORM_KEK_PREVIOUS_VERSION must be set/,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("returns env unchanged and calls fetch zero times when nothing is set", async () => {
|
|
165
|
+
const { fetch, calls } = trackedFetch([]);
|
|
166
|
+
const env: KekSourceEnv = { SUBJECT_KEYS_DATABASE_URL: "postgres://localhost/x" };
|
|
167
|
+
|
|
168
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
169
|
+
|
|
170
|
+
expect(result).toBe(env);
|
|
171
|
+
expect(calls.length).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("retries once on HTTP 500 and succeeds on the second attempt", async () => {
|
|
175
|
+
const { fetch, calls } = trackedFetch([
|
|
176
|
+
jsonResponse(500, {}),
|
|
177
|
+
jsonResponse(200, { plaintext: PLAINTEXT_A }),
|
|
178
|
+
]);
|
|
179
|
+
const env: KekSourceEnv = {
|
|
180
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
181
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
182
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
186
|
+
|
|
187
|
+
expect(result.PLATFORM_KEK).toBe(PLAINTEXT_A);
|
|
188
|
+
expect(calls.length).toBe(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("gives up after exactly one attempt on HTTP 403, no retry", async () => {
|
|
192
|
+
const { fetch, calls } = trackedFetch([jsonResponse(403, {})]);
|
|
193
|
+
const env: KekSourceEnv = {
|
|
194
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
195
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
196
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/HTTP 403/);
|
|
200
|
+
expect(calls.length).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("throws when a 2xx response carries no plaintext field", async () => {
|
|
204
|
+
const { fetch } = trackedFetch([jsonResponse(200, { notPlaintext: "x" })]);
|
|
205
|
+
const env: KekSourceEnv = {
|
|
206
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
207
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
208
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
await expect(resolvePlatformKeks(env, { fetch })).rejects.toThrow(/no plaintext field/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// The slot-to-version mapping is the part that "silently corrupts data when
|
|
215
|
+
// it is wrong" (kms-wiring.ts) — this is the one test that would catch a
|
|
216
|
+
// swapped active/previous assignment all the way through the adapter options.
|
|
217
|
+
test("maps a decrypted active slot and a plaintext previous slot into the correct buildPgKmsOptions fields", async () => {
|
|
218
|
+
const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "decrypted-active" })]);
|
|
219
|
+
const env: KekSourceEnv = {
|
|
220
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
221
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
222
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
223
|
+
PLATFORM_KEK_VERSION: "2",
|
|
224
|
+
PLATFORM_KEK_PREVIOUS: "plaintext-previous",
|
|
225
|
+
PLATFORM_KEK_PREVIOUS_VERSION: "1",
|
|
226
|
+
SUBJECT_KEYS_DATABASE_URL: "postgres://localhost/x",
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const resolved = await resolvePlatformKeks(env, { fetch });
|
|
230
|
+
const options = buildPgKmsOptions({
|
|
231
|
+
...resolved,
|
|
232
|
+
PLATFORM_KEK: resolved.PLATFORM_KEK ?? "",
|
|
233
|
+
SUBJECT_KEYS_DATABASE_URL: resolved["SUBJECT_KEYS_DATABASE_URL"] ?? "",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(options.platformKek).toBe("decrypted-active");
|
|
237
|
+
expect(options.previousKeks).toEqual({ 1: "plaintext-previous" });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("rollback state: both plaintexts win over a leftover ciphertext, no fetch, no error", async () => {
|
|
241
|
+
const { fetch, calls } = trackedFetch([]);
|
|
242
|
+
const env: KekSourceEnv = {
|
|
243
|
+
PLATFORM_KEK: "current-plaintext",
|
|
244
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
245
|
+
PLATFORM_KEK_PREVIOUS: "rolled-back-plaintext",
|
|
246
|
+
PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B,
|
|
247
|
+
PLATFORM_KEK_PREVIOUS_VERSION: "1",
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
251
|
+
|
|
252
|
+
expect(result.PLATFORM_KEK).toBe("current-plaintext");
|
|
253
|
+
expect(result.PLATFORM_KEK_PREVIOUS).toBe("rolled-back-plaintext");
|
|
254
|
+
expect(calls.length).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("names the ignored ciphertext when a leftover plaintext wins", async () => {
|
|
258
|
+
const { fetch } = trackedFetch([]);
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
const env: KekSourceEnv = {
|
|
261
|
+
PLATFORM_KEK: PLAINTEXT_A,
|
|
262
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
263
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
264
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
await resolvePlatformKeks(env, { fetch, log: (line) => lines.push(line), logPrefix: "[ps]" });
|
|
268
|
+
|
|
269
|
+
expect(lines).toEqual([
|
|
270
|
+
"[ps] PLATFORM_KEK source=plaintext-env (ciphertext present and ignored)",
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("names the key id and region when the ciphertext is used, without the token or the key", async () => {
|
|
275
|
+
const { fetch } = trackedFetch([jsonResponse(200, { plaintext: PLAINTEXT_A })]);
|
|
276
|
+
const lines: string[] = [];
|
|
277
|
+
const env: KekSourceEnv = {
|
|
278
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
279
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
280
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
await resolvePlatformKeks(env, { fetch, log: (line) => lines.push(line) });
|
|
284
|
+
|
|
285
|
+
expect(lines).toEqual(["PLATFORM_KEK source=key-manager keyId=key-1 region=fr-par"]);
|
|
286
|
+
expect(lines.join("\n")).not.toContain(TOKEN);
|
|
287
|
+
expect(lines.join("\n")).not.toContain(PLAINTEXT_A);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("says nothing when neither slot is configured", async () => {
|
|
291
|
+
const { fetch } = trackedFetch([]);
|
|
292
|
+
const lines: string[] = [];
|
|
293
|
+
|
|
294
|
+
await resolvePlatformKeks({}, { fetch, log: (line) => lines.push(line) });
|
|
295
|
+
|
|
296
|
+
expect(lines).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
});
|
package/src/crypto/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
} from "./event-pii";
|
|
16
16
|
export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
|
|
17
17
|
export { isSelfPiiField } from "./is-self-pii-field";
|
|
18
|
+
export { type KekSourceEnv, type KekSourceOptions, resolvePlatformKeks } from "./kek-source";
|
|
18
19
|
export {
|
|
19
20
|
isLocalKeyKmsAdapter,
|
|
20
21
|
KeyAlreadyExistsError,
|
|
@@ -46,7 +47,9 @@ export {
|
|
|
46
47
|
type PgKmsRotationEnv,
|
|
47
48
|
type PlaintextPiiWiring,
|
|
48
49
|
requireKmsWiring,
|
|
50
|
+
requireKmsWiringAsync,
|
|
49
51
|
resolveKmsWiring,
|
|
52
|
+
resolveKmsWiringAsync,
|
|
50
53
|
} from "./kms-wiring";
|
|
51
54
|
export {
|
|
52
55
|
createPgKmsAdapter,
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Resolves PLATFORM_KEK / PLATFORM_KEK_PREVIOUS from a Key Manager ciphertext
|
|
2
|
+
// when no plaintext is set, so the KEK need not sit in the pod env in the
|
|
3
|
+
// clear. `PLATFORM_KEK` stays the source of truth: a plaintext value always
|
|
4
|
+
// wins over its ciphertext sibling, with no request made at all.
|
|
5
|
+
|
|
6
|
+
const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
|
|
7
|
+
const DEFAULT_REGION = "fr-par";
|
|
8
|
+
const DECRYPT_TIMEOUT_MS = 5_000;
|
|
9
|
+
const RETRY_DELAYS_MS = [200, 800];
|
|
10
|
+
|
|
11
|
+
export type KekSourceEnv = {
|
|
12
|
+
readonly PLATFORM_KEK?: string | undefined;
|
|
13
|
+
readonly PLATFORM_KEK_CIPHERTEXT?: string | undefined;
|
|
14
|
+
readonly PLATFORM_KEK_PREVIOUS?: string | undefined;
|
|
15
|
+
readonly PLATFORM_KEK_PREVIOUS_CIPHERTEXT?: string | undefined;
|
|
16
|
+
readonly PLATFORM_KEK_PREVIOUS_VERSION?: string | undefined;
|
|
17
|
+
readonly PLATFORM_KEK_KMS_KEY_ID?: string | undefined;
|
|
18
|
+
readonly PLATFORM_KEK_KMS_TOKEN?: string | undefined;
|
|
19
|
+
readonly PLATFORM_KEK_KMS_REGION?: string | undefined;
|
|
20
|
+
readonly [key: string]: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type KekSourceOptions = {
|
|
24
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
25
|
+
readonly logPrefix?: string;
|
|
26
|
+
/** Where the boot line naming the KEK source goes. Defaults to `console.info`. */
|
|
27
|
+
readonly log?: (message: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function isRetryableStatus(status: number): boolean {
|
|
31
|
+
return status >= 500;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function sleep(ms: number): Promise<void> {
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Scaleway's decrypt SLA is 99.5% — a bootpath that gives up on the first
|
|
39
|
+
// transient 5xx or timeout trades a rare retry for a routine boot failure.
|
|
40
|
+
// 4xx means the request itself is wrong (bad token, bad key id) and no
|
|
41
|
+
// amount of retrying fixes that, so it fails fast instead of stalling boot.
|
|
42
|
+
async function decryptCiphertext(
|
|
43
|
+
ciphertext: string,
|
|
44
|
+
keyId: string,
|
|
45
|
+
token: string,
|
|
46
|
+
region: string,
|
|
47
|
+
fetchImpl: typeof globalThis.fetch,
|
|
48
|
+
logPrefix: string | undefined,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const url = `https://api.scaleway.com/key-manager/${SCALEWAY_KEY_MANAGER_API_VERSION}/regions/${region}/keys/${keyId}/decrypt`;
|
|
51
|
+
const prefix = logPrefix ? `${logPrefix} ` : "";
|
|
52
|
+
const maxAttempts = RETRY_DELAYS_MS.length + 1;
|
|
53
|
+
|
|
54
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
55
|
+
let response: Response;
|
|
56
|
+
try {
|
|
57
|
+
response = await fetchImpl(url, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "X-Auth-Token": token, "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify({ ciphertext }),
|
|
61
|
+
signal: AbortSignal.timeout(DECRYPT_TIMEOUT_MS),
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
if (attempt < maxAttempts) {
|
|
65
|
+
await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(
|
|
69
|
+
`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
if (isRetryableStatus(response.status) && attempt < maxAttempts) {
|
|
75
|
+
await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(
|
|
79
|
+
`${prefix}Key Manager decrypt failed for key ${keyId}: HTTP ${response.status}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let body: { plaintext?: unknown };
|
|
84
|
+
try {
|
|
85
|
+
body = (await response.json()) as { plaintext?: unknown };
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${prefix}Key Manager decrypt for key ${keyId} returned an unparseable response`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (typeof body.plaintext !== "string") {
|
|
92
|
+
throw new Error(`${prefix}Key Manager decrypt for key ${keyId} returned no plaintext field`);
|
|
93
|
+
}
|
|
94
|
+
return body.plaintext;
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: retries exhausted`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function resolveSlot(
|
|
100
|
+
plaintext: string | undefined,
|
|
101
|
+
ciphertext: string | undefined,
|
|
102
|
+
env: KekSourceEnv,
|
|
103
|
+
options: KekSourceOptions,
|
|
104
|
+
fetchImpl: typeof globalThis.fetch,
|
|
105
|
+
): Promise<string | undefined> {
|
|
106
|
+
if (plaintext) return plaintext;
|
|
107
|
+
if (!ciphertext) return undefined;
|
|
108
|
+
|
|
109
|
+
const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
|
|
110
|
+
const token = env.PLATFORM_KEK_KMS_TOKEN;
|
|
111
|
+
if (!keyId || !token) {
|
|
112
|
+
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext — a partial set means the KMS wiring is broken.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
118
|
+
return decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A leftover plaintext beside a ciphertext boots green while nothing was
|
|
122
|
+
// migrated, which is indistinguishable from a finished cutover unless the
|
|
123
|
+
// boot says which source won. Never carries a key value, only its origin.
|
|
124
|
+
function describeKekSource(
|
|
125
|
+
name: string,
|
|
126
|
+
plaintext: string | undefined,
|
|
127
|
+
ciphertext: string | undefined,
|
|
128
|
+
env: KekSourceEnv,
|
|
129
|
+
): string | undefined {
|
|
130
|
+
if (plaintext) {
|
|
131
|
+
return ciphertext
|
|
132
|
+
? `${name} source=plaintext-env (ciphertext present and ignored)`
|
|
133
|
+
: `${name} source=plaintext-env`;
|
|
134
|
+
}
|
|
135
|
+
if (!ciphertext) return undefined;
|
|
136
|
+
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
137
|
+
return `${name} source=key-manager keyId=${env.PLATFORM_KEK_KMS_KEY_ID} region=${region}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Each slot resolves independently so a rollback that clears one slot's
|
|
141
|
+
// plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks the
|
|
142
|
+
// other slot's fallback path — the trio check downstream still applies.
|
|
143
|
+
export async function resolvePlatformKeks(
|
|
144
|
+
env: KekSourceEnv,
|
|
145
|
+
options: KekSourceOptions = {},
|
|
146
|
+
): Promise<KekSourceEnv> {
|
|
147
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
148
|
+
|
|
149
|
+
const active = await resolveSlot(
|
|
150
|
+
env.PLATFORM_KEK,
|
|
151
|
+
env.PLATFORM_KEK_CIPHERTEXT,
|
|
152
|
+
env,
|
|
153
|
+
options,
|
|
154
|
+
fetchImpl,
|
|
155
|
+
);
|
|
156
|
+
const previous = await resolveSlot(
|
|
157
|
+
env.PLATFORM_KEK_PREVIOUS,
|
|
158
|
+
env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
|
|
159
|
+
env,
|
|
160
|
+
options,
|
|
161
|
+
fetchImpl,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
165
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
166
|
+
const log = options.log ?? console.info;
|
|
167
|
+
for (const line of [
|
|
168
|
+
describeKekSource("PLATFORM_KEK", env.PLATFORM_KEK, env.PLATFORM_KEK_CIPHERTEXT, env),
|
|
169
|
+
describeKekSource(
|
|
170
|
+
"PLATFORM_KEK_PREVIOUS",
|
|
171
|
+
env.PLATFORM_KEK_PREVIOUS,
|
|
172
|
+
env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
|
|
173
|
+
env,
|
|
174
|
+
),
|
|
175
|
+
]) {
|
|
176
|
+
if (line) log(`${prefix}${line}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (previous && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (active === env.PLATFORM_KEK && previous === env.PLATFORM_KEK_PREVIOUS) {
|
|
186
|
+
return env;
|
|
187
|
+
}
|
|
188
|
+
return { ...env, PLATFORM_KEK: active, PLATFORM_KEK_PREVIOUS: previous };
|
|
189
|
+
}
|