@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.
- package/app/components/actions/case-export/download-handlers.ts +51 -3
- package/app/components/actions/case-import/confirmation-import.ts +41 -17
- package/app/components/actions/case-import/confirmation-package.ts +86 -0
- package/app/components/actions/case-import/index.ts +1 -0
- package/app/components/actions/case-import/orchestrator.ts +12 -2
- package/app/components/actions/case-import/validation.ts +5 -98
- package/app/components/actions/case-import/zip-processing.ts +44 -2
- package/app/components/actions/confirm-export.ts +44 -13
- package/app/components/form/form-button.tsx +1 -1
- package/app/components/form/form.module.css +9 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
- package/app/components/sidebar/case-export/case-export.tsx +2 -54
- package/app/components/sidebar/case-import/case-import.tsx +18 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
- package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
- package/app/components/sidebar/cases/case-sidebar.tsx +101 -46
- package/app/components/sidebar/cases/cases.module.css +101 -18
- package/app/components/sidebar/notes/notes.module.css +33 -13
- package/app/components/user/manage-profile.tsx +1 -1
- package/app/components/user/mfa-phone-update.tsx +15 -12
- package/app/root.tsx +2 -2
- package/app/routes/auth/login.tsx +129 -6
- package/app/utils/SHA256.ts +5 -1
- package/app/utils/confirmation-signature.ts +5 -1
- package/app/utils/export-verification.ts +353 -0
- package/app/utils/signature-utils.ts +74 -4
- package/package.json +7 -4
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +39 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/react-router.config.ts +5 -0
- package/workers/pdf-worker/scripts/generate-assets.js +94 -0
- 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(
|
|
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(
|
|
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(
|
|
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
|
|
53
|
+
onProgress?.('Reading confirmation file', 10, 'Loading confirmation package...');
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
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-
|
|
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;
|