QA-04: sicherheits-penetrationspruefung-core

Voller Merge von RBAC-02/IAM-06/IAM-07/API-03/API-10/API-08/API-09/IAM-10/
IAM-11/IAM-13 plus echte Angriffstests (internal/pentest) gegen SSO/OIDC
(alg=none, Fremdschluessel, Claims-Manipulation, Nonce-Replay), Rate-
Limiting/Lockout im simulierten Mehrinstanz-Betrieb, zentrale Policy-
Durchsetzung (Rechteausweitung, struktureller Guard-Bypass) und Master-
Key-/Tenant-KEK-Rotation. 29/29 Pakete gruen auf 192.168.1.131.

Vier real gefundene Testinfrastruktur-Fehler behoben: reset-test-env.sh
liess tenant_keks (und weitere neuere Registry-Tabellen) beim Reset stehen
(FK-CASCADE loescht nur die Constraint, keine Zeilen); zwei E2E-Tests und
kek_test.go schlossen ihren adminPool per defer VOR ihrer t.Cleanup-
Bereinigung (t.Cleanup laeuft immer nach allen defers); migrate_test.go
hatte ein Testschema ohne die TEN-04-Lifecycle-Spalten. Alle vier Fixes
betreffen ausschliesslich Testcode, kein Produktionscode geaendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-29 09:57:36 +02:00
co-authored by Claude Sonnet 5
parent 5e6cb4e2db
commit 9f641fec76
7 changed files with 926 additions and 4 deletions
+5 -1
View File
@@ -25,7 +25,11 @@ func TestE2E_CrossTenantIsolation(t *testing.T) {
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
t.Cleanup(func() { adminPool.Close() })
applyRegistrySchema(t, ctx, adminPool, root)
+5 -1
View File
@@ -30,7 +30,11 @@ func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
t.Cleanup(func() { adminPool.Close() })
applyRegistrySchema(t, ctx, adminPool, root)
+17 -1
View File
@@ -59,7 +59,14 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
// Als t.Cleanup statt nur per defer im Aufrufer zurueckgegeben: falls ein
// Aufrufer "defer cleanup()" nutzt, liefe das Schliessen VOR den
// t.Cleanup-Aufraeumfunktionen von createTenant (die den Pool noch
// brauchen), da t.Cleanup grundsaetzlich erst nach allen defers der
// Testfunktion ausgefuehrt wird. Registrierung hier stellt sicher, dass
// das Schliessen selbst als letzte Cleanup-Funktion (LIFO) laeuft.
t.Cleanup(func() { pool.Close() })
cleanup := func() {}
return NewStore(pool), pool, cleanup
}
@@ -81,6 +88,15 @@ func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string {
if err != nil {
t.Fatalf("tenant anlegen: %v", err)
}
// Aufraeumen nach Testende - ohne dies bleiben Registry-Zeilen mit
// db_dsn='unused' dauerhaft in der geteilten tenants-Tabelle stehen und
// verfaelschen registry.List()-Ergebnisse in anderen Paketen (z.B.
// internal/migrate), die im selben Testlauf gegen dieselbe physische
// Postgres-Instanz laufen.
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
}
+3 -1
View File
@@ -61,7 +61,9 @@ func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tena
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
previous_status TEXT,
deletion_scheduled_at TIMESTAMPTZ
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
+732
View File
@@ -0,0 +1,732 @@
// 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") {
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
}