Objektspeicher verschluesseln, statt es dem Anbieter zu ueberlassen
Im S3-Treiber stand nur ServerSideEncryption: 'AES256'. Das ist
Verschluesselung mit dem Schluessel des Anbieters - er entschluesselt beim
Ausliefern selbst. Gegen eine verlorene Platte im Rechenzentrum hilft das,
gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer nicht. In
diesem Eimer liegen Bescheide, Arztbriefe und Kontoauszuege.
Der Dateitreiber hat es von Anfang an richtig gemacht. Der Umschlag zieht
deshalb nach src/lib/speicher/umschlag.ts um und wird von beiden benutzt,
statt ihn ein zweites Mal zu schreiben.
Neu ist die Bindung an den Ablageort: Der Schluessel des Objekts wandert
in die Zusatzdaten. Wer Schreibrechte auf den Eimer hat, kann die Bytes
von Dokument A damit nicht unter dem Namen von Dokument B ablegen - beim
Lesen schlaegt die Pruefung fehl, statt den falschen Bescheid mit
gueltiger Pruefsumme auszuliefern.
Damit fallen die presigned URLs weg. Sie liessen den Browser direkt beim
Anbieter laden; was dort liegt, ist jetzt Geheimtext. Die Auslieferung
geht ueber dieselbe eigene Route wie beim Dateitreiber - die prueft
zusaetzlich die Berechtigung und schreibt einen Audit-Eintrag. Ein
weitergeleiteter Link allein oeffnet also nichts mehr.
Bestehende Dateien bleiben durchgehend lesbar: Der Treiber erkennt am
Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
Auch die alten Umschlaege ohne Ortsbindung gehen weiter auf - sonst waere
die Verbesserung ein Datenverlust. Nachgezogen wird mit
npm run speicher:verschluesseln # nur schauen
npm run speicher:verschluesseln -- --anwenden
Geprueft: 12 Tests gegen drei Mutationen, ein Trockenlauf ueber die 120
Dateien der Entwicklungsablage, und eine Probe mit einer Klartextdatei,
die nach dem Lauf verschluesselt auf der Platte liegt und sich trotzdem
korrekt zurueeklesen laesst.
Refs #38
This commit is contained in:
+93
-21
@@ -3,12 +3,18 @@ import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { env } from '@/lib/env'
|
||||
import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/schluessel'
|
||||
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.
|
||||
@@ -17,10 +23,28 @@ import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/sch
|
||||
* Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und
|
||||
* Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber.
|
||||
*
|
||||
* Ausgeliefert wird nur ueber kurzlebige presigned URLs. Sie tragen
|
||||
* Content-Disposition: attachment und einen festen Content-Type, damit der
|
||||
* Objektspeicher eine hochgeladene Datei niemals als HTML im Browser
|
||||
* rendern kann.
|
||||
* ## 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
|
||||
* bleibt zusaetzlich stehen; sie kostet nichts.
|
||||
*
|
||||
* ## 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
|
||||
@@ -42,18 +66,29 @@ 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,
|
||||
_mimeTyp: string,
|
||||
): Promise<void> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: eimer(),
|
||||
Key: geprueft,
|
||||
Body: inhalt,
|
||||
ContentType: mimeTyp,
|
||||
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',
|
||||
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
|
||||
@@ -67,7 +102,10 @@ export async function lesen(schluessel: string): Promise<Buffer> {
|
||||
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()
|
||||
return Buffer.from(bytes)
|
||||
// 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> {
|
||||
@@ -85,20 +123,54 @@ export async function existiert(schluessel: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getSignedUrl(
|
||||
s3(),
|
||||
new GetObjectCommand({
|
||||
Bucket: eimer(),
|
||||
Key: geprueft,
|
||||
// Die Antwort des Objektspeichers wird hier festgenagelt: Anhang,
|
||||
// niemals HTML, egal was beim Hochladen gemeldet wurde.
|
||||
ResponseContentDisposition: 'attachment',
|
||||
}),
|
||||
{ expiresIn: Math.min(Math.max(Math.floor(sekunden), 1), 60 * 60) },
|
||||
)
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user