@striae-org/striae 5.0.0 → 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 (30) hide show
  1. package/.env.example +5 -2
  2. package/app/components/actions/case-export/download-handlers.ts +6 -7
  3. package/app/components/actions/case-manage.ts +10 -11
  4. package/app/components/actions/generate-pdf.ts +43 -1
  5. package/app/components/actions/image-manage.ts +13 -45
  6. package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
  7. package/app/routes/striae/striae.tsx +15 -4
  8. package/app/utils/data/operations/case-operations.ts +13 -1
  9. package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
  10. package/app/utils/data/operations/file-annotation-operations.ts +13 -1
  11. package/package.json +2 -2
  12. package/scripts/deploy-config.sh +149 -6
  13. package/scripts/deploy-pages-secrets.sh +0 -6
  14. package/scripts/deploy-worker-secrets.sh +66 -5
  15. package/scripts/encrypt-r2-backfill.mjs +376 -0
  16. package/worker-configuration.d.ts +13 -7
  17. package/workers/audit-worker/package.json +1 -4
  18. package/workers/audit-worker/src/audit-worker.example.ts +522 -61
  19. package/workers/audit-worker/wrangler.jsonc.example +5 -0
  20. package/workers/data-worker/package.json +1 -4
  21. package/workers/data-worker/src/data-worker.example.ts +280 -2
  22. package/workers/data-worker/src/encryption-utils.ts +145 -1
  23. package/workers/data-worker/wrangler.jsonc.example +4 -0
  24. package/workers/image-worker/package.json +1 -4
  25. package/workers/image-worker/src/encryption-utils.ts +217 -0
  26. package/workers/image-worker/src/image-worker.example.ts +196 -127
  27. package/workers/image-worker/wrangler.jsonc.example +7 -0
  28. package/workers/keys-worker/package.json +1 -4
  29. package/workers/pdf-worker/package.json +1 -4
  30. package/workers/user-worker/package.json +1 -4
@@ -5,13 +5,10 @@
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
7
7
  "dev": "wrangler dev",
8
- "start": "wrangler dev",
9
- "test": "vitest"
8
+ "start": "wrangler dev"
10
9
  },
11
10
  "devDependencies": {
12
11
  "@cloudflare/puppeteer": "^1.0.6",
13
- "@cloudflare/vitest-pool-workers": "^0.13.0",
14
- "vitest": "~4.1.0",
15
12
  "wrangler": "^4.76.0"
16
13
  },
17
14
  "overrides": {
@@ -1,5 +1,11 @@
1
1
  import { signPayload as signWithWorkerKey } from './signature-utils';
2
- import { decryptExportData, decryptImageBlob } from './encryption-utils';
2
+ import {
3
+ decryptExportData,
4
+ decryptImageBlob,
5
+ decryptJsonFromStorage,
6
+ encryptJsonForStorage,
7
+ type DataAtRestEnvelope
8
+ } from './encryption-utils';
3
9
  import {
4
10
  AUDIT_EXPORT_SIGNATURE_VERSION,
5
11
  CONFIRMATION_SIGNATURE_VERSION,
@@ -23,6 +29,10 @@ interface Env {
23
29
  MANIFEST_SIGNING_KEY_ID: string;
24
30
  EXPORT_ENCRYPTION_PRIVATE_KEY?: string;
25
31
  EXPORT_ENCRYPTION_KEY_ID?: string;
32
+ DATA_AT_REST_ENCRYPTION_ENABLED?: string;
33
+ DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
34
+ DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
35
+ DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
26
36
  }
27
37
 
28
38
  interface SuccessResponse {
@@ -54,6 +64,203 @@ const SIGN_MANIFEST_PATH = '/api/forensic/sign-manifest';
54
64
  const SIGN_CONFIRMATION_PATH = '/api/forensic/sign-confirmation';
55
65
  const SIGN_AUDIT_EXPORT_PATH = '/api/forensic/sign-audit-export';
56
66
  const DECRYPT_EXPORT_PATH = '/api/forensic/decrypt-export';
67
+ const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
68
+ const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
69
+ const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
70
+
71
+ function isDataAtRestEncryptionEnabled(env: Env): boolean {
72
+ const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
73
+ if (!value) {
74
+ return false;
75
+ }
76
+
77
+ const normalizedValue = value.trim().toLowerCase();
78
+ return normalizedValue === '1' || normalizedValue === 'true' || normalizedValue === 'yes' || normalizedValue === 'on';
79
+ }
80
+
81
+ function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
82
+ const metadata = file.customMetadata;
83
+ if (!metadata) {
84
+ return null;
85
+ }
86
+
87
+ const {
88
+ algorithm,
89
+ encryptionVersion,
90
+ keyId,
91
+ dataIv,
92
+ wrappedKey
93
+ } = metadata;
94
+
95
+ if (
96
+ typeof algorithm !== 'string' ||
97
+ typeof encryptionVersion !== 'string' ||
98
+ typeof keyId !== 'string' ||
99
+ typeof dataIv !== 'string' ||
100
+ typeof wrappedKey !== 'string'
101
+ ) {
102
+ return null;
103
+ }
104
+
105
+ return {
106
+ algorithm,
107
+ encryptionVersion,
108
+ keyId,
109
+ dataIv,
110
+ wrappedKey
111
+ };
112
+ }
113
+
114
+ function hasDataAtRestMetadata(metadata: Record<string, string> | undefined): boolean {
115
+ if (!metadata) {
116
+ return false;
117
+ }
118
+
119
+ return (
120
+ typeof metadata.algorithm === 'string' &&
121
+ typeof metadata.encryptionVersion === 'string' &&
122
+ typeof metadata.keyId === 'string' &&
123
+ typeof metadata.dataIv === 'string' &&
124
+ typeof metadata.wrappedKey === 'string'
125
+ );
126
+ }
127
+
128
+ function clampBackfillBatchSize(size: number | undefined): number {
129
+ if (typeof size !== 'number' || !Number.isFinite(size)) {
130
+ return 100;
131
+ }
132
+
133
+ const normalized = Math.floor(size);
134
+ if (normalized < 1) {
135
+ return 1;
136
+ }
137
+
138
+ if (normalized > 1000) {
139
+ return 1000;
140
+ }
141
+
142
+ return normalized;
143
+ }
144
+
145
+ async function handleDataAtRestBackfill(request: Request, env: Env): Promise<Response> {
146
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
147
+ return createResponse(
148
+ { error: 'Data-at-rest encryption is not configured for backfill writes' },
149
+ 400
150
+ );
151
+ }
152
+
153
+ const requestBody = await request.json().catch(() => ({})) as {
154
+ dryRun?: boolean;
155
+ prefix?: string;
156
+ cursor?: string;
157
+ batchSize?: number;
158
+ };
159
+
160
+ const dryRun = requestBody.dryRun === true;
161
+ const prefix = typeof requestBody.prefix === 'string' ? requestBody.prefix : '';
162
+ const cursor = typeof requestBody.cursor === 'string' && requestBody.cursor.length > 0
163
+ ? requestBody.cursor
164
+ : undefined;
165
+ const batchSize = clampBackfillBatchSize(requestBody.batchSize);
166
+
167
+ const bucket = env.STRIAE_DATA;
168
+ const listed = await bucket.list({
169
+ prefix: prefix.length > 0 ? prefix : undefined,
170
+ cursor,
171
+ limit: batchSize
172
+ });
173
+
174
+ let scanned = 0;
175
+ let eligible = 0;
176
+ let encrypted = 0;
177
+ let skippedEncrypted = 0;
178
+ let skippedNonJson = 0;
179
+ let failed = 0;
180
+ const failures: Array<{ key: string; error: string }> = [];
181
+
182
+ for (const object of listed.objects) {
183
+ scanned += 1;
184
+ const key = object.key;
185
+
186
+ if (!key.endsWith('.json')) {
187
+ skippedNonJson += 1;
188
+ continue;
189
+ }
190
+
191
+ const objectHead = await bucket.head(key);
192
+ if (!objectHead) {
193
+ failed += 1;
194
+ if (failures.length < 20) {
195
+ failures.push({ key, error: 'Object not found during metadata check' });
196
+ }
197
+ continue;
198
+ }
199
+
200
+ if (hasDataAtRestMetadata(objectHead.customMetadata)) {
201
+ skippedEncrypted += 1;
202
+ continue;
203
+ }
204
+
205
+ eligible += 1;
206
+
207
+ if (dryRun) {
208
+ continue;
209
+ }
210
+
211
+ try {
212
+ const existingObject = await bucket.get(key);
213
+ if (!existingObject) {
214
+ failed += 1;
215
+ if (failures.length < 20) {
216
+ failures.push({ key, error: 'Object disappeared before processing' });
217
+ }
218
+ continue;
219
+ }
220
+
221
+ const plaintext = await existingObject.text();
222
+ const encryptedPayload = await encryptJsonForStorage(
223
+ plaintext,
224
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
225
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
226
+ );
227
+
228
+ await bucket.put(key, encryptedPayload.ciphertext, {
229
+ customMetadata: {
230
+ algorithm: encryptedPayload.envelope.algorithm,
231
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
232
+ keyId: encryptedPayload.envelope.keyId,
233
+ dataIv: encryptedPayload.envelope.dataIv,
234
+ wrappedKey: encryptedPayload.envelope.wrappedKey
235
+ }
236
+ });
237
+
238
+ encrypted += 1;
239
+ } catch (error) {
240
+ failed += 1;
241
+ if (failures.length < 20) {
242
+ const errorMessage = error instanceof Error ? error.message : 'Unknown backfill failure';
243
+ failures.push({ key, error: errorMessage });
244
+ }
245
+ }
246
+ }
247
+
248
+ return createResponse({
249
+ success: failed === 0,
250
+ dryRun,
251
+ prefix: prefix.length > 0 ? prefix : null,
252
+ batchSize,
253
+ scanned,
254
+ eligible,
255
+ encrypted,
256
+ skippedEncrypted,
257
+ skippedNonJson,
258
+ failed,
259
+ failures,
260
+ hasMore: listed.truncated,
261
+ nextCursor: listed.truncated ? listed.cursor : null
262
+ });
263
+ }
57
264
 
58
265
  async function signPayloadWithWorkerKey(payload: string, env: Env): Promise<{
59
266
  algorithm: string;
@@ -353,6 +560,10 @@ export default {
353
560
  return await handleDecryptExport(request, env);
354
561
  }
355
562
 
563
+ if (request.method === 'POST' && pathname === DATA_AT_REST_BACKFILL_PATH) {
564
+ return await handleDataAtRestBackfill(request, env);
565
+ }
566
+
356
567
  const filename = pathname.slice(1) || 'data.json';
357
568
 
358
569
  if (!filename.endsWith('.json')) {
@@ -365,6 +576,39 @@ export default {
365
576
  if (!file) {
366
577
  return createResponse([], 200);
367
578
  }
579
+
580
+ const atRestEnvelope = extractDataAtRestEnvelope(file);
581
+ if (atRestEnvelope) {
582
+ if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
583
+ return createResponse({ error: 'Unsupported data-at-rest encryption algorithm' }, 500);
584
+ }
585
+
586
+ if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
587
+ return createResponse({ error: 'Unsupported data-at-rest encryption version' }, 500);
588
+ }
589
+
590
+ if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
591
+ return createResponse(
592
+ { error: 'Data-at-rest decryption is not configured on this server' },
593
+ 500
594
+ );
595
+ }
596
+
597
+ try {
598
+ const encryptedData = await file.arrayBuffer();
599
+ const plaintext = await decryptJsonFromStorage(
600
+ encryptedData,
601
+ atRestEnvelope,
602
+ env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
603
+ );
604
+ const decryptedPayload = JSON.parse(plaintext);
605
+ return createResponse(decryptedPayload);
606
+ } catch (error) {
607
+ console.error('Data-at-rest decryption failed:', error);
608
+ return createResponse({ error: 'Failed to decrypt stored data' }, 500);
609
+ }
610
+ }
611
+
368
612
  const fileText = await file.text();
369
613
  const data = JSON.parse(fileText);
370
614
  return createResponse(data);
@@ -372,7 +616,41 @@ export default {
372
616
 
373
617
  case 'PUT': {
374
618
  const newData = await request.json();
375
- await bucket.put(filename, JSON.stringify(newData));
619
+ const serializedData = JSON.stringify(newData);
620
+
621
+ if (!isDataAtRestEncryptionEnabled(env)) {
622
+ await bucket.put(filename, serializedData);
623
+ return createResponse({ success: true });
624
+ }
625
+
626
+ if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
627
+ return createResponse(
628
+ { error: 'Data-at-rest encryption is enabled but not fully configured' },
629
+ 500
630
+ );
631
+ }
632
+
633
+ try {
634
+ const encryptedPayload = await encryptJsonForStorage(
635
+ serializedData,
636
+ env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
637
+ env.DATA_AT_REST_ENCRYPTION_KEY_ID
638
+ );
639
+
640
+ await bucket.put(filename, encryptedPayload.ciphertext, {
641
+ customMetadata: {
642
+ algorithm: encryptedPayload.envelope.algorithm,
643
+ encryptionVersion: encryptedPayload.envelope.encryptionVersion,
644
+ keyId: encryptedPayload.envelope.keyId,
645
+ dataIv: encryptedPayload.envelope.dataIv,
646
+ wrappedKey: encryptedPayload.envelope.wrappedKey
647
+ }
648
+ });
649
+ } catch (error) {
650
+ console.error('Data-at-rest encryption failed:', error);
651
+ return createResponse({ error: 'Failed to encrypt data for storage' }, 500);
652
+ }
653
+
376
654
  return createResponse({ success: true });
377
655
  }
378
656
 
@@ -11,6 +11,64 @@ export function base64UrlDecode(value: string): Uint8Array {
11
11
  return bytes;
12
12
  }
13
13
 
14
+ export function base64UrlEncode(value: Uint8Array): string {
15
+ let binary = '';
16
+ const chunkSize = 8192;
17
+
18
+ for (let i = 0; i < value.length; i += chunkSize) {
19
+ const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
20
+ for (let j = 0; j < chunk.length; j += 1) {
21
+ binary += String.fromCharCode(chunk[j]);
22
+ }
23
+ }
24
+
25
+ return btoa(binary)
26
+ .replace(/\+/g, '-')
27
+ .replace(/\//g, '_')
28
+ .replace(/=+$/g, '');
29
+ }
30
+
31
+ const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
32
+ const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
33
+
34
+ export interface DataAtRestEnvelope {
35
+ algorithm: string;
36
+ encryptionVersion: string;
37
+ keyId: string;
38
+ dataIv: string;
39
+ wrappedKey: string;
40
+ }
41
+
42
+ interface EncryptJsonAtRestResult {
43
+ ciphertext: Uint8Array;
44
+ envelope: DataAtRestEnvelope;
45
+ }
46
+
47
+ function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
48
+ const normalizedKey = publicKey
49
+ .trim()
50
+ .replace(/^['"]|['"]$/g, '')
51
+ .replace(/\\n/g, '\n');
52
+
53
+ const pemBody = normalizedKey
54
+ .replace('-----BEGIN PUBLIC KEY-----', '')
55
+ .replace('-----END PUBLIC KEY-----', '')
56
+ .replace(/\s+/g, '');
57
+
58
+ if (!pemBody) {
59
+ throw new Error('Encryption public key is invalid');
60
+ }
61
+
62
+ const binary = atob(pemBody);
63
+ const bytes = new Uint8Array(binary.length);
64
+
65
+ for (let index = 0; index < binary.length; index += 1) {
66
+ bytes[index] = binary.charCodeAt(index);
67
+ }
68
+
69
+ return bytes.buffer;
70
+ }
71
+
14
72
  function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
15
73
  const normalizedKey = privateKey
16
74
  .trim()
@@ -54,6 +112,47 @@ async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey
54
112
  return key;
55
113
  }
56
114
 
115
+ async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
116
+ const key = await crypto.subtle.importKey(
117
+ 'spki',
118
+ parseSpkiPublicKey(publicKeyPem),
119
+ {
120
+ name: 'RSA-OAEP',
121
+ hash: 'SHA-256'
122
+ },
123
+ false,
124
+ ['encrypt']
125
+ );
126
+
127
+ return key;
128
+ }
129
+
130
+ async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
131
+ return crypto.subtle.generateKey(
132
+ {
133
+ name: 'AES-GCM',
134
+ length: 256
135
+ },
136
+ true,
137
+ usages
138
+ ) as Promise<CryptoKey>;
139
+ }
140
+
141
+ async function wrapAesKey(
142
+ aesKey: CryptoKey,
143
+ publicKeyPem: string
144
+ ): Promise<string> {
145
+ const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
146
+ const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
147
+ const wrappedKey = await crypto.subtle.encrypt(
148
+ { name: 'RSA-OAEP' },
149
+ rsaPublicKey,
150
+ rawAesKey as BufferSource
151
+ );
152
+
153
+ return base64UrlEncode(new Uint8Array(wrappedKey));
154
+ }
155
+
57
156
  /**
58
157
  * Decrypt AES key from RSA-OAEP wrapped form
59
158
  */
@@ -75,10 +174,55 @@ async function unwrapAesKey(
75
174
  rawAesKey,
76
175
  { name: 'AES-GCM' },
77
176
  false,
78
- ['decrypt']
177
+ ['encrypt', 'decrypt']
79
178
  );
80
179
  }
81
180
 
181
+ export async function encryptJsonForStorage(
182
+ plaintextJson: string,
183
+ publicKeyPem: string,
184
+ keyId: string
185
+ ): Promise<EncryptJsonAtRestResult> {
186
+ const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
187
+ const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
188
+ const iv = crypto.getRandomValues(new Uint8Array(12));
189
+
190
+ const plaintextBytes = new TextEncoder().encode(plaintextJson);
191
+ const encryptedBuffer = await crypto.subtle.encrypt(
192
+ { name: 'AES-GCM', iv: iv as BufferSource },
193
+ aesKey,
194
+ plaintextBytes as BufferSource
195
+ );
196
+
197
+ return {
198
+ ciphertext: new Uint8Array(encryptedBuffer),
199
+ envelope: {
200
+ algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
201
+ encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
202
+ keyId,
203
+ dataIv: base64UrlEncode(iv),
204
+ wrappedKey
205
+ }
206
+ };
207
+ }
208
+
209
+ export async function decryptJsonFromStorage(
210
+ ciphertext: ArrayBuffer,
211
+ envelope: DataAtRestEnvelope,
212
+ privateKeyPem: string
213
+ ): Promise<string> {
214
+ const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
215
+ const iv = base64UrlDecode(envelope.dataIv);
216
+
217
+ const plaintext = await crypto.subtle.decrypt(
218
+ { name: 'AES-GCM', iv: iv as BufferSource },
219
+ aesKey,
220
+ ciphertext as BufferSource
221
+ );
222
+
223
+ return new TextDecoder().decode(plaintext);
224
+ }
225
+
82
226
  /**
83
227
  * Decrypt data file (plaintext JSON/CSV)
84
228
  */
@@ -1,5 +1,9 @@
1
1
  {
2
2
  // Required secrets: R2_KEY_SECRET, MANIFEST_SIGNING_PRIVATE_KEY, MANIFEST_SIGNING_KEY_ID, EXPORT_ENCRYPTION_PRIVATE_KEY, EXPORT_ENCRYPTION_KEY_ID
3
+ // Optional data-at-rest secrets/vars:
4
+ // - DATA_AT_REST_ENCRYPTION_ENABLED=true
5
+ // - DATA_AT_REST_ENCRYPTION_PRIVATE_KEY (required for decrypting encrypted records)
6
+ // - DATA_AT_REST_ENCRYPTION_PUBLIC_KEY and DATA_AT_REST_ENCRYPTION_KEY_ID (required when encrypt-on-write is enabled)
3
7
  "name": "DATA_WORKER_NAME",
4
8
  "account_id": "ACCOUNT_ID",
5
9
  "main": "src/data-worker.ts",
@@ -5,13 +5,10 @@
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
7
7
  "dev": "wrangler dev",
8
- "start": "wrangler dev",
9
- "test": "vitest"
8
+ "start": "wrangler dev"
10
9
  },
11
10
  "devDependencies": {
12
11
  "@cloudflare/puppeteer": "^1.0.6",
13
- "@cloudflare/vitest-pool-workers": "^0.13.0",
14
- "vitest": "~4.1.0",
15
12
  "wrangler": "^4.76.0"
16
13
  },
17
14
  "overrides": {