@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
@@ -13,6 +13,7 @@ import {
13
13
  type ForensicManifestSignature,
14
14
  FORENSIC_MANIFEST_VERSION
15
15
  } from '../../forensics/SHA256';
16
+ import type { EncryptionManifest } from '../../forensics/export-encryption';
16
17
  import { canAccessCase, validateUserSession } from '../permissions';
17
18
  import type {
18
19
  AuditExportSigningResponse,
@@ -223,3 +224,95 @@ export const signAuditExportData = async (
223
224
  throw error;
224
225
  }
225
226
  };
227
+
228
+ /**
229
+ * Request batch decryption of export data file and images from the data worker
230
+ */
231
+ export const decryptExportBatch = async (
232
+ user: User,
233
+ encryptionManifest: EncryptionManifest,
234
+ encryptedDataBase64: string,
235
+ encryptedImageMap: Record<string, string>
236
+ ): Promise<{ plaintext: string; decryptedImages: Record<string, Blob> }> => {
237
+ try {
238
+ const sessionValidation = await validateUserSession(user);
239
+ if (!sessionValidation.valid) {
240
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
241
+ }
242
+
243
+ // Convert encryptedImageMap to array format expected by worker, including per-image IV from manifest
244
+ const encryptedImages = Object.entries(encryptedImageMap).map(([filename, encryptedData]) => {
245
+ const manifestEntry = encryptionManifest.encryptedImages.find(e => e.filename === filename);
246
+ return {
247
+ filename,
248
+ encryptedData,
249
+ iv: manifestEntry?.iv
250
+ };
251
+ });
252
+
253
+ const response = await fetchDataApi(user, '/api/forensic/decrypt-export', {
254
+ method: 'POST',
255
+ headers: {
256
+ 'Content-Type': 'application/json'
257
+ },
258
+ body: JSON.stringify({
259
+ userId: user.uid,
260
+ wrappedKey: encryptionManifest.wrappedKey,
261
+ dataIv: encryptionManifest.dataIv,
262
+ encryptedData: encryptedDataBase64,
263
+ encryptedImages,
264
+ keyId: encryptionManifest.keyId
265
+ })
266
+ });
267
+
268
+ const responseData = await response.json().catch(() => null) as {
269
+ success?: boolean;
270
+ error?: string;
271
+ plaintext?: string;
272
+ decryptedImages?: Array<{ filename: string; data: string }>;
273
+ } | null;
274
+
275
+ if (!response.ok) {
276
+ const errorMessage = responseData?.error || `Failed to decrypt export: ${response.status} ${response.statusText}`;
277
+
278
+ // Special handling for encrypted exports without configured key
279
+ if (response.status === 400 && errorMessage.includes('not configured')) {
280
+ throw new Error(
281
+ 'This export is encrypted. To import it, your Striae instance must have EXPORT_ENCRYPTION_PRIVATE_KEY configured.'
282
+ );
283
+ }
284
+
285
+ throw new Error(errorMessage);
286
+ }
287
+
288
+ if (!responseData?.success || !responseData.plaintext) {
289
+ throw new Error('Invalid decrypt response from data worker');
290
+ }
291
+
292
+ // Convert decrypted image base64 data back to Blobs
293
+ const decryptedImages: Record<string, Blob> = {};
294
+ if (Array.isArray(responseData.decryptedImages)) {
295
+ for (const imageEntry of responseData.decryptedImages) {
296
+ try {
297
+ const binaryString = atob(imageEntry.data);
298
+ const bytes = new Uint8Array(binaryString.length);
299
+ for (let i = 0; i < binaryString.length; i++) {
300
+ bytes[i] = binaryString.charCodeAt(i);
301
+ }
302
+ decryptedImages[imageEntry.filename] = new Blob([bytes]);
303
+ } catch (error) {
304
+ console.error(`Failed to convert decrypted image ${imageEntry.filename}:`, error);
305
+ throw new Error(`Failed to convert decrypted image: ${imageEntry.filename}`);
306
+ }
307
+ }
308
+ }
309
+
310
+ return {
311
+ plaintext: responseData.plaintext,
312
+ decryptedImages
313
+ };
314
+ } catch (error) {
315
+ console.error('Error decrypting export batch:', error);
316
+ throw error;
317
+ }
318
+ };
@@ -39,4 +39,10 @@ export interface AuditExportSigningResponse {
39
39
  signature: ForensicManifestSignature;
40
40
  }
41
41
 
42
+ export interface DecryptExportBatchResponse {
43
+ success: boolean;
44
+ plaintext: string;
45
+ decryptedImages: Array<{ filename: string; data: string }>;
46
+ }
47
+
42
48
  export type DataOperation<T> = (user: User, ...args: unknown[]) => Promise<T>;
@@ -0,0 +1,316 @@
1
+ import paths from '~/config/config.json';
2
+
3
+ export const EXPORT_ENCRYPTION_VERSION = '1.0';
4
+ export const EXPORT_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
5
+
6
+ export interface EncryptedImageEntry {
7
+ filename: string;
8
+ encryptedHash: string; // SHA256 of encrypted bytes (lowercase hex)
9
+ iv: string; // base64url — per-image nonce
10
+ }
11
+
12
+ export interface EncryptionManifest {
13
+ encryptionVersion: string;
14
+ algorithm: string;
15
+ keyId: string;
16
+ wrappedKey: string; // base64url
17
+ dataIv: string; // base64url — nonce for the data file
18
+ encryptedImages: EncryptedImageEntry[];
19
+ }
20
+
21
+ export interface EncryptedExportResult {
22
+ ciphertext: Uint8Array;
23
+ encryptedImages: Uint8Array[];
24
+ encryptionManifest: EncryptionManifest;
25
+ }
26
+
27
+ export interface PublicEncryptionKeyDetails {
28
+ keyId: string | null;
29
+ publicKeyPem: string | null;
30
+ }
31
+
32
+ type ManifestEncryptionConfig = {
33
+ export_encryption_key_id?: string;
34
+ export_encryption_public_key?: string;
35
+ export_encryption_public_keys?: Record<string, string>;
36
+ };
37
+
38
+ function base64UrlEncode(value: Uint8Array): string {
39
+ let binary = '';
40
+ for (const byte of value) {
41
+ binary += String.fromCharCode(byte);
42
+ }
43
+
44
+ return btoa(binary)
45
+ .replace(/\+/g, '-')
46
+ .replace(/\//g, '_')
47
+ .replace(/=+$/g, '');
48
+ }
49
+
50
+ export function base64UrlDecode(value: string): Uint8Array {
51
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
52
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
53
+ const decoded = atob(normalized + padding);
54
+ const bytes = new Uint8Array(decoded.length);
55
+
56
+ for (let i = 0; i < decoded.length; i += 1) {
57
+ bytes[i] = decoded.charCodeAt(i);
58
+ }
59
+
60
+ return bytes;
61
+ }
62
+
63
+ function normalizePemPublicKey(pem: string): string {
64
+ return pem.replace(/\\n/g, '\n').trim();
65
+ }
66
+
67
+ function publicKeyPemToArrayBuffer(publicKeyPem: string): ArrayBuffer {
68
+ const normalized = normalizePemPublicKey(publicKeyPem);
69
+ const pemBody = normalized
70
+ .replace('-----BEGIN PUBLIC KEY-----', '')
71
+ .replace('-----END PUBLIC KEY-----', '')
72
+ .replace(/\s+/g, '');
73
+
74
+ if (!pemBody) {
75
+ throw new Error('Encryption public key is invalid');
76
+ }
77
+
78
+ const binary = atob(pemBody);
79
+ const bytes = new Uint8Array(binary.length);
80
+
81
+ for (let index = 0; index < binary.length; index += 1) {
82
+ bytes[index] = binary.charCodeAt(index);
83
+ }
84
+
85
+ return bytes.buffer;
86
+ }
87
+
88
+ async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
89
+ const key = await crypto.subtle.importKey(
90
+ 'spki',
91
+ publicKeyPemToArrayBuffer(publicKeyPem),
92
+ {
93
+ name: 'RSA-OAEP',
94
+ hash: 'SHA-256'
95
+ },
96
+ false,
97
+ ['encrypt']
98
+ );
99
+
100
+ return key;
101
+ }
102
+
103
+ export function getCurrentEncryptionPublicKeyDetails(): PublicEncryptionKeyDetails {
104
+ const config = paths as unknown as ManifestEncryptionConfig;
105
+ const configuredKeyId =
106
+ typeof config.export_encryption_key_id === 'string' &&
107
+ config.export_encryption_key_id.trim().length > 0
108
+ ? config.export_encryption_key_id
109
+ : null;
110
+
111
+ if (configuredKeyId) {
112
+ const configuredKey = getEncryptionPublicKey(configuredKeyId);
113
+ if (configuredKey) {
114
+ return {
115
+ keyId: configuredKeyId,
116
+ publicKeyPem: configuredKey
117
+ };
118
+ }
119
+ }
120
+
121
+ const keyMap = config.export_encryption_public_keys;
122
+ if (keyMap && typeof keyMap === 'object') {
123
+ const firstConfiguredEntry = Object.entries(keyMap).find(
124
+ ([, value]) => typeof value === 'string' && value.trim().length > 0
125
+ );
126
+
127
+ if (firstConfiguredEntry) {
128
+ return {
129
+ keyId: firstConfiguredEntry[0],
130
+ publicKeyPem: normalizePemPublicKey(firstConfiguredEntry[1])
131
+ };
132
+ }
133
+ }
134
+
135
+ return {
136
+ keyId: null,
137
+ publicKeyPem:
138
+ typeof config.export_encryption_public_key === 'string' &&
139
+ config.export_encryption_public_key.trim().length > 0
140
+ ? normalizePemPublicKey(config.export_encryption_public_key)
141
+ : null
142
+ };
143
+ }
144
+
145
+ function getEncryptionPublicKey(keyId: string): string | null {
146
+ const config = paths as unknown as ManifestEncryptionConfig;
147
+ const keyMap = config.export_encryption_public_keys;
148
+
149
+ if (keyMap && typeof keyMap === 'object') {
150
+ const mappedKey = keyMap[keyId];
151
+ if (typeof mappedKey === 'string' && mappedKey.trim().length > 0) {
152
+ return normalizePemPublicKey(mappedKey);
153
+ }
154
+ }
155
+
156
+ if (
157
+ typeof config.export_encryption_key_id === 'string' &&
158
+ config.export_encryption_key_id === keyId &&
159
+ typeof config.export_encryption_public_key === 'string' &&
160
+ config.export_encryption_public_key.trim().length > 0
161
+ ) {
162
+ return normalizePemPublicKey(config.export_encryption_public_key);
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Generate a shared AES-256-GCM key for all exports in one batch
170
+ */
171
+ export async function generateSharedAesKey(): Promise<CryptoKey> {
172
+ return crypto.subtle.generateKey(
173
+ { name: 'AES-GCM', length: 256 },
174
+ true, // extractable for wrapping
175
+ ['encrypt', 'decrypt']
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Encrypt plaintext data file with shared AES key
181
+ */
182
+ export async function encryptDataWithSharedKey(
183
+ plaintextString: string,
184
+ sharedAesKey: CryptoKey,
185
+ iv: Uint8Array
186
+ ): Promise<Uint8Array> {
187
+ const plaintext = new TextEncoder().encode(plaintextString);
188
+
189
+ const ciphertext = await crypto.subtle.encrypt(
190
+ { name: 'AES-GCM', iv: iv as BufferSource },
191
+ sharedAesKey,
192
+ plaintext
193
+ );
194
+
195
+ return new Uint8Array(ciphertext);
196
+ }
197
+
198
+ /**
199
+ * Encrypt a single image blob with shared AES key, return ciphertext and SHA256 hash
200
+ */
201
+ export async function encryptImageWithSharedKey(
202
+ imageBlob: Blob,
203
+ sharedAesKey: CryptoKey,
204
+ iv: Uint8Array
205
+ ): Promise<{ ciphertext: Uint8Array; hash: string }> {
206
+ const imageBuffer = await imageBlob.arrayBuffer();
207
+ const imageBytes = new Uint8Array(imageBuffer);
208
+
209
+ const ciphertext = await crypto.subtle.encrypt(
210
+ { name: 'AES-GCM', iv: iv as BufferSource },
211
+ sharedAesKey,
212
+ imageBytes
213
+ );
214
+
215
+ const ciphertextBytes = new Uint8Array(ciphertext);
216
+
217
+ // Calculate SHA256 of encrypted bytes
218
+ const hashBuffer = await crypto.subtle.digest('SHA-256', ciphertextBytes);
219
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
220
+ const hash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
221
+
222
+ return {
223
+ ciphertext: ciphertextBytes,
224
+ hash: hash.toLowerCase()
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Wrap AES key with RSA-OAEP public key
230
+ */
231
+ export async function wrapAesKeyWithPublicKey(
232
+ aesKey: CryptoKey,
233
+ publicKeyPem: string
234
+ ): Promise<string> {
235
+ const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
236
+
237
+ // Export the AES key to raw format
238
+ const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
239
+
240
+ // Wrap the raw AES key with RSA-OAEP
241
+ const wrappedKey = await crypto.subtle.encrypt(
242
+ { name: 'RSA-OAEP' },
243
+ rsaPublicKey,
244
+ rawAesKey
245
+ );
246
+
247
+ return base64UrlEncode(new Uint8Array(wrappedKey));
248
+ }
249
+
250
+ /**
251
+ * Encrypt export data file and all images with a shared AES-256 key
252
+ * Returns ciphertext, encrypted image array, and encryption manifest
253
+ */
254
+ export async function encryptExportDataWithAllImages(
255
+ plaintextString: string,
256
+ imageBlobs: Array<{ filename: string; blob: Blob }>,
257
+ publicKeyPem: string,
258
+ keyId: string
259
+ ): Promise<EncryptedExportResult> {
260
+ // Generate shared AES-256 key
261
+ const sharedAesKey = await generateSharedAesKey();
262
+
263
+ // Generate a unique 96-bit IV for the data file
264
+ const dataIv = crypto.getRandomValues(new Uint8Array(12));
265
+ const dataIvBase64 = base64UrlEncode(dataIv);
266
+
267
+ // Encrypt data file with its own IV
268
+ const dataCiphertext = await encryptDataWithSharedKey(
269
+ plaintextString,
270
+ sharedAesKey,
271
+ dataIv
272
+ );
273
+
274
+ // Encrypt all images — each with its own unique IV
275
+ const encryptedImages: Uint8Array[] = [];
276
+ const encryptedImageEntries: EncryptedImageEntry[] = [];
277
+
278
+ for (const { filename, blob } of imageBlobs) {
279
+ const imageIv = crypto.getRandomValues(new Uint8Array(12));
280
+ const imageIvBase64 = base64UrlEncode(imageIv);
281
+
282
+ const { ciphertext, hash } = await encryptImageWithSharedKey(
283
+ blob,
284
+ sharedAesKey,
285
+ imageIv
286
+ );
287
+
288
+ encryptedImages.push(ciphertext);
289
+ encryptedImageEntries.push({
290
+ filename,
291
+ encryptedHash: hash,
292
+ iv: imageIvBase64
293
+ });
294
+ }
295
+
296
+ // Wrap shared AES key with RSA-OAEP
297
+ const wrappedKeyBase64 = await wrapAesKeyWithPublicKey(
298
+ sharedAesKey,
299
+ publicKeyPem
300
+ );
301
+
302
+ const encryptionManifest: EncryptionManifest = {
303
+ encryptionVersion: EXPORT_ENCRYPTION_VERSION,
304
+ algorithm: EXPORT_ENCRYPTION_ALGORITHM,
305
+ keyId,
306
+ wrappedKey: wrappedKeyBase64,
307
+ dataIv: dataIvBase64,
308
+ encryptedImages: encryptedImageEntries
309
+ };
310
+
311
+ return {
312
+ ciphertext: dataCiphertext,
313
+ encryptedImages,
314
+ encryptionManifest
315
+ };
316
+ }