@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.
Files changed (47) hide show
  1. package/.env.example +18 -1
  2. package/app/components/actions/case-manage/operations.ts +2 -1
  3. package/app/routes/striae/utils/case-export.ts +1 -17
  4. package/app/utils/data/operations/case-export-loader.ts +17 -0
  5. package/app/utils/forensics/signature-utils.ts +110 -44
  6. package/functions/[[path]].ts +2 -1
  7. package/load-context.ts +13 -1
  8. package/package.json +23 -21
  9. package/public/_routes.json +1 -0
  10. package/react-router.config.ts +7 -0
  11. package/scripts/deploy-all.sh +28 -14
  12. package/scripts/deploy-config/modules/env-utils.sh +10 -1
  13. package/scripts/deploy-config/modules/keys.sh +33 -4
  14. package/scripts/deploy-config/modules/prompt.sh +2 -0
  15. package/scripts/deploy-config/modules/scaffolding.sh +32 -14
  16. package/scripts/deploy-config/modules/validation.sh +6 -1
  17. package/scripts/deploy-worker-secrets.sh +20 -50
  18. package/scripts/encrypt-registry.mjs +104 -0
  19. package/scripts/upload-registries.sh +146 -0
  20. package/shared/registry/r2-key-registry.ts +161 -0
  21. package/shared/registry/registry-encryption.ts +112 -0
  22. package/vite.config.ts +2 -1
  23. package/workers/audit-worker/package.json +2 -2
  24. package/workers/audit-worker/src/crypto/data-at-rest.ts +9 -72
  25. package/workers/audit-worker/src/types.ts +2 -0
  26. package/workers/audit-worker/wrangler.jsonc.example +5 -1
  27. package/workers/data-worker/package.json +2 -2
  28. package/workers/data-worker/src/handlers/decrypt-export.ts +1 -1
  29. package/workers/data-worker/src/handlers/signing.ts +5 -2
  30. package/workers/data-worker/src/registry/key-registry.ts +43 -101
  31. package/workers/data-worker/src/types.ts +6 -2
  32. package/workers/data-worker/wrangler.jsonc.example +5 -1
  33. package/workers/image-worker/package.json +2 -2
  34. package/workers/image-worker/src/security/key-registry.ts +13 -74
  35. package/workers/image-worker/src/types.ts +2 -0
  36. package/workers/image-worker/wrangler.jsonc.example +5 -1
  37. package/workers/lists-worker/package.json +2 -2
  38. package/workers/lists-worker/wrangler.jsonc.example +1 -1
  39. package/workers/pdf-worker/package.json +2 -2
  40. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  41. package/workers/user-worker/package.json +2 -2
  42. package/workers/user-worker/src/cleanup/account-deletion.ts +9 -74
  43. package/workers/user-worker/src/registry/user-kv.ts +8 -65
  44. package/workers/user-worker/src/storage/user-records.ts +1 -1
  45. package/workers/user-worker/src/types.ts +2 -0
  46. package/workers/user-worker/wrangler.jsonc.example +5 -1
  47. 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
- local escaped_manifest_signing_key_id
220
- local escaped_manifest_signing_public_key
221
- local escaped_export_encryption_key_id
222
- local escaped_export_encryption_public_key
223
- escaped_manifest_signing_key_id=$(escape_for_sed_replacement "$MANIFEST_SIGNING_KEY_ID")
224
- escaped_manifest_signing_public_key=$(escape_for_sed_replacement "$MANIFEST_SIGNING_PUBLIC_KEY")
225
- escaped_export_encryption_key_id=$(escape_for_sed_replacement "$EXPORT_ENCRYPTION_KEY_ID")
226
- escaped_export_encryption_public_key=$(escape_for_sed_replacement "$EXPORT_ENCRYPTION_PUBLIC_KEY")
227
-
228
- sed -i "s|\"url\": \"[^\"]*\"|\"url\": \"https://$escaped_pages_custom_domain\"|g" app/config/config.json
229
- sed -i "s|\"MANIFEST_SIGNING_KEY_ID\"|\"$escaped_manifest_signing_key_id\"|g" app/config/config.json
230
- sed -i "s|\"MANIFEST_SIGNING_PUBLIC_KEY\"|\"$escaped_manifest_signing_public_key\"|g" app/config/config.json
231
- sed -i "s|\"EXPORT_ENCRYPTION_KEY_ID\"|\"$escaped_export_encryption_key_id\"|g" app/config/config.json
232
- sed -i "s|\"EXPORT_ENCRYPTION_PUBLIC_KEY\"|\"$escaped_export_encryption_public_key\"|g" app/config/config.json
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
- if [ -n "${DATA_AT_REST_ENCRYPTION_PRIVATE_KEY:-}" ]; then
106
- secrets+=("DATA_AT_REST_ENCRYPTION_PRIVATE_KEY")
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
- if [ -n "${DATA_AT_REST_ENCRYPTION_ENABLED:-}" ]; then
153
- secrets+=("DATA_AT_REST_ENCRYPTION_ENABLED")
154
- fi
138
+ local secrets=(
139
+ "REGISTRY_ENCRYPTION_KEY"
140
+ )
155
141
 
156
- if [ -n "${DATA_AT_REST_ENCRYPTION_PRIVATE_KEY:-}" ]; then
157
- secrets+=("DATA_AT_REST_ENCRYPTION_PRIVATE_KEY")
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
- "MANIFEST_SIGNING_PRIVATE_KEY"
230
- "MANIFEST_SIGNING_KEY_ID"
231
- "EXPORT_ENCRYPTION_PRIVATE_KEY"
232
- "EXPORT_ENCRYPTION_KEY_ID"
210
+ "REGISTRY_ENCRYPTION_KEY"
233
211
  )
234
212
 
235
- if [ -n "${DATA_AT_REST_ENCRYPTION_ENABLED:-}" ]; then
236
- secrets+=("DATA_AT_REST_ENCRYPTION_ENABLED")
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 "${DATA_AT_REST_ENCRYPTION_PRIVATE_KEY:-}" ]; then
240
- secrets+=("DATA_AT_REST_ENCRYPTION_PRIVATE_KEY")
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
- if [ -n "${DATA_AT_REST_ENCRYPTION_PRIVATE_KEY:-}" ]; then
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
+ }