@striae-org/striae 5.2.1 → 5.3.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 (117) hide show
  1. package/.env.example +2 -10
  2. package/README.md +5 -46
  3. package/app/components/actions/case-export/core-export.ts +5 -174
  4. package/app/components/actions/case-export/download-handlers.ts +84 -751
  5. package/app/components/actions/case-export/index.ts +6 -30
  6. package/app/components/actions/case-export/metadata-helpers.ts +0 -78
  7. package/app/components/actions/case-export/types-constants.ts +0 -43
  8. package/app/components/actions/case-import/confirmation-import.ts +75 -36
  9. package/app/components/actions/case-import/confirmation-package.ts +68 -1
  10. package/app/components/actions/case-import/index.ts +1 -1
  11. package/app/components/actions/case-import/orchestrator.ts +78 -53
  12. package/app/components/actions/case-import/zip-processing.ts +160 -330
  13. package/app/components/actions/generate-pdf.ts +3 -2
  14. package/app/components/audit/user-audit-viewer.tsx +0 -19
  15. package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
  16. package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
  17. package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
  18. package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
  19. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
  20. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
  21. package/app/components/navbar/navbar.tsx +1 -1
  22. package/app/components/sidebar/case-import/case-import.module.css +35 -0
  23. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +51 -3
  24. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
  25. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
  26. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
  27. package/app/components/sidebar/case-import/index.ts +1 -4
  28. package/app/components/sidebar/notes/class-details-shared.ts +2 -2
  29. package/app/components/toast/toast.module.css +36 -0
  30. package/app/components/toast/toast.tsx +6 -2
  31. package/app/components/user/manage-profile.tsx +4 -3
  32. package/app/config-example/config.json +1 -2
  33. package/app/root.tsx +0 -7
  34. package/app/routes/_index.tsx +1 -1
  35. package/app/routes/auth/login.example.tsx +22 -103
  36. package/app/routes/auth/login.tsx +22 -103
  37. package/app/routes/auth/route.ts +1 -1
  38. package/app/routes/striae/striae.tsx +117 -59
  39. package/app/services/firebase/index.ts +0 -3
  40. package/app/types/case.ts +1 -0
  41. package/app/types/export.ts +2 -2
  42. package/app/types/import.ts +10 -0
  43. package/app/utils/auth/index.ts +0 -1
  44. package/app/utils/data/permissions.ts +3 -2
  45. package/package.json +9 -16
  46. package/public/_headers +0 -4
  47. package/public/_routes.json +0 -1
  48. package/worker-configuration.d.ts +20 -17
  49. package/workers/audit-worker/src/audit-worker.example.ts +9 -806
  50. package/workers/audit-worker/src/config.ts +7 -0
  51. package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
  52. package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
  53. package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
  54. package/workers/audit-worker/src/types.ts +56 -0
  55. package/workers/audit-worker/worker-configuration.d.ts +1 -1
  56. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  57. package/workers/data-worker/src/config.ts +11 -0
  58. package/workers/data-worker/src/data-worker.example.ts +21 -942
  59. package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
  60. package/workers/data-worker/src/handlers/signing.ts +174 -0
  61. package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
  62. package/workers/data-worker/src/registry/key-registry.ts +368 -0
  63. package/workers/data-worker/src/types.ts +46 -0
  64. package/workers/data-worker/worker-configuration.d.ts +1 -1
  65. package/workers/data-worker/wrangler.jsonc.example +1 -1
  66. package/workers/image-worker/worker-configuration.d.ts +1 -1
  67. package/workers/image-worker/wrangler.jsonc.example +1 -1
  68. package/workers/pdf-worker/worker-configuration.d.ts +2 -3
  69. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  70. package/workers/user-worker/src/auth.ts +30 -0
  71. package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
  72. package/workers/user-worker/src/config.ts +4 -0
  73. package/workers/user-worker/src/encryption-utils.ts +25 -0
  74. package/workers/user-worker/src/firebase/admin.ts +152 -0
  75. package/workers/user-worker/src/handlers/user-routes.ts +242 -0
  76. package/workers/user-worker/src/registry/user-kv.ts +172 -0
  77. package/workers/user-worker/src/storage/user-records.ts +34 -0
  78. package/workers/user-worker/src/types.ts +106 -0
  79. package/workers/user-worker/src/user-worker.example.ts +18 -964
  80. package/workers/user-worker/worker-configuration.d.ts +4 -2
  81. package/workers/user-worker/wrangler.jsonc.example +12 -1
  82. package/wrangler.toml.example +1 -1
  83. package/app/components/actions/case-export/data-processing.ts +0 -223
  84. package/app/components/sidebar/case-export/case-export.module.css +0 -418
  85. package/app/components/sidebar/case-export/case-export.tsx +0 -310
  86. package/app/types/exceljs-bare.d.ts +0 -9
  87. package/app/utils/auth/auth.ts +0 -11
  88. package/public/.well-known/security.txt +0 -6
  89. package/public/favicon.ico +0 -0
  90. package/public/icon-256.png +0 -0
  91. package/public/icon-512.png +0 -0
  92. package/public/manifest.json +0 -39
  93. package/public/shortcut.png +0 -0
  94. package/public/social-image.png +0 -0
  95. package/public/vendor/exceljs.LICENSE +0 -22
  96. package/public/vendor/exceljs.bare.min.js +0 -45
  97. package/scripts/deploy-all.sh +0 -166
  98. package/scripts/deploy-config/modules/env-utils.sh +0 -322
  99. package/scripts/deploy-config/modules/keys.sh +0 -404
  100. package/scripts/deploy-config/modules/prompt.sh +0 -372
  101. package/scripts/deploy-config/modules/scaffolding.sh +0 -344
  102. package/scripts/deploy-config/modules/validation.sh +0 -365
  103. package/scripts/deploy-config.sh +0 -236
  104. package/scripts/deploy-pages-secrets.sh +0 -231
  105. package/scripts/deploy-pages.sh +0 -34
  106. package/scripts/deploy-primershear-emails.sh +0 -167
  107. package/scripts/deploy-worker-secrets.sh +0 -374
  108. package/scripts/dev.cjs +0 -23
  109. package/scripts/install-workers.sh +0 -88
  110. package/scripts/run-eslint.cjs +0 -43
  111. package/scripts/update-compatibility-dates.cjs +0 -124
  112. package/scripts/update-markdown-versions.cjs +0 -43
  113. package/workers/keys-worker/package.json +0 -18
  114. package/workers/keys-worker/src/keys.example.ts +0 -67
  115. package/workers/keys-worker/src/keys.ts +0 -67
  116. package/workers/keys-worker/worker-configuration.d.ts +0 -7447
  117. package/workers/keys-worker/wrangler.jsonc.example +0 -15
@@ -1,71 +1,18 @@
1
- import { signPayload as signWithWorkerKey } from './signature-utils';
2
1
  import {
3
- decryptExportData,
4
- decryptImageBlob,
5
- decryptJsonFromStorage,
6
- encryptJsonForStorage,
7
- type DataAtRestEnvelope
8
- } from './encryption-utils';
2
+ DECRYPT_EXPORT_PATH,
3
+ SIGN_AUDIT_EXPORT_PATH,
4
+ SIGN_CONFIRMATION_PATH,
5
+ SIGN_MANIFEST_PATH,
6
+ hasValidHeader
7
+ } from './config';
8
+ import { handleDecryptExport } from './handlers/decrypt-export';
9
9
  import {
10
- AUDIT_EXPORT_SIGNATURE_VERSION,
11
- CONFIRMATION_SIGNATURE_VERSION,
12
- FORENSIC_MANIFEST_SIGNATURE_ALGORITHM,
13
- FORENSIC_MANIFEST_VERSION,
14
- type AuditExportSigningPayload,
15
- type ConfirmationSigningPayload,
16
- type ForensicManifestPayload,
17
- createAuditExportSigningPayload,
18
- createConfirmationSigningPayload,
19
- createManifestSigningPayload,
20
- isValidAuditExportPayload,
21
- isValidConfirmationPayload,
22
- isValidManifestPayload
23
- } from './signing-payload-utils';
24
-
25
- interface Env {
26
- R2_KEY_SECRET: string;
27
- STRIAE_DATA: R2Bucket;
28
- MANIFEST_SIGNING_PRIVATE_KEY: string;
29
- MANIFEST_SIGNING_KEY_ID: string;
30
- EXPORT_ENCRYPTION_PRIVATE_KEY?: string;
31
- EXPORT_ENCRYPTION_KEY_ID?: string;
32
- EXPORT_ENCRYPTION_KEYS_JSON?: string;
33
- EXPORT_ENCRYPTION_ACTIVE_KEY_ID?: string;
34
- DATA_AT_REST_ENCRYPTION_ENABLED?: string;
35
- DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
36
- DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
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;
40
- }
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
-
60
- interface SuccessResponse {
61
- success: boolean;
62
- }
63
-
64
- interface ErrorResponse {
65
- error: string;
66
- }
67
-
68
- type APIResponse = SuccessResponse | ErrorResponse | unknown[] | Record<string, unknown>;
10
+ handleSignAuditExport,
11
+ handleSignConfirmation,
12
+ handleSignManifest
13
+ } from './handlers/signing';
14
+ import { handleStorageRequest } from './handlers/storage-routes';
15
+ import type { CreateResponse, Env } from './types';
69
16
 
70
17
  const corsHeaders: Record<string, string> = {
71
18
  'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
@@ -74,780 +21,11 @@ const corsHeaders: Record<string, string> = {
74
21
  'Content-Type': 'application/json'
75
22
  };
76
23
 
77
- const createResponse = (data: APIResponse, status: number = 200): Response => new Response(
24
+ const createWorkerResponse: CreateResponse = (data, status: number = 200): Response => new Response(
78
25
  JSON.stringify(data),
79
26
  { status, headers: corsHeaders }
80
27
  );
81
28
 
82
- const hasValidHeader = (request: Request, env: Env): boolean =>
83
- request.headers.get('X-Custom-Auth-Key') === env.R2_KEY_SECRET;
84
-
85
- const SIGN_MANIFEST_PATH = '/api/forensic/sign-manifest';
86
- const SIGN_CONFIRMATION_PATH = '/api/forensic/sign-confirmation';
87
- const SIGN_AUDIT_EXPORT_PATH = '/api/forensic/sign-audit-export';
88
- const DECRYPT_EXPORT_PATH = '/api/forensic/decrypt-export';
89
- const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
90
- const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
91
- const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
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
-
405
- function isDataAtRestEncryptionEnabled(env: Env): boolean {
406
- const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
407
- if (!value) {
408
- return false;
409
- }
410
-
411
- const normalizedValue = value.trim().toLowerCase();
412
- return normalizedValue === '1' || normalizedValue === 'true' || normalizedValue === 'yes' || normalizedValue === 'on';
413
- }
414
-
415
- function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
416
- const metadata = file.customMetadata;
417
- if (!metadata) {
418
- return null;
419
- }
420
-
421
- const {
422
- algorithm,
423
- encryptionVersion,
424
- keyId,
425
- dataIv,
426
- wrappedKey
427
- } = metadata;
428
-
429
- if (
430
- typeof algorithm !== 'string' ||
431
- typeof encryptionVersion !== 'string' ||
432
- typeof keyId !== 'string' ||
433
- typeof dataIv !== 'string' ||
434
- typeof wrappedKey !== 'string'
435
- ) {
436
- return null;
437
- }
438
-
439
- return {
440
- algorithm,
441
- encryptionVersion,
442
- keyId,
443
- dataIv,
444
- wrappedKey
445
- };
446
- }
447
-
448
- function hasDataAtRestMetadata(metadata: Record<string, string> | undefined): boolean {
449
- if (!metadata) {
450
- return false;
451
- }
452
-
453
- return (
454
- typeof metadata.algorithm === 'string' &&
455
- typeof metadata.encryptionVersion === 'string' &&
456
- typeof metadata.keyId === 'string' &&
457
- typeof metadata.dataIv === 'string' &&
458
- typeof metadata.wrappedKey === 'string'
459
- );
460
- }
461
-
462
- function clampBackfillBatchSize(size: number | undefined): number {
463
- if (typeof size !== 'number' || !Number.isFinite(size)) {
464
- return 100;
465
- }
466
-
467
- const normalized = Math.floor(size);
468
- if (normalized < 1) {
469
- return 1;
470
- }
471
-
472
- if (normalized > 1000) {
473
- return 1000;
474
- }
475
-
476
- return normalized;
477
- }
478
-
479
- async function handleDataAtRestBackfill(request: Request, env: Env): Promise<Response> {
480
- if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
481
- return createResponse(
482
- { error: 'Data-at-rest encryption is not configured for backfill writes' },
483
- 400
484
- );
485
- }
486
-
487
- const requestBody = await request.json().catch(() => ({})) as {
488
- dryRun?: boolean;
489
- prefix?: string;
490
- cursor?: string;
491
- batchSize?: number;
492
- };
493
-
494
- const dryRun = requestBody.dryRun === true;
495
- const prefix = typeof requestBody.prefix === 'string' ? requestBody.prefix : '';
496
- const cursor = typeof requestBody.cursor === 'string' && requestBody.cursor.length > 0
497
- ? requestBody.cursor
498
- : undefined;
499
- const batchSize = clampBackfillBatchSize(requestBody.batchSize);
500
-
501
- const bucket = env.STRIAE_DATA;
502
- const listed = await bucket.list({
503
- prefix: prefix.length > 0 ? prefix : undefined,
504
- cursor,
505
- limit: batchSize
506
- });
507
-
508
- let scanned = 0;
509
- let eligible = 0;
510
- let encrypted = 0;
511
- let skippedEncrypted = 0;
512
- let skippedNonJson = 0;
513
- let failed = 0;
514
- const failures: Array<{ key: string; error: string }> = [];
515
-
516
- for (const object of listed.objects) {
517
- scanned += 1;
518
- const key = object.key;
519
-
520
- if (!key.endsWith('.json')) {
521
- skippedNonJson += 1;
522
- continue;
523
- }
524
-
525
- const objectHead = await bucket.head(key);
526
- if (!objectHead) {
527
- failed += 1;
528
- if (failures.length < 20) {
529
- failures.push({ key, error: 'Object not found during metadata check' });
530
- }
531
- continue;
532
- }
533
-
534
- if (hasDataAtRestMetadata(objectHead.customMetadata)) {
535
- skippedEncrypted += 1;
536
- continue;
537
- }
538
-
539
- eligible += 1;
540
-
541
- if (dryRun) {
542
- continue;
543
- }
544
-
545
- try {
546
- const existingObject = await bucket.get(key);
547
- if (!existingObject) {
548
- failed += 1;
549
- if (failures.length < 20) {
550
- failures.push({ key, error: 'Object disappeared before processing' });
551
- }
552
- continue;
553
- }
554
-
555
- const plaintext = await existingObject.text();
556
- const encryptedPayload = await encryptJsonForStorage(
557
- plaintext,
558
- env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
559
- env.DATA_AT_REST_ENCRYPTION_KEY_ID
560
- );
561
-
562
- await bucket.put(key, encryptedPayload.ciphertext, {
563
- customMetadata: {
564
- algorithm: encryptedPayload.envelope.algorithm,
565
- encryptionVersion: encryptedPayload.envelope.encryptionVersion,
566
- keyId: encryptedPayload.envelope.keyId,
567
- dataIv: encryptedPayload.envelope.dataIv,
568
- wrappedKey: encryptedPayload.envelope.wrappedKey
569
- }
570
- });
571
-
572
- encrypted += 1;
573
- } catch (error) {
574
- failed += 1;
575
- if (failures.length < 20) {
576
- const errorMessage = error instanceof Error ? error.message : 'Unknown backfill failure';
577
- failures.push({ key, error: errorMessage });
578
- }
579
- }
580
- }
581
-
582
- return createResponse({
583
- success: failed === 0,
584
- dryRun,
585
- prefix: prefix.length > 0 ? prefix : null,
586
- batchSize,
587
- scanned,
588
- eligible,
589
- encrypted,
590
- skippedEncrypted,
591
- skippedNonJson,
592
- failed,
593
- failures,
594
- hasMore: listed.truncated,
595
- nextCursor: listed.truncated ? listed.cursor : null
596
- });
597
- }
598
-
599
- async function signPayloadWithWorkerKey(payload: string, env: Env): Promise<{
600
- algorithm: string;
601
- keyId: string;
602
- signedAt: string;
603
- value: string;
604
- }> {
605
- return signWithWorkerKey(
606
- payload,
607
- env.MANIFEST_SIGNING_PRIVATE_KEY,
608
- env.MANIFEST_SIGNING_KEY_ID,
609
- FORENSIC_MANIFEST_SIGNATURE_ALGORITHM
610
- );
611
- }
612
-
613
- async function signManifest(manifest: ForensicManifestPayload, env: Env): Promise<{
614
- algorithm: string;
615
- keyId: string;
616
- signedAt: string;
617
- value: string;
618
- }> {
619
- const payload = createManifestSigningPayload(manifest);
620
- return signPayloadWithWorkerKey(payload, env);
621
- }
622
-
623
- async function signConfirmation(confirmationData: ConfirmationSigningPayload, env: Env): Promise<{
624
- algorithm: string;
625
- keyId: string;
626
- signedAt: string;
627
- value: string;
628
- }> {
629
- const payload = createConfirmationSigningPayload(confirmationData);
630
- return signPayloadWithWorkerKey(payload, env);
631
- }
632
-
633
- async function signAuditExport(auditExportData: AuditExportSigningPayload, env: Env): Promise<{
634
- algorithm: string;
635
- keyId: string;
636
- signedAt: string;
637
- value: string;
638
- }> {
639
- const payload = createAuditExportSigningPayload(auditExportData);
640
- return signPayloadWithWorkerKey(payload, env);
641
- }
642
-
643
- async function handleSignManifest(request: Request, env: Env): Promise<Response> {
644
- try {
645
- const requestBody = await request.json() as { manifest?: Partial<ForensicManifestPayload> } & Partial<ForensicManifestPayload>;
646
- const manifestCandidate: Partial<ForensicManifestPayload> = requestBody.manifest ?? requestBody;
647
-
648
- if (!manifestCandidate || !isValidManifestPayload(manifestCandidate)) {
649
- return createResponse({ error: 'Invalid manifest payload' }, 400);
650
- }
651
-
652
- const signature = await signManifest(manifestCandidate, env);
653
-
654
- return createResponse({
655
- success: true,
656
- manifestVersion: FORENSIC_MANIFEST_VERSION,
657
- signature
658
- });
659
- } catch (error) {
660
- console.error('Manifest signing failed:', error);
661
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
662
- return createResponse({ error: errorMessage }, 500);
663
- }
664
- }
665
-
666
- async function handleSignConfirmation(request: Request, env: Env): Promise<Response> {
667
- try {
668
- const requestBody = await request.json() as {
669
- confirmationData?: Partial<ConfirmationSigningPayload>;
670
- signatureVersion?: string;
671
- } & Partial<ConfirmationSigningPayload>;
672
-
673
- const requestedSignatureVersion =
674
- typeof requestBody.signatureVersion === 'string' && requestBody.signatureVersion.trim().length > 0
675
- ? requestBody.signatureVersion
676
- : CONFIRMATION_SIGNATURE_VERSION;
677
-
678
- if (requestedSignatureVersion !== CONFIRMATION_SIGNATURE_VERSION) {
679
- return createResponse(
680
- { error: `Unsupported confirmation signature version: ${requestedSignatureVersion}` },
681
- 400
682
- );
683
- }
684
-
685
- const confirmationCandidate: Partial<ConfirmationSigningPayload> = requestBody.confirmationData ?? requestBody;
686
-
687
- if (!confirmationCandidate || !isValidConfirmationPayload(confirmationCandidate)) {
688
- return createResponse({ error: 'Invalid confirmation payload' }, 400);
689
- }
690
-
691
- const signature = await signConfirmation(confirmationCandidate, env);
692
-
693
- return createResponse({
694
- success: true,
695
- signatureVersion: CONFIRMATION_SIGNATURE_VERSION,
696
- signature
697
- });
698
- } catch (error) {
699
- console.error('Confirmation signing failed:', error);
700
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
701
- return createResponse({ error: errorMessage }, 500);
702
- }
703
- }
704
-
705
- async function handleSignAuditExport(request: Request, env: Env): Promise<Response> {
706
- try {
707
- const requestBody = await request.json() as {
708
- auditExport?: Partial<AuditExportSigningPayload>;
709
- signatureVersion?: string;
710
- } & Partial<AuditExportSigningPayload>;
711
-
712
- const requestedSignatureVersion =
713
- typeof requestBody.signatureVersion === 'string' && requestBody.signatureVersion.trim().length > 0
714
- ? requestBody.signatureVersion
715
- : AUDIT_EXPORT_SIGNATURE_VERSION;
716
-
717
- if (requestedSignatureVersion !== AUDIT_EXPORT_SIGNATURE_VERSION) {
718
- return createResponse(
719
- { error: `Unsupported audit export signature version: ${requestedSignatureVersion}` },
720
- 400
721
- );
722
- }
723
-
724
- const auditExportCandidate: Partial<AuditExportSigningPayload> = requestBody.auditExport ?? requestBody;
725
-
726
- if (!auditExportCandidate || !isValidAuditExportPayload(auditExportCandidate)) {
727
- return createResponse({ error: 'Invalid audit export payload' }, 400);
728
- }
729
-
730
- const signature = await signAuditExport(auditExportCandidate, env);
731
-
732
- return createResponse({
733
- success: true,
734
- signatureVersion: AUDIT_EXPORT_SIGNATURE_VERSION,
735
- signature
736
- });
737
- } catch (error) {
738
- console.error('Audit export signing failed:', error);
739
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
740
- return createResponse({ error: errorMessage }, 500);
741
- }
742
- }
743
-
744
- async function handleDecryptExport(request: Request, env: Env): Promise<Response> {
745
- try {
746
- const requestBody = await request.json() as {
747
- wrappedKey?: string;
748
- dataIv?: string;
749
- encryptedData?: string;
750
- encryptedImages?: Array<{ filename: string; encryptedData: string; iv?: string }>;
751
- keyId?: string;
752
- };
753
-
754
- const { wrappedKey, dataIv, encryptedData, encryptedImages, keyId } = requestBody;
755
-
756
- // Validate required fields
757
- if (
758
- !wrappedKey ||
759
- typeof wrappedKey !== 'string' ||
760
- !dataIv ||
761
- typeof dataIv !== 'string' ||
762
- !encryptedData ||
763
- typeof encryptedData !== 'string'
764
- ) {
765
- return createResponse(
766
- { error: 'Missing or invalid required fields: wrappedKey, dataIv, encryptedData' },
767
- 400
768
- );
769
- }
770
-
771
- const recordKeyId = getNonEmptyString(keyId);
772
- const decryptionContext = buildExportDecryptionContext(recordKeyId, env);
773
-
774
- // Decrypt data file
775
- let plaintextData: string;
776
- try {
777
- plaintextData = await decryptExportDataWithRegistry(
778
- encryptedData,
779
- wrappedKey,
780
- dataIv,
781
- decryptionContext
782
- );
783
- } catch (error) {
784
- console.error('Data file decryption failed:', error);
785
- const errorMessage = error instanceof Error ? error.message : 'Decryption failed';
786
- return createResponse(
787
- { error: `Failed to decrypt data file: ${errorMessage}` },
788
- 500
789
- );
790
- }
791
-
792
- // Decrypt images if provided
793
- const decryptedImages: Array<{ filename: string; data: string }> = [];
794
- if (Array.isArray(encryptedImages) && encryptedImages.length > 0) {
795
- for (const imageEntry of encryptedImages) {
796
- try {
797
- if (!imageEntry.iv || typeof imageEntry.iv !== 'string') {
798
- return createResponse(
799
- { error: `Missing IV for image ${imageEntry.filename}` },
800
- 400
801
- );
802
- }
803
-
804
- const imageBlob = await decryptExportImageWithRegistry(
805
- imageEntry.encryptedData,
806
- wrappedKey,
807
- imageEntry.iv,
808
- decryptionContext
809
- );
810
-
811
- // Convert blob to base64 for transport
812
- const arrayBuffer = await imageBlob.arrayBuffer();
813
- const bytes = new Uint8Array(arrayBuffer);
814
- const chunkSize = 8192;
815
- let binary = '';
816
- for (let i = 0; i < bytes.length; i += chunkSize) {
817
- const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
818
- for (let j = 0; j < chunk.length; j++) {
819
- binary += String.fromCharCode(chunk[j]);
820
- }
821
- }
822
- const base64Data = btoa(binary);
823
-
824
- decryptedImages.push({
825
- filename: imageEntry.filename,
826
- data: base64Data
827
- });
828
- } catch (error) {
829
- console.error(`Image decryption failed for ${imageEntry.filename}:`, error);
830
- const errorMessage = error instanceof Error ? error.message : 'Decryption failed';
831
- return createResponse(
832
- { error: `Failed to decrypt image ${imageEntry.filename}: ${errorMessage}` },
833
- 500
834
- );
835
- }
836
- }
837
- }
838
-
839
- return createResponse({
840
- success: true,
841
- plaintext: plaintextData,
842
- decryptedImages
843
- });
844
- } catch (error) {
845
- console.error('Export decryption request failed:', error);
846
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
847
- return createResponse({ error: errorMessage }, 500);
848
- }
849
- }
850
-
851
29
  export default {
852
30
  async fetch(request: Request, env: Env): Promise<Response> {
853
31
  if (request.method === 'OPTIONS') {
@@ -855,133 +33,34 @@ export default {
855
33
  }
856
34
 
857
35
  if (!hasValidHeader(request, env)) {
858
- return createResponse({ error: 'Forbidden' }, 403);
36
+ return createWorkerResponse({ error: 'Forbidden' }, 403);
859
37
  }
860
38
 
861
39
  try {
862
40
  const url = new URL(request.url);
863
41
  const pathname = url.pathname;
864
- const bucket = env.STRIAE_DATA;
865
42
 
866
43
  if (request.method === 'POST' && pathname === SIGN_MANIFEST_PATH) {
867
- return await handleSignManifest(request, env);
44
+ return await handleSignManifest(request, env, createWorkerResponse);
868
45
  }
869
46
 
870
47
  if (request.method === 'POST' && pathname === SIGN_CONFIRMATION_PATH) {
871
- return await handleSignConfirmation(request, env);
48
+ return await handleSignConfirmation(request, env, createWorkerResponse);
872
49
  }
873
50
 
874
51
  if (request.method === 'POST' && pathname === SIGN_AUDIT_EXPORT_PATH) {
875
- return await handleSignAuditExport(request, env);
52
+ return await handleSignAuditExport(request, env, createWorkerResponse);
876
53
  }
877
54
 
878
55
  if (request.method === 'POST' && pathname === DECRYPT_EXPORT_PATH) {
879
- return await handleDecryptExport(request, env);
56
+ return await handleDecryptExport(request, env, createWorkerResponse);
880
57
  }
881
58
 
882
- if (request.method === 'POST' && pathname === DATA_AT_REST_BACKFILL_PATH) {
883
- return await handleDataAtRestBackfill(request, env);
884
- }
885
-
886
- const filename = pathname.slice(1) || 'data.json';
887
-
888
- if (!filename.endsWith('.json')) {
889
- return createResponse({ error: 'Invalid file type. Only JSON files are allowed.' }, 400);
890
- }
891
-
892
- switch (request.method) {
893
- case 'GET': {
894
- const file = await bucket.get(filename);
895
- if (!file) {
896
- return createResponse([], 200);
897
- }
898
-
899
- const atRestEnvelope = extractDataAtRestEnvelope(file);
900
- if (atRestEnvelope) {
901
- if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
902
- return createResponse({ error: 'Unsupported data-at-rest encryption algorithm' }, 500);
903
- }
904
-
905
- if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
906
- return createResponse({ error: 'Unsupported data-at-rest encryption version' }, 500);
907
- }
908
-
909
- try {
910
- const encryptedData = await file.arrayBuffer();
911
- const plaintext = await decryptJsonFromStorageWithRegistry(
912
- encryptedData,
913
- atRestEnvelope,
914
- env
915
- );
916
- const decryptedPayload = JSON.parse(plaintext);
917
- return createResponse(decryptedPayload);
918
- } catch (error) {
919
- console.error('Data-at-rest decryption failed:', error);
920
- return createResponse({ error: 'Failed to decrypt stored data' }, 500);
921
- }
922
- }
923
-
924
- const fileText = await file.text();
925
- const data = JSON.parse(fileText);
926
- return createResponse(data);
927
- }
928
-
929
- case 'PUT': {
930
- const newData = await request.json();
931
- const serializedData = JSON.stringify(newData);
932
-
933
- if (!isDataAtRestEncryptionEnabled(env)) {
934
- await bucket.put(filename, serializedData);
935
- return createResponse({ success: true });
936
- }
937
-
938
- if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
939
- return createResponse(
940
- { error: 'Data-at-rest encryption is enabled but not fully configured' },
941
- 500
942
- );
943
- }
944
-
945
- try {
946
- const encryptedPayload = await encryptJsonForStorage(
947
- serializedData,
948
- env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
949
- env.DATA_AT_REST_ENCRYPTION_KEY_ID
950
- );
951
-
952
- await bucket.put(filename, encryptedPayload.ciphertext, {
953
- customMetadata: {
954
- algorithm: encryptedPayload.envelope.algorithm,
955
- encryptionVersion: encryptedPayload.envelope.encryptionVersion,
956
- keyId: encryptedPayload.envelope.keyId,
957
- dataIv: encryptedPayload.envelope.dataIv,
958
- wrappedKey: encryptedPayload.envelope.wrappedKey
959
- }
960
- });
961
- } catch (error) {
962
- console.error('Data-at-rest encryption failed:', error);
963
- return createResponse({ error: 'Failed to encrypt data for storage' }, 500);
964
- }
965
-
966
- return createResponse({ success: true });
967
- }
968
-
969
- case 'DELETE': {
970
- const file = await bucket.get(filename);
971
- if (!file) {
972
- return createResponse({ error: 'File not found' }, 404);
973
- }
974
- await bucket.delete(filename);
975
- return createResponse({ success: true });
976
- }
977
-
978
- default:
979
- return createResponse({ error: 'Method not allowed' }, 405);
980
- }
59
+ return await handleStorageRequest(request, env, pathname, createWorkerResponse);
981
60
  } catch (error) {
982
61
  console.error('Worker error:', error);
983
62
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
984
- return createResponse({ error: errorMessage }, 500);
63
+ return createWorkerResponse({ error: errorMessage }, 500);
985
64
  }
986
65
  }
987
66
  };