@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
package/.env.example CHANGED
@@ -66,6 +66,10 @@ DATA_WORKER_DOMAIN=your_data_worker_domain_here
66
66
  MANIFEST_SIGNING_PRIVATE_KEY=your_manifest_signing_private_key_here
67
67
  MANIFEST_SIGNING_KEY_ID=your_manifest_signing_key_id_here
68
68
  MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
69
+ EXPORT_ENCRYPTION_PRIVATE_KEY=your_export_encryption_private_key_here
70
+ EXPORT_ENCRYPTION_KEY_ID=your_export_encryption_key_id_here
71
+ EXPORT_ENCRYPTION_PUBLIC_KEY=your_export_encryption_public_key_here
72
+
69
73
 
70
74
  # ================================
71
75
  # AUDIT WORKER ENVIRONMENT VARIABLES
@@ -7,7 +7,9 @@ import {
7
7
  calculateSHA256Secure,
8
8
  createPublicSigningKeyFileName,
9
9
  getCurrentPublicSigningKeyDetails,
10
- getVerificationPublicKey
10
+ getVerificationPublicKey,
11
+ getCurrentEncryptionPublicKeyDetails,
12
+ encryptExportDataWithAllImages
11
13
  } from '~/utils/forensics';
12
14
  import { signForensicManifest } from '~/utils/data';
13
15
  import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
@@ -717,6 +719,56 @@ export async function downloadCaseAsZip(
717
719
  // Add dedicated forensic manifest file for validation
718
720
  zip.file('FORENSIC_MANIFEST.json', JSON.stringify(signedForensicManifest, null, 2));
719
721
 
722
+ // Export encryption is mandatory
723
+ const encKeyDetails = getCurrentEncryptionPublicKeyDetails();
724
+
725
+ if (!encKeyDetails.publicKeyPem || !encKeyDetails.keyId) {
726
+ throw new Error(
727
+ 'Export encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
728
+ 'Please contact your administrator to set up export encryption.'
729
+ );
730
+ }
731
+
732
+ let encryptionManifestJson: string | null = null;
733
+ const isEncrypted = true;
734
+
735
+ try {
736
+ // Build image blobs array from the collected imageFiles
737
+ const imagesToEncrypt = Object.entries(imageFiles).map(([filename, blob]) => ({
738
+ filename,
739
+ blob
740
+ }));
741
+
742
+ // Encrypt data file and all images with shared AES key
743
+ const encryptionResult = await encryptExportDataWithAllImages(
744
+ contentForHash,
745
+ imagesToEncrypt,
746
+ encKeyDetails.publicKeyPem,
747
+ encKeyDetails.keyId
748
+ );
749
+
750
+ // Replace data file with encrypted ciphertext
751
+ zip.file(`${caseNumber}_data.${format}`, encryptionResult.ciphertext);
752
+
753
+ // Replace images in the ZIP with encrypted versions
754
+ if (imageFolder && encryptionResult.encryptedImages.length > 0) {
755
+ for (let i = 0; i < imagesToEncrypt.length; i++) {
756
+ const originalFilename = imagesToEncrypt[i].filename;
757
+ // Remove the original file and add encrypted version
758
+ imageFolder.file(originalFilename, encryptionResult.encryptedImages[i]);
759
+ }
760
+ }
761
+
762
+ // Add encryption manifest
763
+ encryptionManifestJson = JSON.stringify(encryptionResult.encryptionManifest, null, 2);
764
+ zip.file('ENCRYPTION_MANIFEST.json', encryptionManifestJson);
765
+
766
+ onProgress?.(80);
767
+ } catch (error) {
768
+ console.error('Export encryption failed:', error);
769
+ throw new Error(`Failed to encrypt export: ${error instanceof Error ? error.message : 'Unknown error'}`);
770
+ }
771
+
720
772
  // Add read-only instruction file
721
773
  const instructionContent = `EVIDENCE ARCHIVE - READ ONLY
722
774
 
@@ -727,11 +779,13 @@ IMPORTANT WARNINGS:
727
779
  - Do not modify, rename, or delete any files in this archive
728
780
  - Any modifications may compromise evidence integrity
729
781
  - Maintain proper chain of custody procedures
782
+ ${isEncrypted ? '- This archive is encrypted. Only Striae can decrypt and re-import it.\n' : ''}
730
783
 
731
784
  Archive Contents:
732
- - ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format
733
- - images/: Original image files with annotations
785
+ - ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format${isEncrypted ? ' (encrypted)' : ''}
786
+ - images/: Image files with annotations${isEncrypted ? ' (encrypted)' : ''}
734
787
  - FORENSIC_MANIFEST.json: File integrity validation manifest
788
+ ${isEncrypted ? '- ENCRYPTION_MANIFEST.json: Encryption metadata and encrypted file hashes\n' : ''}
735
789
  - ${publicKeyFileName}: Public signing key PEM for verification
736
790
  - README.txt: General information about this export
737
791
 
@@ -743,6 +797,7 @@ Case Information:
743
797
  - Total Annotations: ${(exportData.summary?.filesWithAnnotations || 0) + (exportData.summary?.totalBoxAnnotations || 0)}
744
798
  - Total Confirmations: ${exportData.summary?.filesWithConfirmations || 0}
745
799
  - Confirmations Requested: ${exportData.summary?.filesWithConfirmationsRequested || 0}
800
+ ${isEncrypted ? `- Encryption Status: ENCRYPTED (key ID: ${encKeyDetails.keyId})\n` : ''}
746
801
 
747
802
  For questions about this export, contact your Striae system administrator.
748
803
  `;
@@ -769,7 +824,8 @@ For questions about this export, contact your Striae system administrator.
769
824
  // Download
770
825
  const url = URL.createObjectURL(zipBlob);
771
826
  const protectionSuffix = options.protectForensicData ? '-protected' : '';
772
- const exportFileName = `striae-case-${caseNumber}-export${protectionSuffix}-${formatDateForFilename(new Date())}.zip`;
827
+ const encryptedSuffix = isEncrypted ? '-encrypted' : '';
828
+ const exportFileName = `striae-case-${caseNumber}-export${protectionSuffix}${encryptedSuffix}-${formatDateForFilename(new Date())}.zip`;
773
829
 
774
830
  const linkElement = document.createElement('a');
775
831
  linkElement.href = url;
@@ -1,7 +1,8 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { fetchDataApi } from '~/utils/api';
3
- import { upsertFileConfirmationSummary } from '~/utils/data';
3
+ import { upsertFileConfirmationSummary, decryptExportBatch } from '~/utils/data';
4
4
  import { type AnnotationData, type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
5
+ import type { EncryptionManifest } from '~/utils/forensics/export-encryption';
5
6
  import { checkExistingCase } from '../case-manage';
6
7
  import { extractConfirmationImportPackage } from './confirmation-package';
7
8
  import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
@@ -23,6 +24,21 @@ type AnnotationImportData = Record<string, unknown> & {
23
24
  updatedAt?: string;
24
25
  };
25
26
 
27
+ function isEncryptionManifest(value: unknown): value is EncryptionManifest {
28
+ if (!value || typeof value !== 'object') {
29
+ return false;
30
+ }
31
+
32
+ const candidate = value as Partial<EncryptionManifest>;
33
+ return (
34
+ typeof candidate.encryptionVersion === 'string' &&
35
+ typeof candidate.algorithm === 'string' &&
36
+ typeof candidate.keyId === 'string' &&
37
+ typeof candidate.wrappedKey === 'string' &&
38
+ typeof candidate.dataIv === 'string'
39
+ );
40
+ }
41
+
26
42
  /**
27
43
  * Import confirmation data from JSON file
28
44
  */
@@ -52,12 +68,39 @@ export async function importConfirmationData(
52
68
  try {
53
69
  onProgress?.('Reading confirmation file', 10, 'Loading confirmation package...');
54
70
 
55
- const {
56
- confirmationData,
57
- confirmationJsonContent,
58
- verificationPublicKeyPem,
59
- confirmationFileName
60
- } = await extractConfirmationImportPackage(confirmationFile);
71
+ const packageData = await extractConfirmationImportPackage(confirmationFile);
72
+
73
+ let confirmationData = packageData.confirmationData;
74
+ let confirmationJsonContent = packageData.confirmationJsonContent;
75
+ const verificationPublicKeyPem = packageData.verificationPublicKeyPem;
76
+ const confirmationFileName = packageData.confirmationFileName;
77
+
78
+ // Handle encrypted confirmation data
79
+ if (packageData.isEncrypted && packageData.encryptionManifest && packageData.encryptedDataBase64) {
80
+ onProgress?.('Decrypting confirmation data', 15, 'Decrypting exported confirmation...');
81
+
82
+ try {
83
+ if (!isEncryptionManifest(packageData.encryptionManifest)) {
84
+ throw new Error('Invalid encryption manifest format.');
85
+ }
86
+
87
+ const decryptResult = await decryptExportBatch(
88
+ user,
89
+ packageData.encryptionManifest,
90
+ packageData.encryptedDataBase64,
91
+ {} // No image hashes for confirmation-only exports
92
+ );
93
+
94
+ // Parse decrypted plaintext as confirmation data
95
+ const decryptedJsonString = decryptResult.plaintext;
96
+ confirmationData = JSON.parse(decryptedJsonString) as ConfirmationImportData;
97
+ confirmationJsonContent = decryptedJsonString;
98
+ } catch (error) {
99
+ throw new Error(
100
+ `Failed to decrypt confirmation data: ${error instanceof Error ? error.message : 'Unknown decryption error'}`
101
+ );
102
+ }
103
+ }
61
104
 
62
105
  confirmationDataForAudit = confirmationData;
63
106
  confirmationJsonFileNameForAudit = confirmationFileName;
@@ -1,12 +1,34 @@
1
1
  import { type ConfirmationImportData } from '~/types';
2
2
 
3
3
  const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
4
+ const ENCRYPTION_MANIFEST_FILE_NAME = 'encryption_manifest.json';
5
+
6
+ function uint8ArrayToBase64Url(data: Uint8Array): string {
7
+ const chunkSize = 8192;
8
+ let binaryString = '';
9
+
10
+ for (let index = 0; index < data.length; index += chunkSize) {
11
+ const chunk = data.subarray(index, Math.min(index + chunkSize, data.length));
12
+
13
+ for (let chunkIndex = 0; chunkIndex < chunk.length; chunkIndex += 1) {
14
+ binaryString += String.fromCharCode(chunk[chunkIndex]);
15
+ }
16
+ }
17
+
18
+ return btoa(binaryString)
19
+ .replace(/\+/g, '-')
20
+ .replace(/\//g, '_')
21
+ .replace(/=+$/g, '');
22
+ }
4
23
 
5
24
  export interface ConfirmationImportPackage {
6
25
  confirmationData: ConfirmationImportData;
7
26
  confirmationJsonContent: string;
8
27
  verificationPublicKeyPem?: string;
9
28
  confirmationFileName: string;
29
+ isEncrypted?: boolean;
30
+ encryptionManifest?: unknown;
31
+ encryptedDataBase64?: string;
10
32
  }
11
33
 
12
34
  function getLeafFileName(path: string): string {
@@ -32,22 +54,78 @@ async function extractConfirmationPackageFromZip(file: File): Promise<Confirmati
32
54
  const zip = await JSZip.loadAsync(file);
33
55
  const fileEntries = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
34
56
 
35
- const confirmationPaths = fileEntries.filter((path) =>
36
- CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
57
+ // Check for encryption manifest first
58
+ const hasEncryptionManifest = fileEntries.some((path) =>
59
+ getLeafFileName(path).toLowerCase() === ENCRYPTION_MANIFEST_FILE_NAME
37
60
  );
38
61
 
39
- if (confirmationPaths.length !== 1) {
40
- throw new Error('Confirmation ZIP must contain exactly one confirmation-data JSON file.');
41
- }
42
-
43
- const confirmationPath = confirmationPaths[0];
44
- const confirmationJsonContent = await zip.file(confirmationPath)?.async('text');
45
- if (!confirmationJsonContent) {
46
- throw new Error('Failed to read confirmation JSON from ZIP package.');
62
+ let confirmationData: ConfirmationImportData;
63
+ let confirmationJsonContent: string;
64
+ let confirmationFileName: string;
65
+ let isEncrypted = false;
66
+ let encryptionManifest: unknown;
67
+ let encryptedDataBase64: string | undefined;
68
+
69
+ if (hasEncryptionManifest) {
70
+ // Handle encrypted confirmation export
71
+ isEncrypted = true;
72
+
73
+ // Read encryption manifest
74
+ const manifestPath = fileEntries.find((path) =>
75
+ getLeafFileName(path).toLowerCase() === ENCRYPTION_MANIFEST_FILE_NAME
76
+ );
77
+ if (!manifestPath) {
78
+ throw new Error('Encrypted confirmation ZIP is missing ENCRYPTION_MANIFEST.json.');
79
+ }
80
+
81
+ const manifestFile = zip.file(manifestPath);
82
+ if (!manifestFile) {
83
+ throw new Error('Failed to read ENCRYPTION_MANIFEST.json from encrypted confirmation ZIP package.');
84
+ }
85
+
86
+ const manifestContent = await manifestFile.async('text');
87
+ if (manifestContent.trim().length === 0) {
88
+ throw new Error('ENCRYPTION_MANIFEST.json is empty in the encrypted confirmation ZIP package.');
89
+ }
90
+
91
+ try {
92
+ encryptionManifest = JSON.parse(manifestContent);
93
+ } catch {
94
+ throw new Error('ENCRYPTION_MANIFEST.json is invalid in the encrypted confirmation ZIP package.');
95
+ }
96
+
97
+ // Find and read encrypted confirmation data file
98
+ const confirmationPaths = fileEntries.filter((path) =>
99
+ CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
100
+ );
101
+
102
+ if (confirmationPaths.length !== 1) {
103
+ throw new Error('Encrypted confirmation ZIP must contain exactly one confirmation-data file.');
104
+ }
105
+
106
+ const confirmationPath = confirmationPaths[0];
107
+ const encryptedContent = await zip.file(confirmationPath)?.async('uint8array');
108
+ if (!encryptedContent) {
109
+ throw new Error('Failed to read encrypted confirmation data from ZIP package.');
110
+ }
111
+
112
+ encryptedDataBase64 = uint8ArrayToBase64Url(encryptedContent);
113
+ confirmationFileName = getLeafFileName(confirmationPath);
114
+
115
+ // For encrypted data, return placeholder confirmationData for now
116
+ // The actual decryption will happen in confirmation-import.ts
117
+ confirmationData = {
118
+ metadata: {},
119
+ confirmations: {}
120
+ } as ConfirmationImportData;
121
+ confirmationJsonContent = encryptedDataBase64;
122
+ } else {
123
+ throw new Error(
124
+ 'Confirmation imports now require an encrypted confirmation ZIP package exported from Striae. ' +
125
+ 'Legacy plaintext confirmation ZIP packages are no longer supported.'
126
+ );
47
127
  }
48
128
 
49
- const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
50
-
51
129
  const pemPaths = fileEntries.filter((path) => getLeafFileName(path).toLowerCase().endsWith('.pem'));
52
130
  const preferredPemPath = selectPreferredPemPath(pemPaths);
53
131
 
@@ -60,7 +138,10 @@ async function extractConfirmationPackageFromZip(file: File): Promise<Confirmati
60
138
  confirmationData,
61
139
  confirmationJsonContent,
62
140
  verificationPublicKeyPem,
63
- confirmationFileName: getLeafFileName(confirmationPath)
141
+ confirmationFileName,
142
+ isEncrypted,
143
+ encryptionManifest,
144
+ encryptedDataBase64
64
145
  };
65
146
  }
66
147
 
@@ -68,19 +149,15 @@ export async function extractConfirmationImportPackage(file: File): Promise<Conf
68
149
  const lowerName = file.name.toLowerCase();
69
150
 
70
151
  if (lowerName.endsWith('.json')) {
71
- const confirmationJsonContent = await file.text();
72
- const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
73
-
74
- return {
75
- confirmationData,
76
- confirmationJsonContent,
77
- confirmationFileName: file.name
78
- };
152
+ throw new Error(
153
+ 'Confirmation imports now require an encrypted confirmation ZIP package exported from Striae. ' +
154
+ 'Plaintext confirmation JSON files are no longer supported.'
155
+ );
79
156
  }
80
157
 
81
158
  if (lowerName.endsWith('.zip')) {
82
159
  return extractConfirmationPackageFromZip(file);
83
160
  }
84
161
 
85
- throw new Error('Unsupported confirmation import file type. Use a confirmation JSON or confirmation ZIP file.');
162
+ throw new Error('Unsupported confirmation import file type. Use an encrypted confirmation ZIP package exported from Striae.');
86
163
  }
@@ -5,13 +5,15 @@ import {
5
5
  type ReadOnlyCaseMetadata,
6
6
  type FileData,
7
7
  type BundledAuditTrailData,
8
- type ValidationAuditEntry
8
+ type ValidationAuditEntry,
9
+ type CaseExportData
9
10
  } from '~/types';
10
- import { checkExistingCase } from '../case-manage';
11
+ import { checkExistingCase, validateCaseNumber } from '../case-manage';
11
12
  import {
12
13
  type SignedForensicManifest,
13
14
  verifyCasePackageIntegrity
14
15
  } from '~/utils/forensics';
16
+ import type { EncryptionManifest } from '~/utils/forensics/export-encryption';
15
17
  import { deleteFile } from '../image-manage';
16
18
  import { parseImportZip } from './zip-processing';
17
19
  import {
@@ -25,6 +27,8 @@ import {
25
27
  import { uploadImageBlob } from './image-operations';
26
28
  import { importAnnotations } from './annotation-import';
27
29
  import { auditService } from '~/services/audit';
30
+ import { decryptExportBatch } from '~/utils/data/operations/signing-operations';
31
+ import { validateCaseExporterUidForImport } from './validation';
28
32
 
29
33
  /**
30
34
  * Track the state of an import operation for cleanup purposes
@@ -46,6 +50,22 @@ interface BundledAuditTrailFile {
46
50
  };
47
51
  }
48
52
 
53
+ function isEncryptionManifest(value: unknown): value is EncryptionManifest {
54
+ if (!value || typeof value !== 'object') {
55
+ return false;
56
+ }
57
+
58
+ const candidate = value as Partial<EncryptionManifest>;
59
+ return (
60
+ typeof candidate.encryptionVersion === 'string' &&
61
+ typeof candidate.algorithm === 'string' &&
62
+ typeof candidate.keyId === 'string' &&
63
+ typeof candidate.wrappedKey === 'string' &&
64
+ typeof candidate.dataIv === 'string' &&
65
+ Array.isArray(candidate.encryptedImages)
66
+ );
67
+ }
68
+
49
69
  function extractBundledAuditTrailData(
50
70
  bundledAuditFiles: {
51
71
  auditTrailContent?: string;
@@ -179,21 +199,104 @@ export async function importCaseForReview(
179
199
  let signatureValidationPassed = false;
180
200
  let signatureKeyId: string | undefined;
181
201
  let parsedForensicManifest: SignedForensicManifest | undefined;
202
+ let exporterUidValidationPassed = false;
182
203
 
183
204
  try {
184
205
  onProgress?.('Parsing ZIP file', 10, 'Extracting archive contents...');
185
206
 
186
207
  // Step 1: Parse ZIP file
187
208
  const {
188
- caseData,
189
- imageFiles,
209
+ caseData: initialCaseData,
210
+ imageFiles: initialImageFiles,
190
211
  imageIdMapping,
191
212
  isArchivedExport,
192
213
  bundledAuditFiles,
193
214
  metadata,
194
- cleanedContent,
195
- verificationPublicKeyPem
215
+ cleanedContent: initialCleanedContent,
216
+ verificationPublicKeyPem,
217
+ encryptionManifest,
218
+ encryptedDataBase64,
219
+ encryptedImages,
220
+ isEncrypted
196
221
  } = await parseImportZip(zipFile, user);
222
+
223
+ // Step 1.2: Handle decryption if export is encrypted
224
+ let caseData = initialCaseData;
225
+ let cleanedContent = initialCleanedContent || '';
226
+ let imageFiles = initialImageFiles;
227
+ let resolvedBundledAuditFiles = bundledAuditFiles;
228
+ let decryptedImageBlobMap: { [filename: string]: Blob } | undefined;
229
+
230
+ if (isEncrypted && isEncryptionManifest(encryptionManifest) && encryptedDataBase64) {
231
+ onProgress?.('Decrypting export', 11, 'Decrypting case data and images...');
232
+
233
+ try {
234
+ // Call decrypt endpoint on data-worker
235
+ const decryptResult = await decryptExportBatch(
236
+ user,
237
+ encryptionManifest,
238
+ encryptedDataBase64,
239
+ encryptedImages ?? {}
240
+ );
241
+
242
+ // Decrypted data is plaintext JSON
243
+ cleanedContent = decryptResult.plaintext;
244
+ const parsedCaseData = JSON.parse(cleanedContent) as unknown;
245
+ caseData = parsedCaseData as CaseExportData;
246
+
247
+ const decryptedFiles = decryptResult.decryptedImages;
248
+ const decryptedAuditTrailBlob = decryptedFiles['audit/case-audit-trail.json'];
249
+ const decryptedAuditSignatureBlob = decryptedFiles['audit/case-audit-signature.json'];
250
+
251
+ if (decryptedAuditTrailBlob || decryptedAuditSignatureBlob) {
252
+ resolvedBundledAuditFiles = {
253
+ ...(resolvedBundledAuditFiles ?? {}),
254
+ auditTrailContent: decryptedAuditTrailBlob
255
+ ? await decryptedAuditTrailBlob.text()
256
+ : resolvedBundledAuditFiles?.auditTrailContent,
257
+ auditSignatureContent: decryptedAuditSignatureBlob
258
+ ? await decryptedAuditSignatureBlob.text()
259
+ : resolvedBundledAuditFiles?.auditSignatureContent
260
+ };
261
+ }
262
+
263
+ decryptedImageBlobMap = Object.fromEntries(
264
+ Object.entries(decryptedFiles).filter(([filename]) => !filename.startsWith('audit/'))
265
+ );
266
+
267
+ // Update imageFiles with decrypted images only
268
+ imageFiles = { ...imageFiles, ...decryptedImageBlobMap };
269
+
270
+ onProgress?.('Decryption successful', 13, `Decrypted case data and ${Object.keys(decryptedImageBlobMap).length} images`);
271
+ } catch (decryptError) {
272
+ throw new Error(
273
+ `Failed to decrypt export: ${decryptError instanceof Error ? decryptError.message : 'Unknown error'}. ` +
274
+ 'Ensure your Striae instance has export encryption configured.'
275
+ );
276
+ }
277
+ }
278
+
279
+ if (isEncrypted) {
280
+ await validateCaseExporterUidForImport(caseData, user);
281
+ exporterUidValidationPassed = true;
282
+ } else {
283
+ exporterUidValidationPassed = true;
284
+ }
285
+
286
+ // Now validate case number and format
287
+ if (!caseData.metadata?.caseNumber) {
288
+ throw new Error('Invalid case data: missing case number');
289
+ }
290
+
291
+ if (!validateCaseNumber(caseData.metadata.caseNumber)) {
292
+ throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
293
+ }
294
+
295
+ const resolvedIsArchivedExport =
296
+ isArchivedExport ||
297
+ caseData.metadata.archived === true ||
298
+ (typeof caseData.metadata.archivedAt === 'string' && caseData.metadata.archivedAt.trim().length > 0);
299
+
197
300
  parsedForensicManifest = metadata?.forensicManifest as SignedForensicManifest | undefined;
198
301
  result.caseNumber = caseData.metadata.caseNumber;
199
302
  importState.caseNumber = result.caseNumber;
@@ -240,7 +343,7 @@ export async function importCaseForReview(
240
343
  imageFiles: imageBlobs,
241
344
  forensicManifest: parsedForensicManifest,
242
345
  verificationPublicKeyPem,
243
- bundledAuditFiles
346
+ bundledAuditFiles: resolvedBundledAuditFiles
244
347
  });
245
348
 
246
349
  signatureValidationPassed = casePackageResult.signatureResult.isValid;
@@ -283,10 +386,10 @@ export async function importCaseForReview(
283
386
 
284
387
  // Step 2a: Check if case already exists in user's regular cases (original analyst)
285
388
  const existingRegularCase = await checkExistingCase(user, result.caseNumber);
286
- if (existingRegularCase && !isArchivedExport) {
389
+ if (existingRegularCase && !resolvedIsArchivedExport) {
287
390
  throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
288
391
  }
289
- if (existingRegularCase && isArchivedExport) {
392
+ if (existingRegularCase && resolvedIsArchivedExport) {
290
393
  throw new Error(`Cannot import this archived case because "${result.caseNumber}" already exists in your regular case list. Delete the regular case before importing this archive.`);
291
394
  }
292
395
 
@@ -366,8 +469,8 @@ export async function importCaseForReview(
366
469
  importedFiles,
367
470
  originalImageIdMapping,
368
471
  parsedForensicManifest,
369
- isArchivedExport,
370
- isArchivedExport ? extractBundledAuditTrailData(bundledAuditFiles) : undefined
472
+ resolvedIsArchivedExport,
473
+ resolvedIsArchivedExport ? extractBundledAuditTrailData(resolvedBundledAuditFiles) : undefined
371
474
  );
372
475
  importState.caseDataStored = true;
373
476
 
@@ -414,7 +517,7 @@ export async function importCaseForReview(
414
517
  validationStepsCompleted: result.filesImported + result.annotationsImported,
415
518
  validationStepsFailed: 0
416
519
  },
417
- true, // Exporter UID was validated during zip parsing
520
+ exporterUidValidationPassed,
418
521
  {
419
522
  present: !!parsedForensicManifest,
420
523
  valid: signatureValidationPassed,
@@ -445,7 +548,7 @@ export async function importCaseForReview(
445
548
  processingTimeMs: endTime - startTime,
446
549
  fileSizeBytes: zipFile.size
447
550
  },
448
- false, // If import failed, exporter UID validation may not have completed
551
+ exporterUidValidationPassed,
449
552
  {
450
553
  present: !!parsedForensicManifest,
451
554
  valid: signatureValidationPassed,