Skip to content
File

Blob: src/worker/security/secrets.ts

typescript161 lines
1import { HttpError } from "@/worker/http";
2import { getConfig } from "@/worker/config";
3import { decodeBase64, toArrayBuffer } from "@/worker/services/crypto";
4 
5const AES_GCM_ALGORITHM = "AES-GCM";
6const AES_GCM_NONCE_BYTES = 12;
7const AES_256_KEY_BYTES = 32;
8 
9interface EncryptionKeyConfig {
10 currentVersion: number;
11 keys: Map<number, string>;
12}
13 
14export interface EncryptedSecret {
15 ciphertext: Uint8Array;
16 keyVersion: number;
17 nonce: Uint8Array;
18}
19 
20const importedKeys = new Map<string, Promise<CryptoKey>>();
21 
22const encryptionNotConfigured = (message = "Repository token encryption is not configured."): HttpError =>
23 new HttpError(500, "encryption_not_configured", message);
24 
25const readEncryptionConfig = (env: Env): EncryptionKeyConfig => {
26 const config = getConfig(env);
27 
28 if (!Number.isInteger(config.appEncryptionKeyCurrentVersion) || config.appEncryptionKeyCurrentVersion <= 0) {
29 throw encryptionNotConfigured();
30 }
31 
32 let parsed: unknown;
33 try {
34 parsed = JSON.parse(config.appEncryptionKeysJson) as unknown;
35 } catch {
36 throw encryptionNotConfigured();
37 }
38 
39 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
40 throw encryptionNotConfigured();
41 }
42 
43 const keys = new Map<number, string>();
44 
45 for (const [versionText, encodedKey] of Object.entries(parsed)) {
46 const version = Number(versionText);
47 if (!Number.isInteger(version) || version <= 0 || typeof encodedKey !== "string") {
48 throw encryptionNotConfigured();
49 }
50 
51 keys.set(version, encodedKey);
52 }
53 
54 if (!keys.has(config.appEncryptionKeyCurrentVersion)) {
55 throw encryptionNotConfigured();
56 }
57 
58 return {
59 currentVersion: config.appEncryptionKeyCurrentVersion,
60 keys,
61 };
62};
63 
64const importAesKey = async (encodedKey: string): Promise<CryptoKey> => {
65 let pendingKey = importedKeys.get(encodedKey);
66 
67 if (!pendingKey) {
68 pendingKey = (async () => {
69 let rawKey: Uint8Array;
70 try {
71 rawKey = decodeBase64(encodedKey);
72 } catch {
73 throw encryptionNotConfigured();
74 }
75 
76 if (rawKey.byteLength !== AES_256_KEY_BYTES) {
77 throw encryptionNotConfigured();
78 }
79 
80 try {
81 return await crypto.subtle.importKey("raw", toArrayBuffer(rawKey), { name: AES_GCM_ALGORITHM }, false, [
82 "encrypt",
83 "decrypt",
84 ]);
85 } catch {
86 throw encryptionNotConfigured();
87 }
88 })();
89 
90 importedKeys.set(encodedKey, pendingKey);
91 }
92 
93 try {
94 return await pendingKey;
95 } catch (error) {
96 importedKeys.delete(encodedKey);
97 throw error;
98 }
99};
100 
101const importVersionedKey = async (env: Env, version: number): Promise<CryptoKey> => {
102 const config = readEncryptionConfig(env);
103 const encodedKey = config.keys.get(version);
104 
105 if (!encodedKey) {
106 throw encryptionNotConfigured();
107 }
108 
109 return importAesKey(encodedKey);
110};
111 
112export const validateAppEncryptionConfig = async (env: Env): Promise<void> => {
113 const config = readEncryptionConfig(env);
114 
115 await Promise.all([...config.keys.values()].map((encodedKey) => importAesKey(encodedKey)));
116};
117 
118export const encryptSecret = async (env: Env, plaintext: string): Promise<EncryptedSecret> => {
119 if (plaintext.length === 0) {
120 throw new HttpError(400, "invalid_repo_token", "Repository token cannot be empty.");
121 }
122 
123 const config = readEncryptionConfig(env);
124 const nonce = crypto.getRandomValues(new Uint8Array(AES_GCM_NONCE_BYTES));
125 const key = await importVersionedKey(env, config.currentVersion);
126 const encodedPlaintext = new TextEncoder().encode(plaintext);
127 const ciphertext = await crypto.subtle.encrypt(
128 {
129 name: AES_GCM_ALGORITHM,
130 iv: toArrayBuffer(nonce),
131 },
132 key,
133 encodedPlaintext,
134 );
135 
136 return {
137 ciphertext: new Uint8Array(ciphertext),
138 keyVersion: config.currentVersion,
139 nonce,
140 };
141};
142 
143export const decryptSecret = async (env: Env, encryptedSecret: EncryptedSecret): Promise<string> => {
144 const key = await importVersionedKey(env, encryptedSecret.keyVersion);
145 
146 try {
147 const plaintext = await crypto.subtle.decrypt(
148 {
149 name: AES_GCM_ALGORITHM,
150 iv: toArrayBuffer(encryptedSecret.nonce),
151 },
152 key,
153 toArrayBuffer(encryptedSecret.ciphertext),
154 );
155 
156 return new TextDecoder().decode(plaintext);
157 } catch {
158 throw encryptionNotConfigured("Stored repository token could not be decrypted.");
159 }
160};