@striae-org/striae 4.3.4 → 5.0.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 (42) hide show
  1. package/.env.example +4 -0
  2. package/app/components/actions/case-export/download-handlers.ts +60 -4
  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 +64 -4
  9. package/app/components/actions/confirm-export.ts +32 -3
  10. package/app/components/navbar/navbar.module.css +0 -10
  11. package/app/components/navbar/navbar.tsx +0 -22
  12. package/app/components/sidebar/case-import/case-import.module.css +7 -131
  13. package/app/components/sidebar/case-import/case-import.tsx +7 -14
  14. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
  15. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
  16. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
  17. package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
  18. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
  19. package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
  20. package/app/config-example/config.json +5 -0
  21. package/app/routes/auth/login.tsx +1 -1
  22. package/app/utils/data/operations/signing-operations.ts +93 -0
  23. package/app/utils/data/operations/types.ts +6 -0
  24. package/app/utils/forensics/export-encryption.ts +316 -0
  25. package/app/utils/forensics/export-verification.ts +1 -409
  26. package/app/utils/forensics/index.ts +1 -0
  27. package/app/utils/ui/case-messages.ts +5 -2
  28. package/package.json +1 -1
  29. package/scripts/deploy-config.sh +97 -3
  30. package/scripts/deploy-worker-secrets.sh +1 -1
  31. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  32. package/workers/data-worker/src/data-worker.example.ts +130 -0
  33. package/workers/data-worker/src/encryption-utils.ts +125 -0
  34. package/workers/data-worker/worker-configuration.d.ts +1 -1
  35. package/workers/data-worker/wrangler.jsonc.example +2 -2
  36. package/workers/image-worker/wrangler.jsonc.example +1 -1
  37. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  38. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  39. package/workers/user-worker/wrangler.jsonc.example +1 -1
  40. package/wrangler.toml.example +1 -1
  41. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
  42. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
@@ -2,21 +2,185 @@ import type { User } from 'firebase/auth';
2
2
  import { checkUserExistsApi } from '~/utils/api';
3
3
  import { type CaseExportData, type ConfirmationImportData } from '~/types';
4
4
  import { type ManifestSignatureVerificationResult, verifyConfirmationSignature } from '~/utils/forensics';
5
+ import { checkExistingCase } from '../case-manage';
5
6
  export { removeForensicWarning, validateConfirmationHash } from '~/utils/forensics';
6
7
 
8
+ const REDACTED_UID_VALUES = new Set([
9
+ '[user info excluded]',
10
+ 'n/a',
11
+ 'na',
12
+ 'unknown',
13
+ 'null',
14
+ 'undefined'
15
+ ]);
16
+
17
+ function toNonEmptyString(value: unknown): string | null {
18
+ if (typeof value !== 'string') {
19
+ return null;
20
+ }
21
+
22
+ const trimmed = value.trim();
23
+ return trimmed.length > 0 ? trimmed : null;
24
+ }
25
+
26
+ function normalizeExporterUid(value: unknown): string | null {
27
+ const candidate = toNonEmptyString(value);
28
+ if (!candidate) {
29
+ return null;
30
+ }
31
+
32
+ if (REDACTED_UID_VALUES.has(candidate.toLowerCase())) {
33
+ return null;
34
+ }
35
+
36
+ return candidate;
37
+ }
38
+
39
+ function resolveExporterUid(caseData: CaseExportData, parsedData: unknown): string | null {
40
+ const root = (parsedData && typeof parsedData === 'object')
41
+ ? (parsedData as Record<string, unknown>)
42
+ : {};
43
+ const metadata = (root.metadata && typeof root.metadata === 'object')
44
+ ? (root.metadata as Record<string, unknown>)
45
+ : {};
46
+
47
+ const candidates: unknown[] = [
48
+ caseData.metadata.exportedByUid,
49
+ caseData.metadata.archivedBy,
50
+ metadata.exportedByUid,
51
+ metadata.exportedByUID,
52
+ metadata.exporterUid,
53
+ metadata.exporterUID,
54
+ metadata.archivedBy,
55
+ metadata.archivedByUid,
56
+ metadata.archivedByUID,
57
+ metadata.userUid,
58
+ metadata.userUID,
59
+ root.exportedByUid,
60
+ root.exportedByUID,
61
+ root.exporterUid,
62
+ root.exporterUID,
63
+ root.archivedBy,
64
+ root.archivedByUid,
65
+ root.archivedByUID,
66
+ root.userUid,
67
+ root.userUID
68
+ ];
69
+
70
+ for (const candidate of candidates) {
71
+ const resolved = normalizeExporterUid(candidate);
72
+ if (resolved) {
73
+ return resolved;
74
+ }
75
+ }
76
+
77
+ return null;
78
+ }
79
+
7
80
  /**
8
81
  * Validate that a user exists in the database by UID and is not the current user
9
82
  */
10
83
  export async function validateExporterUid(exporterUid: string, currentUser: User): Promise<{ exists: boolean; isSelf: boolean }> {
84
+ const exists = await checkUserExistsApi(currentUser, exporterUid);
85
+ const isSelf = exporterUid === currentUser.uid;
86
+
87
+ return { exists, isSelf };
88
+ }
89
+
90
+ export function isArchivedExportData(parsedData: unknown): boolean {
91
+ if (!parsedData || typeof parsedData !== 'object') {
92
+ return false;
93
+ }
94
+
95
+ const root = parsedData as Record<string, unknown>;
96
+
97
+ if (root.archived === true) {
98
+ return true;
99
+ }
100
+
101
+ if (typeof root.archivedAt === 'string' && root.archivedAt.trim().length > 0) {
102
+ return true;
103
+ }
104
+
105
+ const metadata = root.metadata;
106
+ if (!metadata || typeof metadata !== 'object') {
107
+ return false;
108
+ }
109
+
110
+ const metadataRecord = metadata as Record<string, unknown>;
111
+
112
+ if (metadataRecord.archived === true) {
113
+ return true;
114
+ }
115
+
116
+ if (typeof metadataRecord.archivedAt === 'string' && metadataRecord.archivedAt.trim().length > 0) {
117
+ return true;
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ export async function validateCaseExporterUidForImport(
124
+ caseData: CaseExportData,
125
+ currentUser: User,
126
+ parsedData: unknown = caseData
127
+ ): Promise<{
128
+ exists: boolean;
129
+ isSelf: boolean;
130
+ isArchivedExport: boolean;
131
+ allowArchivedSelfImport: boolean;
132
+ }> {
133
+ const isArchivedExport = isArchivedExportData(parsedData);
134
+ const exportedByUid = resolveExporterUid(caseData, parsedData);
135
+
136
+ if (!exportedByUid) {
137
+ if (isArchivedExport) {
138
+ // Some legacy or privacy-sanitized archived exports may not retain exporter UID fields.
139
+ // Archived import safety is still enforced by integrity/signature checks and regular-case conflict checks.
140
+ return {
141
+ exists: true,
142
+ isSelf: false,
143
+ isArchivedExport: true,
144
+ allowArchivedSelfImport: true
145
+ };
146
+ }
147
+
148
+ throw new Error(
149
+ 'Case export is missing usable exporter UID information. This case cannot be imported.'
150
+ );
151
+ }
152
+
153
+ let validation: { exists: boolean; isSelf: boolean };
11
154
  try {
12
- const exists = await checkUserExistsApi(currentUser, exporterUid);
13
- const isSelf = exporterUid === currentUser.uid;
14
-
15
- return { exists, isSelf };
16
- } catch (error) {
17
- console.error('Error validating exporter UID:', error);
18
- return { exists: false, isSelf: false };
155
+ validation = await validateExporterUid(exportedByUid, currentUser);
156
+ } catch {
157
+ throw new Error(
158
+ 'Unable to validate exporter identity right now. Please retry the import.'
159
+ );
19
160
  }
161
+
162
+ if (!validation.exists) {
163
+ throw new Error('The original exporter is not a valid Striae user. This case cannot be imported.');
164
+ }
165
+
166
+ let allowArchivedSelfImport = false;
167
+
168
+ if (isArchivedExport) {
169
+ const existingRegularCase = await checkExistingCase(currentUser, caseData.metadata.caseNumber);
170
+ allowArchivedSelfImport = existingRegularCase === null;
171
+ }
172
+
173
+ if (validation.isSelf && !allowArchivedSelfImport) {
174
+ throw new Error(
175
+ 'You cannot import a case that you originally exported unless it is an archived case that has already been deleted from your regular case list.'
176
+ );
177
+ }
178
+
179
+ return {
180
+ ...validation,
181
+ isArchivedExport,
182
+ allowArchivedSelfImport
183
+ };
20
184
  }
21
185
 
22
186
  /**
@@ -1,62 +1,15 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { type CaseExportData, type CaseImportPreview } from '~/types';
3
- import { getCaseData } from '~/utils/data';
4
3
  import { validateCaseNumber } from '../case-manage';
5
4
  import {
6
5
  type SignedForensicManifest,
7
6
  verifyCasePackageIntegrity
8
7
  } from '~/utils/forensics';
9
- import { validateExporterUid, removeForensicWarning } from './validation';
10
-
11
- function isArchivedExportData(parsedData: unknown): boolean {
12
- if (!parsedData || typeof parsedData !== 'object') {
13
- return false;
14
- }
15
-
16
- const root = parsedData as Record<string, unknown>;
17
-
18
- if (root.archived === true) {
19
- return true;
20
- }
21
-
22
- if (typeof root.archivedAt === 'string' && root.archivedAt.trim().length > 0) {
23
- return true;
24
- }
25
-
26
- const metadata = root.metadata;
27
- if (!metadata || typeof metadata !== 'object') {
28
- return false;
29
- }
30
-
31
- const metadataRecord = metadata as Record<string, unknown>;
32
-
33
- if (metadataRecord.archived === true) {
34
- return true;
35
- }
36
-
37
- if (typeof metadataRecord.archivedAt === 'string' && metadataRecord.archivedAt.trim().length > 0) {
38
- return true;
39
- }
40
-
41
- return false;
42
- }
43
-
44
- async function allowSelfImportForArchivedCase(
45
- currentUser: User,
46
- caseNumber: string,
47
- parsedData: unknown
48
- ): Promise<boolean> {
49
- if (isArchivedExportData(parsedData)) {
50
- return true;
51
- }
52
-
53
- try {
54
- const existingCase = await getCaseData(currentUser, caseNumber);
55
- return existingCase?.archived === true;
56
- } catch {
57
- return false;
58
- }
59
- }
8
+ import {
9
+ isArchivedExportData,
10
+ removeForensicWarning,
11
+ validateCaseExporterUidForImport
12
+ } from './validation';
60
13
 
61
14
  function getLeafFileName(path: string): string {
62
15
  const segments = path.split('/').filter(Boolean);
@@ -93,6 +46,27 @@ async function extractVerificationPublicKeyFromZip(
93
46
  return zip.file(preferredPemPath)?.async('text');
94
47
  }
95
48
 
49
+ /**
50
+ * Safe conversion of Uint8Array to base64url without spread operator stack overflow
51
+ * For large arrays, uses chunking approach to avoid "Maximum call stack size exceeded"
52
+ */
53
+ function uint8ArrayToBase64Url(data: Uint8Array): string {
54
+ const chunkSize = 8192;
55
+ let binaryString = '';
56
+
57
+ for (let i = 0; i < data.length; i += chunkSize) {
58
+ const chunk = data.subarray(i, Math.min(i + chunkSize, data.length));
59
+ for (let j = 0; j < chunk.length; j += 1) {
60
+ binaryString += String.fromCharCode(chunk[j]);
61
+ }
62
+ }
63
+
64
+ return btoa(binaryString)
65
+ .replace(/\+/g, '-')
66
+ .replace(/\//g, '_')
67
+ .replace(/=+$/g, '');
68
+ }
69
+
96
70
  /**
97
71
  * Extract original image ID from export filename format
98
72
  * Format: {originalFilename}-{id}.{extension}
@@ -139,6 +113,57 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
139
113
  const zip = await JSZip.loadAsync(zipFile);
140
114
  const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
141
115
 
116
+ // Check if export is encrypted
117
+ const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
118
+ if (encryptionManifestFile) {
119
+ // For encrypted exports, we can't read the plaintext data to extract case info
120
+ // Return an encrypted preview that requires decryption during import
121
+ try {
122
+ const manifestContent = await encryptionManifestFile.async('text');
123
+ JSON.parse(manifestContent); // Validate it's valid JSON
124
+
125
+ // Count image files
126
+ let totalFiles = 0;
127
+ const imagesFolder = zip.folder('images');
128
+ if (imagesFolder) {
129
+ for (const [, file] of Object.entries(imagesFolder.files)) {
130
+ if (!file.dir && file.name.includes('/')) {
131
+ totalFiles++;
132
+ }
133
+ }
134
+ }
135
+
136
+ const hasForensicManifest = zip.file('FORENSIC_MANIFEST.json') !== null;
137
+
138
+ return {
139
+ caseNumber: 'ENCRYPTED',
140
+ archived: false,
141
+ exportedBy: null,
142
+ exportedByName: null,
143
+ exportedByCompany: null,
144
+ exportedByBadgeId: null,
145
+ exportDate: new Date().toISOString(),
146
+ totalFiles,
147
+ hasAnnotations: false,
148
+ validationSummary: 'Export is encrypted. Integrity validation will occur during import.',
149
+ hashValid: undefined,
150
+ hashError: undefined,
151
+ validationDetails: {
152
+ hasForensicManifest,
153
+ dataValid: undefined,
154
+ manifestValid: undefined,
155
+ signatureValid: undefined,
156
+ validationSummary: 'Encrypted export — integrity validation deferred to import stage',
157
+ integrityErrors: []
158
+ }
159
+ };
160
+ } catch (error) {
161
+ throw new Error(
162
+ `Encrypted export detected but encryption manifest is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`
163
+ );
164
+ }
165
+ }
166
+
142
167
  // First, validate hash if forensic metadata exists
143
168
  let hashValid: boolean | undefined = undefined;
144
169
  let hashError: string | undefined = undefined;
@@ -295,26 +320,9 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
295
320
  throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
296
321
  }
297
322
 
298
- const isArchivedExport = await allowSelfImportForArchivedCase(
299
- currentUser,
300
- caseData.metadata.caseNumber,
301
- parsedCaseData
302
- );
323
+ const isArchivedExport = isArchivedExportData(parsedCaseData);
303
324
 
304
- // Validate exporter UID exists in user database and is not current user
305
- if (caseData.metadata.exportedByUid) {
306
- const validation = await validateExporterUid(caseData.metadata.exportedByUid, currentUser);
307
-
308
- if (!validation.exists) {
309
- throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
310
- }
311
-
312
- if (validation.isSelf && !isArchivedExport) {
313
- throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
314
- }
315
- } else {
316
- throw new Error('Case export missing exporter UID information. This case cannot be imported.');
317
- }
325
+ await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
318
326
 
319
327
  // Count image files
320
328
  let totalFiles = 0;
@@ -365,6 +373,10 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
365
373
  metadata?: Record<string, unknown>;
366
374
  cleanedContent?: string; // Add cleaned content for hash validation
367
375
  verificationPublicKeyPem?: string;
376
+ encryptionManifest?: Record<string, unknown>; // Optional: decryption metadata
377
+ encryptedDataBase64?: string; // Optional: encrypted data file content (base64url)
378
+ encryptedImages?: { [filename: string]: string }; // Optional: encrypted image files (filename -> base64url)
379
+ isEncrypted?: boolean;
368
380
  }> {
369
381
  // Dynamic import of JSZip to avoid bundle size issues
370
382
  const JSZip = (await import('jszip')).default;
@@ -389,82 +401,163 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
389
401
  const dataFileName = dataFiles[0];
390
402
  const isJsonFormat = dataFileName.endsWith('.json');
391
403
 
392
- // Extract and parse case data
404
+ // Check for encryption manifest first
405
+ const encryptionManifestFile = zip.file('ENCRYPTION_MANIFEST.json');
406
+ let encryptionManifest: Record<string, unknown> | undefined;
407
+ let encryptedDataBase64: string | undefined;
408
+ const encryptedImages: { [filename: string]: string } = {};
409
+ let isEncrypted = false;
410
+
411
+ // Initialize variables before if-else to ensure scope
393
412
  let caseData: CaseExportData;
394
413
  let parsedCaseData: unknown;
395
414
  let cleanedContent: string = '';
396
- if (isJsonFormat) {
397
- const dataContent = await zip.file(dataFileName)?.async('text');
398
- if (!dataContent) {
399
- throw new Error('Failed to read data file from ZIP');
415
+
416
+ if (encryptionManifestFile) {
417
+ try {
418
+ const manifestContent = await encryptionManifestFile.async('text');
419
+ encryptionManifest = JSON.parse(manifestContent) as Record<string, unknown>;
420
+ isEncrypted = true;
421
+
422
+ // Extract the encrypted data file
423
+ const dataContent = await zip.file(dataFileName)?.async('uint8array');
424
+ if (!dataContent) {
425
+ throw new Error('Failed to read encrypted data file from ZIP');
426
+ }
427
+ // Convert to base64url for transmission to worker (chunked to avoid stack overflow)
428
+ encryptedDataBase64 = uint8ArrayToBase64Url(dataContent);
429
+
430
+ // Extract encrypted files referenced by encrypted export payloads
431
+ const encryptedImagePromises: Promise<[string, string]>[] = [];
432
+
433
+ const fileList = Object.keys(zip.files);
434
+ for (const filePath of fileList) {
435
+ const isImageFile = filePath.startsWith('images/') && filePath !== 'images/';
436
+ const isBundledAuditFile =
437
+ filePath === 'audit/case-audit-trail.json' ||
438
+ filePath === 'audit/case-audit-signature.json';
439
+
440
+ if ((!isImageFile && !isBundledAuditFile) || filePath.endsWith('/')) {
441
+ continue;
442
+ }
443
+
444
+ const file = zip.files[filePath];
445
+ if (!file || file.dir) {
446
+ continue;
447
+ }
448
+
449
+ const filename = isImageFile ? filePath.replace(/^images\//, '') : filePath;
450
+
451
+ encryptedImagePromises.push((async () => {
452
+ try {
453
+ const encryptedBlob = await file.async('uint8array');
454
+ // Convert to base64url (chunked to avoid stack overflow)
455
+ const encryptedBase64Url = uint8ArrayToBase64Url(encryptedBlob);
456
+ return [filename, encryptedBase64Url] as [string, string];
457
+ } catch (err) {
458
+ throw new Error(`Failed to extract encrypted image ${filename}: ${err instanceof Error ? err.message : 'Unknown error'}`);
459
+ }
460
+ })());
461
+ }
462
+
463
+ // Wait for all image conversions
464
+ const encryptedImageResults = await Promise.all(encryptedImagePromises);
465
+ for (const [filename, data] of encryptedImageResults) {
466
+ encryptedImages[filename] = data;
467
+ }
468
+
469
+ // For encrypted exports, data file will be processed after decryption
470
+ // Set placeholder values that will be replaced after decryption
471
+ caseData = { metadata: { caseNumber: 'ENCRYPTED' } } as CaseExportData;
472
+ parsedCaseData = caseData;
473
+ cleanedContent = '';
474
+
475
+ } catch (error) {
476
+ throw new Error(`Failed to process encrypted export: ${error instanceof Error ? error.message : 'Unknown error'}`);
400
477
  }
401
-
402
- // Handle forensic protection warnings in JSON
403
- cleanedContent = removeForensicWarning(dataContent);
404
- parsedCaseData = JSON.parse(cleanedContent) as unknown;
405
- caseData = parsedCaseData as CaseExportData;
406
478
  } else {
407
- throw new Error('CSV import not yet supported. Please use JSON format.');
408
- }
409
-
410
- // Validate case data structure
411
- if (!caseData.metadata?.caseNumber) {
412
- throw new Error('Invalid case data: missing case number');
479
+ // Standard unencrypted extract and parse case data
480
+ if (isJsonFormat) {
481
+ const dataContent = await zip.file(dataFileName)?.async('text');
482
+ if (!dataContent) {
483
+ throw new Error('Failed to read data file from ZIP');
484
+ }
485
+
486
+ // Handle forensic protection warnings in JSON
487
+ cleanedContent = removeForensicWarning(dataContent);
488
+ parsedCaseData = JSON.parse(cleanedContent) as unknown;
489
+ caseData = parsedCaseData as CaseExportData;
490
+ } else {
491
+ throw new Error('CSV import not yet supported. Please use JSON format.');
492
+ }
413
493
  }
414
494
 
415
- if (!validateCaseNumber(caseData.metadata.caseNumber)) {
416
- throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
495
+ // Validate case data structure only for unencrypted exports
496
+ // (encrypted exports will be validated after decryption in orchestrator)
497
+ if (!isEncrypted) {
498
+ if (!caseData.metadata?.caseNumber) {
499
+ throw new Error('Invalid case data: missing case number');
500
+ }
501
+
502
+ if (!validateCaseNumber(caseData.metadata.caseNumber)) {
503
+ throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
504
+ }
417
505
  }
418
506
 
419
- const isArchivedExport = await allowSelfImportForArchivedCase(
420
- currentUser,
421
- caseData.metadata.caseNumber,
422
- parsedCaseData
423
- );
507
+ const isArchivedExport = isArchivedExportData(parsedCaseData);
424
508
 
425
- // Validate exporter UID exists in user database and is not current user
426
- if (caseData.metadata.exportedByUid) {
427
- const validation = await validateExporterUid(caseData.metadata.exportedByUid, currentUser);
428
-
429
- if (!validation.exists) {
430
- throw new Error(`The original exporter is not a valid Striae user. This case cannot be imported.`);
431
- }
432
-
433
- if (validation.isSelf && !isArchivedExport) {
434
- throw new Error(`You cannot import a case that you originally exported. Original analysts cannot review their own cases.`);
435
- }
436
- } else {
437
- throw new Error('Case export missing exporter UID information. This case cannot be imported.');
509
+ // Validate exporter UID exists in user database and is not current user (skip for encrypted)
510
+ if (!isEncrypted) {
511
+ await validateCaseExporterUidForImport(caseData, currentUser, parsedCaseData);
438
512
  }
439
513
 
440
- // Extract image files and create ID mapping
514
+ // Extract image files and create ID mapping - iterate through zip.files directly
441
515
  const imageFiles: { [filename: string]: Blob } = {};
442
516
  const imageIdMapping: { [exportFilename: string]: string } = {};
443
- const imagesFolder = zip.folder('images');
444
517
 
445
- if (imagesFolder) {
446
- for (const [path, file] of Object.entries(imagesFolder.files)) {
447
- if (!path.startsWith('images/') || path.endsWith('/') || file.dir) {
448
- continue;
449
- }
450
-
451
- const exportFilename = path.replace('images/', '');
452
- const blob = await file.async('blob');
453
- imageFiles[exportFilename] = blob;
454
-
455
- // Extract original image ID from filename
456
- const originalImageId = extractImageIdFromFilename(exportFilename);
457
- if (originalImageId) {
458
- imageIdMapping[exportFilename] = originalImageId;
459
- }
518
+ const imageExtractionPromises: Promise<void>[] = [];
519
+
520
+ const fileListForImages = Object.keys(zip.files);
521
+ for (const filePath of fileListForImages) {
522
+ // Only process files in the images folder
523
+ if (!filePath.startsWith('images/') || filePath === 'images/' || filePath.endsWith('/')) {
524
+ continue;
460
525
  }
526
+
527
+ const file = zip.files[filePath];
528
+ if (!file || file.dir) {
529
+ continue;
530
+ }
531
+
532
+ imageExtractionPromises.push((async () => {
533
+ try {
534
+ const exportFilename = filePath.replace(/^images\//, '');
535
+ const blob = await file.async('blob');
536
+ imageFiles[exportFilename] = blob;
537
+
538
+ // Extract original image ID from filename
539
+ const originalImageId = extractImageIdFromFilename(exportFilename);
540
+ if (originalImageId) {
541
+ imageIdMapping[exportFilename] = originalImageId;
542
+ }
543
+ } catch (err) {
544
+ console.error(`Failed to extract image ${filePath}:`, err);
545
+ }
546
+ })());
461
547
  }
462
548
 
549
+ // Wait for all image extractions to complete
550
+ await Promise.all(imageExtractionPromises);
551
+
463
552
  // Extract forensic manifest if present
464
553
  let metadata: Record<string, unknown> | undefined;
465
554
  const manifestFile = zip.file('FORENSIC_MANIFEST.json');
466
- const auditTrailContent = await zip.file('audit/case-audit-trail.json')?.async('text');
467
- const auditSignatureContent = await zip.file('audit/case-audit-signature.json')?.async('text');
555
+ const auditTrailContent = isEncrypted
556
+ ? undefined
557
+ : await zip.file('audit/case-audit-trail.json')?.async('text');
558
+ const auditSignatureContent = isEncrypted
559
+ ? undefined
560
+ : await zip.file('audit/case-audit-signature.json')?.async('text');
468
561
 
469
562
  if (manifestFile) {
470
563
  const manifestContent = await manifestFile.async('text');
@@ -482,7 +575,11 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
482
575
  },
483
576
  metadata,
484
577
  cleanedContent,
485
- verificationPublicKeyPem
578
+ verificationPublicKeyPem,
579
+ encryptionManifest,
580
+ encryptedDataBase64,
581
+ encryptedImages: Object.keys(encryptedImages).length > 0 ? encryptedImages : undefined,
582
+ isEncrypted
486
583
  };
487
584
 
488
585
  } catch (error) {