@striae-org/striae 5.1.1 → 5.2.1
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 +41 -11
- package/app/utils/data/permissions.ts +4 -2
- package/package.json +5 -5
- package/scripts/deploy-config/modules/env-utils.sh +322 -0
- package/scripts/deploy-config/modules/keys.sh +404 -0
- package/scripts/deploy-config/modules/prompt.sh +372 -0
- package/scripts/deploy-config/modules/scaffolding.sh +344 -0
- package/scripts/deploy-config/modules/validation.sh +365 -0
- package/scripts/deploy-config.sh +47 -1572
- package/scripts/deploy-worker-secrets.sh +100 -5
- package/worker-configuration.d.ts +6 -3
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/src/audit-worker.example.ts +188 -6
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/src/data-worker.example.ts +344 -32
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/src/image-worker.example.ts +190 -5
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/pdf-worker.example.ts +0 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -5
- package/workers/user-worker/package.json +17 -17
- package/workers/user-worker/src/encryption-utils.ts +244 -0
- package/workers/user-worker/src/user-worker.example.ts +333 -31
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -29,12 +29,34 @@ interface Env {
|
|
|
29
29
|
MANIFEST_SIGNING_KEY_ID: string;
|
|
30
30
|
EXPORT_ENCRYPTION_PRIVATE_KEY?: string;
|
|
31
31
|
EXPORT_ENCRYPTION_KEY_ID?: string;
|
|
32
|
+
EXPORT_ENCRYPTION_KEYS_JSON?: string;
|
|
33
|
+
EXPORT_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
32
34
|
DATA_AT_REST_ENCRYPTION_ENABLED?: string;
|
|
33
35
|
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
|
|
34
36
|
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
|
|
35
37
|
DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
|
|
38
|
+
DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
|
|
39
|
+
DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
interface KeyRegistryPayload {
|
|
43
|
+
activeKeyId?: unknown;
|
|
44
|
+
keys?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PrivateKeyRegistry {
|
|
48
|
+
activeKeyId: string | null;
|
|
49
|
+
keys: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ExportDecryptionContext {
|
|
53
|
+
recordKeyId: string | null;
|
|
54
|
+
candidates: Array<{ keyId: string; privateKeyPem: string }>;
|
|
55
|
+
primaryKeyId: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
59
|
+
|
|
38
60
|
interface SuccessResponse {
|
|
39
61
|
success: boolean;
|
|
40
62
|
}
|
|
@@ -68,6 +90,318 @@ const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
|
|
|
68
90
|
const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
69
91
|
const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
|
|
70
92
|
|
|
93
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
94
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
98
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parsePrivateKeyRegistry(input: {
|
|
102
|
+
registryJson: string | undefined;
|
|
103
|
+
activeKeyId: string | undefined;
|
|
104
|
+
legacyKeyId: string | undefined;
|
|
105
|
+
legacyPrivateKey: string | undefined;
|
|
106
|
+
context: string;
|
|
107
|
+
}): PrivateKeyRegistry {
|
|
108
|
+
const keys: Record<string, string> = {};
|
|
109
|
+
const configuredActiveKeyId = getNonEmptyString(input.activeKeyId);
|
|
110
|
+
const registryJson = getNonEmptyString(input.registryJson);
|
|
111
|
+
|
|
112
|
+
if (registryJson) {
|
|
113
|
+
let parsedRegistry: unknown;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
parsedRegistry = JSON.parse(registryJson) as unknown;
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error(`${input.context} registry JSON is invalid`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
122
|
+
throw new Error(`${input.context} registry JSON must be an object`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const payload = parsedRegistry as KeyRegistryPayload;
|
|
126
|
+
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
127
|
+
const rawKeys = payload.keys && typeof payload.keys === 'object'
|
|
128
|
+
? payload.keys as Record<string, unknown>
|
|
129
|
+
: parsedRegistry as Record<string, unknown>;
|
|
130
|
+
|
|
131
|
+
for (const [keyId, pemValue] of Object.entries(rawKeys)) {
|
|
132
|
+
if (keyId === 'activeKeyId' || keyId === 'keys') {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const normalizedKeyId = getNonEmptyString(keyId);
|
|
137
|
+
const normalizedPem = getNonEmptyString(pemValue);
|
|
138
|
+
|
|
139
|
+
if (!normalizedKeyId || !normalizedPem) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
147
|
+
|
|
148
|
+
if (Object.keys(keys).length === 0) {
|
|
149
|
+
throw new Error(`${input.context} registry does not contain any usable keys`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
|
|
153
|
+
throw new Error(`${input.context} active key ID is not present in registry`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
activeKeyId: resolvedActiveKeyId ?? null,
|
|
158
|
+
keys
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const legacyKeyId = getNonEmptyString(input.legacyKeyId);
|
|
163
|
+
const legacyPrivateKey = getNonEmptyString(input.legacyPrivateKey);
|
|
164
|
+
|
|
165
|
+
if (!legacyKeyId || !legacyPrivateKey) {
|
|
166
|
+
throw new Error(`${input.context} private key registry is not configured`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
|
|
170
|
+
const resolvedActiveKeyId = configuredActiveKeyId ?? legacyKeyId;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
activeKeyId: resolvedActiveKeyId,
|
|
174
|
+
keys
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildPrivateKeyCandidates(
|
|
179
|
+
recordKeyId: string | null,
|
|
180
|
+
registry: PrivateKeyRegistry
|
|
181
|
+
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
182
|
+
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
|
|
185
|
+
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
186
|
+
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const privateKeyPem = registry.keys[candidateKeyId];
|
|
191
|
+
if (!privateKeyPem) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
seen.add(candidateKeyId);
|
|
196
|
+
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
appendCandidate(recordKeyId);
|
|
200
|
+
appendCandidate(registry.activeKeyId);
|
|
201
|
+
|
|
202
|
+
for (const keyId of Object.keys(registry.keys)) {
|
|
203
|
+
appendCandidate(keyId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return candidates;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function logRegistryDecryptionTelemetry(input: {
|
|
210
|
+
scope: 'data-at-rest' | 'export-data' | 'export-image';
|
|
211
|
+
recordKeyId: string | null;
|
|
212
|
+
selectedKeyId: string | null;
|
|
213
|
+
attemptCount: number;
|
|
214
|
+
outcome: DecryptionTelemetryOutcome;
|
|
215
|
+
reason?: string;
|
|
216
|
+
}): void {
|
|
217
|
+
const details = {
|
|
218
|
+
scope: input.scope,
|
|
219
|
+
recordKeyId: input.recordKeyId,
|
|
220
|
+
selectedKeyId: input.selectedKeyId,
|
|
221
|
+
attemptCount: input.attemptCount,
|
|
222
|
+
fallbackUsed: input.outcome === 'fallback-hit',
|
|
223
|
+
outcome: input.outcome,
|
|
224
|
+
reason: input.reason ?? null
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (input.outcome === 'all-failed') {
|
|
228
|
+
console.warn('Key registry decryption failed', details);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.info('Key registry decryption resolved', details);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
236
|
+
return parsePrivateKeyRegistry({
|
|
237
|
+
registryJson: env.DATA_AT_REST_ENCRYPTION_KEYS_JSON,
|
|
238
|
+
activeKeyId: env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID,
|
|
239
|
+
legacyKeyId: env.DATA_AT_REST_ENCRYPTION_KEY_ID,
|
|
240
|
+
legacyPrivateKey: env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY,
|
|
241
|
+
context: 'Data-at-rest decryption'
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getExportPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
246
|
+
return parsePrivateKeyRegistry({
|
|
247
|
+
registryJson: env.EXPORT_ENCRYPTION_KEYS_JSON,
|
|
248
|
+
activeKeyId: env.EXPORT_ENCRYPTION_ACTIVE_KEY_ID,
|
|
249
|
+
legacyKeyId: env.EXPORT_ENCRYPTION_KEY_ID,
|
|
250
|
+
legacyPrivateKey: env.EXPORT_ENCRYPTION_PRIVATE_KEY,
|
|
251
|
+
context: 'Export decryption'
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function buildExportDecryptionContext(keyId: string | null, env: Env): ExportDecryptionContext {
|
|
256
|
+
const keyRegistry = getExportPrivateKeyRegistry(env);
|
|
257
|
+
const candidates = buildPrivateKeyCandidates(keyId, keyRegistry);
|
|
258
|
+
|
|
259
|
+
if (candidates.length === 0) {
|
|
260
|
+
throw new Error('Export decryption key registry does not contain any usable keys');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
recordKeyId: keyId,
|
|
265
|
+
candidates,
|
|
266
|
+
primaryKeyId: candidates[0]?.keyId ?? null
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function decryptJsonFromStorageWithRegistry(
|
|
271
|
+
ciphertext: ArrayBuffer,
|
|
272
|
+
envelope: DataAtRestEnvelope,
|
|
273
|
+
env: Env
|
|
274
|
+
): Promise<string> {
|
|
275
|
+
const keyRegistry = getDataAtRestPrivateKeyRegistry(env);
|
|
276
|
+
const candidates = buildPrivateKeyCandidates(getNonEmptyString(envelope.keyId), keyRegistry);
|
|
277
|
+
const primaryKeyId = candidates[0]?.keyId ?? null;
|
|
278
|
+
let lastError: unknown;
|
|
279
|
+
|
|
280
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
281
|
+
const candidate = candidates[index];
|
|
282
|
+
try {
|
|
283
|
+
const plaintext = await decryptJsonFromStorage(ciphertext, envelope, candidate.privateKeyPem);
|
|
284
|
+
logRegistryDecryptionTelemetry({
|
|
285
|
+
scope: 'data-at-rest',
|
|
286
|
+
recordKeyId: getNonEmptyString(envelope.keyId),
|
|
287
|
+
selectedKeyId: candidate.keyId,
|
|
288
|
+
attemptCount: index + 1,
|
|
289
|
+
outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
290
|
+
});
|
|
291
|
+
return plaintext;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
lastError = error;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
logRegistryDecryptionTelemetry({
|
|
298
|
+
scope: 'data-at-rest',
|
|
299
|
+
recordKeyId: getNonEmptyString(envelope.keyId),
|
|
300
|
+
selectedKeyId: null,
|
|
301
|
+
attemptCount: candidates.length,
|
|
302
|
+
outcome: 'all-failed',
|
|
303
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Failed to decrypt stored data after ${candidates.length} key attempt(s): ${
|
|
308
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
309
|
+
}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function decryptExportDataWithRegistry(
|
|
314
|
+
encryptedDataBase64: string,
|
|
315
|
+
wrappedKeyBase64: string,
|
|
316
|
+
ivBase64: string,
|
|
317
|
+
context: ExportDecryptionContext
|
|
318
|
+
): Promise<string> {
|
|
319
|
+
let lastError: unknown;
|
|
320
|
+
|
|
321
|
+
for (let index = 0; index < context.candidates.length; index += 1) {
|
|
322
|
+
const candidate = context.candidates[index];
|
|
323
|
+
try {
|
|
324
|
+
const plaintext = await decryptExportData(
|
|
325
|
+
encryptedDataBase64,
|
|
326
|
+
wrappedKeyBase64,
|
|
327
|
+
ivBase64,
|
|
328
|
+
candidate.privateKeyPem
|
|
329
|
+
);
|
|
330
|
+
logRegistryDecryptionTelemetry({
|
|
331
|
+
scope: 'export-data',
|
|
332
|
+
recordKeyId: context.recordKeyId,
|
|
333
|
+
selectedKeyId: candidate.keyId,
|
|
334
|
+
attemptCount: index + 1,
|
|
335
|
+
outcome: candidate.keyId === context.primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
336
|
+
});
|
|
337
|
+
return plaintext;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
lastError = error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
logRegistryDecryptionTelemetry({
|
|
344
|
+
scope: 'export-data',
|
|
345
|
+
recordKeyId: context.recordKeyId,
|
|
346
|
+
selectedKeyId: null,
|
|
347
|
+
attemptCount: context.candidates.length,
|
|
348
|
+
outcome: 'all-failed',
|
|
349
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Failed to decrypt export payload after ${context.candidates.length} key attempt(s): ${
|
|
354
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
355
|
+
}`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function decryptExportImageWithRegistry(
|
|
360
|
+
encryptedImageBase64: string,
|
|
361
|
+
wrappedKeyBase64: string,
|
|
362
|
+
ivBase64: string,
|
|
363
|
+
context: ExportDecryptionContext
|
|
364
|
+
): Promise<Blob> {
|
|
365
|
+
let lastError: unknown;
|
|
366
|
+
|
|
367
|
+
for (let index = 0; index < context.candidates.length; index += 1) {
|
|
368
|
+
const candidate = context.candidates[index];
|
|
369
|
+
try {
|
|
370
|
+
const imageBlob = await decryptImageBlob(
|
|
371
|
+
encryptedImageBase64,
|
|
372
|
+
wrappedKeyBase64,
|
|
373
|
+
ivBase64,
|
|
374
|
+
candidate.privateKeyPem
|
|
375
|
+
);
|
|
376
|
+
logRegistryDecryptionTelemetry({
|
|
377
|
+
scope: 'export-image',
|
|
378
|
+
recordKeyId: context.recordKeyId,
|
|
379
|
+
selectedKeyId: candidate.keyId,
|
|
380
|
+
attemptCount: index + 1,
|
|
381
|
+
outcome: candidate.keyId === context.primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
382
|
+
});
|
|
383
|
+
return imageBlob;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
lastError = error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
logRegistryDecryptionTelemetry({
|
|
390
|
+
scope: 'export-image',
|
|
391
|
+
recordKeyId: context.recordKeyId,
|
|
392
|
+
selectedKeyId: null,
|
|
393
|
+
attemptCount: context.candidates.length,
|
|
394
|
+
outcome: 'all-failed',
|
|
395
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Failed to decrypt export image after ${context.candidates.length} key attempt(s): ${
|
|
400
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
401
|
+
}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
71
405
|
function isDataAtRestEncryptionEnabled(env: Env): boolean {
|
|
72
406
|
const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
|
|
73
407
|
if (!value) {
|
|
@@ -409,14 +743,6 @@ async function handleSignAuditExport(request: Request, env: Env): Promise<Respon
|
|
|
409
743
|
|
|
410
744
|
async function handleDecryptExport(request: Request, env: Env): Promise<Response> {
|
|
411
745
|
try {
|
|
412
|
-
// Check if encryption is configured
|
|
413
|
-
if (!env.EXPORT_ENCRYPTION_PRIVATE_KEY || !env.EXPORT_ENCRYPTION_KEY_ID) {
|
|
414
|
-
return createResponse(
|
|
415
|
-
{ error: 'Export decryption is not configured on this server' },
|
|
416
|
-
400
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
746
|
const requestBody = await request.json() as {
|
|
421
747
|
wrappedKey?: string;
|
|
422
748
|
dataIv?: string;
|
|
@@ -434,32 +760,25 @@ async function handleDecryptExport(request: Request, env: Env): Promise<Response
|
|
|
434
760
|
!dataIv ||
|
|
435
761
|
typeof dataIv !== 'string' ||
|
|
436
762
|
!encryptedData ||
|
|
437
|
-
typeof encryptedData !== 'string'
|
|
438
|
-
!keyId ||
|
|
439
|
-
typeof keyId !== 'string'
|
|
763
|
+
typeof encryptedData !== 'string'
|
|
440
764
|
) {
|
|
441
765
|
return createResponse(
|
|
442
|
-
{ error: 'Missing or invalid required fields: wrappedKey, dataIv, encryptedData
|
|
766
|
+
{ error: 'Missing or invalid required fields: wrappedKey, dataIv, encryptedData' },
|
|
443
767
|
400
|
|
444
768
|
);
|
|
445
769
|
}
|
|
446
770
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return createResponse(
|
|
450
|
-
{ error: `Key ID mismatch: expected ${env.EXPORT_ENCRYPTION_KEY_ID}, got ${keyId}` },
|
|
451
|
-
400
|
|
452
|
-
);
|
|
453
|
-
}
|
|
771
|
+
const recordKeyId = getNonEmptyString(keyId);
|
|
772
|
+
const decryptionContext = buildExportDecryptionContext(recordKeyId, env);
|
|
454
773
|
|
|
455
774
|
// Decrypt data file
|
|
456
775
|
let plaintextData: string;
|
|
457
776
|
try {
|
|
458
|
-
plaintextData = await
|
|
777
|
+
plaintextData = await decryptExportDataWithRegistry(
|
|
459
778
|
encryptedData,
|
|
460
779
|
wrappedKey,
|
|
461
780
|
dataIv,
|
|
462
|
-
|
|
781
|
+
decryptionContext
|
|
463
782
|
);
|
|
464
783
|
} catch (error) {
|
|
465
784
|
console.error('Data file decryption failed:', error);
|
|
@@ -482,11 +801,11 @@ async function handleDecryptExport(request: Request, env: Env): Promise<Response
|
|
|
482
801
|
);
|
|
483
802
|
}
|
|
484
803
|
|
|
485
|
-
const imageBlob = await
|
|
804
|
+
const imageBlob = await decryptExportImageWithRegistry(
|
|
486
805
|
imageEntry.encryptedData,
|
|
487
806
|
wrappedKey,
|
|
488
807
|
imageEntry.iv,
|
|
489
|
-
|
|
808
|
+
decryptionContext
|
|
490
809
|
);
|
|
491
810
|
|
|
492
811
|
// Convert blob to base64 for transport
|
|
@@ -587,19 +906,12 @@ export default {
|
|
|
587
906
|
return createResponse({ error: 'Unsupported data-at-rest encryption version' }, 500);
|
|
588
907
|
}
|
|
589
908
|
|
|
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
909
|
try {
|
|
598
910
|
const encryptedData = await file.arrayBuffer();
|
|
599
|
-
const plaintext = await
|
|
911
|
+
const plaintext = await decryptJsonFromStorageWithRegistry(
|
|
600
912
|
encryptedData,
|
|
601
913
|
atRestEnvelope,
|
|
602
|
-
env
|
|
914
|
+
env
|
|
603
915
|
);
|
|
604
916
|
const decryptedPayload = JSON.parse(plaintext);
|
|
605
917
|
return createResponse(decryptedPayload);
|
|
@@ -7,13 +7,27 @@ import {
|
|
|
7
7
|
interface Env {
|
|
8
8
|
IMAGES_API_TOKEN: string;
|
|
9
9
|
STRIAE_FILES: R2Bucket;
|
|
10
|
-
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY
|
|
10
|
+
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
|
|
11
11
|
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
|
|
12
12
|
DATA_AT_REST_ENCRYPTION_KEY_ID: string;
|
|
13
|
+
DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
|
|
14
|
+
DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
13
15
|
IMAGE_SIGNED_URL_SECRET?: string;
|
|
14
16
|
IMAGE_SIGNED_URL_TTL_SECONDS?: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
interface KeyRegistryPayload {
|
|
20
|
+
activeKeyId?: unknown;
|
|
21
|
+
keys?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PrivateKeyRegistry {
|
|
25
|
+
activeKeyId: string | null;
|
|
26
|
+
keys: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
30
|
+
|
|
17
31
|
interface UploadResult {
|
|
18
32
|
id: string;
|
|
19
33
|
filename: string;
|
|
@@ -89,9 +103,180 @@ function requireEncryptionUploadConfig(env: Env): void {
|
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
function requireEncryptionRetrievalConfig(env: Env): void {
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
const hasLegacyPrivateKey = typeof env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY === 'string' && env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
|
|
107
|
+
const hasRegistry = typeof env.DATA_AT_REST_ENCRYPTION_KEYS_JSON === 'string' && env.DATA_AT_REST_ENCRYPTION_KEYS_JSON.trim().length > 0;
|
|
108
|
+
|
|
109
|
+
if (!hasLegacyPrivateKey && !hasRegistry) {
|
|
110
|
+
throw new Error('Data-at-rest decryption registry is not configured for image retrieval');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
115
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
119
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
123
|
+
const keys: Record<string, string> = {};
|
|
124
|
+
const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
|
|
125
|
+
const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
|
|
126
|
+
|
|
127
|
+
if (registryJson) {
|
|
128
|
+
let parsedRegistry: unknown;
|
|
129
|
+
try {
|
|
130
|
+
parsedRegistry = JSON.parse(registryJson) as unknown;
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
136
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const payload = parsedRegistry as KeyRegistryPayload;
|
|
140
|
+
if (!payload.keys || typeof payload.keys !== 'object') {
|
|
141
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must include a keys object');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
|
|
145
|
+
const normalizedKeyId = getNonEmptyString(keyId);
|
|
146
|
+
const normalizedPem = getNonEmptyString(pemValue);
|
|
147
|
+
if (!normalizedKeyId || !normalizedPem) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
155
|
+
const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
156
|
+
|
|
157
|
+
if (Object.keys(keys).length === 0) {
|
|
158
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
|
|
162
|
+
throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
activeKeyId: resolvedActiveKeyId ?? null,
|
|
167
|
+
keys
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
|
|
172
|
+
const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
|
|
173
|
+
if (!legacyKeyId || !legacyPrivateKey) {
|
|
174
|
+
throw new Error('Data-at-rest decryption key registry is not configured');
|
|
94
175
|
}
|
|
176
|
+
|
|
177
|
+
keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
activeKeyId: configuredActiveKeyId ?? legacyKeyId,
|
|
181
|
+
keys
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildPrivateKeyCandidates(
|
|
186
|
+
recordKeyId: string,
|
|
187
|
+
registry: PrivateKeyRegistry
|
|
188
|
+
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
189
|
+
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
190
|
+
const seen = new Set<string>();
|
|
191
|
+
|
|
192
|
+
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
193
|
+
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const privateKeyPem = registry.keys[candidateKeyId];
|
|
198
|
+
if (!privateKeyPem) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
seen.add(candidateKeyId);
|
|
203
|
+
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
appendCandidate(getNonEmptyString(recordKeyId));
|
|
207
|
+
appendCandidate(registry.activeKeyId);
|
|
208
|
+
|
|
209
|
+
for (const keyId of Object.keys(registry.keys)) {
|
|
210
|
+
appendCandidate(keyId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return candidates;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function logFileDecryptionTelemetry(input: {
|
|
217
|
+
recordKeyId: string;
|
|
218
|
+
selectedKeyId: string | null;
|
|
219
|
+
attemptCount: number;
|
|
220
|
+
outcome: DecryptionTelemetryOutcome;
|
|
221
|
+
reason?: string;
|
|
222
|
+
}): void {
|
|
223
|
+
const details = {
|
|
224
|
+
scope: 'file-at-rest',
|
|
225
|
+
recordKeyId: input.recordKeyId,
|
|
226
|
+
selectedKeyId: input.selectedKeyId,
|
|
227
|
+
attemptCount: input.attemptCount,
|
|
228
|
+
fallbackUsed: input.outcome === 'fallback-hit',
|
|
229
|
+
outcome: input.outcome,
|
|
230
|
+
reason: input.reason ?? null
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (input.outcome === 'all-failed') {
|
|
234
|
+
console.warn('Key registry decryption failed', details);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.info('Key registry decryption resolved', details);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function decryptBinaryWithRegistry(
|
|
242
|
+
ciphertext: ArrayBuffer,
|
|
243
|
+
envelope: DataAtRestEnvelope,
|
|
244
|
+
env: Env
|
|
245
|
+
): Promise<ArrayBuffer> {
|
|
246
|
+
const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
|
|
247
|
+
const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
|
|
248
|
+
const primaryKeyId = candidates[0]?.keyId ?? null;
|
|
249
|
+
let lastError: unknown;
|
|
250
|
+
|
|
251
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
252
|
+
const candidate = candidates[index];
|
|
253
|
+
try {
|
|
254
|
+
const plaintext = await decryptBinaryFromStorage(ciphertext, envelope, candidate.privateKeyPem);
|
|
255
|
+
logFileDecryptionTelemetry({
|
|
256
|
+
recordKeyId: envelope.keyId,
|
|
257
|
+
selectedKeyId: candidate.keyId,
|
|
258
|
+
attemptCount: index + 1,
|
|
259
|
+
outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
260
|
+
});
|
|
261
|
+
return plaintext;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
lastError = error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
logFileDecryptionTelemetry({
|
|
268
|
+
recordKeyId: envelope.keyId,
|
|
269
|
+
selectedKeyId: null,
|
|
270
|
+
attemptCount: candidates.length,
|
|
271
|
+
outcome: 'all-failed',
|
|
272
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Failed to decrypt stored file after ${candidates.length} key attempt(s): ${
|
|
277
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
278
|
+
}`
|
|
279
|
+
);
|
|
95
280
|
}
|
|
96
281
|
|
|
97
282
|
function parseFileId(pathname: string): string | null {
|
|
@@ -453,10 +638,10 @@ async function handleImageServing(request: Request, env: Env, fileId: string): P
|
|
|
453
638
|
}
|
|
454
639
|
|
|
455
640
|
const encryptedData = await file.arrayBuffer();
|
|
456
|
-
const plaintext = await
|
|
641
|
+
const plaintext = await decryptBinaryWithRegistry(
|
|
457
642
|
encryptedData,
|
|
458
643
|
envelope,
|
|
459
|
-
env
|
|
644
|
+
env
|
|
460
645
|
);
|
|
461
646
|
|
|
462
647
|
const contentType = file.customMetadata?.contentType || 'application/octet-stream';
|