@striae-org/striae 3.2.2 → 4.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 (82) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-export/download-handlers.ts +51 -3
  4. package/app/components/actions/case-import/confirmation-import.ts +65 -40
  5. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  6. package/app/components/actions/case-import/image-operations.ts +20 -49
  7. package/app/components/actions/case-import/index.ts +1 -0
  8. package/app/components/actions/case-import/orchestrator.ts +13 -3
  9. package/app/components/actions/case-import/storage-operations.ts +54 -89
  10. package/app/components/actions/case-import/validation.ts +7 -111
  11. package/app/components/actions/case-import/zip-processing.ts +44 -2
  12. package/app/components/actions/case-manage.ts +15 -27
  13. package/app/components/actions/confirm-export.ts +44 -13
  14. package/app/components/actions/generate-pdf.ts +3 -7
  15. package/app/components/actions/image-manage.ts +63 -129
  16. package/app/components/button/button.module.css +12 -8
  17. package/app/components/form/form-button.tsx +1 -1
  18. package/app/components/form/form.module.css +9 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  21. package/app/components/sidebar/case-export/case-export.tsx +13 -60
  22. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  24. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  25. package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
  26. package/app/components/sidebar/cases/cases.module.css +101 -18
  27. package/app/components/sidebar/notes/notes.module.css +33 -13
  28. package/app/components/sidebar/sidebar.module.css +0 -2
  29. package/app/components/user/delete-account.tsx +7 -7
  30. package/app/components/user/manage-profile.tsx +1 -1
  31. package/app/components/user/mfa-phone-update.tsx +15 -12
  32. package/app/config-example/config.json +2 -8
  33. package/app/hooks/useInactivityTimeout.ts +2 -5
  34. package/app/root.tsx +96 -65
  35. package/app/routes/auth/login.tsx +132 -11
  36. package/app/routes/auth/route.ts +4 -3
  37. package/app/routes/striae/striae.tsx +4 -8
  38. package/app/services/audit/audit-api-client.ts +40 -0
  39. package/app/services/audit/audit-worker-client.ts +14 -17
  40. package/app/styles/root.module.css +13 -101
  41. package/app/tailwind.css +9 -2
  42. package/app/utils/SHA256.ts +5 -1
  43. package/app/utils/auth.ts +5 -32
  44. package/app/utils/confirmation-signature.ts +5 -1
  45. package/app/utils/data-api-client.ts +43 -0
  46. package/app/utils/data-operations.ts +59 -75
  47. package/app/utils/export-verification.ts +353 -0
  48. package/app/utils/image-api-client.ts +130 -0
  49. package/app/utils/pdf-api-client.ts +43 -0
  50. package/app/utils/permissions.ts +10 -23
  51. package/app/utils/signature-utils.ts +74 -4
  52. package/app/utils/user-api-client.ts +90 -0
  53. package/functions/api/_shared/firebase-auth.ts +255 -0
  54. package/functions/api/audit/[[path]].ts +150 -0
  55. package/functions/api/data/[[path]].ts +141 -0
  56. package/functions/api/image/[[path]].ts +127 -0
  57. package/functions/api/pdf/[[path]].ts +110 -0
  58. package/functions/api/user/[[path]].ts +196 -0
  59. package/package.json +8 -4
  60. package/public/favicon.ico +0 -0
  61. package/public/icon-256.png +0 -0
  62. package/public/icon-512.png +0 -0
  63. package/public/manifest.json +39 -0
  64. package/public/shortcut.png +0 -0
  65. package/public/social-image.png +0 -0
  66. package/react-router.config.ts +5 -0
  67. package/scripts/deploy-all.sh +22 -8
  68. package/scripts/deploy-config.sh +143 -148
  69. package/scripts/deploy-pages-secrets.sh +231 -0
  70. package/scripts/deploy-worker-secrets.sh +1 -1
  71. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  72. package/workers/data-worker/wrangler.jsonc.example +1 -8
  73. package/workers/image-worker/wrangler.jsonc.example +1 -8
  74. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  75. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  76. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  77. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  78. package/workers/user-worker/src/user-worker.example.ts +121 -41
  79. package/workers/user-worker/wrangler.jsonc.example +1 -8
  80. package/wrangler.toml.example +1 -1
  81. package/app/styles/legal-pages.module.css +0 -113
  82. package/public/favicon.svg +0 -9
@@ -6,8 +6,7 @@
6
6
 
7
7
  import type { User } from 'firebase/auth';
8
8
  import { type CaseData, type AnnotationData, type ConfirmationImportData } from '~/types';
9
- import paths from '~/config/config.json';
10
- import { getDataApiKey } from './auth';
9
+ import { fetchDataApi } from './data-api-client';
11
10
  import { validateUserSession, canAccessCase, canModifyCase } from './permissions';
12
11
  import {
13
12
  type ForensicManifestData,
@@ -21,8 +20,6 @@ import {
21
20
  isValidAuditExportSigningPayload
22
21
  } from './audit-export-signature';
23
22
 
24
- const DATA_WORKER_URL = paths.data_worker_url;
25
-
26
23
  // ============================================================================
27
24
  // INTERFACES AND TYPES
28
25
  // ============================================================================
@@ -101,15 +98,13 @@ export const getCaseData = async (
101
98
  throw new Error('Invalid case number provided');
102
99
  }
103
100
 
104
- const apiKey = await getDataApiKey();
105
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
106
-
107
- const response = await fetch(url, {
108
- method: 'GET',
109
- headers: {
110
- 'X-Custom-Auth-Key': apiKey
101
+ const response = await fetchDataApi(
102
+ user,
103
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
104
+ {
105
+ method: 'GET'
111
106
  }
112
- });
107
+ );
113
108
 
114
109
  if (response.status === 404) {
115
110
  return null; // Case not found
@@ -163,23 +158,23 @@ export const updateCaseData = async (
163
158
  throw new Error('Invalid case data provided');
164
159
  }
165
160
 
166
- const apiKey = await getDataApiKey();
167
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
168
-
169
161
  // Add timestamp if requested (default: true)
170
162
  const dataToSave = options.includeTimestamp !== false ? {
171
163
  ...caseData,
172
164
  updatedAt: new Date().toISOString()
173
165
  } : caseData;
174
166
 
175
- const response = await fetch(url, {
176
- method: 'PUT',
177
- headers: {
178
- 'Content-Type': 'application/json',
179
- 'X-Custom-Auth-Key': apiKey
180
- },
181
- body: JSON.stringify(dataToSave)
182
- });
167
+ const response = await fetchDataApi(
168
+ user,
169
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
170
+ {
171
+ method: 'PUT',
172
+ headers: {
173
+ 'Content-Type': 'application/json'
174
+ },
175
+ body: JSON.stringify(dataToSave)
176
+ }
177
+ );
183
178
 
184
179
  if (!response.ok) {
185
180
  throw new Error(`Failed to update case data: ${response.status} ${response.statusText}`);
@@ -216,15 +211,13 @@ export const deleteCaseData = async (
216
211
  }
217
212
  }
218
213
 
219
- const apiKey = await getDataApiKey();
220
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
221
-
222
- const response = await fetch(url, {
223
- method: 'DELETE',
224
- headers: {
225
- 'X-Custom-Auth-Key': apiKey
214
+ const response = await fetchDataApi(
215
+ user,
216
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
217
+ {
218
+ method: 'DELETE'
226
219
  }
227
- });
220
+ );
228
221
 
229
222
  if (!response.ok && response.status !== 404) {
230
223
  throw new Error(`Failed to delete case data: ${response.status} ${response.statusText}`);
@@ -269,15 +262,13 @@ export const getFileAnnotations = async (
269
262
  throw new Error('Invalid file ID provided');
270
263
  }
271
264
 
272
- const apiKey = await getDataApiKey();
273
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
274
-
275
- const response = await fetch(url, {
276
- method: 'GET',
277
- headers: {
278
- 'X-Custom-Auth-Key': apiKey
265
+ const response = await fetchDataApi(
266
+ user,
267
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
268
+ {
269
+ method: 'GET'
279
270
  }
280
- });
271
+ );
281
272
 
282
273
  if (response.status === 404) {
283
274
  return null; // No annotations found
@@ -334,16 +325,14 @@ export const saveFileAnnotations = async (
334
325
  throw new Error('Invalid annotation data provided');
335
326
  }
336
327
 
337
- const apiKey = await getDataApiKey();
338
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
339
-
340
328
  // Enforce immutability once confirmation data exists on an image.
341
- const existingResponse = await fetch(url, {
342
- method: 'GET',
343
- headers: {
344
- 'X-Custom-Auth-Key': apiKey
329
+ const existingResponse = await fetchDataApi(
330
+ user,
331
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
332
+ {
333
+ method: 'GET'
345
334
  }
346
- });
335
+ );
347
336
 
348
337
  if (existingResponse.ok) {
349
338
  const existingAnnotations = await existingResponse.json() as AnnotationData;
@@ -360,14 +349,17 @@ export const saveFileAnnotations = async (
360
349
  updatedAt: new Date().toISOString()
361
350
  };
362
351
 
363
- const response = await fetch(url, {
364
- method: 'PUT',
365
- headers: {
366
- 'Content-Type': 'application/json',
367
- 'X-Custom-Auth-Key': apiKey
368
- },
369
- body: JSON.stringify(dataToSave)
370
- });
352
+ const response = await fetchDataApi(
353
+ user,
354
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
355
+ {
356
+ method: 'PUT',
357
+ headers: {
358
+ 'Content-Type': 'application/json'
359
+ },
360
+ body: JSON.stringify(dataToSave)
361
+ }
362
+ );
371
363
 
372
364
  if (!response.ok) {
373
365
  throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
@@ -407,15 +399,13 @@ export const deleteFileAnnotations = async (
407
399
  }
408
400
  }
409
401
 
410
- const apiKey = await getDataApiKey();
411
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
412
-
413
- const response = await fetch(url, {
414
- method: 'DELETE',
415
- headers: {
416
- 'X-Custom-Auth-Key': apiKey
402
+ const response = await fetchDataApi(
403
+ user,
404
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
405
+ {
406
+ method: 'DELETE'
417
407
  }
418
- });
408
+ );
419
409
 
420
410
  if (!response.ok && response.status !== 404) {
421
411
  throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
@@ -684,12 +674,10 @@ export const signForensicManifest = async (
684
674
  throw new Error(`Manifest signing denied: ${accessCheck.reason}`);
685
675
  }
686
676
 
687
- const apiKey = await getDataApiKey();
688
- const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-manifest`, {
677
+ const response = await fetchDataApi(user, '/api/forensic/sign-manifest', {
689
678
  method: 'POST',
690
679
  headers: {
691
- 'Content-Type': 'application/json',
692
- 'X-Custom-Auth-Key': apiKey
680
+ 'Content-Type': 'application/json'
693
681
  },
694
682
  body: JSON.stringify({
695
683
  userId: user.uid,
@@ -752,12 +740,10 @@ export const signConfirmationData = async (
752
740
  throw new Error(`Confirmation signing denied: ${accessCheck.reason}`);
753
741
  }
754
742
 
755
- const apiKey = await getDataApiKey();
756
- const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-confirmation`, {
743
+ const response = await fetchDataApi(user, '/api/forensic/sign-confirmation', {
757
744
  method: 'POST',
758
745
  headers: {
759
- 'Content-Type': 'application/json',
760
- 'X-Custom-Auth-Key': apiKey
746
+ 'Content-Type': 'application/json'
761
747
  },
762
748
  body: JSON.stringify({
763
749
  userId: user.uid,
@@ -828,12 +814,10 @@ export const signAuditExportData = async (
828
814
  }
829
815
  }
830
816
 
831
- const apiKey = await getDataApiKey();
832
- const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-audit-export`, {
817
+ const response = await fetchDataApi(user, '/api/forensic/sign-audit-export', {
833
818
  method: 'POST',
834
819
  headers: {
835
- 'Content-Type': 'application/json',
836
- 'X-Custom-Auth-Key': apiKey
820
+ 'Content-Type': 'application/json'
837
821
  },
838
822
  body: JSON.stringify({
839
823
  userId: user.uid,
@@ -0,0 +1,353 @@
1
+ import { type ConfirmationImportData } from '~/types';
2
+ import {
3
+ extractForensicManifestData,
4
+ type SignedForensicManifest,
5
+ calculateSHA256Secure,
6
+ validateCaseIntegritySecure,
7
+ verifyForensicManifestSignature
8
+ } from './SHA256';
9
+ import { verifyConfirmationSignature } from './confirmation-signature';
10
+
11
+ export interface ExportVerificationResult {
12
+ isValid: boolean;
13
+ message: string;
14
+ exportType?: 'case-zip' | 'confirmation';
15
+ }
16
+
17
+ const CASE_EXPORT_FILE_REGEX = /_data\.(json|csv)$/i;
18
+ const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
19
+
20
+ function createVerificationResult(
21
+ isValid: boolean,
22
+ message: string,
23
+ exportType?: ExportVerificationResult['exportType']
24
+ ): ExportVerificationResult {
25
+ return {
26
+ isValid,
27
+ message,
28
+ exportType
29
+ };
30
+ }
31
+
32
+ function getSignatureFailureMessage(
33
+ error: string | undefined,
34
+ targetLabel: 'export ZIP' | 'confirmation file'
35
+ ): string {
36
+ if (error?.includes('invalid public key')) {
37
+ return 'The selected PEM file is not a valid public key.';
38
+ }
39
+
40
+ if (error?.includes('Unsupported')) {
41
+ return `This ${targetLabel} uses an unsupported signature format.`;
42
+ }
43
+
44
+ if (error?.includes('Missing')) {
45
+ return `This ${targetLabel} is missing required signature information.`;
46
+ }
47
+
48
+ return `The ${targetLabel} signature did not verify with the selected public key.`;
49
+ }
50
+
51
+ function isConfirmationImportCandidate(candidate: unknown): candidate is Partial<ConfirmationImportData> {
52
+ if (!candidate || typeof candidate !== 'object') {
53
+ return false;
54
+ }
55
+
56
+ const confirmationCandidate = candidate as Partial<ConfirmationImportData>;
57
+ return (
58
+ !!confirmationCandidate.metadata &&
59
+ typeof confirmationCandidate.metadata.hash === 'string' &&
60
+ !!confirmationCandidate.confirmations &&
61
+ typeof confirmationCandidate.confirmations === 'object'
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Remove forensic warning from content for hash validation.
67
+ * Supports the warning formats added to JSON and CSV case exports.
68
+ */
69
+ export function removeForensicWarning(content: string): string {
70
+ const jsonForensicWarningRegex = /^\/\*\s*CASE\s+DATA\s+WARNING[\s\S]*?\*\/\s*\r?\n*/;
71
+ const csvForensicWarningRegex = /^"CASE DATA WARNING: This file contains evidence data for forensic examination\. Any modification may compromise the integrity of the evidence\. Handle according to your organization's chain of custody procedures\."(?:\r?\n){2}/;
72
+
73
+ let cleaned = content;
74
+
75
+ if (jsonForensicWarningRegex.test(content)) {
76
+ cleaned = content.replace(jsonForensicWarningRegex, '');
77
+ } else if (csvForensicWarningRegex.test(content)) {
78
+ cleaned = content.replace(csvForensicWarningRegex, '');
79
+ } else if (content.startsWith('"CASE DATA WARNING:')) {
80
+ const match = content.match(/^"[^"]*"(?:\r?\n)+/);
81
+ if (match) {
82
+ cleaned = content.substring(match[0].length);
83
+ }
84
+ }
85
+
86
+ return cleaned.replace(/^\s+/, '');
87
+ }
88
+
89
+ /**
90
+ * Validate the stored confirmation hash without exposing expected/actual values.
91
+ */
92
+ export async function validateConfirmationHash(jsonContent: string, expectedHash: string): Promise<boolean> {
93
+ try {
94
+ if (!expectedHash || typeof expectedHash !== 'string') {
95
+ return false;
96
+ }
97
+
98
+ const data = JSON.parse(jsonContent);
99
+ const dataWithoutHash = {
100
+ ...data,
101
+ metadata: {
102
+ ...data.metadata,
103
+ hash: undefined
104
+ }
105
+ };
106
+
107
+ delete dataWithoutHash.metadata.hash;
108
+ delete dataWithoutHash.metadata.signature;
109
+ delete dataWithoutHash.metadata.signatureVersion;
110
+
111
+ const contentForHash = JSON.stringify(dataWithoutHash, null, 2);
112
+ const actualHash = await calculateSHA256Secure(contentForHash);
113
+
114
+ return actualHash.toUpperCase() === expectedHash.toUpperCase();
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ async function verifyCaseZipExport(
121
+ file: File,
122
+ verificationPublicKeyPem: string
123
+ ): Promise<ExportVerificationResult> {
124
+ const JSZip = (await import('jszip')).default;
125
+
126
+ try {
127
+ const zip = await JSZip.loadAsync(file);
128
+ const dataFiles = Object.keys(zip.files).filter((name) => CASE_EXPORT_FILE_REGEX.test(name));
129
+
130
+ if (dataFiles.length !== 1) {
131
+ return createVerificationResult(
132
+ false,
133
+ 'The ZIP file must contain exactly one case export data file.',
134
+ 'case-zip'
135
+ );
136
+ }
137
+
138
+ const dataContent = await zip.file(dataFiles[0])?.async('text');
139
+ if (!dataContent) {
140
+ return createVerificationResult(false, 'The ZIP data file could not be read.', 'case-zip');
141
+ }
142
+
143
+ const manifestContent = await zip.file('FORENSIC_MANIFEST.json')?.async('text');
144
+ if (!manifestContent) {
145
+ return createVerificationResult(
146
+ false,
147
+ 'The ZIP file does not contain FORENSIC_MANIFEST.json.',
148
+ 'case-zip'
149
+ );
150
+ }
151
+
152
+ const forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
153
+ const manifestData = extractForensicManifestData(forensicManifest);
154
+
155
+ if (!manifestData) {
156
+ return createVerificationResult(false, 'The forensic manifest is malformed.', 'case-zip');
157
+ }
158
+
159
+ const cleanedContent = removeForensicWarning(dataContent);
160
+ const imageFiles: Record<string, Blob> = {};
161
+
162
+ await Promise.all(
163
+ Object.keys(zip.files).map(async (path) => {
164
+ if (!path.startsWith('images/') || path.endsWith('/')) {
165
+ return;
166
+ }
167
+
168
+ const zipEntry = zip.file(path);
169
+ if (!zipEntry) {
170
+ return;
171
+ }
172
+
173
+ imageFiles[path.replace('images/', '')] = await zipEntry.async('blob');
174
+ })
175
+ );
176
+
177
+ const signatureResult = await verifyForensicManifestSignature(forensicManifest, verificationPublicKeyPem);
178
+ const integrityResult = await validateCaseIntegritySecure(cleanedContent, imageFiles, manifestData);
179
+
180
+ if (signatureResult.isValid && integrityResult.isValid) {
181
+ return createVerificationResult(
182
+ true,
183
+ 'The export ZIP passed signature and integrity verification.',
184
+ 'case-zip'
185
+ );
186
+ }
187
+
188
+ if (!signatureResult.isValid && !integrityResult.isValid) {
189
+ return createVerificationResult(
190
+ false,
191
+ 'The export ZIP failed signature and integrity verification.',
192
+ 'case-zip'
193
+ );
194
+ }
195
+
196
+ if (!signatureResult.isValid) {
197
+ return createVerificationResult(
198
+ false,
199
+ getSignatureFailureMessage(signatureResult.error, 'export ZIP'),
200
+ 'case-zip'
201
+ );
202
+ }
203
+
204
+ return createVerificationResult(false, 'The export ZIP failed integrity verification.', 'case-zip');
205
+ } catch {
206
+ return createVerificationResult(
207
+ false,
208
+ 'The ZIP file could not be read as a supported Striae export.',
209
+ 'case-zip'
210
+ );
211
+ }
212
+ }
213
+
214
+ async function verifyConfirmationExport(
215
+ file: File,
216
+ verificationPublicKeyPem: string
217
+ ): Promise<ExportVerificationResult> {
218
+ try {
219
+ const fileContent = await file.text();
220
+ return verifyConfirmationContent(fileContent, verificationPublicKeyPem);
221
+ } catch {
222
+ return createVerificationResult(
223
+ false,
224
+ 'The JSON file could not be read as a supported Striae confirmation export.',
225
+ 'confirmation'
226
+ );
227
+ }
228
+ }
229
+
230
+ async function verifyConfirmationContent(
231
+ fileContent: string,
232
+ verificationPublicKeyPem: string
233
+ ): Promise<ExportVerificationResult> {
234
+ try {
235
+ const parsedContent = JSON.parse(fileContent) as unknown;
236
+
237
+ if (!isConfirmationImportCandidate(parsedContent)) {
238
+ return createVerificationResult(
239
+ false,
240
+ 'The JSON file is not a supported Striae confirmation export.',
241
+ 'confirmation'
242
+ );
243
+ }
244
+
245
+ const confirmationData = parsedContent as Partial<ConfirmationImportData>;
246
+ const hashValid = await validateConfirmationHash(fileContent, confirmationData.metadata!.hash);
247
+ const signatureResult = await verifyConfirmationSignature(confirmationData, verificationPublicKeyPem);
248
+
249
+ if (hashValid && signatureResult.isValid) {
250
+ return createVerificationResult(
251
+ true,
252
+ 'The confirmation file passed signature and integrity verification.',
253
+ 'confirmation'
254
+ );
255
+ }
256
+
257
+ if (!signatureResult.isValid && signatureResult.error === 'Confirmation content is malformed') {
258
+ return createVerificationResult(
259
+ false,
260
+ 'The JSON file is not a supported Striae confirmation export.',
261
+ 'confirmation'
262
+ );
263
+ }
264
+
265
+ if (!hashValid && !signatureResult.isValid) {
266
+ return createVerificationResult(
267
+ false,
268
+ 'The confirmation file failed signature and integrity verification.',
269
+ 'confirmation'
270
+ );
271
+ }
272
+
273
+ if (!signatureResult.isValid) {
274
+ return createVerificationResult(
275
+ false,
276
+ getSignatureFailureMessage(signatureResult.error, 'confirmation file'),
277
+ 'confirmation'
278
+ );
279
+ }
280
+
281
+ return createVerificationResult(
282
+ false,
283
+ 'The confirmation file failed integrity verification.',
284
+ 'confirmation'
285
+ );
286
+ } catch {
287
+ return createVerificationResult(
288
+ false,
289
+ 'The confirmation content could not be read as a supported Striae confirmation export.',
290
+ 'confirmation'
291
+ );
292
+ }
293
+ }
294
+
295
+ async function verifyConfirmationZipExport(
296
+ file: File,
297
+ verificationPublicKeyPem: string
298
+ ): Promise<ExportVerificationResult> {
299
+ const JSZip = (await import('jszip')).default;
300
+
301
+ try {
302
+ const zip = await JSZip.loadAsync(file);
303
+ const confirmationFiles = Object.keys(zip.files).filter((name) => CONFIRMATION_EXPORT_FILE_REGEX.test(name));
304
+
305
+ if (confirmationFiles.length !== 1) {
306
+ return createVerificationResult(
307
+ false,
308
+ 'The ZIP file is not a supported Striae confirmation export package.'
309
+ );
310
+ }
311
+
312
+ const confirmationContent = await zip.file(confirmationFiles[0])?.async('text');
313
+ if (!confirmationContent) {
314
+ return createVerificationResult(
315
+ false,
316
+ 'The confirmation JSON file inside the ZIP could not be read.',
317
+ 'confirmation'
318
+ );
319
+ }
320
+
321
+ return verifyConfirmationContent(confirmationContent, verificationPublicKeyPem);
322
+ } catch {
323
+ return createVerificationResult(
324
+ false,
325
+ 'The ZIP file could not be read as a supported Striae export.'
326
+ );
327
+ }
328
+ }
329
+
330
+ export async function verifyExportFile(
331
+ file: File,
332
+ verificationPublicKeyPem: string
333
+ ): Promise<ExportVerificationResult> {
334
+ const lowerName = file.name.toLowerCase();
335
+
336
+ if (lowerName.endsWith('.zip')) {
337
+ const confirmationZipResult = await verifyConfirmationZipExport(file, verificationPublicKeyPem);
338
+ if (confirmationZipResult.exportType === 'confirmation' || confirmationZipResult.isValid) {
339
+ return confirmationZipResult;
340
+ }
341
+
342
+ return verifyCaseZipExport(file, verificationPublicKeyPem);
343
+ }
344
+
345
+ if (lowerName.endsWith('.json')) {
346
+ return verifyConfirmationExport(file, verificationPublicKeyPem);
347
+ }
348
+
349
+ return createVerificationResult(
350
+ false,
351
+ 'Select a confirmation JSON/ZIP file or a case export ZIP file.'
352
+ );
353
+ }