From 8bceeaf4c07adfee49fdf86c6792bb6366852233 Mon Sep 17 00:00:00 2001 From: Maxime Cannoodt Date: Sun, 13 Nov 2022 15:47:10 +0100 Subject: [PATCH] feat: :lock: Upgrade encryption model to use GCM cipher and randomized IV. --- .../20221113141944_add_iv_field/migration.sql | 16 ++++++ server/prisma/schema.prisma | 3 +- .../controllers/note/note.get.controller.ts | 4 +- .../controllers/note/note.post.controller.ts | 13 +++-- server/src/logging/EventLogger.ts | 10 ++++ server/src/tasks/deleteExpiredNotes.ts | 9 +-- server/src/util.ts | 9 +++ webapp/src/lib/crypto/decrypt.ts | 55 ++++++++++++++++--- webapp/src/routes/note/[id]/+page.svelte | 11 ++-- 9 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 server/prisma/migrations/20221113141944_add_iv_field/migration.sql diff --git a/server/prisma/migrations/20221113141944_add_iv_field/migration.sql b/server/prisma/migrations/20221113141944_add_iv_field/migration.sql new file mode 100644 index 0000000..7d1ba66 --- /dev/null +++ b/server/prisma/migrations/20221113141944_add_iv_field/migration.sql @@ -0,0 +1,16 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_EncryptedNote" ( + "id" TEXT NOT NULL PRIMARY KEY, + "insert_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expire_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ciphertext" TEXT NOT NULL, + "hmac" TEXT, + "iv" TEXT, + "crypto_version" TEXT NOT NULL DEFAULT 'v1' +); +INSERT INTO "new_EncryptedNote" ("ciphertext", "crypto_version", "expire_time", "hmac", "id", "insert_time") SELECT "ciphertext", "crypto_version", "expire_time", "hmac", "id", "insert_time" FROM "EncryptedNote"; +DROP TABLE "EncryptedNote"; +ALTER TABLE "new_EncryptedNote" RENAME TO "EncryptedNote"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7c217e0..e638c83 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -15,7 +15,8 @@ model EncryptedNote { insert_time DateTime @default(now()) expire_time DateTime @default(now()) ciphertext String - hmac String + hmac String? + iv String? crypto_version String @default("v1") } diff --git a/server/src/controllers/note/note.get.controller.ts b/server/src/controllers/note/note.get.controller.ts index 11a473d..0dad741 100644 --- a/server/src/controllers/note/note.get.controller.ts +++ b/server/src/controllers/note/note.get.controller.ts @@ -1,7 +1,7 @@ import { NextFunction, Request, Response } from "express"; import { getExpiredNoteFilter } from "../../lib/expiredNoteFilter"; import EventLogger from "../../logging/EventLogger"; -import { getConnectingIp } from "../../util"; +import { getConnectingIp, getNoteSize } from "../../util"; import { getNote } from "../../db/note.dao"; export async function getNoteController( req: Request, @@ -16,7 +16,7 @@ export async function getNoteController( success: true, host: ip, note_id: note.id, - size_bytes: note.ciphertext.length + note.hmac.length, + size_bytes: getNoteSize(note), }); res.send(note); } else { diff --git a/server/src/controllers/note/note.post.controller.ts b/server/src/controllers/note/note.post.controller.ts index 6defe41..d411222 100644 --- a/server/src/controllers/note/note.post.controller.ts +++ b/server/src/controllers/note/note.post.controller.ts @@ -2,7 +2,7 @@ import { EncryptedNote } from "@prisma/client"; import { NextFunction, Request, Response } from "express"; import { crc16 as crc } from "crc"; import { createNote } from "../../db/note.dao"; -import { addDays, getConnectingIp } from "../../util"; +import { addDays, getConnectingIp, getNoteSize } from "../../util"; import EventLogger, { WriteEvent } from "../../logging/EventLogger"; import { validateOrReject, @@ -23,8 +23,12 @@ export class NotePostRequest { ciphertext: string | undefined; @IsBase64() - @IsNotEmpty() - hmac: string | undefined; + @ValidateIf((o) => !o.iv) + hmac?: string | undefined; + + @IsBase64() + @ValidateIf((o) => !o.hmac) + iv?: string | undefined; @ValidateIf((o) => o.user_id != null) @IsHexadecimal() @@ -77,6 +81,7 @@ export async function postNoteController( const note = { ciphertext: notePostRequest.ciphertext as string, hmac: notePostRequest.hmac as string, + iv: notePostRequest.iv as string, expire_time: addDays(new Date(), EXPIRE_WINDOW_DAYS), crypto_version: notePostRequest.crypto_version, } as EncryptedNote; @@ -86,7 +91,7 @@ export async function postNoteController( .then(async (savedNote) => { event.success = true; event.note_id = savedNote.id; - event.size_bytes = savedNote.ciphertext.length + savedNote.hmac.length; + event.size_bytes = getNoteSize(note); event.expire_window_days = EXPIRE_WINDOW_DAYS; await EventLogger.writeEvent(event); res.json({ diff --git a/server/src/logging/EventLogger.ts b/server/src/logging/EventLogger.ts index b2f5e84..4671c11 100644 --- a/server/src/logging/EventLogger.ts +++ b/server/src/logging/EventLogger.ts @@ -1,5 +1,6 @@ import { event } from "@prisma/client"; import prisma from "../db/client"; +import logger from "./logger"; export enum EventType { WRITE = "WRITE", @@ -33,19 +34,28 @@ interface PurgeEvent extends Event { } export default class EventLogger { + private static printError(event: Event) { + if (event.error) { + logger.error(event.error); + } + } + public static writeEvent(event: WriteEvent): Promise { + this.printError(event); return prisma.event.create({ data: { type: EventType.WRITE, ...event }, }); } public static readEvent(event: ReadEvent): Promise { + this.printError(event); return prisma.event.create({ data: { type: EventType.READ, ...event }, }); } public static purgeEvent(event: PurgeEvent): Promise { + this.printError(event); return prisma.event.create({ data: { type: EventType.PURGE, ...event }, }); diff --git a/server/src/tasks/deleteExpiredNotes.ts b/server/src/tasks/deleteExpiredNotes.ts index b24257c..f185a2d 100644 --- a/server/src/tasks/deleteExpiredNotes.ts +++ b/server/src/tasks/deleteExpiredNotes.ts @@ -2,6 +2,7 @@ import { deleteNotes, getExpiredNotes } from "../db/note.dao"; import { getExpiredNoteFilter } from "../lib/expiredNoteFilter"; import EventLogger from "../logging/EventLogger"; import logger from "../logging/logger"; +import { getNoteSize } from "../util"; export async function deleteExpiredNotes(): Promise { logger.info("[Cleanup] Cleaning up expired notes..."); @@ -11,14 +12,14 @@ export async function deleteExpiredNotes(): Promise { .then(async (deleteCount) => { const logs = toDelete.map(async (note) => { logger.info( - `[Cleanup] Deleted note ${note.id} with size ${ - note.ciphertext.length + note.hmac.length - } bytes` + `[Cleanup] Deleted note ${note.id} with size ${getNoteSize( + note + )} bytes` ); return EventLogger.purgeEvent({ success: true, note_id: note.id, - size_bytes: note.ciphertext.length + note.hmac.length, + size_bytes: getNoteSize(note), }); }); await Promise.all(logs); diff --git a/server/src/util.ts b/server/src/util.ts index 01b03cf..23805ab 100644 --- a/server/src/util.ts +++ b/server/src/util.ts @@ -1,3 +1,4 @@ +import { EncryptedNote } from "@prisma/client"; import { Request } from "express"; export function addDays(date: Date, days: number): Date { @@ -11,3 +12,11 @@ export function getConnectingIp(req: Request): string { req.headers["X-Forwarded-For"] || req.socket.remoteAddress) as string; } + +export function getNoteSize( + note: Pick +) { + return ( + note.ciphertext.length + (note.hmac?.length ?? 0) + (note.iv?.length ?? 0) + ); +} diff --git a/webapp/src/lib/crypto/decrypt.ts b/webapp/src/lib/crypto/decrypt.ts index d5cc472..59d255b 100644 --- a/webapp/src/lib/crypto/decrypt.ts +++ b/webapp/src/lib/crypto/decrypt.ts @@ -2,16 +2,31 @@ import { AES, enc, HmacSHA256 } from 'crypto-js'; -export async function decrypt( - cryptData: { ciphertext: string; hmac: string; key: string }, - version: string -): Promise { +type CryptData = { + ciphertext: string; + key: string; + iv?: string; + hmac?: string; +}; + +type CryptData_v1 = CryptData & { + hmac: string; +}; + +type CryptData_v3 = CryptData & { + iv: string; +}; + +export async function decrypt(cryptData: CryptData, version: string): Promise { console.debug(`decrypting with crypto suite ${version}`); if (version === 'v1') { - return decrypt_v1(cryptData); + return decrypt_v1(cryptData as CryptData_v1); } if (version === 'v2') { - return decrypt_v2(cryptData); + return decrypt_v2(cryptData as CryptData_v1); + } + if (version === 'v3') { + return decrypt_v3(cryptData as CryptData_v3); } throw new Error(`Unsupported crypto version: ${version}`); } @@ -53,15 +68,37 @@ export async function decrypt_v2(cryptData: { const md = await window.crypto.subtle.decrypt( { name: 'AES-CBC', iv: new Uint8Array(16) }, - await _getAesKey(secret), + await _getAesCbcKey(secret), ciphertext_buf ); return new TextDecoder().decode(md); } -function _getAesKey(secret: ArrayBuffer): Promise { +export async function decrypt_v3(cryptData: { + ciphertext: string; + iv: string; + key: string; +}): Promise { + const secret = base64ToArrayBuffer(cryptData.key); + const ciphertext_buf = base64ToArrayBuffer(cryptData.ciphertext); + const iv_buf = base64ToArrayBuffer(cryptData.iv); + + const md = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv_buf }, + await _getAesGcmKey(secret), + ciphertext_buf + ); + return new TextDecoder().decode(md); +} + +function _getAesCbcKey(secret: ArrayBuffer): Promise { return window.crypto.subtle.importKey('raw', secret, { name: 'AES-CBC', length: 256 }, false, [ - 'encrypt', + 'decrypt' + ]); +} + +function _getAesGcmKey(secret: ArrayBuffer): Promise { + return window.crypto.subtle.importKey('raw', secret, { name: 'AES-GCM', length: 256 }, false, [ 'decrypt' ]); } diff --git a/webapp/src/routes/note/[id]/+page.svelte b/webapp/src/routes/note/[id]/+page.svelte index 71ed894..e838ed8 100644 --- a/webapp/src/routes/note/[id]/+page.svelte +++ b/webapp/src/routes/note/[id]/+page.svelte @@ -8,19 +8,18 @@ import RawRenderer from '$lib/components/RawRenderer.svelte'; import LogoDocument from 'svelte-icons/md/MdUndo.svelte'; import Dismissable from '$lib/components/Dismissable.svelte'; + import type { PageData } from './$types'; - // Auto-loaded from [id].ts endpoint - - /** @type {import('./$types').PageData} */ - export let data; + export let data: PageData; let { note } = data; + let plaintext: string; let timeString: string; let decryptFailed = false; let showRaw = false; onMount(() => { - if (browser) { + if (browser && note) { const key = location.hash.slice(1); decrypt({ ...note, key }, note.crypto_version) .then((value) => (plaintext = value)) @@ -28,7 +27,7 @@ } }); - $: if (note.insert_time) { + $: if (note?.insert_time) { const diff_ms = new Date().valueOf() - new Date(note.insert_time).valueOf(); timeString = msToString(diff_ms); }