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:
@@ -18,6 +18,7 @@
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:migration": "node scripts/migration-erzeugen.mjs",
|
||||
"speicher:verschluesseln": "tsx --env-file=.env scripts/speicher-verschluesseln.mts",
|
||||
"db:pruefen": "node -e \"import('./scripts/migration-erzeugen.mjs').then(m => m.indizesPruefen())\"",
|
||||
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
|
||||
"db:seed": "prisma db seed",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Bestehende Dateien nachtraeglich in den Umschlag stecken.
|
||||
*
|
||||
* npm run speicher:verschluesseln # nur schauen
|
||||
* npm run speicher:verschluesseln -- --anwenden
|
||||
*
|
||||
* Warum es diesen Lauf gibt: Der Objektspeicher-Treiber hat bis v1.10.2
|
||||
* unverschluesselt abgelegt - es stand nur ServerSideEncryption: 'AES256'
|
||||
* darauf, also Verschluesselung mit dem Schluessel des Anbieters. Seit
|
||||
* v1.11.0 liegt derselbe Umschlag darum wie beim Dateitreiber. Neue Dateien
|
||||
* bekommen ihn beim Ablegen; die alten liegen weiter im Klartext, bis sie
|
||||
* hier einmal umgeschrieben werden.
|
||||
*
|
||||
* Lesen funktioniert waehrenddessen durchgehend: Der Treiber erkennt am
|
||||
* Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
|
||||
* Der Lauf laesst sich also jederzeit starten, abbrechen und wiederholen.
|
||||
*
|
||||
* Er ist absichtlich langsam und einfach: ein Objekt nach dem anderen,
|
||||
* lesen, pruefen, zurueckschreiben. Bei ein paar hundert Dokumenten dauert
|
||||
* das Sekunden, und ein Fehler betrifft immer nur eine Datei.
|
||||
*
|
||||
* OHNE --anwenden wird nichts geschrieben.
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
/**
|
||||
* "server-only" fuer diesen Lauf neutralisieren.
|
||||
*
|
||||
* Das Paket wirft beim Import ausserhalb einer Server-Umgebung - genau
|
||||
* dafuer ist es da. Hier laufen wir aber bewusst ausserhalb von Next, und
|
||||
* die Alternative waere, den Umschlag im Skript noch einmal zu schreiben.
|
||||
* Zwei Fassungen derselben Krypto sind die schlechtere Wahl: Wenn eine
|
||||
* davon abweicht, merkt es niemand, bis eine Datei nicht mehr aufgeht.
|
||||
*
|
||||
* Deshalb wird der Modullader vorher umgebogen und danach dynamisch
|
||||
* importiert - statische Importe wuerden vorgezogen und liefen in den
|
||||
* Fehler.
|
||||
*/
|
||||
const verlangen = createRequire(import.meta.url)
|
||||
const modulLader = verlangen('node:module') as {
|
||||
_load: (anfrage: string, ...rest: unknown[]) => unknown
|
||||
}
|
||||
const echterLader = modulLader._load
|
||||
modulLader._load = function (anfrage: string, ...rest: unknown[]) {
|
||||
if (anfrage === 'server-only') return {}
|
||||
return echterLader.call(this, anfrage, ...rest)
|
||||
}
|
||||
|
||||
const { env } = await import('../src/lib/env')
|
||||
const { istVerschluesselt, verschluesselnBinaer } = await import('../src/lib/speicher/umschlag')
|
||||
|
||||
const anwenden = process.argv.includes('--anwenden')
|
||||
|
||||
type Bilanz = { geprueft: number; umgeschrieben: number; schonGut: number; fehler: number }
|
||||
|
||||
function zeile(text = ''): void {
|
||||
console.log(text)
|
||||
}
|
||||
|
||||
/** Objektspeicher: auflisten, lesen, bei Bedarf zurueckschreiben. */
|
||||
async function s3Durchgehen(): Promise<Bilanz> {
|
||||
const s3 = await import('../src/lib/speicher/s3')
|
||||
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
|
||||
|
||||
const schluessel = await s3.objekteAuflisten()
|
||||
zeile(` ${schluessel.length} Objekte im Eimer "${env.s3.bucket}".`)
|
||||
zeile()
|
||||
|
||||
for (const eintrag of schluessel) {
|
||||
bilanz.geprueft += 1
|
||||
try {
|
||||
const roh = await s3.rohLesen(eintrag)
|
||||
|
||||
if (istVerschluesselt(roh)) {
|
||||
bilanz.schonGut += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (anwenden) {
|
||||
// schreiben() legt den Umschlag selbst an - der Inhalt geht also
|
||||
// als Klartext hinein und kommt verschluesselt im Eimer an.
|
||||
await s3.schreiben(eintrag, roh, 'application/octet-stream')
|
||||
}
|
||||
bilanz.umgeschrieben += 1
|
||||
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${eintrag}`)
|
||||
} catch (fehler) {
|
||||
bilanz.fehler += 1
|
||||
zeile(` FEHLER bei ${eintrag}: ${fehler instanceof Error ? fehler.message : fehler}`)
|
||||
}
|
||||
}
|
||||
|
||||
return bilanz
|
||||
}
|
||||
|
||||
/** Dateitreiber: dasselbe fuer das Ablageverzeichnis. */
|
||||
async function dateienDurchgehen(): Promise<Bilanz> {
|
||||
const { readdir, stat } = await import('node:fs/promises')
|
||||
const { join, relative, sep } = await import('node:path')
|
||||
const treiber = await import('../src/lib/speicher/datei')
|
||||
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
|
||||
|
||||
const wurzel = treiber.ablageverzeichnis()
|
||||
zeile(` Verzeichnis ${wurzel}`)
|
||||
zeile()
|
||||
|
||||
async function absteigen(verzeichnis: string): Promise<void> {
|
||||
let eintraege: string[]
|
||||
try {
|
||||
eintraege = await readdir(verzeichnis)
|
||||
} catch {
|
||||
return // gibt es noch nicht
|
||||
}
|
||||
|
||||
for (const name of eintraege) {
|
||||
const pfad = join(verzeichnis, name)
|
||||
if ((await stat(pfad)).isDirectory()) {
|
||||
await absteigen(pfad)
|
||||
continue
|
||||
}
|
||||
|
||||
bilanz.geprueft += 1
|
||||
try {
|
||||
const roh = await readFile(pfad)
|
||||
if (istVerschluesselt(roh)) {
|
||||
bilanz.schonGut += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Der Ablageschluessel ist der Pfad unterhalb der Wurzel, mit
|
||||
// Schraegstrichen - genau das, was der Treiber beim Schreiben
|
||||
// benutzt. Er gehoert in die Zusatzdaten, sonst waere die Datei
|
||||
// hinterher zwar verschluesselt, aber nicht an ihren Platz
|
||||
// gebunden, und liesse sich unter fremdem Namen wieder einspielen.
|
||||
const ablageschluessel = relative(wurzel, pfad).split(sep).join('/')
|
||||
|
||||
if (anwenden) {
|
||||
await writeFile(pfad, verschluesselnBinaer(roh, ablageschluessel), { mode: 0o600 })
|
||||
}
|
||||
bilanz.umgeschrieben += 1
|
||||
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${pfad}`)
|
||||
} catch (fehler) {
|
||||
bilanz.fehler += 1
|
||||
zeile(` FEHLER bei ${pfad}: ${fehler instanceof Error ? fehler.message : fehler}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await absteigen(wurzel)
|
||||
return bilanz
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
zeile()
|
||||
zeile(' Bestehende Dateien in den Umschlag stecken')
|
||||
zeile(' ' + '-'.repeat(50))
|
||||
zeile()
|
||||
|
||||
if (!anwenden) {
|
||||
zeile(' TROCKENLAUF - es wird nichts geschrieben.')
|
||||
zeile(' Zum Anwenden: npm run speicher:verschluesseln -- --anwenden')
|
||||
zeile()
|
||||
}
|
||||
|
||||
const treiber = env.speicherTreiber
|
||||
zeile(` Treiber: ${treiber}`)
|
||||
zeile()
|
||||
|
||||
const bilanz = treiber === 's3' ? await s3Durchgehen() : await dateienDurchgehen()
|
||||
|
||||
zeile()
|
||||
zeile(` Geprueft: ${bilanz.geprueft}`)
|
||||
zeile(` Schon im Umschlag: ${bilanz.schonGut}`)
|
||||
zeile(` ${anwenden ? 'Umgeschrieben' : 'Umzuschreiben'}: ${bilanz.umgeschrieben}`)
|
||||
if (bilanz.fehler > 0) zeile(` Fehler: ${bilanz.fehler}`)
|
||||
zeile()
|
||||
|
||||
if (bilanz.fehler > 0) process.exitCode = 1
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error(fehler)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'server-only'
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { env } from '@/lib/env'
|
||||
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
|
||||
import {
|
||||
dateiRoutePfad,
|
||||
schluesselErfordern,
|
||||
@@ -23,15 +23,10 @@ import {
|
||||
* gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis.
|
||||
* Der Schluessel liegt in der Umgebung, nicht neben den Daten.
|
||||
*
|
||||
* Umschlag (binaer):
|
||||
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext
|
||||
* Der Umschlag selbst steht in src/lib/speicher/umschlag.ts - der
|
||||
* Objektspeicher benutzt denselben.
|
||||
*/
|
||||
|
||||
const MAGIE = Buffer.from('BCD1', 'ascii')
|
||||
const IV_LAENGE = 12
|
||||
const TAG_LAENGE = 16
|
||||
const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
|
||||
|
||||
function wurzel(): string {
|
||||
return resolve(process.cwd(), env.dateiSpeicherPfad)
|
||||
}
|
||||
@@ -52,57 +47,15 @@ function pfadFuer(schluessel: string): string {
|
||||
return ziel
|
||||
}
|
||||
|
||||
function verschluesselnBinaer(inhalt: Buffer): Buffer {
|
||||
const version = env.aktiveSchluesselversion
|
||||
const schluessel = env.verschluesselungsschluessel.get(version)
|
||||
if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`)
|
||||
|
||||
const iv = randomBytes(IV_LAENGE)
|
||||
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
|
||||
cipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
|
||||
const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()])
|
||||
|
||||
const versionBytes = Buffer.alloc(4)
|
||||
versionBytes.writeUInt32BE(version, 0)
|
||||
|
||||
return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim])
|
||||
}
|
||||
|
||||
function entschluesselnBinaer(roh: Buffer): Buffer {
|
||||
// Dateien ohne Umschlag (von Hand hineingelegt, aus einer aelteren
|
||||
// Installation uebernommen) werden unveraendert durchgereicht.
|
||||
if (roh.length < KOPF_LAENGE || !roh.subarray(0, MAGIE.length).equals(MAGIE)) return roh
|
||||
|
||||
const version = roh.readUInt32BE(MAGIE.length)
|
||||
const schluessel = env.verschluesselungsschluessel.get(version)
|
||||
if (!schluessel) {
|
||||
throw new Error(
|
||||
`Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` +
|
||||
`Rotieren nicht aus der Umgebung entfernt werden.`,
|
||||
)
|
||||
}
|
||||
|
||||
const ivVon = MAGIE.length + 4
|
||||
const iv = roh.subarray(ivVon, ivVon + IV_LAENGE)
|
||||
const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE)
|
||||
const geheim = roh.subarray(KOPF_LAENGE)
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
|
||||
decipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
return Buffer.concat([decipher.update(geheim), decipher.final()])
|
||||
}
|
||||
|
||||
export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> {
|
||||
const pfad = pfadFuer(schluessel)
|
||||
await mkdir(dirname(pfad), { recursive: true })
|
||||
await writeFile(pfad, verschluesselnBinaer(inhalt), { mode: 0o600 })
|
||||
await writeFile(pfad, verschluesselnBinaer(inhalt, schluessel), { mode: 0o600 })
|
||||
}
|
||||
|
||||
export async function lesen(schluessel: string): Promise<Buffer> {
|
||||
const roh = await readFile(pfadFuer(schluessel))
|
||||
return entschluesselnBinaer(roh)
|
||||
return entschluesselnBinaer(roh, schluessel)
|
||||
}
|
||||
|
||||
export async function entfernen(schluessel: string): Promise<void> {
|
||||
|
||||
+92
-20
@@ -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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'server-only'
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
/**
|
||||
* Anwendungsseitiger Umschlag fuer abgelegte Dateien (AES-256-GCM).
|
||||
*
|
||||
* Der Dateitreiber hat das von Anfang an getan, der Objektspeicher nicht:
|
||||
* Dort stand nur ServerSideEncryption: 'AES256'. Das ist Verschluesselung
|
||||
* mit dem Schluessel des Anbieters - er entschluesselt beim Ausliefern
|
||||
* selbst. Gegen ein kopiertes Backup hilft es, gegen den Anbieter und gegen
|
||||
* gestohlene Zugangsdaten zum Eimer nicht. Bei Bescheiden, Arztbriefen und
|
||||
* Kontoauszuegen ist das der falsche Zuschnitt.
|
||||
*
|
||||
* Jetzt liegt derselbe Umschlag um beides. Der Schluessel kommt aus der
|
||||
* Umgebung und nie aus der Datenbank oder vom Anbieter.
|
||||
*
|
||||
* ## Format
|
||||
*
|
||||
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext
|
||||
*
|
||||
* Bewusst dasselbe Format wie bisher im Dateitreiber - bestehende Dateien
|
||||
* bleiben ohne Umschreiben lesbar.
|
||||
*
|
||||
* ## Zusatzdaten
|
||||
*
|
||||
* Mitauthentifiziert wird die Schluesselversion und, wo der Aufrufer ihn
|
||||
* angibt, der Ablageschluessel des Objekts. Letzteres bindet den Geheimtext
|
||||
* an seinen Platz: Wer Schreibrechte auf den Eimer hat, kann die Bytes von
|
||||
* Dokument A nicht unter dem Namen von Dokument B ablegen - beim Lesen
|
||||
* schlaegt die Pruefung fehl, statt den falschen Bescheid auszuliefern.
|
||||
*
|
||||
* Ohne Angabe bleibt es bei "bcd:<version>", damit die Dateien, die der
|
||||
* Dateitreiber frueher geschrieben hat, weiter aufgehen.
|
||||
*/
|
||||
|
||||
const MAGIE = Buffer.from('BCD1', 'ascii')
|
||||
const IV_LAENGE = 12
|
||||
const TAG_LAENGE = 16
|
||||
|
||||
export const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
|
||||
|
||||
function zusatzdaten(version: number, ablageschluessel?: string): Buffer {
|
||||
return Buffer.from(
|
||||
ablageschluessel ? `bcd:${version}:${ablageschluessel}` : `bcd:${version}`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
/** Traegt dieser Inhalt bereits einen Umschlag? */
|
||||
export function istVerschluesselt(roh: Buffer): boolean {
|
||||
return roh.length >= KOPF_LAENGE && roh.subarray(0, MAGIE.length).equals(MAGIE)
|
||||
}
|
||||
|
||||
export function verschluesselnBinaer(inhalt: Buffer, ablageschluessel?: string): Buffer {
|
||||
const version = env.aktiveSchluesselversion
|
||||
const schluessel = env.verschluesselungsschluessel.get(version)
|
||||
if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`)
|
||||
|
||||
const iv = randomBytes(IV_LAENGE)
|
||||
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
|
||||
cipher.setAAD(zusatzdaten(version, ablageschluessel))
|
||||
const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()])
|
||||
|
||||
const versionBytes = Buffer.alloc(4)
|
||||
versionBytes.writeUInt32BE(version, 0)
|
||||
|
||||
return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim])
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt den Umschlag ab.
|
||||
*
|
||||
* Inhalte ohne Umschlag werden unveraendert durchgereicht: Dateien aus
|
||||
* einer aelteren Installation, von Hand hineingelegte, und - bis der
|
||||
* Migrationslauf durch ist - die Objekte, die vor dieser Aenderung im
|
||||
* Objektspeicher lagen.
|
||||
*/
|
||||
export function entschluesselnBinaer(roh: Buffer, ablageschluessel?: string): Buffer {
|
||||
if (!istVerschluesselt(roh)) return roh
|
||||
|
||||
const version = roh.readUInt32BE(MAGIE.length)
|
||||
const schluessel = env.verschluesselungsschluessel.get(version)
|
||||
if (!schluessel) {
|
||||
throw new Error(
|
||||
`Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` +
|
||||
`Rotieren nicht aus der Umgebung entfernt werden.`,
|
||||
)
|
||||
}
|
||||
|
||||
const ivVon = MAGIE.length + 4
|
||||
const iv = roh.subarray(ivVon, ivVon + IV_LAENGE)
|
||||
const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE)
|
||||
const geheim = roh.subarray(KOPF_LAENGE)
|
||||
|
||||
// Zwei Anlaeufe: erst mit dem Ablageschluessel als Zusatzdaten, dann ohne.
|
||||
// Der zweite Anlauf ist fuer alles, was vor der Bindung an den Platz
|
||||
// geschrieben wurde - ohne ihn waeren die alten Dateien nicht mehr
|
||||
// lesbar, und das waere ein Datenverlust fuer eine Verbesserung.
|
||||
const varianten = ablageschluessel ? [ablageschluessel, undefined] : [undefined]
|
||||
|
||||
let letzterFehler: unknown = null
|
||||
for (const variante of varianten) {
|
||||
try {
|
||||
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, {
|
||||
authTagLength: TAG_LAENGE,
|
||||
})
|
||||
decipher.setAAD(zusatzdaten(version, variante))
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(geheim), decipher.final()])
|
||||
} catch (fehler) {
|
||||
letzterFehler = fehler
|
||||
}
|
||||
}
|
||||
|
||||
throw letzterFehler instanceof Error
|
||||
? letzterFehler
|
||||
: new Error('Die Datei ließ sich nicht entschlüsseln.')
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entschluesselnBinaer,
|
||||
istVerschluesselt,
|
||||
KOPF_LAENGE,
|
||||
verschluesselnBinaer,
|
||||
} from '@/lib/speicher/umschlag'
|
||||
|
||||
/**
|
||||
* Der Umschlag um abgelegte Dateien.
|
||||
*
|
||||
* Der Dateitreiber hat ihn von Anfang an benutzt, der Objektspeicher nicht:
|
||||
* Dort stand nur ServerSideEncryption: 'AES256' - Verschluesselung mit dem
|
||||
* Schluessel des Anbieters, der beim Ausliefern selbst entschluesselt.
|
||||
* Gegen eine verlorene Platte im Rechenzentrum hilft das, gegen den
|
||||
* Anbieter und gegen gestohlene Zugangsdaten zum Eimer nicht.
|
||||
*
|
||||
* Seit v1.11.0 liegt derselbe Umschlag um beides. Diese Tests halten die
|
||||
* drei Eigenschaften fest, auf die es dabei ankommt: Er geht wieder auf,
|
||||
* er merkt Veraenderungen, und er bindet den Inhalt an seinen Platz.
|
||||
*/
|
||||
|
||||
const INHALT = Buffer.from('Bescheid des Sozialamts vom 3. Maerz, Seite 1 von 2.', 'utf8')
|
||||
const ORT = 'betreute/abc123/dokumente/eins.pdf'
|
||||
|
||||
describe('Umschlag um abgelegte Dateien', () => {
|
||||
it('geht wieder auf', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(entschluesselnBinaer(zu, ORT).equals(INHALT)).toBe(true)
|
||||
})
|
||||
|
||||
it('sieht dem Geheimtext den Klartext nicht an', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(zu.includes(Buffer.from('Sozialamt', 'utf8'))).toBe(false)
|
||||
expect(istVerschluesselt(zu)).toBe(true)
|
||||
expect(zu.length).toBe(KOPF_LAENGE + INHALT.length)
|
||||
})
|
||||
|
||||
it('erzeugt bei gleichem Inhalt zwei verschiedene Geheimtexte', () => {
|
||||
// Sonst liesse sich an zwei gleich aussehenden Objekten ablesen, dass
|
||||
// dasselbe Dokument zweimal in der Akte liegt.
|
||||
const a = verschluesselnBinaer(INHALT, ORT)
|
||||
const b = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(a.equals(b)).toBe(false)
|
||||
expect(entschluesselnBinaer(a, ORT).equals(entschluesselnBinaer(b, ORT))).toBe(true)
|
||||
})
|
||||
|
||||
it('merkt, wenn am Geheimtext gedreht wurde', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
const letzte = zu.length - 1
|
||||
zu.writeUInt8(zu.readUInt8(letzte) ^ 0x01, letzte)
|
||||
|
||||
expect(() => entschluesselnBinaer(zu, ORT)).toThrow()
|
||||
})
|
||||
|
||||
it('merkt, wenn am Kopf gedreht wurde', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
// Ein Bit im IV.
|
||||
zu.writeUInt8(zu.readUInt8(8) ^ 0x01, 8)
|
||||
|
||||
expect(() => entschluesselnBinaer(zu, ORT)).toThrow()
|
||||
})
|
||||
|
||||
describe('Bindung an den Ablageort', () => {
|
||||
it('laesst sich nicht unter einem anderen Namen auspacken', () => {
|
||||
// Der Angriff dahinter: Wer Schreibrechte auf den Eimer hat, legt die
|
||||
// Bytes von Dokument A unter dem Namen von Dokument B ab. Ohne die
|
||||
// Bindung lieferte die Anwendung anschliessend den falschen Bescheid
|
||||
// aus - und zwar mit gueltiger Pruefsumme fuer den falschen Inhalt.
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(() =>
|
||||
entschluesselnBinaer(zu, 'betreute/abc123/dokumente/zwei.pdf'),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('laesst sich nicht in eine fremde Akte verschieben', () => {
|
||||
const zu = verschluesselnBinaer(INHALT, ORT)
|
||||
|
||||
expect(() => entschluesselnBinaer(zu, 'betreute/xyz789/dokumente/eins.pdf')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rueckwaertsvertraeglichkeit', () => {
|
||||
it('reicht Inhalte ohne Umschlag unveraendert durch', () => {
|
||||
// Genau der Zustand, in dem die Objekte im Speicher liegen, bis der
|
||||
// Migrationslauf sie umgeschrieben hat. Wuerde hier geworfen, waere
|
||||
// nach dem Update kein einziges altes Dokument mehr abrufbar.
|
||||
const roh = Buffer.from('%PDF-1.4 ganz gewoehnlich', 'utf8')
|
||||
|
||||
expect(istVerschluesselt(roh)).toBe(false)
|
||||
expect(entschluesselnBinaer(roh, ORT).equals(roh)).toBe(true)
|
||||
})
|
||||
|
||||
it('haelt auch etwas fuer unverpackt, das zufaellig kurz ist', () => {
|
||||
const winzig = Buffer.from('abc', 'utf8')
|
||||
|
||||
expect(istVerschluesselt(winzig)).toBe(false)
|
||||
expect(entschluesselnBinaer(winzig, ORT).equals(winzig)).toBe(true)
|
||||
})
|
||||
|
||||
it('oeffnet einen Umschlag, der noch ohne Ortsbindung geschrieben wurde', () => {
|
||||
// So hat der Dateitreiber bis v1.10.2 geschrieben. Diese Dateien
|
||||
// muessen weiter aufgehen, sonst waere die Verbesserung ein
|
||||
// Datenverlust.
|
||||
const alt = verschluesselnBinaer(INHALT)
|
||||
|
||||
expect(entschluesselnBinaer(alt, ORT).equals(INHALT)).toBe(true)
|
||||
expect(entschluesselnBinaer(alt).equals(INHALT)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('kommt mit einer leeren Datei zurecht', () => {
|
||||
const leer = Buffer.alloc(0)
|
||||
const zu = verschluesselnBinaer(leer, ORT)
|
||||
|
||||
expect(entschluesselnBinaer(zu, ORT).length).toBe(0)
|
||||
})
|
||||
|
||||
it('kommt mit einer groesseren Datei zurecht', () => {
|
||||
const gross = Buffer.alloc(3 * 1024 * 1024, 0x41)
|
||||
const zu = verschluesselnBinaer(gross, ORT)
|
||||
|
||||
expect(entschluesselnBinaer(zu, ORT).equals(gross)).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user