diff --git a/mail/docs/ARC-08-PRUEFPROTOKOLL.md b/mail/docs/ARC-08-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..bad22e0 --- /dev/null +++ b/mail/docs/ARC-08-PRUEFPROTOKOLL.md @@ -0,0 +1,87 @@ +# ARC-08 – Prüfprotokoll: Verschlüsselungsschlüssel-Rotation + +Voraussetzung ARC-02 (Fertig). + +## Architektur-Ausgangslage (real geprüft) + +Core (API-10, `internal/kek.Store.RotateTenantKEK`, bereits Fertig) +ersetzt den Tenant-KEK bei Rotation durch einen komplett NEUEN Wert und +hält KEINE Historie vor — der laufende `kek-api`-Dienst (192.168.1.131, +Port 8102) exponiert ausschließlich `TenantKEKHandler`, der immer nur den +AKTUELLEN KEK liefert (real im Quelltext von +`/root/nexarch-code/internal/kek/handler.go` und `cmd/kek-api/main.go` +auf 131 verifiziert). Damit Mail nach einer Core-seitigen Rotation +Altbestand weiterhin lesen kann, MUSS Mail selbst jeden bezogenen +Tenant-KEK versioniert zwischenspeichern — das ist der Kern dieser +Kachel. + +## Umsetzung + +- `mail/internal/crypto/kekversions.go` — `KEKVersionStore`: persistiert + jede vom Core bezogene Tenant-KEK-Version lokal, verschlüsselt mit + einem eigenen, ausschließlich über Umgebungsvariable bezogenen + Wrap-Schlüssel (kein Klartext-KEK in der Datenbank). `RecordIfNew` + erkennt Rotation (neuer KEK-Wert ≠ letzter bekannter) und legt nur dann + eine neue Version an (Akzeptanzkriterium 1). `Revoke` sperrt gezielt + eine einzelne Version (Pflichtprüfung 2). +- `mail/internal/crypto/service.go` — `Service.WithVersionStore` + (optional, Rückwärtskompatibilität: ohne Aufruf verhält sich `Service` + exakt wie vor ARC-08). `Seal` zeichnet bei aktivierter Versionierung + die verwendete KEK-Version im `Envelope` auf. Neue Methode + `OpenAtVersion` entpackt mit der historischen statt der aktuellen + Tenant-KEK-Version (Akzeptanzkriterium 3) — `Open` bleibt unverändert + für Rückwärtskompatibilität. +- `mail/internal/encstorage/encstorage.go` — neuer Sidecar + `.dek.version` (gleiches Muster wie der bestehende `.dek`-Sidecar + aus ARC-02) speichert die KEK-Version je Objekt. `GetDecrypted` nutzt + jetzt `OpenAtVersion` statt `Open`; fehlt der Sidecar (vor ARC-08 + geschriebene Objekte), wird Version 0 angenommen (identisches + Verhalten wie vorher). +- Kein Umbau: `mail/internal/storage`/`mail/internal/dedup`/ + `mail/internal/indexworker`/`mail/internal/search` unverändert; + bestehende ARC-02-Tests (`encstorage_test.go`) unverändert lauffähig + ohne Codeänderung an ihnen. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test: Rotation des Hauptschlüssels lässt Altbestand weiterhin lesbar | **bestanden** – `TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation`: Objekt vor Rotation versiegelt (Version 1), Tenant-Hauptschlüssel real rotiert (Provider liefert ab dann einen anderen Wert, exakt wie `RotateTenantKEK` es bei Core bewirkt), neues Objekt nach Rotation versiegelt (Version 2), Altbestand über `OpenAtVersion` real weiterhin korrekt entschlüsselt — zusätzlich real bestätigt, dass der naive `Open()` mit dem neuen aktuellen KEK für das alte Objekt fehlschlägt (beweist, dass `OpenAtVersion` tatsächlich etwas leistet) | +| 2 | Test: kompromittierter alter Schlüssel kann gezielt gesperrt werden | **bestanden** – `TestRotation_CompromisedOldKeyCanBeRevoked`: Version gesperrt, `OpenAtVersion` liefert danach real `ErrKEKVersionRevoked`; `TestKEKVersionStore_RevokeBlocksOnlyThatVersion` bestätigt zusätzlich, dass eine ANDERE Version davon unberührt bleibt | +| 3 | Dokumentierter Rotationsvorgang wurde einmal vollständig durchgespielt | **bestanden** – siehe Abschnitt "Rotationsvorgang" unten, real durchlaufen als `TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation` | + +### Rotationsvorgang (Pflichtprüfung 3, vollständig durchgespielt) + +1. Objekt A wird mit Tenant-KEK-Version 1 versiegelt (`Seal`, Envelope + trägt `KEKVersion=1`, `KEKVersionStore` legt Version 1 real an). +2. Core rotiert den Tenant-Hauptschlüssel (in diesem Test durch den + `KEKProvider` simuliert, exakt am selben Punkt, an dem `Service` mit + dem echten `HTTPKEKProvider`/Core API-12 interagieren würde). +3. Objekt B wird versiegelt — automatisch mit der NEUEN Version 2, ohne + dass Objekt A angefasst wird (Akzeptanzkriterium 2: kein + Neuverschlüsseln des Bestands). +4. Objekt A wird über `OpenAtVersion(..., kekVersion=1, ...)` gelesen — + real erfolgreich, Klartext identisch zum Original. +5. Ein naiver Lesezugriff über `Open()` (aktueller KEK) auf Objekt A + schlägt real fehl — zeigt, dass ohne Versionsverfolgung der + Altbestand nach Rotation unlesbar geworden wäre. + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \ +TEST_MANTICORE_URL=http://127.0.0.1:9308 \ + go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/crypto (4 Tests, neu) + und internal/encstorage (4 Tests, unverändert weiterhin grün — Rückwärtskompatibilität + real bestätigt) +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Trägt (gemeinsam mit SRC-02, SRC-04, SRC-05, SRC-09) zu +QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch SRC-08 und SRC-10 +fertig sind. diff --git a/mail/internal/crypto/kekversions.go b/mail/internal/crypto/kekversions.go new file mode 100644 index 0000000..a414eb2 --- /dev/null +++ b/mail/internal/crypto/kekversions.go @@ -0,0 +1,144 @@ +// ARC-08: Tenant-KEK-Rotation. Core (API-10, `internal/kek.Store. +// RotateTenantKEK`) ersetzt den Tenant-KEK durch einen komplett neuen +// Wert — Core selbst hält KEINE Historie vor, `TenantKEKHandler` liefert +// immer nur den AKTUELLEN Schlüssel (siehe kekprovider.go). Damit ARC-08s +// Akzeptanzkriterium 3 ("alte Schlüsselversionen bleiben für +// Lesezugriff kontrolliert verfügbar") erfüllbar ist, muss Mail selbst +// jeden von Core bezogenen Tenant-KEK versioniert zwischenspeichern — +// KEKVersionStore übernimmt genau das, lokal mit einem eigenen, +// ausschließlich über Umgebungsvariable bezogenen Wrap-Schlüssel +// verschlüsselt (kein Klartext-KEK in der Datenbank). +package crypto + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrKEKVersionNotFound wird geliefert, wenn die angefragte Version für +// den Mandanten nicht existiert. +var ErrKEKVersionNotFound = errors.New("crypto: kek-version nicht gefunden") + +// ErrKEKVersionRevoked wird geliefert, wenn die angefragte Version gezielt +// gesperrt wurde (Pflichtprüfung 2: kompromittierter alter Schlüssel kann +// gezielt gesperrt werden) — der Lesezugriff auf mit dieser Version +// verschlüsselte Altobjekte ist dann bewusst blockiert. +var ErrKEKVersionRevoked = errors.New("crypto: kek-version wurde gesperrt") + +// KEKVersionStore verwaltet die Versionshistorie der Tenant-KEKs, die +// dieses Mail-Modul im Lauf der Zeit von Core bezogen hat. +type KEKVersionStore struct { + pool *pgxpool.Pool + localWrapKey []byte +} + +// NewKEKVersionStore erzeugt einen Store. localWrapKey verschlüsselt die +// zwischengespeicherten Tenant-KEKs lokal at rest (KEKSize Bytes, +// ausschließlich über Umgebungsvariable bezogen — nie im Code). +func NewKEKVersionStore(pool *pgxpool.Pool, localWrapKey []byte) *KEKVersionStore { + return &KEKVersionStore{pool: pool, localWrapKey: localWrapKey} +} + +// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert — +// gleiches Muster wie mail/internal/dedup/indexworker (kein zentraler +// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden). +func (s *KEKVersionStore) EnsureSchema(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS mail_kek_versions ( + tenant_slug TEXT NOT NULL, + version INT NOT NULL, + wrapped_kek BYTEA NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_slug, version) + ) + `); err != nil { + return fmt.Errorf("crypto: kek-versionsschema anlegen: %w", err) + } + return nil +} + +// RecordIfNew merkt sich plainKEK als neue Version für tenantSlug, FALLS +// er sich vom zuletzt gespeicherten Wert unterscheidet (Rotation +// erkannt) — bei unverändertem KEK wird keine neue Version angelegt, +// sondern die bestehende Versionsnummer zurückgegeben (Akzeptanzkriterium +// 1: rotierbar verwaltet, nicht bei jedem Aufruf eine neue Version). +func (s *KEKVersionStore) RecordIfNew(ctx context.Context, tenantSlug string, plainKEK []byte) (version int, err error) { + var latestVersion int + var latestWrapped []byte + err = s.pool.QueryRow(ctx, ` + SELECT version, wrapped_kek FROM mail_kek_versions + WHERE tenant_slug = $1 ORDER BY version DESC LIMIT 1 + `, tenantSlug).Scan(&latestVersion, &latestWrapped) + switch { + case errors.Is(err, pgx.ErrNoRows): + return s.insertVersion(ctx, tenantSlug, 1, plainKEK) + case err != nil: + return 0, fmt.Errorf("crypto: letzte kek-version lesen: %w", err) + } + + latestPlain, err := open(s.localWrapKey, latestWrapped) + if err != nil { + return 0, fmt.Errorf("crypto: zwischengespeicherten kek entpacken: %w", err) + } + if bytes.Equal(latestPlain, plainKEK) { + return latestVersion, nil + } + return s.insertVersion(ctx, tenantSlug, latestVersion+1, plainKEK) +} + +func (s *KEKVersionStore) insertVersion(ctx context.Context, tenantSlug string, version int, plainKEK []byte) (int, error) { + wrapped, err := seal(s.localWrapKey, plainKEK) + if err != nil { + return 0, fmt.Errorf("crypto: kek für zwischenspeicherung verpacken: %w", err) + } + if _, err := s.pool.Exec(ctx, ` + INSERT INTO mail_kek_versions (tenant_slug, version, wrapped_kek) VALUES ($1, $2, $3) + `, tenantSlug, version, wrapped); err != nil { + return 0, fmt.Errorf("crypto: kek-version speichern: %w", err) + } + return version, nil +} + +// Get liefert den entschlüsselten historischen Tenant-KEK einer +// bestimmten Version. Liefert ErrKEKVersionRevoked, wenn die Version +// gezielt gesperrt wurde (Pflichtprüfung 2). +func (s *KEKVersionStore) Get(ctx context.Context, tenantSlug string, version int) ([]byte, error) { + var wrapped []byte + var revoked bool + err := s.pool.QueryRow(ctx, ` + SELECT wrapped_kek, revoked FROM mail_kek_versions + WHERE tenant_slug = $1 AND version = $2 + `, tenantSlug, version).Scan(&wrapped, &revoked) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrKEKVersionNotFound + } + return nil, fmt.Errorf("crypto: kek-version lesen: %w", err) + } + if revoked { + return nil, ErrKEKVersionRevoked + } + return open(s.localWrapKey, wrapped) +} + +// Revoke sperrt eine Tenant-KEK-Version gezielt (Pflichtprüfung 2): +// nachfolgende Get-Aufrufe für genau diese Version schlagen mit +// ErrKEKVersionRevoked fehl, andere Versionen bleiben unberührt. +func (s *KEKVersionStore) Revoke(ctx context.Context, tenantSlug string, version int) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE mail_kek_versions SET revoked = true WHERE tenant_slug = $1 AND version = $2 + `, tenantSlug, version) + if err != nil { + return fmt.Errorf("crypto: kek-version sperren: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrKEKVersionNotFound + } + return nil +} diff --git a/mail/internal/crypto/kekversions_test.go b/mail/internal/crypto/kekversions_test.go new file mode 100644 index 0000000..2add571 --- /dev/null +++ b/mail/internal/crypto/kekversions_test.go @@ -0,0 +1,111 @@ +// Integrationstest (ARC-08): echte Postgres-Instanz, folgt derselben +// Testhost-Konvention wie mail/internal/dedup/indexworker — TEST_TENANT_DSN. +package crypto + +import ( + "bytes" + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var testLocalWrapKey = bytes.Repeat([]byte{0x7a}, KEKSize) + +func setupKEKVersionStore(t *testing.T, tenantSlug string) *KEKVersionStore { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + store := NewKEKVersionStore(pool, testLocalWrapKey) + if err := store.EnsureSchema(ctx); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM mail_kek_versions WHERE tenant_slug = $1`, tenantSlug) + }) + return store +} + +func TestKEKVersionStore_RecordIfNewDetectsRotationOnly(t *testing.T) { + tenant := "mandant-arc08-recordifnew" + store := setupKEKVersionStore(t, tenant) + ctx := context.Background() + + kekV1 := bytes.Repeat([]byte{0x01}, KEKSize) + v1, err := store.RecordIfNew(ctx, tenant, kekV1) + if err != nil { + t.Fatalf("erste erfassung: %v", err) + } + if v1 != 1 { + t.Fatalf("erwartete version 1, habe %d", v1) + } + + // Erneuter Aufruf mit UNVERÄNDERTEM KEK darf keine neue Version anlegen. + vAgain, err := store.RecordIfNew(ctx, tenant, kekV1) + if err != nil { + t.Fatalf("zweite erfassung (unverändert): %v", err) + } + if vAgain != 1 { + t.Fatalf("erwartete weiterhin version 1 bei unverändertem kek, habe %d", vAgain) + } + + kekV2 := bytes.Repeat([]byte{0x02}, KEKSize) + v2, err := store.RecordIfNew(ctx, tenant, kekV2) + if err != nil { + t.Fatalf("dritte erfassung (rotiert): %v", err) + } + if v2 != 2 { + t.Fatalf("erwartete version 2 nach rotation, habe %d", v2) + } + + gotV1, err := store.Get(ctx, tenant, 1) + if err != nil { + t.Fatalf("get v1: %v", err) + } + if !bytes.Equal(gotV1, kekV1) { + t.Fatal("v1 liefert nicht den ursprünglichen kek zurück") + } + gotV2, err := store.Get(ctx, tenant, 2) + if err != nil { + t.Fatalf("get v2: %v", err) + } + if !bytes.Equal(gotV2, kekV2) { + t.Fatal("v2 liefert nicht den rotierten kek zurück") + } +} + +func TestKEKVersionStore_RevokeBlocksOnlyThatVersion(t *testing.T) { + tenant := "mandant-arc08-revoke" + store := setupKEKVersionStore(t, tenant) + ctx := context.Background() + + kekV1 := bytes.Repeat([]byte{0x11}, KEKSize) + kekV2 := bytes.Repeat([]byte{0x22}, KEKSize) + if _, err := store.RecordIfNew(ctx, tenant, kekV1); err != nil { + t.Fatalf("v1 erfassen: %v", err) + } + if _, err := store.RecordIfNew(ctx, tenant, kekV2); err != nil { + t.Fatalf("v2 erfassen: %v", err) + } + + if err := store.Revoke(ctx, tenant, 1); err != nil { + t.Fatalf("v1 sperren: %v", err) + } + + if _, err := store.Get(ctx, tenant, 1); err != ErrKEKVersionRevoked { + t.Fatalf("erwartete ErrKEKVersionRevoked für gesperrte version 1, habe: %v", err) + } + if _, err := store.Get(ctx, tenant, 2); err != nil { + t.Fatalf("version 2 sollte unberührt bleiben: %v", err) + } +} diff --git a/mail/internal/crypto/rotation_test.go b/mail/internal/crypto/rotation_test.go new file mode 100644 index 0000000..a1755e4 --- /dev/null +++ b/mail/internal/crypto/rotation_test.go @@ -0,0 +1,131 @@ +// 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") + } +} diff --git a/mail/internal/crypto/service.go b/mail/internal/crypto/service.go index 90a77b8..c9884f3 100644 --- a/mail/internal/crypto/service.go +++ b/mail/internal/crypto/service.go @@ -9,26 +9,41 @@ import ( // 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 + 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). +// 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 { @@ -46,14 +61,25 @@ func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Read if err != nil { return nil, err } - return &Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK}, nil + + 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 +// 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. +// 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 { @@ -65,3 +91,24 @@ func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte } 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) +} diff --git a/mail/internal/encstorage/encstorage.go b/mail/internal/encstorage/encstorage.go index 896bd78..747bf71 100644 --- a/mail/internal/encstorage/encstorage.go +++ b/mail/internal/encstorage/encstorage.go @@ -16,8 +16,10 @@ 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" @@ -30,6 +32,14 @@ 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 @@ -62,6 +72,12 @@ func (s *Service) Put(ctx context.Context, tenantSlug, key string, plaintext io. 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 } @@ -85,9 +101,38 @@ func (s *Service) GetDecrypted(ctx context.Context, tenantSlug, key string) ([]b return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err) } - plaintextReader, err := s.crypto.Open(ctx, tenantSlug, wrappedDEK, bytes.NewReader(ciphertext)) + 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 +}