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>
68 lines
2.2 KiB
Go
68 lines
2.2 KiB
Go
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
|
|
}
|