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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user