@striae-org/striae 3.2.2 → 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 (37) hide show
  1. package/app/components/actions/case-export/download-handlers.ts +51 -3
  2. package/app/components/actions/case-import/confirmation-import.ts +41 -17
  3. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  4. package/app/components/actions/case-import/index.ts +1 -0
  5. package/app/components/actions/case-import/orchestrator.ts +12 -2
  6. package/app/components/actions/case-import/validation.ts +5 -98
  7. package/app/components/actions/case-import/zip-processing.ts +44 -2
  8. package/app/components/actions/confirm-export.ts +44 -13
  9. package/app/components/form/form-button.tsx +1 -1
  10. package/app/components/form/form.module.css +9 -0
  11. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  12. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  13. package/app/components/sidebar/case-export/case-export.tsx +2 -54
  14. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  15. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  16. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  17. package/app/components/sidebar/cases/case-sidebar.tsx +101 -46
  18. package/app/components/sidebar/cases/cases.module.css +101 -18
  19. package/app/components/sidebar/notes/notes.module.css +33 -13
  20. package/app/components/user/manage-profile.tsx +1 -1
  21. package/app/components/user/mfa-phone-update.tsx +15 -12
  22. package/app/root.tsx +2 -2
  23. package/app/routes/auth/login.tsx +129 -6
  24. package/app/utils/SHA256.ts +5 -1
  25. package/app/utils/confirmation-signature.ts +5 -1
  26. package/app/utils/export-verification.ts +353 -0
  27. package/app/utils/signature-utils.ts +74 -4
  28. package/package.json +7 -4
  29. package/public/favicon.ico +0 -0
  30. package/public/icon-256.png +0 -0
  31. package/public/icon-512.png +0 -0
  32. package/public/manifest.json +39 -0
  33. package/public/shortcut.png +0 -0
  34. package/public/social-image.png +0 -0
  35. package/react-router.config.ts +5 -0
  36. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  37. package/public/favicon.svg +0 -9
@@ -4,6 +4,11 @@ import { type FileData, type AllCasesExportData, type CaseExportData, type Expor
4
4
  import { getImageUrl } from '../image-manage';
5
5
  import { generateForensicManifestSecure, calculateSHA256Secure } from '~/utils/SHA256';
6
6
  import { signForensicManifest } from '~/utils/data-operations';
7
+ import {
8
+ createPublicSigningKeyFileName,
9
+ getCurrentPublicSigningKeyDetails,
10
+ getVerificationPublicKey
11
+ } from '~/utils/signature-utils';
7
12
  import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
8
13
  import { protectExcelWorksheet, addForensicDataWarning } from './metadata-helpers';
9
14
  import { generateMetadataRows, generateCSVContent, processFileDataForTabular, sanitizeTabularMatrix } from './data-processing';
@@ -115,6 +120,30 @@ function generateExportFilename(originalFilename: string, id: string): string {
115
120
  return `${basename}-${id}${extension}`;
116
121
  }
117
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
+
118
147
  /**
119
148
  * Download all cases data as JSON file
120
149
  */
@@ -609,6 +638,7 @@ export async function downloadCaseAsZip(
609
638
  const startTime = Date.now();
610
639
  let manifestSignatureKeyId: string | undefined;
611
640
  let manifestSigned = false;
641
+ let publicKeyFileName: string | undefined;
612
642
 
613
643
  try {
614
644
  // Start audit workflow
@@ -672,6 +702,8 @@ export async function downloadCaseAsZip(
672
702
  manifestSignatureKeyId = signingResult.signature.keyId;
673
703
  manifestSigned = true;
674
704
 
705
+ publicKeyFileName = addPublicSigningKeyPemToZip(zip, signingResult.signature.keyId);
706
+
675
707
  const signedForensicManifest = {
676
708
  ...forensicManifest,
677
709
  manifestVersion: signingResult.manifestVersion,
@@ -696,6 +728,7 @@ Archive Contents:
696
728
  - ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format
697
729
  - images/: Original image files with annotations
698
730
  - FORENSIC_MANIFEST.json: File integrity validation manifest
731
+ - ${publicKeyFileName}: Public signing key PEM for verification
699
732
  - README.txt: General information about this export
700
733
 
701
734
  Case Information:
@@ -713,7 +746,11 @@ For questions about this export, contact your Striae system administrator.
713
746
  zip.file('READ_ONLY_INSTRUCTIONS.txt', instructionContent);
714
747
 
715
748
  // Add README
716
- const readme = generateZipReadme(exportData, options.protectForensicData);
749
+ const readme = generateZipReadme(
750
+ exportData,
751
+ options.protectForensicData,
752
+ publicKeyFileName
753
+ );
717
754
  zip.file('README.txt', readme);
718
755
  onProgress?.(85);
719
756
 
@@ -772,8 +809,14 @@ For questions about this export, contact your Striae system administrator.
772
809
  return; // Exit early as we've handled the forensic case
773
810
  }
774
811
 
812
+ publicKeyFileName = addPublicSigningKeyPemToZip(zip);
813
+
775
814
  // Add README (standard or enhanced for forensic)
776
- const readme = generateZipReadme(exportData, options.protectForensicData);
815
+ const readme = generateZipReadme(
816
+ exportData,
817
+ options.protectForensicData,
818
+ publicKeyFileName
819
+ );
777
820
  zip.file('README.txt', readme);
778
821
  onProgress?.(85);
779
822
 
@@ -878,7 +921,11 @@ async function fetchImageAsBlob(user: User, fileData: FileData, caseNumber: stri
878
921
  /**
879
922
  * Generate README content for ZIP export with optional forensic protection
880
923
  */
881
- function generateZipReadme(exportData: CaseExportData, protectForensicData: boolean = true): string {
924
+ function generateZipReadme(
925
+ exportData: CaseExportData,
926
+ protectForensicData: boolean = true,
927
+ publicKeyFileName: string = createPublicSigningKeyFileName()
928
+ ): string {
882
929
  const totalFiles = exportData.files?.length || 0;
883
930
  const filesWithAnnotations = exportData.summary?.filesWithAnnotations || 0;
884
931
  const totalBoxAnnotations = exportData.summary?.totalBoxAnnotations || 0;
@@ -912,6 +959,7 @@ Summary:
912
959
  Contents:
913
960
  - ${exportData.metadata.caseNumber}_data.json/.csv: Case data and annotations
914
961
  - images/: Original uploaded images
962
+ - ${publicKeyFileName}: Public signing key PEM for verification
915
963
  - README.txt: This file`;
916
964
 
917
965
  const forensicAddition = `
@@ -3,6 +3,7 @@ import paths from '~/config/config.json';
3
3
  import { getDataApiKey } from '~/utils/auth';
4
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
8
  import { auditService } from '~/services/audit';
8
9
 
@@ -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
+ }
@@ -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';
@@ -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
 
@@ -2,67 +2,12 @@ import type { User } from 'firebase/auth';
2
2
  import paths from '~/config/config.json';
3
3
  import { getUserApiKey } from '~/utils/auth';
4
4
  import { type CaseExportData, type ConfirmationImportData } from '~/types';
5
- import { calculateSHA256Secure, type ManifestSignatureVerificationResult } from '~/utils/SHA256';
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
  }
@@ -9,6 +9,41 @@ import {
9
9
  } from '~/utils/SHA256';
10
10
  import { validateExporterUid, removeForensicWarning } from './validation';
11
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 extractVerificationPublicKeyFromZip(
31
+ zip: {
32
+ files: Record<string, { dir: boolean }>;
33
+ file: (path: string) => { async: (type: 'text') => Promise<string> } | null;
34
+ }
35
+ ): Promise<string | undefined> {
36
+ const filePaths = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
37
+ const pemPaths = filePaths.filter((path) => getLeafFileName(path).toLowerCase().endsWith('.pem'));
38
+ const preferredPemPath = selectPreferredPemPath(pemPaths);
39
+
40
+ if (!preferredPemPath) {
41
+ return undefined;
42
+ }
43
+
44
+ return zip.file(preferredPemPath)?.async('text');
45
+ }
46
+
12
47
  /**
13
48
  * Extract original image ID from export filename format
14
49
  * Format: {originalFilename}-{id}.{extension}
@@ -51,6 +86,7 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
51
86
 
52
87
  try {
53
88
  const zip = await JSZip.loadAsync(zipFile);
89
+ const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
54
90
 
55
91
  // First, validate hash if forensic metadata exists
56
92
  let hashValid: boolean | undefined = undefined;
@@ -128,7 +164,10 @@ export async function previewCaseImport(zipFile: File, currentUser: User): Promi
128
164
  }));
129
165
  }
130
166
 
131
- const signatureResult = await verifyForensicManifestSignature(forensicManifest);
167
+ const signatureResult = await verifyForensicManifestSignature(
168
+ forensicManifest,
169
+ verificationPublicKeyPem
170
+ );
132
171
 
133
172
  // Perform comprehensive validation
134
173
  const validation = await validateForensicIntegrity(
@@ -267,12 +306,14 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
267
306
  imageIdMapping: { [exportFilename: string]: string }; // exportFilename -> originalImageId
268
307
  metadata?: Record<string, unknown>;
269
308
  cleanedContent?: string; // Add cleaned content for hash validation
309
+ verificationPublicKeyPem?: string;
270
310
  }> {
271
311
  // Dynamic import of JSZip to avoid bundle size issues
272
312
  const JSZip = (await import('jszip')).default;
273
313
 
274
314
  try {
275
315
  const zip = await JSZip.loadAsync(zipFile);
316
+ const verificationPublicKeyPem = await extractVerificationPublicKeyFromZip(zip);
276
317
 
277
318
  // Find the main data file (JSON or CSV)
278
319
  const dataFiles = Object.keys(zip.files).filter(name =>
@@ -367,7 +408,8 @@ export async function parseImportZip(zipFile: File, currentUser: User): Promise<
367
408
  imageFiles,
368
409
  imageIdMapping,
369
410
  metadata,
370
- cleanedContent
411
+ cleanedContent,
412
+ verificationPublicKeyPem
371
413
  };
372
414
 
373
415
  } catch (error) {
@@ -3,6 +3,11 @@ import { calculateSHA256Secure } from '~/utils/SHA256';
3
3
  import { getUserData } from '~/utils/permissions';
4
4
  import { getCaseData, updateCaseData, signConfirmationData } from '~/utils/data-operations';
5
5
  import { type ConfirmationData, type CaseConfirmations, type CaseDataWithConfirmations, type ConfirmationImportData } from '~/types';
6
+ import {
7
+ createPublicSigningKeyFileName,
8
+ getCurrentPublicSigningKeyDetails,
9
+ getVerificationPublicKey
10
+ } from '~/utils/signature-utils';
6
11
  import { auditService } from '~/services/audit';
7
12
 
8
13
  /**
@@ -267,15 +272,8 @@ export async function exportConfirmationData(
267
272
  }
268
273
  };
269
274
 
270
- // Convert final data to JSON blob
271
275
  const finalJsonString = JSON.stringify(finalExportData, null, 2);
272
- const blob = new Blob([finalJsonString], { type: 'application/json' });
273
-
274
- // Create download
275
- const url = URL.createObjectURL(blob);
276
- const a = document.createElement('a');
277
- a.href = url;
278
-
276
+
279
277
  // Use local timezone for filename timestamp
280
278
  const now = new Date();
281
279
  const year = now.getFullYear();
@@ -285,14 +283,47 @@ export async function exportConfirmationData(
285
283
  const minutes = String(now.getMinutes()).padStart(2, '0');
286
284
  const seconds = String(now.getSeconds()).padStart(2, '0');
287
285
  const timestampString = `${year}${month}${day}-${hours}${minutes}${seconds}`;
286
+
287
+ const confirmationFileName = `confirmation-data-${caseNumber}-${timestampString}.json`;
288
+
289
+ const keyFromSignature = getVerificationPublicKey(signingResult.signature.keyId);
290
+ const currentKey = getCurrentPublicSigningKeyDetails();
291
+ const publicKeyPem = keyFromSignature ?? currentKey.publicKeyPem;
292
+ const publicKeyFileName = createPublicSigningKeyFileName(
293
+ keyFromSignature ? signingResult.signature.keyId : currentKey.keyId
294
+ );
295
+
296
+ if (!publicKeyPem || publicKeyPem.trim().length === 0) {
297
+ throw new Error('No public signing key is configured for confirmation export packaging.');
298
+ }
299
+
300
+ const JSZip = (await import('jszip')).default;
301
+ const zip = new JSZip();
302
+ const normalizedPem = publicKeyPem.endsWith('\n') ? publicKeyPem : `${publicKeyPem}\n`;
303
+
304
+ zip.file(confirmationFileName, finalJsonString);
305
+ zip.file(publicKeyFileName, normalizedPem);
306
+
307
+ const zipBlob = await zip.generateAsync({
308
+ type: 'blob',
309
+ compression: 'DEFLATE',
310
+ compressionOptions: { level: 6 }
311
+ });
312
+
313
+ const exportFileName = `confirmation-export-${caseNumber}-${timestampString}.zip`;
314
+
315
+ // Create download
316
+ const url = URL.createObjectURL(zipBlob);
317
+ const a = document.createElement('a');
318
+ a.href = url;
288
319
 
289
- a.download = `confirmation-data-${caseNumber}-${timestampString}.json`;
320
+ a.download = exportFileName;
290
321
  document.body.appendChild(a);
291
322
  a.click();
292
323
  document.body.removeChild(a);
293
324
  URL.revokeObjectURL(url);
294
325
 
295
- console.log(`Confirmation data exported for case ${caseNumber}`);
326
+ console.log(`Confirmation export ZIP generated for case ${caseNumber}`);
296
327
 
297
328
  // Log successful confirmation export
298
329
  const endTime = Date.now();
@@ -300,14 +331,14 @@ export async function exportConfirmationData(
300
331
  await auditService.logConfirmationExport(
301
332
  user,
302
333
  caseNumber,
303
- `confirmation-data-${caseNumber}-${timestampString}.json`,
334
+ exportFileName,
304
335
  confirmationCount,
305
336
  'success',
306
337
  [],
307
338
  undefined, // Original examiner UID not available here
308
339
  {
309
340
  processingTimeMs: endTime - startTime,
310
- fileSizeBytes: new Blob([jsonString]).size,
341
+ fileSizeBytes: zipBlob.size,
311
342
  validationStepsCompleted: confirmationCount,
312
343
  validationStepsFailed: 0
313
344
  },
@@ -328,7 +359,7 @@ export async function exportConfirmationData(
328
359
  await auditService.logConfirmationExport(
329
360
  user,
330
361
  caseNumber,
331
- `confirmation-data-${caseNumber}-error.json`,
362
+ `confirmation-export-${caseNumber}-error.zip`,
332
363
  0,
333
364
  'failure',
334
365
  [error instanceof Error ? error.message : 'Unknown error'],
@@ -1,7 +1,7 @@
1
1
  import styles from './form.module.css';
2
2
 
3
3
  interface FormButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
4
- variant?: 'primary' | 'secondary' | 'success' | 'error';
4
+ variant?: 'primary' | 'secondary' | 'success' | 'error' | 'audit';
5
5
  isLoading?: boolean;
6
6
  loadingText?: string;
7
7
  children: React.ReactNode;