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>
87 lines
3.0 KiB
Go
87 lines
3.0 KiB
Go
package moduletrust
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// StaleCache ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
|
|
// (Akzeptanzkriterium 2): TTL-basiert, mit explizitem, benanntem Verhalten
|
|
// bei abgelaufenem Cache waehrend Core nicht erreichbar ist.
|
|
//
|
|
// - Get: FAIL-OPEN fuer Lesevorgaenge. Schlaegt der Refresh fehl, aber es
|
|
// gibt bereits einen (wenn auch abgelaufenen) Stand, wird dieser mit
|
|
// stale=true zurueckgegeben — Begruendung: ein bereits authentifiziertes
|
|
// Modul soll mit dem letztbekannten Stand weiterarbeiten koennen statt
|
|
// hart zu blockieren (siehe "Bekannte Fehler vermeiden" im Ticket).
|
|
// Existiert noch nie ein Stand, gibt es keinen sinnvollen Fallback —
|
|
// dann liefert auch Get einen Fehler.
|
|
// - RequireFresh: FAIL-CLOSED fuer sicherheitskritische Aktionen (z.B.
|
|
// ein komplett NEUER Login). Nutzt NIEMALS einen zwischengespeicherten
|
|
// Stand, ruft immer frisch ab — Begruendung: eine neue Vertrauens-
|
|
// entscheidung darf nicht auf veralteten Daten beruhen, auch wenn das
|
|
// bedeutet, dass die Aktion bei Core-Ausfall sichtbar fehlschlaegt statt
|
|
// unsicher "irgendwie" durchgelassen zu werden.
|
|
//
|
|
// LIC-02 (internal/flag.Service) implementiert bereits denselben Kontrakt
|
|
// fuer Feature-Flags — StaleCache verallgemeinert dasselbe Muster fuer
|
|
// JWT-Signaturschluessel, damit beide Faelle derselben dokumentierten
|
|
// Policy folgen.
|
|
type StaleCache[T any] struct {
|
|
mu sync.RWMutex
|
|
value T
|
|
hasValue bool
|
|
fetchedAt time.Time
|
|
ttl time.Duration
|
|
fetch func(ctx context.Context) (T, error)
|
|
}
|
|
|
|
func NewStaleCache[T any](ttl time.Duration, fetch func(ctx context.Context) (T, error)) *StaleCache[T] {
|
|
return &StaleCache[T]{ttl: ttl, fetch: fetch}
|
|
}
|
|
|
|
// Get liefert den Cache-Wert. FAIL-OPEN: bei Refresh-Fehler wird ein
|
|
// vorhandener, ggf. abgelaufener Stand zurueckgegeben (stale=true).
|
|
func (c *StaleCache[T]) Get(ctx context.Context) (value T, stale bool, err error) {
|
|
c.mu.RLock()
|
|
fresh := c.hasValue && time.Since(c.fetchedAt) < c.ttl
|
|
if fresh {
|
|
v := c.value
|
|
c.mu.RUnlock()
|
|
return v, false, nil
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
newVal, fetchErr := c.fetch(ctx)
|
|
if fetchErr == nil {
|
|
c.mu.Lock()
|
|
c.value, c.hasValue, c.fetchedAt = newVal, true, time.Now()
|
|
c.mu.Unlock()
|
|
return newVal, false, nil
|
|
}
|
|
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.hasValue {
|
|
return c.value, true, nil
|
|
}
|
|
var zero T
|
|
return zero, false, fmt.Errorf("cache leer und refresh fehlgeschlagen: %w", fetchErr)
|
|
}
|
|
|
|
// RequireFresh ruft IMMER frisch ab (FAIL-CLOSED) — fuer sicherheitskritische
|
|
// Aktionen, die niemals auf einem zwischengespeicherten Stand basieren duerfen.
|
|
func (c *StaleCache[T]) RequireFresh(ctx context.Context) (T, error) {
|
|
v, err := c.fetch(ctx)
|
|
if err != nil {
|
|
var zero T
|
|
return zero, fmt.Errorf("core nicht erreichbar, sicherheitskritische aktion abgelehnt: %w", err)
|
|
}
|
|
c.mu.Lock()
|
|
c.value, c.hasValue, c.fetchedAt = v, true, time.Now()
|
|
c.mu.Unlock()
|
|
return v, nil
|
|
}
|