@striae-org/striae 5.1.1 → 5.2.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 +20 -1
- package/app/utils/data/permissions.ts +4 -2
- package/package.json +4 -4
- 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 +336 -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
|
@@ -95,6 +95,46 @@ load_required_admin_service_credentials() {
|
|
|
95
95
|
|
|
96
96
|
load_required_admin_service_credentials
|
|
97
97
|
|
|
98
|
+
build_user_worker_secret_list() {
|
|
99
|
+
local secrets=(
|
|
100
|
+
"USER_DB_AUTH"
|
|
101
|
+
"R2_KEY_SECRET"
|
|
102
|
+
"IMAGES_API_TOKEN"
|
|
103
|
+
"DATA_WORKER_DOMAIN"
|
|
104
|
+
"IMAGES_WORKER_DOMAIN"
|
|
105
|
+
"PROJECT_ID"
|
|
106
|
+
"FIREBASE_SERVICE_ACCOUNT_EMAIL"
|
|
107
|
+
"FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if [ -n "${USER_KV_ENCRYPTION_PRIVATE_KEY:-}" ]; then
|
|
111
|
+
secrets+=("USER_KV_ENCRYPTION_PRIVATE_KEY")
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
if [ -n "${USER_KV_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
115
|
+
secrets+=("USER_KV_ENCRYPTION_KEYS_JSON")
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
if [ -n "${USER_KV_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
119
|
+
secrets+=("USER_KV_ENCRYPTION_ACTIVE_KEY_ID")
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
local write_endpoints_enabled_normalized
|
|
123
|
+
write_endpoints_enabled_normalized=$(printf '%s' "${USER_KV_WRITE_ENDPOINTS_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')
|
|
124
|
+
|
|
125
|
+
if [ "$write_endpoints_enabled_normalized" = "1" ] || [ "$write_endpoints_enabled_normalized" = "true" ] || [ "$write_endpoints_enabled_normalized" = "yes" ] || [ "$write_endpoints_enabled_normalized" = "on" ]; then
|
|
126
|
+
if [ -n "${USER_KV_ENCRYPTION_KEY_ID:-}" ]; then
|
|
127
|
+
secrets+=("USER_KV_ENCRYPTION_KEY_ID")
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
if [ -n "${USER_KV_ENCRYPTION_PUBLIC_KEY:-}" ]; then
|
|
131
|
+
secrets+=("USER_KV_ENCRYPTION_PUBLIC_KEY")
|
|
132
|
+
fi
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
printf '%s\n' "${secrets[@]}"
|
|
136
|
+
}
|
|
137
|
+
|
|
98
138
|
build_audit_worker_secret_list() {
|
|
99
139
|
local secrets=(
|
|
100
140
|
"R2_KEY_SECRET"
|
|
@@ -116,6 +156,14 @@ build_audit_worker_secret_list() {
|
|
|
116
156
|
secrets+=("DATA_AT_REST_ENCRYPTION_KEY_ID")
|
|
117
157
|
fi
|
|
118
158
|
|
|
159
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
160
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
164
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
|
|
165
|
+
fi
|
|
166
|
+
|
|
119
167
|
printf '%s\n' "${secrets[@]}"
|
|
120
168
|
}
|
|
121
169
|
|
|
@@ -192,6 +240,45 @@ build_data_worker_secret_list() {
|
|
|
192
240
|
secrets+=("DATA_AT_REST_ENCRYPTION_KEY_ID")
|
|
193
241
|
fi
|
|
194
242
|
|
|
243
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
244
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
|
|
245
|
+
fi
|
|
246
|
+
|
|
247
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
248
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
if [ -n "${EXPORT_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
252
|
+
secrets+=("EXPORT_ENCRYPTION_KEYS_JSON")
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
if [ -n "${EXPORT_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
256
|
+
secrets+=("EXPORT_ENCRYPTION_ACTIVE_KEY_ID")
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
printf '%s\n' "${secrets[@]}"
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
build_images_worker_secret_list() {
|
|
263
|
+
local secrets=(
|
|
264
|
+
"IMAGES_API_TOKEN"
|
|
265
|
+
"DATA_AT_REST_ENCRYPTION_PUBLIC_KEY"
|
|
266
|
+
"DATA_AT_REST_ENCRYPTION_KEY_ID"
|
|
267
|
+
"IMAGE_SIGNED_URL_SECRET"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_PRIVATE_KEY:-}" ]; then
|
|
271
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_PRIVATE_KEY")
|
|
272
|
+
fi
|
|
273
|
+
|
|
274
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
275
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
279
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
|
|
280
|
+
fi
|
|
281
|
+
|
|
195
282
|
printf '%s\n' "${secrets[@]}"
|
|
196
283
|
}
|
|
197
284
|
|
|
@@ -235,9 +322,13 @@ if ! set_worker_secrets "Keys Worker" "workers/keys-worker" \
|
|
|
235
322
|
echo -e "${YELLOW}⚠️ Skipping Keys Worker (not configured)${NC}"
|
|
236
323
|
fi
|
|
237
324
|
|
|
238
|
-
# User Worker
|
|
239
|
-
|
|
240
|
-
|
|
325
|
+
# User Worker
|
|
326
|
+
user_worker_secrets=()
|
|
327
|
+
while IFS= read -r secret; do
|
|
328
|
+
user_worker_secrets+=("$secret")
|
|
329
|
+
done < <(build_user_worker_secret_list)
|
|
330
|
+
|
|
331
|
+
if ! set_worker_secrets "User Worker" "workers/user-worker" "${user_worker_secrets[@]}"; then
|
|
241
332
|
echo -e "${YELLOW}⚠️ Skipping User Worker (not configured)${NC}"
|
|
242
333
|
fi
|
|
243
334
|
|
|
@@ -252,8 +343,12 @@ if ! set_worker_secrets "Data Worker" "workers/data-worker" "${data_worker_secre
|
|
|
252
343
|
fi
|
|
253
344
|
|
|
254
345
|
# Images Worker
|
|
255
|
-
|
|
256
|
-
|
|
346
|
+
images_worker_secrets=()
|
|
347
|
+
while IFS= read -r secret; do
|
|
348
|
+
images_worker_secrets+=("$secret")
|
|
349
|
+
done < <(build_images_worker_secret_list)
|
|
350
|
+
|
|
351
|
+
if ! set_worker_secrets "Images Worker" "workers/image-worker" "${images_worker_secrets[@]}"; then
|
|
257
352
|
echo -e "${YELLOW}⚠️ Skipping Images Worker (not configured)${NC}"
|
|
258
353
|
fi
|
|
259
354
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
|
-
// Generated by Wrangler by running `wrangler types` (hash:
|
|
3
|
-
// Runtime types generated with workerd@1.20250823.0 2026-03-
|
|
2
|
+
// Generated by Wrangler by running `wrangler types` (hash: 3fce96aefe167606678b821d5dbcc109)
|
|
3
|
+
// Runtime types generated with workerd@1.20250823.0 2026-03-25 nodejs_compat
|
|
4
4
|
declare namespace Cloudflare {
|
|
5
5
|
interface Env {
|
|
6
6
|
ACCOUNT_ID: string;
|
|
@@ -25,6 +25,9 @@ declare namespace Cloudflare {
|
|
|
25
25
|
USER_WORKER_NAME: string;
|
|
26
26
|
USER_WORKER_DOMAIN: string;
|
|
27
27
|
KV_STORE_ID: string;
|
|
28
|
+
USER_KV_ENCRYPTION_PRIVATE_KEY: string;
|
|
29
|
+
USER_KV_ENCRYPTION_KEY_ID: string;
|
|
30
|
+
USER_KV_ENCRYPTION_PUBLIC_KEY: string;
|
|
28
31
|
DATA_WORKER_NAME: string;
|
|
29
32
|
DATA_BUCKET_NAME: string;
|
|
30
33
|
FILES_BUCKET_NAME: string;
|
|
@@ -58,7 +61,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
|
|
58
61
|
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
|
59
62
|
};
|
|
60
63
|
declare namespace NodeJS {
|
|
61
|
-
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ACCOUNT_ID" | "USER_DB_AUTH" | "R2_KEY_SECRET" | "IMAGES_API_TOKEN" | "API_KEY" | "AUTH_DOMAIN" | "PROJECT_ID" | "STORAGE_BUCKET" | "MESSAGING_SENDER_ID" | "APP_ID" | "MEASUREMENT_ID" | "FIREBASE_SERVICE_ACCOUNT_EMAIL" | "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY" | "PAGES_PROJECT_NAME" | "PAGES_CUSTOM_DOMAIN" | "KEYS_WORKER_NAME" | "KEYS_WORKER_DOMAIN" | "KEYS_AUTH" | "ACCOUNT_HASH" | "USER_WORKER_NAME" | "USER_WORKER_DOMAIN" | "KV_STORE_ID" | "DATA_WORKER_NAME" | "DATA_BUCKET_NAME" | "FILES_BUCKET_NAME" | "DATA_WORKER_DOMAIN" | "MANIFEST_SIGNING_PRIVATE_KEY" | "MANIFEST_SIGNING_KEY_ID" | "MANIFEST_SIGNING_PUBLIC_KEY" | "EXPORT_ENCRYPTION_PRIVATE_KEY" | "EXPORT_ENCRYPTION_KEY_ID" | "EXPORT_ENCRYPTION_PUBLIC_KEY" | "DATA_AT_REST_ENCRYPTION_PRIVATE_KEY" | "DATA_AT_REST_ENCRYPTION_KEY_ID" | "DATA_AT_REST_ENCRYPTION_PUBLIC_KEY" | "AUDIT_WORKER_NAME" | "AUDIT_BUCKET_NAME" | "AUDIT_WORKER_DOMAIN" | "IMAGES_WORKER_NAME" | "IMAGES_WORKER_DOMAIN" | "IMAGE_SIGNED_URL_SECRET" | "IMAGE_SIGNED_URL_TTL_SECONDS" | "PDF_WORKER_NAME" | "PDF_WORKER_DOMAIN" | "PDF_WORKER_AUTH" | "BROWSER_API_TOKEN" | "PRIMERSHEAR_EMAILS" | "DATA_AT_REST_ENCRYPTION_ENABLED">> {}
|
|
64
|
+
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ACCOUNT_ID" | "USER_DB_AUTH" | "R2_KEY_SECRET" | "IMAGES_API_TOKEN" | "API_KEY" | "AUTH_DOMAIN" | "PROJECT_ID" | "STORAGE_BUCKET" | "MESSAGING_SENDER_ID" | "APP_ID" | "MEASUREMENT_ID" | "FIREBASE_SERVICE_ACCOUNT_EMAIL" | "FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY" | "PAGES_PROJECT_NAME" | "PAGES_CUSTOM_DOMAIN" | "KEYS_WORKER_NAME" | "KEYS_WORKER_DOMAIN" | "KEYS_AUTH" | "ACCOUNT_HASH" | "USER_WORKER_NAME" | "USER_WORKER_DOMAIN" | "KV_STORE_ID" | "USER_KV_ENCRYPTION_PRIVATE_KEY" | "USER_KV_ENCRYPTION_KEY_ID" | "USER_KV_ENCRYPTION_PUBLIC_KEY" | "DATA_WORKER_NAME" | "DATA_BUCKET_NAME" | "FILES_BUCKET_NAME" | "DATA_WORKER_DOMAIN" | "MANIFEST_SIGNING_PRIVATE_KEY" | "MANIFEST_SIGNING_KEY_ID" | "MANIFEST_SIGNING_PUBLIC_KEY" | "EXPORT_ENCRYPTION_PRIVATE_KEY" | "EXPORT_ENCRYPTION_KEY_ID" | "EXPORT_ENCRYPTION_PUBLIC_KEY" | "DATA_AT_REST_ENCRYPTION_PRIVATE_KEY" | "DATA_AT_REST_ENCRYPTION_KEY_ID" | "DATA_AT_REST_ENCRYPTION_PUBLIC_KEY" | "AUDIT_WORKER_NAME" | "AUDIT_BUCKET_NAME" | "AUDIT_WORKER_DOMAIN" | "IMAGES_WORKER_NAME" | "IMAGES_WORKER_DOMAIN" | "IMAGE_SIGNED_URL_SECRET" | "IMAGE_SIGNED_URL_TTL_SECONDS" | "PDF_WORKER_NAME" | "PDF_WORKER_DOMAIN" | "PDF_WORKER_AUTH" | "BROWSER_API_TOKEN" | "PRIMERSHEAR_EMAILS" | "DATA_AT_REST_ENCRYPTION_ENABLED">> {}
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
// Begin runtime types
|
|
@@ -5,8 +5,22 @@ interface Env {
|
|
|
5
5
|
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
|
|
6
6
|
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
|
|
7
7
|
DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
|
|
8
|
+
DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
|
|
9
|
+
DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
interface KeyRegistryPayload {
|
|
13
|
+
activeKeyId?: unknown;
|
|
14
|
+
keys?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PrivateKeyRegistry {
|
|
18
|
+
activeKeyId: string | null;
|
|
19
|
+
keys: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
23
|
+
|
|
10
24
|
interface AuditEntry {
|
|
11
25
|
timestamp: string;
|
|
12
26
|
userId: string;
|
|
@@ -58,6 +72,178 @@ const DATA_AT_REST_BACKFILL_PATH = '/api/admin/data-at-rest-backfill';
|
|
|
58
72
|
const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
59
73
|
const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
|
|
60
74
|
|
|
75
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
76
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
80
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
|
|
84
|
+
const keys: Record<string, string> = {};
|
|
85
|
+
const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
|
|
86
|
+
const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
|
|
87
|
+
|
|
88
|
+
if (registryJson) {
|
|
89
|
+
let parsedRegistry: unknown;
|
|
90
|
+
try {
|
|
91
|
+
parsedRegistry = JSON.parse(registryJson) as unknown;
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
97
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const payload = parsedRegistry as KeyRegistryPayload;
|
|
101
|
+
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
102
|
+
const rawKeys = payload.keys && typeof payload.keys === 'object'
|
|
103
|
+
? payload.keys as Record<string, unknown>
|
|
104
|
+
: parsedRegistry as Record<string, unknown>;
|
|
105
|
+
|
|
106
|
+
for (const [keyId, pemValue] of Object.entries(rawKeys)) {
|
|
107
|
+
if (keyId === 'activeKeyId' || keyId === 'keys') {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const normalizedKeyId = getNonEmptyString(keyId);
|
|
112
|
+
const normalizedPem = getNonEmptyString(pemValue);
|
|
113
|
+
if (!normalizedKeyId || !normalizedPem) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
121
|
+
|
|
122
|
+
if (Object.keys(keys).length === 0) {
|
|
123
|
+
throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
|
|
127
|
+
throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
activeKeyId: resolvedActiveKeyId ?? null,
|
|
132
|
+
keys
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
|
|
137
|
+
const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
|
|
138
|
+
if (!legacyKeyId || !legacyPrivateKey) {
|
|
139
|
+
throw new Error('Data-at-rest decryption key registry is not configured');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
activeKeyId: configuredActiveKeyId ?? legacyKeyId,
|
|
146
|
+
keys
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildPrivateKeyCandidates(
|
|
151
|
+
recordKeyId: string,
|
|
152
|
+
registry: PrivateKeyRegistry
|
|
153
|
+
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
154
|
+
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
155
|
+
const seen = new Set<string>();
|
|
156
|
+
|
|
157
|
+
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
158
|
+
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const privateKeyPem = registry.keys[candidateKeyId];
|
|
163
|
+
if (!privateKeyPem) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
seen.add(candidateKeyId);
|
|
168
|
+
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
appendCandidate(getNonEmptyString(recordKeyId));
|
|
172
|
+
appendCandidate(registry.activeKeyId);
|
|
173
|
+
|
|
174
|
+
for (const keyId of Object.keys(registry.keys)) {
|
|
175
|
+
appendCandidate(keyId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return candidates;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function logAuditDecryptionTelemetry(input: {
|
|
182
|
+
recordKeyId: string;
|
|
183
|
+
selectedKeyId: string | null;
|
|
184
|
+
attemptCount: number;
|
|
185
|
+
outcome: DecryptionTelemetryOutcome;
|
|
186
|
+
reason?: string;
|
|
187
|
+
}): void {
|
|
188
|
+
const details = {
|
|
189
|
+
scope: 'audit-at-rest',
|
|
190
|
+
recordKeyId: input.recordKeyId,
|
|
191
|
+
selectedKeyId: input.selectedKeyId,
|
|
192
|
+
attemptCount: input.attemptCount,
|
|
193
|
+
fallbackUsed: input.outcome === 'fallback-hit',
|
|
194
|
+
outcome: input.outcome,
|
|
195
|
+
reason: input.reason ?? null
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (input.outcome === 'all-failed') {
|
|
199
|
+
console.warn('Key registry decryption failed', details);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.info('Key registry decryption resolved', details);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function decryptAuditJsonWithRegistry(
|
|
207
|
+
ciphertext: ArrayBuffer,
|
|
208
|
+
envelope: DataAtRestEnvelope,
|
|
209
|
+
env: Env
|
|
210
|
+
): Promise<string> {
|
|
211
|
+
const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
|
|
212
|
+
const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
|
|
213
|
+
const primaryKeyId = candidates[0]?.keyId ?? null;
|
|
214
|
+
let lastError: unknown;
|
|
215
|
+
|
|
216
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
217
|
+
const candidate = candidates[index];
|
|
218
|
+
try {
|
|
219
|
+
const plaintext = await decryptJsonFromStorage(ciphertext, envelope, candidate.privateKeyPem);
|
|
220
|
+
logAuditDecryptionTelemetry({
|
|
221
|
+
recordKeyId: envelope.keyId,
|
|
222
|
+
selectedKeyId: candidate.keyId,
|
|
223
|
+
attemptCount: index + 1,
|
|
224
|
+
outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
225
|
+
});
|
|
226
|
+
return plaintext;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
lastError = error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
logAuditDecryptionTelemetry({
|
|
233
|
+
recordKeyId: envelope.keyId,
|
|
234
|
+
selectedKeyId: null,
|
|
235
|
+
attemptCount: candidates.length,
|
|
236
|
+
outcome: 'all-failed',
|
|
237
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Failed to decrypt audit record after ${candidates.length} key attempt(s): ${
|
|
242
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
243
|
+
}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
61
247
|
function isDataAtRestEncryptionEnabled(env: Env): boolean {
|
|
62
248
|
const value = env.DATA_AT_REST_ENCRYPTION_ENABLED;
|
|
63
249
|
if (!value) {
|
|
@@ -357,15 +543,11 @@ async function readAuditEntriesFromObject(file: R2ObjectBody, env: Env): Promise
|
|
|
357
543
|
throw new Error('Unsupported data-at-rest encryption version');
|
|
358
544
|
}
|
|
359
545
|
|
|
360
|
-
if (!env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY) {
|
|
361
|
-
throw new Error('Data-at-rest decryption is not configured on this server');
|
|
362
|
-
}
|
|
363
|
-
|
|
364
546
|
const encryptedData = await file.arrayBuffer();
|
|
365
|
-
const plaintext = await
|
|
547
|
+
const plaintext = await decryptAuditJsonWithRegistry(
|
|
366
548
|
encryptedData,
|
|
367
549
|
atRestEnvelope,
|
|
368
|
-
env
|
|
550
|
+
env
|
|
369
551
|
);
|
|
370
552
|
|
|
371
553
|
return JSON.parse(plaintext) as AuditEntry[];
|