@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.
Files changed (104) hide show
  1. package/app/components/actions/case-export/core-export.ts +2 -2
  2. package/app/components/actions/case-export/data-processing.ts +19 -4
  3. package/app/components/actions/case-export/download-handlers.ts +57 -8
  4. package/app/components/actions/case-export/metadata-helpers.ts +1 -1
  5. package/app/components/actions/case-import/annotation-import.ts +2 -2
  6. package/app/components/actions/case-import/confirmation-import.ts +44 -20
  7. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  8. package/app/components/actions/case-import/image-operations.ts +1 -1
  9. package/app/components/actions/case-import/index.ts +1 -0
  10. package/app/components/actions/case-import/orchestrator.ts +16 -6
  11. package/app/components/actions/case-import/storage-operations.ts +7 -7
  12. package/app/components/actions/case-import/validation.ts +7 -100
  13. package/app/components/actions/case-import/zip-processing.ts +47 -5
  14. package/app/components/actions/case-manage.ts +3 -3
  15. package/app/components/actions/confirm-export.ts +47 -16
  16. package/app/components/actions/generate-pdf.ts +3 -3
  17. package/app/components/actions/image-manage.ts +3 -3
  18. package/app/components/actions/notes-manage.ts +3 -3
  19. package/app/components/actions/signout.tsx +1 -1
  20. package/app/components/audit/user-audit-viewer.tsx +2 -3
  21. package/app/components/auth/auth-provider.tsx +2 -2
  22. package/app/components/auth/mfa-enrollment.tsx +3 -3
  23. package/app/components/auth/mfa-verification.tsx +4 -4
  24. package/app/components/canvas/box-annotations/box-annotations.tsx +2 -2
  25. package/app/components/canvas/canvas.tsx +1 -1
  26. package/app/components/canvas/confirmation/confirmation.tsx +1 -1
  27. package/app/components/form/form-button.tsx +1 -1
  28. package/app/components/form/form.module.css +9 -0
  29. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  30. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  31. package/app/components/sidebar/case-export/case-export.tsx +2 -54
  32. package/app/components/sidebar/case-import/case-import.tsx +20 -8
  33. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -1
  34. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +1 -1
  35. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +9 -7
  36. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -2
  37. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  38. package/app/components/sidebar/cases/case-sidebar.tsx +106 -50
  39. package/app/components/sidebar/cases/cases-modal.tsx +1 -1
  40. package/app/components/sidebar/cases/cases.module.css +101 -18
  41. package/app/components/sidebar/files/files-modal.tsx +3 -2
  42. package/app/components/sidebar/notes/notes-sidebar.tsx +3 -3
  43. package/app/components/sidebar/notes/notes.module.css +33 -13
  44. package/app/components/sidebar/sidebar-container.tsx +4 -3
  45. package/app/components/sidebar/sidebar.tsx +2 -2
  46. package/app/components/sidebar/upload/image-upload-zone.tsx +2 -2
  47. package/app/components/theme-provider/theme-provider.tsx +1 -1
  48. package/app/components/user/delete-account.tsx +1 -1
  49. package/app/components/user/manage-profile.tsx +3 -3
  50. package/app/components/user/mfa-phone-update.tsx +17 -14
  51. package/app/contexts/auth.context.ts +1 -1
  52. package/app/root.tsx +2 -2
  53. package/app/routes/auth/emailActionHandler.tsx +2 -2
  54. package/app/routes/auth/emailVerification.tsx +2 -2
  55. package/app/routes/auth/login.tsx +134 -11
  56. package/app/routes/auth/passwordReset.tsx +2 -2
  57. package/app/routes/striae/striae.tsx +2 -2
  58. package/app/services/audit/audit-console-logger.ts +46 -0
  59. package/app/services/audit/audit-export-csv.ts +126 -0
  60. package/app/services/audit/audit-export-report.ts +174 -0
  61. package/app/services/audit/audit-export-signing.ts +85 -0
  62. package/app/services/audit/audit-export.service.ts +334 -0
  63. package/app/services/audit/audit-file-type.ts +13 -0
  64. package/app/services/audit/audit-query-helpers.ts +88 -0
  65. package/app/services/audit/audit-worker-client.ts +95 -0
  66. package/app/services/audit/audit.service.ts +990 -0
  67. package/app/services/audit/builders/audit-entry-builder.ts +32 -0
  68. package/app/services/audit/builders/audit-event-builders-annotation.ts +150 -0
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +249 -0
  70. package/app/services/audit/builders/audit-event-builders-user-security.ts +449 -0
  71. package/app/services/audit/builders/audit-event-builders-workflow.ts +272 -0
  72. package/app/services/audit/builders/index.ts +40 -0
  73. package/app/services/audit/index.ts +2 -0
  74. package/app/types/case.ts +2 -2
  75. package/app/types/exceljs-bare.d.ts +3 -1
  76. package/app/types/user.ts +1 -1
  77. package/app/utils/SHA256.ts +5 -1
  78. package/app/utils/audit-export-signature.ts +2 -2
  79. package/app/utils/confirmation-signature.ts +8 -4
  80. package/app/utils/data-operations.ts +5 -5
  81. package/app/utils/export-verification.ts +353 -0
  82. package/app/utils/mfa-phone.ts +1 -1
  83. package/app/utils/mfa.ts +1 -1
  84. package/app/utils/permissions.ts +2 -2
  85. package/app/utils/signature-utils.ts +74 -4
  86. package/package.json +11 -9
  87. package/public/favicon.ico +0 -0
  88. package/public/icon-256.png +0 -0
  89. package/public/icon-512.png +0 -0
  90. package/public/manifest.json +39 -0
  91. package/public/shortcut.png +0 -0
  92. package/public/social-image.png +0 -0
  93. package/react-router.config.ts +5 -0
  94. package/worker-configuration.d.ts +4435 -562
  95. package/workers/data-worker/src/data-worker.example.ts +3 -3
  96. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  97. package/workers/pdf-worker/src/{generated-assets.ts → assets/generated-assets.ts} +117 -117
  98. package/workers/pdf-worker/src/{format-striae.ts → formats/format-striae.ts} +535 -535
  99. package/workers/pdf-worker/src/pdf-worker.example.ts +1 -1
  100. package/app/services/audit-export.service.ts +0 -755
  101. package/app/services/audit.service.ts +0 -1474
  102. package/public/favicon.svg +0 -9
  103. /package/app/services/{firebase-errors.ts → firebase/errors.ts} +0 -0
  104. /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';
@@ -0,0 +1,2 @@
1
+ export { AuditService, auditService } from './audit.service';
2
+ export { AuditExportService, auditExportService } from './audit-export.service';
package/app/types/case.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { FileData } from './file';
2
- import { AnnotationData, ConfirmationData } from './annotations';
1
+ import { type FileData } from './file';
2
+ import { type AnnotationData, type ConfirmationData } from './annotations';
3
3
 
4
4
  // Case-related types and interfaces
5
5
 
@@ -1,6 +1,8 @@
1
+ import type * as ExcelJSModule from 'exceljs';
2
+
1
3
  declare global {
2
4
  interface Window {
3
- ExcelJS?: typeof import('exceljs');
5
+ ExcelJS?: typeof ExcelJSModule;
4
6
  }
5
7
  }
6
8
 
package/app/types/user.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // User-related types and interfaces
2
2
 
3
- import { ReadOnlyCaseMetadata } from './import';
3
+ import { type ReadOnlyCaseMetadata } from './import';
4
4
 
5
5
  export interface UserData {
6
6
  uid: string;
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import type { MultiFactorInfo } from 'firebase/auth';
2
- import { getValidationError } from '~/services/firebase-errors';
2
+ import { getValidationError } from '~/services/firebase/errors';
3
3
 
4
4
  export interface PhoneValidationResult {
5
5
  isValid: boolean;
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
@@ -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 = getVerificationPublicKey(signature.keyId);
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,