feat: Kalender-Zugangsdaten verschluesselt speichern (AES-256-GCM)
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

L1 aus dem TODO-Block Sicherheitsdurchgang: Apple-/Synology-Kalenderpasswoerter lagen
im Klartext in CalendarConnection.accessToken ("email:passwort"). Bei einem
Datenbank-Leak waeren das nicht nur App-Daten, sondern die echten Zugangsdaten der
Nutzer bei Apple bzw. auf ihrem NAS.

- src/lib/credential-crypto.ts: AES-256-GCM, Schluessel aus CREDENTIAL_ENCRYPTION_KEY.
  enc1:-Praefix unterscheidet neu verschluesselte Werte von Alt-Klartext -- Klartext
  bleibt beim Entschluesseln unveraendert lesbar statt zu brechen.
- src/lib/prisma.ts: Prisma-Client-Extension ver-/entschluesselt accessToken/refreshToken
  transparent fuer alle rund ein Dutzend Aufrufstellen (Google/Outlook/Notion-Tokens
  laufen mit, sind ebenso sensibel). Kein Aenderungsbedarf an den Aufrufstellen selbst.
- src/lib/auth.ts: PrismaAdapter braucht einen expliziten Typ-Cast fuer den erweiterten
  Client (kein $on/$use mehr in dessen TS-Form, zur Laufzeit unveraendert).

Bestehende Zeilen brauchen keine Migration: Alt-Klartext bleibt lesbar und wird beim
naechsten Schreiben (Token-Refresh, Neuverbinden) automatisch verschluesselt.

ACHTUNG -- NICHT DEPLOYEN, bevor CREDENTIAL_ENCRYPTION_KEY in der Produktions-.env
steht: ohne den Schluessel wirft jeder Kalender-Request eine Exception. Konnte nicht
selbst gesetzt werden (Remote-Schreibzugriff per SSH von der Sandbox verweigert).

Verifiziert: Typecheck, Lint, 85 Unit-Tests (6 neu), Produktions-Build -- alle gruen,
mit einem Test-Schluessel gebaut.

v1.124.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-12 16:53:33 +02:00
parent 8f017cb879
commit 158051bb47
6 changed files with 255 additions and 6 deletions

View File

@ -2,6 +2,31 @@
All notable changes to this project are documented in this file. 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 ## [1.123.2] - 2026-09-12
Erste drei Punkte aus dem TODO-Block L (Sicherheitsdurchgang vom 12.09.) — sichere, in Erste drei Punkte aus dem TODO-Block L (Sicherheitsdurchgang vom 12.09.) — sichere, in

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -3,13 +3,20 @@ import GoogleProvider from "next-auth/providers/google";
import AzureADProvider from "next-auth/providers/azure-ad"; import AzureADProvider from "next-auth/providers/azure-ad";
import AppleProvider from "next-auth/providers/apple"; import AppleProvider from "next-auth/providers/apple";
import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { PrismaClient } from "@prisma/client";
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcryptjs"; import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
export const authOptions: NextAuthOptions = { 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: [ providers: [
GoogleProvider({ GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "", clientId: process.env.GOOGLE_CLIENT_ID || "",

View File

@ -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:<iv base64>:<authTag base64>:<ciphertext base64>`. 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);
}

View File

@ -1,10 +1,37 @@
import { PrismaClient } from '@prisma/client'; 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<typeof createPrismaClient> };
export const prisma = // Felder, die CalendarConnection im Klartext speicherte: bei Apple/Synology das echte
globalForPrisma.prisma || // Passwort ("email:passwort"), sonst ein OAuth-Token. Beide sind bei einem DB-Leak reale
new PrismaClient({ // Zugangsdaten Dritter, nicht nur App-interne Daten -- siehe src/lib/credential-crypto.ts.
const ENCRYPTED_FIELDS = ['accessToken', 'refreshToken'] as const;
function encryptFields<T extends Record<string, unknown>>(data: T): T {
for (const field of ENCRYPTED_FIELDS) {
const value = data[field];
if (typeof value === 'string' && value.length > 0) {
(data as Record<string, unknown>)[field] = encryptCredential(value);
}
}
return data;
}
function decryptRow<T extends Record<string, unknown> | null>(row: T): T {
if (!row) return row;
const record = row as Record<string, unknown>;
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: { datasources: {
db: { db: {
url: process.env.DATABASE_URL, 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<string, unknown>);
return decryptRow(await query(args));
},
async update({ args, query }) {
if (args.data) encryptFields(args.data as Record<string, unknown>);
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<string, unknown>));
},
},
},
});
}
export const prisma = globalForPrisma.prisma || createPrismaClient();
// Cache the client globally in ALL environments to prevent connection pool exhaustion // Cache the client globally in ALL environments to prevent connection pool exhaustion
globalForPrisma.prisma = prisma; globalForPrisma.prisma = prisma;

View File

@ -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"));
});
});