@striae-org/striae 5.2.1 → 5.3.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 +2 -10
- package/README.md +5 -46
- package/app/components/actions/case-export/core-export.ts +2 -174
- package/app/components/actions/case-export/download-handlers.ts +83 -750
- package/app/components/actions/case-export/index.ts +6 -30
- package/app/components/actions/case-export/metadata-helpers.ts +0 -78
- package/app/components/actions/case-export/types-constants.ts +0 -43
- package/app/components/actions/case-import/confirmation-import.ts +13 -14
- package/app/components/actions/case-import/zip-processing.ts +92 -12
- package/app/components/actions/generate-pdf.ts +3 -2
- package/app/components/audit/user-audit-viewer.tsx +0 -19
- package/app/components/audit/viewer/audit-viewer-header.tsx +0 -33
- package/app/components/navbar/case-modals/archive-case-modal.tsx +1 -1
- package/app/components/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/case-import/case-import.module.css +35 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +59 -3
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +2 -4
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +1 -1
- package/app/components/sidebar/notes/class-details-shared.ts +2 -2
- package/app/components/toast/toast.module.css +36 -0
- package/app/components/toast/toast.tsx +6 -2
- package/app/components/user/manage-profile.tsx +4 -3
- package/app/config-example/config.json +1 -2
- package/app/root.tsx +0 -7
- package/app/routes/_index.tsx +1 -1
- package/app/routes/auth/login.example.tsx +22 -103
- package/app/routes/auth/route.ts +1 -1
- package/app/routes/striae/striae.tsx +53 -59
- package/app/services/firebase/index.ts +0 -3
- package/app/types/export.ts +1 -2
- package/app/utils/auth/index.ts +0 -1
- package/app/utils/data/permissions.ts +3 -2
- package/package.json +9 -16
- package/public/_headers +0 -4
- package/public/_routes.json +0 -1
- package/worker-configuration.d.ts +20 -17
- package/workers/audit-worker/src/audit-worker.example.ts +9 -806
- package/workers/audit-worker/src/config.ts +7 -0
- package/workers/audit-worker/src/crypto/data-at-rest.ts +410 -0
- package/workers/audit-worker/src/handlers/audit-routes.ts +125 -0
- package/workers/audit-worker/src/storage/audit-storage.ts +99 -0
- package/workers/audit-worker/src/types.ts +56 -0
- package/workers/audit-worker/worker-configuration.d.ts +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/config.ts +11 -0
- package/workers/data-worker/src/data-worker.example.ts +21 -942
- package/workers/data-worker/src/handlers/decrypt-export.ts +118 -0
- package/workers/data-worker/src/handlers/signing.ts +174 -0
- package/workers/data-worker/src/handlers/storage-routes.ts +129 -0
- package/workers/data-worker/src/registry/key-registry.ts +368 -0
- package/workers/data-worker/src/types.ts +46 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/worker-configuration.d.ts +2 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/auth.ts +30 -0
- package/workers/user-worker/src/cleanup/account-deletion.ts +337 -0
- package/workers/user-worker/src/config.ts +4 -0
- package/workers/user-worker/src/encryption-utils.ts +25 -0
- package/workers/user-worker/src/firebase/admin.ts +152 -0
- package/workers/user-worker/src/handlers/user-routes.ts +242 -0
- package/workers/user-worker/src/registry/user-kv.ts +172 -0
- package/workers/user-worker/src/storage/user-records.ts +34 -0
- package/workers/user-worker/src/types.ts +106 -0
- package/workers/user-worker/src/user-worker.example.ts +18 -964
- package/workers/user-worker/worker-configuration.d.ts +4 -2
- package/workers/user-worker/wrangler.jsonc.example +12 -1
- package/wrangler.toml.example +1 -1
- package/app/components/actions/case-export/data-processing.ts +0 -223
- package/app/components/sidebar/case-export/case-export.module.css +0 -418
- package/app/components/sidebar/case-export/case-export.tsx +0 -310
- package/app/types/exceljs-bare.d.ts +0 -9
- package/app/utils/auth/auth.ts +0 -11
- package/public/.well-known/security.txt +0 -6
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +0 -39
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/vendor/exceljs.LICENSE +0 -22
- package/public/vendor/exceljs.bare.min.js +0 -45
- package/scripts/deploy-all.sh +0 -166
- package/scripts/deploy-config/modules/env-utils.sh +0 -322
- package/scripts/deploy-config/modules/keys.sh +0 -404
- package/scripts/deploy-config/modules/prompt.sh +0 -372
- package/scripts/deploy-config/modules/scaffolding.sh +0 -344
- package/scripts/deploy-config/modules/validation.sh +0 -365
- package/scripts/deploy-config.sh +0 -236
- package/scripts/deploy-pages-secrets.sh +0 -231
- package/scripts/deploy-pages.sh +0 -34
- package/scripts/deploy-primershear-emails.sh +0 -167
- package/scripts/deploy-worker-secrets.sh +0 -374
- package/scripts/dev.cjs +0 -23
- package/scripts/install-workers.sh +0 -88
- package/scripts/run-eslint.cjs +0 -43
- package/scripts/update-compatibility-dates.cjs +0 -124
- package/scripts/update-markdown-versions.cjs +0 -43
- package/workers/keys-worker/package.json +0 -18
- package/workers/keys-worker/src/keys.example.ts +0 -67
- package/workers/keys-worker/src/keys.ts +0 -67
- package/workers/keys-worker/worker-configuration.d.ts +0 -7447
- package/workers/keys-worker/wrangler.jsonc.example +0 -15
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Env } from './types';
|
|
2
|
+
|
|
3
|
+
export const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
4
|
+
export const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
|
|
5
|
+
|
|
6
|
+
export const hasValidHeader = (request: Request, env: Env): boolean =>
|
|
7
|
+
request.headers.get('X-Custom-Auth-Key') === env.R2_KEY_SECRET;
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DATA_AT_REST_ENCRYPTION_ALGORITHM,
|
|
3
|
+
DATA_AT_REST_ENCRYPTION_VERSION
|
|
4
|
+
} from '../config';
|
|
5
|
+
import type {
|
|
6
|
+
DataAtRestEnvelope,
|
|
7
|
+
DecryptionTelemetryOutcome,
|
|
8
|
+
Env,
|
|
9
|
+
KeyRegistryPayload,
|
|
10
|
+
PrivateKeyRegistry
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
function normalizePrivateKeyPem(rawValue: string): string {
|
|
14
|
+
return rawValue.trim().replace(/^['"]|['"]$/g, '').replace(/\\n/g, '\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getNonEmptyString(value: unknown): string | null {
|
|
18
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
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
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildPrivateKeyCandidates(
|
|
90
|
+
recordKeyId: string,
|
|
91
|
+
registry: PrivateKeyRegistry
|
|
92
|
+
): Array<{ keyId: string; privateKeyPem: string }> {
|
|
93
|
+
const candidates: Array<{ keyId: string; privateKeyPem: string }> = [];
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
|
|
96
|
+
const appendCandidate = (candidateKeyId: string | null): void => {
|
|
97
|
+
if (!candidateKeyId || seen.has(candidateKeyId)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const privateKeyPem = registry.keys[candidateKeyId];
|
|
102
|
+
if (!privateKeyPem) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
seen.add(candidateKeyId);
|
|
107
|
+
candidates.push({ keyId: candidateKeyId, privateKeyPem });
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
appendCandidate(getNonEmptyString(recordKeyId));
|
|
111
|
+
appendCandidate(registry.activeKeyId);
|
|
112
|
+
|
|
113
|
+
for (const keyId of Object.keys(registry.keys)) {
|
|
114
|
+
appendCandidate(keyId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return candidates;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function logAuditDecryptionTelemetry(input: {
|
|
121
|
+
recordKeyId: string;
|
|
122
|
+
selectedKeyId: string | null;
|
|
123
|
+
attemptCount: number;
|
|
124
|
+
outcome: DecryptionTelemetryOutcome;
|
|
125
|
+
reason?: string;
|
|
126
|
+
}): void {
|
|
127
|
+
const details = {
|
|
128
|
+
scope: 'audit-at-rest',
|
|
129
|
+
recordKeyId: input.recordKeyId,
|
|
130
|
+
selectedKeyId: input.selectedKeyId,
|
|
131
|
+
attemptCount: input.attemptCount,
|
|
132
|
+
fallbackUsed: input.outcome === 'fallback-hit',
|
|
133
|
+
outcome: input.outcome,
|
|
134
|
+
reason: input.reason ?? null
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (input.outcome === 'all-failed') {
|
|
138
|
+
console.warn('Key registry decryption failed', details);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.info('Key registry decryption resolved', details);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function base64UrlDecode(value: string): Uint8Array {
|
|
146
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
147
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
148
|
+
const decoded = atob(normalized + padding);
|
|
149
|
+
const bytes = new Uint8Array(decoded.length);
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < decoded.length; i += 1) {
|
|
152
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return bytes;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function base64UrlEncode(value: Uint8Array): string {
|
|
159
|
+
let binary = '';
|
|
160
|
+
const chunkSize = 8192;
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < value.length; i += chunkSize) {
|
|
163
|
+
const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
|
|
164
|
+
for (let j = 0; j < chunk.length; j += 1) {
|
|
165
|
+
binary += String.fromCharCode(chunk[j]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return btoa(binary)
|
|
170
|
+
.replace(/\+/g, '-')
|
|
171
|
+
.replace(/\//g, '_')
|
|
172
|
+
.replace(/=+$/g, '');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
176
|
+
const normalizedKey = privateKey
|
|
177
|
+
.trim()
|
|
178
|
+
.replace(/^['"]|['"]$/g, '')
|
|
179
|
+
.replace(/\\n/g, '\n');
|
|
180
|
+
|
|
181
|
+
const pemBody = normalizedKey
|
|
182
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
183
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
184
|
+
.replace(/\s+/g, '');
|
|
185
|
+
|
|
186
|
+
if (!pemBody) {
|
|
187
|
+
throw new Error('Encryption private key is invalid');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const binary = atob(pemBody);
|
|
191
|
+
const bytes = new Uint8Array(binary.length);
|
|
192
|
+
|
|
193
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
194
|
+
bytes[index] = binary.charCodeAt(index);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return bytes.buffer;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
|
|
201
|
+
const normalizedKey = publicKey
|
|
202
|
+
.trim()
|
|
203
|
+
.replace(/^['"]|['"]$/g, '')
|
|
204
|
+
.replace(/\\n/g, '\n');
|
|
205
|
+
|
|
206
|
+
const pemBody = normalizedKey
|
|
207
|
+
.replace('-----BEGIN PUBLIC KEY-----', '')
|
|
208
|
+
.replace('-----END PUBLIC KEY-----', '')
|
|
209
|
+
.replace(/\s+/g, '');
|
|
210
|
+
|
|
211
|
+
if (!pemBody) {
|
|
212
|
+
throw new Error('Encryption public key is invalid');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const binary = atob(pemBody);
|
|
216
|
+
const bytes = new Uint8Array(binary.length);
|
|
217
|
+
|
|
218
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
219
|
+
bytes[index] = binary.charCodeAt(index);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return bytes.buffer;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
|
|
226
|
+
return crypto.subtle.importKey(
|
|
227
|
+
'pkcs8',
|
|
228
|
+
parsePkcs8PrivateKey(privateKeyPem),
|
|
229
|
+
{
|
|
230
|
+
name: 'RSA-OAEP',
|
|
231
|
+
hash: 'SHA-256'
|
|
232
|
+
},
|
|
233
|
+
false,
|
|
234
|
+
['decrypt']
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
|
239
|
+
return crypto.subtle.importKey(
|
|
240
|
+
'spki',
|
|
241
|
+
parseSpkiPublicKey(publicKeyPem),
|
|
242
|
+
{
|
|
243
|
+
name: 'RSA-OAEP',
|
|
244
|
+
hash: 'SHA-256'
|
|
245
|
+
},
|
|
246
|
+
false,
|
|
247
|
+
['encrypt']
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
|
|
252
|
+
return crypto.subtle.generateKey(
|
|
253
|
+
{
|
|
254
|
+
name: 'AES-GCM',
|
|
255
|
+
length: 256
|
|
256
|
+
},
|
|
257
|
+
true,
|
|
258
|
+
usages
|
|
259
|
+
) as Promise<CryptoKey>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
|
|
263
|
+
const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
|
|
264
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
265
|
+
const wrappedKey = await crypto.subtle.encrypt(
|
|
266
|
+
{ name: 'RSA-OAEP' },
|
|
267
|
+
rsaPublicKey,
|
|
268
|
+
rawAesKey as BufferSource
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
return base64UrlEncode(new Uint8Array(wrappedKey));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
|
|
275
|
+
const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
|
|
276
|
+
const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
|
|
277
|
+
|
|
278
|
+
const rawAesKey = await crypto.subtle.decrypt(
|
|
279
|
+
{ name: 'RSA-OAEP' },
|
|
280
|
+
rsaPrivateKey,
|
|
281
|
+
wrappedKeyBytes as BufferSource
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return crypto.subtle.importKey(
|
|
285
|
+
'raw',
|
|
286
|
+
rawAesKey,
|
|
287
|
+
{ name: 'AES-GCM' },
|
|
288
|
+
false,
|
|
289
|
+
['decrypt']
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function decryptJsonFromStorage(
|
|
294
|
+
ciphertext: ArrayBuffer,
|
|
295
|
+
envelope: DataAtRestEnvelope,
|
|
296
|
+
privateKeyPem: string
|
|
297
|
+
): Promise<string> {
|
|
298
|
+
const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
|
|
299
|
+
const iv = base64UrlDecode(envelope.dataIv);
|
|
300
|
+
|
|
301
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
302
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
303
|
+
aesKey,
|
|
304
|
+
ciphertext as BufferSource
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
return new TextDecoder().decode(plaintext);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function decryptAuditJsonWithRegistry(
|
|
311
|
+
ciphertext: ArrayBuffer,
|
|
312
|
+
envelope: DataAtRestEnvelope,
|
|
313
|
+
env: Env
|
|
314
|
+
): Promise<string> {
|
|
315
|
+
const keyRegistry = parseDataAtRestPrivateKeyRegistry(env);
|
|
316
|
+
const candidates = buildPrivateKeyCandidates(envelope.keyId, keyRegistry);
|
|
317
|
+
const primaryKeyId = candidates[0]?.keyId ?? null;
|
|
318
|
+
let lastError: unknown;
|
|
319
|
+
|
|
320
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
321
|
+
const candidate = candidates[index];
|
|
322
|
+
try {
|
|
323
|
+
const plaintext = await decryptJsonFromStorage(ciphertext, envelope, candidate.privateKeyPem);
|
|
324
|
+
logAuditDecryptionTelemetry({
|
|
325
|
+
recordKeyId: envelope.keyId,
|
|
326
|
+
selectedKeyId: candidate.keyId,
|
|
327
|
+
attemptCount: index + 1,
|
|
328
|
+
outcome: candidate.keyId === primaryKeyId ? 'primary-hit' : 'fallback-hit'
|
|
329
|
+
});
|
|
330
|
+
return plaintext;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
lastError = error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
logAuditDecryptionTelemetry({
|
|
337
|
+
recordKeyId: envelope.keyId,
|
|
338
|
+
selectedKeyId: null,
|
|
339
|
+
attemptCount: candidates.length,
|
|
340
|
+
outcome: 'all-failed',
|
|
341
|
+
reason: lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
throw new Error(
|
|
345
|
+
`Failed to decrypt audit record after ${candidates.length} key attempt(s): ${
|
|
346
|
+
lastError instanceof Error ? lastError.message : 'unknown decryption error'
|
|
347
|
+
}`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function encryptJsonForStorage(
|
|
352
|
+
plaintextJson: string,
|
|
353
|
+
publicKeyPem: string,
|
|
354
|
+
keyId: string
|
|
355
|
+
): Promise<{ ciphertext: Uint8Array; envelope: DataAtRestEnvelope }> {
|
|
356
|
+
const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
|
|
357
|
+
const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
|
|
358
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
359
|
+
|
|
360
|
+
const plaintextBytes = new TextEncoder().encode(plaintextJson);
|
|
361
|
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
|
362
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
363
|
+
aesKey,
|
|
364
|
+
plaintextBytes as BufferSource
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
ciphertext: new Uint8Array(encryptedBuffer),
|
|
369
|
+
envelope: {
|
|
370
|
+
algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
|
|
371
|
+
encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
|
|
372
|
+
keyId,
|
|
373
|
+
dataIv: base64UrlEncode(iv),
|
|
374
|
+
wrappedKey
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function extractDataAtRestEnvelope(file: R2ObjectBody): DataAtRestEnvelope | null {
|
|
380
|
+
const metadata = file.customMetadata;
|
|
381
|
+
if (!metadata) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const {
|
|
386
|
+
algorithm,
|
|
387
|
+
encryptionVersion,
|
|
388
|
+
keyId,
|
|
389
|
+
dataIv,
|
|
390
|
+
wrappedKey
|
|
391
|
+
} = metadata;
|
|
392
|
+
|
|
393
|
+
if (
|
|
394
|
+
typeof algorithm !== 'string' ||
|
|
395
|
+
typeof encryptionVersion !== 'string' ||
|
|
396
|
+
typeof keyId !== 'string' ||
|
|
397
|
+
typeof dataIv !== 'string' ||
|
|
398
|
+
typeof wrappedKey !== 'string'
|
|
399
|
+
) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
algorithm,
|
|
405
|
+
encryptionVersion,
|
|
406
|
+
keyId,
|
|
407
|
+
dataIv,
|
|
408
|
+
wrappedKey
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendAuditEntry,
|
|
3
|
+
generateAuditFileName,
|
|
4
|
+
isValidAuditEntry,
|
|
5
|
+
readAuditEntriesFromObject
|
|
6
|
+
} from '../storage/audit-storage';
|
|
7
|
+
import type { AuditEntry, CreateResponse, Env } from '../types';
|
|
8
|
+
|
|
9
|
+
export async function handleAuditRequest(
|
|
10
|
+
request: Request,
|
|
11
|
+
env: Env,
|
|
12
|
+
url: URL,
|
|
13
|
+
respond: CreateResponse
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
const bucket = env.STRIAE_AUDIT;
|
|
16
|
+
const userId = url.searchParams.get('userId');
|
|
17
|
+
const startDate = url.searchParams.get('startDate');
|
|
18
|
+
const endDate = url.searchParams.get('endDate');
|
|
19
|
+
|
|
20
|
+
if (request.method === 'POST') {
|
|
21
|
+
if (!userId) {
|
|
22
|
+
return respond({ error: 'userId parameter is required' }, 400);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const auditEntry: unknown = await request.json();
|
|
26
|
+
|
|
27
|
+
if (!isValidAuditEntry(auditEntry)) {
|
|
28
|
+
return respond({ error: 'Invalid audit entry structure. Required fields: timestamp, userId, action' }, 400);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (auditEntry.userId !== userId) {
|
|
32
|
+
return respond({ error: 'userId parameter must match auditEntry.userId' }, 400);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const filename = generateAuditFileName(userId);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const entryCount = await appendAuditEntry(bucket, filename, auditEntry, env);
|
|
39
|
+
return respond({
|
|
40
|
+
success: true,
|
|
41
|
+
entryCount,
|
|
42
|
+
filename
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
46
|
+
return respond({ error: `Failed to store audit entry: ${errorMessage}` }, 500);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (request.method === 'GET') {
|
|
51
|
+
if (!userId) {
|
|
52
|
+
return respond({ error: 'userId parameter is required' }, 400);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
let allEntries: AuditEntry[] = [];
|
|
57
|
+
|
|
58
|
+
if (startDate && endDate) {
|
|
59
|
+
const start = new Date(startDate);
|
|
60
|
+
const end = new Date(endDate);
|
|
61
|
+
const currentDate = new Date(start);
|
|
62
|
+
|
|
63
|
+
while (currentDate <= end) {
|
|
64
|
+
const filename = generateAuditFileName(userId, currentDate);
|
|
65
|
+
const file = await bucket.get(filename);
|
|
66
|
+
|
|
67
|
+
if (file) {
|
|
68
|
+
const entries = await readAuditEntriesFromObject(file, env);
|
|
69
|
+
allEntries.push(...entries);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
const filename = generateAuditFileName(userId);
|
|
76
|
+
const file = await bucket.get(filename);
|
|
77
|
+
|
|
78
|
+
if (file) {
|
|
79
|
+
allEntries = await readAuditEntriesFromObject(file, env);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
84
|
+
|
|
85
|
+
return respond({
|
|
86
|
+
entries: allEntries,
|
|
87
|
+
total: allEntries.length
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
91
|
+
return respond({ error: `Failed to retrieve audit entries: ${errorMessage}` }, 500);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (request.method === 'DELETE') {
|
|
96
|
+
if (!userId) {
|
|
97
|
+
return respond({ error: 'userId parameter is required' }, 400);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const prefix = `audit-trails/${userId}/`;
|
|
102
|
+
let deletedCount = 0;
|
|
103
|
+
let cursor: string | undefined;
|
|
104
|
+
|
|
105
|
+
do {
|
|
106
|
+
const listed = await bucket.list({ prefix, cursor, limit: 1000 });
|
|
107
|
+
|
|
108
|
+
const keys = listed.objects.map((obj) => obj.key);
|
|
109
|
+
if (keys.length > 0) {
|
|
110
|
+
await bucket.delete(keys);
|
|
111
|
+
deletedCount += keys.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
115
|
+
} while (cursor !== undefined);
|
|
116
|
+
|
|
117
|
+
return respond({ success: true, deletedCount });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
120
|
+
return respond({ error: `Failed to delete audit entries: ${errorMessage}` }, 500);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return respond({ error: 'Method not allowed for audit endpoints. Only GET, POST, and DELETE are supported.' }, 405);
|
|
125
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DATA_AT_REST_ENCRYPTION_ALGORITHM,
|
|
3
|
+
DATA_AT_REST_ENCRYPTION_VERSION
|
|
4
|
+
} from '../config';
|
|
5
|
+
import {
|
|
6
|
+
decryptAuditJsonWithRegistry,
|
|
7
|
+
encryptJsonForStorage,
|
|
8
|
+
extractDataAtRestEnvelope
|
|
9
|
+
} from '../crypto/data-at-rest';
|
|
10
|
+
import type { AuditEntry, Env } from '../types';
|
|
11
|
+
|
|
12
|
+
export function generateAuditFileName(userId: string, date: Date = new Date()): string {
|
|
13
|
+
const isoDate = date.toISOString().split('T')[0];
|
|
14
|
+
return `audit-trails/${userId}/${isoDate}.json`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isValidAuditEntry(entry: unknown): entry is AuditEntry {
|
|
18
|
+
const candidate = entry as Partial<AuditEntry> | null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
typeof candidate === 'object' &&
|
|
22
|
+
candidate !== null &&
|
|
23
|
+
typeof candidate.timestamp === 'string' &&
|
|
24
|
+
typeof candidate.userId === 'string' &&
|
|
25
|
+
typeof candidate.action === 'string'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readAuditEntriesFromObject(file: R2ObjectBody, env: Env): Promise<AuditEntry[]> {
|
|
30
|
+
const atRestEnvelope = extractDataAtRestEnvelope(file);
|
|
31
|
+
if (!atRestEnvelope) {
|
|
32
|
+
throw new Error('Audit record is not encrypted');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (atRestEnvelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
|
|
36
|
+
throw new Error('Unsupported data-at-rest encryption algorithm');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (atRestEnvelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
|
|
40
|
+
throw new Error('Unsupported data-at-rest encryption version');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const encryptedData = await file.arrayBuffer();
|
|
44
|
+
const plaintext = await decryptAuditJsonWithRegistry(encryptedData, atRestEnvelope, env);
|
|
45
|
+
|
|
46
|
+
return JSON.parse(plaintext) as AuditEntry[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeAuditEntriesToObject(
|
|
50
|
+
bucket: R2Bucket,
|
|
51
|
+
filename: string,
|
|
52
|
+
entries: AuditEntry[],
|
|
53
|
+
env: Env
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const serializedData = JSON.stringify(entries);
|
|
56
|
+
|
|
57
|
+
if (!env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY || !env.DATA_AT_REST_ENCRYPTION_KEY_ID) {
|
|
58
|
+
throw new Error('Audit encryption is not fully configured');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const encryptedPayload = await encryptJsonForStorage(
|
|
62
|
+
serializedData,
|
|
63
|
+
env.DATA_AT_REST_ENCRYPTION_PUBLIC_KEY,
|
|
64
|
+
env.DATA_AT_REST_ENCRYPTION_KEY_ID
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await bucket.put(filename, encryptedPayload.ciphertext, {
|
|
68
|
+
customMetadata: {
|
|
69
|
+
algorithm: encryptedPayload.envelope.algorithm,
|
|
70
|
+
encryptionVersion: encryptedPayload.envelope.encryptionVersion,
|
|
71
|
+
keyId: encryptedPayload.envelope.keyId,
|
|
72
|
+
dataIv: encryptedPayload.envelope.dataIv,
|
|
73
|
+
wrappedKey: encryptedPayload.envelope.wrappedKey
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function appendAuditEntry(
|
|
79
|
+
bucket: R2Bucket,
|
|
80
|
+
filename: string,
|
|
81
|
+
newEntry: AuditEntry,
|
|
82
|
+
env: Env
|
|
83
|
+
): Promise<number> {
|
|
84
|
+
try {
|
|
85
|
+
const existingFile = await bucket.get(filename);
|
|
86
|
+
let entries: AuditEntry[] = [];
|
|
87
|
+
|
|
88
|
+
if (existingFile) {
|
|
89
|
+
entries = await readAuditEntriesFromObject(existingFile, env);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
entries.push(newEntry);
|
|
93
|
+
await writeAuditEntriesToObject(bucket, filename, entries, env);
|
|
94
|
+
return entries.length;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('Error appending audit entry:', error);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface Env {
|
|
2
|
+
R2_KEY_SECRET: string;
|
|
3
|
+
STRIAE_AUDIT: R2Bucket;
|
|
4
|
+
DATA_AT_REST_ENCRYPTION_ENABLED?: string;
|
|
5
|
+
DATA_AT_REST_ENCRYPTION_PRIVATE_KEY?: string;
|
|
6
|
+
DATA_AT_REST_ENCRYPTION_PUBLIC_KEY?: string;
|
|
7
|
+
DATA_AT_REST_ENCRYPTION_KEY_ID?: string;
|
|
8
|
+
DATA_AT_REST_ENCRYPTION_KEYS_JSON?: string;
|
|
9
|
+
DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KeyRegistryPayload {
|
|
13
|
+
activeKeyId?: unknown;
|
|
14
|
+
keys?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PrivateKeyRegistry {
|
|
18
|
+
activeKeyId: string | null;
|
|
19
|
+
keys: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DecryptionTelemetryOutcome = 'primary-hit' | 'fallback-hit' | 'all-failed';
|
|
23
|
+
|
|
24
|
+
export interface AuditEntry {
|
|
25
|
+
timestamp: string;
|
|
26
|
+
userId: string;
|
|
27
|
+
action: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SuccessResponse {
|
|
32
|
+
success: boolean;
|
|
33
|
+
entryCount?: number;
|
|
34
|
+
filename?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ErrorResponse {
|
|
38
|
+
error: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AuditRetrievalResponse {
|
|
42
|
+
entries: AuditEntry[];
|
|
43
|
+
total: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type APIResponse = SuccessResponse | ErrorResponse | AuditRetrievalResponse | Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
export type CreateResponse = (data: APIResponse, status?: number) => Response;
|
|
49
|
+
|
|
50
|
+
export interface DataAtRestEnvelope {
|
|
51
|
+
algorithm: string;
|
|
52
|
+
encryptionVersion: string;
|
|
53
|
+
keyId: string;
|
|
54
|
+
dataIv: string;
|
|
55
|
+
wrappedKey: string;
|
|
56
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
2
|
// Generated by Wrangler by running `wrangler types` (hash: e47787017d9af07a8f6e4fa965fca8c2)
|
|
3
|
-
// Runtime types generated with workerd@1.20250823.0 2026-03-
|
|
3
|
+
// Runtime types generated with workerd@1.20250823.0 2026-03-26 nodejs_compat
|
|
4
4
|
declare namespace Cloudflare {
|
|
5
5
|
interface Env {
|
|
6
6
|
STRIAE_AUDIT: R2Bucket;
|