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>
80 lines
2.3 KiB
Go
80 lines
2.3 KiB
Go
package moduletrust
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
type Claims struct {
|
|
Subject string `json:"sub"`
|
|
TenantSlug string `json:"tenant"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// Issue signiert ein Token mit dem aktuellen Signierschluessel und traegt
|
|
// dessen KID im JWT-Header ein — der Verifier auf Modulseite waehlt darueber
|
|
// den passenden oeffentlichen Schluessel aus PublicKeySet() aus.
|
|
func (m *KeyManager) Issue(subject, tenantSlug string, ttl time.Duration) (string, error) {
|
|
key, err := m.SigningKey()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
now := time.Now()
|
|
claims := Claims{
|
|
Subject: subject,
|
|
TenantSlug: tenantSlug,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
token.Header["kid"] = key.KID
|
|
return token.SignedString(key.Private)
|
|
}
|
|
|
|
type jwksResponse struct {
|
|
Keys []jwksKey `json:"keys"`
|
|
}
|
|
|
|
type jwksKey struct {
|
|
Kid string `json:"kid"`
|
|
PublicKey string `json:"public_key"` // base64 (raw Ed25519, 32 Byte)
|
|
}
|
|
|
|
// ServeJWKS liefert alle bekannten oeffentlichen Schluessel als JSON —
|
|
// Module fragen dies periodisch ab (nicht pro Request), siehe Verifier.
|
|
func (m *KeyManager) ServeJWKS(w http.ResponseWriter, r *http.Request) {
|
|
set := m.PublicKeySet()
|
|
resp := jwksResponse{Keys: make([]jwksKey, 0, len(set))}
|
|
for kid, pub := range set {
|
|
resp.Keys = append(resp.Keys, jwksKey{Kid: kid, PublicKey: base64.StdEncoding.EncodeToString(pub)})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// ParseJWKS dekodiert die JSON-Antwort von ServeJWKS zurueck in kid->PublicKey
|
|
// — Hilfsfunktion fuer Module, die JWKS per HTTP abrufen.
|
|
func ParseJWKS(data []byte) (map[string][]byte, error) {
|
|
var resp jwksResponse
|
|
if err := json.Unmarshal(data, &resp); err != nil {
|
|
return nil, fmt.Errorf("jwks parsen: %w", err)
|
|
}
|
|
out := make(map[string][]byte, len(resp.Keys))
|
|
for _, k := range resp.Keys {
|
|
raw, err := base64.StdEncoding.DecodeString(k.PublicKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oeffentlichen schluessel %q dekodieren: %w", k.Kid, err)
|
|
}
|
|
out[k.Kid] = raw
|
|
}
|
|
return out, nil
|
|
}
|