@striae-org/striae 3.2.2 → 4.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 +1 -1
- package/app/components/actions/case-export/core-export.ts +5 -2
- package/app/components/actions/case-export/download-handlers.ts +51 -3
- package/app/components/actions/case-import/confirmation-import.ts +65 -40
- package/app/components/actions/case-import/confirmation-package.ts +86 -0
- package/app/components/actions/case-import/image-operations.ts +20 -49
- package/app/components/actions/case-import/index.ts +1 -0
- package/app/components/actions/case-import/orchestrator.ts +13 -3
- package/app/components/actions/case-import/storage-operations.ts +54 -89
- package/app/components/actions/case-import/validation.ts +7 -111
- package/app/components/actions/case-import/zip-processing.ts +44 -2
- package/app/components/actions/case-manage.ts +15 -27
- package/app/components/actions/confirm-export.ts +44 -13
- package/app/components/actions/generate-pdf.ts +3 -7
- package/app/components/actions/image-manage.ts +63 -129
- package/app/components/button/button.module.css +12 -8
- 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 +13 -60
- 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 +122 -52
- package/app/components/sidebar/cases/cases.module.css +101 -18
- package/app/components/sidebar/notes/notes.module.css +33 -13
- package/app/components/sidebar/sidebar.module.css +0 -2
- package/app/components/user/delete-account.tsx +7 -7
- package/app/components/user/manage-profile.tsx +1 -1
- package/app/components/user/mfa-phone-update.tsx +15 -12
- package/app/config-example/config.json +2 -8
- package/app/hooks/useInactivityTimeout.ts +2 -5
- package/app/root.tsx +96 -65
- package/app/routes/auth/login.tsx +132 -11
- package/app/routes/auth/route.ts +4 -3
- package/app/routes/striae/striae.tsx +4 -8
- package/app/services/audit/audit-api-client.ts +40 -0
- package/app/services/audit/audit-worker-client.ts +14 -17
- package/app/styles/root.module.css +13 -101
- package/app/tailwind.css +9 -2
- package/app/utils/SHA256.ts +5 -1
- package/app/utils/auth.ts +5 -32
- package/app/utils/confirmation-signature.ts +5 -1
- package/app/utils/data-api-client.ts +43 -0
- package/app/utils/data-operations.ts +59 -75
- package/app/utils/export-verification.ts +353 -0
- package/app/utils/image-api-client.ts +130 -0
- package/app/utils/pdf-api-client.ts +43 -0
- package/app/utils/permissions.ts +10 -23
- package/app/utils/signature-utils.ts +74 -4
- package/app/utils/user-api-client.ts +90 -0
- package/functions/api/_shared/firebase-auth.ts +255 -0
- package/functions/api/audit/[[path]].ts +150 -0
- package/functions/api/data/[[path]].ts +141 -0
- package/functions/api/image/[[path]].ts +127 -0
- package/functions/api/pdf/[[path]].ts +110 -0
- package/functions/api/user/[[path]].ts +196 -0
- package/package.json +8 -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/scripts/deploy-all.sh +22 -8
- package/scripts/deploy-config.sh +143 -148
- package/scripts/deploy-pages-secrets.sh +231 -0
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -8
- package/workers/data-worker/wrangler.jsonc.example +1 -8
- package/workers/image-worker/wrangler.jsonc.example +1 -8
- package/workers/keys-worker/wrangler.jsonc.example +2 -9
- package/workers/pdf-worker/scripts/generate-assets.js +94 -0
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -8
- package/workers/user-worker/src/user-worker.example.ts +121 -41
- package/workers/user-worker/wrangler.jsonc.example +1 -8
- package/wrangler.toml.example +1 -1
- package/app/styles/legal-pages.module.css +0 -113
- package/public/favicon.svg +0 -9
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { User } from 'firebase/auth';
|
|
8
8
|
import { type CaseData, type AnnotationData, type ConfirmationImportData } from '~/types';
|
|
9
|
-
import
|
|
10
|
-
import { getDataApiKey } from './auth';
|
|
9
|
+
import { fetchDataApi } from './data-api-client';
|
|
11
10
|
import { validateUserSession, canAccessCase, canModifyCase } from './permissions';
|
|
12
11
|
import {
|
|
13
12
|
type ForensicManifestData,
|
|
@@ -21,8 +20,6 @@ import {
|
|
|
21
20
|
isValidAuditExportSigningPayload
|
|
22
21
|
} from './audit-export-signature';
|
|
23
22
|
|
|
24
|
-
const DATA_WORKER_URL = paths.data_worker_url;
|
|
25
|
-
|
|
26
23
|
// ============================================================================
|
|
27
24
|
// INTERFACES AND TYPES
|
|
28
25
|
// ============================================================================
|
|
@@ -101,15 +98,13 @@ export const getCaseData = async (
|
|
|
101
98
|
throw new Error('Invalid case number provided');
|
|
102
99
|
}
|
|
103
100
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
headers: {
|
|
110
|
-
'X-Custom-Auth-Key': apiKey
|
|
101
|
+
const response = await fetchDataApi(
|
|
102
|
+
user,
|
|
103
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
104
|
+
{
|
|
105
|
+
method: 'GET'
|
|
111
106
|
}
|
|
112
|
-
|
|
107
|
+
);
|
|
113
108
|
|
|
114
109
|
if (response.status === 404) {
|
|
115
110
|
return null; // Case not found
|
|
@@ -163,23 +158,23 @@ export const updateCaseData = async (
|
|
|
163
158
|
throw new Error('Invalid case data provided');
|
|
164
159
|
}
|
|
165
160
|
|
|
166
|
-
const apiKey = await getDataApiKey();
|
|
167
|
-
const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
|
|
168
|
-
|
|
169
161
|
// Add timestamp if requested (default: true)
|
|
170
162
|
const dataToSave = options.includeTimestamp !== false ? {
|
|
171
163
|
...caseData,
|
|
172
164
|
updatedAt: new Date().toISOString()
|
|
173
165
|
} : caseData;
|
|
174
166
|
|
|
175
|
-
const response = await
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
'
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
167
|
+
const response = await fetchDataApi(
|
|
168
|
+
user,
|
|
169
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
170
|
+
{
|
|
171
|
+
method: 'PUT',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json'
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify(dataToSave)
|
|
176
|
+
}
|
|
177
|
+
);
|
|
183
178
|
|
|
184
179
|
if (!response.ok) {
|
|
185
180
|
throw new Error(`Failed to update case data: ${response.status} ${response.statusText}`);
|
|
@@ -216,15 +211,13 @@ export const deleteCaseData = async (
|
|
|
216
211
|
}
|
|
217
212
|
}
|
|
218
213
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
headers: {
|
|
225
|
-
'X-Custom-Auth-Key': apiKey
|
|
214
|
+
const response = await fetchDataApi(
|
|
215
|
+
user,
|
|
216
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
217
|
+
{
|
|
218
|
+
method: 'DELETE'
|
|
226
219
|
}
|
|
227
|
-
|
|
220
|
+
);
|
|
228
221
|
|
|
229
222
|
if (!response.ok && response.status !== 404) {
|
|
230
223
|
throw new Error(`Failed to delete case data: ${response.status} ${response.statusText}`);
|
|
@@ -269,15 +262,13 @@ export const getFileAnnotations = async (
|
|
|
269
262
|
throw new Error('Invalid file ID provided');
|
|
270
263
|
}
|
|
271
264
|
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
headers: {
|
|
278
|
-
'X-Custom-Auth-Key': apiKey
|
|
265
|
+
const response = await fetchDataApi(
|
|
266
|
+
user,
|
|
267
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
268
|
+
{
|
|
269
|
+
method: 'GET'
|
|
279
270
|
}
|
|
280
|
-
|
|
271
|
+
);
|
|
281
272
|
|
|
282
273
|
if (response.status === 404) {
|
|
283
274
|
return null; // No annotations found
|
|
@@ -334,16 +325,14 @@ export const saveFileAnnotations = async (
|
|
|
334
325
|
throw new Error('Invalid annotation data provided');
|
|
335
326
|
}
|
|
336
327
|
|
|
337
|
-
const apiKey = await getDataApiKey();
|
|
338
|
-
const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
|
|
339
|
-
|
|
340
328
|
// Enforce immutability once confirmation data exists on an image.
|
|
341
|
-
const existingResponse = await
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
329
|
+
const existingResponse = await fetchDataApi(
|
|
330
|
+
user,
|
|
331
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
332
|
+
{
|
|
333
|
+
method: 'GET'
|
|
345
334
|
}
|
|
346
|
-
|
|
335
|
+
);
|
|
347
336
|
|
|
348
337
|
if (existingResponse.ok) {
|
|
349
338
|
const existingAnnotations = await existingResponse.json() as AnnotationData;
|
|
@@ -360,14 +349,17 @@ export const saveFileAnnotations = async (
|
|
|
360
349
|
updatedAt: new Date().toISOString()
|
|
361
350
|
};
|
|
362
351
|
|
|
363
|
-
const response = await
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
'
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
352
|
+
const response = await fetchDataApi(
|
|
353
|
+
user,
|
|
354
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
355
|
+
{
|
|
356
|
+
method: 'PUT',
|
|
357
|
+
headers: {
|
|
358
|
+
'Content-Type': 'application/json'
|
|
359
|
+
},
|
|
360
|
+
body: JSON.stringify(dataToSave)
|
|
361
|
+
}
|
|
362
|
+
);
|
|
371
363
|
|
|
372
364
|
if (!response.ok) {
|
|
373
365
|
throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
|
|
@@ -407,15 +399,13 @@ export const deleteFileAnnotations = async (
|
|
|
407
399
|
}
|
|
408
400
|
}
|
|
409
401
|
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
headers: {
|
|
416
|
-
'X-Custom-Auth-Key': apiKey
|
|
402
|
+
const response = await fetchDataApi(
|
|
403
|
+
user,
|
|
404
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
405
|
+
{
|
|
406
|
+
method: 'DELETE'
|
|
417
407
|
}
|
|
418
|
-
|
|
408
|
+
);
|
|
419
409
|
|
|
420
410
|
if (!response.ok && response.status !== 404) {
|
|
421
411
|
throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
|
|
@@ -684,12 +674,10 @@ export const signForensicManifest = async (
|
|
|
684
674
|
throw new Error(`Manifest signing denied: ${accessCheck.reason}`);
|
|
685
675
|
}
|
|
686
676
|
|
|
687
|
-
const
|
|
688
|
-
const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-manifest`, {
|
|
677
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-manifest', {
|
|
689
678
|
method: 'POST',
|
|
690
679
|
headers: {
|
|
691
|
-
'Content-Type': 'application/json'
|
|
692
|
-
'X-Custom-Auth-Key': apiKey
|
|
680
|
+
'Content-Type': 'application/json'
|
|
693
681
|
},
|
|
694
682
|
body: JSON.stringify({
|
|
695
683
|
userId: user.uid,
|
|
@@ -752,12 +740,10 @@ export const signConfirmationData = async (
|
|
|
752
740
|
throw new Error(`Confirmation signing denied: ${accessCheck.reason}`);
|
|
753
741
|
}
|
|
754
742
|
|
|
755
|
-
const
|
|
756
|
-
const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-confirmation`, {
|
|
743
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-confirmation', {
|
|
757
744
|
method: 'POST',
|
|
758
745
|
headers: {
|
|
759
|
-
'Content-Type': 'application/json'
|
|
760
|
-
'X-Custom-Auth-Key': apiKey
|
|
746
|
+
'Content-Type': 'application/json'
|
|
761
747
|
},
|
|
762
748
|
body: JSON.stringify({
|
|
763
749
|
userId: user.uid,
|
|
@@ -828,12 +814,10 @@ export const signAuditExportData = async (
|
|
|
828
814
|
}
|
|
829
815
|
}
|
|
830
816
|
|
|
831
|
-
const
|
|
832
|
-
const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-audit-export`, {
|
|
817
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-audit-export', {
|
|
833
818
|
method: 'POST',
|
|
834
819
|
headers: {
|
|
835
|
-
'Content-Type': 'application/json'
|
|
836
|
-
'X-Custom-Auth-Key': apiKey
|
|
820
|
+
'Content-Type': 'application/json'
|
|
837
821
|
},
|
|
838
822
|
body: JSON.stringify({
|
|
839
823
|
userId: user.uid,
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { type ConfirmationImportData } from '~/types';
|
|
2
|
+
import {
|
|
3
|
+
extractForensicManifestData,
|
|
4
|
+
type SignedForensicManifest,
|
|
5
|
+
calculateSHA256Secure,
|
|
6
|
+
validateCaseIntegritySecure,
|
|
7
|
+
verifyForensicManifestSignature
|
|
8
|
+
} from './SHA256';
|
|
9
|
+
import { verifyConfirmationSignature } from './confirmation-signature';
|
|
10
|
+
|
|
11
|
+
export interface ExportVerificationResult {
|
|
12
|
+
isValid: boolean;
|
|
13
|
+
message: string;
|
|
14
|
+
exportType?: 'case-zip' | 'confirmation';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CASE_EXPORT_FILE_REGEX = /_data\.(json|csv)$/i;
|
|
18
|
+
const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
|
|
19
|
+
|
|
20
|
+
function createVerificationResult(
|
|
21
|
+
isValid: boolean,
|
|
22
|
+
message: string,
|
|
23
|
+
exportType?: ExportVerificationResult['exportType']
|
|
24
|
+
): ExportVerificationResult {
|
|
25
|
+
return {
|
|
26
|
+
isValid,
|
|
27
|
+
message,
|
|
28
|
+
exportType
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getSignatureFailureMessage(
|
|
33
|
+
error: string | undefined,
|
|
34
|
+
targetLabel: 'export ZIP' | 'confirmation file'
|
|
35
|
+
): string {
|
|
36
|
+
if (error?.includes('invalid public key')) {
|
|
37
|
+
return 'The selected PEM file is not a valid public key.';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (error?.includes('Unsupported')) {
|
|
41
|
+
return `This ${targetLabel} uses an unsupported signature format.`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (error?.includes('Missing')) {
|
|
45
|
+
return `This ${targetLabel} is missing required signature information.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `The ${targetLabel} signature did not verify with the selected public key.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isConfirmationImportCandidate(candidate: unknown): candidate is Partial<ConfirmationImportData> {
|
|
52
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const confirmationCandidate = candidate as Partial<ConfirmationImportData>;
|
|
57
|
+
return (
|
|
58
|
+
!!confirmationCandidate.metadata &&
|
|
59
|
+
typeof confirmationCandidate.metadata.hash === 'string' &&
|
|
60
|
+
!!confirmationCandidate.confirmations &&
|
|
61
|
+
typeof confirmationCandidate.confirmations === 'object'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove forensic warning from content for hash validation.
|
|
67
|
+
* Supports the warning formats added to JSON and CSV case exports.
|
|
68
|
+
*/
|
|
69
|
+
export function removeForensicWarning(content: string): string {
|
|
70
|
+
const jsonForensicWarningRegex = /^\/\*\s*CASE\s+DATA\s+WARNING[\s\S]*?\*\/\s*\r?\n*/;
|
|
71
|
+
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}/;
|
|
72
|
+
|
|
73
|
+
let cleaned = content;
|
|
74
|
+
|
|
75
|
+
if (jsonForensicWarningRegex.test(content)) {
|
|
76
|
+
cleaned = content.replace(jsonForensicWarningRegex, '');
|
|
77
|
+
} else if (csvForensicWarningRegex.test(content)) {
|
|
78
|
+
cleaned = content.replace(csvForensicWarningRegex, '');
|
|
79
|
+
} else if (content.startsWith('"CASE DATA WARNING:')) {
|
|
80
|
+
const match = content.match(/^"[^"]*"(?:\r?\n)+/);
|
|
81
|
+
if (match) {
|
|
82
|
+
cleaned = content.substring(match[0].length);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return cleaned.replace(/^\s+/, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate the stored confirmation hash without exposing expected/actual values.
|
|
91
|
+
*/
|
|
92
|
+
export async function validateConfirmationHash(jsonContent: string, expectedHash: string): Promise<boolean> {
|
|
93
|
+
try {
|
|
94
|
+
if (!expectedHash || typeof expectedHash !== 'string') {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = JSON.parse(jsonContent);
|
|
99
|
+
const dataWithoutHash = {
|
|
100
|
+
...data,
|
|
101
|
+
metadata: {
|
|
102
|
+
...data.metadata,
|
|
103
|
+
hash: undefined
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
delete dataWithoutHash.metadata.hash;
|
|
108
|
+
delete dataWithoutHash.metadata.signature;
|
|
109
|
+
delete dataWithoutHash.metadata.signatureVersion;
|
|
110
|
+
|
|
111
|
+
const contentForHash = JSON.stringify(dataWithoutHash, null, 2);
|
|
112
|
+
const actualHash = await calculateSHA256Secure(contentForHash);
|
|
113
|
+
|
|
114
|
+
return actualHash.toUpperCase() === expectedHash.toUpperCase();
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function verifyCaseZipExport(
|
|
121
|
+
file: File,
|
|
122
|
+
verificationPublicKeyPem: string
|
|
123
|
+
): Promise<ExportVerificationResult> {
|
|
124
|
+
const JSZip = (await import('jszip')).default;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const zip = await JSZip.loadAsync(file);
|
|
128
|
+
const dataFiles = Object.keys(zip.files).filter((name) => CASE_EXPORT_FILE_REGEX.test(name));
|
|
129
|
+
|
|
130
|
+
if (dataFiles.length !== 1) {
|
|
131
|
+
return createVerificationResult(
|
|
132
|
+
false,
|
|
133
|
+
'The ZIP file must contain exactly one case export data file.',
|
|
134
|
+
'case-zip'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const dataContent = await zip.file(dataFiles[0])?.async('text');
|
|
139
|
+
if (!dataContent) {
|
|
140
|
+
return createVerificationResult(false, 'The ZIP data file could not be read.', 'case-zip');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const manifestContent = await zip.file('FORENSIC_MANIFEST.json')?.async('text');
|
|
144
|
+
if (!manifestContent) {
|
|
145
|
+
return createVerificationResult(
|
|
146
|
+
false,
|
|
147
|
+
'The ZIP file does not contain FORENSIC_MANIFEST.json.',
|
|
148
|
+
'case-zip'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
|
|
153
|
+
const manifestData = extractForensicManifestData(forensicManifest);
|
|
154
|
+
|
|
155
|
+
if (!manifestData) {
|
|
156
|
+
return createVerificationResult(false, 'The forensic manifest is malformed.', 'case-zip');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const cleanedContent = removeForensicWarning(dataContent);
|
|
160
|
+
const imageFiles: Record<string, Blob> = {};
|
|
161
|
+
|
|
162
|
+
await Promise.all(
|
|
163
|
+
Object.keys(zip.files).map(async (path) => {
|
|
164
|
+
if (!path.startsWith('images/') || path.endsWith('/')) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const zipEntry = zip.file(path);
|
|
169
|
+
if (!zipEntry) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
imageFiles[path.replace('images/', '')] = await zipEntry.async('blob');
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const signatureResult = await verifyForensicManifestSignature(forensicManifest, verificationPublicKeyPem);
|
|
178
|
+
const integrityResult = await validateCaseIntegritySecure(cleanedContent, imageFiles, manifestData);
|
|
179
|
+
|
|
180
|
+
if (signatureResult.isValid && integrityResult.isValid) {
|
|
181
|
+
return createVerificationResult(
|
|
182
|
+
true,
|
|
183
|
+
'The export ZIP passed signature and integrity verification.',
|
|
184
|
+
'case-zip'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!signatureResult.isValid && !integrityResult.isValid) {
|
|
189
|
+
return createVerificationResult(
|
|
190
|
+
false,
|
|
191
|
+
'The export ZIP failed signature and integrity verification.',
|
|
192
|
+
'case-zip'
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!signatureResult.isValid) {
|
|
197
|
+
return createVerificationResult(
|
|
198
|
+
false,
|
|
199
|
+
getSignatureFailureMessage(signatureResult.error, 'export ZIP'),
|
|
200
|
+
'case-zip'
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return createVerificationResult(false, 'The export ZIP failed integrity verification.', 'case-zip');
|
|
205
|
+
} catch {
|
|
206
|
+
return createVerificationResult(
|
|
207
|
+
false,
|
|
208
|
+
'The ZIP file could not be read as a supported Striae export.',
|
|
209
|
+
'case-zip'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function verifyConfirmationExport(
|
|
215
|
+
file: File,
|
|
216
|
+
verificationPublicKeyPem: string
|
|
217
|
+
): Promise<ExportVerificationResult> {
|
|
218
|
+
try {
|
|
219
|
+
const fileContent = await file.text();
|
|
220
|
+
return verifyConfirmationContent(fileContent, verificationPublicKeyPem);
|
|
221
|
+
} catch {
|
|
222
|
+
return createVerificationResult(
|
|
223
|
+
false,
|
|
224
|
+
'The JSON file could not be read as a supported Striae confirmation export.',
|
|
225
|
+
'confirmation'
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function verifyConfirmationContent(
|
|
231
|
+
fileContent: string,
|
|
232
|
+
verificationPublicKeyPem: string
|
|
233
|
+
): Promise<ExportVerificationResult> {
|
|
234
|
+
try {
|
|
235
|
+
const parsedContent = JSON.parse(fileContent) as unknown;
|
|
236
|
+
|
|
237
|
+
if (!isConfirmationImportCandidate(parsedContent)) {
|
|
238
|
+
return createVerificationResult(
|
|
239
|
+
false,
|
|
240
|
+
'The JSON file is not a supported Striae confirmation export.',
|
|
241
|
+
'confirmation'
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const confirmationData = parsedContent as Partial<ConfirmationImportData>;
|
|
246
|
+
const hashValid = await validateConfirmationHash(fileContent, confirmationData.metadata!.hash);
|
|
247
|
+
const signatureResult = await verifyConfirmationSignature(confirmationData, verificationPublicKeyPem);
|
|
248
|
+
|
|
249
|
+
if (hashValid && signatureResult.isValid) {
|
|
250
|
+
return createVerificationResult(
|
|
251
|
+
true,
|
|
252
|
+
'The confirmation file passed signature and integrity verification.',
|
|
253
|
+
'confirmation'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!signatureResult.isValid && signatureResult.error === 'Confirmation content is malformed') {
|
|
258
|
+
return createVerificationResult(
|
|
259
|
+
false,
|
|
260
|
+
'The JSON file is not a supported Striae confirmation export.',
|
|
261
|
+
'confirmation'
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!hashValid && !signatureResult.isValid) {
|
|
266
|
+
return createVerificationResult(
|
|
267
|
+
false,
|
|
268
|
+
'The confirmation file failed signature and integrity verification.',
|
|
269
|
+
'confirmation'
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!signatureResult.isValid) {
|
|
274
|
+
return createVerificationResult(
|
|
275
|
+
false,
|
|
276
|
+
getSignatureFailureMessage(signatureResult.error, 'confirmation file'),
|
|
277
|
+
'confirmation'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return createVerificationResult(
|
|
282
|
+
false,
|
|
283
|
+
'The confirmation file failed integrity verification.',
|
|
284
|
+
'confirmation'
|
|
285
|
+
);
|
|
286
|
+
} catch {
|
|
287
|
+
return createVerificationResult(
|
|
288
|
+
false,
|
|
289
|
+
'The confirmation content could not be read as a supported Striae confirmation export.',
|
|
290
|
+
'confirmation'
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function verifyConfirmationZipExport(
|
|
296
|
+
file: File,
|
|
297
|
+
verificationPublicKeyPem: string
|
|
298
|
+
): Promise<ExportVerificationResult> {
|
|
299
|
+
const JSZip = (await import('jszip')).default;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const zip = await JSZip.loadAsync(file);
|
|
303
|
+
const confirmationFiles = Object.keys(zip.files).filter((name) => CONFIRMATION_EXPORT_FILE_REGEX.test(name));
|
|
304
|
+
|
|
305
|
+
if (confirmationFiles.length !== 1) {
|
|
306
|
+
return createVerificationResult(
|
|
307
|
+
false,
|
|
308
|
+
'The ZIP file is not a supported Striae confirmation export package.'
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const confirmationContent = await zip.file(confirmationFiles[0])?.async('text');
|
|
313
|
+
if (!confirmationContent) {
|
|
314
|
+
return createVerificationResult(
|
|
315
|
+
false,
|
|
316
|
+
'The confirmation JSON file inside the ZIP could not be read.',
|
|
317
|
+
'confirmation'
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return verifyConfirmationContent(confirmationContent, verificationPublicKeyPem);
|
|
322
|
+
} catch {
|
|
323
|
+
return createVerificationResult(
|
|
324
|
+
false,
|
|
325
|
+
'The ZIP file could not be read as a supported Striae export.'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function verifyExportFile(
|
|
331
|
+
file: File,
|
|
332
|
+
verificationPublicKeyPem: string
|
|
333
|
+
): Promise<ExportVerificationResult> {
|
|
334
|
+
const lowerName = file.name.toLowerCase();
|
|
335
|
+
|
|
336
|
+
if (lowerName.endsWith('.zip')) {
|
|
337
|
+
const confirmationZipResult = await verifyConfirmationZipExport(file, verificationPublicKeyPem);
|
|
338
|
+
if (confirmationZipResult.exportType === 'confirmation' || confirmationZipResult.isValid) {
|
|
339
|
+
return confirmationZipResult;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return verifyCaseZipExport(file, verificationPublicKeyPem);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (lowerName.endsWith('.json')) {
|
|
346
|
+
return verifyConfirmationExport(file, verificationPublicKeyPem);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return createVerificationResult(
|
|
350
|
+
false,
|
|
351
|
+
'Select a confirmation JSON/ZIP file or a case export ZIP file.'
|
|
352
|
+
);
|
|
353
|
+
}
|