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
159 lines
5.9 KiB
TypeScript
159 lines
5.9 KiB
TypeScript
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)
|
||
})
|
||
})
|