Files
nexarch/mail/internal/crypto/service.go
T
sysopsandClaude Sonnet 5 6b5cefc20f ARC-08: verschluesselungsschluessel-rotation
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
2026-08-31 11:17:23 +02:00

115 lines
4.1 KiB
Go

package crypto
import (
"context"
"fmt"
"io"
)
// Envelope ist das Ergebnis einer Seal-Operation: der Chiffretext-
// Stream plus der mit dem Tenant-KEK verpackte DEK, der zusammen mit
// dem Objekt persistiert werden muss (siehe mail/internal/encstorage).
// KEKVersion identifiziert (ARC-08), MIT welcher Tenant-KEK-Version der
// DEK verpackt wurde — 0, solange kein KEKVersionStore konfiguriert ist
// (Rückwärtskompatibilität, siehe WithVersionStore).
type Envelope struct {
Ciphertext io.Reader
WrappedDEK []byte
KEKVersion int
}
// Service verbindet KEKProvider mit den Envelope-Operationen — Aufrufer
// (mail/internal/encstorage) rufen ausschließlich Service auf, nie die
// Einzelfunktionen aus envelope.go direkt.
type Service struct {
kek KEKProvider
versions *KEKVersionStore
}
func NewService(kek KEKProvider) *Service {
return &Service{kek: kek}
}
// WithVersionStore aktiviert die Tenant-KEK-Versionsverfolgung (ARC-08).
// Ohne aufgerufenes WithVersionStore verhält sich Service exakt wie vor
// ARC-08 (KEKVersion bleibt 0, OpenAtVersion fällt auf Open zurück) —
// bestehende Aufrufer (z. B. encstorage) sind unverändert lauffähig.
func (s *Service) WithVersionStore(store *KEKVersionStore) *Service {
s.versions = store
return s
}
// Seal erzeugt einen neuen DEK (Akzeptanzkriterium 1), verschlüsselt
// plaintext damit und verpackt den DEK mit dem aktuellen Tenant-KEK
// (Akzeptanzkriterium 2 — der KEK wird bei JEDEM Aufruf frisch von Core
// bezogen, nie zwischengespeichert außer in der optionalen
// KEK-Versionshistorie für spätere Altbestands-Lesezugriffe).
func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Reader) (*Envelope, error) {
dek, err := GenerateDEK()
if err != nil {
return nil, err
}
ciphertext, err := EncryptStream(dek, plaintext)
if err != nil {
return nil, err
}
kek, err := s.kek.TenantKEK(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err)
}
wrappedDEK, err := WrapDEK(kek, dek)
if err != nil {
return nil, err
}
var kekVersion int
if s.versions != nil {
kekVersion, err = s.versions.RecordIfNew(ctx, tenantSlug, kek)
if err != nil {
return nil, fmt.Errorf("crypto: kek-version erfassen: %w", err)
}
}
return &Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK, KEKVersion: kekVersion}, nil
}
// Open entpackt den DEK mit dem AKTUELLEN Tenant-KEK (Akzeptanzkriterium
// 3: nur mit gültigem, mandantenbezogenem Schlüssel möglich — ein
// falscher Tenant-Slug liefert entweder einen falschen KEK von Core
// [dann schlägt UnwrapDEK fehl] oder Core verweigert den Zugriff direkt)
// und entschlüsselt ciphertext damit. Nach einer Tenant-KEK-Rotation bei
// Core funktioniert Open nur noch für Objekte, die mit dem NEUEN KEK
// versiegelt wurden — für Altbestand siehe OpenAtVersion.
func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
kek, err := s.kek.TenantKEK(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err)
}
dek, err := UnwrapDEK(kek, wrappedDEK)
if err != nil {
return nil, err
}
return DecryptStream(dek, ciphertext)
}
// OpenAtVersion entpackt den DEK mit der beim Seal aufgezeichneten
// historischen Tenant-KEK-Version statt mit dem aktuellen Core-KEK
// (ARC-08 Akzeptanzkriterium 3: Altbestand bleibt nach einer
// Hauptschlüssel-Rotation lesbar). Ist kekVersion 0 oder kein
// KEKVersionStore konfiguriert, verhält es sich wie Open (Rückwärts-
// kompatibilität für vor ARC-08 versiegelte Objekte).
func (s *Service) OpenAtVersion(ctx context.Context, tenantSlug string, kekVersion int, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
if kekVersion == 0 || s.versions == nil {
return s.Open(ctx, tenantSlug, wrappedDEK, ciphertext)
}
kek, err := s.versions.Get(ctx, tenantSlug, kekVersion)
if err != nil {
return nil, fmt.Errorf("crypto: historischen tenant-kek beziehen: %w", err)
}
dek, err := UnwrapDEK(kek, wrappedDEK)
if err != nil {
return nil, err
}
return DecryptStream(dek, ciphertext)
}