Drei Fehler, die sich beim Durchsehen der Typpruefung und der
Browserkonsole gezeigt haben.
1. oeffnen() lieferte Record<string, string | null>. Zusammen mit
noUncheckedIndexedAccess wurde daraus bei jedem Zugriff
string | null | undefined - und jede aufrufende Seite haette das
wegkaesen muessen, obwohl die Felder immer da sind. Jetzt leitet ein
Mapped Type die Klartextnamen aus VERSCHLUESSELTE_FELDER ab:
oeffnen('Betreute', zeile).diagnosen ist sauber string | null.
Das hat auf einen Schlag 17 Folgefehler in fremden Modulen erledigt.
2. Die CSP blockierte im Entwicklungsmodus die eigenen Skripte.
'strict-dynamic' schaltet die Freigabe nach Herkunft ab, also auch
'self' - und Hot-Reload und Fehleranzeige laden ohne Nonce. Man sah
eine tote Seite statt der Fehlermeldung. Im Produktivbetrieb bleibt
es bei Nonce plus 'strict-dynamic', im Entwicklungsmodus gilt die
lockere Fassung.
3. Das Theme-Skript im Layout meldete bei jedem Seitenaufruf eine
Hydrations-Abweichung: React gibt das nonce-Attribut absichtlich
nicht an den Browser weiter, serverseitig steht es aber im HTML.
suppressHydrationWarning am Skript selbst - das html-Element allein
deckt das nicht ab.
Nebenbei: Algorithm.Argon2id ist ein const enum und laesst sich mit
isolatedModules nicht importieren. Die Angabe entfaellt, @node-rs/argon2
benutzt von sich aus Argon2id.
Die Anmeldeseite laedt jetzt ohne einen einzigen Konsolenfehler.
Etappe: 8
Phase: Haertung
253 lines
9.2 KiB
TypeScript
253 lines
9.2 KiB
TypeScript
import 'server-only'
|
|
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
import { env } from '@/lib/env'
|
|
|
|
/**
|
|
* Anwendungsseitige Feldverschluesselung mit AES-256-GCM.
|
|
*
|
|
* Warum zusaetzlich zur Festplattenverschluesselung? Weil ein Datenbank-Dump,
|
|
* ein Backup auf fremdem Speicher oder ein kompromittierter Datenbankzugang
|
|
* sonst Diagnosen, IBANs und Notizen im Klartext preisgibt. Der Schluessel
|
|
* liegt in der Umgebung, nicht in der Datenbank - beides zusammen wird
|
|
* gebraucht, um die Daten zu lesen.
|
|
*
|
|
* Umschlagformat (ASCII, punktgetrennt, base64url ohne Padding):
|
|
*
|
|
* bc1.<schluesselversion>.<iv>.<authtag>.<geheimtext>
|
|
*
|
|
* "bc1" = Betreuungs-Cockpit, Formatversion 1. Die Schluesselversion erlaubt
|
|
* Rotation: neue Werte werden mit ENC_KEY_AKTIV verschluesselt, alte bleiben
|
|
* mit ihrem Schluessel lesbar.
|
|
*
|
|
* Zusatzdaten (AAD): Der Feldname wird mitauthentifiziert. Dadurch laesst
|
|
* sich ein Geheimtext nicht von einem Feld in ein anderes kopieren -
|
|
* "diagnosen" bleibt "diagnosen".
|
|
*/
|
|
|
|
const PRAEFIX = 'bc1'
|
|
const IV_LAENGE = 12 // 96 Bit, der von NIST empfohlene Wert fuer GCM
|
|
const TAG_LAENGE = 16
|
|
|
|
function b64url(buf: Buffer): string {
|
|
return buf.toString('base64url')
|
|
}
|
|
|
|
function ausB64url(text: string): Buffer {
|
|
return Buffer.from(text, 'base64url')
|
|
}
|
|
|
|
/**
|
|
* Verschluesselt einen Klartext. Gibt null zurueck, wenn nichts zu
|
|
* verschluesseln ist - so bleiben leere Felder in der Datenbank NULL und
|
|
* verraten nicht einmal, dass etwas eingetragen war.
|
|
*/
|
|
export function verschluesseln(klartext: string | null | undefined, kontext = ''): string | null {
|
|
if (klartext === null || klartext === undefined || klartext === '') return null
|
|
|
|
const version = env.aktiveSchluesselversion
|
|
const schluessel = env.verschluesselungsschluessel.get(version)
|
|
if (!schluessel) {
|
|
throw new Error(`Verschluesselungsschluessel Version ${version} nicht verfuegbar.`)
|
|
}
|
|
|
|
const iv = randomBytes(IV_LAENGE)
|
|
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
|
|
cipher.setAAD(Buffer.from(`${PRAEFIX}:${version}:${kontext}`, 'utf8'))
|
|
|
|
const geheimtext = Buffer.concat([cipher.update(klartext, 'utf8'), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
|
|
return [PRAEFIX, version, b64url(iv), b64url(tag), b64url(geheimtext)].join('.')
|
|
}
|
|
|
|
/**
|
|
* Entschluesselt einen Umschlag. Wirft, wenn der Geheimtext manipuliert
|
|
* wurde oder der Schluessel nicht passt - stilles Zurueckfallen auf den
|
|
* Rohwert waere hier gefaehrlich.
|
|
*/
|
|
export function entschluesseln(umschlag: string | null | undefined, kontext = ''): string | null {
|
|
if (umschlag === null || umschlag === undefined || umschlag === '') return null
|
|
|
|
const teile = umschlag.split('.')
|
|
if (teile.length !== 5 || teile[0] !== PRAEFIX) {
|
|
throw new Error(
|
|
'Ungueltiger Umschlag: erwartet wurde das Format bc1.<version>.<iv>.<tag>.<ct>.',
|
|
)
|
|
}
|
|
|
|
const version = Number.parseInt(teile[1]!, 10)
|
|
const schluessel = env.verschluesselungsschluessel.get(version)
|
|
if (!schluessel) {
|
|
throw new Error(
|
|
`Zum Entschluesseln fehlt ENC_KEY_V${version}. Der Schluessel darf beim ` +
|
|
`Rotieren nicht aus der Umgebung entfernt werden, solange noch Daten ` +
|
|
`mit ihm verschluesselt sind.`,
|
|
)
|
|
}
|
|
|
|
const iv = ausB64url(teile[2]!)
|
|
const tag = ausB64url(teile[3]!)
|
|
const geheimtext = ausB64url(teile[4]!)
|
|
|
|
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
|
|
decipher.setAAD(Buffer.from(`${PRAEFIX}:${version}:${kontext}`, 'utf8'))
|
|
decipher.setAuthTag(tag)
|
|
|
|
return Buffer.concat([decipher.update(geheimtext), decipher.final()]).toString('utf8')
|
|
}
|
|
|
|
/** Entschluesselt, gibt aber bei Fehlern null zurueck statt zu werfen. */
|
|
export function entschluesselnWennMoeglich(
|
|
umschlag: string | null | undefined,
|
|
kontext = '',
|
|
): string | null {
|
|
try {
|
|
return entschluesseln(umschlag, kontext)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Erkennt, ob ein Wert bereits ein Umschlag ist (fuer Migrationen). */
|
|
export function istUmschlag(wert: unknown): wert is string {
|
|
return typeof wert === 'string' && wert.startsWith(`${PRAEFIX}.`) && wert.split('.').length === 5
|
|
}
|
|
|
|
/** Liest die Schluesselversion aus einem Umschlag - fuer die Rotation. */
|
|
export function schluesselversionVon(umschlag: string): number | null {
|
|
if (!istUmschlag(umschlag)) return null
|
|
return Number.parseInt(umschlag.split('.')[1]!, 10)
|
|
}
|
|
|
|
/**
|
|
* Verschluesselt einen bestehenden Umschlag mit dem aktuell aktiven
|
|
* Schluessel neu. Gibt null zurueck, wenn er bereits aktuell ist.
|
|
* Wird vom Rotationsskript benutzt.
|
|
*/
|
|
export function neuVerschluesseln(umschlag: string, kontext = ''): string | null {
|
|
if (schluesselversionVon(umschlag) === env.aktiveSchluesselversion) return null
|
|
const klartext = entschluesseln(umschlag, kontext)
|
|
return verschluesseln(klartext, kontext)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Bequeme Helfer fuer ganze Datensaetze
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Liste der verschluesselten Felder je Modell. Einziger Ort, an dem steht,
|
|
* was verschluesselt gehoert - Grundlage fuer sealen/oeffnen und fuer den
|
|
* Test, der prueft, dass nichts vergessen wurde.
|
|
*/
|
|
export const VERSCHLUESSELTE_FELDER = {
|
|
Betreute: [
|
|
'krankenversichertennummerVerschluesselt',
|
|
'diagnosenVerschluesselt',
|
|
'ermittelterWilleVerschluesselt',
|
|
'rentenversicherungsnummerVerschluesselt',
|
|
'steuerIdVerschluesselt',
|
|
'notizenVerschluesselt',
|
|
],
|
|
User: ['totpSecretVerschluesselt'],
|
|
Vorgang: ['beschreibungVerschluesselt'],
|
|
Genehmigung: ['notizenVerschluesselt'],
|
|
Dokument: ['notizenVerschluesselt'],
|
|
Konto: ['ibanVerschluesselt'],
|
|
Buchung: ['verwendungszweckVerschluesselt'],
|
|
Vermoegensposten: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'],
|
|
Immobilie: ['statusBegruendungVerschluesselt', 'notizenVerschluesselt'],
|
|
Vertrag: [
|
|
'vertragsnummerVerschluesselt',
|
|
'bewertungBegruendungVerschluesselt',
|
|
'notizenVerschluesselt',
|
|
],
|
|
Kontakt: ['notizenVerschluesselt'],
|
|
Kontaktprotokoll: [
|
|
'inhaltVerschluesselt',
|
|
'wuenscheVerschluesselt',
|
|
'folgeaufgabenVerschluesselt',
|
|
],
|
|
Gesundheitsereignis: ['beschreibungVerschluesselt'],
|
|
Heimvertrag: ['notizenVerschluesselt'],
|
|
SozialhilfeAntrag: ['notizenVerschluesselt'],
|
|
Bescheid: ['notizenVerschluesselt'],
|
|
Bericht: ['entwurfVerschluesselt'],
|
|
AuditLog: ['aenderungVerschluesselt'],
|
|
} as const
|
|
|
|
export type VerschluesseltesModell = keyof typeof VERSCHLUESSELTE_FELDER
|
|
|
|
/** Aus "diagnosenVerschluesselt" wird "diagnosen". */
|
|
type OhneSuffix<F extends string> = F extends `${infer Name}Verschluesselt` ? Name : never
|
|
|
|
/**
|
|
* Die Klartextfelder eines Modells als benannte Eigenschaften.
|
|
*
|
|
* Ohne diesen Typ waere das Ergebnis von oeffnen() nur ein
|
|
* Record<string, string | null>. Mit noUncheckedIndexedAccess bekaeme
|
|
* jeder Zugriff darauf zusaetzlich "undefined" - und jede aufrufende
|
|
* Seite muesste das wegkaesen, obwohl die Felder tatsaechlich immer da
|
|
* sind.
|
|
*/
|
|
export type Klartextfelder<M extends VerschluesseltesModell> = {
|
|
[K in OhneSuffix<(typeof VERSCHLUESSELTE_FELDER)[M][number]>]: string | null
|
|
}
|
|
|
|
/**
|
|
* Nimmt ein Objekt mit Klartextfeldern (ohne Suffix) und liefert die
|
|
* verschluesselten Felder (mit Suffix). Beispiel:
|
|
*
|
|
* sealen('Betreute', { diagnosen: 'COPD GOLD III' })
|
|
* -> { diagnosenVerschluesselt: 'bc1.1....' }
|
|
*
|
|
* Felder, die im Eingabeobjekt fehlen, tauchen im Ergebnis nicht auf -
|
|
* ein Prisma-Update laesst sie damit unangetastet.
|
|
*/
|
|
export function sealen(
|
|
modell: VerschluesseltesModell,
|
|
klartextfelder: Record<string, string | null | undefined>,
|
|
): Record<string, string | null> {
|
|
const ergebnis: Record<string, string | null> = {}
|
|
for (const [name, wert] of Object.entries(klartextfelder)) {
|
|
if (wert === undefined) continue
|
|
const zielfeld = `${name}Verschluesselt`
|
|
if (!(VERSCHLUESSELTE_FELDER[modell] as readonly string[]).includes(zielfeld)) {
|
|
throw new Error(`${modell}.${zielfeld} ist nicht als verschluesseltes Feld eingetragen.`)
|
|
}
|
|
ergebnis[zielfeld] = verschluesseln(wert, zielfeld)
|
|
}
|
|
return ergebnis
|
|
}
|
|
|
|
/**
|
|
* Gegenstueck zu sealen: nimmt einen Datensatz aus der Datenbank und
|
|
* ergaenzt die entschluesselten Klartextfelder ohne Suffix. Die
|
|
* Umschlagfelder bleiben stehen, damit nichts versehentlich zurueck-
|
|
* geschrieben wird.
|
|
*/
|
|
export function oeffnen<M extends VerschluesseltesModell, T extends Record<string, unknown>>(
|
|
modell: M,
|
|
datensatz: T,
|
|
): T & Klartextfelder<M> {
|
|
// Zwischenschritt ueber ein einfaches Objekt: ein generischer Typ laesst
|
|
// sich in TypeScript nicht per Index beschreiben.
|
|
const ergebnis: Record<string, unknown> = { ...datensatz }
|
|
|
|
for (const feld of VERSCHLUESSELTE_FELDER[modell] as readonly string[]) {
|
|
const klarname = feld.replace(/Verschluesselt$/, '')
|
|
ergebnis[klarname] = (feld in datensatz)
|
|
? entschluesselnWennMoeglich(datensatz[feld] as string | null, feld)
|
|
: null
|
|
}
|
|
|
|
return ergebnis as T & Klartextfelder<M>
|
|
}
|
|
|
|
/** Konstantzeit-Vergleich zweier Zeichenketten. */
|
|
export function gleichSicher(a: string, b: string): boolean {
|
|
const bufA = Buffer.from(a, 'utf8')
|
|
const bufB = Buffer.from(b, 'utf8')
|
|
if (bufA.length !== bufB.length) return false
|
|
return timingSafeEqual(bufA, bufB)
|
|
}
|