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)
})
})