syncova-backup/packages/repository/datakey.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

169 lines
6.4 KiB
Go

package repository
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
)
// dataKeyFileName ist die Ablage des Repository-Datenschlüssels.
const dataKeyFileName = "data-key.json"
// dataKeyLength ist die Länge des Datenschlüssels (AES-256).
const dataKeyLength = 32
// DataKeyRecord beschreibt den verschlüsselten Datenschlüssel eines Repositorys.
//
// Der Schlüssel gehört zum **Repository**, nicht zu einem einzelnen Backup.
// Das ist zwingend: nur so kann ein späteres Backup Blöcke wiederverwenden, die
// ein früheres abgelegt hat. Ein Schlüssel je Backup machte jede Deduplizierung
// über Backupgrenzen hinweg unmöglich — die wiederverwendeten Blöcke wären mit
// einem anderen Schlüssel verschlüsselt und für das neue Backup unlesbar.
type DataKeyRecord struct {
// EncryptedKey ist der mit dem übergeordneten Schlüssel gesicherte Datenschlüssel.
EncryptedKey []byte `json:"encrypted_key"`
// KeyVersion benennt den zur Entschlüsselung nötigen übergeordneten Schlüssel.
KeyVersion string `json:"key_version"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
}
// SecretSealer verschlüsselt den Datenschlüssel eines Repositorys.
//
// Die Schnittstelle entspricht dem Ausschnitt des Secret Stores, den das
// Repository tatsächlich braucht.
type SecretSealer interface {
// Encrypt verschlüsselt einen Wert und meldet die verwendete Schlüsselversion.
Encrypt(plaintext []byte) (ciphertext []byte, keyVersion string, encryptError error)
// Decrypt entschlüsselt einen Wert der angegebenen Schlüsselversion.
Decrypt(ciphertext []byte, keyVersion string) ([]byte, error)
}
// ErrDataKeyUnavailable meldet einen nicht lesbaren Datenschlüssel.
//
// Ohne ihn sind alle verschlüsselten Blöcke des Repositorys unlesbar. Das ist
// der schwerste Betriebsfall überhaupt (PROMPT.md §142).
var ErrDataKeyUnavailable = errors.New("der datenschlüssel des repositorys ist nicht verfügbar")
// dataKeyPath liefert den Ablagepfad des Datenschlüssels.
func (localRepository *LocalRepository) dataKeyPath() string {
return filepath.Join(localRepository.rootPath, directoryMetadata, dataKeyFileName)
}
// LoadOrCreateDataKey liefert den Datenschlüssel des Repositorys.
//
// Beim ersten Aufruf entsteht er und wird verschlüsselt abgelegt; danach wird
// er stets derselbe geliefert. Er liegt im Repository, damit ein angehängtes
// Repository ohne Control Server lesbar bleibt — vorausgesetzt, der
// übergeordnete Schlüssel ist vorhanden.
func (localRepository *LocalRepository) LoadOrCreateDataKey(keyContext context.Context, secretSealer SecretSealer) ([]byte, error) {
existingKey, loadError := localRepository.loadDataKey(secretSealer)
if loadError == nil {
return existingKey, nil
}
if !errors.Is(loadError, os.ErrNotExist) {
return nil, loadError
}
// Ohne Schreibsperre könnten zwei Vorgänge unterschiedliche Schlüssel
// anlegen; der zweite machte die Blöcke des ersten unlesbar.
if localRepository.lockHandle == nil {
return nil, fmt.Errorf("%w: er müsste angelegt werden, das repository ist aber nur lesend geöffnet",
ErrDataKeyUnavailable)
}
generatedKey := make([]byte, dataKeyLength)
if _, randomError := rand.Read(generatedKey); randomError != nil {
return nil, fmt.Errorf("es konnte kein sicherer datenschlüssel erzeugt werden: %w", randomError)
}
encryptedKey, keyVersion, sealError := secretSealer.Encrypt(generatedKey)
if sealError != nil {
return nil, fmt.Errorf("der datenschlüssel konnte nicht gesichert werden: %w", sealError)
}
keyRecord := DataKeyRecord{
EncryptedKey: encryptedKey,
KeyVersion: keyVersion,
CreatedAt: localRepository.timeSource().UTC(),
}
encodedRecord, encodeError := jsonMarshalIndent(keyRecord)
if encodeError != nil {
return nil, encodeError
}
if writeError := writeFileAtomically(localRepository.dataKeyPath(), encodedRecord, dataFilePermissions); writeError != nil {
return nil, fmt.Errorf("der datenschlüssel konnte nicht abgelegt werden: %w", writeError)
}
// Der Datenschlüssel ist das lohnendste Ziel des gesamten Repositorys: Wer
// diese eine kleine Datei löscht, vernichtet jedes verschlüsselte Backup,
// ohne einen einzigen Datenblock anzufassen. In einem gehärteten Repository
// trägt er deshalb dasselbe Löschkennzeichen wie die Daten selbst.
localRepository.protectStoredChunk(localRepository.dataKeyPath())
localRepository.logger.Info("datenschlüssel für das repository angelegt",
slog.String("key_version", keyVersion))
return generatedKey, nil
}
// loadDataKey liest den abgelegten Datenschlüssel.
func (localRepository *LocalRepository) loadDataKey(secretSealer SecretSealer) ([]byte, error) {
rawRecord, readError := os.ReadFile(localRepository.dataKeyPath())
if readError != nil {
// Der Fehler wird unverändert weitergereicht, damit der Aufrufer
// "noch nicht vorhanden" von einem echten Problem unterscheiden kann.
return nil, readError
}
var keyRecord DataKeyRecord
if unmarshalError := json.Unmarshal(rawRecord, &keyRecord); unmarshalError != nil {
return nil, fmt.Errorf("%w: die ablage ist unlesbar", ErrDataKeyUnavailable)
}
dataKey, unsealError := secretSealer.Decrypt(keyRecord.EncryptedKey, keyRecord.KeyVersion)
if unsealError != nil {
return nil, fmt.Errorf("%w: der übergeordnete schlüssel der version %q fehlt oder passt nicht",
ErrDataKeyUnavailable, keyRecord.KeyVersion)
}
if len(dataKey) != dataKeyLength {
return nil, fmt.Errorf("%w: er hat eine unerwartete länge", ErrDataKeyUnavailable)
}
return dataKey, nil
}
// HasDataKey meldet, ob für dieses Repository bereits ein Datenschlüssel vorliegt.
func (localRepository *LocalRepository) HasDataKey() bool {
_, statError := os.Stat(localRepository.dataKeyPath())
return statError == nil
}
// DataKeyVersion liefert die Schlüsselversion des abgelegten Datenschlüssels.
//
// Sie wird gebraucht, um bei einer Rotation des übergeordneten Schlüssels zu
// erkennen, welche Fassung noch benötigt wird.
func (localRepository *LocalRepository) DataKeyVersion() (string, error) {
rawRecord, readError := os.ReadFile(localRepository.dataKeyPath())
if readError != nil {
return "", readError
}
var keyRecord DataKeyRecord
if unmarshalError := json.Unmarshal(rawRecord, &keyRecord); unmarshalError != nil {
return "", fmt.Errorf("%w: die ablage ist unlesbar", ErrDataKeyUnavailable)
}
return keyRecord.KeyVersion, nil
}