diff --git a/package.json b/package.json index bdff040..5f0cf8d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/speicher-verschluesseln.mts b/scripts/speicher-verschluesseln.mts new file mode 100644 index 0000000..0577a22 --- /dev/null +++ b/scripts/speicher-verschluesseln.mts @@ -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 { + 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 { + 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 { + 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 { + 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 +}) diff --git a/src/lib/speicher/datei.ts b/src/lib/speicher/datei.ts index bc4264b..db9cb16 100644 --- a/src/lib/speicher/datei.ts +++ b/src/lib/speicher/datei.ts @@ -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 { 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 { const roh = await readFile(pfadFuer(schluessel)) - return entschluesselnBinaer(roh) + return entschluesselnBinaer(roh, schluessel) } export async function entfernen(schluessel: string): Promise { diff --git a/src/lib/speicher/s3.ts b/src/lib/speicher/s3.ts index 35c092f..e0dcde8 100644 --- a/src/lib/speicher/s3.ts +++ b/src/lib/speicher/s3.ts @@ -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 { 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 { 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 { @@ -85,20 +123,54 @@ export async function existiert(schluessel: string): Promise { } } +/** + * 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 { 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 { + 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 { + 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()) } diff --git a/src/lib/speicher/umschlag.ts b/src/lib/speicher/umschlag.ts new file mode 100644 index 0000000..4ce1d99 --- /dev/null +++ b/src/lib/speicher/umschlag.ts @@ -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:", 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.') +} diff --git a/tests/speicher-umschlag.test.ts b/tests/speicher-umschlag.test.ts new file mode 100644 index 0000000..d4eaf29 --- /dev/null +++ b/tests/speicher-umschlag.test.ts @@ -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) + }) +})