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 }