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 }