syncova-backup/packages/backupengine/transform.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

350 lines
13 KiB
Go

package backupengine
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"github.com/klauspost/compress/zstd"
)
// CompressionLevel ist die Kompressionsstufe (PROMPT.md §11).
//
// Der Anwender wählt eine Stufe, keine technischen Parameter.
type CompressionLevel string
const (
// CompressionOff schaltet die Kompression ab.
CompressionOff CompressionLevel = "off"
// CompressionFast bevorzugt Geschwindigkeit.
CompressionFast CompressionLevel = "fast"
// CompressionBalanced ist der Standard aus PROMPT.md §119.
CompressionBalanced CompressionLevel = "balanced"
// CompressionMaximum bevorzugt die Speicherersparnis.
CompressionMaximum CompressionLevel = "maximum"
)
// compressionAlgorithm benennt das verwendete Verfahren im Manifest.
//
// Zstandard bietet bei Backup-Daten das beste Verhältnis aus Geschwindigkeit
// und Ersparnis und ist ein etabliertes, offen spezifiziertes Verfahren.
const compressionAlgorithm = "zstd"
// encryptionAlgorithm benennt das Verschlüsselungsverfahren im Manifest.
const encryptionAlgorithm = "aes-256-gcm"
// zstdLevel übersetzt eine Stufe in einen zstd-Parameter.
func (compressionLevel CompressionLevel) zstdLevel() (zstd.EncoderLevel, bool) {
switch compressionLevel {
case CompressionFast:
return zstd.SpeedFastest, true
case CompressionBalanced:
return zstd.SpeedDefault, true
case CompressionMaximum:
return zstd.SpeedBestCompression, true
default:
return zstd.SpeedDefault, false
}
}
// IsEnabled meldet, ob überhaupt komprimiert wird.
func (compressionLevel CompressionLevel) IsEnabled() bool {
return compressionLevel != CompressionOff && compressionLevel != ""
}
// compressionMarker steht vor jedem abgelegten Block und beschreibt seine Form.
//
// Ohne diese Kennzeichnung liesse sich beim Lesen nicht entscheiden, ob ein
// Block dekomprimiert werden muss. Ein Block, der sich nicht verkleinern liess,
// wird unkomprimiert abgelegt — das kommt bei bereits komprimierten Daten
// (Bildern, Archiven) regelmäßig vor.
type compressionMarker byte
const (
// markerStored kennzeichnet einen unkomprimiert abgelegten Block.
markerStored compressionMarker = 0
// markerCompressed kennzeichnet einen komprimierten Block.
markerCompressed compressionMarker = 1
)
// ChunkTransformer wandelt einen Klartextblock in seine abzulegende Form.
//
// Die Reihenfolge ist zwingend: erst komprimieren, dann verschlüsseln.
// Verschlüsselte Daten sind nicht von Zufall zu unterscheiden und liessen sich
// nicht mehr verkleinern.
type ChunkTransformer struct {
// compressionLevel ist die gewählte Kompressionsstufe.
compressionLevel CompressionLevel
// encoder komprimiert; nil bei abgeschalteter Kompression.
encoder *zstd.Encoder
// decoder dekomprimiert; nil bei abgeschalteter Kompression.
decoder *zstd.Decoder
// aeadCipher verschlüsselt und authentifiziert; nil ohne Verschlüsselung.
aeadCipher cipher.AEAD
// nonceDerivationKey leitet die Nonce aus dem Klartext ab.
//
// Er ist aus dem Datenschlüssel abgeleitet und von diesem verschieden,
// damit die Nonce-Ableitung keine Rückschlüsse auf den Schlüssel erlaubt.
nonceDerivationKey []byte
}
// TransformerOptions steuern den Aufbau eines Transformers.
type TransformerOptions struct {
// CompressionLevel ist die gewünschte Kompressionsstufe.
CompressionLevel CompressionLevel
// DataEncryptionKey ist der 32 Byte lange Schlüssel dieses Backups.
//
// Er ist je Backup neu und macht die zählerbasierte Nonce sicher.
// Ein leerer Schlüssel schaltet die Verschlüsselung ab.
DataEncryptionKey []byte
}
// dataEncryptionKeyLength ist die geforderte Schlüssellänge (AES-256).
const dataEncryptionKeyLength = 32
// ErrInvalidDataKey meldet einen unbrauchbaren Datenschlüssel.
var ErrInvalidDataKey = errors.New("der datenschlüssel hat nicht die erforderliche länge")
// ErrChunkTampered meldet einen veränderten oder falsch entschlüsselten Block.
var ErrChunkTampered = errors.New("der block ist beschädigt oder wurde verändert")
// NewChunkTransformer erzeugt einen Transformer.
func NewChunkTransformer(transformerOptions TransformerOptions) (*ChunkTransformer, error) {
chunkTransformer := &ChunkTransformer{
compressionLevel: transformerOptions.CompressionLevel,
}
if transformerOptions.CompressionLevel.IsEnabled() {
encoderLevel, _ := transformerOptions.CompressionLevel.zstdLevel()
// Der Encoder arbeitet ohne eigene Nebenläufigkeit: die Parallelität
// entsteht bereits durch die Arbeiter der Pipeline. Zwei Ebenen von
// Nebenläufigkeit würden nur um dieselben Kerne konkurrieren.
encoder, encoderError := zstd.NewWriter(nil,
zstd.WithEncoderLevel(encoderLevel),
zstd.WithEncoderConcurrency(1))
if encoderError != nil {
return nil, fmt.Errorf("die kompression konnte nicht eingerichtet werden: %w", encoderError)
}
decoder, decoderError := zstd.NewReader(nil, zstd.WithDecoderConcurrency(1))
if decoderError != nil {
return nil, fmt.Errorf("die dekompression konnte nicht eingerichtet werden: %w", decoderError)
}
chunkTransformer.encoder = encoder
chunkTransformer.decoder = decoder
}
if len(transformerOptions.DataEncryptionKey) > 0 {
if len(transformerOptions.DataEncryptionKey) != dataEncryptionKeyLength {
return nil, fmt.Errorf("%w: %d byte statt %d",
ErrInvalidDataKey, len(transformerOptions.DataEncryptionKey), dataEncryptionKeyLength)
}
blockCipher, cipherError := aes.NewCipher(transformerOptions.DataEncryptionKey)
if cipherError != nil {
return nil, fmt.Errorf("die verschlüsselung konnte nicht eingerichtet werden: %w", cipherError)
}
aeadCipher, gcmError := cipher.NewGCM(blockCipher)
if gcmError != nil {
return nil, fmt.Errorf("die verschlüsselung konnte nicht eingerichtet werden: %w", gcmError)
}
chunkTransformer.aeadCipher = aeadCipher
// Der Ableitungsschlüssel ist vom Datenschlüssel verschieden, damit die
// Nonce-Ableitung keine Rückschlüsse auf ihn erlaubt.
nonceKeyAuthenticator := hmac.New(sha256.New, transformerOptions.DataEncryptionKey)
nonceKeyAuthenticator.Write([]byte("syncova-chunk-nonce-derivation-v1"))
chunkTransformer.nonceDerivationKey = nonceKeyAuthenticator.Sum(nil)
}
return chunkTransformer, nil
}
// IsEncrypting meldet, ob verschlüsselt wird.
func (chunkTransformer *ChunkTransformer) IsEncrypting() bool {
return chunkTransformer.aeadCipher != nil
}
// AlgorithmNames liefert die im Manifest zu vermerkenden Verfahren.
func (chunkTransformer *ChunkTransformer) AlgorithmNames() (compressionName string, encryptionName string) {
if chunkTransformer.encoder != nil {
compressionName = compressionAlgorithm
}
if chunkTransformer.aeadCipher != nil {
encryptionName = encryptionAlgorithm
}
return compressionName, encryptionName
}
// Transform wandelt einen Klartextblock in seine abzulegende Form.
//
// Aufbau des Ergebnisses:
//
// [Nonce (12 Byte)] [Marker (1 Byte)] [Nutzdaten] [GCM-Authentifizierungsschild]
//
// Ohne Verschlüsselung entfallen Nonce und Schild.
//
// Der zweite Rueckgabewert meldet, ob sich der Block **verkleinern liess**. Er
// ist der Entropie-Indikator der Anlage (Phase 16): Normale Nutzdaten —
// Dokumente, Datenbanken, Quelltext — lassen sich fast immer verkleinern.
// Verschluesselte Daten nie. Ein Sprung im Anteil unkomprimierbarer Bloecke ist
// deshalb das billigste verlaessliche Signal fuer massenhafte Verschluesselung.
//
// Der Wert wird hier zurueckgegeben und nicht spaeter ermittelt: Nach der
// Verschluesselung steckt der Marker im Geheimtext und ist nicht mehr lesbar.
func (chunkTransformer *ChunkTransformer) Transform(plaintextChunk []byte) ([]byte, bool, error) {
transformedChunk := chunkTransformer.compress(plaintextChunk)
// Das erste Byte traegt den Marker; danach ist er im Geheimtext verborgen.
wasCompressed := len(transformedChunk) > 0 && transformedChunk[0] == byte(markerCompressed)
if chunkTransformer.aeadCipher == nil {
return transformedChunk, wasCompressed, nil
}
// Die Nonce entsteht aus dem Klartext, nicht aus einem Zähler: nur so
// ergibt derselbe Block stets denselben Geheimtext und bleibt
// deduplizierbar.
messageNonce := chunkTransformer.deriveNonce(plaintextChunk)
// Seal hängt den Geheimtext an die Nonce an, sodass beides zusammenbleibt.
// Das Authentifizierungsschild deckt jede nachträgliche Veränderung auf und
// macht eine zusätzliche Prüfsumme der gespeicherten Form überflüssig.
return chunkTransformer.aeadCipher.Seal(messageNonce, messageNonce, transformedChunk, nil),
wasCompressed, nil
}
// compress verkleinert einen Block und kennzeichnet das Ergebnis.
func (chunkTransformer *ChunkTransformer) compress(plaintextChunk []byte) []byte {
if chunkTransformer.encoder == nil {
return append([]byte{byte(markerStored)}, plaintextChunk...)
}
compressedChunk := chunkTransformer.encoder.EncodeAll(plaintextChunk, make([]byte, 0, len(plaintextChunk)))
// Wurde der Block nicht kleiner, wird er unkomprimiert abgelegt. Bei bereits
// komprimierten Daten kostet die Kompression sonst Platz statt zu sparen.
if len(compressedChunk) >= len(plaintextChunk) {
return append([]byte{byte(markerStored)}, plaintextChunk...)
}
return append([]byte{byte(markerCompressed)}, compressedChunk...)
}
// Restore stellt den Klartext eines abgelegten Blocks wieder her.
func (chunkTransformer *ChunkTransformer) Restore(storedChunk []byte) ([]byte, error) {
decryptedChunk := storedChunk
if chunkTransformer.aeadCipher != nil {
nonceSize := chunkTransformer.aeadCipher.NonceSize()
if len(storedChunk) < nonceSize {
return nil, ErrChunkTampered
}
messageNonce := storedChunk[:nonceSize]
sealedPayload := storedChunk[nonceSize:]
openedChunk, openError := chunkTransformer.aeadCipher.Open(nil, messageNonce, sealedPayload, nil)
if openError != nil {
// GCM meldet hier jede Veränderung und jeden falschen Schlüssel.
// Die Ursache wird nicht durchgereicht: sie trägt keine verwertbare
// Information und könnte einem Angreifer nützen.
return nil, ErrChunkTampered
}
decryptedChunk = openedChunk
}
if len(decryptedChunk) < 1 {
return nil, ErrChunkTampered
}
blockMarker := compressionMarker(decryptedChunk[0])
blockPayload := decryptedChunk[1:]
switch blockMarker {
case markerStored:
return blockPayload, nil
case markerCompressed:
if chunkTransformer.decoder == nil {
return nil, errors.New("der block ist komprimiert, die dekompression ist aber nicht eingerichtet")
}
restoredChunk, decodeError := chunkTransformer.decoder.DecodeAll(blockPayload, nil)
if decodeError != nil {
return nil, fmt.Errorf("%w: der block liess sich nicht entpacken", ErrChunkTampered)
}
return restoredChunk, nil
default:
// Ein unbekannter Marker deutet auf einen verfälschten Block hin.
return nil, ErrChunkTampered
}
}
// deriveNonce leitet die Nonce deterministisch aus dem Blockinhalt ab.
//
// Die Ableitung ist der Schlüssel dazu, Verschlüsselung und Deduplizierung
// zugleich zu erreichen:
//
// - Ein Zähler wäre zwar eindeutig, ergäbe aber für denselben Klartext bei
// jedem Lauf einen anderen Geheimtext. Ein zweites Backup könnte einen
// bereits abgelegten Block dann nicht wiederverwenden.
// - Eine zufällige Nonce hätte dasselbe Problem und brächte zusätzlich ein
// Kollisionsrisiko über viele Millionen Blöcke.
//
// Die Ableitung über HMAC liefert für gleichen Klartext dieselbe Nonce und
// damit denselben Geheimtext. Eine Nonce wiederholt sich also genau dann, wenn
// auch der Klartext derselbe ist — und dann ist der Geheimtext ohnehin
// identisch. Der bei GCM gefürchtete Fall (gleiche Nonce, verschiedener
// Klartext) tritt nicht ein.
//
// Preisgegeben wird dadurch nur, dass zwei Blöcke gleich sind. Das verrät die
// inhaltsadressierte Ablage ohnehin — die Chunk-Kennung ist der Klartext-Hash.
func (chunkTransformer *ChunkTransformer) deriveNonce(plaintextChunk []byte) []byte {
nonceSize := chunkTransformer.aeadCipher.NonceSize()
nonceAuthenticator := hmac.New(sha256.New, chunkTransformer.nonceDerivationKey)
nonceAuthenticator.Write(plaintextChunk)
return nonceAuthenticator.Sum(nil)[:nonceSize]
}
// Close gibt die Betriebsmittel des Transformers frei.
func (chunkTransformer *ChunkTransformer) Close() {
if chunkTransformer.encoder != nil {
_ = chunkTransformer.encoder.Close()
}
if chunkTransformer.decoder != nil {
chunkTransformer.decoder.Close()
}
}
// GenerateDataEncryptionKey erzeugt einen Datenschlüssel für ein Backup.
//
// Je Backup entsteht ein neuer Schlüssel. Erst dadurch ist die zählerbasierte
// Nonce sicher: derselbe Zählerstand tritt nie unter demselben Schlüssel
// erneut auf.
func GenerateDataEncryptionKey() ([]byte, error) {
dataEncryptionKey := make([]byte, dataEncryptionKeyLength)
if _, randomError := rand.Read(dataEncryptionKey); randomError != nil {
return nil, fmt.Errorf("es konnte kein sicherer datenschlüssel erzeugt werden: %w", randomError)
}
return dataEncryptionKey, nil
}