Tenant-KEK-Rotation ohne Neuverschlüsselung des Archivbestands (Envelope-Encryption bleibt aus ARC-02 unverändert, Objekt-DEKs werden nicht angefasst). Core (API-10, RotateTenantKEK) ersetzt den Tenant-KEK durch einen neuen Wert und hält keine Historie vor — TenantKEKHandler liefert immer nur den aktuellen Schlüssel. Damit Mail Altbestand nach einer Rotation weiterhin lesen kann, versioniert Mail selbst jeden bezogenen Tenant-KEK: - crypto/kekversions.go: KEKVersionStore, lokal verschlüsselt mit eigenem Wrap-Schlüssel (nur über Umgebungsvariable), erkennt Rotation automatisch (RecordIfNew), erlaubt gezieltes Sperren einer Version (Revoke). - crypto/service.go: Service.WithVersionStore (optional, Open bleibt für Rückwärtskompatibilität unverändert), Seal zeichnet die verwendete KEK-Version auf, neue Methode OpenAtVersion liest mit historischer statt aktueller Version. - encstorage.go: neuer .dek.version-Sidecar (gleiches Muster wie der bestehende .dek-Sidecar), GetDecrypted nutzt OpenAtVersion; fehlender Sidecar (Altobjekte vor ARC-08) fällt auf Version 0 zurück, identisches Verhalten wie vorher. Prüfungen (alle real durchgeführt, siehe mail/docs/ARC-08-PRUEFPROTOKOLL.md): 1. TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation: Altbestand nach realer Rotation weiterhin lesbar über OpenAtVersion, naives Open mit dem neuen Schlüssel schlägt für das alte Objekt real fehl. 2. TestRotation_CompromisedOldKeyCanBeRevoked: gesperrte Version blockiert Lesezugriff real, andere Versionen bleiben unberührt. 3. Rotationsvorgang vollständig durchgespielt (siehe Prüfprotokoll). Kein Umbau: storage/dedup/indexworker/search unverändert, bestehende ARC-02-Tests (encstorage_test.go) unverändert weiterhin grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
139 lines
5.4 KiB
Go
139 lines
5.4 KiB
Go
// Package encstorage implementiert ARC-02s Anbindung an ARC-01: jedes
|
|
// archivierte Objekt wird VOR dem Schreiben über
|
|
// mail/internal/storage.Service verschlüsselt (Akzeptanzkriterium 1).
|
|
// Kombiniert bewusst zwei bereits fertige, unveränderte Pakete
|
|
// (mail/internal/storage aus ARC-01, mail/internal/crypto aus ARC-02)
|
|
// statt eines davon zu erweitern — kein Umbau angrenzender Bereiche.
|
|
//
|
|
// Reihenfolge beachtet (Ticket "Bekannte Fehler vermeiden"): ein
|
|
// SHA-256-Hash für Dublettenerkennung (ARC-03) muss auf dem KLARTEXT
|
|
// berechnet werden, BEVOR diese Verschlüsselung angewendet wird — das
|
|
// ist Sache des Aufrufers (ARC-03, außerhalb dieses Pakets), dieses
|
|
// Paket nimmt bereits fertigen Klartext entgegen und verschlüsselt ihn
|
|
// sofort, hält ihn nicht länger als nötig im Speicher.
|
|
package encstorage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
|
)
|
|
|
|
// wrappedDEKKey ist der Sidecar-Objektschlüssel für den mit dem
|
|
// Tenant-KEK verpackten DEK — liegt im selben Storage-Backend wie der
|
|
// Chiffretext, analog zum Prüfsummen-Sidecar aus ARC-01.
|
|
func wrappedDEKKey(key string) string {
|
|
return key + ".dek"
|
|
}
|
|
|
|
// kekVersionKey ist der Sidecar-Objektschlüssel für die Tenant-KEK-
|
|
// Version, mit der der DEK verpackt wurde (ARC-08 Akzeptanzkriterium 3).
|
|
// Fehlt dieser Sidecar (vor ARC-08 geschriebene Objekte), wird Version 0
|
|
// angenommen — GetDecrypted verhält sich dann wie vor ARC-08.
|
|
func kekVersionKey(key string) string {
|
|
return key + ".dek.version"
|
|
}
|
|
|
|
// Service verbindet Storage (ARC-01) und Crypto (ARC-02): der Rest von
|
|
// Mail ruft AUSSCHLIESSLICH diesen Service auf, nie storage.Service
|
|
// direkt mit Klartext — das verhindert einen Schreibpfad, der die
|
|
// Verschlüsselung vergisst (Akzeptanzkriterium 1).
|
|
type Service struct {
|
|
storage *storage.Service
|
|
crypto *crypto.Service
|
|
}
|
|
|
|
func NewService(storageSvc *storage.Service, cryptoSvc *crypto.Service) *Service {
|
|
return &Service{storage: storageSvc, crypto: cryptoSvc}
|
|
}
|
|
|
|
// Put verschlüsselt plaintext (Akzeptanzkriterium 1/2: neuer DEK je
|
|
// Objekt, mit dem per API-12 bezogenen Tenant-KEK verpackt) und legt
|
|
// Chiffretext + verpackten DEK über storage.Service ab (Prüfsumme,
|
|
// Nutzungsmeldung — ARC-01 unverändert wiederverwendet).
|
|
func (s *Service) Put(ctx context.Context, tenantSlug, key string, plaintext io.Reader, contentType string) error {
|
|
env, err := s.crypto.Seal(ctx, tenantSlug, plaintext)
|
|
if err != nil {
|
|
return fmt.Errorf("encstorage: verschlüsseln: %w", err)
|
|
}
|
|
ciphertext, err := io.ReadAll(env.Ciphertext)
|
|
if err != nil {
|
|
return fmt.Errorf("encstorage: chiffretext lesen: %w", err)
|
|
}
|
|
if _, err := s.storage.Put(ctx, key, bytes.NewReader(ciphertext), int64(len(ciphertext)), contentType); err != nil {
|
|
return fmt.Errorf("encstorage: chiffretext speichern: %w", err)
|
|
}
|
|
if _, err := s.storage.Put(ctx, wrappedDEKKey(key), bytes.NewReader(env.WrappedDEK), int64(len(env.WrappedDEK)), "application/octet-stream"); err != nil {
|
|
return fmt.Errorf("encstorage: verpackten dek speichern: %w", err)
|
|
}
|
|
if env.KEKVersion != 0 {
|
|
versionBytes := []byte(strconv.Itoa(env.KEKVersion))
|
|
if _, err := s.storage.Put(ctx, kekVersionKey(key), bytes.NewReader(versionBytes), int64(len(versionBytes)), "text/plain"); err != nil {
|
|
return fmt.Errorf("encstorage: kek-version speichern: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetDecrypted liest Chiffretext (über storage.GetVerified — ARC-01s
|
|
// Prüfsummenverifikation wird dabei mitgenutzt) und verpackten DEK,
|
|
// entpackt den DEK mit dem AKTUELLEN Tenant-KEK und entschlüsselt den
|
|
// Inhalt (Akzeptanzkriterium 3: nur mit gültigem, mandantenbezogenem
|
|
// Schlüssel möglich).
|
|
func (s *Service) GetDecrypted(ctx context.Context, tenantSlug, key string) ([]byte, error) {
|
|
ciphertext, err := s.storage.GetVerified(ctx, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encstorage: chiffretext lesen: %w", err)
|
|
}
|
|
dekReader, err := s.storage.Get(ctx, wrappedDEKKey(key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err)
|
|
}
|
|
defer func() { _ = dekReader.Close() }()
|
|
wrappedDEK, err := io.ReadAll(dekReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err)
|
|
}
|
|
|
|
kekVersion, err := s.readKEKVersion(ctx, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
plaintextReader, err := s.crypto.OpenAtVersion(ctx, tenantSlug, kekVersion, wrappedDEK, bytes.NewReader(ciphertext))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return io.ReadAll(plaintextReader)
|
|
}
|
|
|
|
// readKEKVersion liest den Versions-Sidecar (ARC-08). Fehlt er (vor
|
|
// ARC-08 geschriebene Objekte, oder ein Seal ohne konfigurierten
|
|
// KEKVersionStore), gilt Version 0 — crypto.Service.OpenAtVersion fällt
|
|
// dafür auf das unveränderte Open-Verhalten zurück.
|
|
func (s *Service) readKEKVersion(ctx context.Context, key string) (int, error) {
|
|
reader, err := s.storage.Get(ctx, kekVersionKey(key))
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return 0, nil
|
|
}
|
|
return 0, fmt.Errorf("encstorage: kek-version lesen: %w", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
raw, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encstorage: kek-version lesen: %w", err)
|
|
}
|
|
version, err := strconv.Atoi(string(raw))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encstorage: kek-version parsen: %w", err)
|
|
}
|
|
return version, nil
|
|
}
|