@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.
- package/.env.example +5 -2
- package/app/components/actions/case-export/download-handlers.ts +6 -7
- package/app/components/actions/case-manage.ts +10 -11
- package/app/components/actions/generate-pdf.ts +43 -1
- package/app/components/actions/image-manage.ts +13 -45
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
- package/app/routes/striae/striae.tsx +15 -4
- package/app/utils/data/operations/case-operations.ts +13 -1
- package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
- package/app/utils/data/operations/file-annotation-operations.ts +13 -1
- package/package.json +2 -2
- package/scripts/deploy-config.sh +149 -6
- package/scripts/deploy-pages-secrets.sh +0 -6
- package/scripts/deploy-worker-secrets.sh +66 -5
- package/scripts/encrypt-r2-backfill.mjs +376 -0
- package/worker-configuration.d.ts +13 -7
- package/workers/audit-worker/package.json +1 -4
- package/workers/audit-worker/src/audit-worker.example.ts +522 -61
- package/workers/audit-worker/wrangler.jsonc.example +5 -0
- package/workers/data-worker/package.json +1 -4
- package/workers/data-worker/src/data-worker.example.ts +280 -2
- package/workers/data-worker/src/encryption-utils.ts +145 -1
- package/workers/data-worker/wrangler.jsonc.example +4 -0
- package/workers/image-worker/package.json +1 -4
- package/workers/image-worker/src/encryption-utils.ts +217 -0
- package/workers/image-worker/src/image-worker.example.ts +196 -127
- package/workers/image-worker/wrangler.jsonc.example +7 -0
- package/workers/keys-worker/package.json +1 -4
- package/workers/pdf-worker/package.json +1 -4
- 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 {
|
|
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
|
-
|
|
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": {
|