// Package pentest enthaelt QA-04 (Sicherheits- & Penetrationspruefung Core): // gezielte Angriffsversuche gegen die sensibelsten Core-Bereiche - SSO/OIDC, // Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb, zentrale // Policy-Durchsetzung und Master-Key-/Tenant-KEK-Verwaltung - jeweils gegen // die echten Produktionspakete, nicht gegen Mocks. package pentest import ( "context" "crypto/ed25519" "crypto/rand" "crypto/rsa" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "sync/atomic" "testing" "time" "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/kek" "gitea.perlbach24.de/scripte/nexarch/internal/lockout" "gitea.perlbach24.de/scripte/nexarch/internal/oidc" "gitea.perlbach24.de/scripte/nexarch/internal/policy" "gitea.perlbach24.de/scripte/nexarch/internal/ratelimit" "gitea.perlbach24.de/scripte/nexarch/internal/rbac" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" ) // --------------------------------------------------------------------- // Testinfrastruktur (identisch zum Muster aus internal/e2e/helpers_test.go) // --------------------------------------------------------------------- func requireAdminDSN(t *testing.T) string { t.Helper() dsn := os.Getenv("TEST_ADMIN_DSN") if dsn == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Pentest uebersprungen") } return dsn } func repoRoot(t *testing.T) string { t.Helper() wd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } return filepath.Join(wd, "..", "..") } func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err) } var names []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { names = append(names, e.Name()) } } sort.Strings(names) for _, name := range names { sqlBytes, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Fatalf("migration %q lesen: %v", name, err) } if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710" || pgErr.Code == "42723") { continue } t.Fatalf("migration %q anwenden: %v", name, err) } } } func setupRegistryPool(t *testing.T) (*pgxpool.Pool, string) { t.Helper() adminDSN := requireAdminDSN(t) ctx := context.Background() pool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("registry pool: %v", err) } t.Cleanup(func() { pool.Close() }) applyAllUpSQL(t, ctx, pool, filepath.Join(repoRoot(t), "migrations")) return pool, adminDSN } func uniqueSlug(prefix string) string { return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) } // ======================================================================= // Pruefung 2 / Akzeptanzkriterium 1: SSO/OIDC gegen Token-Manipulation, // Replay und Claims-Manipulation. // ======================================================================= // TestPentest_OIDC_AlgNoneAttack simuliert den klassischen "alg=none"-Angriff // gegen den EXTERNEN OIDC-Verifier (IAM-06, internal/oidc.Verifier): ein // Angreifer stellt sich selbst ein Token mit Signaturalgorithmus "none" aus, // in der Hoffnung, dass eine naive JWT-Bibliothek die fehlende Signatur // akzeptiert. internal/oidc.Verifier.Verify prueft explizit auf // *jwt.SigningMethodRSA - "none" muss zuverlaessig abgewiesen werden. func TestPentest_OIDC_AlgNoneAttack(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("rsa key: %v", err) } verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") claims := jwt.MapClaims{ "sub": "angreifer@example.test", "iss": "https://idp.example.test", "exp": time.Now().Add(time.Hour).Unix(), } unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, claims) unsigned.Header["kid"] = "kid-1" forged, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType) if err != nil { t.Fatalf("alg=none token faelschen: %v", err) } if _, err := verifier.Verify(forged); err == nil { t.Fatal("alg=none-token wurde akzeptiert - kritische luecke") } } // TestPentest_OIDC_ForeignKeySignature simuliert einen Angreifer, der ein // Token mit einem EIGENEN RSA-Schluessel signiert und die kid eines // bekannten, vertrauenswuerdigen Schluessels vorgibt (Schluessel-Confusion). // Verify darf das Token nur mit dem TATSAECHLICH registrierten Public Key // pruefen, niemals mit dem im Token mitgelieferten. func TestPentest_OIDC_ForeignKeySignature(t *testing.T) { trustedKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("trusted key: %v", err) } attackerKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("attacker key: %v", err) } verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"trusted-kid": &trustedKey.PublicKey}, "https://idp.example.test") claims := jwt.MapClaims{ "sub": "angreifer@example.test", "iss": "https://idp.example.test", "exp": time.Now().Add(time.Hour).Unix(), } forgedToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) forgedToken.Header["kid"] = "trusted-kid" // gibt vor, mit dem bekannten Schluessel signiert zu sein forged, err := forgedToken.SignedString(attackerKey) if err != nil { t.Fatalf("token mit fremdem schluessel signieren: %v", err) } if _, err := verifier.Verify(forged); err == nil { t.Fatal("mit fremdem schluessel signiertes token wurde akzeptiert - kritische luecke") } } // TestPentest_OIDC_TamperedClaims prueft, dass eine nachtraegliche // Claims-Manipulation (z.B. sub oder roles aendern, ohne neu zu signieren) // zuverlaessig an der Signaturpruefung scheitert. func TestPentest_OIDC_TamperedClaims(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("rsa key: %v", err) } verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") claims := jwt.MapClaims{ "sub": "normaler-benutzer@example.test", "iss": "https://idp.example.test", "roles": []string{"user"}, "exp": time.Now().Add(time.Hour).Unix(), } valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) valid.Header["kid"] = "kid-1" signed, err := valid.SignedString(rsaKey) if err != nil { t.Fatalf("token signieren: %v", err) } // Gegenprobe: das unveraenderte, echte Token wird akzeptiert. if _, err := verifier.Verify(signed); err != nil { t.Fatalf("unveraendertes, echtes token wurde abgewiesen: %v", err) } // Claims-Manipulation: Payload-Segment durch eine Version mit // erhoehter Rolle ersetzen, Signatur-Segment unveraendert lassen. parts := strings.Split(signed, ".") if len(parts) != 3 { t.Fatalf("unerwartetes token-format: %d segmente", len(parts)) } tamperedClaims := jwt.MapClaims{ "sub": "normaler-benutzer@example.test", "iss": "https://idp.example.test", "roles": []string{"superadmin"}, // Rechteausweitung "exp": time.Now().Add(time.Hour).Unix(), } tamperedHeader := jwt.NewWithClaims(jwt.SigningMethodRS256, tamperedClaims) tamperedHeader.Header["kid"] = "kid-1" tamperedUnsigned, err := tamperedHeader.SigningString() if err != nil { t.Fatalf("manipulierten payload kodieren: %v", err) } tamperedParts := strings.Split(tamperedUnsigned, ".") forged := tamperedParts[0] + "." + tamperedParts[1] + "." + parts[2] // fremder payload + ALTE signatur if _, err := verifier.Verify(forged); err == nil { t.Fatal("token mit manipulierten claims (rechteausweitung) wurde akzeptiert - kritische luecke") } } // TestPentest_OIDC_NonceReplay ist Pruefung 2 (Replay-Schutz): ein gueltiges, // korrekt signiertes ID-Token eines FRUEHEREN Logins darf nicht fuer einen // neuen Login-Vorgang mit einem anderen erwarteten Nonce wiederverwendet // werden koennen. func TestPentest_OIDC_NonceReplay(t *testing.T) { rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("rsa key: %v", err) } verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") claims := jwt.MapClaims{ "sub": "benutzer@example.test", "iss": "https://idp.example.test", "nonce": "nonce-aus-login-versuch-1", "exp": time.Now().Add(time.Hour).Unix(), } valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) valid.Header["kid"] = "kid-1" signed, err := valid.SignedString(rsaKey) if err != nil { t.Fatalf("token signieren: %v", err) } // Der urspruengliche Login-Versuch (passender Nonce) ist gueltig. if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-1"); err != nil { t.Fatalf("token mit korrektem nonce wurde abgewiesen: %v", err) } // Ein Angreifer faengt das Token ab und spielt es bei einem NEUEN // Login-Versuch (mit eigenem, anderem erwarteten Nonce) erneut ein. if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-2"); err == nil { t.Fatal("token-replay mit abweichendem nonce wurde akzeptiert - kritische luecke") } } // TestPentest_OIDC_IdPTokenManipulation prueft den Weg, bei dem NEXARCH // selbst als OIDC-Provider fuer Drittanwendungen auftritt (IAM-13, // internal/oidc.Handler.JWKS + issueIDToken, Ed25519/EdDSA). Ein Angreifer // versucht, ein selbst signiertes ID-Token gegen den echten, aus dem // JWKS-Endpunkt gelesenen Schluessel zu verifizieren. func TestPentest_OIDC_IdPTokenManipulation(t *testing.T) { genuinePub, genuinePriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("echtes schluesselpaar erzeugen: %v", err) } attackerPub, attackerPriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("angreifer-schluesselpaar erzeugen: %v", err) } _ = attackerPub claims := oidc.IDTokenClaims{ Subject: "opfer-user-id", TenantSlug: "opfer-tenant", Scopes: []string{"openid"}, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "https://core.nexarch.example", Audience: jwt.ClaimStrings{"drittanwendung-client-id"}, IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), }, } forged := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) forged.Header["kid"] = "genuine-kid" // gibt vor, mit dem echten Schluessel signiert zu sein forgedSigned, err := forged.SignedString(attackerPriv) if err != nil { t.Fatalf("token mit angreiferschluessel signieren: %v", err) } // Verifikation exakt wie eine Drittanwendung sie gegen den echten, // aus dem JWKS-Endpunkt bezogenen Schluessel durchfuehren wuerde. verify := func(tokenString string, kid2key map[string]ed25519.PublicKey) error { parsed, err := jwt.Parse(tokenString, func(tk *jwt.Token) (interface{}, error) { if _, ok := tk.Method.(*jwt.SigningMethodEd25519); !ok { return nil, fmt.Errorf("unerwarteter algorithmus: %v", tk.Header["alg"]) } kid, _ := tk.Header["kid"].(string) pub, ok := kid2key[kid] if !ok { return nil, fmt.Errorf("unbekannte kid %q", kid) } return pub, nil }) if err != nil || !parsed.Valid { return fmt.Errorf("ungueltig: %w", err) } return nil } trustedKeys := map[string]ed25519.PublicKey{"genuine-kid": genuinePub} if err := verify(forgedSigned, trustedKeys); err == nil { t.Fatal("mit angreiferschluessel signiertes idp-token wurde akzeptiert - kritische luecke") } // Gegenprobe: ein echtes, mit dem tatsaechlichen Schluessel signiertes // Token muss weiterhin akzeptiert werden (keine False-Positive-Abwehr). genuine := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) genuine.Header["kid"] = "genuine-kid" genuineSigned, err := genuine.SignedString(genuinePriv) if err != nil { t.Fatalf("echtes token signieren: %v", err) } if err := verify(genuineSigned, trustedKeys); err != nil { t.Fatalf("echtes, korrekt signiertes idp-token wurde abgewiesen: %v", err) } } // ======================================================================= // Pruefung 1 / Akzeptanzkriterium 2: Rate-Limiting/Account-Lockout im // Mehrinstanz-Betrieb nachweislich nicht umgehbar. // ======================================================================= // TestPentest_RateLimit_MultiInstanceBypassAttempt simuliert einen // Angreifer, der versucht, das Rate-Limit zu umgehen, indem er seine // Anfragen ueber MEHRERE Core-Instanzen (hier: mehrere unabhaengige // pgxpool.Pool-Verbindungen zur selben Postgres-Instanz, wie es getrennte // Core-Prozesse waeren) verteilt, in der Annahme, jede Instanz fuehre einen // eigenen In-Process-Zaehler. Da ratelimit.Store atomar gegen dieselbe // geteilte Tabelle arbeitet (API-03), darf die Gesamtsumme der erlaubten // Anfragen ueber ALLE "Instanzen" hinweg das konfigurierte Limit nicht // uebersteigen. func TestPentest_RateLimit_MultiInstanceBypassAttempt(t *testing.T) { _, adminDSN := setupRegistryPool(t) ctx := context.Background() key := uniqueSlug("pentest-ratelimit") const instanceCount = 4 const requestsPerInstance = 50 const limit = 100 // Eine eigene Konfiguration ueber EINE der "Instanzen" gesetzt - muss // fuer alle anderen sofort sichtbar sein (geteilter Zustand). configPool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("config pool: %v", err) } defer configPool.Close() if err := ratelimit.NewStore(configPool).SetLimit(ctx, key, limit, time.Minute); err != nil { t.Fatalf("limit setzen: %v", err) } var allowed int64 var wg sync.WaitGroup for i := 0; i < instanceCount; i++ { wg.Add(1) go func() { defer wg.Done() // Jede "Instanz" hat einen EIGENEN Pool - simuliert getrennte // Prozesse, die sich keinen In-Memory-Zustand teilen. instancePool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Errorf("instanz-pool: %v", err) return } defer instancePool.Close() store := ratelimit.NewStore(instancePool) for j := 0; j < requestsPerInstance; j++ { res, err := store.Allow(ctx, key) if err != nil { t.Errorf("allow: %v", err) return } if res.Allowed { atomic.AddInt64(&allowed, 1) } } }() } wg.Wait() totalRequests := int64(instanceCount * requestsPerInstance) if allowed > limit { t.Fatalf("mehrinstanz-umgehungsversuch erfolgreich: %d von %d anfragen ueber %d instanzen erlaubt, konfiguriertes limit war %d", allowed, totalRequests, instanceCount, limit) } if allowed != limit { t.Fatalf("erwartet exakt %d erlaubte anfragen (limit ausgeschoepft, nicht mehr/weniger), habe %d", limit, allowed) } t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen, %d anfragen gesamt, exakt %d (=limit) erlaubt", instanceCount, totalRequests, allowed) } // TestPentest_Lockout_MultiInstanceBypassAttempt simuliert denselben // Umgehungsversuch gegen den Account-Lockout (IAM-07): ein Angreifer verteilt // Brute-Force-Loginversuche fuer EIN Konto ueber mehrere Instanzen, um zu // verhindern, dass eine einzelne Instanz die Schwelle erreicht. func TestPentest_Lockout_MultiInstanceBypassAttempt(t *testing.T) { registryPool, adminDSN := setupRegistryPool(t) ctx := context.Background() dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) registry := tenant.NewRegistry(registryPool) provisioner := tenant.NewProvisioner(registryPool, registry, dsnTemplate) slug := uniqueSlug("pentest_lockout") tn, err := provisioner.Provision(ctx, slug, "Pentest Lockout GmbH") if err != nil { t.Fatalf("provision: %v", err) } t.Cleanup(func() { _, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug) _, _ = registryPool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`) }) tenantPool, err := pgxpool.New(ctx, tn.DBDSN) if err != nil { t.Fatalf("tenant pool: %v", err) } defer tenantPool.Close() applyAllUpSQL(t, ctx, tenantPool, filepath.Join(repoRoot(t), "migrations", "tenant")) const email = "opfer@pentest.example" const maxFailed = lockout.DefaultMaxFailedAttempts const instanceCount = 3 const attemptsPerInstance = 4 // 3*4=12 > maxFailed(5), muesste ohne umgehung frueh sperren var wg sync.WaitGroup var mu sync.Mutex var lockedAt []int // Reihenfolge, in der jede "Instanz" eine Sperre beobachtet hat for i := 0; i < instanceCount; i++ { wg.Add(1) instanceID := i go func() { defer wg.Done() instancePool, err := pgxpool.New(ctx, tn.DBDSN) if err != nil { t.Errorf("instanz-pool: %v", err) return } defer instancePool.Close() store := lockout.NewStore(instancePool) for j := 0; j < attemptsPerInstance; j++ { locked, _, err := store.RecordFailure(ctx, email) if err != nil { t.Errorf("fehlversuch erfassen: %v", err) return } if locked { mu.Lock() lockedAt = append(lockedAt, instanceID) mu.Unlock() } } }() } wg.Wait() if len(lockedAt) == 0 { t.Fatal("mehrinstanz-umgehungsversuch erfolgreich: konto wurde trotz insgesamt mehr als maxFailed fehlversuchen ueber mehrere instanzen NICHT gesperrt") } locked, _, err := lockout.NewStore(tenantPool).IsLocked(ctx, email) if err != nil { t.Fatalf("sperrstatus pruefen: %v", err) } if !locked { t.Fatal("konto ist nach ueberschreiten der schwelle ueber mehrere instanzen hinweg nicht als gesperrt markiert") } t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen x %d versuche, konto nach ueberschreiten von maxFailed=%d gesperrt", instanceCount, attemptsPerInstance, maxFailed) } // ======================================================================= // Pruefung 3 / Akzeptanzkriterium 3: zentrale Policy-Durchsetzung gegen // mindestens einen gezielten Umgehungsversuch je Modul. // ======================================================================= // TestPentest_Policy_PrivilegeEscalationViaWrongRole ist der // Umgehungsversuch fuer den Identitaets-/Mandantenbereich (TEN/IAM-Module): // ein normaler Benutzer versucht, ueber den zentralen Enforcer (RBAC-02) ein // Recht zu nutzen, das ausschliesslich Superadmin vorbehalten ist // (PermManageTenants). Default-Deny muss greifen. func TestPentest_Policy_PrivilegeEscalationViaWrongRole(t *testing.T) { registryPool, _ := setupRegistryPool(t) ctx := context.Background() store := policy.NewStore(registryPool) enforcer := policy.NewEnforcer(store) // Nur die Basisrechte gewaehren, wie es die reale Policy vorsieht - // PermManageTenants bewusst NICHT an user oder tenant_admin vergeben. if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-setup"); err != nil { t.Fatalf("basisrecht gewaehren: %v", err) } t.Cleanup(func() { _ = store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-cleanup") }) // Angriff: ein Benutzer mit Rolle "user" versucht, eine // mandantenverwaltende Aktion auszufuehren. err := enforcer.Authorize(ctx, rbac.RoleUser, rbac.PermManageTenants) if err == nil { t.Fatal("privilege-escalation erfolgreich: rolle 'user' durfte platform.manage_tenants ausfuehren") } if !errors.Is(err, policy.ErrDenied) { t.Fatalf("erwartete policy.ErrDenied, habe: %v", err) } // Angriff Variante 2: dieselbe Aktion fuer tenant_admin, der ebenfalls // nicht ueber PermManageTenants verfuegt. if err := enforcer.Authorize(ctx, rbac.RoleTenantAdmin, rbac.PermManageTenants); !errors.Is(err, policy.ErrDenied) { t.Fatalf("privilege-escalation ueber tenant_admin: erwartete policy.ErrDenied, habe: %v", err) } } // TestPentest_Policy_GuardBypassStructurallyImpossible ist der // Umgehungsversuch gegen die Durchsetzungsschicht selbst (Akzeptanzkriterium // 1): policy.Guard ruft die uebergebene Query-Funktion NUR bei Erfolg von // Authorize auf. Dieser Test beweist das Verhalten aktiv, indem ein // Zaehler in der Query-Funktion inkrementiert wird - bei einer verweigerten // Autorisierung darf der Zaehler NIEMALS erhoeht werden, unabhaengig davon, // wie oft Guard aufgerufen wird. func TestPentest_Policy_GuardBypassStructurallyImpossible(t *testing.T) { registryPool, _ := setupRegistryPool(t) ctx := context.Background() store := policy.NewStore(registryPool) enforcer := policy.NewEnforcer(store) // Bewusst KEINE Regel gewaehrt - default-deny. var dataAccessed int32 query := func(ctx context.Context) (string, error) { atomic.AddInt32(&dataAccessed, 1) return "geheime mandantendaten", nil } for i := 0; i < 10; i++ { _, err := policy.Guard(ctx, enforcer, rbac.RoleUser, rbac.PermManageSettings, query) if err == nil { t.Fatal("guard hat query trotz fehlender berechtigung ausgefuehrt") } } if atomic.LoadInt32(&dataAccessed) != 0 { t.Fatalf("umgehungsversuch erfolgreich: query wurde %d mal trotz verweigerter autorisierung ausgefuehrt", dataAccessed) } } // ======================================================================= // Akzeptanzkriterium 4: Master-Key- und Tenant-KEK-Verwaltung (API-10) // gegen Schluesselkompromittierung und Rotation geprueft. // ======================================================================= // TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation prueft: nach // einer Master-Key-Rotation kann der ALTE (potenziell kompromittierte) // Master-Key die Tenant-KEKs nicht mehr entschluesseln - kompromittiert ein // Angreifer den alten Master-Key NACH der Rotation, ist der Zugriff bereits // verwehrt. func TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation(t *testing.T) { oldMasterKey := randomMasterKey(t) newMasterKey := randomMasterKey(t) store, pool := setupKEKStore(t) tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek")) plainKEK, err := store.CreateForTenant(context.Background(), tenantID, oldMasterKey) if err != nil { t.Fatalf("tenant-kek anlegen: %v", err) } rotated, failed, err := store.RotateMasterKey(context.Background(), oldMasterKey, newMasterKey) if err != nil { t.Fatalf("master-key-rotation: %v", err) } if rotated != 1 || len(failed) != 0 { t.Fatalf("rotation unerwartet: rotated=%d failed=%v", rotated, failed) } // Angriff: der (kompromittierte) ALTE Master-Key wird nach der Rotation // verwendet, um den Tenant-KEK zu entschluesseln. if _, err := store.GetDecrypted(context.Background(), tenantID, oldMasterKey); err == nil { t.Fatal("kompromittierter alter master-key konnte tenant-kek nach rotation weiterhin entschluesseln - kritische luecke") } // Gegenprobe: der neue Master-Key liefert weiterhin denselben Plaintext- // Tenant-KEK (Akzeptanzkriterium 3: Rotation aendert nur die Verpackung). decrypted, err := store.GetDecrypted(context.Background(), tenantID, newMasterKey) if err != nil { t.Fatalf("neuer master-key konnte tenant-kek nicht entschluesseln: %v", err) } if string(decrypted) != string(plainKEK) { t.Fatal("tenant-kek-plaintext hat sich durch master-key-rotation veraendert - widerspricht akzeptanzkriterium 3") } } // TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants ist der // Umgehungs-/Kompromittierungstest fuer Tenant-KEK-Rotation: kompromittiert // ein Angreifer den Tenant-KEK von Mandant A und dieser wird rotiert, darf // das keinerlei Auswirkung auf Mandant B haben (Fortsetzung der physischen // Modell-C-Isolation auf Schluesselebene). func TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants(t *testing.T) { masterKey := randomMasterKey(t) store, pool := setupKEKStore(t) tenantA := insertTenantRow(t, pool, uniqueSlug("pentest_kek_a")) tenantB := insertTenantRow(t, pool, uniqueSlug("pentest_kek_b")) if _, err := store.CreateForTenant(context.Background(), tenantA, masterKey); err != nil { t.Fatalf("tenant a kek anlegen: %v", err) } kekBBefore, err := store.CreateForTenant(context.Background(), tenantB, masterKey) if err != nil { t.Fatalf("tenant b kek anlegen: %v", err) } // Tenant As KEK wird rotiert (z.B. weil kompromittiert). if _, err := store.RotateTenantKEK(context.Background(), tenantA, masterKey); err != nil { t.Fatalf("tenant a rotation: %v", err) } kekBAfter, err := store.GetDecrypted(context.Background(), tenantB, masterKey) if err != nil { t.Fatalf("tenant b kek nach fremder rotation nicht mehr lesbar: %v", err) } if string(kekBAfter) != string(kekBBefore) { t.Fatal("tenant bs kek hat sich durch rotation von tenant a veraendert - isolationsverletzung") } } // TestPentest_KEK_TamperedWrappedKeyRejected prueft, dass eine Manipulation // des in der Datenbank gespeicherten, verpackten Tenant-KEK (z.B. durch // einen Angreifer mit Datenbankzugriff, aber ohne Master-Key) beim // Entschluesseln zuverlaessig als ungueltig erkannt wird - AES-GCM ist // authentifizierend, ein manipulierter Chiffretext darf niemals einen // (falschen) Plaintext zurueckliefern. func TestPentest_KEK_TamperedWrappedKeyRejected(t *testing.T) { masterKey := randomMasterKey(t) store, pool := setupKEKStore(t) tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek_tamper")) if _, err := store.CreateForTenant(context.Background(), tenantID, masterKey); err != nil { t.Fatalf("tenant-kek anlegen: %v", err) } // Angriff: ein Byte im gespeicherten, verpackten Schluessel wird // direkt in der Datenbank veraendert (kein Master-Key noetig fuer diesen // Schritt). tag, err := pool.Exec(context.Background(), ` UPDATE tenant_keks SET wrapped_kek = set_byte(wrapped_kek, 20, get_byte(wrapped_kek, 20) # 255) WHERE tenant_id = $1 `, tenantID) if err != nil { t.Fatalf("wrapped_kek manipulieren: %v", err) } if tag.RowsAffected() != 1 { t.Fatalf("erwartet 1 manipulierte zeile, habe %d", tag.RowsAffected()) } if _, err := store.GetDecrypted(context.Background(), tenantID, masterKey); !errors.Is(err, kek.ErrUnwrapFailed) { t.Fatalf("manipulierter wrapped_kek wurde nicht als ErrUnwrapFailed erkannt: %v", err) } } // --------------------------------------------------------------------- // KEK-Testhilfsfunktionen (eigenes Minimalschema wie in internal/kek/kek_test.go, // mit derselben Aufraeum-Disziplin - siehe QA-04-Befund zu // t.Cleanup/defer-Reihenfolge). // --------------------------------------------------------------------- func randomMasterKey(t *testing.T) kek.MasterKey { t.Helper() buf := make([]byte, kek.MasterKeySize) if _, err := rand.Read(buf); err != nil { t.Fatalf("master-key erzeugen: %v", err) } return kek.MasterKey(buf) } func setupKEKStore(t *testing.T) (*kek.Store, *pgxpool.Pool) { t.Helper() adminDSN := requireAdminDSN(t) ctx := context.Background() pool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("pool: %v", err) } // Ueber t.Cleanup registriert (nicht defer), damit spaeter via t.Cleanup // registrierte Aufraeumfunktionen (insertTenantRow) den Pool beim // Ausfuehren noch offen vorfinden - siehe QA-04-Befund in // internal/kek/kek_test.go zur defer/t.Cleanup-Reihenfolge. t.Cleanup(func() { pool.Close() }) if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS tenant_keks ( tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ )`); err != nil { t.Fatalf("tenant_keks-schema: %v", err) } return kek.NewStore(pool), pool } func insertTenantRow(t *testing.T, pool *pgxpool.Pool, slug string) string { t.Helper() var id string err := pool.QueryRow(context.Background(), ` INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id `, slug).Scan(&id) if err != nil { t.Fatalf("tenant-zeile anlegen: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id) _, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id) }) return id }