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>
69 lines
2.9 KiB
TypeScript
69 lines
2.9 KiB
TypeScript
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"));
|
|
});
|
|
});
|