Files
betreuungs-cockpit/tests/zweiter-faktor.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

171 lines
6.0 KiB
TypeScript

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