@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
2
|
-
import { Link, useSearchParams } from 'react-router';
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Link, useSearchParams, type MetaFunction } from 'react-router';
|
|
3
3
|
import { auth } from '~/services/firebase';
|
|
4
4
|
import {
|
|
5
5
|
signInWithEmailAndPassword,
|
|
@@ -18,6 +18,7 @@ import { EmailActionHandler } from '~/routes/auth/emailActionHandler';
|
|
|
18
18
|
import { handleAuthError } from '~/services/firebase/errors';
|
|
19
19
|
import { MFAVerification } from '~/components/auth/mfa-verification';
|
|
20
20
|
import { MFAEnrollment } from '~/components/auth/mfa-enrollment';
|
|
21
|
+
import { Toast } from '~/components/toast/toast';
|
|
21
22
|
import { Icon } from '~/components/icon/icon';
|
|
22
23
|
import styles from './login.module.css';
|
|
23
24
|
import { Striae } from '~/routes/striae/striae';
|
|
@@ -28,23 +29,123 @@ import { evaluatePasswordPolicy } from '~/utils/password-policy';
|
|
|
28
29
|
import { buildActionCodeSettings } from '~/utils/auth-action-settings';
|
|
29
30
|
import { userHasMFA } from '~/utils/mfa';
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const APP_CANONICAL_ORIGIN = 'https://app.striae.org';
|
|
33
|
+
const SOCIAL_IMAGE_PATH = '/social-image.png';
|
|
34
|
+
const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
|
|
35
|
+
const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
|
|
36
|
+
|
|
37
|
+
type AuthMetaContent = {
|
|
38
|
+
title: string;
|
|
39
|
+
description: string;
|
|
40
|
+
robots: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const getCanonicalPath = (pathname: string): string => {
|
|
44
|
+
if (!pathname || LOGIN_PATH_ALIASES.has(pathname)) {
|
|
45
|
+
return '/';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getAuthMetaContent = (mode: string | null, hasActionCode: boolean): AuthMetaContent => {
|
|
52
|
+
if (!mode && !hasActionCode) {
|
|
53
|
+
return {
|
|
54
|
+
title: 'Striae | Secure Login for Firearms Examiners',
|
|
55
|
+
description: 'Sign in to Striae to access your forensic annotation workspace, case files, and comparison tools.',
|
|
56
|
+
robots: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (mode === 'resetPassword') {
|
|
61
|
+
return {
|
|
62
|
+
title: 'Striae | Reset Your Password',
|
|
63
|
+
description: 'Use this secure page to reset your Striae account password and restore access to your workspace.',
|
|
64
|
+
robots: 'noindex,nofollow,noarchive',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (mode === 'verifyEmail') {
|
|
69
|
+
return {
|
|
70
|
+
title: 'Striae | Verify Your Email Address',
|
|
71
|
+
description: 'Confirm your email address to complete Striae account activation and continue securely.',
|
|
72
|
+
robots: 'noindex,nofollow,noarchive',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mode === 'recoverEmail') {
|
|
77
|
+
return {
|
|
78
|
+
title: 'Striae | Recover Email Access',
|
|
79
|
+
description: 'Complete your Striae account email recovery steps securely.',
|
|
80
|
+
robots: 'noindex,nofollow,noarchive',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
title: 'Striae | Account Action',
|
|
86
|
+
description: 'Complete your Striae account action securely.',
|
|
87
|
+
robots: 'noindex,nofollow,noarchive',
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const meta: MetaFunction = ({ location }) => {
|
|
92
|
+
const searchParams = new URLSearchParams(location.search);
|
|
93
|
+
const mode = searchParams.get('mode');
|
|
94
|
+
const hasActionCode = Boolean(searchParams.get('oobCode'));
|
|
95
|
+
|
|
96
|
+
const canonicalPath = getCanonicalPath(location.pathname);
|
|
97
|
+
const canonicalHref = `${APP_CANONICAL_ORIGIN}${canonicalPath}`;
|
|
98
|
+
const socialImageHref = `${APP_CANONICAL_ORIGIN}${SOCIAL_IMAGE_PATH}`;
|
|
99
|
+
const { title, description, robots } = getAuthMetaContent(mode, hasActionCode);
|
|
34
100
|
|
|
35
101
|
return [
|
|
36
|
-
{ title
|
|
102
|
+
{ title },
|
|
37
103
|
{ name: 'description', content: description },
|
|
104
|
+
{ name: 'robots', content: robots },
|
|
105
|
+
{ property: 'og:site_name', content: 'Striae' },
|
|
106
|
+
{ property: 'og:type', content: 'website' },
|
|
107
|
+
{ property: 'og:url', content: canonicalHref },
|
|
108
|
+
{ property: 'og:title', content: title },
|
|
109
|
+
{ property: 'og:description', content: description },
|
|
110
|
+
{ property: 'og:image', content: socialImageHref },
|
|
111
|
+
{ property: 'og:image:secure_url', content: socialImageHref },
|
|
112
|
+
{ property: 'og:image:alt', content: SOCIAL_IMAGE_ALT },
|
|
113
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
114
|
+
{ name: 'twitter:title', content: title },
|
|
115
|
+
{ name: 'twitter:description', content: description },
|
|
116
|
+
{ name: 'twitter:image', content: socialImageHref },
|
|
117
|
+
{ name: 'twitter:image:alt', content: SOCIAL_IMAGE_ALT },
|
|
118
|
+
{ tagName: 'link', rel: 'canonical', href: canonicalHref },
|
|
38
119
|
];
|
|
39
120
|
};
|
|
40
121
|
|
|
41
122
|
const SUPPORTED_EMAIL_ACTION_MODES = new Set(['resetPassword', 'verifyEmail', 'recoverEmail']);
|
|
42
123
|
|
|
124
|
+
const getUserFirstName = (user: User): string => {
|
|
125
|
+
const displayName = user.displayName?.trim();
|
|
126
|
+
if (displayName) {
|
|
127
|
+
const [firstName] = displayName.split(/\s+/);
|
|
128
|
+
if (firstName) {
|
|
129
|
+
return firstName;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const emailPrefix = user.email?.split('@')[0]?.trim();
|
|
134
|
+
if (emailPrefix) {
|
|
135
|
+
return emailPrefix;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return 'User';
|
|
139
|
+
};
|
|
140
|
+
|
|
43
141
|
export const Login = () => {
|
|
44
142
|
const [searchParams] = useSearchParams();
|
|
143
|
+
const shouldShowWelcomeToastRef = useRef(false);
|
|
45
144
|
|
|
46
145
|
const [error, setError] = useState('');
|
|
47
146
|
const [success, setSuccess] = useState('');
|
|
147
|
+
const [welcomeToastMessage, setWelcomeToastMessage] = useState('');
|
|
148
|
+
const [isWelcomeToastVisible, setIsWelcomeToastVisible] = useState(false);
|
|
48
149
|
const [isLogin, setIsLogin] = useState(true);
|
|
49
150
|
const [isLoading, setIsLoading] = useState(false);
|
|
50
151
|
const [isCheckingUser, setIsCheckingUser] = useState(false);
|
|
@@ -180,6 +281,12 @@ export const Login = () => {
|
|
|
180
281
|
|
|
181
282
|
console.log("User signed in:", currentUser.email);
|
|
182
283
|
setShowMfaEnrollment(false);
|
|
284
|
+
|
|
285
|
+
if (shouldShowWelcomeToastRef.current) {
|
|
286
|
+
setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
|
|
287
|
+
setIsWelcomeToastVisible(true);
|
|
288
|
+
shouldShowWelcomeToastRef.current = false;
|
|
289
|
+
}
|
|
183
290
|
|
|
184
291
|
// Log successful login audit
|
|
185
292
|
try {
|
|
@@ -198,6 +305,8 @@ export const Login = () => {
|
|
|
198
305
|
setUser(null);
|
|
199
306
|
setShowMfaEnrollment(false);
|
|
200
307
|
setIsCheckingUser(false);
|
|
308
|
+
setIsWelcomeToastVisible(false);
|
|
309
|
+
shouldShowWelcomeToastRef.current = false;
|
|
201
310
|
}
|
|
202
311
|
});
|
|
203
312
|
|
|
@@ -339,6 +448,7 @@ export const Login = () => {
|
|
|
339
448
|
// Don't sign out - let user stay logged in but unverified to see verification screen
|
|
340
449
|
} else {
|
|
341
450
|
// Login
|
|
451
|
+
shouldShowWelcomeToastRef.current = true;
|
|
342
452
|
try {
|
|
343
453
|
await signInWithEmailAndPassword(auth, email, password);
|
|
344
454
|
} catch (loginError: unknown) {
|
|
@@ -356,10 +466,12 @@ export const Login = () => {
|
|
|
356
466
|
setIsLoading(false);
|
|
357
467
|
return;
|
|
358
468
|
}
|
|
469
|
+
shouldShowWelcomeToastRef.current = false;
|
|
359
470
|
throw loginError; // Re-throw non-MFA errors
|
|
360
471
|
}
|
|
361
472
|
}
|
|
362
473
|
} catch (err) {
|
|
474
|
+
shouldShowWelcomeToastRef.current = false;
|
|
363
475
|
const { message } = handleAuthError(err);
|
|
364
476
|
setError(message);
|
|
365
477
|
|
|
@@ -408,6 +520,8 @@ export const Login = () => {
|
|
|
408
520
|
setShowMfaEnrollment(false);
|
|
409
521
|
setShowMfaVerification(false);
|
|
410
522
|
setMfaResolver(null);
|
|
523
|
+
setIsWelcomeToastVisible(false);
|
|
524
|
+
shouldShowWelcomeToastRef.current = false;
|
|
411
525
|
} catch (err) {
|
|
412
526
|
console.error('Sign out error:', err);
|
|
413
527
|
}
|
|
@@ -648,6 +762,15 @@ export const Login = () => {
|
|
|
648
762
|
mandatory={true}
|
|
649
763
|
/>
|
|
650
764
|
)}
|
|
765
|
+
|
|
766
|
+
{!shouldHandleEmailAction && (
|
|
767
|
+
<Toast
|
|
768
|
+
message={welcomeToastMessage}
|
|
769
|
+
type="success"
|
|
770
|
+
isVisible={isWelcomeToastVisible}
|
|
771
|
+
onClose={() => setIsWelcomeToastVisible(false)}
|
|
772
|
+
/>
|
|
773
|
+
)}
|
|
651
774
|
|
|
652
775
|
</>
|
|
653
776
|
);
|
package/app/utils/SHA256.ts
CHANGED
|
@@ -120,7 +120,8 @@ export function createManifestSigningPayload(
|
|
|
120
120
|
* Verify manifest signature using configured public key(s).
|
|
121
121
|
*/
|
|
122
122
|
export async function verifyForensicManifestSignature(
|
|
123
|
-
manifest: Partial<SignedForensicManifest
|
|
123
|
+
manifest: Partial<SignedForensicManifest>,
|
|
124
|
+
verificationPublicKeyPem?: string
|
|
124
125
|
): Promise<ManifestSignatureVerificationResult> {
|
|
125
126
|
if (!manifest.signature) {
|
|
126
127
|
return {
|
|
@@ -158,6 +159,9 @@ export async function verifyForensicManifestSignature(
|
|
|
158
159
|
noVerificationKeyPrefix: 'No verification key configured for key ID',
|
|
159
160
|
invalidPublicKeyError: 'Manifest signature verification failed: invalid public key',
|
|
160
161
|
verificationFailedError: 'Manifest signature verification failed'
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
verificationPublicKeyPem
|
|
161
165
|
}
|
|
162
166
|
);
|
|
163
167
|
}
|
|
@@ -148,7 +148,8 @@ export function createConfirmationSigningPayload(
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
export async function verifyConfirmationSignature(
|
|
151
|
-
confirmationData: Partial<ConfirmationImportData
|
|
151
|
+
confirmationData: Partial<ConfirmationImportData>,
|
|
152
|
+
verificationPublicKeyPem?: string
|
|
152
153
|
): Promise<ManifestSignatureVerificationResult> {
|
|
153
154
|
const signature = confirmationData.metadata?.signature as ForensicManifestSignature | undefined;
|
|
154
155
|
const signatureVersion = confirmationData.metadata?.signatureVersion;
|
|
@@ -188,6 +189,9 @@ export async function verifyConfirmationSignature(
|
|
|
188
189
|
noVerificationKeyPrefix: 'No verification key configured for key ID',
|
|
189
190
|
invalidPublicKeyError: 'Confirmation signature verification failed: invalid public key',
|
|
190
191
|
verificationFailedError: 'Confirmation signature verification failed'
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
verificationPublicKeyPem
|
|
191
195
|
}
|
|
192
196
|
);
|
|
193
197
|
}
|
|
@@ -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
|
+
}
|