@striae-org/striae 4.1.0 → 4.2.1

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 (124) hide show
  1. package/.env.example +8 -0
  2. package/LICENSE +1 -1
  3. package/app/components/actions/case-export/core-export.ts +14 -8
  4. package/app/components/actions/case-export/data-processing.ts +1 -0
  5. package/app/components/actions/case-export/download-handlers.ts +7 -0
  6. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  7. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  8. package/app/components/actions/case-import/orchestrator.ts +78 -32
  9. package/app/components/actions/case-import/storage-operations.ts +97 -8
  10. package/app/components/actions/case-import/zip-processing.ts +159 -86
  11. package/app/components/actions/case-manage.ts +463 -8
  12. package/app/components/actions/confirm-export.ts +9 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +19 -8
  15. package/app/components/audit/user-audit.module.css +21 -0
  16. package/app/components/audit/viewer/audit-entries-list.tsx +12 -2
  17. package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
  18. package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
  19. package/app/components/audit/viewer/use-audit-viewer-data.ts +24 -1
  20. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  21. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  22. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  23. package/app/components/canvas/canvas.module.css +64 -54
  24. package/app/components/canvas/canvas.tsx +14 -16
  25. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  26. package/app/components/canvas/confirmation/confirmation.tsx +12 -14
  27. package/app/components/colors/colors.module.css +4 -3
  28. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  29. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  30. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  31. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  32. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  33. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  34. package/app/components/navbar/navbar.module.css +447 -0
  35. package/app/components/navbar/navbar.tsx +402 -0
  36. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  37. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
  38. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  39. package/app/components/sidebar/case-export/case-export.tsx +8 -46
  40. package/app/components/sidebar/case-import/case-import.module.css +23 -0
  41. package/app/components/sidebar/case-import/case-import.tsx +64 -16
  42. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  43. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  44. package/app/components/sidebar/cases/case-sidebar.tsx +68 -588
  45. package/app/components/sidebar/cases/cases-modal.module.css +1 -0
  46. package/app/components/sidebar/cases/cases-modal.tsx +82 -43
  47. package/app/components/sidebar/cases/cases.module.css +82 -21
  48. package/app/components/sidebar/files/files-modal.module.css +1 -0
  49. package/app/components/sidebar/files/files-modal.tsx +49 -52
  50. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  51. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +187 -138
  52. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  53. package/app/components/sidebar/notes/notes-editor-modal.tsx +64 -0
  54. package/app/components/sidebar/notes/notes.module.css +170 -1
  55. package/app/components/sidebar/sidebar-container.tsx +16 -28
  56. package/app/components/sidebar/sidebar.module.css +5 -69
  57. package/app/components/sidebar/sidebar.tsx +27 -125
  58. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  59. package/app/components/user/inactivity-warning.module.css +1 -0
  60. package/app/components/user/inactivity-warning.tsx +15 -2
  61. package/app/components/user/manage-profile.tsx +23 -10
  62. package/app/{tailwind.css → global.css} +1 -3
  63. package/app/hooks/useOverlayDismiss.ts +54 -4
  64. package/app/root.tsx +1 -1
  65. package/app/routes/auth/login.tsx +785 -774
  66. package/app/routes/striae/striae.module.css +10 -3
  67. package/app/routes/striae/striae.tsx +475 -30
  68. package/app/services/audit/audit.service.ts +173 -27
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  70. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
  71. package/app/services/audit/builders/index.ts +1 -0
  72. package/app/types/audit.ts +4 -1
  73. package/app/types/case.ts +29 -0
  74. package/app/types/import.ts +3 -0
  75. package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
  76. package/app/utils/data/data-operations.ts +17 -861
  77. package/app/utils/data/index.ts +11 -1
  78. package/app/utils/data/operations/batch-operations.ts +113 -0
  79. package/app/utils/data/operations/case-operations.ts +168 -0
  80. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  81. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  82. package/app/utils/data/operations/index.ts +7 -0
  83. package/app/utils/data/operations/signing-operations.ts +225 -0
  84. package/app/utils/data/operations/types.ts +42 -0
  85. package/app/utils/data/operations/validation-operations.ts +48 -0
  86. package/app/utils/data/permissions.ts +16 -1
  87. package/app/utils/forensics/audit-export-signature.ts +5 -1
  88. package/app/utils/forensics/confirmation-signature.ts +3 -0
  89. package/app/utils/forensics/export-verification.ts +426 -22
  90. package/functions/api/_shared/firebase-auth.ts +2 -7
  91. package/functions/api/image/[[path]].ts +20 -23
  92. package/functions/api/pdf/[[path]].ts +27 -8
  93. package/package.json +7 -12
  94. package/scripts/deploy-primershear-emails.sh +2 -1
  95. package/worker-configuration.d.ts +3 -3
  96. package/workers/audit-worker/package.json +1 -1
  97. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  98. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  99. package/workers/data-worker/package.json +1 -1
  100. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  101. package/workers/data-worker/wrangler.jsonc.example +1 -1
  102. package/workers/image-worker/package.json +1 -1
  103. package/workers/image-worker/src/image-worker.example.ts +16 -5
  104. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  105. package/workers/image-worker/wrangler.jsonc.example +1 -1
  106. package/workers/keys-worker/package.json +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/package.json +1 -1
  110. package/workers/pdf-worker/src/formats/format-striae.ts +9 -14
  111. package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
  112. package/workers/pdf-worker/src/report-types.ts +3 -3
  113. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  114. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  115. package/workers/user-worker/package.json +1 -1
  116. package/workers/user-worker/src/user-worker.example.ts +17 -0
  117. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  118. package/workers/user-worker/wrangler.jsonc.example +1 -1
  119. package/wrangler.toml.example +1 -1
  120. package/NOTICE +0 -13
  121. package/app/components/sidebar/notes/notes-modal.tsx +0 -53
  122. package/postcss.config.js +0 -6
  123. package/public/.well-known/keybase.txt +0 -56
  124. package/tailwind.config.ts +0 -22
@@ -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,118 @@ 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
+ const verificationPublicKeyPem = input.verificationPublicKeyPem;
678
+
679
+ if (!manifestData) {
680
+ return {
681
+ isValid: false,
682
+ signatureResult: {
683
+ isValid: false,
684
+ error: 'Forensic manifest structure is invalid'
685
+ },
686
+ integrityResult: {
687
+ isValid: false,
688
+ dataValid: false,
689
+ imageValidation: {},
690
+ manifestValid: false,
691
+ errors: ['Forensic manifest structure is invalid'],
692
+ summary: 'Manifest validation failed'
693
+ },
694
+ bundledAuditVerification: null
695
+ };
696
+ }
697
+
698
+ if (!verificationPublicKeyPem) {
699
+ return {
700
+ isValid: false,
701
+ signatureResult: {
702
+ isValid: false,
703
+ error: 'Missing verification public key'
704
+ },
705
+ integrityResult: {
706
+ isValid: false,
707
+ dataValid: false,
708
+ imageValidation: {},
709
+ manifestValid: false,
710
+ errors: ['Missing verification public key'],
711
+ summary: 'Manifest validation failed'
712
+ },
713
+ bundledAuditVerification: null
714
+ };
715
+ }
716
+
717
+ const signatureResult = await verifyForensicManifestSignature(
718
+ input.forensicManifest,
719
+ verificationPublicKeyPem
720
+ );
721
+
722
+ const integrityResult = await validateCaseIntegritySecure(
723
+ input.cleanedContent,
724
+ input.imageFiles,
725
+ manifestData
352
726
  );
727
+
728
+ const bundledAuditVerification = input.bundledAuditFiles
729
+ ? await verifyBundledAuditExport(
730
+ {
731
+ file: (path: string) => {
732
+ const content = path === 'audit/case-audit-trail.json'
733
+ ? input.bundledAuditFiles?.auditTrailContent
734
+ : path === 'audit/case-audit-signature.json'
735
+ ? input.bundledAuditFiles?.auditSignatureContent
736
+ : undefined;
737
+
738
+ if (content === undefined) {
739
+ return null;
740
+ }
741
+
742
+ return {
743
+ async: async () => content,
744
+ };
745
+ }
746
+ },
747
+ verificationPublicKeyPem
748
+ )
749
+ : null;
750
+
751
+ return {
752
+ isValid: signatureResult.isValid && integrityResult.isValid && !bundledAuditVerification,
753
+ signatureResult,
754
+ integrityResult,
755
+ bundledAuditVerification
756
+ };
353
757
  }
@@ -1,5 +1,3 @@
1
- import firebaseConfig from '../../../app/config/firebase';
2
-
3
1
  interface FirebaseJwtHeader {
4
2
  alg?: string;
5
3
  kid?: string;
@@ -31,8 +29,6 @@ const GOOGLE_SECURETOKEN_JWKS_URL =
31
29
  'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
32
30
  const DEFAULT_JWKS_CACHE_SECONDS = 300;
33
31
  const CLOCK_SKEW_SECONDS = 300;
34
- const FALLBACK_PROJECT_ID =
35
- typeof firebaseConfig.projectId === 'string' ? firebaseConfig.projectId.trim() : '';
36
32
 
37
33
  const textEncoder = new TextEncoder();
38
34
  const textDecoder = new TextDecoder();
@@ -156,12 +152,11 @@ async function verifyTokenSignature(
156
152
 
157
153
  function validateTokenClaims(payload: FirebaseJwtPayload, env: Env): boolean {
158
154
  const configuredProjectId = typeof env.PROJECT_ID === 'string' ? env.PROJECT_ID.trim() : '';
159
- const allowedProjectIds = new Set([configuredProjectId, FALLBACK_PROJECT_ID].filter(Boolean));
160
- if (allowedProjectIds.size === 0) {
155
+ if (configuredProjectId.length === 0) {
161
156
  return false;
162
157
  }
163
158
 
164
- if (typeof payload.aud !== 'string' || !allowedProjectIds.has(payload.aud)) {
159
+ if (typeof payload.aud !== 'string' || payload.aud !== configuredProjectId) {
165
160
  return false;
166
161
  }
167
162
 
@@ -30,44 +30,37 @@ function normalizeWorkerBaseUrl(workerDomain: string): string {
30
30
  return `https://${trimmedDomain}`;
31
31
  }
32
32
 
33
- function extractProxyPath(url: URL): string | null {
33
+ type ProxyPathResult =
34
+ | { ok: true; path: string }
35
+ | { ok: false; reason: 'not-found' | 'bad-encoding' };
36
+
37
+ function extractProxyPath(url: URL): ProxyPathResult {
34
38
  const routePrefix = '/api/image';
35
39
  if (!url.pathname.startsWith(routePrefix)) {
36
- return null;
40
+ return { ok: false, reason: 'not-found' };
37
41
  }
38
42
 
39
43
  const remainder = url.pathname.slice(routePrefix.length);
40
44
  if (remainder.length === 0) {
41
- return '/';
45
+ return { ok: true, path: '/' };
42
46
  }
43
47
 
44
48
  const normalizedRemainder = remainder.startsWith('/') ? remainder : `/${remainder}`;
45
49
  const encodedPath = normalizedRemainder.slice(1);
50
+ if (encodedPath.length === 0) {
51
+ return { ok: true, path: normalizedRemainder };
52
+ }
46
53
 
47
54
  try {
48
55
  const decodedPath = decodeURIComponent(encodedPath);
49
- if (decodedPath.length > 0) {
50
- return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`;
51
- }
56
+ return { ok: true, path: decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}` };
52
57
  } catch {
53
- // Keep legacy behavior for non-encoded paths.
58
+ return { ok: false, reason: 'bad-encoding' };
54
59
  }
55
-
56
- return normalizedRemainder;
57
60
  }
58
61
 
59
62
  function resolveImageWorkerToken(env: Env): string {
60
- const imageToken = typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
61
- if (imageToken.length > 0) {
62
- return imageToken;
63
- }
64
-
65
- const apiToken = typeof env.API_TOKEN === 'string' ? env.API_TOKEN.trim() : '';
66
- if (apiToken.length > 0) {
67
- return apiToken;
68
- }
69
-
70
- return '';
63
+ return typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
71
64
  }
72
65
 
73
66
  export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Response> => {
@@ -91,11 +84,15 @@ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Re
91
84
  }
92
85
 
93
86
  const requestUrl = new URL(request.url);
94
- const proxyPath = extractProxyPath(requestUrl);
95
- if (!proxyPath) {
96
- return textResponse('Not Found', 404);
87
+ const proxyPathResult = extractProxyPath(requestUrl);
88
+ if (!proxyPathResult.ok) {
89
+ return proxyPathResult.reason === 'bad-encoding'
90
+ ? textResponse('Bad Request: malformed image path encoding', 400)
91
+ : textResponse('Not Found', 404);
97
92
  }
98
93
 
94
+ const proxyPath = proxyPathResult.path;
95
+
99
96
  const imageWorkerToken = resolveImageWorkerToken(env);
100
97
  if (!env.IMAGES_WORKER_DOMAIN || !imageWorkerToken) {
101
98
  return textResponse('Image service not configured', 502);
@@ -9,6 +9,10 @@ const SUPPORTED_METHODS = new Set(['POST', 'OPTIONS']);
9
9
  const PRIMERSHEAR_FORMAT = 'primershear';
10
10
  const DEFAULT_FORMAT = 'striae';
11
11
 
12
+ interface PdfProxyRequestBody {
13
+ data: Record<string, unknown>;
14
+ }
15
+
12
16
  function textResponse(message: string, status: number): Response {
13
17
  return new Response(message, {
14
18
  status,
@@ -48,6 +52,21 @@ function resolveReportFormat(email: string | null, primershearEmails: string): s
48
52
  return allowed.includes(email.toLowerCase()) ? PRIMERSHEAR_FORMAT : DEFAULT_FORMAT;
49
53
  }
50
54
 
55
+ function parsePdfProxyRequestBody(payload: unknown): PdfProxyRequestBody | null {
56
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
57
+ return null;
58
+ }
59
+
60
+ const record = payload as Record<string, unknown>;
61
+ if (!record.data || typeof record.data !== 'object' || Array.isArray(record.data)) {
62
+ return null;
63
+ }
64
+
65
+ return {
66
+ data: record.data as Record<string, unknown>
67
+ };
68
+ }
69
+
51
70
  export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Response> => {
52
71
  if (!SUPPORTED_METHODS.has(request.method)) {
53
72
  return textResponse('Method not allowed', 405);
@@ -103,15 +122,15 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
103
122
 
104
123
  let upstreamBody: BodyInit;
105
124
  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;
125
+ const payload = parsePdfProxyRequestBody(await request.json());
126
+ if (!payload) {
127
+ return textResponse('Invalid PDF request body', 400);
113
128
  }
114
- upstreamBody = JSON.stringify(payload);
129
+
130
+ upstreamBody = JSON.stringify({
131
+ data: payload.data,
132
+ reportFormat
133
+ });
115
134
  upstreamHeaders.set('Content-Type', 'application/json');
116
135
  } catch {
117
136
  return textResponse('Invalid request body', 400);