From 9e5adeb610bdc4800ff536412d0ae8a11fb4338d Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Sun, 14 Mar 2021 14:39:43 +0100 Subject: [PATCH] feat(email): add pgp support (#1138) --- package.json | 1 + server/entity/UserSettings.ts | 3 + .../interfaces/api/userSettingsInterfaces.ts | 1 + server/lib/email/index.ts | 13 +- server/lib/email/openpgpEncrypt.ts | 181 ++++++++++++++++++ server/lib/notifications/agents/email.ts | 12 +- server/lib/settings.ts | 2 + .../1615333940450-AddPGPToUserSettings.ts | 31 +++ server/routes/user/usersettings.ts | 4 + .../Notifications/NotificationsEmail.tsx | 77 ++++++++ .../UserNotificationSettings/index.tsx | 30 +++ src/i18n/locale/en.json | 7 + src/styles/globals.css | 4 + yarn.lock | 9 +- 14 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 server/lib/email/openpgpEncrypt.ts create mode 100644 server/migration/1615333940450-AddPGPToUserSettings.ts diff --git a/package.json b/package.json index 8279b29f..83feb1ba 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "node-schedule": "^2.0.0", "nodemailer": "^6.5.0", "nookies": "^2.5.2", + "openpgp": "^5.0.0-1", "plex-api": "^5.3.1", "pug": "^3.0.2", "react": "17.0.1", diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index d2fe3892..8e60865a 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -37,4 +37,7 @@ export class UserSettings { @Column({ nullable: true }) public originalLanguage?: string; + + @Column({ nullable: true }) + public pgpKey?: string; } diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 99d01251..91653991 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -10,4 +10,5 @@ export interface UserSettingsNotificationsResponse { discordId?: string; telegramChatId?: string; telegramSendSilently?: boolean; + pgpKey?: string; } diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts index c4c6e61a..abbc1632 100644 --- a/server/lib/email/index.ts +++ b/server/lib/email/index.ts @@ -1,8 +1,9 @@ import nodemailer from 'nodemailer'; import Email from 'email-templates'; import { getSettings } from '../settings'; +import { openpgpEncrypt } from './openpgpEncrypt'; class PreparedEmail extends Email { - public constructor() { + public constructor(pgpKey?: string) { const settings = getSettings().notifications.agents.email; const transport = nodemailer.createTransport({ @@ -22,6 +23,16 @@ class PreparedEmail extends Email { } : undefined, }); + if (pgpKey) { + transport.use( + 'stream', + openpgpEncrypt({ + signingKey: settings.options.pgpPrivateKey, + password: settings.options.pgpPassword, + encryptionKeys: [pgpKey], + }) + ); + } super({ message: { from: { diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts new file mode 100644 index 00000000..146dc73e --- /dev/null +++ b/server/lib/email/openpgpEncrypt.ts @@ -0,0 +1,181 @@ +import * as openpgp from 'openpgp'; +import { Transform, TransformCallback } from 'stream'; +import crypto from 'crypto'; + +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: any, + _encoding: BufferEncoding, + callback: TransformCallback + ): void => { + this._messageChunks.push(chunk); + this._messageLength += chunk.length; + callback(); + }; + + // Actually do stuff + _flush = async (callback: TransformCallback): Promise => { + // Reconstruct message as buffer + const message = Buffer.concat(this._messageChunks, this._messageLength); + const validPublicKeys = await Promise.all( + this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey })) + ); + let privateKey: openpgp.Key | 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.readKey({ + armoredKey: this._signingKey, + }); + await privateKey.decrypt(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_' + crypto.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: openpgp.Message.fromText( + contentHeadersRaw + + emailPartDelimiter + + messageParts.join(emailPartDelimiter) + ), + publicKeys: validPublicKeys, + privateKeys: 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(); + }; +} + +export const openpgpEncrypt = (options: EncryptorOptions) => { + 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); + }; +}; diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 175e5a0c..64483c13 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -47,7 +47,7 @@ class EmailAgent users .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) .forEach((user) => { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); email.send({ template: path.join( @@ -97,7 +97,7 @@ class EmailAgent users .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) .forEach((user) => { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); email.send({ template: path.join( @@ -142,7 +142,7 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); await email.send({ template: path.join( @@ -234,7 +234,7 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); await email.send({ template: path.join( @@ -276,7 +276,7 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); await email.send({ template: path.join( @@ -318,7 +318,7 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); await email.send({ template: path.join(__dirname, '../../../templates/email/test-email'), diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 4debc801..ad0c3dba 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -117,6 +117,8 @@ export interface NotificationAgentEmail extends NotificationAgentConfig { authPass?: string; allowSelfSigned: boolean; senderName: string; + pgpPrivateKey?: string; + pgpPassword?: string; }; } diff --git a/server/migration/1615333940450-AddPGPToUserSettings.ts b/server/migration/1615333940450-AddPGPToUserSettings.ts new file mode 100644 index 00000000..b88e0dca --- /dev/null +++ b/server/migration/1615333940450-AddPGPToUserSettings.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPGPToUserSettings1615333940450 implements MigrationInterface { + name = 'AddPGPToUserSettings1615333940450'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 5b62362d..02ff2c72 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -225,6 +225,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( discordId: user.settings?.discordId, telegramChatId: user.settings?.telegramChatId, telegramSendSilently: user?.settings?.telegramSendSilently, + pgpKey: user?.settings?.pgpKey, }); } catch (e) { next({ status: 500, message: e.message }); @@ -263,12 +264,14 @@ userSettingsRoutes.post< discordId: req.body.discordId, telegramChatId: req.body.telegramChatId, telegramSendSilently: req.body.telegramSendSilently, + pgpKey: req.body.pgpKey, }); } else { user.settings.enableNotifications = req.body.enableNotifications; user.settings.discordId = req.body.discordId; user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramSendSilently = req.body.telegramSendSilently; + user.settings.pgpKey = req.body.pgpKey; } userRepository.save(user); @@ -278,6 +281,7 @@ userSettingsRoutes.post< discordId: user.settings.discordId, telegramChatId: user.settings.telegramChatId, telegramSendSilently: user.settings.telegramSendSilently, + pgpKey: user.settings.pgpKey, }); } catch (e) { next({ status: 500, message: e.message }); diff --git a/src/components/Settings/Notifications/NotificationsEmail.tsx b/src/components/Settings/Notifications/NotificationsEmail.tsx index 644fb628..4419ac8b 100644 --- a/src/components/Settings/Notifications/NotificationsEmail.tsx +++ b/src/components/Settings/Notifications/NotificationsEmail.tsx @@ -9,6 +9,8 @@ import * as Yup from 'yup'; import { useToasts } from 'react-toast-notifications'; import NotificationTypeSelector from '../../NotificationTypeSelector'; import Alert from '../../Common/Alert'; +import Badge from '../../Common/Badge'; +import globalMessages from '../../../i18n/globalMessages'; const messages = defineMessages({ save: 'Save Changes', @@ -39,8 +41,27 @@ const messages = defineMessages({ emailNotificationTypesAlertDescriptionPt2: 'Media Approved, Media Declined, and Media Available\ email notifications are sent to the user who submitted the request.', + pgpPrivateKey: 'PGP Private Key', + pgpPrivateKeyTip: + 'Sign encrypted email messages (PGP password is also required)', + pgpPassword: 'PGP Password', + pgpPasswordTip: + 'Sign encrypted email messages (PGP private key is also required)', }); +export function PgpLink(msg: string): JSX.Element { + return ( + + {msg} + + ); +} + const NotificationsEmail: React.FC = () => { const intl = useIntl(); const { addToast } = useToasts(); @@ -77,6 +98,8 @@ const NotificationsEmail: React.FC = () => { authPass: data.options.authPass, allowSelfSigned: data.options.allowSelfSigned, senderName: data.options.senderName, + pgpPrivateKey: data.options.pgpPrivateKey, + pgpPassword: data.options.pgpPassword, }} validationSchema={NotificationsEmailSchema} onSubmit={async (values) => { @@ -93,6 +116,8 @@ const NotificationsEmail: React.FC = () => { authPass: values.authPass, allowSelfSigned: values.allowSelfSigned, senderName: values.senderName, + pgpPrivateKey: values.pgpPrivateKey, + pgpPassword: values.pgpPassword, }, }); addToast(intl.formatMessage(messages.emailsettingssaved), { @@ -122,6 +147,8 @@ const NotificationsEmail: React.FC = () => { authUser: values.authUser, authPass: values.authPass, senderName: values.senderName, + pgpPrivateKey: values.pgpPrivateKey, + pgpPassword: values.pgpPassword, }, }); @@ -291,6 +318,56 @@ const NotificationsEmail: React.FC = () => { +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
PGP Public Key', + pgpKeyTip: 'Encrypt email messages', }); const UserNotificationSettings: React.FC = () => { @@ -76,6 +81,7 @@ const UserNotificationSettings: React.FC = () => { discordId: data?.discordId, telegramChatId: data?.telegramChatId, telegramSendSilently: data?.telegramSendSilently, + pgpKey: data?.pgpKey, }} validationSchema={UserNotificationSettingsSchema} enableReinitialize @@ -88,6 +94,7 @@ const UserNotificationSettings: React.FC = () => { discordId: values.discordId, telegramChatId: values.telegramChatId, telegramSendSilently: values.telegramSendSilently, + pgpKey: values.pgpKey, } ); @@ -123,6 +130,29 @@ const UserNotificationSettings: React.FC = () => { />
+
+ +
+
+ +
+ {errors.pgpKey && touched.pgpKey && ( +
{errors.pgpKey}
+ )} +
+