@striae-org/striae 5.1.1 → 5.2.1

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.
Files changed (30) hide show
  1. package/.env.example +41 -11
  2. package/app/utils/data/permissions.ts +4 -2
  3. package/package.json +5 -5
  4. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  5. package/scripts/deploy-config/modules/keys.sh +404 -0
  6. package/scripts/deploy-config/modules/prompt.sh +372 -0
  7. package/scripts/deploy-config/modules/scaffolding.sh +344 -0
  8. package/scripts/deploy-config/modules/validation.sh +365 -0
  9. package/scripts/deploy-config.sh +47 -1572
  10. package/scripts/deploy-worker-secrets.sh +100 -5
  11. package/worker-configuration.d.ts +6 -3
  12. package/workers/audit-worker/package.json +1 -1
  13. package/workers/audit-worker/src/audit-worker.example.ts +188 -6
  14. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  15. package/workers/data-worker/package.json +1 -1
  16. package/workers/data-worker/src/data-worker.example.ts +344 -32
  17. package/workers/data-worker/wrangler.jsonc.example +1 -1
  18. package/workers/image-worker/package.json +1 -1
  19. package/workers/image-worker/src/image-worker.example.ts +190 -5
  20. package/workers/image-worker/wrangler.jsonc.example +1 -1
  21. package/workers/keys-worker/package.json +1 -1
  22. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  23. package/workers/pdf-worker/package.json +1 -1
  24. package/workers/pdf-worker/src/pdf-worker.example.ts +0 -1
  25. package/workers/pdf-worker/wrangler.jsonc.example +1 -5
  26. package/workers/user-worker/package.json +17 -17
  27. package/workers/user-worker/src/encryption-utils.ts +244 -0
  28. package/workers/user-worker/src/user-worker.example.ts +333 -31
  29. package/workers/user-worker/wrangler.jsonc.example +1 -1
  30. package/wrangler.toml.example +1 -1
@@ -29,12 +29,34 @@ interface Env {
29
29
  MANIFEST_SIGNING_KEY_ID: string;
30
30
  EXPORT_ENCRYPTION_PRIVATE_KEY?: string;
31
31
  EXPORT_ENCRYPTION_KEY_ID?: string;
32
+ EXPORT_ENCRYPTION_KEYS_JSON?: string;
33
+ EXPORT_ENCRYPTION_ACTIVE_KEY_ID?: string;
32
34
  DATA_AT_REST_ENCRYPTION_ENABLED?: string;
33
35
  DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
34
36
  DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
35
37
  DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
38
+ DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
39
+ DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
36
40
  }
37
41
 
42
+ interface KeyRegistryPayload {
43
+ activeKeyId?: unknown;
44
+ keys?: unknown;
45
+ }
46
+
47
+ interface PrivateKeyRegistry {
48
+ activeKeyId: string | null;
49
+ keys: Record<string, string>;
50
+ }
51
+
52
+ interface ExportDecryptionContext {
53
+ recordKeyId: string | null;
54
+ candidates: Array<{ keyId: string; privateKeyPem: string }>;
55
+ primaryKeyId: string | null;
56
+ }
57
+
58
+ type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
59
+
38
60
  interface SuccessResponse {
39
61
  success: boolean;
40
62
  }
@@ -68,6 +90,318 @@ const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
68
90
  const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
69
91
  const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
70
92
 
93
+ function normalizePrivateKeyPem(rawValue: string): string {
94
+ return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
95
+ }
96
+
97
+ function getNonEmptyString(value: unknown): string | null {
98
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
99
+ }
100
+
101
+ function parsePrivateKeyRegistry(input: {
102
+ registryJson: string | undefined;
103
+ activeKeyId: string | undefined;
104
+ legacyKeyId: string | undefined;
105
+ legacyPrivateKey: string | undefined;
106
+ context: string;
107
+ }): PrivateKeyRegistry {
108
+ const keys: Record<string, string> = {};
109
+ const configuredActiveKeyId = getNonEmptyString(input.activeKeyId);
110
+ const registryJson = getNonEmptyString(input.registryJson);
111
+
112
+ if (registryJson) {
113
+ let parsedRegistry: unknown;
114
+
115
+ try {
116
+ parsedRegistry = JSON.parse(registryJson) as unknown;
117
+ } catch {
118
+ throw new Error(`${input.context} registry JSON is invalid`);
119
+ }
120
+
121
+ if (!parsedRegistry || typeof parsedRegistry !== 'object') {
122
+ throw new Error(`${input.context} registry JSON must be an object`);
123
+ }
124
+
125
+ const payload = parsedRegistry as KeyRegistryPayload;
126
+ const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
127
+ const rawKeys = payload.keys && typeof payload.keys === 'object'
128
+ ? payload.keys as Record<string, unknown>
129
+ : parsedRegistry as Record<string, unknown>;
130
+
131
+ for (const [keyId, pemValue] of Object.entries(rawKeys)) {
132
+ if (keyId === 'activeKeyId' || keyId === 'keys') {
133
+ continue;
134
+ }
135
+
136
+ const normalizedKeyId = getNonEmptyString(keyId);
137
+ const normalizedPem = getNonEmptyString(pemValue);
138
+
139
+ if (!normalizedKeyId || !normalizedPem) {
140
+ continue;
141
+ }
142
+
143
+ keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
144
+ }
145
+
146
+ const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
147
+
148
+ if (Object.keys(keys).length === 0) {
149
+ throw new Error(`${input.context} registry does not contain any usable keys`);
150
+ }
151
+
152
+ if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
153
+ throw new Error(`${input.context} active key ID is not present in registry`);
154
+ }
155
+
156
+ return {
157
+ activeKeyId: resolvedActiveKeyId ?? null,
158
+ keys
159
+ };
160
+ }
161
+
162
+ const legacyKeyId = getNonEmptyString(input.legacyKeyId);
163
+ const legacyPrivateKey = getNonEmptyString(input.legacyPrivateKey);
164
+
165
+ if (!legacyKeyId || !legacyPrivateKey) {
166
+ throw new Error(`${input.context} private key registry is not configured`);
167
+ }
168
+
169
+ keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
170
+ const resolvedActiveKeyId = configuredActiveKeyId ?? legacyKeyId;
171
+
172
+ return {
173
+ activeKeyId: resolvedActiveKeyId,
174
+ keys
175
+ };
176
+ }
177
+
178
+ function buildPrivateKeyCandidates(
179
+ recordKeyId: string | null,
180
+ registry: PrivateKeyRegistry
181
+ ): Array<{ keyId: string; privateKeyPem: string }> {
182
+ const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
183
+ const seen = new Set<string>();
184
+
185
+ const appendCandidate = (candidateKeyId: string | null): void => {
186
+ if (!candidateKeyId || seen.has(candidateKeyId)) {
187
+ return;
188
+ }
189
+
190
+ const privateKeyPem = registry.keys[candidateKeyId];
191
+ if (!privateKeyPem) {
192
+ return;
193
+ }
194
+
195
+ seen.add(candidateKeyId);
196
+ candidates.push({ keyId: candidateKeyId, privateKeyPem });
197
+ };
198
+
199
+ appendCandidate(recordKeyId);
200
+ appendCandidate(registry.activeKeyId);
201
+
202
+ for (const keyId of Object.keys(registry.keys)) {
203
+ appendCandidate(keyId);
204
+ }
205
+
206
+ return candidates;
207
+ }
208
+
209
+ function logRegistryDecryptionTelemetry(input: {
210
+ scope: 'data-at-rest' | 'export-data' | 'export-image';
211
+ recordKeyId: string | null;
212
+ selectedKeyId: string | null;
213
+ attemptCount: number;
214
+ outcome: DecryptionTelemetryOutcome;
215
+ reason?: string;
216
+ }): void {
217
+ const details = {
218
+ scope: input.scope,
219
+ recordKeyId: input.recordKeyId,
220
+ selectedKeyId: input.selectedKeyId,
221
+ attemptCount: input.attemptCount,
222
+ fallbackUsed: input.outcome === 'fallback-hit',
223
+ outcome: input.outcome,
224
+ reason: input.reason ?? null
225
+ };
226
+
227
+ if (input.outcome === 'all-failed') {
228
+ console.warn('Key registry decryption failed', details);
229
+ return;
230
+ }
231
+
232
+ console.info('Key registry decryption resolved', details);
233
+ }
234
+
235
+ function getDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
236
+ return parsePrivateKeyRegistry({
237
+ registryJson: env.DATA_AT_REST_ENCRYPTION_KEYS_JSON,
238
+ activeKeyId: env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID,
239
+ legacyKeyId: env.DATA_AT_REST_ENCRYPTION_KEY_ID,
240
+ legacyPrivateKey: env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY,
241
+ context: 'Data-at-rest decryption'
242
+ });
243
+ }
244
+
245
+ function getExportPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
246
+ return parsePrivateKeyRegistry({
247
+ registryJson: env.EXPORT_ENCRYPTION_KEYS_JSON,
248
+ activeKeyId: env.EXPORT_ENCRYPTION_ACTIVE_KEY_ID,
249
+ legacyKeyId: env.EXPORT_ENCRYPTION_KEY_ID,
250
+ legacyPrivateKey: env.EXPORT_ENCRYPTION_PRIVATE_KEY,
251
+ context: 'Export decryption'
252
+ });
253
+ }
254
+
255
+ function buildExportDecryptionContext(keyId: string | null, env: Env): ExportDecryptionContext {
256
+ const keyRegistry = getExportPrivateKeyRegistry(env);
257
+ const candidates = buildPrivateKeyCandidates(keyId, keyRegistry);
258
+
259
+ if (candidates.length === 0) {
260
+ throw new Error('Export decryption key registry does not contain any usable keys');
261
+ }
262
+
263
+ return {
264
+ recordKeyId: keyId,
265
+ candidates,
266
+ primaryKeyId: candidates[0]?.keyId ?? null
267
+ };
268
+ }
269
+
270
+ async function decryptJsonFromStorageWithRegistry(
271
+ ciphertext: ArrayBuffer,
272
+ envelope: DataAtRestEnvelope,
273
+ env: Env
274
+ ): Promise<string> {
275
+ const keyRegistry = getDataAtRestPrivateKeyRegistry(env);
276
+ const candidates = buildPrivateKeyCandidates(getNonEmptyString(envelope.keyId), keyRegistry);
277
+ const primaryKeyId = candidates[0]?.keyId ?? null;
278
+ let lastError: unknown;
279
+
280
+ for (let index = 0; index < candidates.length; index += 1) {
281
+ const candidate = candidates[index];
282
+ try {
283
+ const plaintext = await decryptJsonFromStorage(ciphertext, envelope, candidate.privateKeyPem);
284
+ logRegistryDecryptionTelemetry({
285
+ scope: 'data-at-rest',
286
+ recordKeyId: getNonEmptyString(envelope.keyId),
287
+ selectedKeyId: candidate.keyId,
288
+ attemptCount: index + 1,
289
+ outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
290
+ });
291
+ return plaintext;
292
+ } catch (error) {
293
+ lastError = error;
294
+ }
295
+ }
296
+
297
+ logRegistryDecryptionTelemetry({
298
+ scope: 'data-at-rest',
299
+ recordKeyId: getNonEmptyString(envelope.keyId),
300
+ selectedKeyId: null,
301
+ attemptCount: candidates.length,
302
+ outcome: 'all-failed',
303
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
304
+ });
305
+
306
+ throw new Error(
307
+ `Failed to decrypt stored data after ${candidates.length} key attempt(s): ${
308
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
309
+ }`
310
+ );
311
+ }
312
+
313
+ async function decryptExportDataWithRegistry(
314
+ encryptedDataBase64: string,
315
+ wrappedKeyBase64: string,
316
+ ivBase64: string,
317
+ context: ExportDecryptionContext
318
+ ): Promise<string> {
319
+ let lastError: unknown;
320
+
321
+ for (let index = 0; index < context.candidates.length; index += 1) {
322
+ const candidate = context.candidates[index];
323
+ try {
324
+ const plaintext = await decryptExportData(
325
+ encryptedDataBase64,
326
+ wrappedKeyBase64,
327
+ ivBase64,
328
+ candidate.privateKeyPem
329
+ );
330
+ logRegistryDecryptionTelemetry({
331
+ scope: 'export-data',
332
+ recordKeyId: context.recordKeyId,
333
+ selectedKeyId: candidate.keyId,
334
+ attemptCount: index + 1,
335
+ outcome: candidate.keyId === context.primaryKeyId ? 'primary-hit' : 'fallback-hit'
336
+ });
337
+ return plaintext;
338
+ } catch (error) {
339
+ lastError = error;
340
+ }
341
+ }
342
+
343
+ logRegistryDecryptionTelemetry({
344
+ scope: 'export-data',
345
+ recordKeyId: context.recordKeyId,
346
+ selectedKeyId: null,
347
+ attemptCount: context.candidates.length,
348
+ outcome: 'all-failed',
349
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
350
+ });
351
+
352
+ throw new Error(
353
+ `Failed to decrypt export payload after ${context.candidates.length} key attempt(s): ${
354
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
355
+ }`
356
+ );
357
+ }
358
+
359
+ async function decryptExportImageWithRegistry(
360
+ encryptedImageBase64: string,
361
+ wrappedKeyBase64: string,
362
+ ivBase64: string,
363
+ context: ExportDecryptionContext
364
+ ): Promise<Blob> {
365
+ let lastError: unknown;
366
+
367
+ for (let index = 0; index < context.candidates.length; index += 1) {
368
+ const candidate = context.candidates[index];
369
+ try {
370
+ const imageBlob = await decryptImageBlob(
371
+ encryptedImageBase64,
372
+ wrappedKeyBase64,
373
+ ivBase64,
374
+ candidate.privateKeyPem
375
+ );
376
+ logRegistryDecryptionTelemetry({
377
+ scope: 'export-image',
378
+ recordKeyId: context.recordKeyId,
379
+ selectedKeyId: candidate.keyId,
380
+ attemptCount: index + 1,
381
+ outcome: candidate.keyId === context.primaryKeyId ? 'primary-hit' : 'fallback-hit'
382
+ });
383
+ return imageBlob;
384
+ } catch (error) {
385
+ lastError = error;
386
+ }
387
+ }
388
+
389
+ logRegistryDecryptionTelemetry({
390
+ scope: 'export-image',
391
+ recordKeyId: context.recordKeyId,
392
+ selectedKeyId: null,
393
+ attemptCount: context.candidates.length,
394
+ outcome: 'all-failed',
395
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
396
+ });
397
+
398
+ throw new Error(
399
+ `Failed to decrypt export image after ${context.candidates.length} key attempt(s): ${
400
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
401
+ }`
402
+ );
403
+ }
404
+
71
405
  function isDataAtRestEncryptionEnabled(env: Env): boolean {
72
406
  const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
73
407
  if (!value) {
@@ -409,14 +743,6 @@ async function handleSignAuditExport(request: Request, env: Env): Promise<Respon
409
743
 
410
744
  async function handleDecryptExport(request: Request, env: Env): Promise<Response> {
411
745
  try {
412
- // Check if encryption is configured
413
- if (!env.EXPORT_ENCRYPTION_PRIVATE_KEY || !env.EXPORT_ENCRYPTION_KEY_ID) {
414
- return createResponse(
415
- { error: 'Export decryption is not configured on this server' },
416
- 400
417
- );
418
- }
419
-
420
746
  const requestBody = await request.json() as {
421
747
  wrappedKey?: string;
422
748
  dataIv?: string;
@@ -434,32 +760,25 @@ async function handleDecryptExport(request: Request, env: Env): Promise<Response
434
760
  !dataIv ||
435
761
  typeof dataIv !== 'string' ||
436
762
  !encryptedData ||
437
- typeof encryptedData !== 'string' ||
438
- !keyId ||
439
- typeof keyId !== 'string'
763
+ typeof encryptedData !== 'string'
440
764
  ) {
441
765
  return createResponse(
442
- { error: 'Missing or invalid required fields: wrappedKey, dataIv, encryptedData, keyId' },
766
+ { error: 'Missing or invalid required fields: wrappedKey, dataIv, encryptedData' },
443
767
  400
444
768
  );
445
769
  }
446
770
 
447
- // Validate keyId matches configured key
448
- if (keyId !== env.EXPORT_ENCRYPTION_KEY_ID) {
449
- return createResponse(
450
- { error: `Key ID mismatch: expected ${env.EXPORT_ENCRYPTION_KEY_ID}, got ${keyId}` },
451
- 400
452
- );
453
- }
771
+ const recordKeyId = getNonEmptyString(keyId);
772
+ const decryptionContext = buildExportDecryptionContext(recordKeyId, env);
454
773
 
455
774
  // Decrypt data file
456
775
  let plaintextData: string;
457
776
  try {
458
- plaintextData = await decryptExportData(
777
+ plaintextData = await decryptExportDataWithRegistry(
459
778
  encryptedData,
460
779
  wrappedKey,
461
780
  dataIv,
462
- env.EXPORT_ENCRYPTION_PRIVATE_KEY
781
+ decryptionContext
463
782
  );
464
783
  } catch (error) {
465
784
  console.error('Data file decryption failed:', error);
@@ -482,11 +801,11 @@ async function handleDecryptExport(request: Request, env: Env): Promise<Response
482
801
  );
483
802
  }
484
803
 
485
- const imageBlob = await decryptImageBlob(
804
+ const imageBlob = await decryptExportImageWithRegistry(
486
805
  imageEntry.encryptedData,
487
806
  wrappedKey,
488
807
  imageEntry.iv,
489
- env.EXPORT_ENCRYPTION_PRIVATE_KEY
808
+ decryptionContext
490
809
  );
491
810
 
492
811
  // Convert blob to base64 for transport
@@ -587,19 +906,12 @@ export default {
587
906
  return createResponse({ error: 'Unsupported data-at-rest encryption version' }, 500);
588
907
  }
589
908
 
590
- if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
591
- return createResponse(
592
- { error: 'Data-at-rest decryption is not configured on this server' },
593
- 500
594
- );
595
- }
596
-
597
909
  try {
598
910
  const encryptedData = await file.arrayBuffer();
599
- const plaintext = await decryptJsonFromStorage(
911
+ const plaintext = await decryptJsonFromStorageWithRegistry(
600
912
  encryptedData,
601
913
  atRestEnvelope,
602
- env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
914
+ env
603
915
  );
604
916
  const decryptedPayload = JSON.parse(plaintext);
605
917
  return createResponse(decryptedPayload);
@@ -5,7 +5,7 @@
5
5
  "name": "DATA_WORKER_NAME",
6
6
  "account_id": "ACCOUNT_ID",
7
7
  "main": "src/data-worker.ts",
8
- "compatibility_date": "2026-03-24",
8
+ "compatibility_date": "2026-03-25",
9
9
  "compatibility_flags": [
10
10
  "nodejs_compat"
11
11
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.76.0"
12
+ "wrangler": "^4.77.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -7,13 +7,27 @@ import {
7
7
  interface Env {
8
8
  IMAGES_API_TOKEN: string;
9
9
  STRIAE_FILES: R2Bucket;
10
- DATA_AT_REST_ENCRYPTION_PRIVATE_KEY: string;
10
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
11
11
  DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
12
12
  DATA_AT_REST_ENCRYPTION_KEY_ID: string;
13
+ DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
14
+ DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
13
15
  IMAGE_SIGNED_URL_SECRET?: string;
14
16
  IMAGE_SIGNED_URL_TTL_SECONDS?: string;
15
17
  }
16
18
 
19
+ interface KeyRegistryPayload {
20
+ activeKeyId?: unknown;
21
+ keys?: unknown;
22
+ }
23
+
24
+ interface PrivateKeyRegistry {
25
+ activeKeyId: string | null;
26
+ keys: Record<string, string>;
27
+ }
28
+
29
+ type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
30
+
17
31
  interface UploadResult {
18
32
  id: string;
19
33
  filename: string;
@@ -89,9 +103,180 @@ function requireEncryptionUploadConfig(env: Env): void {
89
103
  }
90
104
 
91
105
  function requireEncryptionRetrievalConfig(env: Env): void {
92
- if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
93
- throw new Error('Data-at-rest decryption is not configured for image retrieval');
106
+ const hasLegacyPrivateKey = typeof env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY === 'string' && env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
107
+ const hasRegistry = typeof env.DATA_AT_REST_ENCRYPTION_KEYS_JSON === 'string' && env.DATA_AT_REST_ENCRYPTION_KEYS_JSON.trim().length > 0;
108
+
109
+ if (!hasLegacyPrivateKey && !hasRegistry) {
110
+ throw new Error('Data-at-rest decryption registry is not configured for image retrieval');
111
+ }
112
+ }
113
+
114
+ function normalizePrivateKeyPem(rawValue: string): string {
115
+ return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
116
+ }
117
+
118
+ function getNonEmptyString(value: unknown): string | null {
119
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
120
+ }
121
+
122
+ function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
123
+ const keys: Record<string, string> = {};
124
+ const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
125
+ const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
126
+
127
+ if (registryJson) {
128
+ let parsedRegistry: unknown;
129
+ try {
130
+ parsedRegistry = JSON.parse(registryJson) as unknown;
131
+ } catch {
132
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
133
+ }
134
+
135
+ if (!parsedRegistry || typeof parsedRegistry !== 'object') {
136
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
137
+ }
138
+
139
+ const payload = parsedRegistry as KeyRegistryPayload;
140
+ if (!payload.keys || typeof payload.keys !== 'object') {
141
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must include a keys object');
142
+ }
143
+
144
+ for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
145
+ const normalizedKeyId = getNonEmptyString(keyId);
146
+ const normalizedPem = getNonEmptyString(pemValue);
147
+ if (!normalizedKeyId || !normalizedPem) {
148
+ continue;
149
+ }
150
+
151
+ keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
152
+ }
153
+
154
+ const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
155
+ const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
156
+
157
+ if (Object.keys(keys).length === 0) {
158
+ throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
159
+ }
160
+
161
+ if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
162
+ throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
163
+ }
164
+
165
+ return {
166
+ activeKeyId: resolvedActiveKeyId ?? null,
167
+ keys
168
+ };
169
+ }
170
+
171
+ const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
172
+ const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
173
+ if (!legacyKeyId || !legacyPrivateKey) {
174
+ throw new Error('Data-at-rest decryption key registry is not configured');
94
175
  }
176
+
177
+ keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
178
+
179
+ return {
180
+ activeKeyId: configuredActiveKeyId ?? legacyKeyId,
181
+ keys
182
+ };
183
+ }
184
+
185
+ function buildPrivateKeyCandidates(
186
+ recordKeyId: string,
187
+ registry: PrivateKeyRegistry
188
+ ): Array<{ keyId: string; privateKeyPem: string }> {
189
+ const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
190
+ const seen = new Set<string>();
191
+
192
+ const appendCandidate = (candidateKeyId: string | null): void => {
193
+ if (!candidateKeyId || seen.has(candidateKeyId)) {
194
+ return;
195
+ }
196
+
197
+ const privateKeyPem = registry.keys[candidateKeyId];
198
+ if (!privateKeyPem) {
199
+ return;
200
+ }
201
+
202
+ seen.add(candidateKeyId);
203
+ candidates.push({ keyId: candidateKeyId, privateKeyPem });
204
+ };
205
+
206
+ appendCandidate(getNonEmptyString(recordKeyId));
207
+ appendCandidate(registry.activeKeyId);
208
+
209
+ for (const keyId of Object.keys(registry.keys)) {
210
+ appendCandidate(keyId);
211
+ }
212
+
213
+ return candidates;
214
+ }
215
+
216
+ function logFileDecryptionTelemetry(input: {
217
+ recordKeyId: string;
218
+ selectedKeyId: string | null;
219
+ attemptCount: number;
220
+ outcome: DecryptionTelemetryOutcome;
221
+ reason?: string;
222
+ }): void {
223
+ const details = {
224
+ scope: 'file-at-rest',
225
+ recordKeyId: input.recordKeyId,
226
+ selectedKeyId: input.selectedKeyId,
227
+ attemptCount: input.attemptCount,
228
+ fallbackUsed: input.outcome === 'fallback-hit',
229
+ outcome: input.outcome,
230
+ reason: input.reason ?? null
231
+ };
232
+
233
+ if (input.outcome === 'all-failed') {
234
+ console.warn('Key registry decryption failed', details);
235
+ return;
236
+ }
237
+
238
+ console.info('Key registry decryption resolved', details);
239
+ }
240
+
241
+ async function decryptBinaryWithRegistry(
242
+ ciphertext: ArrayBuffer,
243
+ envelope: DataAtRestEnvelope,
244
+ env: Env
245
+ ): Promise<ArrayBuffer> {
246
+ const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
247
+ const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
248
+ const primaryKeyId = candidates[0]?.keyId ?? null;
249
+ let lastError: unknown;
250
+
251
+ for (let index = 0; index < candidates.length; index += 1) {
252
+ const candidate = candidates[index];
253
+ try {
254
+ const plaintext = await decryptBinaryFromStorage(ciphertext, envelope, candidate.privateKeyPem);
255
+ logFileDecryptionTelemetry({
256
+ recordKeyId: envelope.keyId,
257
+ selectedKeyId: candidate.keyId,
258
+ attemptCount: index + 1,
259
+ outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
260
+ });
261
+ return plaintext;
262
+ } catch (error) {
263
+ lastError = error;
264
+ }
265
+ }
266
+
267
+ logFileDecryptionTelemetry({
268
+ recordKeyId: envelope.keyId,
269
+ selectedKeyId: null,
270
+ attemptCount: candidates.length,
271
+ outcome: 'all-failed',
272
+ reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
273
+ });
274
+
275
+ throw new Error(
276
+ `Failed to decrypt stored file after ${candidates.length} key attempt(s): ${
277
+ lastError instanceof Error ? lastError.message : 'unknown decryption error'
278
+ }`
279
+ );
95
280
  }
96
281
 
97
282
  function parseFileId(pathname: string): string | null {
@@ -453,10 +638,10 @@ async function handleImageServing(request: Request, env: Env, fileId: string): P
453
638
  }
454
639
 
455
640
  const encryptedData = await file.arrayBuffer();
456
- const plaintext = await decryptBinaryFromStorage(
641
+ const plaintext = await decryptBinaryWithRegistry(
457
642
  encryptedData,
458
643
  envelope,
459
- env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
644
+ env
460
645
  );
461
646
 
462
647
  const contentType = file.customMetadata?.contentType || 'application/octet-stream';
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-24",
5
+ "compatibility_date": "2026-03-25",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.76.0"
12
+ "wrangler": "^4.77.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",