-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathindex.ts
More file actions
284 lines (239 loc) · 9.1 KB
/
Copy pathindex.ts
File metadata and controls
284 lines (239 loc) · 9.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import { EncryptionError } from '@/lib/logging/errors';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
/**
* Gets the encryption key from environment variables and ensures it is valid.
*/
function getEncryptionKey(): Buffer {
const keyHex = process.env.ENCRYPTION_KEY;
if (!keyHex) {
throw new EncryptionError('encrypt', 'ENCRYPTION_KEY environment variable is not set');
}
// The key should be a 32-byte (64 char) hex string for AES-256
if (keyHex.length !== 64) {
throw new EncryptionError('encrypt', 'ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
}
return Buffer.from(keyHex, 'hex');
}
/**
* Encrypts a text string using AES-256-GCM.
* Returns the result in format: "iv:authTag:encryptedContent" (hex encoded)
*/
export function encrypt(text: string): string {
if (!text) return text;
try {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
} catch (error) {
if (error instanceof EncryptionError) throw error;
/* v8 ignore start */
const encryptCause = error instanceof Error ? error : undefined;
/* v8 ignore stop */
throw new EncryptionError('encrypt', 'Failed to encrypt data', { cause: encryptCause });
}
}
/**
* Decrypts a text string using AES-256-GCM.
* Expects format: "iv:authTag:encryptedContent" (hex encoded)
*/
export function decrypt(text: string): string {
if (!text) return text;
// Return original text if it doesn't look like our encrypted format
// simplistic check: contains 2 colons
if (text.split(':').length !== 3) return text;
try {
const key = getEncryptionKey();
const parts = text.split(':');
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encryptedText = parts[2];
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
// If decryption fails (e.g. wrong key, modified data, or plain text data)
// currently we might want to return the original text if it wasn't encrypted?
// But for security, if it *looked* encrypted but failed, we should probably throw.
// Use case: migrating existing unencrypted data vs failed decryption.
if (error instanceof EncryptionError) throw error;
/* v8 ignore start */
const decryptCause = error instanceof Error ? error : undefined;
/* v8 ignore stop */
throw new EncryptionError('decrypt', 'Failed to decrypt data', { cause: decryptCause });
}
}
export const SENSITIVE_KEYS = [
'password',
'token',
'secret',
'secretKey',
'secretAccessKey', // AWS/S3
'accessKey',
'accessKeyId', // AWS/S3
'apiKey',
'webhookUrl',
'uri', // MongoDB Connection String
'passphrase', // SSH Key Passphrase
'privateKey', // SSH Private Key
'sshPassword', // SSH tunnel password (legacy inline SSH field)
'sshPrivateKey', // SSH tunnel private key (legacy inline SSH field)
'sshPassphrase', // SSH tunnel key passphrase (legacy inline SSH field)
'clientSecret', // OAuth Client Secret (Google Drive, etc.)
'refreshToken', // OAuth Refresh Token
'authHeader', // Generic Webhook Authorization header
'accountSid', // Twilio Account SID
'authToken', // Twilio Auth Token
'appToken', // Gotify application token
'botToken', // Telegram bot token
'accessToken', // ntfy access token
];
/**
* Recursively strips sensitive fields from an object (sets them to empty string).
*/
export function stripSecrets(config: any): any {
if (!config || typeof config !== 'object') {
return config;
}
// Clone to avoid mutation
const result = Array.isArray(config) ? [...config] : { ...config };
for (const key of Object.keys(result)) {
const value = result[key];
if (typeof value === 'object' && value !== null) {
result[key] = stripSecrets(value);
} else if (typeof value === 'string' && SENSITIVE_KEYS.includes(key)) {
result[key] = "";
}
}
return result;
}
/**
* Recursively encrypts sensitive fields in an object.
*/
export function encryptConfig(config: any): any {
if (!config || typeof config !== 'object') {
return config;
}
// Clone to avoid mutation
const result = Array.isArray(config) ? [...config] : { ...config };
for (const key of Object.keys(result)) {
const value = result[key];
if (typeof value === 'object' && value !== null) {
result[key] = encryptConfig(value);
} else if (typeof value === 'string' && SENSITIVE_KEYS.includes(key)) {
result[key] = encrypt(value);
}
}
return result;
}
/**
* Recursively decrypts sensitive fields in an object.
*/
export function decryptConfig(config: any): any {
if (!config || typeof config !== 'object') {
return config;
}
// Clone to avoid mutation
const result = Array.isArray(config) ? [...config] : { ...config };
for (const key of Object.keys(result)) {
const value = result[key];
if (typeof value === 'object' && value !== null) {
result[key] = decryptConfig(value);
} else if (typeof value === 'string' && SENSITIVE_KEYS.includes(key)) {
result[key] = decrypt(value);
}
}
return result;
}
/**
* Merges sensitive fields of an incoming (plaintext) config with an existing
* (plaintext) config, preserving the existing secret whenever the incoming
* value for a sensitive key is empty or absent.
*
* This is the server-side half of the "hasSecret" pattern: the API only ever
* returns redacted secrets (see `stripSecrets` / the adapter DTO), so an edit
* round-trip submits empty secret fields. Without this merge, re-encrypting the
* submitted config would clobber the real secret with an encrypted empty string.
*
* Non-sensitive keys are taken verbatim from `incoming`. Nested objects are
* merged recursively for sensitive keys; non-object structural values pass
* through from `incoming`.
*/
export function mergeSecrets(incoming: any, existing: any): any {
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
return incoming;
}
const existingObj =
existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const result: Record<string, any> = { ...incoming };
const isEmpty = (v: unknown) => v === undefined || v === null || v === '';
// 1. Sensitive keys present in `incoming`: if empty, restore from existing.
// Nested objects merge recursively.
for (const key of Object.keys(result)) {
const value = result[key];
const existingValue = existingObj[key];
if (value && typeof value === 'object') {
result[key] = mergeSecrets(value, existingValue);
} else if (SENSITIVE_KEYS.includes(key) && isEmpty(value) && existingValue !== undefined) {
result[key] = existingValue;
}
}
// 2. Sensitive keys present in `existing` but absent from `incoming`: restore
// them. The API redacts (removes) secret keys, so an edit round-trip omits
// untouched secrets entirely — without this they would be lost on save.
for (const key of Object.keys(existingObj)) {
if (!(key in result) && SENSITIVE_KEYS.includes(key) && !isEmpty(existingObj[key])) {
result[key] = existingObj[key];
}
}
return result;
}
/**
* Recursively removes sensitive fields from a (decrypted) config, returning a
* config that structurally cannot carry a secret. Unlike `stripSecrets` (which
* blanks values to `""`), this deletes the keys entirely so a response DTO never
* even hints at a value. Use together with `getSecretStatus` to tell the client
* which secrets are set without exposing them.
*/
export function redactSecrets(config: any): any {
if (!config || typeof config !== 'object') {
return config;
}
if (Array.isArray(config)) {
return config.map(redactSecrets);
}
const result: Record<string, any> = {};
for (const key of Object.keys(config)) {
const value = config[key];
if (SENSITIVE_KEYS.includes(key) && typeof value !== 'object') {
continue; // drop scalar secret entirely
}
result[key] = value && typeof value === 'object' ? redactSecrets(value) : value;
}
return result;
}
/**
* Reports which sensitive top-level keys of a (decrypted) config hold a
* non-empty value, e.g. `{ clientSecret: true, refreshToken: false }`. Lets the
* UI render "secret is set, leave blank to keep" without seeing the value.
*/
export function getSecretStatus(config: any): Record<string, boolean> {
const status: Record<string, boolean> = {};
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return status;
}
for (const key of Object.keys(config)) {
if (SENSITIVE_KEYS.includes(key)) {
const value = config[key];
status[key] = typeof value === 'string' ? value.length > 0 : value != null;
}
}
return status;
}