@striae-org/striae 4.3.3 → 5.0.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 (53) hide show
  1. package/.env.example +4 -0
  2. package/app/components/actions/case-export/download-handlers.ts +60 -4
  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 +110 -10
  9. package/app/components/actions/confirm-export.ts +32 -3
  10. package/app/components/audit/user-audit.module.css +49 -0
  11. package/app/components/audit/viewer/audit-entries-list.tsx +130 -48
  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/services/audit/audit-console-logger.ts +1 -1
  25. package/app/services/audit/audit-export-csv.ts +1 -1
  26. package/app/services/audit/audit-export-signing.ts +2 -2
  27. package/app/services/audit/audit-export.service.ts +1 -1
  28. package/app/services/audit/audit-worker-client.ts +1 -1
  29. package/app/services/audit/audit.service.ts +5 -75
  30. package/app/services/audit/builders/audit-event-builders-case-file.ts +3 -0
  31. package/app/services/audit/index.ts +2 -2
  32. package/app/types/audit.ts +8 -7
  33. package/app/utils/data/operations/signing-operations.ts +93 -0
  34. package/app/utils/data/operations/types.ts +6 -0
  35. package/app/utils/forensics/export-encryption.ts +316 -0
  36. package/app/utils/forensics/export-verification.ts +1 -409
  37. package/app/utils/forensics/index.ts +1 -0
  38. package/app/utils/ui/case-messages.ts +5 -2
  39. package/package.json +1 -1
  40. package/scripts/deploy-config.sh +97 -3
  41. package/scripts/deploy-worker-secrets.sh +1 -1
  42. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  43. package/workers/data-worker/src/data-worker.example.ts +130 -0
  44. package/workers/data-worker/src/encryption-utils.ts +125 -0
  45. package/workers/data-worker/worker-configuration.d.ts +1 -1
  46. package/workers/data-worker/wrangler.jsonc.example +2 -2
  47. package/workers/image-worker/wrangler.jsonc.example +1 -1
  48. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  49. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  50. package/workers/user-worker/wrangler.jsonc.example +1 -1
  51. package/wrangler.toml.example +1 -1
  52. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
  53. 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.3",
3
+ "version": "5.0.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",
@@ -334,7 +334,7 @@ write_env_var() {
334
334
  var_value=$(strip_carriage_returns "$var_value")
335
335
  env_file_value="$var_value"
336
336
 
337
- if [ "$var_name" = "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY" ] || [ "$var_name" = "MANIFEST_SIGNING_PRIVATE_KEY" ] || [ "$var_name" = "MANIFEST_SIGNING_PUBLIC_KEY" ]; then
337
+ if [ "$var_name" = "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY" ] || [ "$var_name" = "MANIFEST_SIGNING_PRIVATE_KEY" ] || [ "$var_name" = "MANIFEST_SIGNING_PUBLIC_KEY" ] || [ "$var_name" = "EXPORT_ENCRYPTION_PRIVATE_KEY" ] || [ "$var_name" = "EXPORT_ENCRYPTION_PUBLIC_KEY" ]; then
338
338
  # Store as a quoted string so sourced .env preserves escaped newline markers (\n)
339
339
  env_file_value=${env_file_value//\"/\\\"}
340
340
  env_file_value="\"$env_file_value\""
@@ -508,6 +508,88 @@ configure_manifest_signing_credentials() {
508
508
  echo ""
509
509
  }
510
510
 
511
+ generate_export_encryption_key_pair() {
512
+ local private_key_file
513
+ local public_key_file
514
+ private_key_file=$(mktemp)
515
+ public_key_file=$(mktemp)
516
+
517
+ if ! node -e "const { generateKeyPairSync } = require('crypto'); const fs = require('fs'); const pair = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }); fs.writeFileSync(process.argv[1], pair.privateKey, 'utf8'); fs.writeFileSync(process.argv[2], pair.publicKey, 'utf8');" "$private_key_file" "$public_key_file"; then
518
+ rm -f "$private_key_file" "$public_key_file"
519
+ return 1
520
+ fi
521
+
522
+ local private_key_pem
523
+ local public_key_pem
524
+ private_key_pem=$(cat "$private_key_file")
525
+ public_key_pem=$(cat "$public_key_file")
526
+ rm -f "$private_key_file" "$public_key_file"
527
+
528
+ private_key_pem="${private_key_pem//$'\r'/}"
529
+ public_key_pem="${public_key_pem//$'\r'/}"
530
+
531
+ EXPORT_ENCRYPTION_PRIVATE_KEY="${private_key_pem//$'\n'/\\n}"
532
+ EXPORT_ENCRYPTION_PUBLIC_KEY="${public_key_pem//$'\n'/\\n}"
533
+
534
+ export EXPORT_ENCRYPTION_PRIVATE_KEY
535
+ export EXPORT_ENCRYPTION_PUBLIC_KEY
536
+
537
+ write_env_var "EXPORT_ENCRYPTION_PRIVATE_KEY" "$EXPORT_ENCRYPTION_PRIVATE_KEY"
538
+ write_env_var "EXPORT_ENCRYPTION_PUBLIC_KEY" "$EXPORT_ENCRYPTION_PUBLIC_KEY"
539
+
540
+ return 0
541
+ }
542
+
543
+ configure_export_encryption_credentials() {
544
+ echo -e "${BLUE}🔐 EXPORT ENCRYPTION CONFIGURATION${NC}"
545
+ echo "================================="
546
+
547
+ local should_generate="false"
548
+ local regenerate_choice=""
549
+
550
+ if [ "$update_env" = "true" ]; then
551
+ should_generate="true"
552
+ elif [ -z "$EXPORT_ENCRYPTION_PRIVATE_KEY" ] || is_placeholder "$EXPORT_ENCRYPTION_PRIVATE_KEY" || [ -z "$EXPORT_ENCRYPTION_PUBLIC_KEY" ] || is_placeholder "$EXPORT_ENCRYPTION_PUBLIC_KEY"; then
553
+ should_generate="true"
554
+ else
555
+ echo -e "${GREEN}Current export encryption key pair: [HIDDEN]${NC}"
556
+ read -p "Generate new export encryption key pair? (press Enter to keep current, or type 'y' to regenerate): " regenerate_choice
557
+ regenerate_choice=$(strip_carriage_returns "$regenerate_choice")
558
+ if [ "$regenerate_choice" = "y" ] || [ "$regenerate_choice" = "Y" ]; then
559
+ should_generate="true"
560
+ fi
561
+ fi
562
+
563
+ if [ "$should_generate" = "true" ]; then
564
+ echo -e "${YELLOW}Generating export encryption RSA key pair...${NC}"
565
+ if generate_export_encryption_key_pair; then
566
+ echo -e "${GREEN}✅ Export encryption key pair generated${NC}"
567
+ else
568
+ echo -e "${RED}❌ Error: Failed to generate export encryption key pair${NC}"
569
+ exit 1
570
+ fi
571
+ else
572
+ echo -e "${GREEN}✅ Keeping current export encryption key pair${NC}"
573
+ fi
574
+
575
+ if [ -z "$EXPORT_ENCRYPTION_KEY_ID" ] || is_placeholder "$EXPORT_ENCRYPTION_KEY_ID" || [ "$should_generate" = "true" ]; then
576
+ local generated_key_id
577
+ generated_key_id=$(generate_worker_subdomain_label)
578
+ if [ -z "$generated_key_id" ] || [ ${#generated_key_id} -ne 10 ]; then
579
+ echo -e "${RED}❌ Error: Failed to generate EXPORT_ENCRYPTION_KEY_ID${NC}"
580
+ exit 1
581
+ fi
582
+ EXPORT_ENCRYPTION_KEY_ID="$generated_key_id"
583
+ export EXPORT_ENCRYPTION_KEY_ID
584
+ write_env_var "EXPORT_ENCRYPTION_KEY_ID" "$EXPORT_ENCRYPTION_KEY_ID"
585
+ echo -e "${GREEN}✅ EXPORT_ENCRYPTION_KEY_ID generated: $EXPORT_ENCRYPTION_KEY_ID${NC}"
586
+ else
587
+ echo -e "${GREEN}✅ EXPORT_ENCRYPTION_KEY_ID: $EXPORT_ENCRYPTION_KEY_ID${NC}"
588
+ fi
589
+
590
+ echo ""
591
+ }
592
+
511
593
  # Validate required variables
512
594
  required_vars=(
513
595
  # Core Cloudflare Configuration
@@ -564,6 +646,9 @@ required_vars=(
564
646
  "MANIFEST_SIGNING_PRIVATE_KEY"
565
647
  "MANIFEST_SIGNING_KEY_ID"
566
648
  "MANIFEST_SIGNING_PUBLIC_KEY"
649
+ "EXPORT_ENCRYPTION_PRIVATE_KEY"
650
+ "EXPORT_ENCRYPTION_KEY_ID"
651
+ "EXPORT_ENCRYPTION_PUBLIC_KEY"
567
652
  )
568
653
 
569
654
  validate_required_vars() {
@@ -591,7 +676,7 @@ assert_contains_literal() {
591
676
  local literal=$2
592
677
  local description=$3
593
678
 
594
- if ! grep -Fq "$literal" "$file_path"; then
679
+ if ! grep -Fq -- "$literal" "$file_path"; then
595
680
  echo -e "${RED}❌ Error: ${description}${NC}"
596
681
  echo -e "${YELLOW} Expected to find '$literal' in $file_path${NC}"
597
682
  exit 1
@@ -743,6 +828,8 @@ validate_generated_configs() {
743
828
 
744
829
  assert_contains_literal "app/config/config.json" "https://$PAGES_CUSTOM_DOMAIN" "PAGES_CUSTOM_DOMAIN missing in app/config/config.json"
745
830
  assert_contains_literal "app/config/config.json" "$ACCOUNT_HASH" "ACCOUNT_HASH missing in app/config/config.json"
831
+ assert_contains_literal "app/config/config.json" "$EXPORT_ENCRYPTION_KEY_ID" "EXPORT_ENCRYPTION_KEY_ID missing in app/config/config.json"
832
+ assert_contains_literal "app/config/config.json" "\"export_encryption_public_key\":" "export_encryption_public_key missing in app/config/config.json"
746
833
  assert_contains_literal "app/routes/auth/login.tsx" "const APP_CANONICAL_ORIGIN = 'https://$PAGES_CUSTOM_DOMAIN';" "PAGES_CUSTOM_DOMAIN missing in app/routes/auth/login.tsx canonical origin"
747
834
 
748
835
  assert_contains_literal "app/config/firebase.ts" "$API_KEY" "API_KEY missing in app/config/firebase.ts"
@@ -761,7 +848,7 @@ validate_generated_configs() {
761
848
  assert_contains_literal "workers/user-worker/src/user-worker.ts" "https://$PAGES_CUSTOM_DOMAIN" "PAGES_CUSTOM_DOMAIN missing in user-worker source"
762
849
 
763
850
  local placeholder_pattern
764
- placeholder_pattern="(\"(ACCOUNT_ID|PAGES_PROJECT_NAME|PAGES_CUSTOM_DOMAIN|KEYS_WORKER_NAME|USER_WORKER_NAME|DATA_WORKER_NAME|AUDIT_WORKER_NAME|IMAGES_WORKER_NAME|PDF_WORKER_NAME|KEYS_WORKER_DOMAIN|USER_WORKER_DOMAIN|DATA_WORKER_DOMAIN|AUDIT_WORKER_DOMAIN|IMAGES_WORKER_DOMAIN|PDF_WORKER_DOMAIN|DATA_BUCKET_NAME|AUDIT_BUCKET_NAME|KV_STORE_ID|ACCOUNT_HASH|MANIFEST_SIGNING_KEY_ID|MANIFEST_SIGNING_PUBLIC_KEY|YOUR_FIREBASE_API_KEY|YOUR_FIREBASE_AUTH_DOMAIN|YOUR_FIREBASE_PROJECT_ID|YOUR_FIREBASE_STORAGE_BUCKET|YOUR_FIREBASE_MESSAGING_SENDER_ID|YOUR_FIREBASE_APP_ID|YOUR_FIREBASE_MEASUREMENT_ID)\"|'(PAGES_CUSTOM_DOMAIN|DATA_WORKER_DOMAIN|IMAGES_WORKER_DOMAIN)')"
851
+ placeholder_pattern="(\"(ACCOUNT_ID|PAGES_PROJECT_NAME|PAGES_CUSTOM_DOMAIN|KEYS_WORKER_NAME|USER_WORKER_NAME|DATA_WORKER_NAME|AUDIT_WORKER_NAME|IMAGES_WORKER_NAME|PDF_WORKER_NAME|KEYS_WORKER_DOMAIN|USER_WORKER_DOMAIN|DATA_WORKER_DOMAIN|AUDIT_WORKER_DOMAIN|IMAGES_WORKER_DOMAIN|PDF_WORKER_DOMAIN|DATA_BUCKET_NAME|AUDIT_BUCKET_NAME|KV_STORE_ID|ACCOUNT_HASH|MANIFEST_SIGNING_KEY_ID|MANIFEST_SIGNING_PUBLIC_KEY|EXPORT_ENCRYPTION_KEY_ID|EXPORT_ENCRYPTION_PUBLIC_KEY|YOUR_FIREBASE_API_KEY|YOUR_FIREBASE_AUTH_DOMAIN|YOUR_FIREBASE_PROJECT_ID|YOUR_FIREBASE_STORAGE_BUCKET|YOUR_FIREBASE_MESSAGING_SENDER_ID|YOUR_FIREBASE_APP_ID|YOUR_FIREBASE_MEASUREMENT_ID)\"|'(PAGES_CUSTOM_DOMAIN|DATA_WORKER_DOMAIN|IMAGES_WORKER_DOMAIN)')"
765
852
 
766
853
  local files_to_scan=(
767
854
  "wrangler.toml"
@@ -1310,6 +1397,7 @@ prompt_for_secrets() {
1310
1397
  prompt_for_var "HMAC_KEY" "Cloudflare Images HMAC signing key"
1311
1398
 
1312
1399
  configure_manifest_signing_credentials
1400
+ configure_export_encryption_credentials
1313
1401
 
1314
1402
  # Reload the updated .env file
1315
1403
  source .env
@@ -1447,15 +1535,21 @@ update_wrangler_configs() {
1447
1535
  echo -e "${YELLOW} Updating app/config/config.json...${NC}"
1448
1536
  local escaped_manifest_signing_key_id
1449
1537
  local escaped_manifest_signing_public_key
1538
+ local escaped_export_encryption_key_id
1539
+ local escaped_export_encryption_public_key
1450
1540
  local escaped_account_hash
1451
1541
  escaped_manifest_signing_key_id=$(escape_for_sed_replacement "$MANIFEST_SIGNING_KEY_ID")
1452
1542
  escaped_manifest_signing_public_key=$(escape_for_sed_replacement "$MANIFEST_SIGNING_PUBLIC_KEY")
1543
+ escaped_export_encryption_key_id=$(escape_for_sed_replacement "$EXPORT_ENCRYPTION_KEY_ID")
1544
+ escaped_export_encryption_public_key=$(escape_for_sed_replacement "$EXPORT_ENCRYPTION_PUBLIC_KEY")
1453
1545
  escaped_account_hash=$(escape_for_sed_replacement "$ACCOUNT_HASH")
1454
1546
 
1455
1547
  sed -i "s|\"url\": \"[^\"]*\"|\"url\": \"https://$escaped_pages_custom_domain\"|g" app/config/config.json
1456
1548
  sed -i "s|\"account_hash\": \"[^\"]*\"|\"account_hash\": \"$escaped_account_hash\"|g" app/config/config.json
1457
1549
  sed -i "s|\"MANIFEST_SIGNING_KEY_ID\"|\"$escaped_manifest_signing_key_id\"|g" app/config/config.json
1458
1550
  sed -i "s|\"MANIFEST_SIGNING_PUBLIC_KEY\"|\"$escaped_manifest_signing_public_key\"|g" app/config/config.json
1551
+ sed -i "s|\"EXPORT_ENCRYPTION_KEY_ID\"|\"$escaped_export_encryption_key_id\"|g" app/config/config.json
1552
+ sed -i "s|\"EXPORT_ENCRYPTION_PUBLIC_KEY\"|\"$escaped_export_encryption_public_key\"|g" app/config/config.json
1459
1553
  echo -e "${GREEN} ✅ app config.json updated${NC}"
1460
1554
  fi
1461
1555
 
@@ -187,7 +187,7 @@ fi
187
187
 
188
188
  # Data Worker
189
189
  if ! set_worker_secrets "Data Worker" "workers/data-worker" \
190
- "R2_KEY_SECRET" "MANIFEST_SIGNING_PRIVATE_KEY" "MANIFEST_SIGNING_KEY_ID"; then
190
+ "R2_KEY_SECRET" "MANIFEST_SIGNING_PRIVATE_KEY" "MANIFEST_SIGNING_KEY_ID" "EXPORT_ENCRYPTION_PRIVATE_KEY" "EXPORT_ENCRYPTION_KEY_ID"; then
191
191
  echo -e "${YELLOW}⚠️ Skipping Data Worker (not configured)${NC}"
192
192
  fi
193
193
 
@@ -2,7 +2,7 @@
2
2
  "name": "AUDIT_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],