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>
350 lines
13 KiB
Go
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
|
|
}
|