diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a96dba..e99132a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to this project are documented in this file. +## [1.124.0] - noch nicht deployed + +**Achtung: dieser Commit braucht `CREDENTIAL_ENCRYPTION_KEY` in der Produktions-`.env`, +bevor er deployed wird — siehe unten.** + +### Security +- **Apple-/Synology-Kalenderpasswörter und OAuth-Tokens werden jetzt verschlüsselt + gespeichert.** `CalendarConnection.accessToken`/`refreshToken` enthielten bei Apple- und + Synology-Verbindungen das echte Passwort im Klartext (`"email:passwort"`). Neue + Prisma-Client-Extension (`src/lib/prisma.ts`) ver-/entschlüsselt diese Felder transparent + für alle bestehenden Aufrufstellen (AES-256-GCM, `src/lib/credential-crypto.ts`). + Bestehende Klartextzeilen bleiben lesbar (`enc1:`-Präfix unterscheidet altes von neuem + Format) und werden beim nächsten Schreiben automatisch verschlüsselt — keine Migration, + keine erzwungene Neuverbindung nötig. + +### Erforderlich vor dem Deploy +`CREDENTIAL_ENCRYPTION_KEY` (32 Byte, base64) muss in der Produktions-`.env` stehen. Ohne +diese Variable wirft jeder Kalender-Request eine Exception. Erzeugen mit: +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +``` + +Verifiziert: Typecheck, Lint, 85 Unit-Tests (6 neu für `credential-crypto`), +Produktions-Build — alle grün, mit einem Test-Schlüssel gebaut. + ## [1.123.2] - 2026-09-12 Erste drei Punkte aus dem TODO-Block L (Sicherheitsdurchgang vom 12.09.) — sichere, in diff --git a/package.json b/package.json index 8b1f8c9..0e42290 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.123.2", + "version": "1.124.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 44f98df..be9f2fe 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,13 +3,20 @@ import GoogleProvider from "next-auth/providers/google"; import AzureADProvider from "next-auth/providers/azure-ad"; import AppleProvider from "next-auth/providers/apple"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import type { PrismaClient } from "@prisma/client"; import CredentialsProvider from "next-auth/providers/credentials"; import { compare } from "bcryptjs"; import { prisma } from "@/lib/prisma"; import { rateLimit } from "@/lib/rate-limit"; export const authOptions: NextAuthOptions = { - adapter: PrismaAdapter(prisma), + // @next-auth/prisma-adapter typisiert seinen Parameter gegen den unveraenderten + // PrismaClient. Seit prisma.ts den Client per $extends() um die Verschluesselung von + // CalendarConnection.accessToken/refreshToken erweitert (siehe dort), hat er eine andere + // TS-Form (kein $on/$use mehr), ist zur Laufzeit aber ein voll funktionsfaehiger Prisma- + // Client -- der Adapter beruehrt weder $on/$use noch das erweiterte Modell. Reiner + // Typ-Cast fuer diese eine Uebergabestelle. + adapter: PrismaAdapter(prisma as unknown as PrismaClient), providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID || "", diff --git a/src/lib/credential-crypto.ts b/src/lib/credential-crypto.ts new file mode 100644 index 0000000..e74ee23 --- /dev/null +++ b/src/lib/credential-crypto.ts @@ -0,0 +1,91 @@ +import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; + +/** + * Verschlüsselung ruhender Zugangsdaten (Apple-/Synology-Passwörter, OAuth-Tokens). + * + * `CalendarConnection.accessToken`/`refreshToken` enthielten bislang bei Apple und Synology + * das echte Passwort im Klartext ("email:passwort") — bei einem Datenbank-Leak wären das nicht + * nur App-Daten, sondern die tatsächlichen Zugangsdaten der Nutzer bei Apple bzw. auf ihrem NAS. + * + * AES-256-GCM, Schlüssel aus `CREDENTIAL_ENCRYPTION_KEY` (32 Byte, base64 oder hex — nie in der + * Datenbank). Format: `enc1:::`. Der Präfix `enc1:` + * unterscheidet neu verschlüsselte Werte von den bestehenden Klartext-Zeilen: decryptCredential + * gibt alles ohne diesen Präfix unverändert zurück, statt einen Fehler zu werfen. So brechen + * bestehende Verbindungen beim Rollout nicht — sie werden verschlüsselt, sobald sie das nächste + * Mal geschrieben werden (Token-Refresh, Neuverbinden), nicht rückwirkend per Migration. + */ + +const ALGORITHM = 'aes-256-gcm'; +const PREFIX = 'enc1:'; +const IV_LENGTH = 12; // GCM-Standard: 96 Bit + +let cachedKey: Buffer | null = null; + +function loadKey(): Buffer { + if (cachedKey) return cachedKey; + + const raw = process.env.CREDENTIAL_ENCRYPTION_KEY; + if (!raw) { + throw new Error( + 'CREDENTIAL_ENCRYPTION_KEY ist nicht gesetzt. Ohne diesen Schlüssel können ' + + 'Kalender-Zugangsdaten weder ver- noch entschlüsselt werden. Erzeugen mit: ' + + 'node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"', + ); + } + + // Sowohl base64 (44 Zeichen mit Padding) als auch hex (64 Zeichen) akzeptieren. + const key = raw.length === 64 && /^[0-9a-fA-F]+$/.test(raw) + ? Buffer.from(raw, 'hex') + : Buffer.from(raw, 'base64'); + + if (key.length !== 32) { + throw new Error( + `CREDENTIAL_ENCRYPTION_KEY muss nach Dekodierung genau 32 Byte ergeben, sind aber ${key.length}.`, + ); + } + + cachedKey = key; + return key; +} + +/** Nur zum Testen: erzwingt beim nächsten Aufruf ein erneutes Einlesen des Schlüssels. */ +export function _resetKeyCacheForTests(): void { + cachedKey = null; +} + +export function encryptCredential(plaintext: string): string { + const key = loadKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${ciphertext.toString('base64')}`; +} + +/** + * Entschlüsselt einen Wert im `enc1:`-Format. Werte ohne diesen Präfix sind Alt-Klartext aus + * der Zeit vor dieser Änderung und werden unverändert zurückgegeben. + */ +export function decryptCredential(value: string): string { + if (!value.startsWith(PREFIX)) return value; + + const parts = value.slice(PREFIX.length).split(':'); + if (parts.length !== 3) { + throw new Error('Ungültiges enc1-Format für gespeicherte Zugangsdaten.'); + } + const [ivB64, authTagB64, ciphertextB64] = parts; + + const key = loadKey(); + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivB64, 'base64')); + decipher.setAuthTag(Buffer.from(authTagB64, 'base64')); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertextB64, 'base64')), + decipher.final(), + ]); + return plaintext.toString('utf8'); +} + +/** Ob ein gespeicherter Wert bereits im neuen, verschlüsselten Format vorliegt. */ +export function isEncryptedCredential(value: string): boolean { + return value.startsWith(PREFIX); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 9bec1d2..9945b48 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,10 +1,37 @@ import { PrismaClient } from '@prisma/client'; +import { encryptCredential, decryptCredential } from './credential-crypto'; -const globalForPrisma = global as unknown as { prisma: PrismaClient }; +const globalForPrisma = global as unknown as { prisma: ReturnType }; -export const prisma = - globalForPrisma.prisma || - new PrismaClient({ +// Felder, die CalendarConnection im Klartext speicherte: bei Apple/Synology das echte +// Passwort ("email:passwort"), sonst ein OAuth-Token. Beide sind bei einem DB-Leak reale +// Zugangsdaten Dritter, nicht nur App-interne Daten -- siehe src/lib/credential-crypto.ts. +const ENCRYPTED_FIELDS = ['accessToken', 'refreshToken'] as const; + +function encryptFields>(data: T): T { + for (const field of ENCRYPTED_FIELDS) { + const value = data[field]; + if (typeof value === 'string' && value.length > 0) { + (data as Record)[field] = encryptCredential(value); + } + } + return data; +} + +function decryptRow | null>(row: T): T { + if (!row) return row; + const record = row as Record; + for (const field of ENCRYPTED_FIELDS) { + const value = record[field]; + if (typeof value === 'string' && value.length > 0) { + record[field] = decryptCredential(value); + } + } + return row; +} + +function createPrismaClient() { + const client = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL, @@ -12,5 +39,36 @@ export const prisma = }, }); + // Transparente Ver-/Entschlüsselung von accessToken/refreshToken bei CalendarConnection. + // Bestehender Aufrufcode (an rund einem Dutzend Stellen) liest und schreibt weiterhin + // Klartext -- die Umwandlung passiert ausschliesslich hier, an der Prisma-Grenze. + return client.$extends({ + query: { + calendarConnection: { + async create({ args, query }) { + if (args.data) encryptFields(args.data as Record); + return decryptRow(await query(args)); + }, + async update({ args, query }) { + if (args.data) encryptFields(args.data as Record); + return decryptRow(await query(args)); + }, + async findFirst({ args, query }) { + return decryptRow(await query(args)); + }, + async findUnique({ args, query }) { + return decryptRow(await query(args)); + }, + async findMany({ args, query }) { + const rows = await query(args); + return rows.map((row) => decryptRow(row as Record)); + }, + }, + }, + }); +} + +export const prisma = globalForPrisma.prisma || createPrismaClient(); + // Cache the client globally in ALL environments to prevent connection pool exhaustion globalForPrisma.prisma = prisma; diff --git a/tests/unit/credential-crypto.test.ts b/tests/unit/credential-crypto.test.ts new file mode 100644 index 0000000..d708864 --- /dev/null +++ b/tests/unit/credential-crypto.test.ts @@ -0,0 +1,68 @@ +import { test, describe, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + encryptCredential, + decryptCredential, + isEncryptedCredential, + _resetKeyCacheForTests, +} from "../../src/lib/credential-crypto.ts"; + +/** + * Ver-/Entschlüsselung ruhender Kalender-Zugangsdaten. + * + * Beobachtbares Verhalten: ein verschlüsselter Wert lässt sich mit demselben Schlüssel wieder + * lesbar machen; ein Alt-Klartextwert (vor dieser Änderung in der DB) bleibt unverändert lesbar; + * ein manipulierter Geheimtext fällt auf, statt stillschweigend falsche Daten zu liefern. + * + * CREDENTIAL_ENCRYPTION_KEY wird vor jedem Test gesetzt (deterministischer Test-Schlüssel, + * keine echte Produktionsvariable) -- loadKey() liest die Env-Var erst beim ersten Ver-/ + * Entschlüsseln, nicht beim Modul-Import, deshalb reicht das hier statt eines Mocks. + */ +const TEST_KEY = Buffer.alloc(32, 7).toString("base64"); + +describe("credential-crypto", () => { + before(() => { + process.env.CREDENTIAL_ENCRYPTION_KEY = TEST_KEY; + _resetKeyCacheForTests(); + }); + after(() => _resetKeyCacheForTests()); + + test("Roundtrip: verschlüsselt und wieder entschlüsselt ergibt das Original", () => { + const plain = "someone@icloud.com:app-specific-password-123"; + const cipher = encryptCredential(plain); + assert.notEqual(cipher, plain); + assert.equal(decryptCredential(cipher), plain); + }); + + test("Verschlüsselter Wert trägt das enc1-Präfix, Klartext nicht", () => { + const cipher = encryptCredential("secret"); + assert.equal(isEncryptedCredential(cipher), true); + assert.equal(isEncryptedCredential("plain-old-value"), false); + }); + + test("Alt-Klartext ohne enc1-Präfix bleibt beim Entschlüsseln unverändert", () => { + // Simuliert eine bestehende DB-Zeile von vor dieser Änderung. + const legacy = "user@example.com:hunter2"; + assert.equal(decryptCredential(legacy), legacy); + }); + + test("zwei Verschlüsselungen desselben Werts ergeben unterschiedliche Geheimtexte", () => { + // Zufälliger IV pro Aufruf -- wichtig, damit gleiche Passwörter nicht denselben + // Geheimtext ergeben (das würde sie in der DB als "gleich" erkennbar machen). + const a = encryptCredential("same-password"); + const b = encryptCredential("same-password"); + assert.notEqual(a, b); + assert.equal(decryptCredential(a), "same-password"); + assert.equal(decryptCredential(b), "same-password"); + }); + + test("manipulierter Geheimtext wird beim Entschlüsseln abgelehnt", () => { + const cipher = encryptCredential("secret-value"); + const tampered = cipher.slice(0, -4) + "abcd"; + assert.throws(() => decryptCredential(tampered)); + }); + + test("falsches enc1-Format wirft statt still falsche Daten zu liefern", () => { + assert.throws(() => decryptCredential("enc1:nur-ein-teil")); + }); +});