@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.
- package/app/components/actions/case-export/core-export.ts +2 -2
- package/app/components/actions/case-export/data-processing.ts +19 -4
- package/app/components/actions/case-export/download-handlers.ts +57 -8
- package/app/components/actions/case-export/metadata-helpers.ts +1 -1
- package/app/components/actions/case-import/annotation-import.ts +2 -2
- package/app/components/actions/case-import/confirmation-import.ts +44 -20
- package/app/components/actions/case-import/confirmation-package.ts +86 -0
- package/app/components/actions/case-import/image-operations.ts +1 -1
- package/app/components/actions/case-import/index.ts +1 -0
- package/app/components/actions/case-import/orchestrator.ts +16 -6
- package/app/components/actions/case-import/storage-operations.ts +7 -7
- package/app/components/actions/case-import/validation.ts +7 -100
- package/app/components/actions/case-import/zip-processing.ts +47 -5
- package/app/components/actions/case-manage.ts +3 -3
- package/app/components/actions/confirm-export.ts +47 -16
- package/app/components/actions/generate-pdf.ts +3 -3
- package/app/components/actions/image-manage.ts +3 -3
- package/app/components/actions/notes-manage.ts +3 -3
- package/app/components/actions/signout.tsx +1 -1
- package/app/components/audit/user-audit-viewer.tsx +2 -3
- package/app/components/auth/auth-provider.tsx +2 -2
- package/app/components/auth/mfa-enrollment.tsx +3 -3
- package/app/components/auth/mfa-verification.tsx +4 -4
- package/app/components/canvas/box-annotations/box-annotations.tsx +2 -2
- package/app/components/canvas/canvas.tsx +1 -1
- package/app/components/canvas/confirmation/confirmation.tsx +1 -1
- 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 +20 -8
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -1
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +9 -7
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -2
- package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
- package/app/components/sidebar/cases/case-sidebar.tsx +106 -50
- package/app/components/sidebar/cases/cases-modal.tsx +1 -1
- package/app/components/sidebar/cases/cases.module.css +101 -18
- package/app/components/sidebar/files/files-modal.tsx +3 -2
- package/app/components/sidebar/notes/notes-sidebar.tsx +3 -3
- package/app/components/sidebar/notes/notes.module.css +33 -13
- package/app/components/sidebar/sidebar-container.tsx +4 -3
- package/app/components/sidebar/sidebar.tsx +2 -2
- package/app/components/sidebar/upload/image-upload-zone.tsx +2 -2
- package/app/components/theme-provider/theme-provider.tsx +1 -1
- package/app/components/user/delete-account.tsx +1 -1
- package/app/components/user/manage-profile.tsx +3 -3
- package/app/components/user/mfa-phone-update.tsx +17 -14
- package/app/contexts/auth.context.ts +1 -1
- package/app/root.tsx +2 -2
- package/app/routes/auth/emailActionHandler.tsx +2 -2
- package/app/routes/auth/emailVerification.tsx +2 -2
- package/app/routes/auth/login.tsx +134 -11
- package/app/routes/auth/passwordReset.tsx +2 -2
- package/app/routes/striae/striae.tsx +2 -2
- package/app/services/audit/audit-console-logger.ts +46 -0
- package/app/services/audit/audit-export-csv.ts +126 -0
- package/app/services/audit/audit-export-report.ts +174 -0
- package/app/services/audit/audit-export-signing.ts +85 -0
- package/app/services/audit/audit-export.service.ts +334 -0
- package/app/services/audit/audit-file-type.ts +13 -0
- package/app/services/audit/audit-query-helpers.ts +88 -0
- package/app/services/audit/audit-worker-client.ts +95 -0
- package/app/services/audit/audit.service.ts +990 -0
- package/app/services/audit/builders/audit-entry-builder.ts +32 -0
- package/app/services/audit/builders/audit-event-builders-annotation.ts +150 -0
- package/app/services/audit/builders/audit-event-builders-case-file.ts +249 -0
- package/app/services/audit/builders/audit-event-builders-user-security.ts +449 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +272 -0
- package/app/services/audit/builders/index.ts +40 -0
- package/app/services/audit/index.ts +2 -0
- package/app/types/case.ts +2 -2
- package/app/types/exceljs-bare.d.ts +3 -1
- package/app/types/user.ts +1 -1
- package/app/utils/SHA256.ts +5 -1
- package/app/utils/audit-export-signature.ts +2 -2
- package/app/utils/confirmation-signature.ts +8 -4
- package/app/utils/data-operations.ts +5 -5
- package/app/utils/export-verification.ts +353 -0
- package/app/utils/mfa-phone.ts +1 -1
- package/app/utils/mfa.ts +1 -1
- package/app/utils/permissions.ts +2 -2
- package/app/utils/signature-utils.ts +74 -4
- package/package.json +11 -9
- 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/worker-configuration.d.ts +4435 -562
- package/workers/data-worker/src/data-worker.example.ts +3 -3
- package/workers/pdf-worker/scripts/generate-assets.js +94 -0
- package/workers/pdf-worker/src/{generated-assets.ts → assets/generated-assets.ts} +117 -117
- package/workers/pdf-worker/src/{format-striae.ts → formats/format-striae.ts} +535 -535
- package/workers/pdf-worker/src/pdf-worker.example.ts +1 -1
- package/app/services/audit-export.service.ts +0 -755
- package/app/services/audit.service.ts +0 -1474
- package/public/favicon.svg +0 -9
- /package/app/services/{firebase-errors.ts → firebase/errors.ts} +0 -0
- /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
|
|
10
|
-
|
|
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)
|
|
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
|
|
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 {
|
|
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
|
|
16
|
+
import { auditService } from '~/services/audit';
|
|
11
17
|
|
|
12
18
|
type TabularRow = Array<string | number | boolean | null | undefined>;
|
|
13
|
-
type ExcelJsBrowserBundle = typeof
|
|
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(
|
|
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(
|
|
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(
|
|
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,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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
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 {
|
|
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
|
|
|
@@ -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 {
|
|
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
|
}
|