Merge #42: Serverseitige Verschlüsselung optional machen
This commit was merged in pull request #49.
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