feat: Feldverschluesselung, Argon2id und TOTP-Zweitfaktor

Die Sicherheitsschicht, auf der alles Weitere aufsetzt.

Feldverschluesselung (AES-256-GCM):
- Umschlagformat bc1.<version>.<iv>.<tag>.<ct>. Die Schluesselversion
  erlaubt Rotation, ohne alte Daten unlesbar zu machen.
- Der Feldname wird als AAD mitauthentifiziert. Ein Geheimtext laesst
  sich damit nicht von diagnosen nach notizen umhaengen.
- Leere Werte werden zu NULL statt zu einem Umschlag - sonst verriete
  die Datenbank, dass ueberhaupt etwas eingetragen war.
- VERSCHLUESSELTE_FELDER ist der einzige Ort, an dem steht, was
  verschluesselt gehoert. sealen()/oeffnen() arbeiten darauf.

Argon2id nach OWASP-Empfehlung (19 MiB, t=2, p=1), mit hashVeraltet()
fuer spaetere Parametererhoehung ohne Migration.

Blindindex per HMAC-SHA256 mit eigenem Schluessel: erlaubt die Frage
"kenne ich diese IBAN schon?", ohne sie im Klartext zu halten.
Derselbe Mechanismus pseudonymisiert IP-Adressen fuers Audit-Log.

TOTP mit otpauth, QR-Code serverseitig als Data-URI - kein externer
QR-Dienst, kein CDN. Wiederherstellungscodes aus einem Alphabet ohne
0/O und 1/I, einzeln Argon2id-gehasht, Pruefung ohne fruehen Abbruch.

37 Tests decken Rundlauf, Manipulation, Feldbindung, Rotation,
Salzung und Normalisierung ab.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:42:13 +02:00
parent 6c1915ab9c
commit c49810d092
11 changed files with 10949 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
// Platzhalter fuer das Paket "server-only", das unter Vitest nicht laufen
// kann (es wirft absichtlich, wenn es ausserhalb einer Server-Umgebung
// importiert wird). Siehe Alias in vitest.config.ts.
export {}
+12
View File
@@ -0,0 +1,12 @@
/**
* Testschluessel. Bewusst fest verdrahtet und offensichtlich unecht -
* so kann niemand auf die Idee kommen, sie in einer .env zu verwenden.
*/
import { randomBytes } from 'node:crypto'
process.env.ENC_KEY_V1 = Buffer.alloc(32, 1).toString('base64')
process.env.ENC_KEY_V2 = Buffer.alloc(32, 2).toString('base64')
process.env.ENC_KEY_AKTIV = '1'
process.env.INDEX_KEY = Buffer.alloc(32, 3).toString('base64')
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
process.env.AUTH_SECRET = randomBytes(32).toString('base64')