@striae-org/striae 3.2.1 → 3.3.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 (104) hide show
  1. package/app/components/actions/case-export/core-export.ts +2 -2
  2. package/app/components/actions/case-export/data-processing.ts +19 -4
  3. package/app/components/actions/case-export/download-handlers.ts +57 -8
  4. package/app/components/actions/case-export/metadata-helpers.ts +1 -1
  5. package/app/components/actions/case-import/annotation-import.ts +2 -2
  6. package/app/components/actions/case-import/confirmation-import.ts +44 -20
  7. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  8. package/app/components/actions/case-import/image-operations.ts +1 -1
  9. package/app/components/actions/case-import/index.ts +1 -0
  10. package/app/components/actions/case-import/orchestrator.ts +16 -6
  11. package/app/components/actions/case-import/storage-operations.ts +7 -7
  12. package/app/components/actions/case-import/validation.ts +7 -100
  13. package/app/components/actions/case-import/zip-processing.ts +47 -5
  14. package/app/components/actions/case-manage.ts +3 -3
  15. package/app/components/actions/confirm-export.ts +47 -16
  16. package/app/components/actions/generate-pdf.ts +3 -3
  17. package/app/components/actions/image-manage.ts +3 -3
  18. package/app/components/actions/notes-manage.ts +3 -3
  19. package/app/components/actions/signout.tsx +1 -1
  20. package/app/components/audit/user-audit-viewer.tsx +2 -3
  21. package/app/components/auth/auth-provider.tsx +2 -2
  22. package/app/components/auth/mfa-enrollment.tsx +3 -3
  23. package/app/components/auth/mfa-verification.tsx +4 -4
  24. package/app/components/canvas/box-annotations/box-annotations.tsx +2 -2
  25. package/app/components/canvas/canvas.tsx +1 -1
  26. package/app/components/canvas/confirmation/confirmation.tsx +1 -1
  27. package/app/components/form/form-button.tsx +1 -1
  28. package/app/components/form/form.module.css +9 -0
  29. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  30. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  31. package/app/components/sidebar/case-export/case-export.tsx +2 -54
  32. package/app/components/sidebar/case-import/case-import.tsx +20 -8
  33. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -1
  34. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
  35. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +9 -7
  36. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -2
  37. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  38. package/app/components/sidebar/cases/case-sidebar.tsx +106 -50
  39. package/app/components/sidebar/cases/cases-modal.tsx +1 -1
  40. package/app/components/sidebar/cases/cases.module.css +101 -18
  41. package/app/components/sidebar/files/files-modal.tsx +3 -2
  42. package/app/components/sidebar/notes/notes-sidebar.tsx +3 -3
  43. package/app/components/sidebar/notes/notes.module.css +33 -13
  44. package/app/components/sidebar/sidebar-container.tsx +4 -3
  45. package/app/components/sidebar/sidebar.tsx +2 -2
  46. package/app/components/sidebar/upload/image-upload-zone.tsx +2 -2
  47. package/app/components/theme-provider/theme-provider.tsx +1 -1
  48. package/app/components/user/delete-account.tsx +1 -1
  49. package/app/components/user/manage-profile.tsx +3 -3
  50. package/app/components/user/mfa-phone-update.tsx +17 -14
  51. package/app/contexts/auth.context.ts +1 -1
  52. package/app/root.tsx +2 -2
  53. package/app/routes/auth/emailActionHandler.tsx +2 -2
  54. package/app/routes/auth/emailVerification.tsx +2 -2
  55. package/app/routes/auth/login.tsx +134 -11
  56. package/app/routes/auth/passwordReset.tsx +2 -2
  57. package/app/routes/striae/striae.tsx +2 -2
  58. package/app/services/audit/audit-console-logger.ts +46 -0
  59. package/app/services/audit/audit-export-csv.ts +126 -0
  60. package/app/services/audit/audit-export-report.ts +174 -0
  61. package/app/services/audit/audit-export-signing.ts +85 -0
  62. package/app/services/audit/audit-export.service.ts +334 -0
  63. package/app/services/audit/audit-file-type.ts +13 -0
  64. package/app/services/audit/audit-query-helpers.ts +88 -0
  65. package/app/services/audit/audit-worker-client.ts +95 -0
  66. package/app/services/audit/audit.service.ts +990 -0
  67. package/app/services/audit/builders/audit-entry-builder.ts +32 -0
  68. package/app/services/audit/builders/audit-event-builders-annotation.ts +150 -0
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +249 -0
  70. package/app/services/audit/builders/audit-event-builders-user-security.ts +449 -0
  71. package/app/services/audit/builders/audit-event-builders-workflow.ts +272 -0
  72. package/app/services/audit/builders/index.ts +40 -0
  73. package/app/services/audit/index.ts +2 -0
  74. package/app/types/case.ts +2 -2
  75. package/app/types/exceljs-bare.d.ts +3 -1
  76. package/app/types/user.ts +1 -1
  77. package/app/utils/SHA256.ts +5 -1
  78. package/app/utils/audit-export-signature.ts +2 -2
  79. package/app/utils/confirmation-signature.ts +8 -4
  80. package/app/utils/data-operations.ts +5 -5
  81. package/app/utils/export-verification.ts +353 -0
  82. package/app/utils/mfa-phone.ts +1 -1
  83. package/app/utils/mfa.ts +1 -1
  84. package/app/utils/permissions.ts +2 -2
  85. package/app/utils/signature-utils.ts +74 -4
  86. package/package.json +11 -9
  87. package/public/favicon.ico +0 -0
  88. package/public/icon-256.png +0 -0
  89. package/public/icon-512.png +0 -0
  90. package/public/manifest.json +39 -0
  91. package/public/shortcut.png +0 -0
  92. package/public/social-image.png +0 -0
  93. package/react-router.config.ts +5 -0
  94. package/worker-configuration.d.ts +4435 -562
  95. package/workers/data-worker/src/data-worker.example.ts +3 -3
  96. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  97. package/workers/pdf-worker/src/{generated-assets.ts → assets/generated-assets.ts} +117 -117
  98. package/workers/pdf-worker/src/{format-striae.ts → formats/format-striae.ts} +535 -535
  99. package/workers/pdf-worker/src/pdf-worker.example.ts +1 -1
  100. package/app/services/audit-export.service.ts +0 -755
  101. package/app/services/audit.service.ts +0 -1474
  102. package/public/favicon.svg +0 -9
  103. /package/app/services/{firebase-errors.ts → firebase/errors.ts} +0 -0
  104. /package/app/services/{firebase.ts → firebase/index.ts} +0 -0
@@ -1,5 +1,5 @@
1
- import { User } from 'firebase/auth';
2
- import { AnnotationData, CaseExportData, AllCasesExportData, ExportOptions } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import { type AnnotationData, type CaseExportData, type AllCasesExportData, type ExportOptions } from '~/types';
3
3
  import { fetchFiles } from '../image-manage';
4
4
  import { getNotes } from '../notes-manage';
5
5
  import { checkExistingCase, validateCaseNumber, listCases } from '../case-manage';
@@ -1,4 +1,4 @@
1
- import { CaseExportData } from '~/types';
1
+ import { type CaseExportData } from '~/types';
2
2
  import { calculateSHA256Secure } from '~/utils/SHA256';
3
3
  import { CSV_HEADERS } from './types-constants';
4
4
  import { addForensicDataWarning } from './metadata-helpers';
@@ -6,8 +6,23 @@ import { addForensicDataWarning } from './metadata-helpers';
6
6
  export type TabularCell = string | number | boolean | null;
7
7
 
8
8
  const MAX_SPREADSHEET_CELL_LENGTH = 32767;
9
- const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
10
- const DANGEROUS_FORMULA_PREFIX_PATTERN = /^[\s\u0000-\u001F]*[=+\-@]/;
9
+ const DANGEROUS_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@]/;
10
+
11
+ function stripUnsafeControlChars(input: string): string {
12
+ let output = '';
13
+
14
+ for (let index = 0; index < input.length; index += 1) {
15
+ const code = input.charCodeAt(index);
16
+ const isControlChar = code <= 0x1f || code === 0x7f;
17
+ const isAllowedWhitespace = code === 0x09 || code === 0x0a || code === 0x0d;
18
+
19
+ if (!isControlChar || isAllowedWhitespace) {
20
+ output += input[index];
21
+ }
22
+ }
23
+
24
+ return output;
25
+ }
11
26
 
12
27
  /**
13
28
  * Sanitize cell values before CSV/XLSX export.
@@ -24,7 +39,7 @@ export function sanitizeTabularCell(value: unknown): TabularCell {
24
39
  return value;
25
40
  }
26
41
 
27
- let normalized = String(value).replace(CONTROL_CHAR_PATTERN, '');
42
+ let normalized = stripUnsafeControlChars(String(value));
28
43
 
29
44
  if (normalized.length > MAX_SPREADSHEET_CELL_LENGTH) {
30
45
  normalized = normalized.slice(0, MAX_SPREADSHEET_CELL_LENGTH);
@@ -1,16 +1,22 @@
1
- import { User } from 'firebase/auth';
2
- import { FileData, AllCasesExportData, CaseExportData, ExportOptions } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import type * as ExcelJSModule from 'exceljs';
3
+ import { type FileData, type AllCasesExportData, type CaseExportData, type ExportOptions } from '~/types';
3
4
  import { getImageUrl } from '../image-manage';
4
5
  import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/SHA256';
5
6
  import { signForensicManifest } from '~/utils/data-operations';
6
- import { ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
7
+ import {
8
+ createPublicSigningKeyFileName,
9
+ getCurrentPublicSigningKeyDetails,
10
+ getVerificationPublicKey
11
+ } from '~/utils/signature-utils';
12
+ import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
7
13
  import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
8
14
  import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
9
15
  import { exportCaseData } from './core-export';
10
- import { auditService } from '~/services/audit.service';
16
+ import { auditService } from '~/services/audit';
11
17
 
12
18
  type TabularRow = Array<string | number | boolean | null | undefined>;
13
- type ExcelJsBrowserBundle = typeof import('exceljs');
19
+ type ExcelJsBrowserBundle = typeof ExcelJSModule;
14
20
 
15
21
  const EXCELJS_BROWSER_BUNDLE_SRC = '/vendor/exceljs.bare.min.js';
16
22
  let excelJsBundlePromise: Promise<ExcelJsBrowserBundle> | null = null;
@@ -114,6 +120,30 @@ function generateExportFilename(originalFilename: string, id: string): string {
114
120
  return `${basename}-${id}${extension}`;
115
121
  }
116
122
 
123
+ function addPublicSigningKeyPemToZip(
124
+ zip: { file: (path: string, data: string) => unknown },
125
+ preferredKeyId?: string
126
+ ): string {
127
+ const preferredPublicKey =
128
+ typeof preferredKeyId === 'string' && preferredKeyId.trim().length > 0
129
+ ? getVerificationPublicKey(preferredKeyId)
130
+ : null;
131
+
132
+ const currentKey = getCurrentPublicSigningKeyDetails();
133
+ const keyId = preferredPublicKey ? preferredKeyId ?? null : currentKey.keyId;
134
+ const publicKeyPem = preferredPublicKey ?? currentKey.publicKeyPem;
135
+
136
+ if (!publicKeyPem || publicKeyPem.trim().length === 0) {
137
+ throw new Error('No public signing key is configured for ZIP export packaging.');
138
+ }
139
+
140
+ const publicKeyFileName = createPublicSigningKeyFileName(keyId);
141
+ const normalizedPem = publicKeyPem.endsWith('\n') ? publicKeyPem : `${publicKeyPem}\n`;
142
+ zip.file(publicKeyFileName, normalizedPem);
143
+
144
+ return publicKeyFileName;
145
+ }
146
+
117
147
  /**
118
148
  * Download all cases data as JSON file
119
149
  */
@@ -608,6 +638,7 @@ export async function downloadCaseAsZip(
608
638
  const startTime = Date.now();
609
639
  let manifestSignatureKeyId: string | undefined;
610
640
  let manifestSigned = false;
641
+ let publicKeyFileName: string | undefined;
611
642
 
612
643
  try {
613
644
  // Start audit workflow
@@ -671,6 +702,8 @@ export async function downloadCaseAsZip(
671
702
  manifestSignatureKeyId = signingResult.signature.keyId;
672
703
  manifestSigned = true;
673
704
 
705
+ publicKeyFileName = addPublicSigningKeyPemToZip(zip, signingResult.signature.keyId);
706
+
674
707
  const signedForensicManifest = {
675
708
  ...forensicManifest,
676
709
  manifestVersion: signingResult.manifestVersion,
@@ -695,6 +728,7 @@ Archive Contents:
695
728
  - ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format
696
729
  - images/: Original image files with annotations
697
730
  - FORENSIC_MANIFEST.json: File integrity validation manifest
731
+ - ${publicKeyFileName}: Public signing key PEM for verification
698
732
  - README.txt: General information about this export
699
733
 
700
734
  Case Information:
@@ -712,7 +746,11 @@ For questions about this export, contact your Striae system administrator.
712
746
  zip.file('READ_ONLY_INSTRUCTIONS.txt', instructionContent);
713
747
 
714
748
  // Add README
715
- const readme = generateZipReadme(exportData, options.protectForensicData);
749
+ const readme = generateZipReadme(
750
+ exportData,
751
+ options.protectForensicData,
752
+ publicKeyFileName
753
+ );
716
754
  zip.file('README.txt', readme);
717
755
  onProgress?.(85);
718
756
 
@@ -771,8 +809,14 @@ For questions about this export, contact your Striae system administrator.
771
809
  return; // Exit early as we've handled the forensic case
772
810
  }
773
811
 
812
+ publicKeyFileName = addPublicSigningKeyPemToZip(zip);
813
+
774
814
  // Add README (standard or enhanced for forensic)
775
- const readme = generateZipReadme(exportData, options.protectForensicData);
815
+ const readme = generateZipReadme(
816
+ exportData,
817
+ options.protectForensicData,
818
+ publicKeyFileName
819
+ );
776
820
  zip.file('README.txt', readme);
777
821
  onProgress?.(85);
778
822
 
@@ -877,7 +921,11 @@ async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: stri
877
921
  /**
878
922
  * Generate README content for ZIP export with optional forensic protection
879
923
  */
880
- function generateZipReadme(exportData: CaseExportData, protectForensicData: boolean = true): string {
924
+ function generateZipReadme(
925
+ exportData: CaseExportData,
926
+ protectForensicData: boolean = true,
927
+ publicKeyFileName: string = createPublicSigningKeyFileName()
928
+ ): string {
881
929
  const totalFiles = exportData.files?.length || 0;
882
930
  const filesWithAnnotations = exportData.summary?.filesWithAnnotations || 0;
883
931
  const totalBoxAnnotations = exportData.summary?.totalBoxAnnotations || 0;
@@ -911,6 +959,7 @@ Summary:
911
959
  Contents:
912
960
  - ${exportData.metadata.caseNumber}_data.json/.csv: Case data and annotations
913
961
  - images/: Original uploaded images
962
+ - ${publicKeyFileName}: Public signing key PEM for verification
914
963
  - README.txt: This file`;
915
964
 
916
965
  const forensicAddition = `
@@ -1,4 +1,4 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import { getUserData } from '~/utils/permissions';
3
3
 
4
4
  /**
@@ -1,5 +1,5 @@
1
- import { User } from 'firebase/auth';
2
- import { CaseExportData } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import { type CaseExportData } from '~/types';
3
3
  import { saveNotes } from '../notes-manage';
4
4
 
5
5
  /**
@@ -1,10 +1,11 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import paths from '~/config/config.json';
3
3
  import { getDataApiKey } from '~/utils/auth';
4
- import { ConfirmationImportResult, ConfirmationImportData } from '~/types';
4
+ import { type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
5
5
  import { checkExistingCase } from '../case-manage';
6
+ import { extractConfirmationImportPackage } from './confirmation-package';
6
7
  import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
7
- import { auditService } from '~/services/audit.service';
8
+ import { auditService } from '~/services/audit';
8
9
 
9
10
  const DATA_WORKER_URL = paths.data_worker_url;
10
11
 
@@ -36,6 +37,8 @@ export async function importConfirmationData(
36
37
  let signatureValid = false;
37
38
  let signaturePresent = false;
38
39
  let signatureKeyId: string | undefined;
40
+ let confirmationDataForAudit: ConfirmationImportData | null = null;
41
+ let confirmationJsonFileNameForAudit = confirmationFile.name;
39
42
 
40
43
  const result: ConfirmationImportResult = {
41
44
  success: false,
@@ -47,11 +50,17 @@ export async function importConfirmationData(
47
50
  };
48
51
 
49
52
  try {
50
- onProgress?.('Reading confirmation file', 10, 'Loading JSON data...');
53
+ onProgress?.('Reading confirmation file', 10, 'Loading confirmation package...');
51
54
 
52
- // Read and parse the JSON file
53
- const fileContent = await confirmationFile.text();
54
- const confirmationData: ConfirmationImportData = JSON.parse(fileContent);
55
+ const {
56
+ confirmationData,
57
+ confirmationJsonContent,
58
+ verificationPublicKeyPem,
59
+ confirmationFileName
60
+ } = await extractConfirmationImportPackage(confirmationFile);
61
+
62
+ confirmationDataForAudit = confirmationData;
63
+ confirmationJsonFileNameForAudit = confirmationFileName;
55
64
  result.caseNumber = confirmationData.metadata.caseNumber;
56
65
 
57
66
  // Start audit workflow
@@ -60,14 +69,17 @@ export async function importConfirmationData(
60
69
  onProgress?.('Validating hash', 20, 'Verifying data integrity...');
61
70
 
62
71
  // Validate hash
63
- hashValid = await validateConfirmationHash(fileContent, confirmationData.metadata.hash);
72
+ hashValid = await validateConfirmationHash(confirmationJsonContent, confirmationData.metadata.hash);
64
73
  if (!hashValid) {
65
74
  throw new Error('Confirmation data hash validation failed. The file may have been tampered with or corrupted.');
66
75
  }
67
76
 
68
77
  onProgress?.('Validating signature', 30, 'Verifying signed confirmation metadata...');
69
78
 
70
- const signatureResult = await validateConfirmationSignatureFile(confirmationData);
79
+ const signatureResult = await validateConfirmationSignatureFile(
80
+ confirmationData,
81
+ verificationPublicKeyPem
82
+ );
71
83
  signaturePresent = !!confirmationData.metadata.signature;
72
84
  signatureValid = signatureResult.isValid;
73
85
  signatureKeyId = signatureResult.keyId;
@@ -276,7 +288,7 @@ export async function importConfirmationData(
276
288
  await auditService.logConfirmationImport(
277
289
  user,
278
290
  result.caseNumber,
279
- confirmationFile.name,
291
+ confirmationJsonFileNameForAudit,
280
292
  result.success ? (result.errors && result.errors.length > 0 ? 'warning' : 'success') : 'failure',
281
293
  hashValid,
282
294
  result.confirmationsImported, // Successfully imported confirmations
@@ -318,19 +330,31 @@ export async function importConfirmationData(
318
330
  let signatureValidForAudit = signatureValid;
319
331
  let signatureKeyIdForAudit = signatureKeyId;
320
332
 
333
+ const auditConfirmationData = confirmationDataForAudit;
334
+
321
335
  // First, try to extract basic metadata for audit purposes (if file is parseable)
322
- try {
323
- const confirmationData = JSON.parse(await confirmationFile.text()) as ConfirmationImportData;
324
- reviewingExaminerUidForAudit = confirmationData.metadata?.exportedByUid;
325
- totalConfirmationsForAudit = confirmationData.metadata?.totalConfirmations || 0;
326
- if (confirmationData.metadata?.signature) {
336
+ if (auditConfirmationData) {
337
+ reviewingExaminerUidForAudit = auditConfirmationData.metadata?.exportedByUid;
338
+ totalConfirmationsForAudit = auditConfirmationData.metadata?.totalConfirmations || 0;
339
+ if (auditConfirmationData.metadata?.signature) {
327
340
  signaturePresentForAudit = true;
328
- signatureKeyIdForAudit = confirmationData.metadata.signature.keyId;
341
+ signatureKeyIdForAudit = auditConfirmationData.metadata.signature.keyId;
342
+ }
343
+ } else {
344
+ try {
345
+ const extracted = await extractConfirmationImportPackage(confirmationFile);
346
+ reviewingExaminerUidForAudit = extracted.confirmationData.metadata?.exportedByUid;
347
+ totalConfirmationsForAudit = extracted.confirmationData.metadata?.totalConfirmations || 0;
348
+ confirmationJsonFileNameForAudit = extracted.confirmationFileName;
349
+ if (extracted.confirmationData.metadata?.signature) {
350
+ signaturePresentForAudit = true;
351
+ signatureKeyIdForAudit = extracted.confirmationData.metadata.signature.keyId;
352
+ }
353
+ } catch {
354
+ // If we can't parse the file, keep undefined/default values
329
355
  }
330
- } catch {
331
- // If we can't parse the file, keep undefined/default values
332
356
  }
333
-
357
+
334
358
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
335
359
  if (errorMessage.includes('hash validation failed')) {
336
360
  // Hash failed - only flag file integrity, don't affect other validations
@@ -352,7 +376,7 @@ export async function importConfirmationData(
352
376
  await auditService.logConfirmationImport(
353
377
  user,
354
378
  result.caseNumber || 'unknown',
355
- confirmationFile.name,
379
+ confirmationJsonFileNameForAudit,
356
380
  'failure',
357
381
  hashValidForAudit,
358
382
  0, // No confirmations successfully imported for failures
@@ -0,0 +1,86 @@
1
+ import { type ConfirmationImportData } from '~/types';
2
+
3
+ const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
4
+
5
+ export interface ConfirmationImportPackage {
6
+ confirmationData: ConfirmationImportData;
7
+ confirmationJsonContent: string;
8
+ verificationPublicKeyPem?: string;
9
+ confirmationFileName: string;
10
+ }
11
+
12
+ function getLeafFileName(path: string): string {
13
+ const segments = path.split('/').filter(Boolean);
14
+ return segments.length > 0 ? segments[segments.length - 1] : path;
15
+ }
16
+
17
+ function selectPreferredPemPath(pemPaths: string[]): string | undefined {
18
+ if (pemPaths.length === 0) {
19
+ return undefined;
20
+ }
21
+
22
+ const sortedPaths = [...pemPaths].sort((left, right) => left.localeCompare(right));
23
+ const preferred = sortedPaths.find((path) =>
24
+ /^striae-public-signing-key.*\.pem$/i.test(getLeafFileName(path))
25
+ );
26
+
27
+ return preferred ?? sortedPaths[0];
28
+ }
29
+
30
+ async function extractConfirmationPackageFromZip(file: File): Promise<ConfirmationImportPackage> {
31
+ const JSZip = (await import('jszip')).default;
32
+ const zip = await JSZip.loadAsync(file);
33
+ const fileEntries = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
34
+
35
+ const confirmationPaths = fileEntries.filter((path) =>
36
+ CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
37
+ );
38
+
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.');
47
+ }
48
+
49
+ const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
50
+
51
+ const pemPaths = fileEntries.filter((path) => getLeafFileName(path).toLowerCase().endsWith('.pem'));
52
+ const preferredPemPath = selectPreferredPemPath(pemPaths);
53
+
54
+ let verificationPublicKeyPem: string | undefined;
55
+ if (preferredPemPath) {
56
+ verificationPublicKeyPem = await zip.file(preferredPemPath)?.async('text');
57
+ }
58
+
59
+ return {
60
+ confirmationData,
61
+ confirmationJsonContent,
62
+ verificationPublicKeyPem,
63
+ confirmationFileName: getLeafFileName(confirmationPath)
64
+ };
65
+ }
66
+
67
+ export async function extractConfirmationImportPackage(file: File): Promise<ConfirmationImportPackage> {
68
+ const lowerName = file.name.toLowerCase();
69
+
70
+ 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
+ };
79
+ }
80
+
81
+ if (lowerName.endsWith('.zip')) {
82
+ return extractConfirmationPackageFromZip(file);
83
+ }
84
+
85
+ throw new Error('Unsupported confirmation import file type. Use a confirmation JSON or confirmation ZIP file.');
86
+ }
@@ -1,6 +1,6 @@
1
1
  import paths from '~/config/config.json';
2
2
  import { getImageApiKey } from '~/utils/auth';
3
- import { FileData, ImageUploadResponse } from '~/types';
3
+ import { type FileData, type ImageUploadResponse } from '~/types';
4
4
 
5
5
  const IMAGE_WORKER_URL = paths.image_worker_url;
6
6
 
@@ -34,6 +34,7 @@ export { importAnnotations } from './annotation-import';
34
34
 
35
35
  // Confirmation import
36
36
  export { importConfirmationData } from './confirmation-import';
37
+ export { extractConfirmationImportPackage } from './confirmation-package';
37
38
 
38
39
  // Main orchestrator
39
40
  export { importCaseForReview } from './orchestrator';
@@ -1,9 +1,9 @@
1
- import { User } from 'firebase/auth';
2
- import { ImportOptions, ImportResult, ReadOnlyCaseMetadata, FileData } from '~/types';
1
+ import type { User } from 'firebase/auth';
2
+ import { type ImportOptions, type ImportResult, type ReadOnlyCaseMetadata, type FileData } from '~/types';
3
3
  import { checkExistingCase } from '../case-manage';
4
4
  import {
5
5
  extractForensicManifestData,
6
- SignedForensicManifest,
6
+ type SignedForensicManifest,
7
7
  validateCaseIntegritySecure as validateForensicIntegrity,
8
8
  verifyForensicManifestSignature
9
9
  } from '~/utils/SHA256';
@@ -19,7 +19,7 @@ import {
19
19
  } from './storage-operations';
20
20
  import { uploadImageBlob } from './image-operations';
21
21
  import { importAnnotations } from './annotation-import';
22
- import { auditService } from '~/services/audit.service';
22
+ import { auditService } from '~/services/audit';
23
23
 
24
24
  /**
25
25
  * Track the state of an import operation for cleanup purposes
@@ -137,7 +137,14 @@ export async function importCaseForReview(
137
137
  onProgress?.('Parsing ZIP file', 10, 'Extracting archive contents...');
138
138
 
139
139
  // Step 1: Parse ZIP file
140
- const { caseData, imageFiles, imageIdMapping, metadata, cleanedContent } = await parseImportZip(zipFile, user);
140
+ const {
141
+ caseData,
142
+ imageFiles,
143
+ imageIdMapping,
144
+ metadata,
145
+ cleanedContent,
146
+ verificationPublicKeyPem
147
+ } = await parseImportZip(zipFile, user);
141
148
  parsedForensicManifest = metadata?.forensicManifest as SignedForensicManifest | undefined;
142
149
  result.caseNumber = caseData.metadata.caseNumber;
143
150
  importState.caseNumber = result.caseNumber;
@@ -181,7 +188,10 @@ export async function importCaseForReview(
181
188
  );
182
189
  }
183
190
 
184
- const signatureResult = await verifyForensicManifestSignature(parsedForensicManifest);
191
+ const signatureResult = await verifyForensicManifestSignature(
192
+ parsedForensicManifest,
193
+ verificationPublicKeyPem
194
+ );
185
195
  signatureValidationPassed = signatureResult.isValid;
186
196
  signatureKeyId = signatureResult.keyId;
187
197
 
@@ -1,4 +1,4 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import paths from '~/config/config.json';
3
3
  import {
4
4
  getDataApiKey,
@@ -10,14 +10,14 @@ import {
10
10
  validateUserSession
11
11
  } from '~/utils/permissions';
12
12
  import {
13
- CaseExportData,
14
- ExtendedUserData,
15
- FileData,
16
- CaseData,
17
- ReadOnlyCaseMetadata
13
+ type CaseExportData,
14
+ type ExtendedUserData,
15
+ type FileData,
16
+ type CaseData,
17
+ type ReadOnlyCaseMetadata
18
18
  } from '~/types';
19
19
  import { deleteFile } from '../image-manage';
20
- import { SignedForensicManifest } from '~/utils/SHA256';
20
+ import { type SignedForensicManifest } from '~/utils/SHA256';
21
21
 
22
22
  const USER_WORKER_URL = paths.user_worker_url;
23
23
  const DATA_WORKER_URL = paths.data_worker_url;
@@ -1,68 +1,13 @@
1
- import { User } from 'firebase/auth';
1
+ import type { User } from 'firebase/auth';
2
2
  import paths from '~/config/config.json';
3
3
  import { getUserApiKey } from '~/utils/auth';
4
- import { CaseExportData, ConfirmationImportData } from '~/types';
5
- import { calculateSHA256Secure, ManifestSignatureVerificationResult } from '~/utils/SHA256';
4
+ import { type CaseExportData, type ConfirmationImportData } from '~/types';
5
+ import { type ManifestSignatureVerificationResult } from '~/utils/SHA256';
6
6
  import { verifyConfirmationSignature } from '~/utils/confirmation-signature';
7
+ export { removeForensicWarning, validateConfirmationHash } from '~/utils/export-verification';
7
8
 
8
9
  const USER_WORKER_URL = paths.user_worker_url;
9
10
 
10
- /**
11
- * Remove forensic warning from content for hash validation (supports both JSON and CSV formats)
12
- * This function ensures exact match with the content used during export hash generation
13
- */
14
- export function removeForensicWarning(content: string): string {
15
- // Handle JSON forensic warnings (block comment format)
16
- // /* CASE DATA WARNING
17
- // * This file contains evidence data for forensic examination.
18
- // * Any modification may compromise the integrity of the evidence.
19
- // * Handle according to your organization's chain of custody procedures.
20
- // *
21
- // * File generated: YYYY-MM-DDTHH:mm:ss.sssZ
22
- // */
23
- const jsonForensicWarningRegex = /^\/\*\s*CASE\s+DATA\s+WARNING[\s\S]*?\*\/\s*\r?\n*/;
24
-
25
- // Handle CSV forensic warnings (quoted string format at the beginning of file)
26
- // CRITICAL: The CSV forensic warning is ONLY the first quoted line, followed by two newlines
27
- // Format: "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."\n\n
28
- //
29
- // After removal, what remains should be the csvWithHash content:
30
- // # Striae Case Export - Generated: ...
31
- // # Case: ...
32
- // # Total Files: ...
33
- // # SHA256 Hash: ...
34
- // # Verification: ...
35
- //
36
- // [actual CSV data]
37
- // More robust regex to handle various line endings and exact format from generation
38
- 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}/;
39
-
40
- let cleaned = content;
41
-
42
- // Try JSON format first
43
- if (jsonForensicWarningRegex.test(content)) {
44
- cleaned = content.replace(jsonForensicWarningRegex, '');
45
- }
46
- // Try CSV format with exact pattern match
47
- else if (csvForensicWarningRegex.test(content)) {
48
- cleaned = content.replace(csvForensicWarningRegex, '');
49
- }
50
- // Fallback: try broader CSV pattern in case of slight format differences
51
- else if (content.startsWith('"CASE DATA WARNING:')) {
52
- // Find the end of the first quoted string followed by newlines
53
- const match = content.match(/^"[^"]*"(?:\r?\n)+/);
54
- if (match) {
55
- cleaned = content.substring(match[0].length);
56
- }
57
- }
58
-
59
- // Additional cleanup: remove any leading whitespace that might remain
60
- // This ensures we match exactly what the generation functions produce with protectForensicData: false
61
- cleaned = cleaned.replace(/^\s+/, '');
62
-
63
- return cleaned;
64
- }
65
-
66
11
  /**
67
12
  * Validate that a user exists in the database by UID and is not the current user
68
13
  */
@@ -93,45 +38,6 @@ export function isConfirmationDataFile(filename: string): boolean {
93
38
  return filename.startsWith('confirmation-data') && filename.endsWith('.json');
94
39
  }
95
40
 
96
- /**
97
- * Validate confirmation data file hash
98
- */
99
- export async function validateConfirmationHash(jsonContent: string, expectedHash: string): Promise<boolean> {
100
- try {
101
- // Validate input parameters
102
- if (!expectedHash || typeof expectedHash !== 'string') {
103
- console.error('validateConfirmationHash: expected hash input is invalid');
104
- return false;
105
- }
106
-
107
- // Create data without hash for validation
108
- const data = JSON.parse(jsonContent);
109
- const dataWithoutHash = {
110
- ...data,
111
- metadata: {
112
- ...data.metadata,
113
- hash: undefined
114
- }
115
- };
116
- delete dataWithoutHash.metadata.hash;
117
- delete dataWithoutHash.metadata.signature;
118
- delete dataWithoutHash.metadata.signatureVersion;
119
-
120
- const contentForHash = JSON.stringify(dataWithoutHash, null, 2);
121
- const actualHash = await calculateSHA256Secure(contentForHash);
122
-
123
- if (!actualHash) {
124
- console.error('validateConfirmationHash: failed to calculate hash');
125
- return false;
126
- }
127
-
128
- return actualHash.toUpperCase() === expectedHash.toUpperCase();
129
- } catch {
130
- console.error('validateConfirmationHash: validation failed');
131
- return false;
132
- }
133
- }
134
-
135
41
  /**
136
42
  * Validate imported case data integrity (optional verification)
137
43
  */
@@ -183,7 +89,8 @@ export function validateCaseIntegrity(
183
89
  * Validate confirmation data file signature.
184
90
  */
185
91
  export async function validateConfirmationSignatureFile(
186
- confirmationData: Partial<ConfirmationImportData>
92
+ confirmationData: Partial<ConfirmationImportData>,
93
+ verificationPublicKeyPem?: string
187
94
  ): Promise<ManifestSignatureVerificationResult> {
188
- return verifyConfirmationSignature(confirmationData);
95
+ return verifyConfirmationSignature(confirmationData, verificationPublicKeyPem);
189
96
  }