syncova-backup/packages/platform/crypto/secret.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

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
}