@striae-org/striae 4.1.0 → 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 (91) 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 +9 -2
  12. package/app/components/actions/image-manage.ts +77 -44
  13. package/app/components/audit/user-audit-viewer.tsx +19 -8
  14. package/app/components/audit/user-audit.module.css +21 -0
  15. package/app/components/audit/viewer/audit-entries-list.tsx +7 -0
  16. package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
  17. package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
  18. package/app/components/audit/viewer/use-audit-viewer-data.ts +21 -1
  19. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  20. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  21. package/app/components/canvas/canvas.module.css +64 -54
  22. package/app/components/canvas/canvas.tsx +14 -16
  23. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  24. package/app/components/canvas/confirmation/confirmation.tsx +6 -12
  25. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  26. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  27. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  28. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  29. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  30. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  31. package/app/components/navbar/navbar.module.css +447 -0
  32. package/app/components/navbar/navbar.tsx +377 -0
  33. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  34. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
  35. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  36. package/app/components/sidebar/case-export/case-export.tsx +8 -46
  37. package/app/components/sidebar/case-import/case-import.module.css +23 -0
  38. package/app/components/sidebar/case-import/case-import.tsx +64 -16
  39. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  40. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  41. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  42. package/app/components/sidebar/cases/cases-modal.module.css +1 -0
  43. package/app/components/sidebar/cases/cases-modal.tsx +6 -8
  44. package/app/components/sidebar/cases/cases.module.css +62 -21
  45. package/app/components/sidebar/files/files-modal.module.css +1 -0
  46. package/app/components/sidebar/files/files-modal.tsx +12 -13
  47. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  48. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  49. package/app/components/sidebar/notes/notes-modal.tsx +7 -8
  50. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  51. package/app/components/sidebar/notes/notes.module.css +153 -0
  52. package/app/components/sidebar/sidebar-container.tsx +15 -28
  53. package/app/components/sidebar/sidebar.module.css +5 -69
  54. package/app/components/sidebar/sidebar.tsx +24 -125
  55. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  56. package/app/components/user/inactivity-warning.module.css +1 -0
  57. package/app/components/user/inactivity-warning.tsx +15 -2
  58. package/app/components/user/manage-profile.tsx +23 -10
  59. package/app/hooks/useOverlayDismiss.ts +52 -4
  60. package/app/routes/auth/login.tsx +785 -774
  61. package/app/routes/striae/striae.module.css +10 -3
  62. package/app/routes/striae/striae.tsx +469 -30
  63. package/app/services/audit/audit.service.ts +173 -27
  64. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  65. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
  66. package/app/services/audit/builders/index.ts +1 -0
  67. package/app/types/audit.ts +3 -1
  68. package/app/types/case.ts +29 -0
  69. package/app/types/import.ts +3 -0
  70. package/app/utils/data/permissions.ts +16 -1
  71. package/app/utils/forensics/audit-export-signature.ts +5 -1
  72. package/app/utils/forensics/confirmation-signature.ts +3 -0
  73. package/app/utils/forensics/export-verification.ts +497 -22
  74. package/package.json +3 -3
  75. package/scripts/deploy-primershear-emails.sh +2 -1
  76. package/worker-configuration.d.ts +1 -1
  77. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  78. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  79. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  80. package/workers/data-worker/wrangler.jsonc.example +1 -1
  81. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  82. package/workers/image-worker/wrangler.jsonc.example +1 -1
  83. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  84. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  85. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  86. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  87. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  88. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  89. package/workers/user-worker/wrangler.jsonc.example +1 -1
  90. package/wrangler.toml.example +1 -1
  91. 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.1.0",
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",
@@ -102,9 +102,9 @@
102
102
  "install-workers": "bash ./scripts/install-workers.sh",
103
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",
104
104
  "deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
105
- "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh",
105
+ "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh --production-only",
106
106
  "deploy-pages": "bash ./scripts/deploy-pages.sh",
107
- "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh",
107
+ "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh --production-only",
108
108
  "deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
109
109
  "deploy-workers:data": "cd workers/data-worker && npm run deploy",
110
110
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
@@ -81,7 +81,8 @@ if [ ! -f "$EMAILS_FILE" ]; then
81
81
  fi
82
82
 
83
83
  # Strip comment lines and blank lines, then join with commas
84
- PRIMERSHEAR_EMAILS=$(grep -v '^\s*#' "$EMAILS_FILE" | grep -v '^\s*$' | paste -sd ',' -)
84
+ # Use || true to avoid failure if paste gets no input (handles empty file gracefully)
85
+ PRIMERSHEAR_EMAILS=$(grep -v '^\s*#' "$EMAILS_FILE" | grep -v '^\s*$' | paste -sd ',' - || true)
85
86
 
86
87
  if [ -z "$PRIMERSHEAR_EMAILS" ]; then
87
88
  echo -e "${YELLOW}⚠️ primershear.emails contains no active email addresses.${NC}"
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable */
2
2
  // Generated by Wrangler by running `wrangler types` (hash: 3509cf2c6fcfb4a2a6e6bd16efbe22b3)
3
- // Runtime types generated with workerd@1.20250823.0 2026-03-19 nodejs_compat
3
+ // Runtime types generated with workerd@1.20250823.0 2026-03-20 nodejs_compat
4
4
  declare namespace Cloudflare {
5
5
  interface Env {
6
6
  ACCOUNT_ID: string;