Fix #42: Serverseitige Verschlüsselung optional machen #49
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+11
-4
@@ -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<void> {
|
||||
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 } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user