From 7b4e057764d1305801c9688c848eb7340e5bc54e Mon Sep 17 00:00:00 2001 From: Tarrgon <61888458+Tarrgon@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:04:58 -0400 Subject: [PATCH] Add encryption --- .env.sample | 1 + package.json | 3 +- scripts/encrypt-data.js | 99 +++++++++++++ .../0003-add-timestamp-to-messages.sql | 46 ++++++ src/config.ts | 3 +- src/events/handle-message.ts | 5 +- src/index.ts | 2 + src/scheduler/tasks/index.ts | 2 + .../tasks/prune-old-messages-task.ts | 13 ++ src/shared/Database.ts | 136 ++++++++++++------ src/shared/Encrypter.ts | 42 ++++++ src/types/database-types.d.ts | 7 + src/utils/message-utils.ts | 13 +- 13 files changed, 321 insertions(+), 51 deletions(-) create mode 100644 scripts/encrypt-data.js create mode 100644 sql/migrations/0003-add-timestamp-to-messages.sql create mode 100644 src/scheduler/tasks/prune-old-messages-task.ts create mode 100644 src/shared/Encrypter.ts diff --git a/.env.sample b/.env.sample index ddac746..a167a2a 100644 --- a/.env.sample +++ b/.env.sample @@ -3,6 +3,7 @@ DISCORD_CLIENT_SECRET= DISCORD_CLIENT_ID= DISCORD_GUILD_ID= RELEASE_SECRET= +DATABASE_SECRET= LINK_SECRET=super_secret_for_url_discord E621_BASE_URL=https://e621.net E926_BASE_URL=https://e926.net diff --git a/package.json b/package.json index 79443a9..8e68cf4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "start": "node dist/index.js", "build": "npm run clean && node scripts/build.js && npm run copyfiles", "clean": "rimraf dist", - "copyfiles": "copyfiles -u 1 \"./src/**/*.html\" ./dist" + "copyfiles": "copyfiles -u 1 \"./src/**/*.html\" ./dist", + "encrypt": "node ./scripts/encrypt-data.js" } } diff --git a/scripts/encrypt-data.js b/scripts/encrypt-data.js new file mode 100644 index 0000000..a3b7631 --- /dev/null +++ b/scripts/encrypt-data.js @@ -0,0 +1,99 @@ +import crypto from 'crypto'; +import dotenv from 'dotenv'; +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; + +dotenv.config(); + +async function main() { + Encrypter.initialize(process.env.DATABASE_SECRET); + + const db = await open({ + filename: './data/discord-main.db', + driver: sqlite3.Database + }); + + const discordNames = await db.all('SELECT * FROM discord_names'); + + db.exec('BEGIN TRANSACTION'); + for (const name of discordNames) { + db.run('UPDATE discord_names SET discord_id = ?, discord_username = ?, discord_id_hash = ?, discord_username_hash = ? WHERE id = ?', Encrypter.encrypt(name.user_id), Encrypter.encrypt(name.discord_username), Encrypter.hash(name.user_id), Encrypter.hash(name.discord_username), name.id); + } + db.exec('COMMIT'); + + const ticketPhrases = await db.all('SELECT * FROM ticket_phrases'); + + db.exec('BEGIN TRANSACTION'); + for (const phrase of ticketPhrases) { + db.run('UPDATE ticket_phrases SET user_id = ?, user_id_hash = ? WHERE id = ?', Encrypter.encrypt(phrase.user_id), Encrypter.hash(phrase.user_id), phrase.id); + } + db.exec('COMMIT'); + + const notes = await db.all('SELECT * FROM notes'); + + db.exec('BEGIN TRANSACTION'); + for (const note of notes) { + db.run('UPDATE notes SET user_id = ?, mod_id = ?, user_id_hash = ? WHERE id = ?', Encrypter.encrypt(note.user_id), Encrypter.encrypt(note.mod_id), Encrypter.hash(note.user_id), note.id); + } + db.exec('COMMIT'); + + const noteEdits = await db.all('SELECT * FROM note_edits'); + + db.exec('BEGIN TRANSACTION'); + for (const note of noteEdits) { + db.run('UPDATE note_edits SET mod_id = ? WHERE id = ?', Encrypter.encrypt(note.mod_id), note.id); + } + db.exec('COMMIT'); + + const bans = await db.all('SELECT * FROM bans'); + + db.exec('BEGIN TRANSACTION'); + for (const ban of bans) { + db.run('UPDATE bans SET user_id = ?, user_id_hash = ? WHERE id = ?', Encrypter.encrypt(ban.user_id), Encrypter.hash(ban.user_id), ban.id); + } + db.exec('COMMIT'); + + const githubUserMappings = await db.all('SELECT * FROM github_user_mapping'); + + db.exec('BEGIN TRANSACTION'); + for (const mapping of githubUserMappings) { + db.run('UPDATE github_user_mapping SET discord_id = ?, discord_id_hash = ? WHERE id = ?', Encrypter.encrypt(mapping.discord_id), Encrypter.hash(mapping.discord_id), mapping.id); + } + db.exec('COMMIT'); + + const privateHelpTickets = await db.all('SELECT * FROM private_help_tickets'); + + db.exec('BEGIN TRANSACTION'); + for (const mapping of privateHelpTickets) { + db.run('UPDATE private_help_tickets SET user_id = ?, user_id_hash = ? WHERE id = ?', Encrypter.encrypt(mapping.user_id), Encrypter.hash(mapping.user_id), mapping.id); + } + db.exec('COMMIT'); +} + +class Encrypter { + static initialize(encryptionKey) { + this.key = crypto.scryptSync(encryptionKey, 'salt', 32); + } + static encrypt(clearText) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); + const encrypted = cipher.update(clearText, 'utf8', 'hex'); + return [ + encrypted + cipher.final('hex'), + Buffer.from(iv).toString('hex'), + ].join('|'); + } + static decrypt(encryptedText) { + const [encrypted, iv] = encryptedText.split('|'); + if (!iv) + throw new Error('IV not found'); + const decipher = crypto.createDecipheriv(this.algorithm, this.key, Buffer.from(iv, 'hex')); + return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8'); + } + static hash(clearText) { + return crypto.createHash('sha256', this.key).update(clearText).digest('base64'); + } +} +Encrypter.algorithm = 'aes-256-cbc'; + +main(); diff --git a/sql/migrations/0003-add-timestamp-to-messages.sql b/sql/migrations/0003-add-timestamp-to-messages.sql new file mode 100644 index 0000000..e66a5c6 --- /dev/null +++ b/sql/migrations/0003-add-timestamp-to-messages.sql @@ -0,0 +1,46 @@ +-------------------------------------------------------------------------------- +-- Up +-------------------------------------------------------------------------------- +DELETE FROM messages; +ALTER TABLE messages ADD id_hash TEXT NOT NULL; +ALTER TABLE messages ADD timestamp datetime NOT NULL DEFAULT (datetime ('now', 'localtime')); +CREATE INDEX IF NOT EXISTS index_id_hash ON messages (id_hash); + +ALTER TABLE discord_names ADD discord_id_hash TEXT NOT NULL; +ALTER TABLE discord_names ADD discord_username_hash TEXT NOT NULL; +CREATE INDEX IF NOT EXISTS index_discord_id_hash ON discord_names (discord_id_hash); + +ALTER TABLE ticket_phrases ADD user_id_hash TEXT NOT NULL; +CREATE INDEX IF NOT EXISTS index_user_id_hash ON ticket_phrases (user_id_hash); + +ALTER TABLE notes ADD user_id_hash TEXT NOT NULL; +CREATE INDEX IF NOT EXISTS index_user_id_hash ON notes (user_id_hash); + +ALTER TABLE bans ADD user_id_hash TEXT NOT NULL; +CREATE INDEX IF NOT EXISTS index_user_id_hash ON bans (user_id_hash); + +ALTER TABLE github_user_mapping ADD discord_id_hash TEXT NOT NULL; +CREATE INDEX IF NOT EXISTS index_discord_id_hash ON github_user_mapping (discord_id_hash); + +ALTER TABLE private_help_tickets ADD user_id_hash TEXT NOT NULL; +CREATE INDEX IF NOT EXISTS index_user_id_hash ON private_help_tickets (user_id_hash); + +-------------------------------------------------------------------------------- +-- Down +-------------------------------------------------------------------------------- +ALTER TABLE messages DROP COLUMN id_hash; +ALTER TABLE messages DROP COLUMN timestamp; + +ALTER TABLE discord_names DROP COLUMN discord_id_hash; +ALTER TABLE discord_names DROP COLUMN discord_username_hash; + +ALTER TABLE ticket_phrases DROP COLUMN user_id_hash; + +ALTER TABLE notes DROP COLUMN user_id_hash; + +ALTER TABLE bans DROP COLUMN user_id_hash; + +ALTER TABLE github_user_mapping DROP COLUMN discord_id_hash; + +ALTER TABLE private_help_tickets DROP COLUMN user_id_hash; + diff --git a/src/config.ts b/src/config.ts index 56024fe..5ff8267 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import dotenv from 'dotenv'; dotenv.config(); -const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, GIT_REPO_BASE_URL, REDIS_URL, PORT, DEBUG } = process.env; +const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, DATABASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, GIT_REPO_BASE_URL, REDIS_URL, PORT, DEBUG } = process.env; export const config = { DISCORD_TOKEN, @@ -10,6 +10,7 @@ export const config = { DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, + DATABASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, diff --git a/src/events/handle-message.ts b/src/events/handle-message.ts index d0b9b89..07d5301 100644 --- a/src/events/handle-message.ts +++ b/src/events/handle-message.ts @@ -181,8 +181,11 @@ export async function handleMessageDelete(message: Message | PartialMessage) { const loggedMessage = await Database.getMessageWithRetry(message.id); if (!loggedMessage) return; + else await Database.removeMessge(message.id); - if (message.inGuild()) await logDeletion(loggedMessage, message); + if (message.inGuild()) { + await logDeletion(loggedMessage, message); + } } export async function handleBulkMessageDelete(messages: ReadonlyCollection, channel: GuildTextBasedChannel) { diff --git a/src/index.ts b/src/index.ts index 23209ee..24db94c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { openRedisClient } from './shared/RedisClient'; import { Handler } from './types'; import { initIfNecessary, loadHandlersFrom, refreshCommands } from './utils'; import { initializeWebserver } from './webserver'; +import { Encrypter } from './shared/Encrypter'; let ready = false; @@ -133,6 +134,7 @@ client.on('clientReady', async () => { await initIfNecessary(client, modals); await initIfNecessary(client, menus); + Encrypter.initialize(config.DATABASE_SECRET!); await Database.open('./data/discord-main.db'); await openRedisClient(config.REDIS_URL!, client); diff --git a/src/scheduler/tasks/index.ts b/src/scheduler/tasks/index.ts index 900da47..8ce79a0 100644 --- a/src/scheduler/tasks/index.ts +++ b/src/scheduler/tasks/index.ts @@ -1,7 +1,9 @@ import checkExpiredBansTask from './check-expired-bans-task'; import closeStaleTicketsTask from './close-stale-tickets-task'; +import pruneOldMessagesTask from './prune-old-messages-task'; export default [ closeStaleTicketsTask, checkExpiredBansTask, + pruneOldMessagesTask ]; diff --git a/src/scheduler/tasks/prune-old-messages-task.ts b/src/scheduler/tasks/prune-old-messages-task.ts new file mode 100644 index 0000000..822a52e --- /dev/null +++ b/src/scheduler/tasks/prune-old-messages-task.ts @@ -0,0 +1,13 @@ +import { Task } from '../../types'; +import { Database } from '../../shared/Database'; + +class PruneOldMessagesTask implements Task { + interval: number = 3.6e6; + firstRun: boolean = true; + + async handle(): Promise { + await Database.pruneOldMessages(); + } +} + +export default new PruneOldMessagesTask(); diff --git a/src/shared/Database.ts b/src/shared/Database.ts index 70a7566..d291ff5 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -3,8 +3,9 @@ import { open, Database as SqliteDatabase } from 'sqlite'; import sqlite3 from 'sqlite3'; import { Message } from '../events'; import { AppealMessage, Ban, GithubUserMapping, GuildArraySetting, GuildSetting, GuildSettings, KnowledgebaseItem, LoggedMessage, Note, PrivateHelpTicket, RoleButton, TicketMessage, TicketPhrase } from '../types'; -import { serializeMessage, wait } from '../utils'; +import { deserializeMessage, serializeMessage, wait } from '../utils'; import { readFileSync } from 'fs'; +import { Encrypter } from './Encrypter'; export const enum PrivateHelpTicketStatus { OPEN = 0, @@ -50,7 +51,7 @@ export class Database { //#region WHOIS static async getE621Ids(discordId: string): Promise { - const ids = await Database.db.all<{ user_id: number }[]>('SELECT DISTINCT user_id FROM discord_names WHERE discord_id = ?', discordId); + const ids = await Database.db.all<{ user_id: number }[]>('SELECT DISTINCT user_id FROM discord_names WHERE discord_id_hash = ?', Encrypter.hash(discordId)); return ids.map(r => r.user_id); } @@ -59,29 +60,15 @@ export class Database { // Perhaps this would be better and then we can return all the data: SELECT * FROM (SELECT * FROM discord_names WHERE user_id = ? ORDER BY id DESC) GROUP BY discord_id; const ids = await Database.db.all<{ discord_id: string }[]>('SELECT DISTINCT discord_id FROM discord_names WHERE user_id = ?', e621Id); - return ids.map(r => r.discord_id); - } - - static async getCombinedIds(id: string): Promise<{ userId: string, discordId: string }[]> { - const ids = await Database.db.all<{ discord_id: string, user_id: number }[]>(` - WITH RECURSIVE rec AS ( - SELECT DISTINCT d1.user_id, d1.discord_id, 1 AS depth FROM discord_names d1 WHERE d1.user_id = ? or d1.discord_id = ? - UNION - SELECT d3.user_id, d3.discord_id, depth + 1 AS depth FROM rec - LEFT OUTER JOIN discord_names d2 ON rec.discord_id = d2.discord_id - LEFT OUTER JOIN discord_names d3 ON d2.user_id = d3.user_id - WHERE depth <= 5 AND rec.depth = depth - ) SELECT DISTINCT user_id, discord_id FROM rec`, id, id); - - return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id })); + return ids.map(r => Encrypter.decrypt(r.discord_id)); } static async putUser(id: number, user: { id: string, username: string }) { - await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username); + await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_id_hash, discord_username, discord_username_hash) VALUES (?, ?, ?, ?, ?)', id, Encrypter.encrypt(user.id), Encrypter.hash(user.id), Encrypter.encrypt(user.username), Encrypter.hash(user.username)); } static async removeUser(id: number, discordId: string) { - await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id = ?', id, discordId); + await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id_hash = ?', id, Encrypter.hash(discordId)); } //#endregion @@ -144,8 +131,8 @@ export class Database { const serializedMessage = serializeMessage(message); await Database.db.run(` - INSERT INTO messages (id, author_id, author_name, channel_id, attachments, stickers, content) VALUES - (:id, :author_id, :author_name, :channel_id, :attachments, :stickers, :content) + INSERT INTO messages (id, id_hash, author_id, author_name, channel_id, attachments, stickers, content) VALUES + (:id, :id_hash, :author_id, :author_name, :channel_id, :attachments, :stickers, :content) `, ...serializedMessage); return true; @@ -155,20 +142,34 @@ export class Database { } } - static async getMessage(id: string): Promise { - return await Database.db.get('SELECT * FROM messages WHERE id = ?', id); + static async getMessage(id: string): Promise { + const data = await Database.db.get('SELECT * FROM messages WHERE id_hash = ?', Encrypter.hash(id)); + + if (!data) return null; + + return deserializeMessage(data); } - static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise { + static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise { let tried = 0; while (tried < retries) { tried++; - const message = await Database.db.get('SELECT * FROM messages WHERE id = ?', id); + const message = await Database.getMessage(id); if (message) return message; await wait(delay); } + + return null; + } + + static async removeMessge(id: string) { + await Database.db.run('DELETE from messages WHERE id_hash = ?', Encrypter.hash(id)); + } + + static async pruneOldMessages() { + await Database.db.run('DELETE from messages WHERE datetime(timestamp) < datetime("now", "-28 days")'); } //#endregion @@ -201,11 +202,15 @@ export class Database { } static async putTicketPhrase(userId: string, phrase: string) { - await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase); + await Database.db.run('INSERT INTO ticket_phrases(user_id, user_id_hash, phrase) VALUES (?, ?, ?)', Encrypter.encrypt(userId), Encrypter.hash(userId), phrase); } static async getTicketPhrase(id: number): Promise { - return await Database.db.get('SELECT * FROM ticket_phrases WHERE id = ?', id); + const data: TicketPhrase | undefined = await Database.db.get('SELECT * FROM ticket_phrases WHERE id = ?', id); + return data ? { + ...data, + user_id: Encrypter.decrypt(data.user_id) + } : undefined; } static async removeTicketPhrase(id: number) { @@ -213,18 +218,27 @@ export class Database { } static async removeAllTicketPhrasesFor(id: string): Promise { - return (await Database.db.run('DELETE from ticket_phrases WHERE user_id = ?', id)).changes!; + return (await Database.db.run('DELETE from ticket_phrases WHERE user_id_hash = ?', Encrypter.hash(id))).changes!; } - static async getTicketPhrasesFor(userId: string): Promise { - return await Database.db.all('SELECT * from ticket_phrases WHERE user_id = ?', userId); + static async getTicketPhrasesFor(id: string): Promise { + const data: TicketPhrase[] = await Database.db.all('SELECT * from ticket_phrases WHERE user_id_hash = ?', Encrypter.hash(id)); + return data.map((ticketPhrase) => { + return { + ...ticketPhrase, + user_id: Encrypter.decrypt(ticketPhrase.user_id) + }; + }); } static async getAllTicketPhrases(cb: (ticketPhrase: TicketPhrase) => void) { await Database.db.each('SELECT * from ticket_phrases', (err: any, ticketPhrase: TicketPhrase) => { if (err) return console.error(err); - cb(ticketPhrase); + cb({ + ...ticketPhrase, + user_id: Encrypter.decrypt(ticketPhrase.user_id) + }); }); } @@ -262,12 +276,12 @@ export class Database { //#region Notes static async putNote(userId: string, reason: string, modId: string) { - await Database.db.run('INSERT INTO notes(user_id, reason, mod_id) VALUES (?, ?, ?)', userId, reason, modId); + await Database.db.run('INSERT INTO notes(user_id, user_id_hash, reason, mod_id) VALUES (?, ?, ?, ?)', Encrypter.encrypt(userId), Encrypter.hash(userId), reason, Encrypter.encrypt(modId)); } static async editNote(id: number, oldReason: string, newReason: string, modId: string) { - await Database.db.run('UPDATE notes SET reason = ?, mod_id = ? WHERE id = ?', newReason, modId, id); - await Database.db.run('INSERT INTO note_edits(note_id, mod_id, previous_reason) VALUES (?, ?, ?)', id, modId, oldReason); + await Database.db.run('UPDATE notes SET reason = ?, mod_id = ? WHERE id = ?', newReason, Encrypter.encrypt(modId), id); + await Database.db.run('INSERT INTO note_edits(note_id, mod_id, previous_reason) VALUES (?, ?, ?)', id, Encrypter.encrypt(modId), oldReason); } static async removeNote(id: number): Promise { @@ -277,7 +291,13 @@ export class Database { } static async getNotes(userId: string): Promise { - return await Database.db.all('SELECT * from notes WHERE user_id = ?', userId); + const data: Note[] = await Database.db.all('SELECT * from notes WHERE user_id_hash = ?', Encrypter.hash(userId)); + return data.map((note) => { + return { + ...note, + user_id: Encrypter.decrypt(note.user_id) + }; + }); } //#endregion @@ -285,15 +305,25 @@ export class Database { //#region Bans static async putBan(userId: string, expiresAt: Date | null, fullBan = false) { - await Database.db.run('INSERT INTO bans(user_id, expires, expires_at, full_ban) VALUES (?, ?, ?, ?)', userId, expiresAt != null ? 1 : 0, expiresAt, fullBan); + await Database.db.run('INSERT INTO bans(user_id, user_id_hash, expires, expires_at, full_ban) VALUES (?, ?, ?, ?, ?)', Encrypter.encrypt(userId), Encrypter.hash(userId), expiresAt != null ? 1 : 0, expiresAt, fullBan); } static async getBan(userId: string): Promise { - return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId); + const data: Ban | undefined = await Database.db.get('SELECT * from bans WHERE user_id_hash = ? ORDER BY id DESC', Encrypter.hash(userId)); + return data ? { + ...data, + user_id: Encrypter.decrypt(data.user_id) + } : undefined; } static async getExpiredBans(date: Date): Promise { - return await Database.db.all('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date); + const data: Ban[] = await Database.db.all('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date); + return data.map((ban) => { + return { + ...ban, + user_id: Encrypter.decrypt(ban.user_id) + }; + }); } static async pruneExpiredBans(date: Date) { @@ -301,7 +331,7 @@ export class Database { } static async removeBan(userId: string) { - await Database.db.run('DELETE from bans WHERE user_id = ?', userId); + await Database.db.run('DELETE from bans WHERE user_id_hash = ?', Encrypter.hash(userId)); } //#endregion @@ -310,27 +340,33 @@ export class Database { // github_user_mapping static async putGithubUserMapping(discordId: string, githubUsername: string) { - await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername); + await Database.db.run('INSERT INTO github_user_mapping(discord_id, discord_id_hash, github_username) VALUES (?, ?, ?)', Encrypter.encrypt(discordId), Encrypter.hash(discordId), githubUsername); } static async getDiscordIdFromGithub(githubUsername: string): Promise { const mapping = await Database.db.get>('SELECT discord_id FROM github_user_mapping WHERE github_username = ?', githubUsername); - return mapping?.discord_id ?? null; + return mapping?.discord_id ? Encrypter.decrypt(mapping.discord_id) : null; } static async getGithubFromDiscordId(discordId: string): Promise { - const mapping = await Database.db.get>('SELECT github_username FROM github_user_mapping WHERE discord_id = ?', discordId); + const mapping = await Database.db.get>('SELECT github_username FROM github_user_mapping WHERE discord_id_hash = ?', Encrypter.hash(discordId)); return mapping?.github_username ?? null; } static async getAllGithubUserMappings(): Promise { - return await Database.db.all('SELECT * from github_user_mapping'); + const mappings: GithubUserMapping[] = await Database.db.all('SELECT * from github_user_mapping'); + return mappings.map((mapping) => { + return { + ...mapping, + discord_id: Encrypter.decrypt(mapping.discord_id) + }; + }); } static async removeGithubUserMapping(discordId: string) { - await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId); + await Database.db.run('DELETE from github_user_mapping WHERE discord_id_hash = ?', Encrypter.encrypt(discordId)); } //#endregion @@ -370,7 +406,7 @@ export class Database { //#region Private Help Tickets static async createPrivateHelpTicket(userId: string, threadId: string) { - await Database.db.run('INSERT INTO private_help_tickets(user_id, thread_id, status) VALUES (?, ?, ?)', userId, threadId, PrivateHelpTicketStatus.OPEN); + await Database.db.run('INSERT INTO private_help_tickets(user_id, user_id_hash, thread_id, status) VALUES (?, ?, ?, ?)', Encrypter.encrypt(userId), Encrypter.hash(userId), threadId, PrivateHelpTicketStatus.OPEN); } static async closePrivateHelpTicket(threadId: string) { @@ -378,11 +414,17 @@ export class Database { } static async getLatestPrivateHelpTicketBy(userId: string): Promise { - return await Database.db.get('SELECT * from private_help_tickets WHERE user_id = ? ORDER BY timestamp DESC LIMIT 1', userId); + return await Database.db.get('SELECT * from private_help_tickets WHERE user_id_hash = ? ORDER BY timestamp DESC LIMIT 1', Encrypter.hash(userId)); } static async getAllOpenPrivateHelpTickets(): Promise { - return await Database.db.all('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN); + const tickets: PrivateHelpTicket[] = await Database.db.all('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN); + return tickets.map((ticket) => { + return { + ...ticket, + user_id: Encrypter.decrypt(ticket.user_id) + }; + }); } //#endregion diff --git a/src/shared/Encrypter.ts b/src/shared/Encrypter.ts new file mode 100644 index 0000000..9c251fc --- /dev/null +++ b/src/shared/Encrypter.ts @@ -0,0 +1,42 @@ +// Based off this stackoverflow answer: https://stackoverflow.com/a/66476430 +// No point reinventing the wheel + +import crypto from 'crypto'; + +export class Encrypter { + private static algorithm = 'aes-256-cbc'; + private static key: Buffer; + + static initialize(encryptionKey: string) { + this.key = crypto.scryptSync(encryptionKey, 'salt', 32); + } + + static encrypt(clearText: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); + const encrypted = cipher.update(clearText, 'utf8', 'hex'); + + return [ + encrypted + cipher.final('hex'), + Buffer.from(iv).toString('hex'), + ].join('|'); + } + + static decrypt(encryptedText: string): string { + const [encrypted, iv] = encryptedText.split('|'); + + if (!iv) throw new Error('IV not found'); + + const decipher = crypto.createDecipheriv( + this.algorithm, + this.key, + Buffer.from(iv, 'hex') + ); + + return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8'); + } + + static hash(clearText: string): string { + return crypto.createHash('sha256', this.key).update(clearText).digest('base64'); + } +} \ No newline at end of file diff --git a/src/types/database-types.d.ts b/src/types/database-types.d.ts index 135ccdd..1fecb34 100644 --- a/src/types/database-types.d.ts +++ b/src/types/database-types.d.ts @@ -1,11 +1,13 @@ export type LoggedMessage = { id: string + id_encrypted: string author_id: string author_name: string channel_id: string attachments: string stickers: string content: string + timestamp: Date } export type GuildSettings = { @@ -45,12 +47,14 @@ export type AppealMessage = { export type TicketPhrase = { id: number user_id: string + user_id_hash: string phrase: string } export type Note = { id: number user_id: string + user_id_hash: string reason: string mod_id: string timestamp: string @@ -59,6 +63,7 @@ export type Note = { export type Ban = { id: number user_id: string + user_id_hash: string expires: 0 | 1 expires_at: string full_ban: 0 | 1 @@ -67,6 +72,7 @@ export type Ban = { export type GithubUserMapping = { id: number discord_id: string + discord_id_hash: string github_username: string } @@ -80,6 +86,7 @@ export type KnowledgebaseItem = { export type PrivateHelpTicket = { id: number user_id: string + user_id_hash: string thread_id: string status: PrivateHelpTicketStatus timestamp: string diff --git a/src/utils/message-utils.ts b/src/utils/message-utils.ts index 0a5837c..5d4091d 100644 --- a/src/utils/message-utils.ts +++ b/src/utils/message-utils.ts @@ -1,4 +1,5 @@ import { Message } from '../events'; +import { Encrypter } from '../shared/Encrypter'; import { LoggedMessage } from '../types'; export const ARRAY_SEPARATOR = '$'; @@ -9,7 +10,17 @@ export function serializeMessage(message: Message): string[] { const attachments = message.attachments.map(a => `${a.name}:${a.id}`); const stickers = message.stickers.map(s => `${s.name}:${s.id}`); - return [message.id, message.author.id, message.author.username, message.channelId, attachments.join(ARRAY_SEPARATOR), stickers.join(ARRAY_SEPARATOR), message.content]; + return [Encrypter.encrypt(message.id), Encrypter.hash(message.id), Encrypter.encrypt(message.author.id), Encrypter.encrypt(message.author.username), message.channelId, attachments.join(ARRAY_SEPARATOR), stickers.join(ARRAY_SEPARATOR), Encrypter.encrypt(message.content)]; +} + +export function deserializeMessage(loggedMessage: LoggedMessage): LoggedMessage { + return { + ...loggedMessage, + id: Encrypter.decrypt(loggedMessage.id), + author_id: Encrypter.decrypt(loggedMessage.author_id), + author_name: Encrypter.decrypt(loggedMessage.author_name), + content: Encrypter.decrypt(loggedMessage.content) + }; } export function deserializeMessagePart(part: string): string[] {