API-05: verteilte-jwt-verifikation-rechte-feature-flag-cache-kontrakt

internal/moduletrust: asymmetrische JWT-Signatur (Ed25519) mit JWKS-
Verteilung, wie im Entscheidungsverlauf "Vertrauensstellung Core<->Module"
(nexarch-state.json) festgelegt. Getrennt von IAM-02s HS256-Session-Cookie
(Browser-Login bleibt unangetastet) — dies ist der Modul-zu-Core-
Vertrauensmechanismus.

KeyManager haelt ALLE noch gueltigen Schluesselpaare (nicht nur das aktuell
signierende); Rotate() erzeugt einen neuen Schluessel, alte bleiben in
PublicKeySet() erhalten — bereits ausgestellte Tokens bleiben dadurch nach
einer Rotation weiterhin verifizierbar (Akzeptanzkriterium 3, keine
Ausfallzeit). ServeJWKS/ParseJWKS sind der Verteilungsmechanismus.

StaleCache[T] ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
(Akzeptanzkriterium 2), mit zwei explizit benannten und begruendeten
Verhalten: Get() ist FAIL-OPEN (nutzt bei Core-Ausfall einen vorhandenen,
abgelaufenen Stand weiter — ein bereits authentifiziertes Modul soll nicht
hart blockieren), RequireFresh() ist FAIL-CLOSED (nie zwischengespeichert,
schlaegt bei Core-Ausfall klar fehl — fuer sicherheitskritische Aktionen wie
einen neuen Login). LIC-02s internal/flag.Service implementiert bereits
denselben Kontrakt fuer Feature-Flags; StaleCache verallgemeinert dasselbe
Muster fuer JWT-Schluessel, damit beide Faelle derselben dokumentierten
Policy folgen statt zwei unterschiedlichen Ad-hoc-Loesungen.

Verifier.Verify ruft KeyFetchFunc nur bei abgelaufener TTL auf, nicht pro
Aufruf (Akzeptanzkriterium 1) — Signaturpruefung selbst ist immer lokal.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Core simuliert abgeschaltet, andere Module bleiben fuer bereits
   authentifizierte Nutzer funktionsfaehig bis TTL/Fail-Open greift —
   TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist: Verify()
   funktioniert weiter mit letztbekanntem Schluesselstand. PASS.
2. Neue sicherheitskritische Aktion schlaegt bei Core-Ausfall klar fehl,
   statt andere Funktionen mitzureissen —
   TestRequireFreshKeys_FailsClosedWhenCoreUnreachable: Fehler trotz
   vorhandenem (aelterem) Cache-Stand. PASS.
3. Schluesselrotation ohne Downtime in einem simulierten zweiten Modul —
   TestRotate_NoDowntimeForAlreadyIssuedTokens: vor UND nach Rotation
   ausgestellte Tokens beide weiterhin gueltig fuer Modul B. PASS.

Zusaetzlich: TestVerify_DoesNotFetchPerCall belegt Akzeptanzkriterium 1
direkt (10 Verify-Aufrufe, genau 1 Fetch). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-08-27 21:38:57 +02:00
co-authored by Claude Sonnet 5
parent 3d20d86a4f
commit f7863fd4d2
7 changed files with 563 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
package moduletrust
import (
"context"
"crypto/ed25519"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidToken = errors.New("moduletrust: ungueltiges token")
// KeyFetchFunc holt den aktuellen Schluesselsatz von Core (z.B. per HTTP-GET
// auf ServeJWKS + ParseJWKS). Wird vom Verifier nur bei abgelaufener TTL
// aufgerufen — NICHT bei jeder Verify()-Anfrage (Akzeptanzkriterium 1).
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
// Verifier ist die Modulseite von API-05: verifiziert JWTs LOKAL gegen einen
// per StaleCache zwischengespeicherten Schluesselsatz, ohne pro Aufruf einen
// synchronen Request an Core zu stellen.
type Verifier struct {
cache *StaleCache[map[string]ed25519.PublicKey]
}
func NewVerifier(ttl time.Duration, fetch KeyFetchFunc) *Verifier {
return &Verifier{cache: NewStaleCache(ttl, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return fetch(ctx)
})}
}
// Verify prueft die Signatur LOKAL gegen den (ggf. abgelaufenen, aber
// vorhandenen) Schluesselsatz — FAIL-OPEN fuer bereits ausgestellte Tokens
// (Akzeptanzkriterium 2): ist Core nicht erreichbar, aber ein alter
// Schluesselsatz bekannt, wird damit weiter verifiziert.
func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, error) {
keys, _, err := v.cache.Get(ctx)
if err != nil {
return nil, err
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, ErrInvalidToken
}
kid, _ := t.Header["kid"].(string)
pub, ok := keys[kid]
if !ok {
return nil, ErrInvalidToken
}
return pub, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// RequireFreshKeys ruft IMMER frisch von Core ab (FAIL-CLOSED) — fuer
// sicherheitskritische Aktionen wie einen komplett neuen Login
// (Akzeptanzkriterium 2): schlaegt klar fehl, wenn Core nicht erreichbar
// ist, statt auf einem veralteten Schluesselsatz zu vertrauen.
func (v *Verifier) RequireFreshKeys(ctx context.Context) error {
_, err := v.cache.RequireFresh(ctx)
return err
}