feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
+141
View File
@@ -0,0 +1,141 @@
import 'server-only'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve, sep } from 'node:path'
import { env } from '@/lib/env'
import {
dateiRoutePfad,
schluesselErfordern,
zugriffTokenErzeugen,
TOKEN_STANDARD_SEKUNDEN,
} from '@/lib/speicher/schluessel'
/**
* Treiber "datei": Ablage in einem Verzeichnis auf dem Server.
*
* Standard in der Entwicklung und fuer kleine Installationen, bei denen
* kein Objektspeicher gewuenscht ist - die Dateien liegen dann auf der
* verschluesselten Platte des eigenen Servers und verlassen sie nie.
*
* Zusaetzlich wird jede Datei anwendungsseitig mit AES-256-GCM
* verschluesselt. Grund ist derselbe wie bei der Feldverschluesselung: ein
* kopiertes Backup oder ein versehentlich falsch gerechtetes Verzeichnis
* gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis.
* Der Schluessel liegt in der Umgebung, nicht neben den Daten.
*
* Umschlag (binaer):
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext
*/
const MAGIE = Buffer.from('BCD1', 'ascii')
const IV_LAENGE = 12
const TAG_LAENGE = 16
const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
function wurzel(): string {
return resolve(process.cwd(), env.dateiSpeicherPfad)
}
/**
* Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich
* unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits
* streng geprueft, aber diese zweite Sperre kostet nichts und faengt auch
* Symlink-Tricks und Windows-Eigenheiten ab.
*/
function pfadFuer(schluessel: string): string {
const geprueft = schluesselErfordern(schluessel)
const basis = wurzel()
const ziel = resolve(basis, ...geprueft.split('/'))
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
}
return ziel
}
function verschluesselnBinaer(inhalt: Buffer): Buffer {
const version = env.aktiveSchluesselversion
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`)
const iv = randomBytes(IV_LAENGE)
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
cipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()])
const versionBytes = Buffer.alloc(4)
versionBytes.writeUInt32BE(version, 0)
return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim])
}
function entschluesselnBinaer(roh: Buffer): Buffer {
// Dateien ohne Umschlag (von Hand hineingelegt, aus einer aelteren
// Installation uebernommen) werden unveraendert durchgereicht.
if (roh.length < KOPF_LAENGE || !roh.subarray(0, MAGIE.length).equals(MAGIE)) return roh
const version = roh.readUInt32BE(MAGIE.length)
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) {
throw new Error(
`Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` +
`Rotieren nicht aus der Umgebung entfernt werden.`,
)
}
const ivVon = MAGIE.length + 4
const iv = roh.subarray(ivVon, ivVon + IV_LAENGE)
const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE)
const geheim = roh.subarray(KOPF_LAENGE)
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
decipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(geheim), decipher.final()])
}
export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = pfadFuer(schluessel)
await mkdir(dirname(pfad), { recursive: true })
await writeFile(pfad, verschluesselnBinaer(inhalt), { mode: 0o600 })
}
export async function lesen(schluessel: string): Promise<Buffer> {
const roh = await readFile(pfadFuer(schluessel))
return entschluesselnBinaer(roh)
}
export async function entfernen(schluessel: string): Promise<void> {
await rm(pfadFuer(schluessel), { force: true })
}
export async function existiert(schluessel: string): Promise<boolean> {
try {
const angaben = await stat(pfadFuer(schluessel))
return angaben.isFile()
} catch {
return false
}
}
/**
* Zeigt auf die eigene, ebenfalls signierte Route. Diese Route prueft
* zusaetzlich die Berechtigung des angemeldeten Benutzers und schreibt
* einen Audit-Eintrag - ein Link allein oeffnet also nichts.
*/
export async function signierteUrl(
schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
): Promise<string> {
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
}
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
export function ablageverzeichnis(): string {
return wurzel()
}
/** Sorgt dafuer, dass das Verzeichnis existiert (Start, Tests, Skripte). */
export async function verzeichnisAnlegen(): Promise<void> {
await mkdir(join(wurzel(), 'betreute'), { recursive: true })
}