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:
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user