import logger from '@server/logger'; import { randomBytes } from 'crypto'; import * as openpgp from 'openpgp'; import type { TransformCallback } from 'stream'; import { Transform } from 'stream'; interface EncryptorOptions { signingKey?: string; password?: string; encryptionKeys: string[]; } class PGPEncryptor extends Transform { private _messageChunks: Uint8Array[] = []; private _messageLength = 0; private _signingKey?: string; private _password?: string; private _encryptionKeys: string[]; constructor(options: EncryptorOptions) { super(); this._signingKey = options.signingKey; this._password = options.password; this._encryptionKeys = options.encryptionKeys; } // just save the whole message _transform = ( chunk: Uint8Array, _encoding: BufferEncoding, callback: TransformCallback ): void => { this._messageChunks.push(chunk); this._messageLength += chunk.length; callback(); }; // Actually do stuff _flush = async (callback: TransformCallback): Promise => { const message = Buffer.concat(this._messageChunks, this._messageLength); try { // Reconstruct message as buffer const validPublicKeys = await Promise.all( this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey }) ) ); let privateKey: openpgp.PrivateKey | undefined; // Just return the message if there is no one to encrypt for if (!validPublicKeys.length) { this.push(message); return callback(); } // Only sign the message if private key and password exist if (this._signingKey && this._password) { privateKey = await openpgp.decryptKey({ privateKey: await openpgp.readPrivateKey({ armoredKey: this._signingKey, }), passphrase: this._password, }); } const emailPartDelimiter = '\r\n\r\n'; const messageParts = message.toString().split(emailPartDelimiter); /** * In this loop original headers are split up into two parts, * one for the email that is sent * and one for the encrypted content */ const header = messageParts.shift() as string; const emailHeaders: string[][] = []; const contentHeaders: string[][] = []; const linesInHeader = header.split('\r\n'); let previousHeader: string[] = []; for (let i = 0; i < linesInHeader.length; i++) { const line = linesInHeader[i]; /** * If it is a multi-line header (current line starts with whitespace) * or it's the first line in the iteration * add the current line with previous header and move on */ if (/^\s/.test(line) || i === 0) { previousHeader.push(line); continue; } /** * This is done to prevent the last header * from being missed */ if (i === linesInHeader.length - 1) { previousHeader.push(line); } /** * We need to seperate the actual content headers * so that we can add it as a header for the encrypted content * So that the content will be displayed properly after decryption */ if ( /^(content-type|content-transfer-encoding):/i.test(previousHeader[0]) ) { contentHeaders.push(previousHeader); } else { emailHeaders.push(previousHeader); } previousHeader = [line]; } // Generate a new boundary for the email content const boundary = 'nm_' + randomBytes(14).toString('hex'); /** * Concatenate everything into single strings * and add pgp headers to the email headers */ const emailHeadersRaw = emailHeaders.map((line) => line.join('\r\n')).join('\r\n') + '\r\n' + 'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' + '\r\n' + ' boundary="' + boundary + '"' + '\r\n' + 'Content-Description: OpenPGP encrypted message' + '\r\n' + 'Content-Transfer-Encoding: 7bit'; const contentHeadersRaw = contentHeaders .map((line) => line.join('\r\n')) .join('\r\n'); const encryptedMessage = await openpgp.encrypt({ message: await openpgp.createMessage({ text: contentHeadersRaw + emailPartDelimiter + messageParts.join(emailPartDelimiter), }), encryptionKeys: validPublicKeys, signingKeys: privateKey, }); const body = '--' + boundary + '\r\n' + 'Content-Type: application/pgp-encrypted\r\n' + 'Content-Transfer-Encoding: 7bit\r\n' + '\r\n' + 'Version: 1\r\n' + '\r\n' + '--' + boundary + '\r\n' + 'Content-Type: application/octet-stream; name=encrypted.asc\r\n' + 'Content-Disposition: inline; filename=encrypted.asc\r\n' + 'Content-Transfer-Encoding: 7bit\r\n' + '\r\n' + encryptedMessage + '\r\n--' + boundary + '--\r\n'; this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body)); callback(); } catch (e) { logger.error( 'Something went wrong while encrypting email message with OpenPGP. Sending email without encryption', { label: 'Notifications', errorMessage: e.message, } ); this.push(message); callback(); } }; } export const openpgpEncrypt = (options: EncryptorOptions) => { // Disabling this line because I don't want to fix it but I am tired // of seeing the lint warning // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (mail: any, callback: () => unknown): void { if (!options.encryptionKeys.length) { setImmediate(callback); } mail.message.transform( () => new PGPEncryptor({ signingKey: options.signingKey, password: options.password, encryptionKeys: options.encryptionKeys, }) ); setImmediate(callback); }; };