@striae-org/striae 4.0.3 → 4.2.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 (118) hide show
  1. package/.env.example +8 -0
  2. package/app/components/actions/case-export/core-export.ts +14 -8
  3. package/app/components/actions/case-export/data-processing.ts +1 -0
  4. package/app/components/actions/case-export/download-handlers.ts +7 -0
  5. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  6. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  7. package/app/components/actions/case-import/orchestrator.ts +78 -32
  8. package/app/components/actions/case-import/storage-operations.ts +97 -8
  9. package/app/components/actions/case-import/zip-processing.ts +159 -86
  10. package/app/components/actions/case-manage.ts +430 -8
  11. package/app/components/actions/confirm-export.ts +13 -4
  12. package/app/components/actions/generate-pdf.ts +10 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +137 -945
  15. package/app/components/audit/user-audit.module.css +41 -0
  16. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  17. package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
  18. package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
  19. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  20. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  21. package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
  22. package/app/components/audit/viewer/types.ts +1 -0
  23. package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
  24. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  25. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  26. package/app/components/auth/mfa-enrollment.module.css +13 -5
  27. package/app/components/auth/mfa-verification.module.css +13 -5
  28. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  29. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  30. package/app/components/canvas/canvas.module.css +64 -54
  31. package/app/components/canvas/canvas.tsx +17 -16
  32. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  33. package/app/components/canvas/confirmation/confirmation.tsx +17 -47
  34. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  35. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  36. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  37. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  38. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  39. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  40. package/app/components/navbar/navbar.module.css +447 -0
  41. package/app/components/navbar/navbar.tsx +377 -0
  42. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
  43. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
  44. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  45. package/app/components/sidebar/case-export/case-export.tsx +14 -77
  46. package/app/components/sidebar/case-import/case-import.module.css +25 -0
  47. package/app/components/sidebar/case-import/case-import.tsx +64 -40
  48. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  49. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  50. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  51. package/app/components/sidebar/cases/cases-modal.module.css +45 -9
  52. package/app/components/sidebar/cases/cases-modal.tsx +16 -16
  53. package/app/components/sidebar/cases/cases.module.css +62 -21
  54. package/app/components/sidebar/files/files-modal.module.css +46 -10
  55. package/app/components/sidebar/files/files-modal.tsx +22 -23
  56. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  57. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  58. package/app/components/sidebar/notes/notes-modal.tsx +18 -17
  59. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  60. package/app/components/sidebar/notes/notes.module.css +155 -0
  61. package/app/components/sidebar/sidebar-container.tsx +15 -28
  62. package/app/components/sidebar/sidebar.module.css +7 -71
  63. package/app/components/sidebar/sidebar.tsx +24 -125
  64. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  65. package/app/components/toast/toast.module.css +2 -1
  66. package/app/components/toast/toast.tsx +16 -11
  67. package/app/components/user/delete-account.tsx +10 -31
  68. package/app/components/user/inactivity-warning.module.css +9 -6
  69. package/app/components/user/inactivity-warning.tsx +15 -2
  70. package/app/components/user/manage-profile.module.css +2 -0
  71. package/app/components/user/manage-profile.tsx +108 -40
  72. package/app/hooks/useOverlayDismiss.ts +116 -0
  73. package/app/routes/auth/login.example.tsx +19 -8
  74. package/app/routes/auth/login.tsx +785 -774
  75. package/app/routes/auth/passwordReset.module.css +23 -13
  76. package/app/routes/striae/striae.module.css +10 -3
  77. package/app/routes/striae/striae.tsx +477 -31
  78. package/app/routes.ts +7 -0
  79. package/app/services/audit/audit-export-csv.ts +2 -0
  80. package/app/services/audit/audit.service.ts +202 -32
  81. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  82. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  83. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  84. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
  85. package/app/services/audit/builders/index.ts +1 -0
  86. package/app/types/audit.ts +5 -2
  87. package/app/types/case.ts +29 -0
  88. package/app/types/import.ts +3 -0
  89. package/app/types/user.ts +1 -0
  90. package/app/utils/data/permissions.ts +17 -1
  91. package/app/utils/forensics/audit-export-signature.ts +5 -1
  92. package/app/utils/forensics/confirmation-signature.ts +3 -0
  93. package/app/utils/forensics/export-verification.ts +497 -22
  94. package/functions/api/pdf/[[path]].ts +32 -1
  95. package/load-context.ts +9 -0
  96. package/package.json +6 -2
  97. package/primershear.emails.example +6 -0
  98. package/scripts/deploy-pages-secrets.sh +6 -0
  99. package/scripts/deploy-primershear-emails.sh +167 -0
  100. package/worker-configuration.d.ts +7493 -7491
  101. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  102. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  103. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  104. package/workers/data-worker/wrangler.jsonc.example +1 -1
  105. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  106. package/workers/image-worker/wrangler.jsonc.example +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  110. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  111. package/workers/pdf-worker/src/report-types.ts +3 -0
  112. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  113. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  114. package/workers/user-worker/src/user-worker.example.ts +6 -1
  115. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  116. package/workers/user-worker/wrangler.jsonc.example +1 -1
  117. package/wrangler.toml.example +1 -1
  118. package/public/.well-known/keybase.txt +0 -56
@@ -1,22 +1,74 @@
1
1
  import { type ConfirmationImportData } from '~/types';
2
2
  import {
3
3
  extractForensicManifestData,
4
+ type ManifestSignatureVerificationResult,
4
5
  type SignedForensicManifest,
5
6
  calculateSHA256Secure,
6
7
  validateCaseIntegritySecure,
7
8
  verifyForensicManifestSignature
8
9
  } from './SHA256';
10
+ import {
11
+ type AuditExportSigningPayload,
12
+ verifyAuditExportSignature
13
+ } from './audit-export-signature';
9
14
  import { verifyConfirmationSignature } from './confirmation-signature';
10
15
 
11
16
  export interface ExportVerificationResult {
12
17
  isValid: boolean;
13
18
  message: string;
14
- exportType?: 'case-zip' | 'confirmation';
19
+ exportType?: 'case-zip' | 'confirmation' | 'audit-json';
15
20
  }
16
21
 
17
22
  const CASE_EXPORT_FILE_REGEX = /_data\.(json|csv)$/i;
18
23
  const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
19
24
 
25
+ interface BundledAuditExportFile {
26
+ metadata?: {
27
+ exportTimestamp?: string;
28
+ exportVersion?: string;
29
+ totalEntries?: number;
30
+ application?: string;
31
+ exportType?: 'entries' | 'trail' | 'report';
32
+ scopeType?: 'case' | 'user';
33
+ scopeIdentifier?: string;
34
+ hash?: string;
35
+ signatureVersion?: string;
36
+ signatureMetadata?: Partial<AuditExportSigningPayload>;
37
+ signature?: {
38
+ algorithm: string;
39
+ keyId: string;
40
+ signedAt: string;
41
+ value: string;
42
+ };
43
+ };
44
+ auditTrail?: unknown;
45
+ auditEntries?: unknown;
46
+ }
47
+
48
+ interface StandaloneAuditExportFile extends BundledAuditExportFile {
49
+ metadata?: BundledAuditExportFile['metadata'] & {
50
+ integrityNote?: string;
51
+ };
52
+ }
53
+
54
+ export interface CasePackageIntegrityInput {
55
+ cleanedContent: string;
56
+ imageFiles: Record<string, Blob>;
57
+ forensicManifest: SignedForensicManifest;
58
+ verificationPublicKeyPem?: string;
59
+ bundledAuditFiles?: {
60
+ auditTrailContent?: string;
61
+ auditSignatureContent?: string;
62
+ };
63
+ }
64
+
65
+ export interface CasePackageIntegrityResult {
66
+ isValid: boolean;
67
+ signatureResult: ManifestSignatureVerificationResult;
68
+ integrityResult: Awaited<ReturnType<typeof validateCaseIntegritySecure>>;
69
+ bundledAuditVerification: ExportVerificationResult | null;
70
+ }
71
+
20
72
  function createVerificationResult(
21
73
  isValid: boolean,
22
74
  message: string,
@@ -31,7 +83,7 @@ function createVerificationResult(
31
83
 
32
84
  function getSignatureFailureMessage(
33
85
  error: string | undefined,
34
- targetLabel: 'export ZIP' | 'confirmation file'
86
+ targetLabel: 'export ZIP' | 'confirmation file' | 'audit export'
35
87
  ): string {
36
88
  if (error?.includes('invalid public key')) {
37
89
  return 'The selected PEM file is not a valid public key.';
@@ -62,6 +114,249 @@ function isConfirmationImportCandidate(candidate: unknown): candidate is Partial
62
114
  );
63
115
  }
64
116
 
117
+ function isAuditExportCandidate(candidate: unknown): candidate is StandaloneAuditExportFile {
118
+ if (!candidate || typeof candidate !== 'object') {
119
+ return false;
120
+ }
121
+
122
+ const auditCandidate = candidate as StandaloneAuditExportFile;
123
+ const metadata = auditCandidate.metadata;
124
+
125
+ if (!metadata || typeof metadata !== 'object') {
126
+ return false;
127
+ }
128
+
129
+ return (
130
+ typeof metadata.exportTimestamp === 'string' &&
131
+ typeof metadata.exportType === 'string' &&
132
+ typeof metadata.scopeType === 'string' &&
133
+ typeof metadata.scopeIdentifier === 'string' &&
134
+ typeof metadata.hash === 'string' &&
135
+ !!metadata.signature &&
136
+ (auditCandidate.auditTrail !== undefined || auditCandidate.auditEntries !== undefined)
137
+ );
138
+ }
139
+
140
+ async function verifyAuditExportContent(
141
+ fileContent: string,
142
+ verificationPublicKeyPem: string
143
+ ): Promise<ExportVerificationResult> {
144
+ try {
145
+ const parsedContent = JSON.parse(fileContent) as unknown;
146
+
147
+ if (!isAuditExportCandidate(parsedContent)) {
148
+ return createVerificationResult(
149
+ false,
150
+ 'The JSON file is not a supported Striae audit export.',
151
+ 'audit-json'
152
+ );
153
+ }
154
+
155
+ const auditExport = parsedContent as StandaloneAuditExportFile;
156
+ const metadata = auditExport.metadata!;
157
+
158
+ const unsignedAuditExport = auditExport.auditTrail !== undefined
159
+ ? {
160
+ metadata: {
161
+ exportTimestamp: metadata.exportTimestamp,
162
+ exportVersion: metadata.exportVersion,
163
+ totalEntries: metadata.totalEntries,
164
+ application: metadata.application,
165
+ exportType: metadata.exportType,
166
+ scopeType: metadata.scopeType,
167
+ scopeIdentifier: metadata.scopeIdentifier,
168
+ },
169
+ auditTrail: auditExport.auditTrail,
170
+ }
171
+ : {
172
+ metadata: {
173
+ exportTimestamp: metadata.exportTimestamp,
174
+ exportVersion: metadata.exportVersion,
175
+ totalEntries: metadata.totalEntries,
176
+ application: metadata.application,
177
+ exportType: metadata.exportType,
178
+ scopeType: metadata.scopeType,
179
+ scopeIdentifier: metadata.scopeIdentifier,
180
+ },
181
+ auditEntries: auditExport.auditEntries,
182
+ };
183
+
184
+ const recalculatedHash = await calculateSHA256Secure(JSON.stringify(unsignedAuditExport, null, 2));
185
+ const hashValid = recalculatedHash.toUpperCase() === metadata.hash!.toUpperCase();
186
+
187
+ const signaturePayload: Partial<AuditExportSigningPayload> = {
188
+ signatureVersion: metadata.signatureVersion,
189
+ exportFormat: 'json',
190
+ exportType: metadata.exportType,
191
+ scopeType: metadata.scopeType,
192
+ scopeIdentifier: metadata.scopeIdentifier,
193
+ generatedAt: metadata.exportTimestamp,
194
+ totalEntries: metadata.totalEntries,
195
+ hash: metadata.hash,
196
+ };
197
+
198
+ const signatureResult = await verifyAuditExportSignature(
199
+ signaturePayload,
200
+ metadata.signature,
201
+ verificationPublicKeyPem
202
+ );
203
+
204
+ if (hashValid && signatureResult.isValid) {
205
+ return createVerificationResult(
206
+ true,
207
+ 'The audit export passed signature and integrity verification.',
208
+ 'audit-json'
209
+ );
210
+ }
211
+
212
+ if (!hashValid && !signatureResult.isValid) {
213
+ return createVerificationResult(
214
+ false,
215
+ 'The audit export failed signature and integrity verification.',
216
+ 'audit-json'
217
+ );
218
+ }
219
+
220
+ if (!signatureResult.isValid) {
221
+ return createVerificationResult(
222
+ false,
223
+ getSignatureFailureMessage(signatureResult.error, 'audit export'),
224
+ 'audit-json'
225
+ );
226
+ }
227
+
228
+ return createVerificationResult(
229
+ false,
230
+ 'The audit export failed integrity verification.',
231
+ 'audit-json'
232
+ );
233
+ } catch {
234
+ return createVerificationResult(
235
+ false,
236
+ 'The JSON file could not be read as a supported Striae audit export.',
237
+ 'audit-json'
238
+ );
239
+ }
240
+ }
241
+
242
+ export async function verifyBundledAuditExport(
243
+ zip: {
244
+ file: (path: string) => { async: (type: 'text') => Promise<string> } | null;
245
+ },
246
+ verificationPublicKeyPem: string
247
+ ): Promise<ExportVerificationResult | null> {
248
+ const auditTrailContent = await zip.file('audit/case-audit-trail.json')?.async('text');
249
+ const auditSignatureContent = await zip.file('audit/case-audit-signature.json')?.async('text');
250
+
251
+ if (!auditTrailContent && !auditSignatureContent) {
252
+ return null;
253
+ }
254
+
255
+ if (!auditTrailContent || !auditSignatureContent) {
256
+ return createVerificationResult(
257
+ false,
258
+ 'The archive ZIP contains incomplete bundled audit verification files.',
259
+ 'case-zip'
260
+ );
261
+ }
262
+
263
+ try {
264
+ const auditTrailExport = JSON.parse(auditTrailContent) as BundledAuditExportFile;
265
+ const auditSignatureExport = JSON.parse(auditSignatureContent) as {
266
+ signatureMetadata?: Partial<AuditExportSigningPayload>;
267
+ signature?: NonNullable<BundledAuditExportFile['metadata']>['signature'];
268
+ };
269
+
270
+ const metadata = auditTrailExport.metadata;
271
+ if (!metadata?.signature || typeof metadata.hash !== 'string') {
272
+ return createVerificationResult(
273
+ false,
274
+ 'The bundled audit export is missing required hash or signature metadata.',
275
+ 'case-zip'
276
+ );
277
+ }
278
+
279
+ const unsignedAuditExport = auditTrailExport.auditTrail !== undefined
280
+ ? {
281
+ metadata: {
282
+ exportTimestamp: metadata.exportTimestamp,
283
+ exportVersion: metadata.exportVersion,
284
+ totalEntries: metadata.totalEntries,
285
+ application: metadata.application,
286
+ exportType: metadata.exportType,
287
+ scopeType: metadata.scopeType,
288
+ scopeIdentifier: metadata.scopeIdentifier,
289
+ },
290
+ auditTrail: auditTrailExport.auditTrail,
291
+ }
292
+ : {
293
+ metadata: {
294
+ exportTimestamp: metadata.exportTimestamp,
295
+ exportVersion: metadata.exportVersion,
296
+ totalEntries: metadata.totalEntries,
297
+ application: metadata.application,
298
+ exportType: metadata.exportType,
299
+ scopeType: metadata.scopeType,
300
+ scopeIdentifier: metadata.scopeIdentifier,
301
+ },
302
+ auditEntries: auditTrailExport.auditEntries,
303
+ };
304
+
305
+ const recalculatedHash = await calculateSHA256Secure(JSON.stringify(unsignedAuditExport, null, 2));
306
+ if (recalculatedHash.toUpperCase() !== metadata.hash.toUpperCase()) {
307
+ return createVerificationResult(
308
+ false,
309
+ 'The bundled audit export failed integrity verification.',
310
+ 'case-zip'
311
+ );
312
+ }
313
+
314
+ const embeddedSignaturePayload: Partial<AuditExportSigningPayload> = metadata.signatureMetadata ?? {
315
+ signatureVersion: metadata.signatureVersion,
316
+ exportFormat: 'json',
317
+ exportType: metadata.exportType,
318
+ scopeType: metadata.scopeType,
319
+ scopeIdentifier: metadata.scopeIdentifier,
320
+ generatedAt: metadata.exportTimestamp,
321
+ totalEntries: metadata.totalEntries,
322
+ hash: metadata.hash,
323
+ };
324
+
325
+ const signatureVerification = await verifyAuditExportSignature(
326
+ embeddedSignaturePayload,
327
+ metadata.signature,
328
+ verificationPublicKeyPem
329
+ );
330
+
331
+ if (!signatureVerification.isValid) {
332
+ return createVerificationResult(
333
+ false,
334
+ getSignatureFailureMessage(signatureVerification.error, 'export ZIP'),
335
+ 'case-zip'
336
+ );
337
+ }
338
+
339
+ if (
340
+ JSON.stringify(auditSignatureExport.signatureMetadata ?? null) !== JSON.stringify(metadata.signatureMetadata ?? null) ||
341
+ JSON.stringify(auditSignatureExport.signature ?? null) !== JSON.stringify(metadata.signature ?? null)
342
+ ) {
343
+ return createVerificationResult(
344
+ false,
345
+ 'The bundled audit signature artifact does not match the signed audit export.',
346
+ 'case-zip'
347
+ );
348
+ }
349
+
350
+ return null;
351
+ } catch {
352
+ return createVerificationResult(
353
+ false,
354
+ 'The bundled audit export could not be parsed for verification.',
355
+ 'case-zip'
356
+ );
357
+ }
358
+ }
359
+
65
360
  /**
66
361
  * Remove forensic warning from content for hash validation.
67
362
  * Supports the warning formats added to JSON and CSV case exports.
@@ -174,8 +469,26 @@ async function verifyCaseZipExport(
174
469
  })
175
470
  );
176
471
 
177
- const signatureResult = await verifyForensicManifestSignature(forensicManifest, verificationPublicKeyPem);
178
- const integrityResult = await validateCaseIntegritySecure(cleanedContent, imageFiles, manifestData);
472
+ const bundledAuditFiles = {
473
+ auditTrailContent: await zip.file('audit/case-audit-trail.json')?.async('text'),
474
+ auditSignatureContent: await zip.file('audit/case-audit-signature.json')?.async('text')
475
+ };
476
+
477
+ const casePackageResult = await verifyCasePackageIntegrity({
478
+ cleanedContent,
479
+ imageFiles,
480
+ forensicManifest,
481
+ verificationPublicKeyPem,
482
+ bundledAuditFiles
483
+ });
484
+
485
+ const signatureResult = casePackageResult.signatureResult;
486
+ const integrityResult = casePackageResult.integrityResult;
487
+ const bundledAuditVerification = casePackageResult.bundledAuditVerification;
488
+
489
+ if (bundledAuditVerification) {
490
+ return bundledAuditVerification;
491
+ }
179
492
 
180
493
  if (signatureResult.isValid && integrityResult.isValid) {
181
494
  return createVerificationResult(
@@ -211,22 +524,6 @@ async function verifyCaseZipExport(
211
524
  }
212
525
  }
213
526
 
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
527
  async function verifyConfirmationContent(
231
528
  fileContent: string,
232
529
  verificationPublicKeyPem: string
@@ -343,11 +640,189 @@ export async function verifyExportFile(
343
640
  }
344
641
 
345
642
  if (lowerName.endsWith('.json')) {
346
- return verifyConfirmationExport(file, verificationPublicKeyPem);
643
+ try {
644
+ const fileContent = await file.text();
645
+ const parsedContent = JSON.parse(fileContent) as unknown;
646
+
647
+ if (isConfirmationImportCandidate(parsedContent)) {
648
+ return verifyConfirmationContent(fileContent, verificationPublicKeyPem);
649
+ }
650
+
651
+ if (isAuditExportCandidate(parsedContent)) {
652
+ return verifyAuditExportContent(fileContent, verificationPublicKeyPem);
653
+ }
654
+
655
+ return createVerificationResult(
656
+ false,
657
+ 'Select a confirmation JSON/ZIP file, standalone audit JSON export, or a case export ZIP file.'
658
+ );
659
+ } catch {
660
+ return createVerificationResult(
661
+ false,
662
+ 'The JSON file could not be read as a supported Striae confirmation or audit export.'
663
+ );
664
+ }
347
665
  }
348
666
 
349
667
  return createVerificationResult(
350
668
  false,
351
- 'Select a confirmation JSON/ZIP file or a case export ZIP file.'
669
+ 'Select a confirmation JSON/ZIP file, standalone audit JSON export, or a case export ZIP file.'
670
+ );
671
+ }
672
+
673
+ export async function verifyCasePackageIntegrity(
674
+ input: CasePackageIntegrityInput
675
+ ): Promise<CasePackageIntegrityResult> {
676
+ const manifestData = extractForensicManifestData(input.forensicManifest);
677
+
678
+ if (!manifestData) {
679
+ return {
680
+ isValid: false,
681
+ signatureResult: {
682
+ isValid: false,
683
+ error: 'Forensic manifest structure is invalid'
684
+ },
685
+ integrityResult: {
686
+ isValid: false,
687
+ dataValid: false,
688
+ imageValidation: {},
689
+ manifestValid: false,
690
+ errors: ['Forensic manifest structure is invalid'],
691
+ summary: 'Manifest validation failed'
692
+ },
693
+ bundledAuditVerification: null
694
+ };
695
+ }
696
+
697
+ const signatureResult = await verifyForensicManifestSignature(
698
+ input.forensicManifest,
699
+ input.verificationPublicKeyPem
700
+ );
701
+
702
+ const integrityResult = await validateCaseIntegritySecure(
703
+ input.cleanedContent,
704
+ input.imageFiles,
705
+ manifestData
352
706
  );
707
+
708
+ const bundledAuditVerification = input.bundledAuditFiles
709
+ ? await (async () => {
710
+ const { auditTrailContent, auditSignatureContent } = input.bundledAuditFiles ?? {};
711
+
712
+ if (!auditTrailContent && !auditSignatureContent) {
713
+ return null;
714
+ }
715
+
716
+ if (!auditTrailContent || !auditSignatureContent) {
717
+ return createVerificationResult(
718
+ false,
719
+ 'The archive ZIP contains incomplete bundled audit verification files.',
720
+ 'case-zip'
721
+ );
722
+ }
723
+
724
+ try {
725
+ const auditTrailExport = JSON.parse(auditTrailContent) as BundledAuditExportFile;
726
+ const auditSignatureExport = JSON.parse(auditSignatureContent) as {
727
+ signatureMetadata?: Partial<AuditExportSigningPayload>;
728
+ signature?: NonNullable<BundledAuditExportFile['metadata']>['signature'];
729
+ };
730
+
731
+ const metadata = auditTrailExport.metadata;
732
+ if (!metadata?.signature || typeof metadata.hash !== 'string') {
733
+ return createVerificationResult(
734
+ false,
735
+ 'The bundled audit export is missing required hash or signature metadata.',
736
+ 'case-zip'
737
+ );
738
+ }
739
+
740
+ const unsignedAuditExport = auditTrailExport.auditTrail !== undefined
741
+ ? {
742
+ metadata: {
743
+ exportTimestamp: metadata.exportTimestamp,
744
+ exportVersion: metadata.exportVersion,
745
+ totalEntries: metadata.totalEntries,
746
+ application: metadata.application,
747
+ exportType: metadata.exportType,
748
+ scopeType: metadata.scopeType,
749
+ scopeIdentifier: metadata.scopeIdentifier,
750
+ },
751
+ auditTrail: auditTrailExport.auditTrail,
752
+ }
753
+ : {
754
+ metadata: {
755
+ exportTimestamp: metadata.exportTimestamp,
756
+ exportVersion: metadata.exportVersion,
757
+ totalEntries: metadata.totalEntries,
758
+ application: metadata.application,
759
+ exportType: metadata.exportType,
760
+ scopeType: metadata.scopeType,
761
+ scopeIdentifier: metadata.scopeIdentifier,
762
+ },
763
+ auditEntries: auditTrailExport.auditEntries,
764
+ };
765
+
766
+ const recalculatedHash = await calculateSHA256Secure(JSON.stringify(unsignedAuditExport, null, 2));
767
+ if (recalculatedHash.toUpperCase() !== metadata.hash.toUpperCase()) {
768
+ return createVerificationResult(
769
+ false,
770
+ 'The bundled audit export failed integrity verification.',
771
+ 'case-zip'
772
+ );
773
+ }
774
+
775
+ const embeddedSignaturePayload: Partial<AuditExportSigningPayload> = metadata.signatureMetadata ?? {
776
+ signatureVersion: metadata.signatureVersion,
777
+ exportFormat: 'json',
778
+ exportType: metadata.exportType,
779
+ scopeType: metadata.scopeType,
780
+ scopeIdentifier: metadata.scopeIdentifier,
781
+ generatedAt: metadata.exportTimestamp,
782
+ totalEntries: metadata.totalEntries,
783
+ hash: metadata.hash,
784
+ };
785
+
786
+ const signatureVerification = await verifyAuditExportSignature(
787
+ embeddedSignaturePayload,
788
+ metadata.signature,
789
+ input.verificationPublicKeyPem
790
+ );
791
+
792
+ if (!signatureVerification.isValid) {
793
+ return createVerificationResult(
794
+ false,
795
+ getSignatureFailureMessage(signatureVerification.error, 'export ZIP'),
796
+ 'case-zip'
797
+ );
798
+ }
799
+
800
+ if (
801
+ JSON.stringify(auditSignatureExport.signatureMetadata ?? null) !== JSON.stringify(metadata.signatureMetadata ?? null) ||
802
+ JSON.stringify(auditSignatureExport.signature ?? null) !== JSON.stringify(metadata.signature ?? null)
803
+ ) {
804
+ return createVerificationResult(
805
+ false,
806
+ 'The bundled audit signature artifact does not match the signed audit export.',
807
+ 'case-zip'
808
+ );
809
+ }
810
+
811
+ return null;
812
+ } catch {
813
+ return createVerificationResult(
814
+ false,
815
+ 'The bundled audit export could not be parsed for verification.',
816
+ 'case-zip'
817
+ );
818
+ }
819
+ })()
820
+ : null;
821
+
822
+ return {
823
+ isValid: signatureResult.isValid && integrityResult.isValid && !bundledAuditVerification,
824
+ signatureResult,
825
+ integrityResult,
826
+ bundledAuditVerification
827
+ };
353
828
  }
@@ -6,6 +6,8 @@ interface PdfProxyContext {
6
6
  }
7
7
 
8
8
  const SUPPORTED_METHODS = new Set(['POST', 'OPTIONS']);
9
+ const PRIMERSHEAR_FORMAT = 'primershear';
10
+ const DEFAULT_FORMAT = 'striae';
9
11
 
10
12
  function textResponse(message: string, status: number): Response {
11
13
  return new Response(message, {
@@ -40,6 +42,12 @@ function extractProxyPath(url: URL): string | null {
40
42
  return remainder.length > 0 ? remainder : '/';
41
43
  }
42
44
 
45
+ function resolveReportFormat(email: string | null, primershearEmails: string): string {
46
+ if (!email) return DEFAULT_FORMAT;
47
+ const allowed = primershearEmails.split(',').map(e => e.trim().toLowerCase()).filter(Boolean);
48
+ return allowed.includes(email.toLowerCase()) ? PRIMERSHEAR_FORMAT : DEFAULT_FORMAT;
49
+ }
50
+
43
51
  export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Response> => {
44
52
  if (!SUPPORTED_METHODS.has(request.method)) {
45
53
  return textResponse('Method not allowed', 405);
@@ -86,12 +94,35 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
86
94
 
87
95
  upstreamHeaders.set('X-Custom-Auth-Key', env.PDF_WORKER_AUTH);
88
96
 
97
+ // Resolve the report format server-side based on the verified user email.
98
+ // This prevents email lists from ever being exposed in the client bundle.
99
+ const reportFormat = resolveReportFormat(
100
+ identity.email,
101
+ env.PRIMERSHEAR_EMAILS ?? ''
102
+ );
103
+
104
+ let upstreamBody: BodyInit;
105
+ try {
106
+ const payload = await request.json() as Record<string, unknown>;
107
+ // Inject the server-resolved format, overriding any client-supplied value.
108
+ if (payload.data && typeof payload.data === 'object') {
109
+ payload.reportFormat = reportFormat;
110
+ } else {
111
+ // Legacy flat payload shape
112
+ payload.reportFormat = reportFormat;
113
+ }
114
+ upstreamBody = JSON.stringify(payload);
115
+ upstreamHeaders.set('Content-Type', 'application/json');
116
+ } catch {
117
+ return textResponse('Invalid request body', 400);
118
+ }
119
+
89
120
  let upstreamResponse: Response;
90
121
  try {
91
122
  upstreamResponse = await fetch(upstreamUrl, {
92
123
  method: request.method,
93
124
  headers: upstreamHeaders,
94
- body: request.body
125
+ body: upstreamBody
95
126
  });
96
127
  } catch {
97
128
  return textResponse('Upstream PDF service unavailable', 502);
@@ -0,0 +1,9 @@
1
+ import { type PlatformProxy } from "wrangler";
2
+
3
+ type Cloudflare = Omit<PlatformProxy<Env>, "dispose">;
4
+
5
+ declare module "react-router" {
6
+ interface AppLoadContext {
7
+ cloudflare: Cloudflare;
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.0.3",
3
+ "version": "4.2.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -43,8 +43,10 @@
43
43
  "app/entry.client.tsx",
44
44
  "app/entry.server.tsx",
45
45
  "app/root.tsx",
46
+ "app/routes.ts",
46
47
  "app/tailwind.css",
47
48
  "react-router.config.ts",
49
+ "load-context.ts",
48
50
  "functions/",
49
51
  "public/",
50
52
  "scripts/",
@@ -60,6 +62,7 @@
60
62
  "workers/pdf-worker/src/report-types.ts",
61
63
  "workers/*/wrangler.jsonc.example",
62
64
  ".env.example",
65
+ "primershear.emails.example",
63
66
  "firebase.json",
64
67
  "postcss.config.js",
65
68
  "tailwind.config.ts",
@@ -99,8 +102,9 @@
99
102
  "install-workers": "bash ./scripts/install-workers.sh",
100
103
  "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:keys && npm run deploy-workers:pdf && npm run deploy-workers:user",
101
104
  "deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
102
- "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh",
105
+ "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh --production-only",
103
106
  "deploy-pages": "bash ./scripts/deploy-pages.sh",
107
+ "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh --production-only",
104
108
  "deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
105
109
  "deploy-workers:data": "cd workers/data-worker && npm run deploy",
106
110
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
@@ -0,0 +1,6 @@
1
+ # PrimerShear PDF format - authorized email addresses
2
+ # One email per line. Lines starting with # are ignored.
3
+ # This file is untracked. Run: npm run deploy-primershear to push changes.
4
+ #
5
+ # Example:
6
+ # analyst@organization.com
@@ -178,6 +178,12 @@ deploy_pages_environment_secrets() {
178
178
  set_pages_secret "API_TOKEN" "$optional_api_token" "$pages_env"
179
179
  fi
180
180
 
181
+ local optional_primershear_emails
182
+ optional_primershear_emails=$(get_optional_value "PRIMERSHEAR_EMAILS")
183
+ if [ -n "$optional_primershear_emails" ]; then
184
+ set_pages_secret "PRIMERSHEAR_EMAILS" "$optional_primershear_emails" "$pages_env"
185
+ fi
186
+
181
187
  echo -e "${GREEN}✅ Pages secrets deployed to $pages_env${NC}"
182
188
  }
183
189