Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39

Merged
florian.wilde merged 10 commits from fix/38-sicherheitsaudit into main 2026-08-24 10:35:20 +00:00
6 changed files with 531 additions and 73 deletions
Showing only changes of commit 92ff8efe39 - Show all commits
+1
View File
@@ -18,6 +18,7 @@
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"db:migration": "node scripts/migration-erzeugen.mjs", "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: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:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
+184
View File
@@ -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
})
+5 -52
View File
@@ -1,8 +1,8 @@
import 'server-only' import 'server-only'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve, sep } from 'node:path' import { dirname, join, resolve, sep } from 'node:path'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
import { import {
dateiRoutePfad, dateiRoutePfad,
schluesselErfordern, schluesselErfordern,
@@ -23,15 +23,10 @@ import {
* gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis. * gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis.
* Der Schluessel liegt in der Umgebung, nicht neben den Daten. * Der Schluessel liegt in der Umgebung, nicht neben den Daten.
* *
* Umschlag (binaer): * Der Umschlag selbst steht in src/lib/speicher/umschlag.ts - der
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext * 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 { function wurzel(): string {
return resolve(process.cwd(), env.dateiSpeicherPfad) return resolve(process.cwd(), env.dateiSpeicherPfad)
} }
@@ -52,57 +47,15 @@ function pfadFuer(schluessel: string): string {
return ziel 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> { export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = pfadFuer(schluessel) const pfad = pfadFuer(schluessel)
await mkdir(dirname(pfad), { recursive: true }) 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> { export async function lesen(schluessel: string): Promise<Buffer> {
const roh = await readFile(pfadFuer(schluessel)) const roh = await readFile(pfadFuer(schluessel))
return entschluesselnBinaer(roh) return entschluesselnBinaer(roh, schluessel)
} }
export async function entfernen(schluessel: string): Promise<void> { export async function entfernen(schluessel: string): Promise<void> {
+92 -20
View File
@@ -3,12 +3,18 @@ import {
DeleteObjectCommand, DeleteObjectCommand,
GetObjectCommand, GetObjectCommand,
HeadObjectCommand, HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
} from '@aws-sdk/client-s3' } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { env } from '@/lib/env' 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. * 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 * Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und
* Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber. * Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber.
* *
* Ausgeliefert wird nur ueber kurzlebige presigned URLs. Sie tragen * ## Anwendungsseitige Verschluesselung
* Content-Disposition: attachment und einen festen Content-Type, damit der *
* Objektspeicher eine hochgeladene Datei niemals als HTML im Browser * Frueher stand hier nur ServerSideEncryption: 'AES256'. Das ist
* rendern kann. * 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 let klient: S3Client | null = null
@@ -42,18 +66,29 @@ function eimer(): string {
return env.s3.bucket 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( export async function schreiben(
schluessel: string, schluessel: string,
inhalt: Buffer, inhalt: Buffer,
mimeTyp: string, _mimeTyp: string,
): Promise<void> { ): Promise<void> {
const geprueft = schluesselErfordern(schluessel) const geprueft = schluesselErfordern(schluessel)
await s3().send( await s3().send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: eimer(), Bucket: eimer(),
Key: geprueft, Key: geprueft,
Body: inhalt, Body: verschluesselnBinaer(inhalt, geprueft),
ContentType: mimeTyp, // 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. // Auch beim direkten Abruf niemals im Browser darstellen.
ContentDisposition: 'attachment', ContentDisposition: 'attachment',
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt. // 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 })) const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`) if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
const bytes = await antwort.Body.transformToByteArray() 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> { 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( export async function signierteUrl(
schluessel: string, schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN, sekunden: number = TOKEN_STANDARD_SEKUNDEN,
): Promise<string> { ): Promise<string> {
const geprueft = schluesselErfordern(schluessel) const geprueft = schluesselErfordern(schluessel)
return getSignedUrl( return dateiRoutePfad(zugriffTokenErzeugen(geprueft, sekunden))
s3(), }
new GetObjectCommand({
Bucket: eimer(), // -----------------------------------------------------------------------------
Key: geprueft, // Wartung
// Die Antwort des Objektspeichers wird hier festgenagelt: Anhang, // -----------------------------------------------------------------------------
// niemals HTML, egal was beim Hochladen gemeldet wurde. //
ResponseContentDisposition: 'attachment', // Nur fuer den Migrationslauf, der bestehende Objekte nachtraeglich in den
}), // Umschlag steckt. Bewusst hier und nicht im Skript: Der Zugang zum Eimer
{ expiresIn: Math.min(Math.max(Math.floor(sekunden), 1), 60 * 60) }, // 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())
} }
+119
View File
@@ -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.')
}
+129
View File
@@ -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)
})
})