@striae-org/striae 4.3.4 → 5.1.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 (61) hide show
  1. package/.env.example +9 -2
  2. package/app/components/actions/case-export/download-handlers.ts +66 -11
  3. package/app/components/actions/case-import/confirmation-import.ts +50 -7
  4. package/app/components/actions/case-import/confirmation-package.ts +99 -22
  5. package/app/components/actions/case-import/orchestrator.ts +116 -13
  6. package/app/components/actions/case-import/validation.ts +171 -7
  7. package/app/components/actions/case-import/zip-processing.ts +224 -127
  8. package/app/components/actions/case-manage.ts +74 -15
  9. package/app/components/actions/confirm-export.ts +32 -3
  10. package/app/components/actions/generate-pdf.ts +43 -1
  11. package/app/components/actions/image-manage.ts +13 -45
  12. package/app/components/navbar/navbar.module.css +0 -10
  13. package/app/components/navbar/navbar.tsx +0 -22
  14. package/app/components/sidebar/case-import/case-import.module.css +7 -131
  15. package/app/components/sidebar/case-import/case-import.tsx +7 -14
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
  19. package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
  20. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
  21. package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
  22. package/app/config-example/config.json +5 -0
  23. package/app/routes/auth/login.tsx +1 -1
  24. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  25. package/app/routes/striae/striae.tsx +15 -4
  26. package/app/utils/data/operations/case-operations.ts +13 -1
  27. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  28. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  29. package/app/utils/data/operations/signing-operations.ts +93 -0
  30. package/app/utils/data/operations/types.ts +6 -0
  31. package/app/utils/forensics/export-encryption.ts +316 -0
  32. package/app/utils/forensics/export-verification.ts +1 -409
  33. package/app/utils/forensics/index.ts +1 -0
  34. package/app/utils/ui/case-messages.ts +5 -2
  35. package/package.json +2 -2
  36. package/scripts/deploy-config.sh +244 -7
  37. package/scripts/deploy-pages-secrets.sh +0 -6
  38. package/scripts/deploy-worker-secrets.sh +66 -5
  39. package/scripts/encrypt-r2-backfill.mjs +376 -0
  40. package/worker-configuration.d.ts +13 -7
  41. package/workers/audit-worker/package.json +1 -4
  42. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  43. package/workers/audit-worker/wrangler.jsonc.example +6 -1
  44. package/workers/data-worker/package.json +1 -4
  45. package/workers/data-worker/src/data-worker.example.ts +409 -1
  46. package/workers/data-worker/src/encryption-utils.ts +269 -0
  47. package/workers/data-worker/worker-configuration.d.ts +1 -1
  48. package/workers/data-worker/wrangler.jsonc.example +6 -2
  49. package/workers/image-worker/package.json +1 -4
  50. package/workers/image-worker/src/encryption-utils.ts +217 -0
  51. package/workers/image-worker/src/image-worker.example.ts +196 -127
  52. package/workers/image-worker/wrangler.jsonc.example +8 -1
  53. package/workers/keys-worker/package.json +1 -4
  54. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  55. package/workers/pdf-worker/package.json +1 -4
  56. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  57. package/workers/user-worker/package.json +1 -4
  58. package/workers/user-worker/wrangler.jsonc.example +1 -1
  59. package/wrangler.toml.example +1 -1
  60. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
  61. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
@@ -1,4 +1,3 @@
1
- import { type ConfirmationImportData } from '~/types';
2
1
  import {
3
2
  extractForensicManifestData,
4
3
  type ManifestSignatureVerificationResult,
@@ -11,7 +10,6 @@ import {
11
10
  type AuditExportSigningPayload,
12
11
  verifyAuditExportSignature
13
12
  } from './audit-export-signature';
14
- import { verifyConfirmationSignature } from './confirmation-signature';
15
13
 
16
14
  export interface ExportVerificationResult {
17
15
  isValid: boolean;
@@ -19,9 +17,6 @@ export interface ExportVerificationResult {
19
17
  exportType?: 'case-zip' | 'confirmation' | 'audit-json';
20
18
  }
21
19
 
22
- const CASE_EXPORT_FILE_REGEX = /_data\.(json|csv)$/i;
23
- const CONFIRMATION_EXPORT_FILE_REGEX = /^confirmation-data-.*\.json$/i;
24
-
25
20
  interface BundledAuditExportFile {
26
21
  metadata?: {
27
22
  exportTimestamp?: string;
@@ -45,12 +40,6 @@ interface BundledAuditExportFile {
45
40
  auditEntries?: unknown;
46
41
  }
47
42
 
48
- interface StandaloneAuditExportFile extends BundledAuditExportFile {
49
- metadata?: BundledAuditExportFile['metadata'] & {
50
- integrityNote?: string;
51
- };
52
- }
53
-
54
43
  export interface CasePackageIntegrityInput {
55
44
  cleanedContent: string;
56
45
  imageFiles: Record<string, Blob>;
@@ -100,146 +89,7 @@ function getSignatureFailureMessage(
100
89
  return `The ${targetLabel} signature did not verify with the selected public key.`;
101
90
  }
102
91
 
103
- function isConfirmationImportCandidate(candidate: unknown): candidate is Partial<ConfirmationImportData> {
104
- if (!candidate || typeof candidate !== 'object') {
105
- return false;
106
- }
107
-
108
- const confirmationCandidate = candidate as Partial<ConfirmationImportData>;
109
- return (
110
- !!confirmationCandidate.metadata &&
111
- typeof confirmationCandidate.metadata.hash === 'string' &&
112
- !!confirmationCandidate.confirmations &&
113
- typeof confirmationCandidate.confirmations === 'object'
114
- );
115
- }
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(
92
+ async function verifyBundledAuditExport(
243
93
  zip: {
244
94
  file: (path: string) => { async: (type: 'text') => Promise<string> } | null;
245
95
  },
@@ -412,264 +262,6 @@ export async function validateConfirmationHash(jsonContent: string, expectedHash
412
262
  }
413
263
  }
414
264
 
415
- async function verifyCaseZipExport(
416
- file: File,
417
- verificationPublicKeyPem: string
418
- ): Promise<ExportVerificationResult> {
419
- const JSZip = (await import('jszip')).default;
420
-
421
- try {
422
- const zip = await JSZip.loadAsync(file);
423
- const dataFiles = Object.keys(zip.files).filter((name) => CASE_EXPORT_FILE_REGEX.test(name));
424
-
425
- if (dataFiles.length !== 1) {
426
- return createVerificationResult(
427
- false,
428
- 'The ZIP file must contain exactly one case export data file.',
429
- 'case-zip'
430
- );
431
- }
432
-
433
- const dataContent = await zip.file(dataFiles[0])?.async('text');
434
- if (!dataContent) {
435
- return createVerificationResult(false, 'The ZIP data file could not be read.', 'case-zip');
436
- }
437
-
438
- const manifestContent = await zip.file('FORENSIC_MANIFEST.json')?.async('text');
439
- if (!manifestContent) {
440
- return createVerificationResult(
441
- false,
442
- 'The ZIP file does not contain FORENSIC_MANIFEST.json.',
443
- 'case-zip'
444
- );
445
- }
446
-
447
- const forensicManifest = JSON.parse(manifestContent) as SignedForensicManifest;
448
- const manifestData = extractForensicManifestData(forensicManifest);
449
-
450
- if (!manifestData) {
451
- return createVerificationResult(false, 'The forensic manifest is malformed.', 'case-zip');
452
- }
453
-
454
- const cleanedContent = removeForensicWarning(dataContent);
455
- const imageFiles: Record<string, Blob> = {};
456
-
457
- await Promise.all(
458
- Object.keys(zip.files).map(async (path) => {
459
- if (!path.startsWith('images/') || path.endsWith('/')) {
460
- return;
461
- }
462
-
463
- const zipEntry = zip.file(path);
464
- if (!zipEntry) {
465
- return;
466
- }
467
-
468
- imageFiles[path.replace('images/', '')] = await zipEntry.async('blob');
469
- })
470
- );
471
-
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
- }
492
-
493
- if (signatureResult.isValid && integrityResult.isValid) {
494
- return createVerificationResult(
495
- true,
496
- 'The export ZIP passed signature and integrity verification.',
497
- 'case-zip'
498
- );
499
- }
500
-
501
- if (!signatureResult.isValid && !integrityResult.isValid) {
502
- return createVerificationResult(
503
- false,
504
- 'The export ZIP failed signature and integrity verification.',
505
- 'case-zip'
506
- );
507
- }
508
-
509
- if (!signatureResult.isValid) {
510
- return createVerificationResult(
511
- false,
512
- getSignatureFailureMessage(signatureResult.error, 'export ZIP'),
513
- 'case-zip'
514
- );
515
- }
516
-
517
- return createVerificationResult(false, 'The export ZIP failed integrity verification.', 'case-zip');
518
- } catch {
519
- return createVerificationResult(
520
- false,
521
- 'The ZIP file could not be read as a supported Striae export.',
522
- 'case-zip'
523
- );
524
- }
525
- }
526
-
527
- async function verifyConfirmationContent(
528
- fileContent: string,
529
- verificationPublicKeyPem: string
530
- ): Promise<ExportVerificationResult> {
531
- try {
532
- const parsedContent = JSON.parse(fileContent) as unknown;
533
-
534
- if (!isConfirmationImportCandidate(parsedContent)) {
535
- return createVerificationResult(
536
- false,
537
- 'The JSON file is not a supported Striae confirmation export.',
538
- 'confirmation'
539
- );
540
- }
541
-
542
- const confirmationData = parsedContent as Partial<ConfirmationImportData>;
543
- const hashValid = await validateConfirmationHash(fileContent, confirmationData.metadata!.hash);
544
- const signatureResult = await verifyConfirmationSignature(confirmationData, verificationPublicKeyPem);
545
-
546
- if (hashValid && signatureResult.isValid) {
547
- return createVerificationResult(
548
- true,
549
- 'The confirmation file passed signature and integrity verification.',
550
- 'confirmation'
551
- );
552
- }
553
-
554
- if (!signatureResult.isValid && signatureResult.error === 'Confirmation content is malformed') {
555
- return createVerificationResult(
556
- false,
557
- 'The JSON file is not a supported Striae confirmation export.',
558
- 'confirmation'
559
- );
560
- }
561
-
562
- if (!hashValid && !signatureResult.isValid) {
563
- return createVerificationResult(
564
- false,
565
- 'The confirmation file failed signature and integrity verification.',
566
- 'confirmation'
567
- );
568
- }
569
-
570
- if (!signatureResult.isValid) {
571
- return createVerificationResult(
572
- false,
573
- getSignatureFailureMessage(signatureResult.error, 'confirmation file'),
574
- 'confirmation'
575
- );
576
- }
577
-
578
- return createVerificationResult(
579
- false,
580
- 'The confirmation file failed integrity verification.',
581
- 'confirmation'
582
- );
583
- } catch {
584
- return createVerificationResult(
585
- false,
586
- 'The confirmation content could not be read as a supported Striae confirmation export.',
587
- 'confirmation'
588
- );
589
- }
590
- }
591
-
592
- async function verifyConfirmationZipExport(
593
- file: File,
594
- verificationPublicKeyPem: string
595
- ): Promise<ExportVerificationResult> {
596
- const JSZip = (await import('jszip')).default;
597
-
598
- try {
599
- const zip = await JSZip.loadAsync(file);
600
- const confirmationFiles = Object.keys(zip.files).filter((name) => CONFIRMATION_EXPORT_FILE_REGEX.test(name));
601
-
602
- if (confirmationFiles.length !== 1) {
603
- return createVerificationResult(
604
- false,
605
- 'The ZIP file is not a supported Striae confirmation export package.'
606
- );
607
- }
608
-
609
- const confirmationContent = await zip.file(confirmationFiles[0])?.async('text');
610
- if (!confirmationContent) {
611
- return createVerificationResult(
612
- false,
613
- 'The confirmation JSON file inside the ZIP could not be read.',
614
- 'confirmation'
615
- );
616
- }
617
-
618
- return verifyConfirmationContent(confirmationContent, verificationPublicKeyPem);
619
- } catch {
620
- return createVerificationResult(
621
- false,
622
- 'The ZIP file could not be read as a supported Striae export.'
623
- );
624
- }
625
- }
626
-
627
- export async function verifyExportFile(
628
- file: File,
629
- verificationPublicKeyPem: string
630
- ): Promise<ExportVerificationResult> {
631
- const lowerName = file.name.toLowerCase();
632
-
633
- if (lowerName.endsWith('.zip')) {
634
- const confirmationZipResult = await verifyConfirmationZipExport(file, verificationPublicKeyPem);
635
- if (confirmationZipResult.exportType === 'confirmation' || confirmationZipResult.isValid) {
636
- return confirmationZipResult;
637
- }
638
-
639
- return verifyCaseZipExport(file, verificationPublicKeyPem);
640
- }
641
-
642
- if (lowerName.endsWith('.json')) {
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
- }
665
- }
666
-
667
- return createVerificationResult(
668
- false,
669
- 'Select a confirmation JSON/ZIP file, standalone audit JSON export, or a case export ZIP file.'
670
- );
671
- }
672
-
673
265
  export async function verifyCasePackageIntegrity(
674
266
  input: CasePackageIntegrityInput
675
267
  ): Promise<CasePackageIntegrityResult> {
@@ -1,5 +1,6 @@
1
1
  export * from './SHA256';
2
2
  export * from './audit-export-signature';
3
3
  export * from './confirmation-signature';
4
+ export * from './export-encryption';
4
5
  export * from './export-verification';
5
6
  export * from './signature-utils';
@@ -5,15 +5,18 @@
5
5
 
6
6
  // Import validation messages
7
7
  export const IMPORT_FILE_TYPE_NOT_ALLOWED =
8
- 'Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.';
8
+ 'Only Striae case ZIP files and encrypted confirmation ZIP packages exported from Striae are allowed.';
9
9
 
10
10
  export const IMPORT_FILE_TYPE_NOT_SUPPORTED =
11
- 'The selected file is not a supported Striae case or confirmation import package.';
11
+ 'The selected file is not a supported Striae case ZIP or encrypted confirmation package.';
12
12
 
13
13
  // Import blocking messages
14
14
  export const ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE =
15
15
  'This archived case cannot be imported because the case already exists in your regular case list. Delete the regular case before importing this archive.';
16
16
 
17
+ export const ARCHIVED_SELF_IMPORT_NOTE =
18
+ 'Archived export detected. Original exporter imports are only allowed after the case has been deleted from your regular case list.';
19
+
17
20
  // Read-only case operations
18
21
  export const CREATE_READ_ONLY_CASE_EXISTS_ERROR = (caseNumber: string): string =>
19
22
  `Case "${caseNumber}" already exists as a read-only review case.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.3.4",
3
+ "version": "5.1.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",
@@ -106,7 +106,7 @@
106
106
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
107
107
  "deploy-workers:keys": "cd workers/keys-worker && npm run deploy",
108
108
  "deploy-workers:pdf": "cd workers/pdf-worker && npm run deploy",
109
- "deploy-workers:user": "cd workers/user-worker && npm run deploy"
109
+ "deploy-workers:user": "cd workers/user-worker && npm run deploy"
110
110
  },
111
111
  "dependencies": {
112
112
  "@react-router/cloudflare": "^7.13.2",