Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39
@@ -18,6 +18,7 @@
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:migration": "node scripts/migration-erzeugen.mjs",
|
||||
"speicher:verschluesseln": "tsx --env-file=.env scripts/speicher-verschluesseln.mts",
|
||||
"db:pruefen": "node -e \"import('./scripts/migration-erzeugen.mjs').then(m => m.indizesPruefen())\"",
|
||||
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
|
||||
"db:seed": "prisma db seed",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Bestehende Dateien nachtraeglich in den Umschlag stecken.
|
||||
*
|
||||
* npm run speicher:verschluesseln # nur schauen
|
||||
* npm run speicher:verschluesseln -- --anwenden
|
||||
*
|
||||
* Warum es diesen Lauf gibt: Der Objektspeicher-Treiber hat bis v1.10.2
|
||||
* unverschluesselt abgelegt - es stand nur ServerSideEncryption: 'AES256'
|
||||
* darauf, also Verschluesselung mit dem Schluessel des Anbieters. Seit
|
||||
* v1.11.0 liegt derselbe Umschlag darum wie beim Dateitreiber. Neue Dateien
|
||||
* bekommen ihn beim Ablegen; die alten liegen weiter im Klartext, bis sie
|
||||
* hier einmal umgeschrieben werden.
|
||||
*
|
||||
* Lesen funktioniert waehrenddessen durchgehend: Der Treiber erkennt am
|
||||
* Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
|
||||
* Der Lauf laesst sich also jederzeit starten, abbrechen und wiederholen.
|
||||
*
|
||||
* Er ist absichtlich langsam und einfach: ein Objekt nach dem anderen,
|
||||
* lesen, pruefen, zurueckschreiben. Bei ein paar hundert Dokumenten dauert
|
||||
* das Sekunden, und ein Fehler betrifft immer nur eine Datei.
|
||||
*
|
||||
* OHNE --anwenden wird nichts geschrieben.
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
/**
|
||||
* "server-only" fuer diesen Lauf neutralisieren.
|
||||
*
|
||||
* Das Paket wirft beim Import ausserhalb einer Server-Umgebung - genau
|
||||
* dafuer ist es da. Hier laufen wir aber bewusst ausserhalb von Next, und
|
||||
* die Alternative waere, den Umschlag im Skript noch einmal zu schreiben.
|
||||
* Zwei Fassungen derselben Krypto sind die schlechtere Wahl: Wenn eine
|
||||
* davon abweicht, merkt es niemand, bis eine Datei nicht mehr aufgeht.
|
||||
*
|
||||
* Deshalb wird der Modullader vorher umgebogen und danach dynamisch
|
||||
* importiert - statische Importe wuerden vorgezogen und liefen in den
|
||||
* Fehler.
|
||||
*/
|
||||
const verlangen = createRequire(import.meta.url)
|
||||
const modulLader = verlangen('node:module') as {
|
||||
_load: (anfrage: string, ...rest: unknown[]) => unknown
|
||||
}
|
||||
const echterLader = modulLader._load
|
||||
modulLader._load = function (anfrage: string, ...rest: unknown[]) {
|
||||
if (anfrage === 'server-only') return {}
|
||||
return echterLader.call(this, anfrage, ...rest)
|
||||
}
|
||||
|
||||
const { env } = await import('../src/lib/env')
|
||||
const { istVerschluesselt, verschluesselnBinaer } = await import('../src/lib/speicher/umschlag')
|
||||
|
||||
const anwenden = process.argv.includes('--anwenden')
|
||||
|
||||
type Bilanz = { geprueft: number; umgeschrieben: number; schonGut: number; fehler: number }
|
||||
|
||||
function zeile(text = ''): void {
|
||||
console.log(text)
|
||||
}
|
||||
|
||||
/** Objektspeicher: auflisten, lesen, bei Bedarf zurueckschreiben. */
|
||||
async function s3Durchgehen(): Promise<Bilanz> {
|
||||
const s3 = await import('../src/lib/speicher/s3')
|
||||
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
|
||||
|
||||
const schluessel = await s3.objekteAuflisten()
|
||||
zeile(` ${schluessel.length} Objekte im Eimer "${env.s3.bucket}".`)
|
||||
zeile()
|
||||
|
||||
for (const eintrag of schluessel) {
|
||||
bilanz.geprueft += 1
|
||||
try {
|
||||
const roh = await s3.rohLesen(eintrag)
|
||||
|
||||
if (istVerschluesselt(roh)) {
|
||||
bilanz.schonGut += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (anwenden) {
|
||||
// schreiben() legt den Umschlag selbst an - der Inhalt geht also
|
||||
// als Klartext hinein und kommt verschluesselt im Eimer an.
|
||||
await s3.schreiben(eintrag, roh, 'application/octet-stream')
|
||||
}
|
||||
bilanz.umgeschrieben += 1
|
||||
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${eintrag}`)
|
||||
} catch (fehler) {
|
||||
bilanz.fehler += 1
|
||||
zeile(` FEHLER bei ${eintrag}: ${fehler instanceof Error ? fehler.message : fehler}`)
|
||||
}
|
||||
}
|
||||
|
||||
return bilanz
|
||||
}
|
||||
|
||||
/** Dateitreiber: dasselbe fuer das Ablageverzeichnis. */
|
||||
async function dateienDurchgehen(): Promise<Bilanz> {
|
||||
const { readdir, stat } = await import('node:fs/promises')
|
||||
const { join, relative, sep } = await import('node:path')
|
||||
const treiber = await import('../src/lib/speicher/datei')
|
||||
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
|
||||
|
||||
const wurzel = treiber.ablageverzeichnis()
|
||||
zeile(` Verzeichnis ${wurzel}`)
|
||||
zeile()
|
||||
|
||||
async function absteigen(verzeichnis: string): Promise<void> {
|
||||
let eintraege: string[]
|
||||
try {
|
||||
eintraege = await readdir(verzeichnis)
|
||||
} catch {
|
||||
return // gibt es noch nicht
|
||||
}
|
||||
|
||||
for (const name of eintraege) {
|
||||
const pfad = join(verzeichnis, name)
|
||||
if ((await stat(pfad)).isDirectory()) {
|
||||
await absteigen(pfad)
|
||||
continue
|
||||
}
|
||||
|
||||
bilanz.geprueft += 1
|
||||
try {
|
||||
const roh = await readFile(pfad)
|
||||
if (istVerschluesselt(roh)) {
|
||||
bilanz.schonGut += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Der Ablageschluessel ist der Pfad unterhalb der Wurzel, mit
|
||||
// Schraegstrichen - genau das, was der Treiber beim Schreiben
|
||||
// benutzt. Er gehoert in die Zusatzdaten, sonst waere die Datei
|
||||
// hinterher zwar verschluesselt, aber nicht an ihren Platz
|
||||
// gebunden, und liesse sich unter fremdem Namen wieder einspielen.
|
||||
const ablageschluessel = relative(wurzel, pfad).split(sep).join('/')
|
||||
|
||||
if (anwenden) {
|
||||
await writeFile(pfad, verschluesselnBinaer(roh, ablageschluessel), { mode: 0o600 })
|
||||
}
|
||||
bilanz.umgeschrieben += 1
|
||||
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${pfad}`)
|
||||
} catch (fehler) {
|
||||
bilanz.fehler += 1
|
||||
zeile(` FEHLER bei ${pfad}: ${fehler instanceof Error ? fehler.message : fehler}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await absteigen(wurzel)
|
||||
return bilanz
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
zeile()
|
||||
zeile(' Bestehende Dateien in den Umschlag stecken')
|
||||
zeile(' ' + '-'.repeat(50))
|
||||
zeile()
|
||||
|
||||
if (!anwenden) {
|
||||
zeile(' TROCKENLAUF - es wird nichts geschrieben.')
|
||||
zeile(' Zum Anwenden: npm run speicher:verschluesseln -- --anwenden')
|
||||
zeile()
|
||||
}
|
||||
|
||||
const treiber = env.speicherTreiber
|
||||
zeile(` Treiber: ${treiber}`)
|
||||
zeile()
|
||||
|
||||
const bilanz = treiber === 's3' ? await s3Durchgehen() : await dateienDurchgehen()
|
||||
|
||||
zeile()
|
||||
zeile(` Geprueft: ${bilanz.geprueft}`)
|
||||
zeile(` Schon im Umschlag: ${bilanz.schonGut}`)
|
||||
zeile(` ${anwenden ? 'Umgeschrieben' : 'Umzuschreiben'}: ${bilanz.umgeschrieben}`)
|
||||
if (bilanz.fehler > 0) zeile(` Fehler: ${bilanz.fehler}`)
|
||||
zeile()
|
||||
|
||||
if (bilanz.fehler > 0) process.exitCode = 1
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error(fehler)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
|
||||
import {
|
||||
dateiRoutePfad,
|
||||
schluesselErfordern,
|
||||
@@ -23,15 +23,10 @@ import {
|
||||
* 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
|
||||
* Der Umschlag selbst steht in src/lib/speicher/umschlag.ts - der
|
||||
* Objektspeicher benutzt denselben.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -52,57 +47,15 @@ function pfadFuer(schluessel: string): string {
|
||||
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 })
|
||||
await writeFile(pfad, verschluesselnBinaer(inhalt, schluessel), { mode: 0o600 })
|
||||
}
|
||||
|
||||
export async function lesen(schluessel: string): Promise<Buffer> {
|
||||
const roh = await readFile(pfadFuer(schluessel))
|
||||
return entschluesselnBinaer(roh)
|
||||
return entschluesselnBinaer(roh, schluessel)
|
||||
}
|
||||
|
||||
export async function entfernen(schluessel: string): Promise<void> {
|
||||
|
||||
+92
-20
@@ -3,12 +3,18 @@ import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { env } from '@/lib/env'
|
||||
import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/schluessel'
|
||||
import {
|
||||
dateiRoutePfad,
|
||||
schluesselErfordern,
|
||||
zugriffTokenErzeugen,
|
||||
TOKEN_STANDARD_SEKUNDEN,
|
||||
} from '@/lib/speicher/schluessel'
|
||||
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
|
||||
|
||||
/**
|
||||
* Treiber "s3": S3-kompatibler Objektspeicher, betrieben in der EU.
|
||||
@@ -17,10 +23,28 @@ import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/sch
|
||||
* Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und
|
||||
* Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber.
|
||||
*
|
||||
* Ausgeliefert wird nur ueber kurzlebige presigned URLs. Sie tragen
|
||||
* Content-Disposition: attachment und einen festen Content-Type, damit der
|
||||
* Objektspeicher eine hochgeladene Datei niemals als HTML im Browser
|
||||
* rendern kann.
|
||||
* ## Anwendungsseitige Verschluesselung
|
||||
*
|
||||
* Frueher stand hier nur ServerSideEncryption: 'AES256'. Das ist
|
||||
* Verschluesselung mit dem Schluessel des Anbieters: Er entschluesselt beim
|
||||
* Ausliefern selbst. Gegen eine verlorene Festplatte im Rechenzentrum hilft
|
||||
* das, gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer
|
||||
* nicht - und in diesem Eimer liegen Bescheide, Arztbriefe und
|
||||
* Kontoauszuege.
|
||||
*
|
||||
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
|
||||
* einem Schluessel aus der Umgebung. Die serverseitige Verschluesselung
|
||||
* bleibt zusaetzlich stehen; sie kostet nichts.
|
||||
*
|
||||
* ## Und deshalb keine presigned URLs mehr
|
||||
*
|
||||
* Eine presigned URL laesst den Browser direkt beim Objektspeicher laden.
|
||||
* Was dort liegt, ist jetzt aber Geheimtext - der Browser bekaeme
|
||||
* unlesbare Bytes. Die Auslieferung geht darum ueber dieselbe eigene Route
|
||||
* wie beim Dateitreiber: Sie prueft zusaetzlich die Berechtigung des
|
||||
* angemeldeten Benutzers und schreibt einen Audit-Eintrag. Dass jeder
|
||||
* Abruf durch die Anwendung laeuft, ist bei einer Handvoll Dokumenten am
|
||||
* Tag kein Thema.
|
||||
*/
|
||||
|
||||
let klient: S3Client | null = null
|
||||
@@ -42,18 +66,29 @@ function eimer(): string {
|
||||
return env.s3.bucket
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Objekt ab - verschluesselt.
|
||||
*
|
||||
* Der gemeldete MIME-Typ wird nicht mehr gebraucht: Was hier landet, ist
|
||||
* Geheimtext und wird als application/octet-stream ausgezeichnet. Der
|
||||
* Parameter bleibt in der Signatur, damit die Fassade beide Treiber gleich
|
||||
* aufrufen kann.
|
||||
*/
|
||||
export async function schreiben(
|
||||
schluessel: string,
|
||||
inhalt: Buffer,
|
||||
mimeTyp: string,
|
||||
_mimeTyp: string,
|
||||
): Promise<void> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: eimer(),
|
||||
Key: geprueft,
|
||||
Body: inhalt,
|
||||
ContentType: mimeTyp,
|
||||
Body: verschluesselnBinaer(inhalt, geprueft),
|
||||
// Bewusst nicht der echte Typ: Was hier liegt, ist Geheimtext. Ein
|
||||
// "application/pdf" darauf waere eine Falschauskunft, und der einzige
|
||||
// Weg zum Inhalt fuehrt ohnehin durch die Anwendung.
|
||||
ContentType: 'application/octet-stream',
|
||||
// Auch beim direkten Abruf niemals im Browser darstellen.
|
||||
ContentDisposition: 'attachment',
|
||||
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
|
||||
@@ -67,7 +102,10 @@ export async function lesen(schluessel: string): Promise<Buffer> {
|
||||
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
||||
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
||||
const bytes = await antwort.Body.transformToByteArray()
|
||||
return Buffer.from(bytes)
|
||||
// Objekte, die vor dieser Aenderung abgelegt wurden, tragen keinen
|
||||
// Umschlag und kommen unveraendert zurueck. Der Migrationslauf
|
||||
// (npm run speicher:verschluesseln) schreibt sie nach und nach um.
|
||||
return entschluesselnBinaer(Buffer.from(bytes), geprueft)
|
||||
}
|
||||
|
||||
export async function entfernen(schluessel: string): Promise<void> {
|
||||
@@ -85,20 +123,54 @@ export async function existiert(schluessel: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt auf die eigene, signierte Route - nicht mehr auf den
|
||||
* Objektspeicher.
|
||||
*
|
||||
* Der direkte Weg zum Anbieter lieferte den Rohinhalt aus. Solange der
|
||||
* Klartext war, ging das; jetzt ist es Geheimtext, und entschluesseln kann
|
||||
* nur die Anwendung. Der Nebeneffekt ist erwuenscht: Die Route prueft die
|
||||
* Berechtigung des angemeldeten Benutzers und schreibt einen Audit-Eintrag.
|
||||
* Ein weitergeleiteter Link allein oeffnet also nichts mehr.
|
||||
*/
|
||||
export async function signierteUrl(
|
||||
schluessel: string,
|
||||
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
|
||||
): Promise<string> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
return getSignedUrl(
|
||||
s3(),
|
||||
new GetObjectCommand({
|
||||
Bucket: eimer(),
|
||||
Key: geprueft,
|
||||
// Die Antwort des Objektspeichers wird hier festgenagelt: Anhang,
|
||||
// niemals HTML, egal was beim Hochladen gemeldet wurde.
|
||||
ResponseContentDisposition: 'attachment',
|
||||
}),
|
||||
{ expiresIn: Math.min(Math.max(Math.floor(sekunden), 1), 60 * 60) },
|
||||
return dateiRoutePfad(zugriffTokenErzeugen(geprueft, sekunden))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Wartung
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// Nur fuer den Migrationslauf, der bestehende Objekte nachtraeglich in den
|
||||
// Umschlag steckt. Bewusst hier und nicht im Skript: Der Zugang zum Eimer
|
||||
// wird an genau einer Stelle aufgebaut.
|
||||
|
||||
/** Alle Ablageschluessel im Eimer, seitenweise geholt. */
|
||||
export async function objekteAuflisten(): Promise<string[]> {
|
||||
const gefunden: string[] = []
|
||||
let weiter: string | undefined
|
||||
|
||||
do {
|
||||
const antwort = await s3().send(
|
||||
new ListObjectsV2Command({ Bucket: eimer(), ContinuationToken: weiter }),
|
||||
)
|
||||
for (const eintrag of antwort.Contents ?? []) {
|
||||
if (eintrag.Key) gefunden.push(eintrag.Key)
|
||||
}
|
||||
weiter = antwort.IsTruncated ? antwort.NextContinuationToken : undefined
|
||||
} while (weiter)
|
||||
|
||||
return gefunden
|
||||
}
|
||||
|
||||
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
|
||||
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
||||
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
||||
return Buffer.from(await antwort.Body.transformToByteArray())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'server-only'
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
/**
|
||||
* Anwendungsseitiger Umschlag fuer abgelegte Dateien (AES-256-GCM).
|
||||
*
|
||||
* Der Dateitreiber hat das von Anfang an getan, der Objektspeicher nicht:
|
||||
* Dort stand nur ServerSideEncryption: 'AES256'. Das ist Verschluesselung
|
||||
* mit dem Schluessel des Anbieters - er entschluesselt beim Ausliefern
|
||||
* selbst. Gegen ein kopiertes Backup hilft es, gegen den Anbieter und gegen
|
||||
* gestohlene Zugangsdaten zum Eimer nicht. Bei Bescheiden, Arztbriefen und
|
||||
* Kontoauszuegen ist das der falsche Zuschnitt.
|
||||
*
|
||||
* Jetzt liegt derselbe Umschlag um beides. Der Schluessel kommt aus der
|
||||
* Umgebung und nie aus der Datenbank oder vom Anbieter.
|
||||
*
|
||||
* ## Format
|
||||
*
|
||||
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext
|
||||
*
|
||||
* Bewusst dasselbe Format wie bisher im Dateitreiber - bestehende Dateien
|
||||
* bleiben ohne Umschreiben lesbar.
|
||||
*
|
||||
* ## Zusatzdaten
|
||||
*
|
||||
* Mitauthentifiziert wird die Schluesselversion und, wo der Aufrufer ihn
|
||||
* angibt, der Ablageschluessel des Objekts. Letzteres bindet den Geheimtext
|
||||
* an seinen Platz: Wer Schreibrechte auf den Eimer hat, kann die Bytes von
|
||||
* Dokument A nicht unter dem Namen von Dokument B ablegen - beim Lesen
|
||||
* schlaegt die Pruefung fehl, statt den falschen Bescheid auszuliefern.
|
||||
*
|
||||
* Ohne Angabe bleibt es bei "bcd:<version>", damit die Dateien, die der
|
||||
* Dateitreiber frueher geschrieben hat, weiter aufgehen.
|
||||
*/
|
||||
|
||||
const MAGIE = Buffer.from('BCD1', 'ascii')
|
||||
const IV_LAENGE = 12
|
||||
const TAG_LAENGE = 16
|
||||
|
||||
export const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
|
||||
|
||||
function zusatzdaten(version: number, ablageschluessel?: string): Buffer {
|
||||
return Buffer.from(
|
||||
ablageschluessel ? `bcd:${version}:${ablageschluessel}` : `bcd:${version}`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
/** Traegt dieser Inhalt bereits einen Umschlag? */
|
||||
export function istVerschluesselt(roh: Buffer): boolean {
|
||||
return roh.length >= KOPF_LAENGE && roh.subarray(0, MAGIE.length).equals(MAGIE)
|
||||
}
|
||||
|
||||
export function verschluesselnBinaer(inhalt: Buffer, ablageschluessel?: string): 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(zusatzdaten(version, ablageschluessel))
|
||||
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])
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt den Umschlag ab.
|
||||
*
|
||||
* Inhalte ohne Umschlag werden unveraendert durchgereicht: Dateien aus
|
||||
* einer aelteren Installation, von Hand hineingelegte, und - bis der
|
||||
* Migrationslauf durch ist - die Objekte, die vor dieser Aenderung im
|
||||
* Objektspeicher lagen.
|
||||
*/
|
||||
export function entschluesselnBinaer(roh: Buffer, ablageschluessel?: string): Buffer {
|
||||
if (!istVerschluesselt(roh)) 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)
|
||||
|
||||
// Zwei Anlaeufe: erst mit dem Ablageschluessel als Zusatzdaten, dann ohne.
|
||||
// Der zweite Anlauf ist fuer alles, was vor der Bindung an den Platz
|
||||
// geschrieben wurde - ohne ihn waeren die alten Dateien nicht mehr
|
||||
// lesbar, und das waere ein Datenverlust fuer eine Verbesserung.
|
||||
const varianten = ablageschluessel ? [ablageschluessel, undefined] : [undefined]
|
||||
|
||||
let letzterFehler: unknown = null
|
||||
for (const variante of varianten) {
|
||||
try {
|
||||
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, {
|
||||
authTagLength: TAG_LAENGE,
|
||||
})
|
||||
decipher.setAAD(zusatzdaten(version, variante))
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(geheim), decipher.final()])
|
||||
} catch (fehler) {
|
||||
letzterFehler = fehler
|
||||
}
|
||||
}
|
||||
|
||||
throw letzterFehler instanceof Error
|
||||
? letzterFehler
|
||||
: new Error('Die Datei ließ sich nicht entschlüsseln.')
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entschluesselnBinaer,
|
||||
istVerschluesselt,
|
||||
KOPF_LAENGE,
|
||||
verschluesselnBinaer,
|
||||
} from '@/lib/speicher/umschlag'
|
||||
|
||||
/**
|
||||
* Der Umschlag um abgelegte Dateien.
|
||||
*
|
||||
* Der Dateitreiber hat ihn von Anfang an benutzt, der Objektspeicher nicht:
|
||||
* Dort stand nur ServerSideEncryption: 'AES256' - Verschluesselung mit dem
|
||||
* Schluessel des Anbieters, der beim Ausliefern selbst entschluesselt.
|
||||
* Gegen eine verlorene Platte im Rechenzentrum hilft das, gegen den
|
||||
* Anbieter und gegen gestohlene Zugangsdaten zum Eimer nicht.
|
||||
*
|
||||
* Seit v1.11.0 liegt derselbe Umschlag um beides. Diese Tests halten die
|
||||
* drei Eigenschaften fest, auf die es dabei ankommt: Er geht wieder auf,
|
||||
* er merkt Veraenderungen, und er bindet den Inhalt an seinen Platz.
|
||||
*/
|
||||
|
||||
const INHALT = Buffer.from('Bescheid des Sozialamts vom 3. Maerz, Seite 1 von 2.', 'utf8')
|
||||
const ORT = 'betreute/abc123/dokumente/eins.pdf'
|
||||
|
||||
describe('Umschlag um abgelegte Dateien', () => {
|
||||
it('geht wieder auf', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(entschluesselnBinaer(zu, ORT).equals(INHALT)).toBe(true)
|
||||
})
|
||||
|
||||
it('sieht dem Geheimtext den Klartext nicht an', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(zu.includes(Buffer.from('Sozialamt', 'utf8'))).toBe(false)
|
||||
expect(istVerschluesselt(zu)).toBe(true)
|
||||
expect(zu.length).toBe(KOPF_LAENGE + INHALT.length)
|
||||
})
|
||||
|
||||
it('erzeugt bei gleichem Inhalt zwei verschiedene Geheimtexte', () => {
|
||||
// Sonst liesse sich an zwei gleich aussehenden Objekten ablesen, dass
|
||||
// dasselbe Dokument zweimal in der Akte liegt.
|
||||
const a = verschluesselnBinaer(INHALT, ORT)
|
||||
const b = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(a.equals(b)).toBe(false)
|
||||
expect(entschluesselnBinaer(a, ORT).equals(entschluesselnBinaer(b, ORT))).toBe(true)
|
||||
})
|
||||
|
||||
it('merkt, wenn am Geheimtext gedreht wurde', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
const letzte = zu.length - 1
|
||||
zu.writeUInt8(zu.readUInt8(letzte) ^ 0x01, letzte)
|
||||
|
||||
expect(() => entschluesselnBinaer(zu, ORT)).toThrow()
|
||||
})
|
||||
|
||||
it('merkt, wenn am Kopf gedreht wurde', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
// Ein Bit im IV.
|
||||
zu.writeUInt8(zu.readUInt8(8) ^ 0x01, 8)
|
||||
|
||||
expect(() => entschluesselnBinaer(zu, ORT)).toThrow()
|
||||
})
|
||||
|
||||
describe('Bindung an den Ablageort', () => {
|
||||
it('laesst sich nicht unter einem anderen Namen auspacken', () => {
|
||||
// Der Angriff dahinter: Wer Schreibrechte auf den Eimer hat, legt die
|
||||
// Bytes von Dokument A unter dem Namen von Dokument B ab. Ohne die
|
||||
// Bindung lieferte die Anwendung anschliessend den falschen Bescheid
|
||||
// aus - und zwar mit gueltiger Pruefsumme fuer den falschen Inhalt.
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(() =>
|
||||
entschluesselnBinaer(zu, 'betreute/abc123/dokumente/zwei.pdf'),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('laesst sich nicht in eine fremde Akte verschieben', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(() => entschluesselnBinaer(zu, 'betreute/xyz789/dokumente/eins.pdf')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rueckwaertsvertraeglichkeit', () => {
|
||||
it('reicht Inhalte ohne Umschlag unveraendert durch', () => {
|
||||
// Genau der Zustand, in dem die Objekte im Speicher liegen, bis der
|
||||
// Migrationslauf sie umgeschrieben hat. Wuerde hier geworfen, waere
|
||||
// nach dem Update kein einziges altes Dokument mehr abrufbar.
|
||||
const roh = Buffer.from('%PDF-1.4 ganz gewoehnlich', 'utf8')
|
||||
|
||||
expect(istVerschluesselt(roh)).toBe(false)
|
||||
expect(entschluesselnBinaer(roh, ORT).equals(roh)).toBe(true)
|
||||
})
|
||||
|
||||
it('haelt auch etwas fuer unverpackt, das zufaellig kurz ist', () => {
|
||||
const winzig = Buffer.from('abc', 'utf8')
|
||||
|
||||
expect(istVerschluesselt(winzig)).toBe(false)
|
||||
expect(entschluesselnBinaer(winzig, ORT).equals(winzig)).toBe(true)
|
||||
})
|
||||
|
||||
it('oeffnet einen Umschlag, der noch ohne Ortsbindung geschrieben wurde', () => {
|
||||
// So hat der Dateitreiber bis v1.10.2 geschrieben. Diese Dateien
|
||||
// muessen weiter aufgehen, sonst waere die Verbesserung ein
|
||||
// Datenverlust.
|
||||
const alt = verschluesselnBinaer(INHALT)
|
||||
|
||||
expect(entschluesselnBinaer(alt, ORT).equals(INHALT)).toBe(true)
|
||||
expect(entschluesselnBinaer(alt).equals(INHALT)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('kommt mit einer leeren Datei zurecht', () => {
|
||||
const leer = Buffer.alloc(0)
|
||||
const zu = verschluesselnBinaer(leer, ORT)
|
||||
|
||||
expect(entschluesselnBinaer(zu, ORT).length).toBe(0)
|
||||
})
|
||||
|
||||
it('kommt mit einer groesseren Datei zurecht', () => {
|
||||
const gross = Buffer.alloc(3 * 1024 * 1024, 0x41)
|
||||
const zu = verschluesselnBinaer(gross, ORT)
|
||||
|
||||
expect(entschluesselnBinaer(zu, ORT).equals(gross)).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user