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
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest'
import {
entschluesseln,
entschluesselnWennMoeglich,
gleichSicher,
istUmschlag,
neuVerschluesseln,
oeffnen,
schluesselversionVon,
sealen,
VERSCHLUESSELTE_FELDER,
verschluesseln,
} from '@/lib/crypto/feld'
describe('Feldverschluesselung', () => {
it('verschluesselt und entschluesselt verlustfrei', () => {
const klartext = 'COPD GOLD III, Langzeitsauerstofftherapie seit 03/2025'
const umschlag = verschluesseln(klartext, 'diagnosenVerschluesselt')
expect(umschlag).not.toBeNull()
expect(umschlag).not.toContain('COPD')
expect(entschluesseln(umschlag, 'diagnosenVerschluesselt')).toBe(klartext)
})
it('haelt Umlaute und Sonderzeichen aus', () => {
const klartext = 'Straße 3½, Grüße – „Anführungszeichen“, 100 €'
const umschlag = verschluesseln(klartext, 'notizenVerschluesselt')
expect(entschluesseln(umschlag, 'notizenVerschluesselt')).toBe(klartext)
})
it('erzeugt bei gleichem Klartext unterschiedliche Geheimtexte', () => {
// Sonst liesse sich aus der Datenbank ablesen, welche Datensaetze
// denselben Wert tragen.
const a = verschluesseln('DE02120300000000202051', 'ibanVerschluesselt')
const b = verschluesseln('DE02120300000000202051', 'ibanVerschluesselt')
expect(a).not.toBe(b)
})
it('behandelt leere Werte als NULL', () => {
expect(verschluesseln(null)).toBeNull()
expect(verschluesseln(undefined)).toBeNull()
expect(verschluesseln('')).toBeNull()
expect(entschluesseln(null)).toBeNull()
})
it('erkennt manipulierte Geheimtexte', () => {
const umschlag = verschluesseln('Rueckkaufswert 12.480,00 EUR', 'notizenVerschluesselt')!
const teile = umschlag.split('.')
// Ein Bit im Geheimtext kippen.
const roh = Buffer.from(teile[4]!, 'base64url')
roh[0] = roh[0]! ^ 0x01
teile[4] = roh.toString('base64url')
expect(() => entschluesseln(teile.join('.'), 'notizenVerschluesselt')).toThrow()
})
it('verhindert das Umhaengen eines Geheimtexts in ein anderes Feld', () => {
// Der Feldname wird als AAD mitauthentifiziert.
const umschlag = verschluesseln('geheim', 'diagnosenVerschluesselt')
expect(() => entschluesseln(umschlag, 'notizenVerschluesselt')).toThrow()
})
it('weist fremde Formate ab', () => {
expect(() => entschluesseln('einfach nur text')).toThrow()
expect(() => entschluesseln('bc1.1.zu.wenig')).toThrow()
expect(entschluesselnWennMoeglich('einfach nur text')).toBeNull()
})
it('erkennt Umschlaege und ihre Schluesselversion', () => {
const umschlag = verschluesseln('x', 'notizenVerschluesselt')!
expect(istUmschlag(umschlag)).toBe(true)
expect(istUmschlag('nein')).toBe(false)
expect(schluesselversionVon(umschlag)).toBe(1)
})
it('rotiert Schluessel, ohne alte Daten unlesbar zu machen', () => {
const klartext = 'DE02120300000000202051'
const alt = verschluesseln(klartext, 'ibanVerschluesselt')!
expect(schluesselversionVon(alt)).toBe(1)
process.env.ENC_KEY_AKTIV = '2'
// env cached die aktive Version bereits - deshalb prueft dieser Test
// nur, dass ein Umschlag der Version 1 weiterhin lesbar bleibt.
process.env.ENC_KEY_AKTIV = '1'
expect(entschluesseln(alt, 'ibanVerschluesselt')).toBe(klartext)
// Gleiche Version -> kein Neuverschluesseln noetig.
expect(neuVerschluesseln(alt, 'ibanVerschluesselt')).toBeNull()
})
})
describe('sealen und oeffnen', () => {
it('setzt die Suffix-Felder aus Klartextfeldern', () => {
const daten = sealen('Betreute', {
diagnosen: 'COPD GOLD III',
notizen: 'Bevorzugt Besuche am Nachmittag',
})
expect(Object.keys(daten).sort()).toEqual([
'diagnosenVerschluesselt',
'notizenVerschluesselt',
])
expect(daten.diagnosenVerschluesselt).toMatch(/^bc1\./)
})
it('laesst undefinierte Felder weg, damit Updates sie nicht loeschen', () => {
const daten = sealen('Betreute', { diagnosen: 'COPD', notizen: undefined })
expect('notizenVerschluesselt' in daten).toBe(false)
})
it('setzt ein leeres Feld auf NULL', () => {
const daten = sealen('Betreute', { notizen: '' })
expect(daten.notizenVerschluesselt).toBeNull()
})
it('weist unbekannte Felder ab', () => {
expect(() => sealen('Betreute', { gibtEsNicht: 'x' })).toThrow(/nicht als verschluesseltes/)
})
it('oeffnet einen Datensatz und ergaenzt die Klartextfelder', () => {
const datenbankzeile = {
id: 'abc',
vorname: 'Erika',
...sealen('Betreute', { diagnosen: 'COPD GOLD III', notizen: null }),
}
const offen = oeffnen('Betreute', datenbankzeile)
expect(offen.vorname).toBe('Erika')
expect(offen.diagnosen).toBe('COPD GOLD III')
expect(offen.notizen).toBeNull()
// Der Umschlag bleibt stehen.
expect(offen.diagnosenVerschluesselt).toMatch(/^bc1\./)
})
})
describe('Feldkatalog', () => {
it('haelt fuer jedes Modell nur Felder mit dem richtigen Suffix', () => {
for (const [modell, felder] of Object.entries(VERSCHLUESSELTE_FELDER)) {
for (const feld of felder) {
expect(feld, `${modell}.${feld}`).toMatch(/Verschluesselt$/)
}
}
})
it('deckt die besonderen Datenkategorien ab', () => {
// Gesundheit, Finanzen, Freitext - das ist die Zusage aus der README.
expect(VERSCHLUESSELTE_FELDER.Betreute).toContain('diagnosenVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Konto).toContain('ibanVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Vertrag).toContain('notizenVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Gesundheitsereignis).toContain('beschreibungVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.Kontaktprotokoll).toContain('inhaltVerschluesselt')
expect(VERSCHLUESSELTE_FELDER.AuditLog).toContain('aenderungVerschluesselt')
})
})
describe('gleichSicher', () => {
it('vergleicht korrekt', () => {
expect(gleichSicher('abc', 'abc')).toBe(true)
expect(gleichSicher('abc', 'abd')).toBe(false)
expect(gleichSicher('abc', 'abcd')).toBe(false)
})
})
+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')
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest'
import {
totpAktuellerCode,
totpPruefen,
totpQrCode,
totpSecretEntschluesseln,
totpSecretErzeugen,
totpSecretVerschluesseln,
totpUri,
} from '@/lib/crypto/totp'
import {
blindindex,
codeNormalisieren,
hashVeraltet,
ipHashen,
passwortHashen,
passwortPruefen,
wiederherstellungscodeFinden,
wiederherstellungscodeHashen,
wiederherstellungscodesErzeugen,
} from '@/lib/crypto/hash'
describe('TOTP', () => {
it('erzeugt ein Base32-Geheimnis brauchbarer Laenge', () => {
const secret = totpSecretErzeugen()
expect(secret).toMatch(/^[A-Z2-7]+$/)
expect(secret.length).toBeGreaterThanOrEqual(32)
})
it('akzeptiert den aktuellen Code und lehnt einen falschen ab', () => {
const secret = totpSecretErzeugen()
const code = totpAktuellerCode(secret, 'fw@wildeflorian.de')
expect(totpPruefen(secret, 'fw@wildeflorian.de', code).gueltig).toBe(true)
expect(totpPruefen(secret, 'fw@wildeflorian.de', '000000').gueltig).toBe(false)
})
it('toleriert Leerzeichen in der Eingabe, aber keine falsche Laenge', () => {
const secret = totpSecretErzeugen()
const code = totpAktuellerCode(secret, 'test')
const mitLuecke = `${code.slice(0, 3)} ${code.slice(3)}`
expect(totpPruefen(secret, 'test', mitLuecke).gueltig).toBe(true)
expect(totpPruefen(secret, 'test', '12345').gueltig).toBe(false)
expect(totpPruefen(secret, 'test', '1234567').gueltig).toBe(false)
})
it('bindet den Code an das Geheimnis', () => {
const a = totpSecretErzeugen()
const b = totpSecretErzeugen()
const code = totpAktuellerCode(a, 'test')
expect(totpPruefen(b, 'test', code).gueltig).toBe(false)
})
it('baut eine otpauth-URI mit Aussteller und Kennung', () => {
const secret = totpSecretErzeugen()
const uri = totpUri(secret, 'fw@wildeflorian.de')
expect(uri).toContain('otpauth://totp/')
expect(uri).toContain('Betreuungs-Cockpit')
expect(uri).toContain('fw%40wildeflorian.de')
expect(uri).toContain('digits=6')
expect(uri).toContain('period=30')
})
it('liefert den QR-Code als eingebettetes Bild, nicht als Fremd-URL', async () => {
const secret = totpSecretErzeugen()
const bild = await totpQrCode(secret, 'fw@wildeflorian.de')
expect(bild.startsWith('data:image/png;base64,')).toBe(true)
expect(bild).not.toContain('http')
})
it('speichert das Geheimnis nur verschluesselt', () => {
const secret = totpSecretErzeugen()
const umschlag = totpSecretVerschluesseln(secret)
expect(umschlag).not.toContain(secret)
expect(totpSecretEntschluesseln(umschlag)).toBe(secret)
})
})
describe('Passwort-Hashing', () => {
it('erzeugt einen Argon2id-Hash', async () => {
const hash = await passwortHashen('ein-langes-passwort-2026')
expect(hash.startsWith('$argon2id$')).toBe(true)
expect(hash).not.toContain('ein-langes-passwort-2026')
})
it('prueft richtig und falsch', async () => {
const hash = await passwortHashen('ein-langes-passwort-2026')
expect(await passwortPruefen(hash, 'ein-langes-passwort-2026')).toBe(true)
expect(await passwortPruefen(hash, 'ein-langes-passwort-2025')).toBe(false)
})
it('salzt - gleiches Passwort ergibt verschiedene Hashes', async () => {
const a = await passwortHashen('gleiches-passwort')
const b = await passwortHashen('gleiches-passwort')
expect(a).not.toBe(b)
})
it('faellt bei kaputtem Hash auf falsch zurueck, statt zu werfen', async () => {
expect(await passwortPruefen('kein-hash', 'egal')).toBe(false)
expect(await passwortPruefen('', 'egal')).toBe(false)
})
it('erkennt zu schwach gerechnete Hashes', async () => {
const aktuell = await passwortHashen('x')
expect(hashVeraltet(aktuell)).toBe(false)
expect(hashVeraltet('$argon2id$v=19$m=4096,t=1,p=1$abc$def')).toBe(true)
expect(hashVeraltet('unsinn')).toBe(true)
})
})
describe('Blindindex', () => {
it('ist stabil und normalisiert Schreibweisen', () => {
const a = blindindex('DE02 1203 0000 0000 2020 51')
const b = blindindex('de02120300000000202051')
expect(a).toBe(b)
expect(a).not.toBeNull()
})
it('gibt den Wert nicht preis', () => {
const index = blindindex('DE02120300000000202051')!
expect(index).not.toContain('DE02')
expect(index.length).toBeGreaterThan(20)
})
it('unterscheidet verschiedene Werte', () => {
expect(blindindex('DE02120300000000202051')).not.toBe(blindindex('DE02120300000000202052'))
})
it('behandelt Leerwerte als null', () => {
expect(blindindex(null)).toBeNull()
expect(blindindex('')).toBeNull()
expect(blindindex(' ')).toBeNull()
})
it('pseudonymisiert IP-Adressen kurz und stabil', () => {
const a = ipHashen('192.0.2.44')
expect(a).toBe(ipHashen('192.0.2.44'))
expect(a).not.toBe(ipHashen('192.0.2.45'))
expect(a!.length).toBe(22)
})
})
describe('Wiederherstellungscodes', () => {
it('erzeugt lesbare Codes ohne verwechselbare Zeichen', () => {
const codes = wiederherstellungscodesErzeugen(8)
expect(codes).toHaveLength(8)
for (const code of codes) {
expect(code).toMatch(/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/)
expect(code).not.toMatch(/[01OI]/) // 0/O und 1/I sind verwechselbar, L in Grossschrift nicht
}
expect(new Set(codes).size).toBe(8)
})
it('normalisiert die Eingabe', () => {
expect(codeNormalisieren('abcd-efgh-jkmn')).toBe('ABCDEFGHJKMN')
expect(codeNormalisieren('ABCD EFGH JKMN')).toBe('ABCDEFGHJKMN')
})
it('findet einen gueltigen Code und weist einen falschen ab', async () => {
const codes = wiederherstellungscodesErzeugen(3)
const hashes = await Promise.all(codes.map(wiederherstellungscodeHashen))
expect(await wiederherstellungscodeFinden(hashes, codes[1]!)).toBe(1)
// Auch klein geschrieben und ohne Bindestriche.
expect(await wiederherstellungscodeFinden(hashes, codes[2]!.replace(/-/g, '').toLowerCase())).toBe(2)
expect(await wiederherstellungscodeFinden(hashes, 'ZZZZ-ZZZZ-ZZZZ')).toBe(-1)
expect(await wiederherstellungscodeFinden([], codes[0]!)).toBe(-1)
})
})