Files
betreuungs-cockpit/tests/feldverschluesselung.test.ts
T
Florian Wilde c49810d092 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
2026-08-22 01:42:13 +02:00

159 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})