Skip to content

Commit

Permalink
feat(email): add pgp support (#1138)
Browse files Browse the repository at this point in the history
  • Loading branch information
ankarhem authored Mar 14, 2021
1 parent ae29e5c commit 9e5adeb
Show file tree
Hide file tree
Showing 14 changed files with 367 additions and 8 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions server/entity/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,7 @@ export class UserSettings {

@Column({ nullable: true })
public originalLanguage?: string;

@Column({ nullable: true })
public pgpKey?: string;
}
1 change: 1 addition & 0 deletions server/interfaces/api/userSettingsInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface UserSettingsNotificationsResponse {
discordId?: string;
telegramChatId?: string;
telegramSendSilently?: boolean;
pgpKey?: string;
}
13 changes: 12 additions & 1 deletion server/lib/email/index.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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: {
Expand Down
181 changes: 181 additions & 0 deletions server/lib/email/openpgpEncrypt.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
// 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);
};
};
12 changes: 6 additions & 6 deletions server/lib/notifications/agents/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'),
Expand Down
2 changes: 2 additions & 0 deletions server/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
authPass?: string;
allowSelfSigned: boolean;
senderName: string;
pgpPrivateKey?: string;
pgpPassword?: string;
};
}

Expand Down
31 changes: 31 additions & 0 deletions server/migration/1615333940450-AddPGPToUserSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPGPToUserSettings1615333940450 implements MigrationInterface {
name = 'AddPGPToUserSettings1615333940450';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
4 changes: 4 additions & 0 deletions server/routes/user/usersettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down
Loading

0 comments on commit 9e5adeb

Please sign in to comment.