QA-04 (Connect): Sicherheitspruefung Drittsystem-Credentials #320

Closed
patrick wants to merge 1 commits from feature/qa-04-04-sicherheitspruefung-drittsystem-credentials into feature/eng-07-drittsystem-zugangsdaten-ber-core-tenant-kek-hierarchie-verschl-sseln
pull from: feature/qa-04-04-sicherheitspruefung-drittsystem-credentials
5 changed files with 354 additions and 0 deletions

No files matched your search

+90
View File
@@ -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.
+48
View File
@@ -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) {
@@ -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)
}
}
+17
View File
@@ -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 {
+50
View File
@@ -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")
}
}