feat: 🔒 Upgrade encryption model to use GCM cipher and randomized IV.
This commit is contained in:
parent
6f3552ce59
commit
8bceeaf4c0
@ -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;
|
@ -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")
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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({
|
||||
|
@ -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<event> {
|
||||
this.printError(event);
|
||||
return prisma.event.create({
|
||||
data: { type: EventType.WRITE, ...event },
|
||||
});
|
||||
}
|
||||
|
||||
public static readEvent(event: ReadEvent): Promise<event> {
|
||||
this.printError(event);
|
||||
return prisma.event.create({
|
||||
data: { type: EventType.READ, ...event },
|
||||
});
|
||||
}
|
||||
|
||||
public static purgeEvent(event: PurgeEvent): Promise<event> {
|
||||
this.printError(event);
|
||||
return prisma.event.create({
|
||||
data: { type: EventType.PURGE, ...event },
|
||||
});
|
||||
|
@ -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<number> {
|
||||
logger.info("[Cleanup] Cleaning up expired notes...");
|
||||
@ -11,14 +12,14 @@ export async function deleteExpiredNotes(): Promise<number> {
|
||||
.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);
|
||||
|
@ -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<EncryptedNote, "ciphertext" | "hmac" | "iv">
|
||||
) {
|
||||
return (
|
||||
note.ciphertext.length + (note.hmac?.length ?? 0) + (note.iv?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<CryptoKey> {
|
||||
export async function decrypt_v3(cryptData: {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
key: string;
|
||||
}): Promise<string> {
|
||||
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<CryptoKey> {
|
||||
return window.crypto.subtle.importKey('raw', secret, { name: 'AES-CBC', length: 256 }, false, [
|
||||
'encrypt',
|
||||
'decrypt'
|
||||
]);
|
||||
}
|
||||
|
||||
function _getAesGcmKey(secret: ArrayBuffer): Promise<CryptoKey> {
|
||||
return window.crypto.subtle.importKey('raw', secret, { name: 'AES-GCM', length: 256 }, false, [
|
||||
'decrypt'
|
||||
]);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user