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>
197 lines
7.6 KiB
Go
197 lines
7.6 KiB
Go
package crypto
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// SecretStore verschlüsselt und entschlüsselt vertrauliche Werte.
|
|
//
|
|
// Die Schnittstelle ist bewusst schmal gehalten, damit die lokale Umsetzung
|
|
// später gegen ein KMS, ein HSM oder einen externen Secret Store getauscht
|
|
// werden kann, ohne aufrufenden Code zu ändern (PROMPT.md §12).
|
|
type SecretStore interface {
|
|
// Encrypt verschlüsselt einen Klartext und meldet die verwendete Schlüsselversion.
|
|
Encrypt(plaintext []byte) (ciphertext []byte, keyVersion string, encryptError error)
|
|
// Decrypt entschlüsselt einen Geheimtext der angegebenen Schlüsselversion.
|
|
Decrypt(ciphertext []byte, keyVersion string) ([]byte, error)
|
|
// CurrentKeyVersion liefert die Version, mit der neu verschlüsselt wird.
|
|
CurrentKeyVersion() string
|
|
}
|
|
|
|
// masterKeyLength ist die geforderte Länge des Hauptschlüssels (AES-256).
|
|
const masterKeyLength = 32
|
|
|
|
// ErrUnknownKeyVersion meldet einen Geheimtext, dessen Schlüssel nicht vorliegt.
|
|
//
|
|
// Das ist ein Datenverlustrisiko und niemals ein stillschweigend zu
|
|
// überspringender Fall (PROMPT.md §142).
|
|
var ErrUnknownKeyVersion = errors.New("der zur entschlüsselung nötige schlüssel ist nicht verfügbar")
|
|
|
|
// ErrSecretTampered meldet einen veränderten oder beschädigten Geheimtext.
|
|
var ErrSecretTampered = errors.New("der geheimtext ist beschädigt oder wurde verändert")
|
|
|
|
// LocalSecretStore verschlüsselt Secrets mit AES-256-GCM und lokal gehaltenen Schlüsseln.
|
|
//
|
|
// GCM ist authentifiziert: eine nachträgliche Veränderung des Geheimtextes wird
|
|
// beim Entschlüsseln erkannt und nicht etwa als gültiger Wert ausgeliefert.
|
|
type LocalSecretStore struct {
|
|
// keysByVersion hält alle bekannten Schlüssel. Alte Versionen bleiben
|
|
// erhalten, damit früher verschlüsselte Werte lesbar bleiben (PROMPT.md §143).
|
|
keysByVersion map[string]cipher.AEAD
|
|
// currentKeyVersion ist die Version, mit der neu verschlüsselt wird.
|
|
currentKeyVersion string
|
|
}
|
|
|
|
// NewLocalSecretStore baut einen Secret Store aus den übergebenen Schlüsseln.
|
|
//
|
|
// keysByVersion bildet Versionsnamen auf 32 Byte lange Schlüssel ab.
|
|
// currentKeyVersion benennt den Schlüssel für neue Verschlüsselungen.
|
|
func NewLocalSecretStore(keysByVersion map[string][]byte, currentKeyVersion string) (*LocalSecretStore, error) {
|
|
if len(keysByVersion) == 0 {
|
|
return nil, errors.New("es wurde kein verschlüsselungsschlüssel übergeben")
|
|
}
|
|
|
|
if _, hasCurrentKey := keysByVersion[currentKeyVersion]; !hasCurrentKey {
|
|
return nil, fmt.Errorf("der aktuelle schlüssel %q ist nicht in der schlüsselliste enthalten", currentKeyVersion)
|
|
}
|
|
|
|
preparedKeys := make(map[string]cipher.AEAD, len(keysByVersion))
|
|
for keyVersion, keyMaterial := range keysByVersion {
|
|
// Ein zu kurzer Schlüssel würde die Verschlüsselung wirkungslos machen.
|
|
if len(keyMaterial) != masterKeyLength {
|
|
return nil, fmt.Errorf("der schlüssel %q hat %d byte, erforderlich sind %d", keyVersion, len(keyMaterial), masterKeyLength)
|
|
}
|
|
|
|
blockCipher, cipherError := aes.NewCipher(keyMaterial)
|
|
if cipherError != nil {
|
|
return nil, fmt.Errorf("der schlüssel %q konnte nicht verwendet werden: %w", keyVersion, cipherError)
|
|
}
|
|
|
|
authenticatedCipher, gcmError := cipher.NewGCM(blockCipher)
|
|
if gcmError != nil {
|
|
return nil, fmt.Errorf("der schlüssel %q konnte nicht für GCM verwendet werden: %w", keyVersion, gcmError)
|
|
}
|
|
|
|
preparedKeys[keyVersion] = authenticatedCipher
|
|
}
|
|
|
|
return &LocalSecretStore{keysByVersion: preparedKeys, currentKeyVersion: currentKeyVersion}, nil
|
|
}
|
|
|
|
// CurrentKeyVersion liefert die Version, mit der neu verschlüsselt wird.
|
|
func (secretStore *LocalSecretStore) CurrentKeyVersion() string {
|
|
return secretStore.currentKeyVersion
|
|
}
|
|
|
|
// Encrypt verschlüsselt einen Klartext mit dem aktuellen Schlüssel.
|
|
//
|
|
// Der Rückgabewert enthält die Nonce vorangestellt, damit der Geheimtext ohne
|
|
// zusätzliche Ablage entschlüsselt werden kann.
|
|
func (secretStore *LocalSecretStore) Encrypt(plaintext []byte) ([]byte, string, error) {
|
|
authenticatedCipher := secretStore.keysByVersion[secretStore.currentKeyVersion]
|
|
|
|
// Die Nonce muss je Schlüssel einmalig sein; sie wird deshalb zufällig erzeugt.
|
|
messageNonce := make([]byte, authenticatedCipher.NonceSize())
|
|
if _, randomError := rand.Read(messageNonce); randomError != nil {
|
|
return nil, "", fmt.Errorf("es konnte keine sichere nonce erzeugt werden: %w", randomError)
|
|
}
|
|
|
|
// Seal hängt den Geheimtext an die Nonce an, sodass beides zusammen bleibt.
|
|
sealedSecret := authenticatedCipher.Seal(messageNonce, messageNonce, plaintext, nil)
|
|
|
|
return sealedSecret, secretStore.currentKeyVersion, nil
|
|
}
|
|
|
|
// Decrypt entschlüsselt einen Geheimtext der angegebenen Schlüsselversion.
|
|
func (secretStore *LocalSecretStore) Decrypt(ciphertext []byte, keyVersion string) ([]byte, error) {
|
|
authenticatedCipher, hasKey := secretStore.keysByVersion[keyVersion]
|
|
if !hasKey {
|
|
return nil, fmt.Errorf("%w (version %q)", ErrUnknownKeyVersion, keyVersion)
|
|
}
|
|
|
|
nonceSize := authenticatedCipher.NonceSize()
|
|
if len(ciphertext) < nonceSize {
|
|
return nil, ErrSecretTampered
|
|
}
|
|
|
|
messageNonce := ciphertext[:nonceSize]
|
|
sealedPayload := ciphertext[nonceSize:]
|
|
|
|
plaintext, openError := authenticatedCipher.Open(nil, messageNonce, sealedPayload, nil)
|
|
if openError != nil {
|
|
// GCM meldet hier jede Veränderung des Geheimtextes. Der Fehler wird
|
|
// bewusst nicht durchgereicht, da er keine verwertbare Information trägt.
|
|
return nil, ErrSecretTampered
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// ParseKeySet liest eine Schlüsselliste aus ihrer Konfigurationsdarstellung.
|
|
//
|
|
// Erwartet wird eine kommaseparierte Liste aus Version und base64-kodiertem
|
|
// Schlüssel, jüngste Version zuerst:
|
|
//
|
|
// v1:<base64>,v2:<base64>
|
|
func ParseKeySet(encodedKeySet string) (map[string][]byte, error) {
|
|
if strings.TrimSpace(encodedKeySet) == "" {
|
|
return nil, errors.New("die schlüsselliste ist leer")
|
|
}
|
|
|
|
parsedKeys := make(map[string][]byte)
|
|
|
|
for _, keyEntry := range strings.Split(encodedKeySet, ",") {
|
|
trimmedEntry := strings.TrimSpace(keyEntry)
|
|
if trimmedEntry == "" {
|
|
continue
|
|
}
|
|
|
|
keyVersion, encodedKey, hasSeparator := strings.Cut(trimmedEntry, ":")
|
|
if !hasSeparator {
|
|
return nil, errors.New("ein eintrag der schlüsselliste hat nicht die form version:base64schlüssel")
|
|
}
|
|
|
|
keyVersion = strings.TrimSpace(keyVersion)
|
|
if keyVersion == "" {
|
|
return nil, errors.New("ein eintrag der schlüsselliste hat keine version")
|
|
}
|
|
|
|
keyMaterial, decodeError := base64.StdEncoding.DecodeString(strings.TrimSpace(encodedKey))
|
|
if decodeError != nil {
|
|
// Der fehlerhafte Wert selbst wird nicht ausgegeben: er ist ein Geheimnis.
|
|
return nil, fmt.Errorf("der schlüssel der version %q ist kein gültiges base64", keyVersion)
|
|
}
|
|
|
|
if len(keyMaterial) != masterKeyLength {
|
|
return nil, fmt.Errorf("der schlüssel der version %q hat %d byte, erforderlich sind %d", keyVersion, len(keyMaterial), masterKeyLength)
|
|
}
|
|
|
|
parsedKeys[keyVersion] = keyMaterial
|
|
}
|
|
|
|
if len(parsedKeys) == 0 {
|
|
return nil, errors.New("die schlüsselliste enthält keinen gültigen schlüssel")
|
|
}
|
|
|
|
return parsedKeys, nil
|
|
}
|
|
|
|
// GenerateMasterKey erzeugt einen neuen zufälligen Hauptschlüssel in base64.
|
|
//
|
|
// Die Funktion dient der Erstinbetriebnahme; erzeugte Schlüssel müssen sicher
|
|
// verwahrt werden, da ohne sie keine verschlüsselten Werte lesbar sind.
|
|
func GenerateMasterKey() (string, error) {
|
|
keyMaterial := make([]byte, masterKeyLength)
|
|
if _, randomError := rand.Read(keyMaterial); randomError != nil {
|
|
return "", fmt.Errorf("es konnte kein sicherer schlüssel erzeugt werden: %w", randomError)
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(keyMaterial), nil
|
|
}
|