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
132 lines
4.8 KiB
Go
132 lines
4.8 KiB
Go
// ARC-08: End-zu-Ende-Rotationstest. Nutzt einen fake KEKProvider (echter
|
|
// Testkonvention aus ARC-02, siehe encstorage_test.go) statt eines echten
|
|
// HTTP-Aufrufs an Core API-12 — Core selbst hat keine rotierbare
|
|
// Testschnittstelle über HTTP exponiert (nur der aktuelle KEK ist
|
|
// abrufbar), die Rotation wird hier auf Höhe der KEKProvider-Schnittstelle
|
|
// simuliert, exakt wie ARC-02 es für Fehlerfälle bereits tut.
|
|
package crypto
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// rotatableKEKProvider liefert für einen Mandanten einen aktuell
|
|
// gesetzten KEK, der zur Laufzeit "rotiert" werden kann (simuliert Core
|
|
// API-10s RotateTenantKEK, dessen Effekt auf API-12 exakt darin besteht,
|
|
// dass TenantKEK ab dann einen anderen Wert liefert).
|
|
type rotatableKEKProvider struct {
|
|
mu sync.Mutex
|
|
current []byte
|
|
}
|
|
|
|
func (p *rotatableKEKProvider) TenantKEK(_ context.Context, _ string) ([]byte, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.current, nil
|
|
}
|
|
|
|
func (p *rotatableKEKProvider) rotate(newKEK []byte) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.current = newKEK
|
|
}
|
|
|
|
// TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation ist die
|
|
// geforderte Pflichtprüfung 1: Rotation des Hauptschlüssels lässt
|
|
// Altbestand weiterhin lesbar.
|
|
func TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation(t *testing.T) {
|
|
tenant := "mandant-arc08-rotation-lesbar"
|
|
store := setupKEKVersionStore(t, tenant)
|
|
ctx := context.Background()
|
|
|
|
provider := &rotatableKEKProvider{current: bytes.Repeat([]byte{0x51}, KEKSize)}
|
|
svc := NewService(provider).WithVersionStore(store)
|
|
|
|
// Objekt VOR der Rotation versiegeln.
|
|
oldEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("altbestand vor rotation"))
|
|
if err != nil {
|
|
t.Fatalf("seal (alt): %v", err)
|
|
}
|
|
if oldEnvelope.KEKVersion != 1 {
|
|
t.Fatalf("erwartete kek-version 1 vor rotation, habe %d", oldEnvelope.KEKVersion)
|
|
}
|
|
oldCiphertext, err := io.ReadAll(oldEnvelope.Ciphertext)
|
|
if err != nil {
|
|
t.Fatalf("chiffretext (alt) lesen: %v", err)
|
|
}
|
|
|
|
// Core rotiert den Tenant-Hauptschlüssel — TenantKEK liefert ab jetzt
|
|
// einen komplett anderen Wert, exakt wie internal/kek.Store.
|
|
// RotateTenantKEK es real bei Core bewirkt.
|
|
provider.rotate(bytes.Repeat([]byte{0x52}, KEKSize))
|
|
|
|
// Objekt NACH der Rotation versiegeln (Akzeptanzkriterium 2: kein
|
|
// Neuverschlüsseln des Altbestands nötig, nur neue Objekte nutzen den
|
|
// neuen Schlüssel).
|
|
newEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("neuer inhalt nach rotation"))
|
|
if err != nil {
|
|
t.Fatalf("seal (neu): %v", err)
|
|
}
|
|
if newEnvelope.KEKVersion != 2 {
|
|
t.Fatalf("erwartete kek-version 2 nach rotation, habe %d", newEnvelope.KEKVersion)
|
|
}
|
|
|
|
// Altbestand bleibt über die aufgezeichnete Version lesbar
|
|
// (Akzeptanzkriterium 3).
|
|
openedOld, err := svc.OpenAtVersion(ctx, tenant, oldEnvelope.KEKVersion, oldEnvelope.WrappedDEK, bytes.NewReader(oldCiphertext))
|
|
if err != nil {
|
|
t.Fatalf("openatversion (alt, nach rotation): %v", err)
|
|
}
|
|
plainOld, err := io.ReadAll(openedOld)
|
|
if err != nil {
|
|
t.Fatalf("altbestand lesen: %v", err)
|
|
}
|
|
if string(plainOld) != "altbestand vor rotation" {
|
|
t.Fatalf("altbestand-inhalt stimmt nicht, habe %q", string(plainOld))
|
|
}
|
|
|
|
// Der naive Open() (aktueller KEK) darf für das ALTE Objekt inzwischen
|
|
// NICHT mehr funktionieren — das beweist, dass OpenAtVersion die
|
|
// Rotation tatsächlich überbrückt, statt zufällig auch so zu klappen.
|
|
if _, err := svc.Open(ctx, tenant, oldEnvelope.WrappedDEK, bytes.NewReader(oldCiphertext)); err == nil {
|
|
t.Fatal("erwartete fehler bei Open() des altbestands mit dem NEUEN aktuellen kek, habe nil")
|
|
}
|
|
}
|
|
|
|
// TestRotation_CompromisedOldKeyCanBeRevoked ist die geforderte
|
|
// Pflichtprüfung 2: kompromittierter alter Schlüssel kann gezielt
|
|
// gesperrt werden.
|
|
func TestRotation_CompromisedOldKeyCanBeRevoked(t *testing.T) {
|
|
tenant := "mandant-arc08-revoke-e2e"
|
|
store := setupKEKVersionStore(t, tenant)
|
|
ctx := context.Background()
|
|
|
|
provider := &rotatableKEKProvider{current: bytes.Repeat([]byte{0x61}, KEKSize)}
|
|
svc := NewService(provider).WithVersionStore(store)
|
|
|
|
compromisedEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("mit kompromittiertem schlüssel versiegelt"))
|
|
if err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
compromisedCiphertext, err := io.ReadAll(compromisedEnvelope.Ciphertext)
|
|
if err != nil {
|
|
t.Fatalf("chiffretext lesen: %v", err)
|
|
}
|
|
|
|
provider.rotate(bytes.Repeat([]byte{0x62}, KEKSize))
|
|
|
|
if err := store.Revoke(ctx, tenant, compromisedEnvelope.KEKVersion); err != nil {
|
|
t.Fatalf("kompromittierte version sperren: %v", err)
|
|
}
|
|
|
|
_, err = svc.OpenAtVersion(ctx, tenant, compromisedEnvelope.KEKVersion, compromisedEnvelope.WrappedDEK, bytes.NewReader(compromisedCiphertext))
|
|
if err == nil {
|
|
t.Fatal("erwartete fehler beim lesen mit gesperrter kek-version, habe nil")
|
|
}
|
|
}
|