@striae-org/striae 4.3.4 → 5.1.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 +9 -2
- package/app/components/actions/case-export/download-handlers.ts +66 -11
- package/app/components/actions/case-import/confirmation-import.ts +50 -7
- package/app/components/actions/case-import/confirmation-package.ts +99 -22
- package/app/components/actions/case-import/orchestrator.ts +116 -13
- package/app/components/actions/case-import/validation.ts +171 -7
- package/app/components/actions/case-import/zip-processing.ts +224 -127
- package/app/components/actions/case-manage.ts +74 -15
- package/app/components/actions/confirm-export.ts +32 -3
- package/app/components/actions/generate-pdf.ts +43 -1
- package/app/components/actions/image-manage.ts +13 -45
- package/app/components/navbar/navbar.module.css +0 -10
- package/app/components/navbar/navbar.tsx +0 -22
- package/app/components/sidebar/case-import/case-import.module.css +7 -131
- package/app/components/sidebar/case-import/case-import.tsx +7 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
- package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
- package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
- package/app/config-example/config.json +5 -0
- package/app/routes/auth/login.tsx +1 -1
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +4 -0
- package/app/routes/striae/striae.tsx +15 -4
- package/app/utils/data/operations/case-operations.ts +13 -1
- package/app/utils/data/operations/confirmation-summary-operations.ts +38 -1
- package/app/utils/data/operations/file-annotation-operations.ts +13 -1
- package/app/utils/data/operations/signing-operations.ts +93 -0
- package/app/utils/data/operations/types.ts +6 -0
- package/app/utils/forensics/export-encryption.ts +316 -0
- package/app/utils/forensics/export-verification.ts +1 -409
- package/app/utils/forensics/index.ts +1 -0
- package/app/utils/ui/case-messages.ts +5 -2
- package/package.json +2 -2
- package/scripts/deploy-config.sh +244 -7
- package/scripts/deploy-pages-secrets.sh +0 -6
- package/scripts/deploy-worker-secrets.sh +66 -5
- package/scripts/encrypt-r2-backfill.mjs +376 -0
- package/worker-configuration.d.ts +13 -7
- package/workers/audit-worker/package.json +1 -4
- package/workers/audit-worker/src/audit-worker.example.ts +522 -61
- package/workers/audit-worker/wrangler.jsonc.example +6 -1
- package/workers/data-worker/package.json +1 -4
- package/workers/data-worker/src/data-worker.example.ts +409 -1
- package/workers/data-worker/src/encryption-utils.ts +269 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +6 -2
- package/workers/image-worker/package.json +1 -4
- package/workers/image-worker/src/encryption-utils.ts +217 -0
- package/workers/image-worker/src/image-worker.example.ts +196 -127
- package/workers/image-worker/wrangler.jsonc.example +8 -1
- package/workers/keys-worker/package.json +1 -4
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -4
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -4
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
export function base64UrlDecode(value: string): Uint8Array {
|
|
2
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
3
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
4
|
+
const decoded = atob(normalized + padding);
|
|
5
|
+
const bytes = new Uint8Array(decoded.length);
|
|
6
|
+
|
|
7
|
+
for (let i = 0; i < decoded.length; i += 1) {
|
|
8
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return bytes;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function base64UrlEncode(value: Uint8Array): string {
|
|
15
|
+
let binary = '';
|
|
16
|
+
const chunkSize = 8192;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < value.length; i += chunkSize) {
|
|
19
|
+
const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
|
|
20
|
+
for (let j = 0; j < chunk.length; j += 1) {
|
|
21
|
+
binary += String.fromCharCode(chunk[j]);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return btoa(binary)
|
|
26
|
+
.replace(/\+/g, '-')
|
|
27
|
+
.replace(/\//g, '_')
|
|
28
|
+
.replace(/=+$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
32
|
+
const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
|
|
33
|
+
|
|
34
|
+
export interface DataAtRestEnvelope {
|
|
35
|
+
algorithm: string;
|
|
36
|
+
encryptionVersion: string;
|
|
37
|
+
keyId: string;
|
|
38
|
+
dataIv: string;
|
|
39
|
+
wrappedKey: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EncryptJsonAtRestResult {
|
|
43
|
+
ciphertext: Uint8Array;
|
|
44
|
+
envelope: DataAtRestEnvelope;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
|
|
48
|
+
const normalizedKey = publicKey
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^['"]|['"]$/g, '')
|
|
51
|
+
.replace(/\\n/g, '\n');
|
|
52
|
+
|
|
53
|
+
const pemBody = normalizedKey
|
|
54
|
+
.replace('-----BEGIN PUBLIC KEY-----', '')
|
|
55
|
+
.replace('-----END PUBLIC KEY-----', '')
|
|
56
|
+
.replace(/\s+/g, '');
|
|
57
|
+
|
|
58
|
+
if (!pemBody) {
|
|
59
|
+
throw new Error('Encryption public key is invalid');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const binary = atob(pemBody);
|
|
63
|
+
const bytes = new Uint8Array(binary.length);
|
|
64
|
+
|
|
65
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
66
|
+
bytes[index] = binary.charCodeAt(index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return bytes.buffer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
73
|
+
const normalizedKey = privateKey
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/^['"]|['"]$/g, '')
|
|
76
|
+
.replace(/\\n/g, '\n');
|
|
77
|
+
|
|
78
|
+
const pemBody = normalizedKey
|
|
79
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
80
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
81
|
+
.replace(/\s+/g, '');
|
|
82
|
+
|
|
83
|
+
if (!pemBody) {
|
|
84
|
+
throw new Error('Encryption private key is invalid');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const binary = atob(pemBody);
|
|
88
|
+
const bytes = new Uint8Array(binary.length);
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
91
|
+
bytes[index] = binary.charCodeAt(index);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return bytes.buffer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Import RSA private key from PKCS8 PEM format
|
|
99
|
+
*/
|
|
100
|
+
async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
|
|
101
|
+
const key = await crypto.subtle.importKey(
|
|
102
|
+
'pkcs8',
|
|
103
|
+
parsePkcs8PrivateKey(privateKeyPem),
|
|
104
|
+
{
|
|
105
|
+
name: 'RSA-OAEP',
|
|
106
|
+
hash: 'SHA-256'
|
|
107
|
+
},
|
|
108
|
+
false,
|
|
109
|
+
['decrypt']
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return key;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
|
116
|
+
const key = await crypto.subtle.importKey(
|
|
117
|
+
'spki',
|
|
118
|
+
parseSpkiPublicKey(publicKeyPem),
|
|
119
|
+
{
|
|
120
|
+
name: 'RSA-OAEP',
|
|
121
|
+
hash: 'SHA-256'
|
|
122
|
+
},
|
|
123
|
+
false,
|
|
124
|
+
['encrypt']
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return key;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
|
|
131
|
+
return crypto.subtle.generateKey(
|
|
132
|
+
{
|
|
133
|
+
name: 'AES-GCM',
|
|
134
|
+
length: 256
|
|
135
|
+
},
|
|
136
|
+
true,
|
|
137
|
+
usages
|
|
138
|
+
) as Promise<CryptoKey>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function wrapAesKey(
|
|
142
|
+
aesKey: CryptoKey,
|
|
143
|
+
publicKeyPem: string
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
|
|
146
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
147
|
+
const wrappedKey = await crypto.subtle.encrypt(
|
|
148
|
+
{ name: 'RSA-OAEP' },
|
|
149
|
+
rsaPublicKey,
|
|
150
|
+
rawAesKey as BufferSource
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return base64UrlEncode(new Uint8Array(wrappedKey));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Decrypt AES key from RSA-OAEP wrapped form
|
|
158
|
+
*/
|
|
159
|
+
async function unwrapAesKey(
|
|
160
|
+
wrappedKeyBase64: string,
|
|
161
|
+
privateKeyPem: string
|
|
162
|
+
): Promise<CryptoKey> {
|
|
163
|
+
const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
|
|
164
|
+
const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
|
|
165
|
+
|
|
166
|
+
const rawAesKey = await crypto.subtle.decrypt(
|
|
167
|
+
{ name: 'RSA-OAEP' },
|
|
168
|
+
rsaPrivateKey,
|
|
169
|
+
wrappedKeyBytes as BufferSource
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return crypto.subtle.importKey(
|
|
173
|
+
'raw',
|
|
174
|
+
rawAesKey,
|
|
175
|
+
{ name: 'AES-GCM' },
|
|
176
|
+
false,
|
|
177
|
+
['encrypt', 'decrypt']
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function encryptJsonForStorage(
|
|
182
|
+
plaintextJson: string,
|
|
183
|
+
publicKeyPem: string,
|
|
184
|
+
keyId: string
|
|
185
|
+
): Promise<EncryptJsonAtRestResult> {
|
|
186
|
+
const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
|
|
187
|
+
const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
|
|
188
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
189
|
+
|
|
190
|
+
const plaintextBytes = new TextEncoder().encode(plaintextJson);
|
|
191
|
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
|
192
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
193
|
+
aesKey,
|
|
194
|
+
plaintextBytes as BufferSource
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
ciphertext: new Uint8Array(encryptedBuffer),
|
|
199
|
+
envelope: {
|
|
200
|
+
algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
|
|
201
|
+
encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
|
|
202
|
+
keyId,
|
|
203
|
+
dataIv: base64UrlEncode(iv),
|
|
204
|
+
wrappedKey
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function decryptJsonFromStorage(
|
|
210
|
+
ciphertext: ArrayBuffer,
|
|
211
|
+
envelope: DataAtRestEnvelope,
|
|
212
|
+
privateKeyPem: string
|
|
213
|
+
): Promise<string> {
|
|
214
|
+
const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
|
|
215
|
+
const iv = base64UrlDecode(envelope.dataIv);
|
|
216
|
+
|
|
217
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
218
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
219
|
+
aesKey,
|
|
220
|
+
ciphertext as BufferSource
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return new TextDecoder().decode(plaintext);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Decrypt data file (plaintext JSON/CSV)
|
|
228
|
+
*/
|
|
229
|
+
export async function decryptExportData(
|
|
230
|
+
encryptedDataBase64: string,
|
|
231
|
+
wrappedKeyBase64: string,
|
|
232
|
+
ivBase64: string,
|
|
233
|
+
privateKeyPem: string
|
|
234
|
+
): Promise<string> {
|
|
235
|
+
const aesKey = await unwrapAesKey(wrappedKeyBase64, privateKeyPem);
|
|
236
|
+
const iv = base64UrlDecode(ivBase64);
|
|
237
|
+
const ciphertext = base64UrlDecode(encryptedDataBase64);
|
|
238
|
+
|
|
239
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
240
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
241
|
+
aesKey,
|
|
242
|
+
ciphertext as BufferSource
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return new TextDecoder().decode(plaintext);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Decrypt a single image blob
|
|
250
|
+
*/
|
|
251
|
+
export async function decryptImageBlob(
|
|
252
|
+
encryptedImageBase64: string,
|
|
253
|
+
wrappedKeyBase64: string,
|
|
254
|
+
ivBase64: string,
|
|
255
|
+
privateKeyPem: string
|
|
256
|
+
): Promise<Blob> {
|
|
257
|
+
const aesKey = await unwrapAesKey(wrappedKeyBase64, privateKeyPem);
|
|
258
|
+
const iv = base64UrlDecode(ivBase64);
|
|
259
|
+
const ciphertext = base64UrlDecode(encryptedImageBase64);
|
|
260
|
+
|
|
261
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
262
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
263
|
+
aesKey,
|
|
264
|
+
ciphertext as BufferSource
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Return as blob (caller can determine MIME type from context)
|
|
268
|
+
return new Blob([plaintext]);
|
|
269
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
2
|
// Generated by Wrangler by running `wrangler types` (hash: 4ccb8b314830f4c7bb743cb9b033a6cb)
|
|
3
|
-
// Runtime types generated with workerd@1.20250823.0 2026-03-
|
|
3
|
+
// Runtime types generated with workerd@1.20250823.0 2026-03-23 nodejs_compat
|
|
4
4
|
declare namespace Cloudflare {
|
|
5
5
|
interface Env {
|
|
6
6
|
STRIAE_DATA: R2Bucket;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
// Required secrets: R2_KEY_SECRET, MANIFEST_SIGNING_PRIVATE_KEY, MANIFEST_SIGNING_KEY_ID
|
|
2
|
+
// Required secrets: R2_KEY_SECRET, MANIFEST_SIGNING_PRIVATE_KEY, MANIFEST_SIGNING_KEY_ID, EXPORT_ENCRYPTION_PRIVATE_KEY, EXPORT_ENCRYPTION_KEY_ID
|
|
3
|
+
// Optional data-at-rest secrets/vars:
|
|
4
|
+
// - DATA_AT_REST_ENCRYPTION_ENABLED=true
|
|
5
|
+
// - DATA_AT_REST_ENCRYPTION_PRIVATE_KEY (required for decrypting encrypted records)
|
|
6
|
+
// - DATA_AT_REST_ENCRYPTION_PUBLIC_KEY and DATA_AT_REST_ENCRYPTION_KEY_ID (required when encrypt-on-write is enabled)
|
|
3
7
|
"name": "DATA_WORKER_NAME",
|
|
4
8
|
"account_id": "ACCOUNT_ID",
|
|
5
9
|
"main": "src/data-worker.ts",
|
|
6
|
-
"compatibility_date": "2026-03-
|
|
10
|
+
"compatibility_date": "2026-03-24",
|
|
7
11
|
"compatibility_flags": [
|
|
8
12
|
"nodejs_compat"
|
|
9
13
|
],
|
|
@@ -5,13 +5,10 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"deploy": "wrangler deploy",
|
|
7
7
|
"dev": "wrangler dev",
|
|
8
|
-
"start": "wrangler dev"
|
|
9
|
-
"test": "vitest"
|
|
8
|
+
"start": "wrangler dev"
|
|
10
9
|
},
|
|
11
10
|
"devDependencies": {
|
|
12
11
|
"@cloudflare/puppeteer": "^1.0.6",
|
|
13
|
-
"@cloudflare/vitest-pool-workers": "^0.13.0",
|
|
14
|
-
"vitest": "~4.1.0",
|
|
15
12
|
"wrangler": "^4.76.0"
|
|
16
13
|
},
|
|
17
14
|
"overrides": {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export function base64UrlDecode(value: string): Uint8Array {
|
|
2
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
3
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
4
|
+
const decoded = atob(normalized + padding);
|
|
5
|
+
const bytes = new Uint8Array(decoded.length);
|
|
6
|
+
|
|
7
|
+
for (let i = 0; i < decoded.length; i += 1) {
|
|
8
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return bytes;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function base64UrlEncode(value: Uint8Array): string {
|
|
15
|
+
let binary = '';
|
|
16
|
+
const chunkSize = 8192;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < value.length; i += chunkSize) {
|
|
19
|
+
const chunk = value.subarray(i, Math.min(i + chunkSize, value.length));
|
|
20
|
+
for (let j = 0; j < chunk.length; j += 1) {
|
|
21
|
+
binary += String.fromCharCode(chunk[j]);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return btoa(binary)
|
|
26
|
+
.replace(/\+/g, '-')
|
|
27
|
+
.replace(/\//g, '_')
|
|
28
|
+
.replace(/=+$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DATA_AT_REST_ENCRYPTION_ALGORITHM = 'RSA-OAEP-AES-256-GCM';
|
|
32
|
+
const DATA_AT_REST_ENCRYPTION_VERSION = '1.0';
|
|
33
|
+
|
|
34
|
+
export interface DataAtRestEnvelope {
|
|
35
|
+
algorithm: string;
|
|
36
|
+
encryptionVersion: string;
|
|
37
|
+
keyId: string;
|
|
38
|
+
dataIv: string;
|
|
39
|
+
wrappedKey: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EncryptBinaryAtRestResult {
|
|
43
|
+
ciphertext: Uint8Array;
|
|
44
|
+
envelope: DataAtRestEnvelope;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseSpkiPublicKey(publicKey: string): ArrayBuffer {
|
|
48
|
+
const normalizedKey = publicKey
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^['"]|['"]$/g, '')
|
|
51
|
+
.replace(/\\n/g, '\n');
|
|
52
|
+
|
|
53
|
+
const pemBody = normalizedKey
|
|
54
|
+
.replace('-----BEGIN PUBLIC KEY-----', '')
|
|
55
|
+
.replace('-----END PUBLIC KEY-----', '')
|
|
56
|
+
.replace(/\s+/g, '');
|
|
57
|
+
|
|
58
|
+
if (!pemBody) {
|
|
59
|
+
throw new Error('Encryption public key is invalid');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const binary = atob(pemBody);
|
|
63
|
+
const bytes = new Uint8Array(binary.length);
|
|
64
|
+
|
|
65
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
66
|
+
bytes[index] = binary.charCodeAt(index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return bytes.buffer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
73
|
+
const normalizedKey = privateKey
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/^['"]|['"]$/g, '')
|
|
76
|
+
.replace(/\\n/g, '\n');
|
|
77
|
+
|
|
78
|
+
const pemBody = normalizedKey
|
|
79
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
80
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
81
|
+
.replace(/\s+/g, '');
|
|
82
|
+
|
|
83
|
+
if (!pemBody) {
|
|
84
|
+
throw new Error('Encryption private key is invalid');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const binary = atob(pemBody);
|
|
88
|
+
const bytes = new Uint8Array(binary.length);
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
91
|
+
bytes[index] = binary.charCodeAt(index);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return bytes.buffer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
|
|
98
|
+
return crypto.subtle.importKey(
|
|
99
|
+
'pkcs8',
|
|
100
|
+
parsePkcs8PrivateKey(privateKeyPem),
|
|
101
|
+
{
|
|
102
|
+
name: 'RSA-OAEP',
|
|
103
|
+
hash: 'SHA-256'
|
|
104
|
+
},
|
|
105
|
+
false,
|
|
106
|
+
['decrypt']
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function importRsaOaepPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
|
111
|
+
return crypto.subtle.importKey(
|
|
112
|
+
'spki',
|
|
113
|
+
parseSpkiPublicKey(publicKeyPem),
|
|
114
|
+
{
|
|
115
|
+
name: 'RSA-OAEP',
|
|
116
|
+
hash: 'SHA-256'
|
|
117
|
+
},
|
|
118
|
+
false,
|
|
119
|
+
['encrypt']
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function createAesGcmKey(usages: KeyUsage[]): Promise<CryptoKey> {
|
|
124
|
+
return crypto.subtle.generateKey(
|
|
125
|
+
{
|
|
126
|
+
name: 'AES-GCM',
|
|
127
|
+
length: 256
|
|
128
|
+
},
|
|
129
|
+
true,
|
|
130
|
+
usages
|
|
131
|
+
) as Promise<CryptoKey>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function wrapAesKey(aesKey: CryptoKey, publicKeyPem: string): Promise<string> {
|
|
135
|
+
const rsaPublicKey = await importRsaOaepPublicKey(publicKeyPem);
|
|
136
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
137
|
+
const wrappedKey = await crypto.subtle.encrypt(
|
|
138
|
+
{ name: 'RSA-OAEP' },
|
|
139
|
+
rsaPublicKey,
|
|
140
|
+
rawAesKey as BufferSource
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return base64UrlEncode(new Uint8Array(wrappedKey));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function unwrapAesKey(wrappedKeyBase64: string, privateKeyPem: string): Promise<CryptoKey> {
|
|
147
|
+
const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
|
|
148
|
+
const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
|
|
149
|
+
|
|
150
|
+
const rawAesKey = await crypto.subtle.decrypt(
|
|
151
|
+
{ name: 'RSA-OAEP' },
|
|
152
|
+
rsaPrivateKey,
|
|
153
|
+
wrappedKeyBytes as BufferSource
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return crypto.subtle.importKey(
|
|
157
|
+
'raw',
|
|
158
|
+
rawAesKey,
|
|
159
|
+
{ name: 'AES-GCM' },
|
|
160
|
+
false,
|
|
161
|
+
['encrypt', 'decrypt']
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validateEnvelope(envelope: DataAtRestEnvelope): void {
|
|
166
|
+
if (envelope.algorithm !== DATA_AT_REST_ENCRYPTION_ALGORITHM) {
|
|
167
|
+
throw new Error('Unsupported data-at-rest encryption algorithm');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (envelope.encryptionVersion !== DATA_AT_REST_ENCRYPTION_VERSION) {
|
|
171
|
+
throw new Error('Unsupported data-at-rest encryption version');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function encryptBinaryForStorage(
|
|
176
|
+
plaintextBytes: ArrayBuffer,
|
|
177
|
+
publicKeyPem: string,
|
|
178
|
+
keyId: string
|
|
179
|
+
): Promise<EncryptBinaryAtRestResult> {
|
|
180
|
+
const aesKey = await createAesGcmKey(['encrypt', 'decrypt']);
|
|
181
|
+
const wrappedKey = await wrapAesKey(aesKey, publicKeyPem);
|
|
182
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
183
|
+
|
|
184
|
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
|
185
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
186
|
+
aesKey,
|
|
187
|
+
plaintextBytes as BufferSource
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ciphertext: new Uint8Array(encryptedBuffer),
|
|
192
|
+
envelope: {
|
|
193
|
+
algorithm: DATA_AT_REST_ENCRYPTION_ALGORITHM,
|
|
194
|
+
encryptionVersion: DATA_AT_REST_ENCRYPTION_VERSION,
|
|
195
|
+
keyId,
|
|
196
|
+
dataIv: base64UrlEncode(iv),
|
|
197
|
+
wrappedKey
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function decryptBinaryFromStorage(
|
|
203
|
+
ciphertext: ArrayBuffer,
|
|
204
|
+
envelope: DataAtRestEnvelope,
|
|
205
|
+
privateKeyPem: string
|
|
206
|
+
): Promise<ArrayBuffer> {
|
|
207
|
+
validateEnvelope(envelope);
|
|
208
|
+
|
|
209
|
+
const aesKey = await unwrapAesKey(envelope.wrappedKey, privateKeyPem);
|
|
210
|
+
const iv = base64UrlDecode(envelope.dataIv);
|
|
211
|
+
|
|
212
|
+
return crypto.subtle.decrypt(
|
|
213
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
214
|
+
aesKey,
|
|
215
|
+
ciphertext as BufferSource
|
|
216
|
+
);
|
|
217
|
+
}
|