@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export { buildValidationAuditEntry } from './audit-entry-builder';
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
buildCaseExportAuditParams,
|
|
5
|
+
buildCaseImportAuditParams,
|
|
6
|
+
buildConfirmationCreationAuditParams,
|
|
7
|
+
buildConfirmationExportAuditParams,
|
|
8
|
+
buildConfirmationImportAuditParams
|
|
9
|
+
} from './audit-event-builders-workflow';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
buildCaseCreationAuditParams,
|
|
13
|
+
buildCaseDeletionAuditParams,
|
|
14
|
+
buildCaseRenameAuditParams,
|
|
15
|
+
buildFileAccessAuditParams,
|
|
16
|
+
buildFileDeletionAuditParams,
|
|
17
|
+
buildFileUploadAuditParams,
|
|
18
|
+
buildPDFGenerationAuditParams
|
|
19
|
+
} from './audit-event-builders-case-file';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
buildAnnotationCreateAuditParams,
|
|
23
|
+
buildAnnotationDeleteAuditParams,
|
|
24
|
+
buildAnnotationEditAuditParams
|
|
25
|
+
} from './audit-event-builders-annotation';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
buildAccountDeletionAuditParams,
|
|
29
|
+
buildEmailVerificationAuditParams,
|
|
30
|
+
buildEmailVerificationByEmailAuditParams,
|
|
31
|
+
buildMarkEmailVerificationSuccessfulAuditParams,
|
|
32
|
+
buildMfaAuthenticationAuditParams,
|
|
33
|
+
buildMfaEnrollmentAuditParams,
|
|
34
|
+
buildPasswordResetAuditParams,
|
|
35
|
+
buildSecurityViolationAuditParams,
|
|
36
|
+
buildUserLoginAuditParams,
|
|
37
|
+
buildUserLogoutAuditParams,
|
|
38
|
+
buildUserProfileUpdateAuditParams,
|
|
39
|
+
buildUserRegistrationAuditParams
|
|
40
|
+
} from './audit-event-builders-user-security';
|
package/app/types/case.ts
CHANGED
package/app/types/user.ts
CHANGED
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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ForensicManifestSignature,
|
|
2
|
+
type ForensicManifestSignature,
|
|
3
3
|
FORENSIC_MANIFEST_SIGNATURE_ALGORITHM,
|
|
4
|
-
ManifestSignatureVerificationResult
|
|
4
|
+
type ManifestSignatureVerificationResult
|
|
5
5
|
} from './SHA256';
|
|
6
6
|
import { verifySignaturePayload } from './signature-utils';
|
|
7
7
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { ConfirmationImportData } from '~/types';
|
|
1
|
+
import { type ConfirmationImportData } from '~/types';
|
|
2
2
|
import {
|
|
3
|
-
ForensicManifestSignature,
|
|
3
|
+
type ForensicManifestSignature,
|
|
4
4
|
FORENSIC_MANIFEST_SIGNATURE_ALGORITHM,
|
|
5
|
-
ManifestSignatureVerificationResult
|
|
5
|
+
type ManifestSignatureVerificationResult
|
|
6
6
|
} from './SHA256';
|
|
7
7
|
import { verifySignaturePayload } from './signature-utils';
|
|
8
8
|
|
|
@@ -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
|
}
|
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
* for all interactions with the data worker microservice
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { User } from 'firebase/auth';
|
|
8
|
-
import { CaseData, AnnotationData, ConfirmationImportData } from '~/types';
|
|
7
|
+
import type { User } from 'firebase/auth';
|
|
8
|
+
import { type CaseData, type AnnotationData, type ConfirmationImportData } from '~/types';
|
|
9
9
|
import paths from '~/config/config.json';
|
|
10
10
|
import { getDataApiKey } from './auth';
|
|
11
11
|
import { validateUserSession, canAccessCase, canModifyCase } from './permissions';
|
|
12
12
|
import {
|
|
13
|
-
ForensicManifestData,
|
|
14
|
-
ForensicManifestSignature,
|
|
13
|
+
type ForensicManifestData,
|
|
14
|
+
type ForensicManifestSignature,
|
|
15
15
|
FORENSIC_MANIFEST_VERSION
|
|
16
16
|
} from './SHA256';
|
|
17
17
|
import { CONFIRMATION_SIGNATURE_VERSION } from './confirmation-signature';
|
|
18
18
|
import {
|
|
19
19
|
AUDIT_EXPORT_SIGNATURE_VERSION,
|
|
20
|
-
AuditExportSigningPayload,
|
|
20
|
+
type AuditExportSigningPayload,
|
|
21
21
|
isValidAuditExportSigningPayload
|
|
22
22
|
} from './audit-export-signature';
|
|
23
23
|
|
|
@@ -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
|
+
}
|
package/app/utils/mfa-phone.ts
CHANGED
package/app/utils/mfa.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// MFA Configuration Helper
|
|
2
2
|
// This file contains utilities and documentation for managing MFA in your Firebase project
|
|
3
3
|
|
|
4
|
-
import { multiFactor, User } from 'firebase/auth';
|
|
4
|
+
import { multiFactor, type User } from 'firebase/auth';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Check if a user has MFA enrolled
|
package/app/utils/permissions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { User } from 'firebase/auth';
|
|
2
|
-
import { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } from '~/types';
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } from '~/types';
|
|
3
3
|
import paths from '~/config/config.json';
|
|
4
4
|
import { getUserApiKey } from './auth';
|
|
5
5
|
|
|
@@ -20,6 +20,15 @@ export interface SignatureVerificationMessages {
|
|
|
20
20
|
verificationFailedError?: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface SignatureVerificationOptions {
|
|
24
|
+
verificationPublicKeyPem?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PublicSigningKeyDetails {
|
|
28
|
+
keyId: string | null;
|
|
29
|
+
publicKeyPem: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
type ManifestSigningConfig = {
|
|
24
33
|
manifest_signing_public_keys?: Record<string, string>;
|
|
25
34
|
manifest_signing_public_key?: string;
|
|
@@ -30,6 +39,63 @@ function normalizePemPublicKey(pem: string): string {
|
|
|
30
39
|
return pem.replace(/\\n/g, '\n').trim();
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
function normalizePemOrNull(pem: unknown): string | null {
|
|
43
|
+
if (typeof pem !== 'string' || pem.trim().length === 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return normalizePemPublicKey(pem);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizeKeyIdForFileName(keyId: string): string {
|
|
51
|
+
return keyId.trim().replace(/[^a-z0-9_-]+/gi, '-');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createPublicSigningKeyFileName(keyId?: string | null): string {
|
|
55
|
+
if (typeof keyId === 'string' && keyId.trim().length > 0) {
|
|
56
|
+
return `striae-public-signing-key-${sanitizeKeyIdForFileName(keyId)}.pem`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return 'striae-public-signing-key.pem';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getCurrentPublicSigningKeyDetails(): PublicSigningKeyDetails {
|
|
63
|
+
const config = paths as unknown as ManifestSigningConfig;
|
|
64
|
+
const configuredKeyId =
|
|
65
|
+
typeof config.manifest_signing_key_id === 'string' && config.manifest_signing_key_id.trim().length > 0
|
|
66
|
+
? config.manifest_signing_key_id
|
|
67
|
+
: null;
|
|
68
|
+
|
|
69
|
+
if (configuredKeyId) {
|
|
70
|
+
const configuredKey = getVerificationPublicKey(configuredKeyId);
|
|
71
|
+
if (configuredKey) {
|
|
72
|
+
return {
|
|
73
|
+
keyId: configuredKeyId,
|
|
74
|
+
publicKeyPem: configuredKey
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const keyMap = config.manifest_signing_public_keys;
|
|
80
|
+
if (keyMap && typeof keyMap === 'object') {
|
|
81
|
+
const firstConfiguredEntry = Object.entries(keyMap).find(
|
|
82
|
+
([, value]) => typeof value === 'string' && value.trim().length > 0
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (firstConfiguredEntry) {
|
|
86
|
+
return {
|
|
87
|
+
keyId: firstConfiguredEntry[0],
|
|
88
|
+
publicKeyPem: normalizePemPublicKey(firstConfiguredEntry[1])
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
keyId: null,
|
|
95
|
+
publicKeyPem: normalizePemOrNull(config.manifest_signing_public_key)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
33
99
|
function publicKeyPemToArrayBuffer(publicKeyPem: string, invalidPublicKeyError: string): ArrayBuffer {
|
|
34
100
|
const normalized = normalizePemPublicKey(publicKeyPem);
|
|
35
101
|
const pemBody = normalized
|
|
@@ -71,7 +137,7 @@ export function getVerificationPublicKey(keyId: string): string | null {
|
|
|
71
137
|
if (keyMap && typeof keyMap === 'object') {
|
|
72
138
|
const mappedKey = keyMap[keyId];
|
|
73
139
|
if (typeof mappedKey === 'string' && mappedKey.trim().length > 0) {
|
|
74
|
-
return mappedKey;
|
|
140
|
+
return normalizePemPublicKey(mappedKey);
|
|
75
141
|
}
|
|
76
142
|
}
|
|
77
143
|
|
|
@@ -81,7 +147,7 @@ export function getVerificationPublicKey(keyId: string): string | null {
|
|
|
81
147
|
typeof config.manifest_signing_public_key === 'string' &&
|
|
82
148
|
config.manifest_signing_public_key.trim().length > 0
|
|
83
149
|
) {
|
|
84
|
-
return config.manifest_signing_public_key;
|
|
150
|
+
return normalizePemPublicKey(config.manifest_signing_public_key);
|
|
85
151
|
}
|
|
86
152
|
|
|
87
153
|
return null;
|
|
@@ -91,7 +157,8 @@ export async function verifySignaturePayload(
|
|
|
91
157
|
payload: string,
|
|
92
158
|
signature: SignatureEnvelope,
|
|
93
159
|
expectedAlgorithm: string,
|
|
94
|
-
messages: SignatureVerificationMessages = {}
|
|
160
|
+
messages: SignatureVerificationMessages = {},
|
|
161
|
+
options: SignatureVerificationOptions = {}
|
|
95
162
|
): Promise<SignatureVerificationResult> {
|
|
96
163
|
if (signature.algorithm !== expectedAlgorithm) {
|
|
97
164
|
return {
|
|
@@ -108,7 +175,10 @@ export async function verifySignaturePayload(
|
|
|
108
175
|
};
|
|
109
176
|
}
|
|
110
177
|
|
|
111
|
-
const publicKeyPem =
|
|
178
|
+
const publicKeyPem =
|
|
179
|
+
typeof options.verificationPublicKeyPem === 'string' && options.verificationPublicKeyPem.trim().length > 0
|
|
180
|
+
? options.verificationPublicKeyPem
|
|
181
|
+
: getVerificationPublicKey(signature.keyId);
|
|
112
182
|
if (!publicKeyPem) {
|
|
113
183
|
return {
|
|
114
184
|
isValid: false,
|