Serverseitige Verschluesselung optional machen
Der S3-Treiber setzte bei jedem PutObject fest ServerSideEncryption: 'AES256'. Das mitgelieferte MinIO laeuft ohne KMS und lehnt das mit "NotImplemented" ab - es liess sich kein einziges Dokument ablegen. Wer der INSTALLATION.md folgte, landete zwangslaeufig dort; es war kein Konfigurationsfehler des Betreibers, sondern der Auslieferungszustand. Gebraucht wird die Zeile ohnehin nicht mehr. Seit v1.11.0 verschluesselt die Anwendung jede Datei selbst, bevor sie den Server verlaesst. Eine zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt. Jetzt steuert S3_SSE, ob der Kopf mitgeht. Leer bedeutet aus - auch eine Zeile mit Leerzeichen, sonst schickte eine leergelassene .env-Zeile einen leeren Kopf und der Anbieter antwortete mit einem Fehler, den niemand versteht. Im Kommentar von v1.11.0 stand, die serverseitige Verschluesselung "kostet nichts". Das war falsch: Auf MinIO ohne KMS kostete sie die gesamte Funktion. Der Satz ist korrigiert. Aufgefallen ist das erst im Betrieb, und der Grund ist derselbe wie beim Ausloeser: In der Entwicklung laeuft der Dateitreiber, der S3-Pfad wurde von keinem Test ausgefuehrt. Deshalb pruefen die neuen Tests den Befehl ab, bevor er das Netz sieht - was hineingeschrieben wird, ist jetzt belegt, nicht angenommen. Gegen drei Mutationen geprueft: SSE wieder fest verdrahtet, leere Angabe nicht abgefangen, Inhalt unverschluesselt - jede wird rot. Refs #42
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 } : {}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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