@striae-org/striae 7.1.3 → 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/utils/forensics/signature-utils.ts +110 -44
- package/functions/[[path]].ts +2 -1
- package/load-context.ts +13 -1
- package/package.json +19 -17
- 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
|
@@ -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": "
|
|
3
|
+
"version": "8.0.0",
|
|
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.
|
|
11
|
+
"wrangler": "^4.98.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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 =
|
|
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;
|
|
@@ -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-
|
|
6
|
+
"compatibility_date": "2026-06-07",
|
|
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": "
|
|
3
|
+
"version": "8.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"deploy": "wrangler deploy",
|
|
@@ -9,6 +9,6 @@
|
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@cloudflare/vitest-pool-workers": "^0.14.9",
|
|
12
|
-
"wrangler": "^4.
|
|
12
|
+
"wrangler": "^4.98.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
|
-
|
|
28
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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-
|
|
6
|
+
"compatibility_date": "2026-06-07",
|
|
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": "
|
|
3
|
+
"version": "8.0.0",
|
|
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.
|
|
11
|
+
"wrangler": "^4.98.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(
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 =
|
|
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;
|