diff --git a/connect/docs/QA-04-PRUEFPROTOKOLL.md b/connect/docs/QA-04-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..96466dc1 --- /dev/null +++ b/connect/docs/QA-04-PRUEFPROTOKOLL.md @@ -0,0 +1,90 @@ +# QA-04 – Sicherheitsprüfung Drittsystem-Credentials: Prüfprotokoll + +Basis: `feature/eng-07-drittsystem-zugangsdaten-ber-core-tenant-kek-hierarchie-verschl-sseln` (PR #313, zum Prüfzeitpunkt noch nicht in `main` gemergt). Getestet auf Entwicklungshost 204, echte PostgreSQL-17-Instanz (`127.0.0.1:5432`), eigene Rolle/DB `qa04_connect_test` (eigens angelegt, Wegwerf-Schema je Testlauf über `connect/internal/testdb`). Core selbst läuft auf 204 nicht als Dienst; wo ein echter Core-Prozess gebraucht würde, wird — wie bereits in ENG-07/FDN-09 — Cores eigener, produktiver Handler-/Store-Code (`internal/kek`) direkt gegen die echte Testdatenbank ausgeführt, nicht nachgebaut. + +## Abhängigkeit ENG-07 (PR #313) + +`main` enthielt zum Prüfzeitpunkt nur ENG-02 (Verbindungs-Credential-Verwaltung mit austauschbarem, aber noch unimplementiertem `KEKProvider`). ENG-07 (Tenant-KEK-Bezug über Core API-10, `HTTPKEKProvider`) lag als unmerged Branch vor. Eine Prüfung gegen `main` allein hätte ein bereits überholtes Schema geprüft. Vorgehen: `git fetch origin feature/eng-07-...`, Arbeitsbranch `feature/qa-04-04-sicherheitspruefung-drittsystem-credentials` direkt von `origin/feature/eng-07-...` abgezweigt (dieser Branch ist zum Prüfzeitpunkt identisch mit `main` + genau einem ENG-07-Commit, Merge-Base = aktueller main-HEAD, daher kein Merge/Cherry-Pick nötig, keine Konflikte). `main` selbst wurde nicht verändert, kein Push dorthin versucht. + +## Prüfaufbau + +- Eigene Postgres-Rolle + DB `qa04_connect_test` auf Host 204 angelegt (owner-gleich, `CREATEDB`), damit Tests gegen eine echte, isolierte Instanz laufen (kein Mocking der DB). Pro Testfunktion ein frisches Schema (`connect/internal/testdb.Pool`). +- Core-Seite (`internal/kek`) mit `TEST_ADMIN_DSN` gegen dieselbe Postgres-Instanz getestet — echter `moduleregistry`/`tenant`-Code, kein Fake. +- Connect-Seite (`connect/internal/credential`, `connect/internal/crypto`) mit `TEST_TENANT_DSN` getestet; der Core-HTTP-Vertrag (`TenantKEKHandler`) wird dabei über `httptest.Server` nachgebildet (identischer Vertrag, derselbe Kompromiss wie in ENG-07/FDN-09 dokumentiert — kein separat zu betreibender Core-Prozess im Checkout verfügbar). + +## Akzeptanzkriterien — Ergebnis + +1. **Verschlüsselung der Zugangsdaten besteht die Sicherheitsprüfung.** Erfüllt. AES-256-GCM-Envelope-Verschlüsselung, frischer DEK je Secret, Tenant-KEK wird bei jedem Seal/Open frisch von Core bezogen (nie gecacht). Rohe DB-Spalten enthalten nachweislich keinen Klartext; falscher Tenant-KEK, manipulierter Ciphertext und nicht erreichbarer KEK-Dienst scheitern alle kontrolliert (fail-closed). +2. **Zugriff auf Zugangsdaten ist auf berechtigte Rollen beschränkt.** Erfüllt für die aktuell existierende Angriffsfläche. Der einzige Weg an einen Tenant-KEK ist Cores `TenantKEKHandler`: Service-Credential-Authentifizierung PLUS Modul-Aktivierungsprüfung für genau diesen Mandanten (dieselbe Prüfung, die auch DMS/Mail absichert — kein Connect-eigener Zweitpfad). Hinweis: Connect selbst definiert aktuell keine eigene RBAC-Permission für Credential-Zugriff (z. B. `connect.credential.read`) und besitzt keinen HTTP-Endpunkt, der Credentials exponiert — `credential.Store` wird bislang von keinem `cmd/`-Binary produktiv konstruiert (bereits im ENG-07-Protokoll als offen vermerkt). Die Zugriffskontrolle ist also korrekt, aber bislang nur auf Service-Ebene (Core-Modul-Aktivierung) scharf, nicht auf Endnutzer-Rollenebene — relevant erst, sobald eine Credential-Verwaltungsoberfläche entsteht (siehe „Empfehlung" unten). +3. **Schlüsselverwaltung folgt dokumentierten Sicherheitsvorgaben.** Erfüllt NACH Behebung eines während der Prüfung gefundenen Lückenfalls (siehe Befund unten): Rotation des Tenant-KEK ist jetzt ohne Datenverlust möglich; Master-Key-Rotation (Core-seitig) verändert keinen Tenant-KEK-Klartext; jeder Tenant-KEK ist eindeutig pro Mandant, Master-Key wird nie persistiert. + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ziel | Ergebnis | +|---|---|---|---| +| 1 | Verschlüsselungsprüfung gegen Datenbankinhalt | nur Chiffrat sichtbar, korrekte AES-256-GCM-Struktur, kein Klartextfragment | **bestanden** | +| 2 | Zugriffsprüfung mit unberechtigter Rolle | Zugriff wird abgewiesen | **bestanden** | +| 3 | Schlüsselrotation im Test ohne Datenverlust | Credential nach Rotation weiterhin entschlüsselbar | **bestanden** (nach Fix, siehe Befund) — vor dem Fix **fehlgeschlagen** (gemessen, siehe unten) | + +### Prüfung 1 — Verschlüsselung (Evidenz) + +- `TestStore_CoreKEK_SecretsAreEncryptedAtRest`, `TestStore_SecretsAreEncryptedAtRest`, `TestStore_RawColumnHasNoPlaintext` (bestehend, gegen echte DB erneut ausgeführt): roh gelesene `ciphertext`-Spalte enthält weder Klartext-Marker noch JSON-Feldnamen. +- Eigene, unabhängige Nachprüfung (ad-hoc, nicht Teil des committeten Diffs — reine Zusatzmessung): `ciphertext`-Länge = 12 (GCM-Nonce) + 16 (Auth-Tag) + Klartextlänge, `wrapped_dek`-Länge = 12+16+32 (DEK-Größe) — Byteebene bestätigt AES-256-GCM-Envelope exakt wie dokumentiert, kein Overhead/Leck. +- `TestStore_CoreKEK_WrongTenantKEK_Fails`, `TestOpen_WrongTenantKEK_Fails`, `TestSeal_TamperedCiphertext_FailsOnOpen`, `TestSeal_KEKUnavailable`: falscher Tenant-KEK, manipuliertes Chiffrat, nicht erreichbarer KEK-Dienst scheitern alle kontrolliert (fail-closed, keine stille Degradierung). + +### Prüfung 2 — Zugriffskontrolle (Evidenz) + +Echte, DB-gestützte Core-Tests (`internal/kek`, `TEST_ADMIN_DSN`, echte `moduleregistry`/`tenant`-Pakete, kein Fake): + +- `TestTenantKEKHandler_RejectsMissingCredential` — **bestanden** (403 ohne Service-Credential) +- `TestTenantKEKHandler_RejectsModuleNotActiveForTenant` — **bestanden** (403 bei ungültigem/falschem Secret bzw. nicht aktiviertem Modul für diesen Mandanten — die „unberechtigte Rolle" in diesem Architekturmodell) +- `TestTenantKEKHandler_AllowsActiveModuleForTenant` — **bestanden** (200 nur für authentifiziertes, für den Mandanten aktives Modul) +- `TestTenantKEKHandler_RecordsPrivilegedMasterKeyAccessAuditEvent` — **bestanden** (jeder Zugriff erzeugt einen eigenen, hochprivilegierten Audit-Event) + +Bewertung: Für die Connect-Seite gilt dieselbe Zugriffskontrolle automatisch mit, weil `HTTPKEKProvider` denselben Vertrag spricht — kein eigener, schwächerer Pfad. Die Prüfung erfolgte bewusst gegen Cores eigenen, produktiven Handler-Code (nicht gegen eine Nachbildung), um keinen Prüf-Fake statt der echten Zugriffskontrolle zu testen. + +### Prüfung 3 — Schlüsselrotation (Befund + Fix) + +**Befund (vor Fix, real gemessen):** `connect/internal/crypto` holt den Tenant-KEK bei jedem `Seal`/`Open` frisch von Core — das ist korrekt für Frische, bedeutet aber: nach einer Core-seitigen Tenant-KEK-Rotation (`internal/kek.Store.RotateTenantKEK`, bereits korrekt implementiert und isoliert pro Mandant, siehe `TestRotateTenantKEK_OnlyAffectsThatTenant`, real gegen DB bestätigt) liefert Core ab sofort nur noch den NEUEN Schlüssel. Jede bereits gespeicherte `connector_credentials`-Zeile wurde aber mit dem ALTEN Tenant-KEK verpackt (`wrapped_dek`) — ohne Rewrap-Mechanismus wird sie damit dauerhaft nicht mehr entschlüsselbar. Ad-hoc-Testlauf gegen die echte Testdatenbank (vor dem Fix, Test danach wieder entfernt) bestätigte das empirisch: + +``` +VOR Rotation: Put+Get erfolgreich +Core-Tenant-KEK rotiert (Simulation: Core liefert ab jetzt kekV2) +ERGEBNIS: Credential NACH Rotation NICHT mehr lesbar (ErrDecryptFailed) - +Connect hat keinen Rewrap-Mechanismus fuer Tenant-KEK-Rotation +``` + +Ursache: `connect/internal/crypto` übernimmt laut eigenem Paketkommentar "dieselben Primitiven wie `dms/internal/crypto` (FDN-09)" — aber ohne dessen `RewrapDEK`-Funktion zu übernehmen. DMS besitzt diese Funktion bereits (eigener Prüfnachweis dort: `TestKeyManagement_RewrapKeepsContentReadableWithNewKEKOnly`, `dms/internal/qa02`), Connect nicht. + +**Fix (kleinstmögliche Lösung, im Rahmen dieser Prüfung umgesetzt, analog DMS):** +- `connect/internal/crypto/envelope.go`: `RewrapDEK(oldKEK, newKEK, wrappedDEK)` — entpackt den DEK mit dem alten KEK, verpackt ihn neu mit dem neuen KEK; DEK und Objekt-Chiffrat bleiben unverändert. 1:1 der DMS-FDN-09-Vertrag. +- `connect/internal/credential/credential.go`: `Store.RewrapTenantKEK(ctx, tenantSlug, oldKEK, newKEK) (int, error)` — liest alle `connector_credentials`-Zeilen eines Mandanten, wendet `RewrapDEK` an, schreibt nur `wrapped_dek` zurück (kein Touch an `ciphertext`). + +**Evidenz nach Fix (committed, echte DB):** +- `TestStore_RewrapTenantKEK_CredentialSurvivesRotation` — **bestanden**: Credential vor Rotation lesbar, nach Core-Rotation ohne Rewrap nachweislich NICHT mehr lesbar (Zwischenassert im selben Test), nach `RewrapTenantKEK` wieder korrekt lesbar, Inhalt identisch. +- `TestStore_RewrapTenantKEK_OnlyTouchesGivenTenant` — **bestanden**: Rewrap eines Mandanten lässt das Credential eines anderen Mandanten unverändert lesbar (Isolation analog Cores `RotateTenantKEK`). +- `TestRewrapDEK_NewKEKOnlyKeepsWrappedDEKReadable`, `TestRewrapDEK_WrongOldKEK_Fails` (crypto-Ebene) — **bestanden**. + +## Gesamtlauf + +`gofmt -l` (sauber), `go vet ./...` (clean), `go build ./...` (clean), `go test ./... -p 1`: alle 25 testbaren Pakete `ok`, 0 FAIL — auf Host 204 gegen echte Postgres-Instanz (`qa04_connect_test`, eigens angelegte Rolle/DB, kein Mocking). `scripts/check-security-test-coverage.sh`: Merge-Gate bestanden (alle sicherheitsrelevanten Pakete inkl. `internal/crypto`, `internal/credential` haben Pflichttests). + +Core-seitig zusätzlich real gegen dieselbe Postgres-Instanz geprüft (`TEST_ADMIN_DSN`): `internal/kek` — `TestCreateForTenant_EachTenantGetsDistinctKEK`, `TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext`, `TestRotateTenantKEK_OnlyAffectsThatTenant`, `TestTenantKEKHandler_*` — alle **bestanden**. + +## QA und Entscheidungen + +- **Entscheidung (reversibel, dokumentiert, Iteration 1 von 20):** Den während der Prüfung gefundenen Rotations-Datenverlust direkt behoben statt nur als Folgeticket zu dokumentieren — kleinstmögliche, durch DMS/FDN-09 bereits präzedierte Lösung (reine Portierung einer existierenden, andernorts bereits bewährten Funktion plus einem schmalen Store-Methode, kein neuer Architekturentwurf). Begründung: Prüfung 3 ist explizit Teil der Abnahmekriterien dieses Tickets; ohne Fix wäre AC3 nicht erfüllbar gewesen und die Prüfung hätte mit „nicht freigegeben" enden müssen. +- **Nicht umgesetzt (bewusst, Scope-Grenze):** Ein automatischer Rotations-Trigger/Hintergrunddienst, der `RewrapTenantKEK` von sich aus aufruft, sobald Core einen Tenant-KEK rotiert (z. B. über einen Core-Webhook oder Polling). Das existiert auch bei DMS/Mail nicht (dort ebenfalls nur die Primitive, kein automatischer Treiber) — Connect hält damit Parität zum übrigen Projekt, baut aber keine neue, andernorts fehlende Automatisierung. Wer `RewrapTenantKEK` aufruft und wann (z. B. als Teil eines künftigen Kek-Rotations-Runbooks), ist nicht Bestandteil dieser Prüfung. +- **Nicht umgesetzt (außerhalb des Tickets):** Eine eigene RBAC-Permission für Connect-Credential-Zugriff (`connect.credential.*`) sowie ein HTTP-Endpunkt dafür — beides existiert noch nicht, weil `credential.Store` bislang kein `cmd/`-Binary produktiv verdrahtet (bereits in ENG-07 offen vermerkt). Empfehlung an ein künftiges Ticket (API-01 o. ä.): sobald eine Credential-Verwaltungsoberfläche/API entsteht, muss sie dem bestehenden Muster aus `statusapi` folgen (`requireIdentity` + `requirePermission` gegen Core-RBAC) und darf NICHT annehmen, dass die Core-Modul-Aktivierungsprüfung allein als Rollen-Gate für Endnutzer ausreicht — das ist ein Service-zu-Service-, kein Endnutzer-Rollen-Mechanismus. +- 1 von 20 erlaubten Iterationen für Fehlerbehebung genutzt (Rotations-Rewrap-Lücke). + +## Gesamtergebnis + +AC1 und AC2 erfüllt. AC3 nach Fix erfüllt. Alle drei Prüfungen vor der Abnahme bestanden (Prüfung 3 nach Behebung des während dieser Prüfung gefundenen Befunds). Keine Freigabe auf Zuruf — alle drei Aussagen sind durch echte Testläufe gegen eine reale PostgreSQL-Instanz auf Host 204 belegt (Protokoll-Ausgaben oben). + +**Offen für Folgearbeit (nicht Bestandteil dieser Kachel, zur Entscheidung vorgelegt):** +1. Produktive Verdrahtung von `credential.Store`/`HTTPKEKProvider` in ein `cmd/`-Binary (bereits aus ENG-07 offen). +2. RBAC-Permission + HTTP-Endpunkt für Connect-Credential-Verwaltung, sobald eine entsprechende Oberfläche gebaut wird — mit Rollenprüfung nach `statusapi`-Muster. +3. Ein Runbook/Trigger, der `RewrapTenantKEK` tatsächlich aufruft, wenn Core einen Tenant-KEK rotiert (die Fähigkeit existiert jetzt, der Aufrufer/Anlass noch nicht — wie bei DMS/Mail auch). + +`QA-05` (Abnahme-Prüfung Connect) ist explizit nicht Bestandteil dieser Kachel. diff --git a/connect/internal/credential/credential.go b/connect/internal/credential/credential.go index 871472b3..c09df97a 100644 --- a/connect/internal/credential/credential.go +++ b/connect/internal/credential/credential.go @@ -154,6 +154,54 @@ func (s *Store) Revoke(ctx context.Context, ref Ref) error { return nil } +// RewrapTenantKEK re-wraps every stored credential's DEK for tenantSlug +// from oldKEK to newKEK after a Core tenant-KEK rotation (QA-04 Pruefung +// 3) — without it, credentials become permanently undecryptable the +// moment Core rotates that tenant's KEK, since crypto.Service always +// fetches the CURRENT tenant KEK (never the one a row was wrapped under). +func (s *Store) RewrapTenantKEK(ctx context.Context, tenantSlug string, oldKEK, newKEK []byte) (int, error) { + rows, err := s.pool.Query(ctx, ` + SELECT connector_name, connector_instance_id, wrapped_dek FROM connector_credentials + WHERE tenant_slug = $1 + `, tenantSlug) + if err != nil { + return 0, fmt.Errorf("credential: list for rewrap: %w", err) + } + type row struct { + connectorName, instanceID string + wrappedDEK []byte + } + var all []row + for rows.Next() { + var r row + if err := rows.Scan(&r.connectorName, &r.instanceID, &r.wrappedDEK); err != nil { + rows.Close() + return 0, fmt.Errorf("credential: scan for rewrap: %w", err) + } + all = append(all, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + + var rewrapped int + for _, r := range all { + newWrapped, err := crypto.RewrapDEK(oldKEK, newKEK, r.wrappedDEK) + if err != nil { + return rewrapped, fmt.Errorf("credential: rewrap %s/%s: %w", r.connectorName, r.instanceID, err) + } + if _, err := s.pool.Exec(ctx, ` + UPDATE connector_credentials SET wrapped_dek = $4, updated_at = now() + WHERE tenant_slug = $1 AND connector_name = $2 AND connector_instance_id = $3 + `, tenantSlug, r.connectorName, r.instanceID, newWrapped); err != nil { + return rewrapped, fmt.Errorf("credential: update rewrapped %s/%s: %w", r.connectorName, r.instanceID, err) + } + rewrapped++ + } + return rewrapped, nil +} + // ExpiringBefore lists refs whose OAuth token expires before cutoff, for // the Refresher to pick up. func (s *Store) ExpiringBefore(ctx context.Context, cutoff time.Time) ([]Ref, error) { diff --git a/connect/internal/credential/kekrotation_test.go b/connect/internal/credential/kekrotation_test.go new file mode 100644 index 00000000..64233559 --- /dev/null +++ b/connect/internal/credential/kekrotation_test.go @@ -0,0 +1,149 @@ +package credential_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/credential" + connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb" +) + +// QA-04 Pruefung 3: ein Credential, das VOR einer Core-Tenant-KEK-Rotation +// gespeichert wurde, muss danach ueber Store.RewrapTenantKEK weiterhin +// lesbar sein — ohne Rewrap wuerde crypto.Service.Open fehlschlagen, weil +// es immer den AKTUELLEN Tenant-KEK von Core holt, nie den, unter dem eine +// Zeile urspruenglich verpackt wurde. +func TestStore_RewrapTenantKEK_CredentialSurvivesRotation(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + + dir, _ := os.Getwd() + migrations, err := migrate.Load(dir + "/../../migrations") + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + + kekV1 := bytes.Repeat([]byte{0x11}, connectcrypto.KEKSize) + kekV2 := bytes.Repeat([]byte{0x22}, connectcrypto.KEKSize) + var current atomic.Pointer[[]byte] + current.Store(&kekV1) + + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + kek := *current.Load() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"tenant_kek_base64": base64.StdEncoding.EncodeToString(kek)}) + })) + defer core.Close() + + provider := connectcrypto.NewHTTPKEKProvider(core.URL, "connect-service-client", "connect-service-secret", nil) + store := credential.NewStore(pool, connectcrypto.NewService(provider)) + + ref := credential.Ref{TenantSlug: "acme", ConnectorName: "test-connector", ConnectorInstanceID: "rotation-survives"} + if err := store.Put(ctx, ref, credential.KindAPIKey, credential.Secret{APIKey: "nach-rotation-noch-lesbar"}, nil); err != nil { + t.Fatalf("put vor rotation: %v", err) + } + + // Core rotiert den Tenant-KEK: ab jetzt liefert es kekV2. + current.Store(&kekV2) + + if _, _, _, err := store.Get(ctx, ref); err == nil { + t.Fatal("ohne rewrap sollte Get nach rotation fehlschlagen (sonst ist dieser test ueberfluessig)") + } + + n, err := store.RewrapTenantKEK(ctx, ref.TenantSlug, kekV1, kekV2) + if err != nil { + t.Fatalf("RewrapTenantKEK: %v", err) + } + if n != 1 { + t.Fatalf("rewrapped = %d, erwartet 1", n) + } + + secret, kind, _, err := store.Get(ctx, ref) + if err != nil { + t.Fatalf("get nach rewrap sollte funktionieren: %v", err) + } + if kind != credential.KindAPIKey || secret.APIKey != "nach-rotation-noch-lesbar" { + t.Fatalf("secret nach rewrap = %+v kind=%s, erwartet unveraendert", secret, kind) + } +} + +// Pruefung 3 (Isolation): RewrapTenantKEK darf nur Zeilen DIESES Mandanten +// anfassen — analog zu Cores RotateTenantKEK (betrifft ausschliesslich +// einen Mandanten). +func TestStore_RewrapTenantKEK_OnlyTouchesGivenTenant(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + dir, _ := os.Getwd() + migrations, err := migrate.Load(dir + "/../../migrations") + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + + kekAcmeV1 := bytes.Repeat([]byte{0x33}, connectcrypto.KEKSize) + kekAcmeV2 := bytes.Repeat([]byte{0x44}, connectcrypto.KEKSize) + kekBeta := bytes.Repeat([]byte{0x55}, connectcrypto.KEKSize) + + var acmeKEK atomic.Pointer[[]byte] + acmeKEK.Store(&kekAcmeV1) + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant := r.URL.Query().Get("tenant") + kek := kekBeta + if tenant == "acme" { + kek = *acmeKEK.Load() + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"tenant_kek_base64": base64.StdEncoding.EncodeToString(kek)}) + })) + defer core.Close() + + provider := connectcrypto.NewHTTPKEKProvider(core.URL, "x", "y", nil) + store := credential.NewStore(pool, connectcrypto.NewService(provider)) + + refAcme := credential.Ref{TenantSlug: "acme", ConnectorName: "test-connector", ConnectorInstanceID: "isolation-acme"} + refBeta := credential.Ref{TenantSlug: "beta", ConnectorName: "test-connector", ConnectorInstanceID: "isolation-beta"} + if err := store.Put(ctx, refAcme, credential.KindAPIKey, credential.Secret{APIKey: "acme-secret"}, nil); err != nil { + t.Fatalf("put acme: %v", err) + } + if err := store.Put(ctx, refBeta, credential.KindAPIKey, credential.Secret{APIKey: "beta-secret"}, nil); err != nil { + t.Fatalf("put beta: %v", err) + } + + acmeKEK.Store(&kekAcmeV2) + if _, err := store.RewrapTenantKEK(ctx, "acme", kekAcmeV1, kekAcmeV2); err != nil { + t.Fatalf("rewrap acme: %v", err) + } + + if _, _, _, err := store.Get(ctx, refAcme); err != nil { + t.Fatalf("acme nach eigenem rewrap sollte lesbar sein: %v", err) + } + gotBeta, _, _, err := store.Get(ctx, refBeta) + if err != nil { + t.Fatalf("beta sollte von acmes rotation unberuehrt bleiben: %v", err) + } + if gotBeta.APIKey != "beta-secret" { + t.Fatalf("beta secret veraendert: %+v", gotBeta) + } +} diff --git a/connect/internal/crypto/envelope.go b/connect/internal/crypto/envelope.go index 2abee0fd..27b9d540 100644 --- a/connect/internal/crypto/envelope.go +++ b/connect/internal/crypto/envelope.go @@ -80,6 +80,23 @@ func (s *Service) Open(ctx context.Context, tenantSlug string, sd *Sealed) ([]by return open(dek, sd.Ciphertext) } +// RewrapDEK re-wraps wrappedDEK from oldKEK to newKEK without touching the +// DEK or ciphertext it protects — QA-04 Pruefung 3: survives a Core +// tenant-KEK rotation. Mirrors dms/internal/crypto.RewrapDEK (FDN-09); this +// primitive was missing here even though envelope.go already documents +// following that package's design. +func RewrapDEK(oldKEK, newKEK, wrappedDEK []byte) ([]byte, error) { + dek, err := open(oldKEK, wrappedDEK) + if err != nil { + return nil, fmt.Errorf("crypto: dek mit altem kek entpacken: %w", err) + } + rewrapped, err := seal(newKEK, dek) + if err != nil { + return nil, fmt.Errorf("crypto: dek mit neuem kek verpacken: %w", err) + } + return rewrapped, nil +} + func seal(key, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { diff --git a/connect/internal/crypto/rewrap_test.go b/connect/internal/crypto/rewrap_test.go new file mode 100644 index 00000000..17d75a84 --- /dev/null +++ b/connect/internal/crypto/rewrap_test.go @@ -0,0 +1,50 @@ +package crypto + +import ( + "bytes" + "testing" +) + +func TestRewrapDEK_NewKEKOnlyKeepsWrappedDEKReadable(t *testing.T) { + oldKEK := bytes.Repeat([]byte{0x01}, KEKSize) + newKEK := bytes.Repeat([]byte{0x02}, KEKSize) + dek := bytes.Repeat([]byte{0x03}, DEKSize) + + wrapped, err := seal(oldKEK, dek) + if err != nil { + t.Fatalf("seal dek mit altem kek: %v", err) + } + + rewrapped, err := RewrapDEK(oldKEK, newKEK, wrapped) + if err != nil { + t.Fatalf("RewrapDEK: %v", err) + } + + if _, err := open(oldKEK, rewrapped); err == nil { + t.Fatal("alter kek sollte den neu verpackten wrapper nicht mehr oeffnen") + } + + got, err := open(newKEK, rewrapped) + if err != nil { + t.Fatalf("neuer kek sollte den neu verpackten wrapper oeffnen: %v", err) + } + if !bytes.Equal(got, dek) { + t.Fatalf("dek nach rewrap = %x, erwartet %x", got, dek) + } +} + +func TestRewrapDEK_WrongOldKEK_Fails(t *testing.T) { + oldKEK := bytes.Repeat([]byte{0x01}, KEKSize) + wrongKEK := bytes.Repeat([]byte{0xAA}, KEKSize) + newKEK := bytes.Repeat([]byte{0x02}, KEKSize) + dek := bytes.Repeat([]byte{0x03}, DEKSize) + + wrapped, err := seal(oldKEK, dek) + if err != nil { + t.Fatalf("seal: %v", err) + } + + if _, err := RewrapDEK(wrongKEK, newKEK, wrapped); err == nil { + t.Fatal("erwartet fehler bei falschem altem kek") + } +}