diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ab4597a4 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +KEY=32_character_encryption_key diff --git a/backend/src/api/index.ts b/backend/src/api/index.ts index 97049892..7579c34e 100644 --- a/backend/src/api/index.ts +++ b/backend/src/api/index.ts @@ -1,8 +1,14 @@ +import "./loadEnv"; + import { connect } from "../data/db"; import path from "path"; import { setIsAPI } from "../globals"; -require("dotenv").config({ path: path.resolve(process.cwd(), "api.env") }); +if (!process.env.KEY) { + // tslint:disable-next-line:no-console + console.error("Project root .env with KEY is required!"); + process.exit(1); +} function errorHandler(err) { console.error(err.stack || err); // tslint:disable-line:no-console diff --git a/backend/src/api/loadEnv.ts b/backend/src/api/loadEnv.ts new file mode 100644 index 00000000..0bbc5063 --- /dev/null +++ b/backend/src/api/loadEnv.ts @@ -0,0 +1,4 @@ +import path from "path"; + +require("dotenv").config({ path: path.resolve(process.cwd(), "../.env") }); +require("dotenv").config({ path: path.resolve(process.cwd(), "api.env") }); diff --git a/backend/src/data/encryptedJsonTransformer.ts b/backend/src/data/encryptedJsonTransformer.ts new file mode 100644 index 00000000..01b6080c --- /dev/null +++ b/backend/src/data/encryptedJsonTransformer.ts @@ -0,0 +1,22 @@ +import { decrypt, encrypt } from "../utils/crypt"; +import { ValueTransformer } from "typeorm"; + +interface EncryptedJsonTransformer extends ValueTransformer { + from(dbValue: any): T; + to(entityValue: T): any; +} + +export function createEncryptedJsonTransformer(): EncryptedJsonTransformer { + return { + // Database -> Entity + from(dbValue) { + const decrypted = decrypt(dbValue); + return JSON.parse(decrypted) as T; + }, + + // Entity -> Database + to(entityValue) { + return encrypt(JSON.stringify(entityValue)); + }, + }; +} diff --git a/backend/src/data/entities/SavedMessage.ts b/backend/src/data/entities/SavedMessage.ts index 1301e45c..8212abf9 100644 --- a/backend/src/data/entities/SavedMessage.ts +++ b/backend/src/data/entities/SavedMessage.ts @@ -1,4 +1,5 @@ import { Column, Entity, PrimaryColumn } from "typeorm"; +import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer"; export interface ISavedMessageData { attachments?: object[]; @@ -25,7 +26,11 @@ export class SavedMessage { @Column() is_bot: boolean; - @Column("simple-json") data: ISavedMessageData; + @Column({ + type: "mediumtext", + transformer: createEncryptedJsonTransformer(), + }) + data: ISavedMessageData; @Column() posted_at: string; diff --git a/backend/src/index.ts b/backend/src/index.ts index ddb551f5..e2721289 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,3 +1,5 @@ +import "./loadEnv"; + import path from "path"; import yaml from "js-yaml"; @@ -25,7 +27,11 @@ import { PluginLoadError } from "knub/dist/plugins/PluginLoadError"; const fsp = fs.promises; -require("dotenv").config({ path: path.resolve(process.cwd(), "bot.env") }); +if (!process.env.KEY) { + // tslint:disable-next-line:no-console + console.error("Project root .env with KEY is required!"); + process.exit(1); +} declare global { // This is here so TypeScript doesn't give an error when importing twemoji diff --git a/backend/src/loadEnv.ts b/backend/src/loadEnv.ts new file mode 100644 index 00000000..d0991965 --- /dev/null +++ b/backend/src/loadEnv.ts @@ -0,0 +1,4 @@ +import path from "path"; + +require("dotenv").config({ path: path.resolve(process.cwd(), "../.env") }); +require("dotenv").config({ path: path.resolve(process.cwd(), "bot.env") }); diff --git a/backend/src/migrations/1600283341726-EncryptExistingMessages.ts b/backend/src/migrations/1600283341726-EncryptExistingMessages.ts new file mode 100644 index 00000000..f54ecae1 --- /dev/null +++ b/backend/src/migrations/1600283341726-EncryptExistingMessages.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { decrypt, encrypt } from "../utils/crypt"; + +export class EncryptExistingMessages1600283341726 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. Delete non-permanent messages + await queryRunner.query("DELETE FROM messages WHERE is_permanent = 0"); + + // 2. Encrypt all permanent messages + const messages = await queryRunner.query("SELECT id, data FROM messages"); + for (const message of messages) { + const encryptedData = encrypt(message.data); + await queryRunner.query("UPDATE messages SET data = ? WHERE id = ?", [encryptedData, message.id]); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Decrypt all messages + const messages = await queryRunner.query("SELECT id, data FROM messages"); + for (const message of messages) { + const decryptedData = decrypt(message.data); + await queryRunner.query("UPDATE messages SET data = ? WHERE id = ?", [decryptedData, message.id]); + } + } +} diff --git a/backend/src/utils/crypt.test.ts b/backend/src/utils/crypt.test.ts new file mode 100644 index 00000000..00619645 --- /dev/null +++ b/backend/src/utils/crypt.test.ts @@ -0,0 +1,10 @@ +import test from "ava"; + +import { encrypt, decrypt } from "./crypt"; + +test("encrypt() followed by decrypt()", t => { + const original = "banana 123 👀 💕"; // Includes emojis to verify utf8 stuff works + const encrypted = encrypt(original); + const decrypted = decrypt(encrypted); + t.is(decrypted, original); +}); diff --git a/backend/src/utils/crypt.ts b/backend/src/utils/crypt.ts new file mode 100644 index 00000000..3184f328 --- /dev/null +++ b/backend/src/utils/crypt.ts @@ -0,0 +1,35 @@ +import "../loadEnv"; + +import crypto, { DecipherGCM } from "crypto"; + +if (!process.env.KEY) { + // tslint:disable-next-line:no-console + console.error("Environment value KEY required for encryption"); + process.exit(1); +} + +const KEY = process.env.KEY; +const ALGORITHM = "aes-256-gcm"; + +export function encrypt(str) { + // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 + + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv); + + let encrypted = cipher.update(str, "utf8", "base64"); + encrypted += cipher.final("base64"); + return `${iv.toString("base64")}.${cipher.getAuthTag().toString("base64")}.${encrypted}`; +} + +export function decrypt(encrypted) { + // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 + + const [iv, authTag, encryptedStr] = encrypted.split("."); + const decipher = crypto.createDecipheriv(ALGORITHM, KEY, Buffer.from(iv, "base64")); + decipher.setAuthTag(Buffer.from(authTag, "base64")); + + let decrypted = decipher.update(encryptedStr, "base64", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; +}