@striae-org/striae 7.1.2 → 8.0.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 +18 -1
- package/app/components/actions/case-manage/operations.ts +2 -1
- package/app/routes/striae/utils/case-export.ts +1 -17
- package/app/utils/data/operations/case-export-loader.ts +17 -0
- package/app/utils/forensics/signature-utils.ts +110 -44
- package/functions/[[path]].ts +2 -1
- package/load-context.ts +13 -1
- package/package.json +23 -21
- package/public/_routes.json +1 -0
- package/react-router.config.ts +7 -0
- package/scripts/deploy-all.sh +28 -14
- package/scripts/deploy-config/modules/env-utils.sh +10 -1
- package/scripts/deploy-config/modules/keys.sh +33 -4
- package/scripts/deploy-config/modules/prompt.sh +2 -0
- package/scripts/deploy-config/modules/scaffolding.sh +32 -14
- package/scripts/deploy-config/modules/validation.sh +6 -1
- package/scripts/deploy-worker-secrets.sh +20 -50
- package/scripts/encrypt-registry.mjs +104 -0
- package/scripts/upload-registries.sh +146 -0
- package/shared/registry/r2-key-registry.ts +161 -0
- package/shared/registry/registry-encryption.ts +112 -0
- package/vite.config.ts +2 -1
- package/workers/audit-worker/package.json +2 -2
- package/workers/audit-worker/src/crypto/data-at-rest.ts +9 -72
- package/workers/audit-worker/src/types.ts +2 -0
- package/workers/audit-worker/wrangler.jsonc.example +5 -1
- package/workers/data-worker/package.json +2 -2
- package/workers/data-worker/src/handlers/decrypt-export.ts +1 -1
- package/workers/data-worker/src/handlers/signing.ts +5 -2
- package/workers/data-worker/src/registry/key-registry.ts +43 -101
- package/workers/data-worker/src/types.ts +6 -2
- package/workers/data-worker/wrangler.jsonc.example +5 -1
- package/workers/image-worker/package.json +2 -2
- package/workers/image-worker/src/security/key-registry.ts +13 -74
- package/workers/image-worker/src/types.ts +2 -0
- package/workers/image-worker/wrangler.jsonc.example +5 -1
- package/workers/lists-worker/package.json +2 -2
- package/workers/lists-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +2 -2
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +2 -2
- package/workers/user-worker/src/cleanup/account-deletion.ts +9 -74
- package/workers/user-worker/src/registry/user-kv.ts +8 -65
- package/workers/user-worker/src/storage/user-records.ts +1 -1
- package/workers/user-worker/src/types.ts +2 -0
- package/workers/user-worker/wrangler.jsonc.example +5 -1
- package/wrangler.toml.example +1 -1
|
@@ -154,6 +154,7 @@ update_wrangler_configs() {
|
|
|
154
154
|
sed -i "s/\"AUDIT_WORKER_NAME\"/\"$AUDIT_WORKER_NAME\"/g" workers/audit-worker/wrangler.jsonc
|
|
155
155
|
sed -i "s/\"ACCOUNT_ID\"/\"$escaped_account_id\"/g" workers/audit-worker/wrangler.jsonc
|
|
156
156
|
sed -i "s/\"AUDIT_BUCKET_NAME\"/\"$AUDIT_BUCKET_NAME\"/g" workers/audit-worker/wrangler.jsonc
|
|
157
|
+
sed -i "s/\"CONFIG_BUCKET_NAME\"/\"$CONFIG_BUCKET_NAME\"/g" workers/audit-worker/wrangler.jsonc
|
|
157
158
|
echo -e "${GREEN} ✅ audit-worker configuration updated${NC}"
|
|
158
159
|
fi
|
|
159
160
|
|
|
@@ -162,6 +163,7 @@ update_wrangler_configs() {
|
|
|
162
163
|
sed -i "s/\"DATA_WORKER_NAME\"/\"$DATA_WORKER_NAME\"/g" workers/data-worker/wrangler.jsonc
|
|
163
164
|
sed -i "s/\"ACCOUNT_ID\"/\"$escaped_account_id\"/g" workers/data-worker/wrangler.jsonc
|
|
164
165
|
sed -i "s/\"DATA_BUCKET_NAME\"/\"$DATA_BUCKET_NAME\"/g" workers/data-worker/wrangler.jsonc
|
|
166
|
+
sed -i "s/\"CONFIG_BUCKET_NAME\"/\"$CONFIG_BUCKET_NAME\"/g" workers/data-worker/wrangler.jsonc
|
|
165
167
|
echo -e "${GREEN} ✅ data-worker configuration updated${NC}"
|
|
166
168
|
fi
|
|
167
169
|
|
|
@@ -170,6 +172,7 @@ update_wrangler_configs() {
|
|
|
170
172
|
sed -i "s/\"IMAGES_WORKER_NAME\"/\"$IMAGES_WORKER_NAME\"/g" workers/image-worker/wrangler.jsonc
|
|
171
173
|
sed -i "s/\"ACCOUNT_ID\"/\"$escaped_account_id\"/g" workers/image-worker/wrangler.jsonc
|
|
172
174
|
sed -i "s/\"FILES_BUCKET_NAME\"/\"$FILES_BUCKET_NAME\"/g" workers/image-worker/wrangler.jsonc
|
|
175
|
+
sed -i "s/\"CONFIG_BUCKET_NAME\"/\"$CONFIG_BUCKET_NAME\"/g" workers/image-worker/wrangler.jsonc
|
|
173
176
|
echo -e "${GREEN} ✅ image-worker configuration updated${NC}"
|
|
174
177
|
fi
|
|
175
178
|
|
|
@@ -197,6 +200,7 @@ update_wrangler_configs() {
|
|
|
197
200
|
sed -i "s/\"KV_STORE_ID\"/\"$KV_STORE_ID\"/g" workers/user-worker/wrangler.jsonc
|
|
198
201
|
sed -i "s/\"DATA_BUCKET_NAME\"/\"$DATA_BUCKET_NAME\"/g" workers/user-worker/wrangler.jsonc
|
|
199
202
|
sed -i "s/\"FILES_BUCKET_NAME\"/\"$FILES_BUCKET_NAME\"/g" workers/user-worker/wrangler.jsonc
|
|
203
|
+
sed -i "s/\"CONFIG_BUCKET_NAME\"/\"$CONFIG_BUCKET_NAME\"/g" workers/user-worker/wrangler.jsonc
|
|
200
204
|
echo -e "${GREEN} ✅ user-worker configuration updated${NC}"
|
|
201
205
|
fi
|
|
202
206
|
|
|
@@ -216,20 +220,34 @@ update_wrangler_configs() {
|
|
|
216
220
|
|
|
217
221
|
if [ -f "app/config/config.json" ]; then
|
|
218
222
|
echo -e "${YELLOW} Updating app/config/config.json...${NC}"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
223
|
+
if ! node -e "
|
|
224
|
+
const fs = require('fs');
|
|
225
|
+
const path = process.argv[1];
|
|
226
|
+
const config = JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
227
|
+
|
|
228
|
+
config.url = 'https://' + process.env.PAGES_CUSTOM_DOMAIN;
|
|
229
|
+
|
|
230
|
+
config.manifest_signing_key_id = process.env.MANIFEST_SIGNING_KEY_ID;
|
|
231
|
+
config.manifest_signing_public_key = process.env.MANIFEST_SIGNING_PUBLIC_KEY.replace(/\\\\n/g, '\n');
|
|
232
|
+
|
|
233
|
+
if (!config.manifest_signing_public_keys || typeof config.manifest_signing_public_keys !== 'object') {
|
|
234
|
+
config.manifest_signing_public_keys = {};
|
|
235
|
+
}
|
|
236
|
+
config.manifest_signing_public_keys[process.env.MANIFEST_SIGNING_KEY_ID] = config.manifest_signing_public_key;
|
|
237
|
+
|
|
238
|
+
config.export_encryption_key_id = process.env.EXPORT_ENCRYPTION_KEY_ID;
|
|
239
|
+
config.export_encryption_public_key = process.env.EXPORT_ENCRYPTION_PUBLIC_KEY.replace(/\\\\n/g, '\n');
|
|
240
|
+
|
|
241
|
+
if (!config.export_encryption_public_keys || typeof config.export_encryption_public_keys !== 'object') {
|
|
242
|
+
config.export_encryption_public_keys = {};
|
|
243
|
+
}
|
|
244
|
+
config.export_encryption_public_keys[process.env.EXPORT_ENCRYPTION_KEY_ID] = config.export_encryption_public_key;
|
|
245
|
+
|
|
246
|
+
fs.writeFileSync(path, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
247
|
+
" "app/config/config.json"; then
|
|
248
|
+
echo -e "${RED}❌ Error: Failed to update app/config/config.json${NC}"
|
|
249
|
+
exit 1
|
|
250
|
+
fi
|
|
233
251
|
echo -e "${GREEN} ✅ app config.json updated${NC}"
|
|
234
252
|
fi
|
|
235
253
|
|
|
@@ -102,6 +102,7 @@ required_vars=(
|
|
|
102
102
|
"DATA_BUCKET_NAME"
|
|
103
103
|
"AUDIT_BUCKET_NAME"
|
|
104
104
|
"FILES_BUCKET_NAME"
|
|
105
|
+
"CONFIG_BUCKET_NAME"
|
|
105
106
|
"KV_STORE_ID"
|
|
106
107
|
"STRIAE_LISTS_KV_ID"
|
|
107
108
|
|
|
@@ -279,11 +280,15 @@ validate_generated_configs() {
|
|
|
279
280
|
assert_contains_literal "workers/pdf-worker/wrangler.jsonc" "$ACCOUNT_ID" "ACCOUNT_ID missing in pdf worker config"
|
|
280
281
|
|
|
281
282
|
assert_contains_literal "workers/data-worker/wrangler.jsonc" "$DATA_BUCKET_NAME" "DATA_BUCKET_NAME missing in data worker config"
|
|
283
|
+
assert_contains_literal "workers/data-worker/wrangler.jsonc" "$CONFIG_BUCKET_NAME" "CONFIG_BUCKET_NAME missing in data worker config"
|
|
282
284
|
assert_contains_literal "workers/audit-worker/wrangler.jsonc" "$AUDIT_BUCKET_NAME" "AUDIT_BUCKET_NAME missing in audit worker config"
|
|
285
|
+
assert_contains_literal "workers/audit-worker/wrangler.jsonc" "$CONFIG_BUCKET_NAME" "CONFIG_BUCKET_NAME missing in audit worker config"
|
|
283
286
|
assert_contains_literal "workers/image-worker/wrangler.jsonc" "$FILES_BUCKET_NAME" "FILES_BUCKET_NAME missing in image worker config"
|
|
287
|
+
assert_contains_literal "workers/image-worker/wrangler.jsonc" "$CONFIG_BUCKET_NAME" "CONFIG_BUCKET_NAME missing in image worker config"
|
|
284
288
|
assert_contains_literal "workers/user-worker/wrangler.jsonc" "$KV_STORE_ID" "KV_STORE_ID missing in user worker config"
|
|
285
289
|
assert_contains_literal "workers/user-worker/wrangler.jsonc" "$DATA_BUCKET_NAME" "DATA_BUCKET_NAME missing in user worker config"
|
|
286
290
|
assert_contains_literal "workers/user-worker/wrangler.jsonc" "$FILES_BUCKET_NAME" "FILES_BUCKET_NAME missing in user worker config"
|
|
291
|
+
assert_contains_literal "workers/user-worker/wrangler.jsonc" "$CONFIG_BUCKET_NAME" "CONFIG_BUCKET_NAME missing in user worker config"
|
|
287
292
|
|
|
288
293
|
assert_contains_literal "app/config/config.json" "https://$PAGES_CUSTOM_DOMAIN" "PAGES_CUSTOM_DOMAIN missing in app/config/config.json"
|
|
289
294
|
assert_contains_literal "app/config/config.json" "$EXPORT_ENCRYPTION_KEY_ID" "EXPORT_ENCRYPTION_KEY_ID missing in app/config/config.json"
|
|
@@ -298,7 +303,7 @@ validate_generated_configs() {
|
|
|
298
303
|
assert_contains_literal "app/config/firebase.ts" "$MEASUREMENT_ID" "MEASUREMENT_ID missing in app/config/firebase.ts"
|
|
299
304
|
|
|
300
305
|
local placeholder_pattern
|
|
301
|
-
placeholder_pattern="(\"(ACCOUNT_ID|PAGES_PROJECT_NAME|PAGES_CUSTOM_DOMAIN|USER_WORKER_NAME|DATA_WORKER_NAME|AUDIT_WORKER_NAME|IMAGES_WORKER_NAME|PDF_WORKER_NAME|DATA_BUCKET_NAME|AUDIT_BUCKET_NAME|FILES_BUCKET_NAME|KV_STORE_ID|MANIFEST_SIGNING_KEY_ID|MANIFEST_SIGNING_PUBLIC_KEY|EXPORT_ENCRYPTION_KEY_ID|EXPORT_ENCRYPTION_PUBLIC_KEY|YOUR_FIREBASE_API_KEY|YOUR_FIREBASE_AUTH_DOMAIN|YOUR_FIREBASE_PROJECT_ID|YOUR_FIREBASE_STORAGE_BUCKET|YOUR_FIREBASE_MESSAGING_SENDER_ID|YOUR_FIREBASE_APP_ID|YOUR_FIREBASE_MEASUREMENT_ID)\")"
|
|
306
|
+
placeholder_pattern="(\"(ACCOUNT_ID|PAGES_PROJECT_NAME|PAGES_CUSTOM_DOMAIN|USER_WORKER_NAME|DATA_WORKER_NAME|AUDIT_WORKER_NAME|IMAGES_WORKER_NAME|PDF_WORKER_NAME|DATA_BUCKET_NAME|AUDIT_BUCKET_NAME|FILES_BUCKET_NAME|CONFIG_BUCKET_NAME|KV_STORE_ID|MANIFEST_SIGNING_KEY_ID|MANIFEST_SIGNING_PUBLIC_KEY|EXPORT_ENCRYPTION_KEY_ID|EXPORT_ENCRYPTION_PUBLIC_KEY|YOUR_FIREBASE_API_KEY|YOUR_FIREBASE_AUTH_DOMAIN|YOUR_FIREBASE_PROJECT_ID|YOUR_FIREBASE_STORAGE_BUCKET|YOUR_FIREBASE_MESSAGING_SENDER_ID|YOUR_FIREBASE_APP_ID|YOUR_FIREBASE_MEASUREMENT_ID)\")"
|
|
302
307
|
|
|
303
308
|
local files_to_scan=(
|
|
304
309
|
"wrangler.toml"
|
|
@@ -100,19 +100,11 @@ build_user_worker_secret_list() {
|
|
|
100
100
|
"PROJECT_ID"
|
|
101
101
|
"FIREBASE_SERVICE_ACCOUNT_EMAIL"
|
|
102
102
|
"FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY"
|
|
103
|
+
"REGISTRY_ENCRYPTION_KEY"
|
|
103
104
|
)
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
fi
|
|
108
|
-
|
|
109
|
-
if [ -n "${DATA_AT_REST_ENCRYPTION_KEY_ID:-}" ]; then
|
|
110
|
-
secrets+=("DATA_AT_REST_ENCRYPTION_KEY_ID")
|
|
111
|
-
fi
|
|
112
|
-
|
|
113
|
-
if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
114
|
-
secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
|
|
115
|
-
fi
|
|
106
|
+
# DATA_AT_REST_ENCRYPTION_PRIVATE_KEY and KEY_ID are now fetched from
|
|
107
|
+
# encrypted R2 registries; only the active key ID override is needed.
|
|
116
108
|
|
|
117
109
|
if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
118
110
|
secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
|
|
@@ -122,10 +114,6 @@ build_user_worker_secret_list() {
|
|
|
122
114
|
secrets+=("USER_KV_ENCRYPTION_PRIVATE_KEY")
|
|
123
115
|
fi
|
|
124
116
|
|
|
125
|
-
if [ -n "${USER_KV_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
126
|
-
secrets+=("USER_KV_ENCRYPTION_KEYS_JSON")
|
|
127
|
-
fi
|
|
128
|
-
|
|
129
117
|
if [ -n "${USER_KV_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
130
118
|
secrets+=("USER_KV_ENCRYPTION_ACTIVE_KEY_ID")
|
|
131
119
|
fi
|
|
@@ -147,15 +135,12 @@ build_user_worker_secret_list() {
|
|
|
147
135
|
}
|
|
148
136
|
|
|
149
137
|
build_audit_worker_secret_list() {
|
|
150
|
-
local secrets=(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
secrets+=("DATA_AT_REST_ENCRYPTION_ENABLED")
|
|
154
|
-
fi
|
|
138
|
+
local secrets=(
|
|
139
|
+
"REGISTRY_ENCRYPTION_KEY"
|
|
140
|
+
)
|
|
155
141
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
fi
|
|
142
|
+
# Private keys are now fetched from encrypted R2 registries.
|
|
143
|
+
# DATA_AT_REST_ENCRYPTION_ENABLED is not checked in audit-worker code.
|
|
159
144
|
|
|
160
145
|
if [ -n "${DATA_AT_REST_ENCRYPTION_PUBLIC_KEY:-}" ]; then
|
|
161
146
|
secrets+=("DATA_AT_REST_ENCRYPTION_PUBLIC_KEY")
|
|
@@ -165,10 +150,6 @@ build_audit_worker_secret_list() {
|
|
|
165
150
|
secrets+=("DATA_AT_REST_ENCRYPTION_KEY_ID")
|
|
166
151
|
fi
|
|
167
152
|
|
|
168
|
-
if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
169
|
-
secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
|
|
170
|
-
fi
|
|
171
|
-
|
|
172
153
|
if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
173
154
|
secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
|
|
174
155
|
fi
|
|
@@ -226,18 +207,20 @@ set_worker_secrets() {
|
|
|
226
207
|
|
|
227
208
|
build_data_worker_secret_list() {
|
|
228
209
|
local secrets=(
|
|
229
|
-
"
|
|
230
|
-
"MANIFEST_SIGNING_KEY_ID"
|
|
231
|
-
"EXPORT_ENCRYPTION_PRIVATE_KEY"
|
|
232
|
-
"EXPORT_ENCRYPTION_KEY_ID"
|
|
210
|
+
"REGISTRY_ENCRYPTION_KEY"
|
|
233
211
|
)
|
|
234
212
|
|
|
235
|
-
|
|
236
|
-
|
|
213
|
+
# Private keys and key IDs for manifest signing, export encryption, and
|
|
214
|
+
# data-at-rest are now fetched from encrypted R2 registries via
|
|
215
|
+
# fetchKeyRegistryFromR2(). Only active-key-ID overrides and the
|
|
216
|
+
# registry encryption key are needed as secrets.
|
|
217
|
+
|
|
218
|
+
if [ -n "${MANIFEST_SIGNING_ACTIVE_KEY_ID:-}" ]; then
|
|
219
|
+
secrets+=("MANIFEST_SIGNING_ACTIVE_KEY_ID")
|
|
237
220
|
fi
|
|
238
221
|
|
|
239
|
-
if [ -n "${
|
|
240
|
-
secrets+=("
|
|
222
|
+
if [ -n "${DATA_AT_REST_ENCRYPTION_ENABLED:-}" ]; then
|
|
223
|
+
secrets+=("DATA_AT_REST_ENCRYPTION_ENABLED")
|
|
241
224
|
fi
|
|
242
225
|
|
|
243
226
|
if [ -n "${DATA_AT_REST_ENCRYPTION_PUBLIC_KEY:-}" ]; then
|
|
@@ -248,18 +231,10 @@ build_data_worker_secret_list() {
|
|
|
248
231
|
secrets+=("DATA_AT_REST_ENCRYPTION_KEY_ID")
|
|
249
232
|
fi
|
|
250
233
|
|
|
251
|
-
if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
252
|
-
secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
|
|
253
|
-
fi
|
|
254
|
-
|
|
255
234
|
if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
256
235
|
secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
|
|
257
236
|
fi
|
|
258
237
|
|
|
259
|
-
if [ -n "${EXPORT_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
260
|
-
secrets+=("EXPORT_ENCRYPTION_KEYS_JSON")
|
|
261
|
-
fi
|
|
262
|
-
|
|
263
238
|
if [ -n "${EXPORT_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
264
239
|
secrets+=("EXPORT_ENCRYPTION_ACTIVE_KEY_ID")
|
|
265
240
|
fi
|
|
@@ -279,15 +254,10 @@ build_images_worker_secret_list() {
|
|
|
279
254
|
"DATA_AT_REST_ENCRYPTION_PUBLIC_KEY"
|
|
280
255
|
"DATA_AT_REST_ENCRYPTION_KEY_ID"
|
|
281
256
|
"IMAGE_SIGNED_URL_SECRET"
|
|
257
|
+
"REGISTRY_ENCRYPTION_KEY"
|
|
282
258
|
)
|
|
283
259
|
|
|
284
|
-
|
|
285
|
-
secrets+=("DATA_AT_REST_ENCRYPTION_PRIVATE_KEY")
|
|
286
|
-
fi
|
|
287
|
-
|
|
288
|
-
if [ -n "${DATA_AT_REST_ENCRYPTION_KEYS_JSON:-}" ]; then
|
|
289
|
-
secrets+=("DATA_AT_REST_ENCRYPTION_KEYS_JSON")
|
|
290
|
-
fi
|
|
260
|
+
# Private keys are now fetched from encrypted R2 registries.
|
|
291
261
|
|
|
292
262
|
if [ -n "${DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID:-}" ]; then
|
|
293
263
|
secrets+=("DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Encrypts a key registry JSON file with AES-256-GCM for R2 storage.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/encrypt-registry.mjs <input-file> [output-file]
|
|
8
|
+
*
|
|
9
|
+
* Reads REGISTRY_ENCRYPTION_KEY from environment (base64-encoded 32 bytes).
|
|
10
|
+
* If output-file is omitted, writes to stdout.
|
|
11
|
+
*
|
|
12
|
+
* The output envelope matches the format expected by
|
|
13
|
+
* shared/registry/registry-encryption.ts (decryptRegistryJson).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createCipheriv, randomBytes } from 'node:crypto';
|
|
17
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
function base64UrlEncode(buffer) {
|
|
20
|
+
return buffer
|
|
21
|
+
.toString('base64')
|
|
22
|
+
.replace(/\+/g, '-')
|
|
23
|
+
.replace(/\//g, '_')
|
|
24
|
+
.replace(/=+$/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function encryptRegistry(plaintextJson, keyBase64) {
|
|
28
|
+
// Decode key (accept both standard base64 and base64url)
|
|
29
|
+
const keyBuffer = Buffer.from(
|
|
30
|
+
keyBase64.replace(/-/g, '+').replace(/_/g, '/'),
|
|
31
|
+
'base64'
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (keyBuffer.length !== 32) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`REGISTRY_ENCRYPTION_KEY must decode to 32 bytes, got ${keyBuffer.length}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const iv = randomBytes(12);
|
|
41
|
+
const cipher = createCipheriv('aes-256-gcm', keyBuffer, iv);
|
|
42
|
+
|
|
43
|
+
const encrypted = Buffer.concat([
|
|
44
|
+
cipher.update(plaintextJson, 'utf8'),
|
|
45
|
+
cipher.final()
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const authTag = cipher.getAuthTag();
|
|
49
|
+
|
|
50
|
+
// AES-GCM ciphertext in WebCrypto includes the auth tag appended
|
|
51
|
+
const ciphertextWithTag = Buffer.concat([encrypted, authTag]);
|
|
52
|
+
|
|
53
|
+
return JSON.stringify(
|
|
54
|
+
{
|
|
55
|
+
encrypted: true,
|
|
56
|
+
algorithm: 'AES-256-GCM',
|
|
57
|
+
version: '1.0',
|
|
58
|
+
iv: base64UrlEncode(iv),
|
|
59
|
+
ciphertext: base64UrlEncode(ciphertextWithTag)
|
|
60
|
+
},
|
|
61
|
+
null,
|
|
62
|
+
2
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Main ---
|
|
67
|
+
|
|
68
|
+
const inputFile = process.argv[2];
|
|
69
|
+
const outputFile = process.argv[3];
|
|
70
|
+
|
|
71
|
+
if (!inputFile) {
|
|
72
|
+
console.error('Usage: node scripts/encrypt-registry.mjs <input-file> [output-file]');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const keyBase64 = process.env.REGISTRY_ENCRYPTION_KEY;
|
|
77
|
+
if (!keyBase64) {
|
|
78
|
+
console.error('Error: REGISTRY_ENCRYPTION_KEY environment variable is not set');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let plaintext;
|
|
83
|
+
try {
|
|
84
|
+
plaintext = readFileSync(inputFile, 'utf8');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`Error reading input file: ${err.message}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Validate that input is valid JSON
|
|
91
|
+
try {
|
|
92
|
+
JSON.parse(plaintext);
|
|
93
|
+
} catch {
|
|
94
|
+
console.error('Error: input file is not valid JSON');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const envelope = encryptRegistry(plaintext, keyBase64);
|
|
99
|
+
|
|
100
|
+
if (outputFile) {
|
|
101
|
+
writeFileSync(outputFile, envelope, 'utf8');
|
|
102
|
+
} else {
|
|
103
|
+
process.stdout.write(envelope);
|
|
104
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# ===================================
|
|
4
|
+
# UPLOAD KEY REGISTRIES TO R2
|
|
5
|
+
# ===================================
|
|
6
|
+
# Extracts key registry JSON from .env and uploads them to the
|
|
7
|
+
# R2 config bucket (CONFIG_BUCKET_NAME) as separate files per scope.
|
|
8
|
+
#
|
|
9
|
+
# Usage: bash ./scripts/upload-registries.sh [--dry-run]
|
|
10
|
+
|
|
11
|
+
set -e
|
|
12
|
+
set -o pipefail
|
|
13
|
+
|
|
14
|
+
RED='\033[0;31m'
|
|
15
|
+
GREEN='\033[0;32m'
|
|
16
|
+
YELLOW='\033[1;33m'
|
|
17
|
+
BLUE='\033[0;34m'
|
|
18
|
+
NC='\033[0m'
|
|
19
|
+
|
|
20
|
+
echo -e "${BLUE}📦 Upload Key Registries to R2${NC}"
|
|
21
|
+
echo "================================"
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
25
|
+
cd "$PROJECT_ROOT"
|
|
26
|
+
|
|
27
|
+
dry_run=false
|
|
28
|
+
for arg in "$@"; do
|
|
29
|
+
case "$arg" in
|
|
30
|
+
--dry-run)
|
|
31
|
+
dry_run=true
|
|
32
|
+
;;
|
|
33
|
+
-h|--help)
|
|
34
|
+
echo "Usage: bash ./scripts/upload-registries.sh [--dry-run]"
|
|
35
|
+
echo ""
|
|
36
|
+
echo "Extracts key registries from .env and uploads them to R2."
|
|
37
|
+
echo ""
|
|
38
|
+
echo "Options:"
|
|
39
|
+
echo " --dry-run Show what would be uploaded without actually uploading"
|
|
40
|
+
echo " -h, --help Show this help message"
|
|
41
|
+
exit 0
|
|
42
|
+
;;
|
|
43
|
+
*)
|
|
44
|
+
echo -e "${RED}❌ Unknown option: $arg${NC}"
|
|
45
|
+
exit 1
|
|
46
|
+
;;
|
|
47
|
+
esac
|
|
48
|
+
done
|
|
49
|
+
|
|
50
|
+
# Source .env
|
|
51
|
+
if [ ! -f ".env" ]; then
|
|
52
|
+
echo -e "${RED}❌ .env file not found. Run deploy-config.sh first.${NC}"
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
set -a
|
|
57
|
+
# shellcheck disable=SC1091
|
|
58
|
+
source .env
|
|
59
|
+
set +a
|
|
60
|
+
|
|
61
|
+
if [ -z "${CONFIG_BUCKET_NAME:-}" ]; then
|
|
62
|
+
echo -e "${RED}❌ CONFIG_BUCKET_NAME is not set in .env${NC}"
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
if [ -z "${REGISTRY_ENCRYPTION_KEY:-}" ]; then
|
|
67
|
+
echo -e "${RED}❌ REGISTRY_ENCRYPTION_KEY is not set in .env${NC}"
|
|
68
|
+
echo -e "${YELLOW} Run deploy-config.sh to generate it.${NC}"
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
export REGISTRY_ENCRYPTION_KEY
|
|
73
|
+
|
|
74
|
+
echo -e "${YELLOW} Target bucket: ${CONFIG_BUCKET_NAME}${NC}"
|
|
75
|
+
|
|
76
|
+
TEMP_DIR=$(mktemp -d)
|
|
77
|
+
trap 'rm -rf "$TEMP_DIR"' EXIT
|
|
78
|
+
|
|
79
|
+
uploaded=0
|
|
80
|
+
skipped=0
|
|
81
|
+
|
|
82
|
+
upload_registry() {
|
|
83
|
+
local env_var_name=$1
|
|
84
|
+
local filename=$2
|
|
85
|
+
local scope_label=$3
|
|
86
|
+
|
|
87
|
+
local value="${!env_var_name:-}"
|
|
88
|
+
|
|
89
|
+
if [ -z "$value" ] || [ "$value" = "'{}'" ] || [ "$value" = "{}" ]; then
|
|
90
|
+
echo -e "${YELLOW} ⏭️ Skipping ${scope_label}: ${env_var_name} is empty or placeholder${NC}"
|
|
91
|
+
skipped=$((skipped + 1))
|
|
92
|
+
return
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
# Strip outer single quotes if present (from .env quoting)
|
|
96
|
+
value="${value#\'}"
|
|
97
|
+
value="${value%\'}"
|
|
98
|
+
|
|
99
|
+
# Validate JSON
|
|
100
|
+
if ! echo "$value" | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{JSON.parse(d); process.exit(0)})" 2>/dev/null; then
|
|
101
|
+
echo -e "${RED} ❌ ${scope_label}: ${env_var_name} is not valid JSON, skipping${NC}"
|
|
102
|
+
skipped=$((skipped + 1))
|
|
103
|
+
return
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
local filepath="${TEMP_DIR}/${filename}"
|
|
107
|
+
printf '%s' "$value" > "$filepath"
|
|
108
|
+
|
|
109
|
+
# Encrypt the registry before upload
|
|
110
|
+
local encrypted_filepath="${TEMP_DIR}/encrypted-${filename}"
|
|
111
|
+
if ! node "${SCRIPT_DIR}/encrypt-registry.mjs" "$filepath" "$encrypted_filepath"; then
|
|
112
|
+
echo -e "${RED} ❌ ${scope_label}: encryption failed, skipping${NC}"
|
|
113
|
+
skipped=$((skipped + 1))
|
|
114
|
+
return
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
local size
|
|
118
|
+
size=$(wc -c < "$encrypted_filepath" | tr -d ' ')
|
|
119
|
+
|
|
120
|
+
if [ "$dry_run" = "true" ]; then
|
|
121
|
+
echo -e "${BLUE} [dry-run] Would upload ${scope_label}: ${filename} (${size} bytes, encrypted)${NC}"
|
|
122
|
+
else
|
|
123
|
+
echo -e "${YELLOW} Uploading ${scope_label}: ${filename} (${size} bytes, encrypted)...${NC}"
|
|
124
|
+
if wrangler r2 object put "${CONFIG_BUCKET_NAME}/${filename}" --file "$encrypted_filepath" --content-type "application/json" --remote 2>/dev/null; then
|
|
125
|
+
echo -e "${GREEN} ✅ ${filename} uploaded${NC}"
|
|
126
|
+
uploaded=$((uploaded + 1))
|
|
127
|
+
else
|
|
128
|
+
echo -e "${RED} ❌ Failed to upload ${filename}${NC}"
|
|
129
|
+
exit 1
|
|
130
|
+
fi
|
|
131
|
+
fi
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
echo ""
|
|
135
|
+
|
|
136
|
+
upload_registry "DATA_AT_REST_ENCRYPTION_KEYS_JSON" "data-at-rest-keys.json" "Data-at-rest encryption"
|
|
137
|
+
upload_registry "EXPORT_ENCRYPTION_KEYS_JSON" "export-encryption-keys.json" "Export encryption"
|
|
138
|
+
upload_registry "MANIFEST_SIGNING_KEYS_JSON" "manifest-signing-keys.json" "Manifest signing"
|
|
139
|
+
upload_registry "USER_KV_ENCRYPTION_KEYS_JSON" "user-kv-encryption-keys.json" "User KV encryption"
|
|
140
|
+
|
|
141
|
+
echo ""
|
|
142
|
+
if [ "$dry_run" = "true" ]; then
|
|
143
|
+
echo -e "${BLUE}[dry-run] Would upload ${uploaded} registries, skipped ${skipped}${NC}"
|
|
144
|
+
else
|
|
145
|
+
echo -e "${GREEN}✅ Uploaded ${uploaded} registries to ${CONFIG_BUCKET_NAME}, skipped ${skipped}${NC}"
|
|
146
|
+
fi
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared R2-based key registry module.
|
|
3
|
+
*
|
|
4
|
+
* Fetches key registries from a shared R2 config bucket instead of
|
|
5
|
+
* environment secrets (which have a 5.1 kB limit per secret).
|
|
6
|
+
*
|
|
7
|
+
* Each scope maps to a separate JSON file in the config bucket:
|
|
8
|
+
* - 'data-at-rest' → data-at-rest-keys.json
|
|
9
|
+
* - 'export-encryption' → export-encryption-keys.json
|
|
10
|
+
* - 'manifest-signing' → manifest-signing-keys.json
|
|
11
|
+
* - 'user-kv' → user-kv-encryption-keys.json
|
|
12
|
+
*
|
|
13
|
+
* Registry files are encrypted at rest with AES-256-GCM using
|
|
14
|
+
* REGISTRY_ENCRYPTION_KEY before upload and decrypted on fetch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { decryptRegistryJson, isEncryptedEnvelope } from './registry-encryption';
|
|
18
|
+
|
|
19
|
+
export type KeyRegistryScope =
|
|
20
|
+
| 'data-at-rest'
|
|
21
|
+
| 'export-encryption'
|
|
22
|
+
| 'manifest-signing'
|
|
23
|
+
| 'user-kv';
|
|
24
|
+
|
|
25
|
+
export interface PrivateKeyRegistry {
|
|
26
|
+
activeKeyId: string | null;
|
|
27
|
+
keys: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface KeyRegistryPayload {
|
|
31
|
+
activeKeyId?: unknown;
|
|
32
|
+
keys?: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SCOPE_FILE_MAP: Record<KeyRegistryScope, string> = {
|
|
36
|
+
'data-at-rest': 'data-at-rest-keys.json',
|
|
37
|
+
'export-encryption': 'export-encryption-keys.json',
|
|
38
|
+
'manifest-signing': 'manifest-signing-keys.json',
|
|
39
|
+
'user-kv': 'user-kv-encryption-keys.json'
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
43
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
47
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetches and parses a key registry from R2.
|
|
52
|
+
*
|
|
53
|
+
* @param r2Bucket - The STRIAE_CONFIG R2 bucket binding
|
|
54
|
+
* @param scope - Which registry to fetch
|
|
55
|
+
* @param activeKeyIdOverride - Optional env-level override for the active key ID
|
|
56
|
+
* @param registryEncryptionKey - Base64-encoded 32-byte AES-256-GCM key for registry decryption
|
|
57
|
+
* @returns Parsed registry with normalized PEM keys
|
|
58
|
+
* @throws If R2 object is missing, decryption fails, JSON is invalid, or no usable keys found
|
|
59
|
+
*/
|
|
60
|
+
export async function fetchKeyRegistryFromR2(
|
|
61
|
+
r2Bucket: R2Bucket,
|
|
62
|
+
scope: KeyRegistryScope,
|
|
63
|
+
activeKeyIdOverride: string | undefined,
|
|
64
|
+
registryEncryptionKey: string
|
|
65
|
+
): Promise<PrivateKeyRegistry> {
|
|
66
|
+
const filename = SCOPE_FILE_MAP[scope];
|
|
67
|
+
const contextLabel = `${scope} key registry`;
|
|
68
|
+
|
|
69
|
+
const object = await r2Bucket.get(filename);
|
|
70
|
+
if (!object) {
|
|
71
|
+
throw new Error(`${contextLabel}: R2 object "${filename}" not found in config bucket`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rawJson = await object.text();
|
|
75
|
+
if (!rawJson || rawJson.trim().length === 0) {
|
|
76
|
+
throw new Error(`${contextLabel}: R2 object "${filename}" is empty`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let parsed: unknown;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(rawJson) as unknown;
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error(`${contextLabel}: R2 object "${filename}" is not valid JSON`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!isEncryptedEnvelope(parsed)) {
|
|
87
|
+
throw new Error(`${contextLabel}: R2 object "${filename}" is not an encrypted registry envelope`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let registryJson: string;
|
|
91
|
+
try {
|
|
92
|
+
registryJson = await decryptRegistryJson(parsed, registryEncryptionKey);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const message = err instanceof Error ? err.message : 'unknown error';
|
|
95
|
+
throw new Error(`${contextLabel}: failed to decrypt registry — ${message}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return parseRegistryJson(registryJson, scope, activeKeyIdOverride);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parses registry JSON (used both for R2-fetched content and legacy env fallback).
|
|
103
|
+
*/
|
|
104
|
+
export function parseRegistryJson(
|
|
105
|
+
registryJson: string,
|
|
106
|
+
scope: KeyRegistryScope,
|
|
107
|
+
activeKeyIdOverride?: string
|
|
108
|
+
): PrivateKeyRegistry {
|
|
109
|
+
const contextLabel = `${scope} key registry`;
|
|
110
|
+
const keys: Record<string, string> = {};
|
|
111
|
+
const configuredActiveKeyId = getNonEmptyString(activeKeyIdOverride);
|
|
112
|
+
|
|
113
|
+
let parsedRegistry: unknown;
|
|
114
|
+
try {
|
|
115
|
+
parsedRegistry = JSON.parse(registryJson) as unknown;
|
|
116
|
+
} catch {
|
|
117
|
+
throw new Error(`${contextLabel}: JSON is invalid`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!parsedRegistry || typeof parsedRegistry !== 'object') {
|
|
121
|
+
throw new Error(`${contextLabel}: JSON must be an object`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const payload = parsedRegistry as KeyRegistryPayload;
|
|
125
|
+
const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
|
|
126
|
+
|
|
127
|
+
// Support both shapes: { activeKeyId, keys: {...} } and flat { keyId: pem }
|
|
128
|
+
const rawKeys = payload.keys && typeof payload.keys === 'object'
|
|
129
|
+
? payload.keys as Record<string, unknown>
|
|
130
|
+
: parsedRegistry as Record<string, unknown>;
|
|
131
|
+
|
|
132
|
+
for (const [keyId, pemValue] of Object.entries(rawKeys)) {
|
|
133
|
+
if (keyId === 'activeKeyId' || keyId === 'keys') {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const normalizedKeyId = getNonEmptyString(keyId);
|
|
138
|
+
const normalizedPem = getNonEmptyString(pemValue);
|
|
139
|
+
|
|
140
|
+
if (!normalizedKeyId || !normalizedPem) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
|
|
148
|
+
|
|
149
|
+
if (Object.keys(keys).length === 0) {
|
|
150
|
+
throw new Error(`${contextLabel}: does not contain any usable keys`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
|
|
154
|
+
throw new Error(`${contextLabel}: active key ID "${resolvedActiveKeyId}" is not present in registry`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
activeKeyId: resolvedActiveKeyId ?? null,
|
|
159
|
+
keys
|
|
160
|
+
};
|
|
161
|
+
}
|