Merge #42: Serverseitige Verschlüsselung optional machen

This commit was merged in pull request #49.
This commit is contained in:
2026-09-01 14:09:01 +00:00
5 changed files with 187 additions and 4 deletions
+13
View File
@@ -109,6 +109,19 @@ S3_BUCKET=betreuungs-cockpit
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= 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) ---------------------------- # --- E-Mail-Erinnerungen (optional, ab Etappe 2) ----------------------------
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
+7
View File
@@ -379,6 +379,13 @@ docker compose exec minio mc ls lokal
# [...] betreuungs-cockpit/ # [...] 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 > **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den > EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
> `minio`-Dienst aus der `docker-compose.yml`, entfern ihn aus > `minio`-Dienst aus der `docker-compose.yml`, entfern ihn aus
+20
View File
@@ -163,6 +163,26 @@ export const env = {
bucket: pflicht('S3_BUCKET'), bucket: pflicht('S3_BUCKET'),
accessKeyId: pflicht('S3_ACCESS_KEY_ID'), accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'), 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,
} }
}, },
+11 -4
View File
@@ -6,6 +6,7 @@ import {
ListObjectsV2Command, ListObjectsV2Command,
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
type ServerSideEncryption,
} from '@aws-sdk/client-s3' } from '@aws-sdk/client-s3'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { import {
@@ -33,8 +34,13 @@ import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umsch
* Kontoauszuege. * Kontoauszuege.
* *
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit * Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
* einem Schluessel aus der Umgebung. Die serverseitige Verschluesselung * einem Schluessel aus der Umgebung.
* bleibt zusaetzlich stehen; sie kostet nichts. *
* 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 * ## Und deshalb keine presigned URLs mehr
* *
@@ -80,6 +86,7 @@ export async function schreiben(
_mimeTyp: string, _mimeTyp: string,
): Promise<void> { ): Promise<void> {
const geprueft = schluesselErfordern(schluessel) const geprueft = schluesselErfordern(schluessel)
const sse = env.s3.serverseitigeVerschluesselung
await s3().send( await s3().send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: eimer(), Bucket: eimer(),
@@ -91,8 +98,8 @@ export async function schreiben(
ContentType: 'application/octet-stream', 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. // Nur mitschicken, wenn konfiguriert - siehe env.s3.
ServerSideEncryption: 'AES256', ...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
}), }),
) )
} }
+136
View File
@@ -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<string, unknown> | null = null
vi.mock('@aws-sdk/client-s3', () => {
class PutObjectCommand {
input: Record<string, unknown>
constructor(input: Record<string, unknown>) {
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<string, unknown> }) {
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')
})
})