Repository navigation
Expand file tree
/
Copy pathencrypt.js
More file actions
392 lines (347 loc) · 14.8 KB
/
Copy pathencrypt.js
File metadata and controls
392 lines (347 loc) · 14.8 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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
'use strict';
if (!process.env.EE_ENV_LOADED) {
require('dotenv').config({ quiet: true });
process.env.EE_ENV_LOADED = 'true';
}
try {
process.chdir(__dirname);
} catch (err) {
// ignore
}
const { redis } = require('./lib/db');
const config = require('@zone-eu/wild-config');
const { reencrypt } = require('./lib/encrypt');
const { encryptedKeys } = require('./lib/settings');
const { ENCRYPTED_ACCOUNT_FIELDS } = require('./lib/account');
const { ENCRYPTED_ROUTE_FIELDS } = require('./lib/webhooks');
const getSecret = require('./lib/get-secret');
const msgpack = require('./lib/msgpack');
const { REDIS_PREFIX, ENCRYPTED_APP_KEYS } = require('./lib/consts');
const { Settings: CertSettings } = require('@postalsys/certs/lib/settings');
const { TLS_KEY } = require('./lib/tls/store');
const DECRYPT_PASSWORDS = [].concat(config.decrypt || []);
const processSecret = (value, encryptSecret) => reencrypt(value, DECRYPT_PASSWORDS, encryptSecret);
async function main() {
console.error('EmailEngine account encryption tool');
const encryptSecret = await getSecret();
if (!encryptSecret && !DECRYPT_PASSWORDS.length) {
console.error('Usage:');
console.error(' emailengine encrypt --dbs.redis="redis://url" --service.secret="new-pass" --decrypt="old-pass"');
console.error('Where');
console.error(' --dbs.redis is a Redis configuration URL');
console.error(' --service.secret is the secret value to use for encryption.');
console.error(' Leave empty to remove encryption.');
console.error(' --decrypt is the old secret value. Not needed if current passwords are not encrypted.');
console.error(' You can set this value multiple times if accounts are encrypted with different secrets.');
return;
}
// convert settings
for (let key of encryptedKeys) {
let value = await redis.hget(`${REDIS_PREFIX}settings`, key);
if (value && typeof value === 'string') {
try {
let updated = await processSecret(value, encryptSecret);
if (updated !== value) {
await redis.hset(`${REDIS_PREFIX}settings`, key, updated);
console.log(`${key}: Updated setting value`);
}
} catch (err) {
console.error(`${key}: Failed to process setting value`);
console.error(err);
}
}
}
let updatedAccounts = 0;
let accounts = await redis.smembers(`${REDIS_PREFIX}ia:accounts`);
for (let account of accounts) {
let accountData = await redis.hgetall(`${REDIS_PREFIX}iad:${account}`);
if (!accountData) {
continue;
}
let updates = {};
let updated = false;
for (let key of ['imap', 'smtp', 'oauth2']) {
if (!accountData[key]) {
continue;
}
try {
accountData[key] = JSON.parse(accountData[key]);
} catch (err) {
console.error(`Failed to parse ${key} for ${account}`);
console.error(err);
continue;
}
if (!accountData[key]) {
continue;
}
let changes = false;
for (let subKey of ['pass', 'accessToken', 'refreshToken']) {
if (accountData[key].auth && accountData[key].auth[subKey]) {
try {
let value = await processSecret(accountData[key].auth[subKey], encryptSecret);
if (value !== accountData[key].auth[subKey]) {
accountData[key].auth[subKey] = value;
changes = true;
}
} catch (err) {
console.error(`Could not process "${key}.auth.${subKey}" for ${account}. Check decryption secrets.`);
}
}
}
for (let subKey of ['accessToken', 'refreshToken']) {
if (accountData[key] && accountData[key][subKey]) {
try {
let value = await processSecret(accountData[key][subKey], encryptSecret);
if (value !== accountData[key][subKey]) {
accountData[key][subKey] = value;
changes = true;
}
} catch (err) {
console.error(`Could not process "${key}.${subKey}" for ${account}. Check decryption secrets.`);
}
}
}
if (changes) {
updates[key] = JSON.stringify(accountData[key]);
updated = true;
}
}
// The credential-bearing fields stored as plain strings (webhooksCustomHeaders is its JSON
// encoding); '' is a cleared marker and stays as it is
for (let key of ENCRYPTED_ACCOUNT_FIELDS) {
if (!accountData[key] || typeof accountData[key] !== 'string') {
continue;
}
try {
let value = await processSecret(accountData[key], encryptSecret);
if (value !== accountData[key]) {
updates[key] = value;
updated = true;
}
} catch (err) {
console.error(`Could not process "${key}" for ${account}. Check decryption secrets.`);
}
}
if (updated) {
let result = await redis.hmset(`${REDIS_PREFIX}iad:${account}`, updates);
if (result === 'OK') {
console.log(`${account}: updated`);
} else {
console.log(`${account}: Unexpected response from DB: ${result}`);
}
updatedAccounts++;
}
}
console.log(`Updated ${updatedAccounts}/${accounts.length} accounts`);
let updatedGateways = 0;
// NB: the index set is `gateways`, not `ia:gateways` - see lib/gateway.js. Reading the wrong
// key made this loop report `0/0` on every rotation while leaving gateway passwords encrypted
// with the old secret.
let gateways = await redis.smembers(`${REDIS_PREFIX}gateways`);
for (let gateway of gateways) {
let pass = await redis.hget(`${REDIS_PREFIX}gateway:${gateway}`, 'pass');
if (!pass) {
continue;
}
try {
let value = await processSecret(pass, encryptSecret);
if (value !== pass) {
let result = await redis.hmset(`${REDIS_PREFIX}gateway:${gateway}`, { pass: value });
if (result === 'OK') {
console.log(`Gateway ${gateway}: updated`);
} else {
console.log(`Gateway ${gateway}: Unexpected response from DB: ${result}`);
}
updatedGateways++;
}
} catch (err) {
console.error(`Could not process "pass" for ${gateway}. Check decryption secrets.`);
}
}
console.log(`Updated ${updatedGateways}/${gateways.length} SMTP gateways`);
let updatedApps = 0;
let apps = await redis.smembers(`${REDIS_PREFIX}oapp:i`);
for (let app of apps) {
let appBuf = await redis.hgetBuffer(`${REDIS_PREFIX}oapp:c`, `${app}:data`);
if (!appBuf) {
continue;
}
let entry;
try {
entry = msgpack.decode(appBuf);
} catch (err) {
console.log(`OAuth2 App ${app}: failed to parse`);
continue;
}
try {
let appUpdated = false;
for (let key of ENCRYPTED_APP_KEYS) {
if (entry[key]) {
let value = await processSecret(entry[key], encryptSecret);
if (value !== entry[key]) {
entry[key] = value;
appUpdated = true;
}
}
}
if (appUpdated) {
let result = await redis.hmset(`${REDIS_PREFIX}oapp:c`, { [`${app}:data`]: msgpack.encode(entry) });
if (result === 'OK') {
console.log(`OAuth2 App ${app}: updated`);
} else {
console.log(`OAuth2 App ${app}: Unexpected response from DB: ${result}`);
}
updatedApps++;
}
} catch (err) {
console.error(`Could not process encrypted values for OAuth2 App ${app}. Check decryption secrets.`);
}
}
console.log(`Updated ${updatedApps}/${apps.length} OAuth2 apps`);
// Custom webhook routes: the target URL and the custom header list inside each route's msgpack
// meta entry. A route written before these were encrypted holds the headers as an array, which
// is converted to the JSON string form lib/webhooks.js stores.
let updatedRoutes = 0;
let routes = await redis.smembers(`${REDIS_PREFIX}wh:i`);
for (let route of routes) {
let metaBuf = await redis.hgetBuffer(`${REDIS_PREFIX}wh:c`, `${route}:meta`);
if (!metaBuf) {
continue;
}
let meta;
try {
meta = msgpack.decode(metaBuf);
} catch (err) {
console.log(`Webhook route ${route}: failed to parse`);
continue;
}
try {
let routeUpdated = false;
for (let key of ENCRYPTED_ROUTE_FIELDS) {
let stored = Array.isArray(meta[key]) ? JSON.stringify(meta[key]) : meta[key];
if (!stored || typeof stored !== 'string') {
continue;
}
let value = await processSecret(stored, encryptSecret);
if (value !== meta[key]) {
meta[key] = value;
routeUpdated = true;
}
}
if (routeUpdated) {
let result = await redis.hmset(`${REDIS_PREFIX}wh:c`, { [`${route}:meta`]: msgpack.encode(meta) });
if (result === 'OK') {
console.log(`Webhook route ${route}: updated`);
} else {
console.log(`Webhook route ${route}: Unexpected response from DB: ${result}`);
}
updatedRoutes++;
}
} catch (err) {
console.error(`Could not process encrypted values for webhook route ${route}. Check decryption secrets.`);
}
}
console.log(`Updated ${updatedRoutes}/${routes.length} webhook routes`);
// TLS private keys managed by @postalsys/certs. These live in the module's own hash, not in the
// EmailEngine settings hash, so they need their own pass - without it a rotation leaves the ACME
// account key and every domain key readable only with the old secret, and TLS fails on the next
// renewal with no self-healing path. Every field in that hash is msgpack encoded; only the ACME
// account's `privateKey` and the `domain:<name>:privateKey` entries hold encrypted values.
let updatedCerts = 0;
// Ask the library for the key instead of rebuilding it here: the namespace it is constructed
// with is `${REDIS_PREFIX}`, and it appends its own separator, so the hash is
// `certs:settings` unprefixed but `<prefix>::certs:settings` when EENGINE_REDIS_PREFIX is set.
let certsKey = CertSettings.create({ namespace: `${REDIS_PREFIX}` }).getKey('settings');
let certEntries = await redis.hgetallBuffer(certsKey);
for (let field of Object.keys(certEntries || {})) {
let isAcmeAccount = /^account:/.test(field);
let isDomainKey = /^domain:.*:privateKey$/.test(field);
if (!isAcmeAccount && !isDomainKey) {
continue;
}
let entry;
try {
entry = msgpack.decode(certEntries[field]);
} catch (err) {
console.log(`Certificate entry ${field}: failed to parse`);
continue;
}
try {
let value;
if (isAcmeAccount) {
if (!entry || !entry.privateKey) {
continue;
}
let updated = await processSecret(entry.privateKey, encryptSecret);
if (updated === entry.privateKey) {
continue;
}
entry.privateKey = updated;
value = entry;
} else {
if (!entry) {
continue;
}
let updated = await processSecret(entry, encryptSecret);
if (updated === entry) {
continue;
}
value = updated;
}
let result = await redis.hmset(certsKey, { [field]: msgpack.encode(value) });
if (result === 'OK') {
console.log(`Certificate entry ${field}: updated`);
} else {
console.log(`Certificate entry ${field}: Unexpected response from DB: ${result}`);
}
updatedCerts++;
} catch (err) {
console.error(`Could not process "${field}". Check decryption secrets.`);
}
}
console.log(`Updated ${updatedCerts} TLS private keys`);
// TLS material EmailEngine manages itself: the certificate an operator uploaded through the
// admin UI, and the self-signed certificate a listener falls back to. Both live in the `tls`
// hash as JSON with an encrypted `privateKey` field, and both need the same pass - an uploaded
// certificate that could not be decrypted after a rotation would take the listener down at the
// next restart, and there is no way to regenerate one.
let updatedTlsRecords = 0;
let tlsKey = TLS_KEY;
let tlsEntries = await redis.hgetall(tlsKey);
for (let field of Object.keys(tlsEntries || {})) {
let record;
try {
record = JSON.parse(tlsEntries[field]);
} catch (err) {
console.log(`TLS entry ${field}: failed to parse`);
continue;
}
if (!record || !record.privateKey) {
continue;
}
try {
let updated = await processSecret(record.privateKey, encryptSecret);
if (updated === record.privateKey) {
continue;
}
record.privateKey = updated;
let result = await redis.hmset(tlsKey, { [field]: JSON.stringify(record) });
if (result === 'OK') {
console.log(`TLS entry ${field}: updated`);
} else {
console.log(`TLS entry ${field}: Unexpected response from DB: ${result}`);
}
updatedTlsRecords++;
} catch (err) {
console.error(`Could not process "${field}". Check decryption secrets.`);
}
}
console.log(`Updated ${updatedTlsRecords} stored TLS certificates`);
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
})
.finally();