@striae-org/striae 7.1.3 → 8.0.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.
Files changed (44) hide show
  1. package/.env.example +18 -1
  2. package/app/utils/forensics/signature-utils.ts +110 -44
  3. package/functions/[[path]].ts +2 -1
  4. package/load-context.ts +13 -1
  5. package/package.json +27 -20
  6. package/public/_routes.json +1 -0
  7. package/react-router.config.ts +7 -0
  8. package/scripts/deploy-all.sh +28 -14
  9. package/scripts/deploy-config/modules/env-utils.sh +10 -1
  10. package/scripts/deploy-config/modules/keys.sh +33 -4
  11. package/scripts/deploy-config/modules/prompt.sh +2 -0
  12. package/scripts/deploy-config/modules/scaffolding.sh +32 -14
  13. package/scripts/deploy-config/modules/validation.sh +6 -1
  14. package/scripts/deploy-worker-secrets.sh +20 -50
  15. package/scripts/encrypt-registry.mjs +104 -0
  16. package/scripts/upload-registries.sh +146 -0
  17. package/shared/registry/r2-key-registry.ts +161 -0
  18. package/shared/registry/registry-encryption.ts +112 -0
  19. package/vite.config.ts +2 -1
  20. package/workers/audit-worker/package.json +2 -2
  21. package/workers/audit-worker/src/crypto/data-at-rest.ts +9 -72
  22. package/workers/audit-worker/src/types.ts +2 -0
  23. package/workers/audit-worker/wrangler.jsonc.example +5 -1
  24. package/workers/data-worker/package.json +3 -3
  25. package/workers/data-worker/src/handlers/decrypt-export.ts +1 -1
  26. package/workers/data-worker/src/handlers/signing.ts +5 -2
  27. package/workers/data-worker/src/registry/key-registry.ts +43 -101
  28. package/workers/data-worker/src/types.ts +6 -2
  29. package/workers/data-worker/wrangler.jsonc.example +5 -1
  30. package/workers/image-worker/package.json +2 -2
  31. package/workers/image-worker/src/security/key-registry.ts +13 -74
  32. package/workers/image-worker/src/types.ts +2 -0
  33. package/workers/image-worker/wrangler.jsonc.example +5 -1
  34. package/workers/lists-worker/package.json +2 -2
  35. package/workers/lists-worker/wrangler.jsonc.example +1 -1
  36. package/workers/pdf-worker/package.json +2 -2
  37. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  38. package/workers/user-worker/package.json +2 -2
  39. package/workers/user-worker/src/cleanup/account-deletion.ts +9 -74
  40. package/workers/user-worker/src/registry/user-kv.ts +8 -65
  41. package/workers/user-worker/src/storage/user-records.ts +1 -1
  42. package/workers/user-worker/src/types.ts +2 -0
  43. package/workers/user-worker/wrangler.jsonc.example +5 -1
  44. package/wrangler.toml.example +1 -1
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Symmetric AES-256-GCM encryption for key registry files stored in R2.
3
+ *
4
+ * Registry JSON is encrypted before upload and decrypted after fetch using
5
+ * a shared REGISTRY_ENCRYPTION_KEY (32-byte key, base64-encoded in env).
6
+ */
7
+
8
+ export interface EncryptedRegistryEnvelope {
9
+ encrypted: true;
10
+ algorithm: 'AES-256-GCM';
11
+ version: '1.0';
12
+ iv: string;
13
+ ciphertext: string;
14
+ }
15
+
16
+ function base64UrlEncode(bytes: Uint8Array): string {
17
+ let binary = '';
18
+ for (const byte of bytes) {
19
+ binary += String.fromCharCode(byte);
20
+ }
21
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
22
+ }
23
+
24
+ function base64UrlDecode(encoded: string): Uint8Array {
25
+ let padded = encoded.replace(/-/g, '+').replace(/_/g, '/');
26
+ const pad = padded.length % 4;
27
+ if (pad === 2) padded += '==';
28
+ else if (pad === 3) padded += '=';
29
+ const binary = atob(padded);
30
+ const bytes = new Uint8Array(binary.length);
31
+ for (let i = 0; i < binary.length; i++) {
32
+ bytes[i] = binary.charCodeAt(i);
33
+ }
34
+ return bytes;
35
+ }
36
+
37
+ function importKeyFromBase64(keyBase64: string): Promise<CryptoKey> {
38
+ const keyBytes = base64UrlDecode(keyBase64);
39
+ if (keyBytes.length !== 32) {
40
+ throw new Error(`Registry encryption key must be 32 bytes, got ${keyBytes.length}`);
41
+ }
42
+ return crypto.subtle.importKey(
43
+ 'raw',
44
+ keyBytes as BufferSource,
45
+ { name: 'AES-GCM', length: 256 },
46
+ false,
47
+ ['encrypt', 'decrypt']
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Encrypts registry JSON for storage in R2.
53
+ */
54
+ export async function encryptRegistryJson(
55
+ plaintextJson: string,
56
+ keyBase64: string
57
+ ): Promise<EncryptedRegistryEnvelope> {
58
+ const key = await importKeyFromBase64(keyBase64);
59
+ const iv = crypto.getRandomValues(new Uint8Array(12));
60
+ const plaintext = new TextEncoder().encode(plaintextJson);
61
+
62
+ const ciphertextBuffer = await crypto.subtle.encrypt(
63
+ { name: 'AES-GCM', iv: iv as BufferSource },
64
+ key,
65
+ plaintext as BufferSource
66
+ );
67
+
68
+ return {
69
+ encrypted: true,
70
+ algorithm: 'AES-256-GCM',
71
+ version: '1.0',
72
+ iv: base64UrlEncode(iv),
73
+ ciphertext: base64UrlEncode(new Uint8Array(ciphertextBuffer))
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Decrypts an encrypted registry envelope back to JSON.
79
+ */
80
+ export async function decryptRegistryJson(
81
+ envelope: EncryptedRegistryEnvelope,
82
+ keyBase64: string
83
+ ): Promise<string> {
84
+ const key = await importKeyFromBase64(keyBase64);
85
+ const iv = base64UrlDecode(envelope.iv);
86
+ const ciphertext = base64UrlDecode(envelope.ciphertext);
87
+
88
+ const plaintextBuffer = await crypto.subtle.decrypt(
89
+ { name: 'AES-GCM', iv: iv as BufferSource },
90
+ key,
91
+ ciphertext as BufferSource
92
+ );
93
+
94
+ return new TextDecoder().decode(plaintextBuffer);
95
+ }
96
+
97
+ /**
98
+ * Type guard to check if parsed JSON is an encrypted registry envelope.
99
+ */
100
+ export function isEncryptedEnvelope(data: unknown): data is EncryptedRegistryEnvelope {
101
+ if (!data || typeof data !== 'object') {
102
+ return false;
103
+ }
104
+ const obj = data as Record<string, unknown>;
105
+ return (
106
+ obj.encrypted === true &&
107
+ obj.algorithm === 'AES-256-GCM' &&
108
+ obj.version === '1.0' &&
109
+ typeof obj.iv === 'string' &&
110
+ typeof obj.ciphertext === 'string'
111
+ );
112
+ }
package/vite.config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { reactRouter } from "@react-router/dev/vite";
2
2
  import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
3
3
  import { defineConfig } from "vite";
4
+ import { getLoadContext } from "./load-context";
4
5
 
5
6
  export default defineConfig({
6
7
  server: {
@@ -14,7 +15,7 @@ export default defineConfig({
14
15
  tsconfigPaths: true,
15
16
  },
16
17
  plugins: [
17
- cloudflareDevProxy(),
18
+ cloudflareDevProxy({ getLoadContext }),
18
19
  reactRouter(),
19
20
  ],
20
21
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "audit-worker",
3
- "version": "7.1.3",
3
+ "version": "8.0.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -8,6 +8,6 @@
8
8
  "start": "wrangler dev"
9
9
  },
10
10
  "devDependencies": {
11
- "wrangler": "^4.90.0"
11
+ "wrangler": "^4.103.0"
12
12
  }
13
13
  }
@@ -6,84 +6,21 @@ import type {
6
6
  DataAtRestEnvelope,
7
7
  DecryptionTelemetryOutcome,
8
8
  Env,
9
- KeyRegistryPayload,
10
9
  PrivateKeyRegistry
11
10
  } from '../types';
12
-
13
- function normalizePrivateKeyPem(rawValue: string): string {
14
- return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
15
- }
11
+ import { fetchKeyRegistryFromR2 } from '../../../../shared/registry/r2-key-registry';
16
12
 
17
13
  function getNonEmptyString(value: unknown): string | null {
18
14
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
19
15
  }
20
16
 
21
- function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
22
- const keys: Record<string, string> = {};
23
- const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
24
- const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
25
-
26
- if (registryJson) {
27
- let parsedRegistry: unknown;
28
-
29
- try {
30
- parsedRegistry = JSON.parse(registryJson) as unknown;
31
- } catch {
32
- throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
33
- }
34
-
35
- if (!parsedRegistry || typeof parsedRegistry !== 'object') {
36
- throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
37
- }
38
-
39
- const payload = parsedRegistry as KeyRegistryPayload;
40
- const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
41
- const rawKeys = payload.keys && typeof payload.keys === 'object'
42
- ? payload.keys as Record<string, unknown>
43
- : parsedRegistry as Record<string, unknown>;
44
-
45
- for (const [keyId, pemValue] of Object.entries(rawKeys)) {
46
- if (keyId === 'activeKeyId' || keyId === 'keys') {
47
- continue;
48
- }
49
-
50
- const normalizedKeyId = getNonEmptyString(keyId);
51
- const normalizedPem = getNonEmptyString(pemValue);
52
- if (!normalizedKeyId || !normalizedPem) {
53
- continue;
54
- }
55
-
56
- keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
57
- }
58
-
59
- const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
60
-
61
- if (Object.keys(keys).length === 0) {
62
- throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
63
- }
64
-
65
- if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
66
- throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
67
- }
68
-
69
- return {
70
- activeKeyId: resolvedActiveKeyId ?? null,
71
- keys
72
- };
73
- }
74
-
75
- const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
76
- const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
77
- if (!legacyKeyId || !legacyPrivateKey) {
78
- throw new Error('Data-at-rest decryption key registry is not configured');
79
- }
80
-
81
- keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
82
-
83
- return {
84
- activeKeyId: configuredActiveKeyId ?? legacyKeyId,
85
- keys
86
- };
17
+ async function getDataAtRestPrivateKeyRegistry(env: Env): Promise<PrivateKeyRegistry> {
18
+ return fetchKeyRegistryFromR2(
19
+ env.STRIAE_CONFIG,
20
+ 'data-at-rest',
21
+ env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID,
22
+ env.REGISTRY_ENCRYPTION_KEY
23
+ );
87
24
  }
88
25
 
89
26
  function buildPrivateKeyCandidates(
@@ -312,7 +249,7 @@ export async function decryptAuditJsonWithRegistry(
312
249
  envelope: DataAtRestEnvelope,
313
250
  env: Env
314
251
  ): Promise<string> {
315
- const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
252
+ const keyRegistry = await getDataAtRestPrivateKeyRegistry(env);
316
253
  const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
317
254
  const primaryKeyId = candidates[0]?.keyId ?? null;
318
255
  let lastError: unknown;
@@ -1,5 +1,7 @@
1
1
  export interface Env {
2
2
  STRIAE_AUDIT: R2Bucket;
3
+ STRIAE_CONFIG: R2Bucket;
4
+ REGISTRY_ENCRYPTION_KEY: string;
3
5
  DATA_AT_REST_ENCRYPTION_ENABLED?: string;
4
6
  DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
5
7
  DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-05-23",
6
+ "compatibility_date": "2026-06-20",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -16,6 +16,10 @@
16
16
  {
17
17
  "binding": "STRIAE_AUDIT",
18
18
  "bucket_name": "AUDIT_BUCKET_NAME"
19
+ },
20
+ {
21
+ "binding": "STRIAE_CONFIG",
22
+ "bucket_name": "CONFIG_BUCKET_NAME"
19
23
  }
20
24
  ],
21
25
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "data-worker",
3
- "version": "7.1.3",
3
+ "version": "8.0.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -8,7 +8,7 @@
8
8
  "start": "wrangler dev"
9
9
  },
10
10
  "devDependencies": {
11
- "@cloudflare/vitest-pool-workers": "^0.14.9",
12
- "wrangler": "^4.90.0"
11
+ "@cloudflare/vitest-pool-workers": "^0.16.18",
12
+ "wrangler": "^4.103.0"
13
13
  }
14
14
  }
@@ -52,7 +52,7 @@ export async function handleDecryptExport(
52
52
  }
53
53
 
54
54
  const recordKeyId = getNonEmptyString(keyId);
55
- const decryptionContext = buildExportDecryptionContext(recordKeyId, env);
55
+ const decryptionContext = await buildExportDecryptionContext(recordKeyId, env);
56
56
 
57
57
  let plaintextData: string;
58
58
  try {
@@ -14,6 +14,7 @@ import {
14
14
  isValidConfirmationPayload,
15
15
  isValidManifestPayload
16
16
  } from '../signing-payload-utils';
17
+ import { getManifestSigningKeyContext } from '../registry/key-registry';
17
18
  import type { CreateResponse, Env } from '../types';
18
19
 
19
20
  async function signPayloadWithWorkerKey(payload: string, env: Env): Promise<{
@@ -22,10 +23,12 @@ async function signPayloadWithWorkerKey(payload: string, env: Env): Promise<{
22
23
  signedAt: string;
23
24
  value: string;
24
25
  }> {
26
+ const signingContext = await getManifestSigningKeyContext(env);
27
+
25
28
  return signWithWorkerKey(
26
29
  payload,
27
- env.MANIFEST_SIGNING_PRIVATE_KEY,
28
- env.MANIFEST_SIGNING_KEY_ID,
30
+ signingContext.privateKeyPem,
31
+ signingContext.keyId,
29
32
  FORENSIC_MANIFEST_SIGNATURE_ALGORITHM
30
33
  );
31
34
  }
@@ -8,95 +8,14 @@ import type {
8
8
  DecryptionTelemetryOutcome,
9
9
  Env,
10
10
  ExportDecryptionContext,
11
- KeyRegistryPayload,
12
11
  PrivateKeyRegistry
13
12
  } from '../types';
14
-
15
- function normalizePrivateKeyPem(rawValue: string): string {
16
- return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
17
- }
13
+ import { fetchKeyRegistryFromR2 } from '../../../../shared/registry/r2-key-registry';
18
14
 
19
15
  export function getNonEmptyString(value: unknown): string | null {
20
16
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
21
17
  }
22
18
 
23
- function parsePrivateKeyRegistry(input: {
24
- registryJson: string | undefined;
25
- activeKeyId: string | undefined;
26
- legacyKeyId: string | undefined;
27
- legacyPrivateKey: string | undefined;
28
- context: string;
29
- }): PrivateKeyRegistry {
30
- const keys: Record<string, string> = {};
31
- const configuredActiveKeyId = getNonEmptyString(input.activeKeyId);
32
- const registryJson = getNonEmptyString(input.registryJson);
33
-
34
- if (registryJson) {
35
- let parsedRegistry: unknown;
36
-
37
- try {
38
- parsedRegistry = JSON.parse(registryJson) as unknown;
39
- } catch {
40
- throw new Error(`${input.context} registry JSON is invalid`);
41
- }
42
-
43
- if (!parsedRegistry || typeof parsedRegistry !== 'object') {
44
- throw new Error(`${input.context} registry JSON must be an object`);
45
- }
46
-
47
- const payload = parsedRegistry as KeyRegistryPayload;
48
- const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
49
- const rawKeys = payload.keys && typeof payload.keys === 'object'
50
- ? payload.keys as Record<string, unknown>
51
- : parsedRegistry as Record<string, unknown>;
52
-
53
- for (const [keyId, pemValue] of Object.entries(rawKeys)) {
54
- if (keyId === 'activeKeyId' || keyId === 'keys') {
55
- continue;
56
- }
57
-
58
- const normalizedKeyId = getNonEmptyString(keyId);
59
- const normalizedPem = getNonEmptyString(pemValue);
60
-
61
- if (!normalizedKeyId || !normalizedPem) {
62
- continue;
63
- }
64
-
65
- keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
66
- }
67
-
68
- const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
69
-
70
- if (Object.keys(keys).length === 0) {
71
- throw new Error(`${input.context} registry does not contain any usable keys`);
72
- }
73
-
74
- if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
75
- throw new Error(`${input.context} active key ID is not present in registry`);
76
- }
77
-
78
- return {
79
- activeKeyId: resolvedActiveKeyId ?? null,
80
- keys
81
- };
82
- }
83
-
84
- const legacyKeyId = getNonEmptyString(input.legacyKeyId);
85
- const legacyPrivateKey = getNonEmptyString(input.legacyPrivateKey);
86
-
87
- if (!legacyKeyId || !legacyPrivateKey) {
88
- throw new Error(`${input.context} private key registry is not configured`);
89
- }
90
-
91
- keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
92
- const resolvedActiveKeyId = configuredActiveKeyId ?? legacyKeyId;
93
-
94
- return {
95
- activeKeyId: resolvedActiveKeyId,
96
- keys
97
- };
98
- }
99
-
100
19
  function buildPrivateKeyCandidates(
101
20
  recordKeyId: string | null,
102
21
  registry: PrivateKeyRegistry
@@ -154,28 +73,51 @@ function logRegistryDecryptionTelemetry(input: {
154
73
  console.info('Key registry decryption resolved', details);
155
74
  }
156
75
 
157
- function getDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
158
- return parsePrivateKeyRegistry({
159
- registryJson: env.DATA_AT_REST_ENCRYPTION_KEYS_JSON,
160
- activeKeyId: env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID,
161
- legacyKeyId: env.DATA_AT_REST_ENCRYPTION_KEY_ID,
162
- legacyPrivateKey: env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY,
163
- context: 'Data-at-rest decryption'
164
- });
76
+ async function getDataAtRestPrivateKeyRegistry(env: Env): Promise<PrivateKeyRegistry> {
77
+ return fetchKeyRegistryFromR2(
78
+ env.STRIAE_CONFIG,
79
+ 'data-at-rest',
80
+ env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID,
81
+ env.REGISTRY_ENCRYPTION_KEY
82
+ );
165
83
  }
166
84
 
167
- function getExportPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
168
- return parsePrivateKeyRegistry({
169
- registryJson: env.EXPORT_ENCRYPTION_KEYS_JSON,
170
- activeKeyId: env.EXPORT_ENCRYPTION_ACTIVE_KEY_ID,
171
- legacyKeyId: env.EXPORT_ENCRYPTION_KEY_ID,
172
- legacyPrivateKey: env.EXPORT_ENCRYPTION_PRIVATE_KEY,
173
- context: 'Export decryption'
174
- });
85
+ async function getExportPrivateKeyRegistry(env: Env): Promise<PrivateKeyRegistry> {
86
+ return fetchKeyRegistryFromR2(
87
+ env.STRIAE_CONFIG,
88
+ 'export-encryption',
89
+ env.EXPORT_ENCRYPTION_ACTIVE_KEY_ID,
90
+ env.REGISTRY_ENCRYPTION_KEY
91
+ );
92
+ }
93
+
94
+ export async function getManifestSigningKeyContext(env: Env): Promise<{ keyId: string; privateKeyPem: string }> {
95
+ const keyRegistry = await fetchKeyRegistryFromR2(
96
+ env.STRIAE_CONFIG,
97
+ 'manifest-signing',
98
+ env.MANIFEST_SIGNING_ACTIVE_KEY_ID,
99
+ env.REGISTRY_ENCRYPTION_KEY
100
+ );
101
+
102
+ const resolvedKeyId = keyRegistry.activeKeyId;
103
+
104
+ if (!resolvedKeyId) {
105
+ throw new Error('Manifest signing active key ID is not configured');
106
+ }
107
+
108
+ const privateKeyPem = keyRegistry.keys[resolvedKeyId];
109
+ if (!privateKeyPem) {
110
+ throw new Error('Manifest signing active key ID is not present in key registry');
111
+ }
112
+
113
+ return {
114
+ keyId: resolvedKeyId,
115
+ privateKeyPem
116
+ };
175
117
  }
176
118
 
177
- export function buildExportDecryptionContext(keyId: string | null, env: Env): ExportDecryptionContext {
178
- const keyRegistry = getExportPrivateKeyRegistry(env);
119
+ export async function buildExportDecryptionContext(keyId: string | null, env: Env): Promise<ExportDecryptionContext> {
120
+ const keyRegistry = await getExportPrivateKeyRegistry(env);
179
121
  const candidates = buildPrivateKeyCandidates(keyId, keyRegistry);
180
122
 
181
123
  if (candidates.length === 0) {
@@ -194,7 +136,7 @@ export async function decryptJsonFromStorageWithRegistry(
194
136
  envelope: DataAtRestEnvelope,
195
137
  env: Env
196
138
  ): Promise<string> {
197
- const keyRegistry = getDataAtRestPrivateKeyRegistry(env);
139
+ const keyRegistry = await getDataAtRestPrivateKeyRegistry(env);
198
140
  const candidates = buildPrivateKeyCandidates(getNonEmptyString(envelope.keyId), keyRegistry);
199
141
  const primaryKeyId = candidates[0]?.keyId ?? null;
200
142
  let lastError: unknown;
@@ -1,7 +1,11 @@
1
1
  export interface Env {
2
2
  STRIAE_DATA: R2Bucket;
3
- MANIFEST_SIGNING_PRIVATE_KEY: string;
4
- MANIFEST_SIGNING_KEY_ID: string;
3
+ STRIAE_CONFIG: R2Bucket;
4
+ REGISTRY_ENCRYPTION_KEY: string;
5
+ MANIFEST_SIGNING_PRIVATE_KEY?: string;
6
+ MANIFEST_SIGNING_KEY_ID?: string;
7
+ MANIFEST_SIGNING_KEYS_JSON?: string;
8
+ MANIFEST_SIGNING_ACTIVE_KEY_ID?: string;
5
9
  EXPORT_ENCRYPTION_PRIVATE_KEY?: string;
6
10
  EXPORT_ENCRYPTION_KEY_ID?: string;
7
11
  EXPORT_ENCRYPTION_KEYS_JSON?: string;
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/data-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-05-23",
6
+ "compatibility_date": "2026-06-20",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -16,6 +16,10 @@
16
16
  {
17
17
  "binding": "STRIAE_DATA",
18
18
  "bucket_name": "DATA_BUCKET_NAME"
19
+ },
20
+ {
21
+ "binding": "STRIAE_CONFIG",
22
+ "bucket_name": "CONFIG_BUCKET_NAME"
19
23
  }
20
24
  ],
21
25
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "image-worker",
3
- "version": "7.1.3",
3
+ "version": "8.0.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -8,6 +8,6 @@
8
8
  "start": "wrangler dev"
9
9
  },
10
10
  "devDependencies": {
11
- "wrangler": "^4.90.0"
11
+ "wrangler": "^4.103.0"
12
12
  }
13
13
  }
@@ -5,13 +5,9 @@ import {
5
5
  import type {
6
6
  DecryptionTelemetryOutcome,
7
7
  Env,
8
- KeyRegistryPayload,
9
8
  PrivateKeyRegistry
10
9
  } from '../types';
11
-
12
- function normalizePrivateKeyPem(rawValue: string): string {
13
- return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
14
- }
10
+ import { fetchKeyRegistryFromR2 } from '../../../../shared/registry/r2-key-registry';
15
11
 
16
12
  function getNonEmptyString(value: unknown): string | null {
17
13
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
@@ -23,76 +19,19 @@ export function requireEncryptionUploadConfig(env: Env): void {
23
19
  }
24
20
  }
25
21
 
26
- export function requireEncryptionRetrievalConfig(env: Env): void {
27
- const hasLegacyPrivateKey = typeof env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY === 'string' && env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY.trim().length > 0;
28
- const hasRegistry = typeof env.DATA_AT_REST_ENCRYPTION_KEYS_JSON === 'string' && env.DATA_AT_REST_ENCRYPTION_KEYS_JSON.trim().length > 0;
29
-
30
- if (!hasLegacyPrivateKey && !hasRegistry) {
31
- throw new Error('Data-at-rest decryption registry is not configured for image retrieval');
32
- }
22
+ export function requireEncryptionRetrievalConfig(_env: Env): void {
23
+ // Key registry is now fetched from R2 at decryption time.
24
+ // This check is kept for interface compatibility but validation
25
+ // happens when fetchKeyRegistryFromR2 is called.
33
26
  }
34
27
 
35
- function parseDataAtRestPrivateKeyRegistry(env: Env): PrivateKeyRegistry {
36
- const keys: Record<string, string> = {};
37
- const configuredActiveKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID);
38
- const registryJson = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEYS_JSON);
39
-
40
- if (registryJson) {
41
- let parsedRegistry: unknown;
42
- try {
43
- parsedRegistry = JSON.parse(registryJson) as unknown;
44
- } catch {
45
- throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON is not valid JSON');
46
- }
47
-
48
- if (!parsedRegistry || typeof parsedRegistry !== 'object') {
49
- throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must be an object');
50
- }
51
-
52
- const payload = parsedRegistry as KeyRegistryPayload;
53
- if (!payload.keys || typeof payload.keys !== 'object') {
54
- throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON must include a keys object');
55
- }
56
-
57
- for (const [keyId, pemValue] of Object.entries(payload.keys as Record<string, unknown>)) {
58
- const normalizedKeyId = getNonEmptyString(keyId);
59
- const normalizedPem = getNonEmptyString(pemValue);
60
- if (!normalizedKeyId || !normalizedPem) {
61
- continue;
62
- }
63
-
64
- keys[normalizedKeyId] = normalizePrivateKeyPem(normalizedPem);
65
- }
66
-
67
- const payloadActiveKeyId = getNonEmptyString(payload.activeKeyId);
68
- const resolvedActiveKeyId = configuredActiveKeyId ?? payloadActiveKeyId;
69
-
70
- if (Object.keys(keys).length === 0) {
71
- throw new Error('DATA_AT_REST_ENCRYPTION_KEYS_JSON does not contain any usable keys');
72
- }
73
-
74
- if (resolvedActiveKeyId && !keys[resolvedActiveKeyId]) {
75
- throw new Error('DATA_AT_REST active key ID is not present in DATA_AT_REST_ENCRYPTION_KEYS_JSON');
76
- }
77
-
78
- return {
79
- activeKeyId: resolvedActiveKeyId ?? null,
80
- keys
81
- };
82
- }
83
-
84
- const legacyKeyId = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_KEY_ID);
85
- const legacyPrivateKey = getNonEmptyString(env.DATA_AT_REST_ENCRYPTION_PRIVATE_KEY);
86
- if (!legacyKeyId || !legacyPrivateKey) {
87
- throw new Error('Data-at-rest decryption key registry is not configured');
88
- }
89
-
90
- keys[legacyKeyId] = normalizePrivateKeyPem(legacyPrivateKey);
91
-
92
- return {
93
- activeKeyId: configuredActiveKeyId ?? legacyKeyId,
94
- keys
95
- };
28
+ async function getDataAtRestPrivateKeyRegistry(env: Env): Promise<PrivateKeyRegistry> {
29
+ return fetchKeyRegistryFromR2(
30
+ env.STRIAE_CONFIG,
31
+ 'data-at-rest',
32
+ env.DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID,
33
+ env.REGISTRY_ENCRYPTION_KEY
34
+ );
96
35
  }
97
36
 
98
37
  function buildPrivateKeyCandidates(
@@ -156,7 +95,7 @@ export async function decryptBinaryWithRegistry(
156
95
  envelope: DataAtRestEnvelope,
157
96
  env: Env
158
97
  ): Promise<ArrayBuffer> {
159
- const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
98
+ const keyRegistry = await getDataAtRestPrivateKeyRegistry(env);
160
99
  const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
161
100
  const primaryKeyId = candidates[0]?.keyId ?? null;
162
101
  let lastError: unknown;
@@ -1,5 +1,7 @@
1
1
  export interface Env {
2
2
  STRIAE_FILES: R2Bucket;
3
+ STRIAE_CONFIG: R2Bucket;
4
+ REGISTRY_ENCRYPTION_KEY: string;
3
5
  DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
4
6
  DATA_AT_REST_ENCRYPTION_PUBLIC_KEY: string;
5
7
  DATA_AT_REST_ENCRYPTION_KEY_ID: string;