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
184 lines
6.7 KiB
TypeScript
184 lines
6.7 KiB
TypeScript
import 'server-only'
|
|
import {
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
HeadObjectCommand,
|
|
ListObjectsV2Command,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
type ServerSideEncryption,
|
|
} from '@aws-sdk/client-s3'
|
|
import { env } from '@/lib/env'
|
|
import {
|
|
dateiRoutePfad,
|
|
schluesselErfordern,
|
|
zugriffTokenErzeugen,
|
|
TOKEN_STANDARD_SEKUNDEN,
|
|
} from '@/lib/speicher/schluessel'
|
|
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
|
|
|
|
/**
|
|
* Treiber "s3": S3-kompatibler Objektspeicher, betrieben in der EU.
|
|
*
|
|
* Voraussetzung fuer den Einsatz ist ein Auftragsverarbeitungsvertrag nach
|
|
* Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und
|
|
* Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber.
|
|
*
|
|
* ## Anwendungsseitige Verschluesselung
|
|
*
|
|
* Frueher stand hier nur ServerSideEncryption: 'AES256'. Das ist
|
|
* Verschluesselung mit dem Schluessel des Anbieters: Er entschluesselt beim
|
|
* Ausliefern selbst. Gegen eine verlorene Festplatte im Rechenzentrum hilft
|
|
* das, gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer
|
|
* nicht - und in diesem Eimer liegen Bescheide, Arztbriefe und
|
|
* Kontoauszuege.
|
|
*
|
|
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
|
|
* 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
|
|
*
|
|
* Eine presigned URL laesst den Browser direkt beim Objektspeicher laden.
|
|
* Was dort liegt, ist jetzt aber Geheimtext - der Browser bekaeme
|
|
* unlesbare Bytes. Die Auslieferung geht darum ueber dieselbe eigene Route
|
|
* wie beim Dateitreiber: Sie prueft zusaetzlich die Berechtigung des
|
|
* angemeldeten Benutzers und schreibt einen Audit-Eintrag. Dass jeder
|
|
* Abruf durch die Anwendung laeuft, ist bei einer Handvoll Dokumenten am
|
|
* Tag kein Thema.
|
|
*/
|
|
|
|
let klient: S3Client | null = null
|
|
|
|
function s3(): S3Client {
|
|
if (klient) return klient
|
|
const k = env.s3
|
|
klient = new S3Client({
|
|
region: k.region,
|
|
endpoint: k.endpoint,
|
|
// Viele S3-kompatible Anbieter (MinIO, Hetzner) brauchen Pfad-Stil.
|
|
forcePathStyle: true,
|
|
credentials: { accessKeyId: k.accessKeyId, secretAccessKey: k.secretAccessKey },
|
|
})
|
|
return klient
|
|
}
|
|
|
|
function eimer(): string {
|
|
return env.s3.bucket
|
|
}
|
|
|
|
/**
|
|
* Legt ein Objekt ab - verschluesselt.
|
|
*
|
|
* Der gemeldete MIME-Typ wird nicht mehr gebraucht: Was hier landet, ist
|
|
* Geheimtext und wird als application/octet-stream ausgezeichnet. Der
|
|
* Parameter bleibt in der Signatur, damit die Fassade beide Treiber gleich
|
|
* aufrufen kann.
|
|
*/
|
|
export async function schreiben(
|
|
schluessel: string,
|
|
inhalt: Buffer,
|
|
_mimeTyp: string,
|
|
): Promise<void> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
const sse = env.s3.serverseitigeVerschluesselung
|
|
await s3().send(
|
|
new PutObjectCommand({
|
|
Bucket: eimer(),
|
|
Key: geprueft,
|
|
Body: verschluesselnBinaer(inhalt, geprueft),
|
|
// Bewusst nicht der echte Typ: Was hier liegt, ist Geheimtext. Ein
|
|
// "application/pdf" darauf waere eine Falschauskunft, und der einzige
|
|
// Weg zum Inhalt fuehrt ohnehin durch die Anwendung.
|
|
ContentType: 'application/octet-stream',
|
|
// Auch beim direkten Abruf niemals im Browser darstellen.
|
|
ContentDisposition: 'attachment',
|
|
// Nur mitschicken, wenn konfiguriert - siehe env.s3.
|
|
...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
|
|
}),
|
|
)
|
|
}
|
|
|
|
export async function lesen(schluessel: string): Promise<Buffer> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
|
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
|
const bytes = await antwort.Body.transformToByteArray()
|
|
// Objekte, die vor dieser Aenderung abgelegt wurden, tragen keinen
|
|
// Umschlag und kommen unveraendert zurueck. Der Migrationslauf
|
|
// (npm run speicher:verschluesseln) schreibt sie nach und nach um.
|
|
return entschluesselnBinaer(Buffer.from(bytes), geprueft)
|
|
}
|
|
|
|
export async function entfernen(schluessel: string): Promise<void> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
|
}
|
|
|
|
export async function existiert(schluessel: string): Promise<boolean> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
try {
|
|
await s3().send(new HeadObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zeigt auf die eigene, signierte Route - nicht mehr auf den
|
|
* Objektspeicher.
|
|
*
|
|
* Der direkte Weg zum Anbieter lieferte den Rohinhalt aus. Solange der
|
|
* Klartext war, ging das; jetzt ist es Geheimtext, und entschluesseln kann
|
|
* nur die Anwendung. Der Nebeneffekt ist erwuenscht: Die Route prueft die
|
|
* Berechtigung des angemeldeten Benutzers und schreibt einen Audit-Eintrag.
|
|
* Ein weitergeleiteter Link allein oeffnet also nichts mehr.
|
|
*/
|
|
export async function signierteUrl(
|
|
schluessel: string,
|
|
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
|
|
): Promise<string> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
return dateiRoutePfad(zugriffTokenErzeugen(geprueft, sekunden))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Wartung
|
|
// -----------------------------------------------------------------------------
|
|
//
|
|
// Nur fuer den Migrationslauf, der bestehende Objekte nachtraeglich in den
|
|
// Umschlag steckt. Bewusst hier und nicht im Skript: Der Zugang zum Eimer
|
|
// wird an genau einer Stelle aufgebaut.
|
|
|
|
/** Alle Ablageschluessel im Eimer, seitenweise geholt. */
|
|
export async function objekteAuflisten(): Promise<string[]> {
|
|
const gefunden: string[] = []
|
|
let weiter: string | undefined
|
|
|
|
do {
|
|
const antwort = await s3().send(
|
|
new ListObjectsV2Command({ Bucket: eimer(), ContinuationToken: weiter }),
|
|
)
|
|
for (const eintrag of antwort.Contents ?? []) {
|
|
if (eintrag.Key) gefunden.push(eintrag.Key)
|
|
}
|
|
weiter = antwort.IsTruncated ? antwort.NextContinuationToken : undefined
|
|
} while (weiter)
|
|
|
|
return gefunden
|
|
}
|
|
|
|
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
|
|
export async function rohLesen(schluessel: string): Promise<Buffer> {
|
|
const geprueft = schluesselErfordern(schluessel)
|
|
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
|
|
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
|
|
return Buffer.from(await antwort.Body.transformToByteArray())
|
|
}
|