CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m27s
CI / Web (pdf-viewer) (pull_request) Failing after 1m2s
CI / Web (shl) (pull_request) Failing after 11s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 19s
CI / Web (dms-browser) (push) Failing after 1m26s
CI / Web (pdf-viewer) (push) Failing after 1m3s
CI / Web (shl) (push) Failing after 11s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 13m12s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (push) Successful in 12m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
218 lines
5.9 KiB
Go
218 lines
5.9 KiB
Go
package moduletrust
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"errors"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestIssueAndVerify_RoundTrip(t *testing.T) {
|
|
km, err := NewKeyManager()
|
|
if err != nil {
|
|
t.Fatalf("new key manager: %v", err)
|
|
}
|
|
token, err := km.Issue("user-1", "acme", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
return km.PublicKeySet(), nil
|
|
})
|
|
claims, err := v.Verify(context.Background(), token)
|
|
if err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
if claims.Subject != "user-1" || claims.TenantSlug != "acme" {
|
|
t.Fatalf("claims unerwartet: %+v", claims)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Verifikation lokal, kein Request pro Aufruf.
|
|
func TestVerify_DoesNotFetchPerCall(t *testing.T) {
|
|
km, err := NewKeyManager()
|
|
if err != nil {
|
|
t.Fatalf("new key manager: %v", err)
|
|
}
|
|
token, err := km.Issue("user-1", "acme", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
fetchCalls := 0
|
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
mu.Lock()
|
|
fetchCalls++
|
|
mu.Unlock()
|
|
return km.PublicKeySet(), nil
|
|
})
|
|
|
|
for i := 0; i < 10; i++ {
|
|
if _, err := v.Verify(context.Background(), token); err != nil {
|
|
t.Fatalf("verify %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if fetchCalls != 1 {
|
|
t.Fatalf("erwartet genau 1 fetch fuer 10 Verify-Aufrufe innerhalb der TTL, habe %d", fetchCalls)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 1: Core simuliert nicht erreichbar,
|
|
// bereits authentifizierte Nutzer bleiben funktionsfaehig (Fail-Open mit
|
|
// letztbekanntem Schluesselstand).
|
|
func TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist(t *testing.T) {
|
|
km, err := NewKeyManager()
|
|
if err != nil {
|
|
t.Fatalf("new key manager: %v", err)
|
|
}
|
|
token, err := km.Issue("user-1", "acme", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
coreDown := false
|
|
v := NewVerifier(30*time.Millisecond, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
if coreDown {
|
|
return nil, errors.New("core nicht erreichbar (simuliert)")
|
|
}
|
|
return km.PublicKeySet(), nil
|
|
})
|
|
|
|
// Cache vorwaermen, waehrend Core noch erreichbar ist.
|
|
if _, err := v.Verify(context.Background(), token); err != nil {
|
|
t.Fatalf("verify (warm): %v", err)
|
|
}
|
|
|
|
// "Core abschalten" und TTL ablaufen lassen.
|
|
coreDown = true
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
if _, err := v.Verify(context.Background(), token); err != nil {
|
|
t.Fatalf("verify sollte trotz core-ausfall mit letztbekanntem stand funktionieren: %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 2: neue sicherheitskritische Aktionen
|
|
// (z.B. neuer Login) schlagen bei Core-Ausfall klar fehl statt unsicher
|
|
// durchgelassen zu werden — auch wenn ein (aelterer) Cache-Stand existiert.
|
|
func TestRequireFreshKeys_FailsClosedWhenCoreUnreachable(t *testing.T) {
|
|
km, err := NewKeyManager()
|
|
if err != nil {
|
|
t.Fatalf("new key manager: %v", err)
|
|
}
|
|
|
|
coreDown := false
|
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
if coreDown {
|
|
return nil, errors.New("core nicht erreichbar (simuliert)")
|
|
}
|
|
return km.PublicKeySet(), nil
|
|
})
|
|
|
|
// Cache vorwaermen (existiert jetzt ein "veralteter" gueltiger Stand).
|
|
if _, _, err := v.cache.Get(context.Background()); err != nil {
|
|
t.Fatalf("warm cache: %v", err)
|
|
}
|
|
|
|
coreDown = true
|
|
if err := v.RequireFreshKeys(context.Background()); err == nil {
|
|
t.Fatal("erwartet fehler (fail-closed) bei core-ausfall, habe nil")
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3: Schluesselrotation ohne Ausfallzeit —
|
|
// ein bereits ausgestelltes Token bleibt nach Rotation weiterhin
|
|
// verifizierbar, ein zweites (simuliertes) Modul bekommt beide Schluessel.
|
|
func TestRotate_NoDowntimeForAlreadyIssuedTokens(t *testing.T) {
|
|
km, err := NewKeyManager()
|
|
if err != nil {
|
|
t.Fatalf("new key manager: %v", err)
|
|
}
|
|
|
|
oldToken, err := km.Issue("user-1", "acme", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue (alt): %v", err)
|
|
}
|
|
|
|
if _, err := km.Rotate(); err != nil {
|
|
t.Fatalf("rotate: %v", err)
|
|
}
|
|
|
|
newToken, err := km.Issue("user-2", "acme", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue (neu): %v", err)
|
|
}
|
|
|
|
// Simuliertes zweites Modul: fragt den vollstaendigen Schluesselsatz ab.
|
|
moduleB := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
return km.PublicKeySet(), nil
|
|
})
|
|
|
|
if _, err := moduleB.Verify(context.Background(), oldToken); err != nil {
|
|
t.Fatalf("altes token sollte nach rotation weiterhin gueltig sein: %v", err)
|
|
}
|
|
if _, err := moduleB.Verify(context.Background(), newToken); err != nil {
|
|
t.Fatalf("neues token sollte gueltig sein: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerify_RejectsUnknownKid(t *testing.T) {
|
|
km1, _ := NewKeyManager()
|
|
km2, _ := NewKeyManager() // komplett anderer, unbekannter schluessel
|
|
|
|
token, err := km1.Issue("user-1", "acme", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
|
return km2.PublicKeySet(), nil // kennt km1s schluessel nicht
|
|
})
|
|
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJWKSRoundTrip(t *testing.T) {
|
|
km, _ := NewKeyManager()
|
|
km.Rotate()
|
|
|
|
var buf []byte
|
|
rec := &captureWriter{}
|
|
km.ServeJWKS(rec, nil)
|
|
buf = rec.body
|
|
|
|
parsed, err := ParseJWKS(buf)
|
|
if err != nil {
|
|
t.Fatalf("parse jwks: %v", err)
|
|
}
|
|
if len(parsed) != len(km.PublicKeySet()) {
|
|
t.Fatalf("erwartet %d schluessel, habe %d", len(km.PublicKeySet()), len(parsed))
|
|
}
|
|
}
|
|
|
|
type captureWriter struct {
|
|
body []byte
|
|
header http.Header
|
|
}
|
|
|
|
func (w *captureWriter) Header() http.Header {
|
|
if w.header == nil {
|
|
w.header = http.Header{}
|
|
}
|
|
return w.header
|
|
}
|
|
func (w *captureWriter) Write(p []byte) (int, error) {
|
|
w.body = append(w.body, p...)
|
|
return len(p), nil
|
|
}
|
|
func (w *captureWriter) WriteHeader(statusCode int) {}
|