-
-
Notifications
You must be signed in to change notification settings - Fork 463
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(email): add pgp support (#1138)
- Loading branch information
Showing
14 changed files
with
367 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.