feat: Kalender-Zugangsdaten verschluesselt speichern (AES-256-GCM)
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:
parent
8f017cb879
commit
158051bb47
25
CHANGELOG.md
25
CHANGELOG.md
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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 || "",
|
||||||
|
|||||||
91
src/lib/credential-crypto.ts
Normal file
91
src/lib/credential-crypto.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
68
tests/unit/credential-crypto.test.ts
Normal file
68
tests/unit/credential-crypto.test.ts
Normal 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user