@striae-org/striae 4.3.3 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/app/components/actions/case-export/download-handlers.ts +60 -4
- package/app/components/actions/case-import/confirmation-import.ts +50 -7
- package/app/components/actions/case-import/confirmation-package.ts +99 -22
- package/app/components/actions/case-import/orchestrator.ts +116 -13
- package/app/components/actions/case-import/validation.ts +171 -7
- package/app/components/actions/case-import/zip-processing.ts +224 -127
- package/app/components/actions/case-manage.ts +110 -10
- package/app/components/actions/confirm-export.ts +32 -3
- package/app/components/audit/user-audit.module.css +49 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +130 -48
- package/app/components/navbar/navbar.module.css +0 -10
- package/app/components/navbar/navbar.tsx +0 -22
- package/app/components/sidebar/case-import/case-import.module.css +7 -131
- package/app/components/sidebar/case-import/case-import.tsx +7 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
- package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
- package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
- package/app/config-example/config.json +5 -0
- package/app/routes/auth/login.tsx +1 -1
- package/app/services/audit/audit-console-logger.ts +1 -1
- package/app/services/audit/audit-export-csv.ts +1 -1
- package/app/services/audit/audit-export-signing.ts +2 -2
- package/app/services/audit/audit-export.service.ts +1 -1
- package/app/services/audit/audit-worker-client.ts +1 -1
- package/app/services/audit/audit.service.ts +5 -75
- package/app/services/audit/builders/audit-event-builders-case-file.ts +3 -0
- package/app/services/audit/index.ts +2 -2
- package/app/types/audit.ts +8 -7
- package/app/utils/data/operations/signing-operations.ts +93 -0
- package/app/utils/data/operations/types.ts +6 -0
- package/app/utils/forensics/export-encryption.ts +316 -0
- package/app/utils/forensics/export-verification.ts +1 -409
- package/app/utils/forensics/index.ts +1 -0
- package/app/utils/ui/case-messages.ts +5 -2
- package/package.json +1 -1
- package/scripts/deploy-config.sh +97 -3
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/data-worker.example.ts +130 -0
- package/workers/data-worker/src/encryption-utils.ts +125 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +2 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
package/.env.example
CHANGED
|
@@ -66,6 +66,10 @@ DATA_WORKER_DOMAIN=your_data_worker_domain_here
|
|
|
66
66
|
MANIFEST_SIGNING_PRIVATE_KEY=your_manifest_signing_private_key_here
|
|
67
67
|
MANIFEST_SIGNING_KEY_ID=your_manifest_signing_key_id_here
|
|
68
68
|
MANIFEST_SIGNING_PUBLIC_KEY=your_manifest_signing_public_key_here
|
|
69
|
+
EXPORT_ENCRYPTION_PRIVATE_KEY=your_export_encryption_private_key_here
|
|
70
|
+
EXPORT_ENCRYPTION_KEY_ID=your_export_encryption_key_id_here
|
|
71
|
+
EXPORT_ENCRYPTION_PUBLIC_KEY=your_export_encryption_public_key_here
|
|
72
|
+
|
|
69
73
|
|
|
70
74
|
# ================================
|
|
71
75
|
# AUDIT WORKER ENVIRONMENT VARIABLES
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
calculateSHA256Secure,
|
|
8
8
|
createPublicSigningKeyFileName,
|
|
9
9
|
getCurrentPublicSigningKeyDetails,
|
|
10
|
-
getVerificationPublicKey
|
|
10
|
+
getVerificationPublicKey,
|
|
11
|
+
getCurrentEncryptionPublicKeyDetails,
|
|
12
|
+
encryptExportDataWithAllImages
|
|
11
13
|
} from '~/utils/forensics';
|
|
12
14
|
import { signForensicManifest } from '~/utils/data';
|
|
13
15
|
import { type ExportFormat, formatDateForFilename, CSV_HEADERS } from './types-constants';
|
|
@@ -717,6 +719,56 @@ export async function downloadCaseAsZip(
|
|
|
717
719
|
// Add dedicated forensic manifest file for validation
|
|
718
720
|
zip.file('FORENSIC_MANIFEST.json', JSON.stringify(signedForensicManifest, null, 2));
|
|
719
721
|
|
|
722
|
+
// Export encryption is mandatory
|
|
723
|
+
const encKeyDetails = getCurrentEncryptionPublicKeyDetails();
|
|
724
|
+
|
|
725
|
+
if (!encKeyDetails.publicKeyPem || !encKeyDetails.keyId) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
'Export encryption is mandatory. Your Striae instance does not have a configured encryption public key. ' +
|
|
728
|
+
'Please contact your administrator to set up export encryption.'
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let encryptionManifestJson: string | null = null;
|
|
733
|
+
const isEncrypted = true;
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
// Build image blobs array from the collected imageFiles
|
|
737
|
+
const imagesToEncrypt = Object.entries(imageFiles).map(([filename, blob]) => ({
|
|
738
|
+
filename,
|
|
739
|
+
blob
|
|
740
|
+
}));
|
|
741
|
+
|
|
742
|
+
// Encrypt data file and all images with shared AES key
|
|
743
|
+
const encryptionResult = await encryptExportDataWithAllImages(
|
|
744
|
+
contentForHash,
|
|
745
|
+
imagesToEncrypt,
|
|
746
|
+
encKeyDetails.publicKeyPem,
|
|
747
|
+
encKeyDetails.keyId
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
// Replace data file with encrypted ciphertext
|
|
751
|
+
zip.file(`${caseNumber}_data.${format}`, encryptionResult.ciphertext);
|
|
752
|
+
|
|
753
|
+
// Replace images in the ZIP with encrypted versions
|
|
754
|
+
if (imageFolder && encryptionResult.encryptedImages.length > 0) {
|
|
755
|
+
for (let i = 0; i < imagesToEncrypt.length; i++) {
|
|
756
|
+
const originalFilename = imagesToEncrypt[i].filename;
|
|
757
|
+
// Remove the original file and add encrypted version
|
|
758
|
+
imageFolder.file(originalFilename, encryptionResult.encryptedImages[i]);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Add encryption manifest
|
|
763
|
+
encryptionManifestJson = JSON.stringify(encryptionResult.encryptionManifest, null, 2);
|
|
764
|
+
zip.file('ENCRYPTION_MANIFEST.json', encryptionManifestJson);
|
|
765
|
+
|
|
766
|
+
onProgress?.(80);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
console.error('Export encryption failed:', error);
|
|
769
|
+
throw new Error(`Failed to encrypt export: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
720
772
|
// Add read-only instruction file
|
|
721
773
|
const instructionContent = `EVIDENCE ARCHIVE - READ ONLY
|
|
722
774
|
|
|
@@ -727,11 +779,13 @@ IMPORTANT WARNINGS:
|
|
|
727
779
|
- Do not modify, rename, or delete any files in this archive
|
|
728
780
|
- Any modifications may compromise evidence integrity
|
|
729
781
|
- Maintain proper chain of custody procedures
|
|
782
|
+
${isEncrypted ? '- This archive is encrypted. Only Striae can decrypt and re-import it.\n' : ''}
|
|
730
783
|
|
|
731
784
|
Archive Contents:
|
|
732
|
-
- ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format
|
|
733
|
-
- images/:
|
|
785
|
+
- ${caseNumber}_data.${format}: Complete case data in ${format.toUpperCase()} format${isEncrypted ? ' (encrypted)' : ''}
|
|
786
|
+
- images/: Image files with annotations${isEncrypted ? ' (encrypted)' : ''}
|
|
734
787
|
- FORENSIC_MANIFEST.json: File integrity validation manifest
|
|
788
|
+
${isEncrypted ? '- ENCRYPTION_MANIFEST.json: Encryption metadata and encrypted file hashes\n' : ''}
|
|
735
789
|
- ${publicKeyFileName}: Public signing key PEM for verification
|
|
736
790
|
- README.txt: General information about this export
|
|
737
791
|
|
|
@@ -743,6 +797,7 @@ Case Information:
|
|
|
743
797
|
- Total Annotations: ${(exportData.summary?.filesWithAnnotations || 0) + (exportData.summary?.totalBoxAnnotations || 0)}
|
|
744
798
|
- Total Confirmations: ${exportData.summary?.filesWithConfirmations || 0}
|
|
745
799
|
- Confirmations Requested: ${exportData.summary?.filesWithConfirmationsRequested || 0}
|
|
800
|
+
${isEncrypted ? `- Encryption Status: ENCRYPTED (key ID: ${encKeyDetails.keyId})\n` : ''}
|
|
746
801
|
|
|
747
802
|
For questions about this export, contact your Striae system administrator.
|
|
748
803
|
`;
|
|
@@ -769,7 +824,8 @@ For questions about this export, contact your Striae system administrator.
|
|
|
769
824
|
// Download
|
|
770
825
|
const url = URL.createObjectURL(zipBlob);
|
|
771
826
|
const protectionSuffix = options.protectForensicData ? '-protected' : '';
|
|
772
|
-
const
|
|
827
|
+
const encryptedSuffix = isEncrypted ? '-encrypted' : '';
|
|
828
|
+
const exportFileName = `striae-case-${caseNumber}-export${protectionSuffix}${encryptedSuffix}-${formatDateForFilename(new Date())}.zip`;
|
|
773
829
|
|
|
774
830
|
const linkElement = document.createElement('a');
|
|
775
831
|
linkElement.href = url;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
2
|
import { fetchDataApi } from '~/utils/api';
|
|
3
|
-
import { upsertFileConfirmationSummary } from '~/utils/data';
|
|
3
|
+
import { upsertFileConfirmationSummary, decryptExportBatch } from '~/utils/data';
|
|
4
4
|
import { type AnnotationData, type ConfirmationImportResult, type ConfirmationImportData } from '~/types';
|
|
5
|
+
import type { EncryptionManifest } from '~/utils/forensics/export-encryption';
|
|
5
6
|
import { checkExistingCase } from '../case-manage';
|
|
6
7
|
import { extractConfirmationImportPackage } from './confirmation-package';
|
|
7
8
|
import { validateExporterUid, validateConfirmationHash, validateConfirmationSignatureFile } from './validation';
|
|
@@ -23,6 +24,21 @@ type AnnotationImportData = Record<string, unknown> & {
|
|
|
23
24
|
updatedAt?: string;
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
function isEncryptionManifest(value: unknown): value is EncryptionManifest {
|
|
28
|
+
if (!value || typeof value !== 'object') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const candidate = value as Partial<EncryptionManifest>;
|
|
33
|
+
return (
|
|
34
|
+
typeof candidate.encryptionVersion === 'string' &&
|
|
35
|
+
typeof candidate.algorithm === 'string' &&
|
|
36
|
+
typeof candidate.keyId === 'string' &&
|
|
37
|
+
typeof candidate.wrappedKey === 'string' &&
|
|
38
|
+
typeof candidate.dataIv === 'string'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
/**
|
|
27
43
|
* Import confirmation data from JSON file
|
|
28
44
|
*/
|
|
@@ -52,12 +68,39 @@ export async function importConfirmationData(
|
|
|
52
68
|
try {
|
|
53
69
|
onProgress?.('Reading confirmation file', 10, 'Loading confirmation package...');
|
|
54
70
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
const packageData = await extractConfirmationImportPackage(confirmationFile);
|
|
72
|
+
|
|
73
|
+
let confirmationData = packageData.confirmationData;
|
|
74
|
+
let confirmationJsonContent = packageData.confirmationJsonContent;
|
|
75
|
+
const verificationPublicKeyPem = packageData.verificationPublicKeyPem;
|
|
76
|
+
const confirmationFileName = packageData.confirmationFileName;
|
|
77
|
+
|
|
78
|
+
// Handle encrypted confirmation data
|
|
79
|
+
if (packageData.isEncrypted && packageData.encryptionManifest && packageData.encryptedDataBase64) {
|
|
80
|
+
onProgress?.('Decrypting confirmation data', 15, 'Decrypting exported confirmation...');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
if (!isEncryptionManifest(packageData.encryptionManifest)) {
|
|
84
|
+
throw new Error('Invalid encryption manifest format.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const decryptResult = await decryptExportBatch(
|
|
88
|
+
user,
|
|
89
|
+
packageData.encryptionManifest,
|
|
90
|
+
packageData.encryptedDataBase64,
|
|
91
|
+
{} // No image hashes for confirmation-only exports
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Parse decrypted plaintext as confirmation data
|
|
95
|
+
const decryptedJsonString = decryptResult.plaintext;
|
|
96
|
+
confirmationData = JSON.parse(decryptedJsonString) as ConfirmationImportData;
|
|
97
|
+
confirmationJsonContent = decryptedJsonString;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Failed to decrypt confirmation data: ${error instanceof Error ? error.message : 'Unknown decryption error'}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
61
104
|
|
|
62
105
|
confirmationDataForAudit = confirmationData;
|
|
63
106
|
confirmationJsonFileNameForAudit = confirmationFileName;
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
import { type ConfirmationImportData } from '~/types';
|
|
2
2
|
|
|
3
3
|
const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
|
|
4
|
+
const ENCRYPTION_MANIFEST_FILE_NAME = 'encryption_manifest.json';
|
|
5
|
+
|
|
6
|
+
function uint8ArrayToBase64Url(data: Uint8Array): string {
|
|
7
|
+
const chunkSize = 8192;
|
|
8
|
+
let binaryString = '';
|
|
9
|
+
|
|
10
|
+
for (let index = 0; index < data.length; index += chunkSize) {
|
|
11
|
+
const chunk = data.subarray(index, Math.min(index + chunkSize, data.length));
|
|
12
|
+
|
|
13
|
+
for (let chunkIndex = 0; chunkIndex < chunk.length; chunkIndex += 1) {
|
|
14
|
+
binaryString += String.fromCharCode(chunk[chunkIndex]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return btoa(binaryString)
|
|
19
|
+
.replace(/\+/g, '-')
|
|
20
|
+
.replace(/\//g, '_')
|
|
21
|
+
.replace(/=+$/g, '');
|
|
22
|
+
}
|
|
4
23
|
|
|
5
24
|
export interface ConfirmationImportPackage {
|
|
6
25
|
confirmationData: ConfirmationImportData;
|
|
7
26
|
confirmationJsonContent: string;
|
|
8
27
|
verificationPublicKeyPem?: string;
|
|
9
28
|
confirmationFileName: string;
|
|
29
|
+
isEncrypted?: boolean;
|
|
30
|
+
encryptionManifest?: unknown;
|
|
31
|
+
encryptedDataBase64?: string;
|
|
10
32
|
}
|
|
11
33
|
|
|
12
34
|
function getLeafFileName(path: string): string {
|
|
@@ -32,22 +54,78 @@ async function extractConfirmationPackageFromZip(file: File): Promise<Confirmati
|
|
|
32
54
|
const zip = await JSZip.loadAsync(file);
|
|
33
55
|
const fileEntries = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
|
|
34
56
|
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
// Check for encryption manifest first
|
|
58
|
+
const hasEncryptionManifest = fileEntries.some((path) =>
|
|
59
|
+
getLeafFileName(path).toLowerCase() === ENCRYPTION_MANIFEST_FILE_NAME
|
|
37
60
|
);
|
|
38
61
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
let confirmationData: ConfirmationImportData;
|
|
63
|
+
let confirmationJsonContent: string;
|
|
64
|
+
let confirmationFileName: string;
|
|
65
|
+
let isEncrypted = false;
|
|
66
|
+
let encryptionManifest: unknown;
|
|
67
|
+
let encryptedDataBase64: string | undefined;
|
|
68
|
+
|
|
69
|
+
if (hasEncryptionManifest) {
|
|
70
|
+
// Handle encrypted confirmation export
|
|
71
|
+
isEncrypted = true;
|
|
72
|
+
|
|
73
|
+
// Read encryption manifest
|
|
74
|
+
const manifestPath = fileEntries.find((path) =>
|
|
75
|
+
getLeafFileName(path).toLowerCase() === ENCRYPTION_MANIFEST_FILE_NAME
|
|
76
|
+
);
|
|
77
|
+
if (!manifestPath) {
|
|
78
|
+
throw new Error('Encrypted confirmation ZIP is missing ENCRYPTION_MANIFEST.json.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const manifestFile = zip.file(manifestPath);
|
|
82
|
+
if (!manifestFile) {
|
|
83
|
+
throw new Error('Failed to read ENCRYPTION_MANIFEST.json from encrypted confirmation ZIP package.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const manifestContent = await manifestFile.async('text');
|
|
87
|
+
if (manifestContent.trim().length === 0) {
|
|
88
|
+
throw new Error('ENCRYPTION_MANIFEST.json is empty in the encrypted confirmation ZIP package.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
encryptionManifest = JSON.parse(manifestContent);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error('ENCRYPTION_MANIFEST.json is invalid in the encrypted confirmation ZIP package.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find and read encrypted confirmation data file
|
|
98
|
+
const confirmationPaths = fileEntries.filter((path) =>
|
|
99
|
+
CONFIRMATION_EXPORT_FILE_REGEX.test(getLeafFileName(path))
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (confirmationPaths.length !== 1) {
|
|
103
|
+
throw new Error('Encrypted confirmation ZIP must contain exactly one confirmation-data file.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const confirmationPath = confirmationPaths[0];
|
|
107
|
+
const encryptedContent = await zip.file(confirmationPath)?.async('uint8array');
|
|
108
|
+
if (!encryptedContent) {
|
|
109
|
+
throw new Error('Failed to read encrypted confirmation data from ZIP package.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
encryptedDataBase64 = uint8ArrayToBase64Url(encryptedContent);
|
|
113
|
+
confirmationFileName = getLeafFileName(confirmationPath);
|
|
114
|
+
|
|
115
|
+
// For encrypted data, return placeholder confirmationData for now
|
|
116
|
+
// The actual decryption will happen in confirmation-import.ts
|
|
117
|
+
confirmationData = {
|
|
118
|
+
metadata: {},
|
|
119
|
+
confirmations: {}
|
|
120
|
+
} as ConfirmationImportData;
|
|
121
|
+
confirmationJsonContent = encryptedDataBase64;
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Confirmation imports now require an encrypted confirmation ZIP package exported from Striae. ' +
|
|
125
|
+
'Legacy plaintext confirmation ZIP packages are no longer supported.'
|
|
126
|
+
);
|
|
47
127
|
}
|
|
48
128
|
|
|
49
|
-
const confirmationData = JSON.parse(confirmationJsonContent) as ConfirmationImportData;
|
|
50
|
-
|
|
51
129
|
const pemPaths = fileEntries.filter((path) => getLeafFileName(path).toLowerCase().endsWith('.pem'));
|
|
52
130
|
const preferredPemPath = selectPreferredPemPath(pemPaths);
|
|
53
131
|
|
|
@@ -60,7 +138,10 @@ async function extractConfirmationPackageFromZip(file: File): Promise<Confirmati
|
|
|
60
138
|
confirmationData,
|
|
61
139
|
confirmationJsonContent,
|
|
62
140
|
verificationPublicKeyPem,
|
|
63
|
-
confirmationFileName
|
|
141
|
+
confirmationFileName,
|
|
142
|
+
isEncrypted,
|
|
143
|
+
encryptionManifest,
|
|
144
|
+
encryptedDataBase64
|
|
64
145
|
};
|
|
65
146
|
}
|
|
66
147
|
|
|
@@ -68,19 +149,15 @@ export async function extractConfirmationImportPackage(file: File): Promise<Conf
|
|
|
68
149
|
const lowerName = file.name.toLowerCase();
|
|
69
150
|
|
|
70
151
|
if (lowerName.endsWith('.json')) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
confirmationData,
|
|
76
|
-
confirmationJsonContent,
|
|
77
|
-
confirmationFileName: file.name
|
|
78
|
-
};
|
|
152
|
+
throw new Error(
|
|
153
|
+
'Confirmation imports now require an encrypted confirmation ZIP package exported from Striae. ' +
|
|
154
|
+
'Plaintext confirmation JSON files are no longer supported.'
|
|
155
|
+
);
|
|
79
156
|
}
|
|
80
157
|
|
|
81
158
|
if (lowerName.endsWith('.zip')) {
|
|
82
159
|
return extractConfirmationPackageFromZip(file);
|
|
83
160
|
}
|
|
84
161
|
|
|
85
|
-
throw new Error('Unsupported confirmation import file type. Use
|
|
162
|
+
throw new Error('Unsupported confirmation import file type. Use an encrypted confirmation ZIP package exported from Striae.');
|
|
86
163
|
}
|
|
@@ -5,13 +5,15 @@ import {
|
|
|
5
5
|
type ReadOnlyCaseMetadata,
|
|
6
6
|
type FileData,
|
|
7
7
|
type BundledAuditTrailData,
|
|
8
|
-
type ValidationAuditEntry
|
|
8
|
+
type ValidationAuditEntry,
|
|
9
|
+
type CaseExportData
|
|
9
10
|
} from '~/types';
|
|
10
|
-
import { checkExistingCase } from '../case-manage';
|
|
11
|
+
import { checkExistingCase, validateCaseNumber } from '../case-manage';
|
|
11
12
|
import {
|
|
12
13
|
type SignedForensicManifest,
|
|
13
14
|
verifyCasePackageIntegrity
|
|
14
15
|
} from '~/utils/forensics';
|
|
16
|
+
import type { EncryptionManifest } from '~/utils/forensics/export-encryption';
|
|
15
17
|
import { deleteFile } from '../image-manage';
|
|
16
18
|
import { parseImportZip } from './zip-processing';
|
|
17
19
|
import {
|
|
@@ -25,6 +27,8 @@ import {
|
|
|
25
27
|
import { uploadImageBlob } from './image-operations';
|
|
26
28
|
import { importAnnotations } from './annotation-import';
|
|
27
29
|
import { auditService } from '~/services/audit';
|
|
30
|
+
import { decryptExportBatch } from '~/utils/data/operations/signing-operations';
|
|
31
|
+
import { validateCaseExporterUidForImport } from './validation';
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* Track the state of an import operation for cleanup purposes
|
|
@@ -46,6 +50,22 @@ interface BundledAuditTrailFile {
|
|
|
46
50
|
};
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
function isEncryptionManifest(value: unknown): value is EncryptionManifest {
|
|
54
|
+
if (!value || typeof value !== 'object') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const candidate = value as Partial<EncryptionManifest>;
|
|
59
|
+
return (
|
|
60
|
+
typeof candidate.encryptionVersion === 'string' &&
|
|
61
|
+
typeof candidate.algorithm === 'string' &&
|
|
62
|
+
typeof candidate.keyId === 'string' &&
|
|
63
|
+
typeof candidate.wrappedKey === 'string' &&
|
|
64
|
+
typeof candidate.dataIv === 'string' &&
|
|
65
|
+
Array.isArray(candidate.encryptedImages)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
function extractBundledAuditTrailData(
|
|
50
70
|
bundledAuditFiles: {
|
|
51
71
|
auditTrailContent?: string;
|
|
@@ -179,21 +199,104 @@ export async function importCaseForReview(
|
|
|
179
199
|
let signatureValidationPassed = false;
|
|
180
200
|
let signatureKeyId: string | undefined;
|
|
181
201
|
let parsedForensicManifest: SignedForensicManifest | undefined;
|
|
202
|
+
let exporterUidValidationPassed = false;
|
|
182
203
|
|
|
183
204
|
try {
|
|
184
205
|
onProgress?.('Parsing ZIP file', 10, 'Extracting archive contents...');
|
|
185
206
|
|
|
186
207
|
// Step 1: Parse ZIP file
|
|
187
208
|
const {
|
|
188
|
-
caseData,
|
|
189
|
-
imageFiles,
|
|
209
|
+
caseData: initialCaseData,
|
|
210
|
+
imageFiles: initialImageFiles,
|
|
190
211
|
imageIdMapping,
|
|
191
212
|
isArchivedExport,
|
|
192
213
|
bundledAuditFiles,
|
|
193
214
|
metadata,
|
|
194
|
-
cleanedContent,
|
|
195
|
-
verificationPublicKeyPem
|
|
215
|
+
cleanedContent: initialCleanedContent,
|
|
216
|
+
verificationPublicKeyPem,
|
|
217
|
+
encryptionManifest,
|
|
218
|
+
encryptedDataBase64,
|
|
219
|
+
encryptedImages,
|
|
220
|
+
isEncrypted
|
|
196
221
|
} = await parseImportZip(zipFile, user);
|
|
222
|
+
|
|
223
|
+
// Step 1.2: Handle decryption if export is encrypted
|
|
224
|
+
let caseData = initialCaseData;
|
|
225
|
+
let cleanedContent = initialCleanedContent || '';
|
|
226
|
+
let imageFiles = initialImageFiles;
|
|
227
|
+
let resolvedBundledAuditFiles = bundledAuditFiles;
|
|
228
|
+
let decryptedImageBlobMap: { [filename: string]: Blob } | undefined;
|
|
229
|
+
|
|
230
|
+
if (isEncrypted && isEncryptionManifest(encryptionManifest) && encryptedDataBase64) {
|
|
231
|
+
onProgress?.('Decrypting export', 11, 'Decrypting case data and images...');
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
// Call decrypt endpoint on data-worker
|
|
235
|
+
const decryptResult = await decryptExportBatch(
|
|
236
|
+
user,
|
|
237
|
+
encryptionManifest,
|
|
238
|
+
encryptedDataBase64,
|
|
239
|
+
encryptedImages ?? {}
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Decrypted data is plaintext JSON
|
|
243
|
+
cleanedContent = decryptResult.plaintext;
|
|
244
|
+
const parsedCaseData = JSON.parse(cleanedContent) as unknown;
|
|
245
|
+
caseData = parsedCaseData as CaseExportData;
|
|
246
|
+
|
|
247
|
+
const decryptedFiles = decryptResult.decryptedImages;
|
|
248
|
+
const decryptedAuditTrailBlob = decryptedFiles['audit/case-audit-trail.json'];
|
|
249
|
+
const decryptedAuditSignatureBlob = decryptedFiles['audit/case-audit-signature.json'];
|
|
250
|
+
|
|
251
|
+
if (decryptedAuditTrailBlob || decryptedAuditSignatureBlob) {
|
|
252
|
+
resolvedBundledAuditFiles = {
|
|
253
|
+
...(resolvedBundledAuditFiles ?? {}),
|
|
254
|
+
auditTrailContent: decryptedAuditTrailBlob
|
|
255
|
+
? await decryptedAuditTrailBlob.text()
|
|
256
|
+
: resolvedBundledAuditFiles?.auditTrailContent,
|
|
257
|
+
auditSignatureContent: decryptedAuditSignatureBlob
|
|
258
|
+
? await decryptedAuditSignatureBlob.text()
|
|
259
|
+
: resolvedBundledAuditFiles?.auditSignatureContent
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
decryptedImageBlobMap = Object.fromEntries(
|
|
264
|
+
Object.entries(decryptedFiles).filter(([filename]) => !filename.startsWith('audit/'))
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Update imageFiles with decrypted images only
|
|
268
|
+
imageFiles = { ...imageFiles, ...decryptedImageBlobMap };
|
|
269
|
+
|
|
270
|
+
onProgress?.('Decryption successful', 13, `Decrypted case data and ${Object.keys(decryptedImageBlobMap).length} images`);
|
|
271
|
+
} catch (decryptError) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Failed to decrypt export: ${decryptError instanceof Error ? decryptError.message : 'Unknown error'}. ` +
|
|
274
|
+
'Ensure your Striae instance has export encryption configured.'
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isEncrypted) {
|
|
280
|
+
await validateCaseExporterUidForImport(caseData, user);
|
|
281
|
+
exporterUidValidationPassed = true;
|
|
282
|
+
} else {
|
|
283
|
+
exporterUidValidationPassed = true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Now validate case number and format
|
|
287
|
+
if (!caseData.metadata?.caseNumber) {
|
|
288
|
+
throw new Error('Invalid case data: missing case number');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!validateCaseNumber(caseData.metadata.caseNumber)) {
|
|
292
|
+
throw new Error(`Invalid case number format: ${caseData.metadata.caseNumber}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const resolvedIsArchivedExport =
|
|
296
|
+
isArchivedExport ||
|
|
297
|
+
caseData.metadata.archived === true ||
|
|
298
|
+
(typeof caseData.metadata.archivedAt === 'string' && caseData.metadata.archivedAt.trim().length > 0);
|
|
299
|
+
|
|
197
300
|
parsedForensicManifest = metadata?.forensicManifest as SignedForensicManifest | undefined;
|
|
198
301
|
result.caseNumber = caseData.metadata.caseNumber;
|
|
199
302
|
importState.caseNumber = result.caseNumber;
|
|
@@ -240,7 +343,7 @@ export async function importCaseForReview(
|
|
|
240
343
|
imageFiles: imageBlobs,
|
|
241
344
|
forensicManifest: parsedForensicManifest,
|
|
242
345
|
verificationPublicKeyPem,
|
|
243
|
-
bundledAuditFiles
|
|
346
|
+
bundledAuditFiles: resolvedBundledAuditFiles
|
|
244
347
|
});
|
|
245
348
|
|
|
246
349
|
signatureValidationPassed = casePackageResult.signatureResult.isValid;
|
|
@@ -283,10 +386,10 @@ export async function importCaseForReview(
|
|
|
283
386
|
|
|
284
387
|
// Step 2a: Check if case already exists in user's regular cases (original analyst)
|
|
285
388
|
const existingRegularCase = await checkExistingCase(user, result.caseNumber);
|
|
286
|
-
if (existingRegularCase && !
|
|
389
|
+
if (existingRegularCase && !resolvedIsArchivedExport) {
|
|
287
390
|
throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
|
|
288
391
|
}
|
|
289
|
-
if (existingRegularCase &&
|
|
392
|
+
if (existingRegularCase && resolvedIsArchivedExport) {
|
|
290
393
|
throw new Error(`Cannot import this archived case because "${result.caseNumber}" already exists in your regular case list. Delete the regular case before importing this archive.`);
|
|
291
394
|
}
|
|
292
395
|
|
|
@@ -366,8 +469,8 @@ export async function importCaseForReview(
|
|
|
366
469
|
importedFiles,
|
|
367
470
|
originalImageIdMapping,
|
|
368
471
|
parsedForensicManifest,
|
|
369
|
-
|
|
370
|
-
|
|
472
|
+
resolvedIsArchivedExport,
|
|
473
|
+
resolvedIsArchivedExport ? extractBundledAuditTrailData(resolvedBundledAuditFiles) : undefined
|
|
371
474
|
);
|
|
372
475
|
importState.caseDataStored = true;
|
|
373
476
|
|
|
@@ -414,7 +517,7 @@ export async function importCaseForReview(
|
|
|
414
517
|
validationStepsCompleted: result.filesImported + result.annotationsImported,
|
|
415
518
|
validationStepsFailed: 0
|
|
416
519
|
},
|
|
417
|
-
|
|
520
|
+
exporterUidValidationPassed,
|
|
418
521
|
{
|
|
419
522
|
present: !!parsedForensicManifest,
|
|
420
523
|
valid: signatureValidationPassed,
|
|
@@ -445,7 +548,7 @@ export async function importCaseForReview(
|
|
|
445
548
|
processingTimeMs: endTime - startTime,
|
|
446
549
|
fileSizeBytes: zipFile.size
|
|
447
550
|
},
|
|
448
|
-
|
|
551
|
+
exporterUidValidationPassed,
|
|
449
552
|
{
|
|
450
553
|
present: !!parsedForensicManifest,
|
|
451
554
|
valid: signatureValidationPassed,
|