@striae-org/striae 4.3.4 → 5.1.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.
Files changed (61) hide show
  1. package/.env.example +9 -2
  2. package/app/components/actions/case-export/download-handlers.ts +66 -11
  3. package/app/components/actions/case-import/confirmation-import.ts +50 -7
  4. package/app/components/actions/case-import/confirmation-package.ts +99 -22
  5. package/app/components/actions/case-import/orchestrator.ts +116 -13
  6. package/app/components/actions/case-import/validation.ts +171 -7
  7. package/app/components/actions/case-import/zip-processing.ts +224 -127
  8. package/app/components/actions/case-manage.ts +74 -15
  9. package/app/components/actions/confirm-export.ts +32 -3
  10. package/app/components/actions/generate-pdf.ts +43 -1
  11. package/app/components/actions/image-manage.ts +13 -45
  12. package/app/components/navbar/navbar.module.css +0 -10
  13. package/app/components/navbar/navbar.tsx +0 -22
  14. package/app/components/sidebar/case-import/case-import.module.css +7 -131
  15. package/app/components/sidebar/case-import/case-import.tsx +7 -14
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
  19. package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
  20. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
  21. package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
  22. package/app/config-example/config.json +5 -0
  23. package/app/routes/auth/login.tsx +1 -1
  24. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  25. package/app/routes/striae/striae.tsx +15 -4
  26. package/app/utils/data/operations/case-operations.ts +13 -1
  27. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  28. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  29. package/app/utils/data/operations/signing-operations.ts +93 -0
  30. package/app/utils/data/operations/types.ts +6 -0
  31. package/app/utils/forensics/export-encryption.ts +316 -0
  32. package/app/utils/forensics/export-verification.ts +1 -409
  33. package/app/utils/forensics/index.ts +1 -0
  34. package/app/utils/ui/case-messages.ts +5 -2
  35. package/package.json +2 -2
  36. package/scripts/deploy-config.sh +244 -7
  37. package/scripts/deploy-pages-secrets.sh +0 -6
  38. package/scripts/deploy-worker-secrets.sh +66 -5
  39. package/scripts/encrypt-r2-backfill.mjs +376 -0
  40. package/worker-configuration.d.ts +13 -7
  41. package/workers/audit-worker/package.json +1 -4
  42. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  43. package/workers/audit-worker/wrangler.jsonc.example +6 -1
  44. package/workers/data-worker/package.json +1 -4
  45. package/workers/data-worker/src/data-worker.example.ts +409 -1
  46. package/workers/data-worker/src/encryption-utils.ts +269 -0
  47. package/workers/data-worker/worker-configuration.d.ts +1 -1
  48. package/workers/data-worker/wrangler.jsonc.example +6 -2
  49. package/workers/image-worker/package.json +1 -4
  50. package/workers/image-worker/src/encryption-utils.ts +217 -0
  51. package/workers/image-worker/src/image-worker.example.ts +196 -127
  52. package/workers/image-worker/wrangler.jsonc.example +8 -1
  53. package/workers/keys-worker/package.json +1 -4
  54. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  55. package/workers/pdf-worker/package.json +1 -4
  56. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  57. package/workers/user-worker/package.json +1 -4
  58. package/workers/user-worker/wrangler.jsonc.example +1 -1
  59. package/wrangler.toml.example +1 -1
  60. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
  61. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
@@ -1,8 +1,13 @@
1
1
  {
2
+ // Required secrets: R2_KEY_SECRET
3
+ // Optional data-at-rest secrets/vars:
4
+ // - DATA_AT_REST_ENCRYPTION_ENABLED=true
5
+ // - DATA_AT_REST_ENCRYPTION_PRIVATE_KEY (required for decrypting encrypted records)
6
+ // - DATA_AT_REST_ENCRYPTION_PUBLIC_KEY and DATA_AT_REST_ENCRYPTION_KEY_ID (required when encrypt-on-write is enabled)
2
7
  "name": "AUDIT_WORKER_NAME",
3
8
  "account_id": "ACCOUNT_ID",
4
9
  "main": "src/audit-worker.ts",
5
- "compatibility_date": "2026-03-23",
10
+ "compatibility_date": "2026-03-24",
6
11
  "compatibility_flags": [
7
12
  "nodejs_compat"
8
13
  ],
@@ -5,13 +5,10 @@
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
7
7
  "dev": "wrangler dev",
8
- "start": "wrangler dev",
9
- "test": "vitest"
8
+ "start": "wrangler dev"
10
9
  },
11
10
  "devDependencies": {
12
11
  "@cloudflare/puppeteer": "^1.0.6",
13
- "@cloudflare/vitest-pool-workers": "^0.13.0",
14
- "vitest": "~4.1.0",
15
12
  "wrangler": "^4.76.0"
16
13
  },
17
14
  "overrides": {
@@ -1,4 +1,11 @@
1
1
  import { signPayload as signWithWorkerKey } from './signature-utils';
2
+ import {
3
+ decryptExportData,
4
+ decryptImageBlob,
5
+ decryptJsonFromStorage,
6
+ encryptJsonForStorage,
7
+ type DataAtRestEnvelope
8
+ } from './encryption-utils';
2
9
  import {
3
10
  AUDIT_EXPORT_SIGNATURE_VERSION,
4
11
  CONFIRMATION_SIGNATURE_VERSION,
@@ -20,6 +27,12 @@ interface Env {
20
27
  STRIAE_DATA: R2Bucket;
21
28
  MANIFEST_SIGNING_PRIVATE_KEY: string;
22
29
  MANIFEST_SIGNING_KEY_ID: string;
30
+ EXPORT_ENCRYPTION_PRIVATE_KEY?: string;
31
+ EXPORT_ENCRYPTION_KEY_ID?: string;
32
+ DATA_AT_REST_ENCRYPTION_ENABLED?: string;
33
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
34
+ DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
35
+ DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
23
36
  }
24
37
 
25
38
  interface SuccessResponse {
@@ -50,6 +63,204 @@ const hasValidHeader = (request: Request, env: Env): boolean =>
50
63
  const SIGN_MANIFEST_PATH = '/api/forensic/sign-manifest';
51
64
  const SIGN_CONFIRMATION_PATH = '/api/forensic/sign-confirmation';
52
65
  const SIGN_AUDIT_EXPORT_PATH = '/api/forensic/sign-audit-export';
66
+ const DECRYPT_EXPORT_PATH = '/api/forensic/decrypt-export';
67
+ const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
68
+ const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
69
+ const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
70
+
71
+ function isDataAtRestEncryptionEnabled(env: Env): boolean {
72
+ const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
73
+ if (!value) {
74
+ return false;
75
+ }
76
+
77
+ const normalizedValue = value.trim().toLowerCase();
78
+ return normalizedValue === '1' || normalizedValue === 'true' || normalizedValue === 'yes' || normalizedValue === 'on';
79
+ }
80
+
81
+ function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
82
+ const metadata = file.customMetadata;
83
+ if (!metadata) {
84
+ return null;
85
+ }
86
+
87
+ const {
88
+ algorithm,
89
+ encryptionVersion,
90
+ keyId,
91
+ dataIv,
92
+ wrappedKey
93
+ } = metadata;
94
+
95
+ if (
96
+ typeof algorithm !== 'string' ||
97
+ typeof encryptionVersion !== 'string' ||
98
+ typeof keyId !== 'string' ||
99
+ typeof dataIv !== 'string' ||
100
+ typeof wrappedKey !== 'string'
101
+ ) {
102
+ return null;
103
+ }
104
+
105
+ return {
106
+ algorithm,
107
+ encryptionVersion,
108
+ keyId,
109
+ dataIv,
110
+ wrappedKey
111
+ };
112
+ }
113
+
114
+ function hasDataAtRestMetadata(metadata: Record<string, string> | undefined): boolean {
115
+ if (!metadata) {
116
+ return false;
117
+ }
118
+
119
+ return (
120
+ typeof metadata.algorithm === 'string' &&
121
+ typeof metadata.encryptionVersion === 'string' &&
122
+ typeof metadata.keyId === 'string' &&
123
+ typeof metadata.dataIv === 'string' &&
124
+ typeof metadata.wrappedKey === 'string'
125
+ );
126
+ }
127
+
128
+ function clampBackfillBatchSize(size: number | undefined): number {
129
+ if (typeof size !== 'number' || !Number.isFinite(size)) {
130
+ return 100;
131
+ }
132
+
133
+ const normalized = Math.floor(size);
134
+ if (normalized < 1) {
135
+ return 1;
136
+ }
137
+
138
+ if (normalized > 1000) {
139
+ return 1000;
140
+ }
141
+
142
+ return normalized;
143
+ }
144
+
145
+ async function handleDataAtRestBackfill(request: Request, env: Env): Promise<Response> {
146
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
147
+ return createResponse(
148
+ { error: 'Data-at-rest encryption is not configured for backfill writes' },
149
+ 400
150
+ );
151
+ }
152
+
153
+ const requestBody = await request.json().catch(() => ({})) as {
154
+ dryRun?: boolean;
155
+ prefix?: string;
156
+ cursor?: string;
157
+ batchSize?: number;
158
+ };
159
+
160
+ const dryRun = requestBody.dryRun === true;
161
+ const prefix = typeof requestBody.prefix === 'string' ? requestBody.prefix : '';
162
+ const cursor = typeof requestBody.cursor === 'string' && requestBody.cursor.length > 0
163
+ ? requestBody.cursor
164
+ : undefined;
165
+ const batchSize = clampBackfillBatchSize(requestBody.batchSize);
166
+
167
+ const bucket = env.STRIAE_DATA;
168
+ const listed = await bucket.list({
169
+ prefix: prefix.length > 0 ? prefix : undefined,
170
+ cursor,
171
+ limit: batchSize
172
+ });
173
+
174
+ let scanned = 0;
175
+ let eligible = 0;
176
+ let encrypted = 0;
177
+ let skippedEncrypted = 0;
178
+ let skippedNonJson = 0;
179
+ let failed = 0;
180
+ const failures: Array<{ key: string; error: string }> = [];
181
+
182
+ for (const object of listed.objects) {
183
+ scanned += 1;
184
+ const key = object.key;
185
+
186
+ if (!key.endsWith('.json')) {
187
+ skippedNonJson += 1;
188
+ continue;
189
+ }
190
+
191
+ const objectHead = await bucket.head(key);
192
+ if (!objectHead) {
193
+ failed += 1;
194
+ if (failures.length < 20) {
195
+ failures.push({ key, error: 'Object not found during metadata check' });
196
+ }
197
+ continue;
198
+ }
199
+
200
+ if (hasDataAtRestMetadata(objectHead.customMetadata)) {
201
+ skippedEncrypted += 1;
202
+ continue;
203
+ }
204
+
205
+ eligible += 1;
206
+
207
+ if (dryRun) {
208
+ continue;
209
+ }
210
+
211
+ try {
212
+ const existingObject = await bucket.get(key);
213
+ if (!existingObject) {
214
+ failed += 1;
215
+ if (failures.length < 20) {
216
+ failures.push({ key, error: 'Object disappeared before processing' });
217
+ }
218
+ continue;
219
+ }
220
+
221
+ const plaintext = await existingObject.text();
222
+ const encryptedPayload = await encryptJsonForStorage(
223
+ plaintext,
224
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
225
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
226
+ );
227
+
228
+ await bucket.put(key, encryptedPayload.ciphertext, {
229
+ customMetadata: {
230
+ algorithm: encryptedPayload.envelope.algorithm,
231
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
232
+ keyId: encryptedPayload.envelope.keyId,
233
+ dataIv: encryptedPayload.envelope.dataIv,
234
+ wrappedKey: encryptedPayload.envelope.wrappedKey
235
+ }
236
+ });
237
+
238
+ encrypted += 1;
239
+ } catch (error) {
240
+ failed += 1;
241
+ if (failures.length < 20) {
242
+ const errorMessage = error instanceof Error ? error.message : 'Unknown backfill failure';
243
+ failures.push({ key, error: errorMessage });
244
+ }
245
+ }
246
+ }
247
+
248
+ return createResponse({
249
+ success: failed === 0,
250
+ dryRun,
251
+ prefix: prefix.length > 0 ? prefix : null,
252
+ batchSize,
253
+ scanned,
254
+ eligible,
255
+ encrypted,
256
+ skippedEncrypted,
257
+ skippedNonJson,
258
+ failed,
259
+ failures,
260
+ hasMore: listed.truncated,
261
+ nextCursor: listed.truncated ? listed.cursor : null
262
+ });
263
+ }
53
264
 
54
265
  async function signPayloadWithWorkerKey(payload: string, env: Env): Promise<{
55
266
  algorithm: string;
@@ -196,6 +407,128 @@ async function handleSignAuditExport(request: Request, env: Env): Promise<Respon
196
407
  }
197
408
  }
198
409
 
410
+ async function handleDecryptExport(request: Request, env: Env): Promise<Response> {
411
+ 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
+ const requestBody = await request.json() as {
421
+ wrappedKey?: string;
422
+ dataIv?: string;
423
+ encryptedData?: string;
424
+ encryptedImages?: Array<{ filename: string; encryptedData: string; iv?: string }>;
425
+ keyId?: string;
426
+ };
427
+
428
+ const { wrappedKey, dataIv, encryptedData, encryptedImages, keyId } = requestBody;
429
+
430
+ // Validate required fields
431
+ if (
432
+ !wrappedKey ||
433
+ typeof wrappedKey !== 'string' ||
434
+ !dataIv ||
435
+ typeof dataIv !== 'string' ||
436
+ !encryptedData ||
437
+ typeof encryptedData !== 'string' ||
438
+ !keyId ||
439
+ typeof keyId !== 'string'
440
+ ) {
441
+ return createResponse(
442
+ { error: 'Missing or invalid required fields: wrappedKey, dataIv, encryptedData, keyId' },
443
+ 400
444
+ );
445
+ }
446
+
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
+ }
454
+
455
+ // Decrypt data file
456
+ let plaintextData: string;
457
+ try {
458
+ plaintextData = await decryptExportData(
459
+ encryptedData,
460
+ wrappedKey,
461
+ dataIv,
462
+ env.EXPORT_ENCRYPTION_PRIVATE_KEY
463
+ );
464
+ } catch (error) {
465
+ console.error('Data file decryption failed:', error);
466
+ const errorMessage = error instanceof Error ? error.message : 'Decryption failed';
467
+ return createResponse(
468
+ { error: `Failed to decrypt data file: ${errorMessage}` },
469
+ 500
470
+ );
471
+ }
472
+
473
+ // Decrypt images if provided
474
+ const decryptedImages: Array<{ filename: string; data: string }> = [];
475
+ if (Array.isArray(encryptedImages) && encryptedImages.length > 0) {
476
+ for (const imageEntry of encryptedImages) {
477
+ try {
478
+ if (!imageEntry.iv || typeof imageEntry.iv !== 'string') {
479
+ return createResponse(
480
+ { error: `Missing IV for image ${imageEntry.filename}` },
481
+ 400
482
+ );
483
+ }
484
+
485
+ const imageBlob = await decryptImageBlob(
486
+ imageEntry.encryptedData,
487
+ wrappedKey,
488
+ imageEntry.iv,
489
+ env.EXPORT_ENCRYPTION_PRIVATE_KEY
490
+ );
491
+
492
+ // Convert blob to base64 for transport
493
+ const arrayBuffer = await imageBlob.arrayBuffer();
494
+ const bytes = new Uint8Array(arrayBuffer);
495
+ const chunkSize = 8192;
496
+ let binary = '';
497
+ for (let i = 0; i < bytes.length; i += chunkSize) {
498
+ const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
499
+ for (let j = 0; j < chunk.length; j++) {
500
+ binary += String.fromCharCode(chunk[j]);
501
+ }
502
+ }
503
+ const base64Data = btoa(binary);
504
+
505
+ decryptedImages.push({
506
+ filename: imageEntry.filename,
507
+ data: base64Data
508
+ });
509
+ } catch (error) {
510
+ console.error(`Image decryption failed for ${imageEntry.filename}:`, error);
511
+ const errorMessage = error instanceof Error ? error.message : 'Decryption failed';
512
+ return createResponse(
513
+ { error: `Failed to decrypt image ${imageEntry.filename}: ${errorMessage}` },
514
+ 500
515
+ );
516
+ }
517
+ }
518
+ }
519
+
520
+ return createResponse({
521
+ success: true,
522
+ plaintext: plaintextData,
523
+ decryptedImages
524
+ });
525
+ } catch (error) {
526
+ console.error('Export decryption request failed:', error);
527
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
528
+ return createResponse({ error: errorMessage }, 500);
529
+ }
530
+ }
531
+
199
532
  export default {
200
533
  async fetch(request: Request, env: Env): Promise<Response> {
201
534
  if (request.method === 'OPTIONS') {
@@ -223,6 +556,14 @@ export default {
223
556
  return await handleSignAuditExport(request, env);
224
557
  }
225
558
 
559
+ if (request.method === 'POST' && pathname === DECRYPT_EXPORT_PATH) {
560
+ return await handleDecryptExport(request, env);
561
+ }
562
+
563
+ if (request.method === 'POST' && pathname === DATA_AT_REST_BACKFILL_PATH) {
564
+ return await handleDataAtRestBackfill(request, env);
565
+ }
566
+
226
567
  const filename = pathname.slice(1) || 'data.json';
227
568
 
228
569
  if (!filename.endsWith('.json')) {
@@ -235,6 +576,39 @@ export default {
235
576
  if (!file) {
236
577
  return createResponse([], 200);
237
578
  }
579
+
580
+ const atRestEnvelope = extractDataAtRestEnvelope(file);
581
+ if (atRestEnvelope) {
582
+ if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
583
+ return createResponse({ error: 'Unsupported data-at-rest encryption algorithm' }, 500);
584
+ }
585
+
586
+ if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
587
+ return createResponse({ error: 'Unsupported data-at-rest encryption version' }, 500);
588
+ }
589
+
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
+ try {
598
+ const encryptedData = await file.arrayBuffer();
599
+ const plaintext = await decryptJsonFromStorage(
600
+ encryptedData,
601
+ atRestEnvelope,
602
+ env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
603
+ );
604
+ const decryptedPayload = JSON.parse(plaintext);
605
+ return createResponse(decryptedPayload);
606
+ } catch (error) {
607
+ console.error('Data-at-rest decryption failed:', error);
608
+ return createResponse({ error: 'Failed to decrypt stored data' }, 500);
609
+ }
610
+ }
611
+
238
612
  const fileText = await file.text();
239
613
  const data = JSON.parse(fileText);
240
614
  return createResponse(data);
@@ -242,7 +616,41 @@ export default {
242
616
 
243
617
  case 'PUT': {
244
618
  const newData = await request.json();
245
- await bucket.put(filename, JSON.stringify(newData));
619
+ const serializedData = JSON.stringify(newData);
620
+
621
+ if (!isDataAtRestEncryptionEnabled(env)) {
622
+ await bucket.put(filename, serializedData);
623
+ return createResponse({ success: true });
624
+ }
625
+
626
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
627
+ return createResponse(
628
+ { error: 'Data-at-rest encryption is enabled but not fully configured' },
629
+ 500
630
+ );
631
+ }
632
+
633
+ try {
634
+ const encryptedPayload = await encryptJsonForStorage(
635
+ serializedData,
636
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
637
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
638
+ );
639
+
640
+ await bucket.put(filename, encryptedPayload.ciphertext, {
641
+ customMetadata: {
642
+ algorithm: encryptedPayload.envelope.algorithm,
643
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
644
+ keyId: encryptedPayload.envelope.keyId,
645
+ dataIv: encryptedPayload.envelope.dataIv,
646
+ wrappedKey: encryptedPayload.envelope.wrappedKey
647
+ }
648
+ });
649
+ } catch (error) {
650
+ console.error('Data-at-rest encryption failed:', error);
651
+ return createResponse({ error: 'Failed to encrypt data for storage' }, 500);
652
+ }
653
+
246
654
  return createResponse({ success: true });
247
655
  }
248
656