diff --git a/.env.example b/.env.example index 18c19f7..ca90ca9 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,19 @@ S3_BUCKET=betreuungs-cockpit S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= +# Serverseitige Verschluesselung durch den Anbieter. +# +# Bleibt leer, und das ist die richtige Voreinstellung: Die Anwendung +# verschluesselt jede Datei selbst, bevor sie den Server verlaesst. Eine +# zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene +# Umschlag nicht schon abdeckt. +# +# Das mitgelieferte MinIO kann sie ohnehin nicht - es laeuft ohne KMS und +# lehnt jede Ablage mit "NotImplemented" ab, sobald hier etwas steht. +# +# Nur setzen, wenn ein externer Anbieter sie ausdruecklich verlangt: +# S3_SSE=AES256 + # --- E-Mail-Erinnerungen (optional, ab Etappe 2) ---------------------------- SMTP_HOST= SMTP_PORT=587 diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 0aaaf8a..1eab7c7 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -379,6 +379,13 @@ docker compose exec minio mc ls lokal # [...] betreuungs-cockpit/ ``` +> **Serverseitige Verschluesselung?** Brauchst du nicht. Die Anwendung +> verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit +> `ENC_KEY_V1`, der den Anbieter nie erreicht. Das mitgelieferte MinIO +> läuft ohne KMS und würde jede Ablage ablehnen, sobald `S3_SSE` in der +> `.env` steht. Setz den Wert nur, wenn ein externer Anbieter ihn +> ausdrücklich verlangt. + > **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der > EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den > `minio`-Dienst aus der `docker-compose.yml`, entfern ihn aus diff --git a/src/lib/env.ts b/src/lib/env.ts index c337853..85dbfdf 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -163,6 +163,26 @@ export const env = { bucket: pflicht('S3_BUCKET'), accessKeyId: pflicht('S3_ACCESS_KEY_ID'), secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'), + + /** + * Serverseitige Verschluesselung - nur, wenn ausdruecklich gewuenscht. + * + * Frueher stand im Treiber fest ServerSideEncryption: 'AES256'. Das + * mitgelieferte MinIO laeuft ohne KMS und lehnt das mit + * "NotImplemented" ab: Es liess sich kein einziges Dokument ablegen, + * und wer der INSTALLATION.md folgte, landete zwangslaeufig dort. + * + * Gebraucht wird sie ohnehin nicht mehr. Seit v1.11.0 verschluesselt + * die Anwendung jede Datei selbst, bevor sie den Server verlaesst + * (src/lib/speicher/umschlag.ts). Serverseitige Verschluesselung + * schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt - + * sie ist hoechstens eine zweite Lage fuer Anbieter, die sie + * anbieten. + * + * Leer bedeutet aus. Wer sie will, setzt S3_SSE=AES256 oder den Wert, + * den sein Anbieter erwartet. + */ + serverseitigeVerschluesselung: process.env.S3_SSE?.trim() || null, } }, diff --git a/src/lib/speicher/s3.ts b/src/lib/speicher/s3.ts index e0dcde8..d9c0dd7 100644 --- a/src/lib/speicher/s3.ts +++ b/src/lib/speicher/s3.ts @@ -6,6 +6,7 @@ import { ListObjectsV2Command, PutObjectCommand, S3Client, + type ServerSideEncryption, } from '@aws-sdk/client-s3' import { env } from '@/lib/env' import { @@ -33,8 +34,13 @@ import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umsch * 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. + * einem Schluessel aus der Umgebung. + * + * Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur + * noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal, + * sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft + * ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete + * die gesamte Funktion. * * ## Und deshalb keine presigned URLs mehr * @@ -80,6 +86,7 @@ export async function schreiben( _mimeTyp: string, ): Promise { const geprueft = schluesselErfordern(schluessel) + const sse = env.s3.serverseitigeVerschluesselung await s3().send( new PutObjectCommand({ Bucket: eimer(), @@ -91,8 +98,8 @@ export async function schreiben( ContentType: 'application/octet-stream', // Auch beim direkten Abruf niemals im Browser darstellen. ContentDisposition: 'attachment', - // Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt. - ServerSideEncryption: 'AES256', + // Nur mitschicken, wenn konfiguriert - siehe env.s3. + ...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}), }), ) } diff --git a/tests/speicher-s3.test.ts b/tests/speicher-s3.test.ts new file mode 100644 index 0000000..eeae8ea --- /dev/null +++ b/tests/speicher-s3.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Was der S3-Treiber tatsächlich an den Objektspeicher schickt. + * + * Anlass: Der Treiber setzte bei jedem PutObject fest + * `ServerSideEncryption: 'AES256'`. Das mitgelieferte MinIO läuft ohne KMS + * und lehnte das mit `NotImplemented` ab — es ließ sich kein einziges + * Dokument ablegen, und wer der INSTALLATION.md folgte, landete + * zwangsläufig dort. + * + * Gemerkt hat das kein Test, weil in der Entwicklung der Dateitreiber + * läuft und der S3-Pfad nie ausgeführt wurde. Diese Datei schließt genau + * diese Lücke: Sie fängt den Befehl ab, bevor er das Netz sieht, und + * schaut hinein. + */ + +process.env.S3_ENDPOINT = 'http://minio:9000' +process.env.S3_BUCKET = 'betreuungs-cockpit' +process.env.S3_ACCESS_KEY_ID = 'probe-zugang' +process.env.S3_SECRET_ACCESS_KEY = 'probe-geheimnis' + +/** Der letzte Befehl, den der Treiber abgeschickt hat. */ +let letzterBefehl: Record | null = null + +vi.mock('@aws-sdk/client-s3', () => { + class PutObjectCommand { + input: Record + constructor(input: Record) { + this.input = input + } + } + class GetObjectCommand extends PutObjectCommand {} + class DeleteObjectCommand extends PutObjectCommand {} + class HeadObjectCommand extends PutObjectCommand {} + class ListObjectsV2Command extends PutObjectCommand {} + + class S3Client { + async send(befehl: { input: Record }) { + letzterBefehl = befehl.input + return {} + } + } + + return { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + } +}) + +const SCHLUESSEL = 'betreute/cmt4ey0jm0003o001wq4rc3m9/dokumente/ba7a8c1a-d4c5-41c7-8f5f-ebfb4805b0e5.pdf' +const INHALT = Buffer.from('%PDF-1.4 ein Bescheid', 'utf8') + +/** + * Der Treiber baut seinen Klienten einmal und merkt ihn sich. Damit die + * Umgebungsvariable je Testfall neu gelesen wird, muss das Modul frisch + * geladen werden. + */ +async function treiberFrisch() { + vi.resetModules() + return import('@/lib/speicher/s3') +} + +describe('S3-Treiber: serverseitige Verschlüsselung', () => { + beforeEach(() => { + letzterBefehl = null + delete process.env.S3_SSE + }) + + afterEach(() => { + delete process.env.S3_SSE + }) + + it('schickt ohne S3_SSE keinen Verschlüsselungskopf mit', async () => { + const s3 = await treiberFrisch() + await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf') + + expect(letzterBefehl).not.toBeNull() + expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption') + }) + + it('schickt ihn mit, wenn S3_SSE gesetzt ist', async () => { + process.env.S3_SSE = 'AES256' + const s3 = await treiberFrisch() + await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf') + + expect(letzterBefehl?.ServerSideEncryption).toBe('AES256') + }) + + it('übernimmt auch einen anderen Wert, den ein Anbieter verlangt', async () => { + process.env.S3_SSE = 'aws:kms' + const s3 = await treiberFrisch() + await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf') + + expect(letzterBefehl?.ServerSideEncryption).toBe('aws:kms') + }) + + it('behandelt eine leere Angabe wie "aus"', async () => { + // Sonst schickte eine leergelassene Zeile in der .env einen leeren + // Kopf, und der Anbieter antwortete mit einem Fehler, den niemand + // versteht. + process.env.S3_SSE = ' ' + const s3 = await treiberFrisch() + await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf') + + expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption') + }) +}) + +describe('S3-Treiber: was sonst noch mitgeht', () => { + beforeEach(() => { + letzterBefehl = null + delete process.env.S3_SSE + }) + + it('legt den Inhalt verschlüsselt ab, nicht im Klartext', async () => { + const s3 = await treiberFrisch() + await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf') + + const koerper = letzterBefehl?.Body as Buffer + expect(koerper.subarray(0, 4).toString('ascii')).toBe('BCD1') + expect(koerper.includes(Buffer.from('ein Bescheid', 'utf8'))).toBe(false) + }) + + it('meldet den Inhaltstyp nicht als PDF - dort liegt Geheimtext', async () => { + const s3 = await treiberFrisch() + await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf') + + expect(letzterBefehl?.ContentType).toBe('application/octet-stream') + expect(letzterBefehl?.ContentDisposition).toBe('attachment') + }) +})