Merge #109 API-16: persistente Modul-Signaturschluessel
This commit is contained in:
commit
1e3ec00365
18 files changed
+1665
-8
No files matched your search
+15
-5
@@ -8,6 +8,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -17,7 +18,6 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/authtoken"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletoken"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/totp"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
@@ -32,6 +32,8 @@ func requireEnv(name string) string {
|
||||
}
|
||||
|
||||
func main() {
|
||||
rotateModuleKey := flag.Bool("rotate-module-key", false, "neuen Modul-Signaturschluessel anlegen und beenden")
|
||||
flag.Parse()
|
||||
dsn := requireEnv("NEXARCH_ACCOUNT_TENANT_DSN")
|
||||
tenantSlug := requireEnv("NEXARCH_ACCOUNT_TENANT_SLUG")
|
||||
jwtSecret := requireEnv("NEXARCH_ACCOUNT_JWT_SECRET")
|
||||
@@ -63,14 +65,22 @@ func main() {
|
||||
resetStore := authtoken.NewStore(pool)
|
||||
resetHandler := authtoken.NewHandler(resetStore, users, authtoken.LogNotifier{})
|
||||
|
||||
moduleKeys, err := moduletrust.NewKeyManager()
|
||||
if err != nil {
|
||||
log.Fatalf("modul-schluessel: %v", err)
|
||||
}
|
||||
moduleTokenTTL, err := moduletoken.ParseTTL(os.Getenv("NEXARCH_ACCOUNT_MODULE_TOKEN_TTL_SECONDS"))
|
||||
if err != nil {
|
||||
log.Fatalf("NEXARCH_ACCOUNT_MODULE_TOKEN_TTL_SECONDS: %v", err)
|
||||
}
|
||||
moduleKeys, err := newModuleKeyManager(ctx, pool, os.Getenv, moduleTokenTTL)
|
||||
if err != nil {
|
||||
log.Fatalf("modul-schluessel: %v", err)
|
||||
}
|
||||
if *rotateModuleKey {
|
||||
kp, err := moduleKeys.RotateContext(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("modul-schluessel rotieren: %v", err)
|
||||
}
|
||||
log.Printf("account-api: neuer modul-schluessel %s angelegt (signiert nach der aktivierungsverzoegerung)", kp.KID)
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||
)
|
||||
|
||||
const (
|
||||
envKeyEncryptionKey = "NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY"
|
||||
envKeysEphemeral = "NEXARCH_ACCOUNT_MODULE_KEYS_EPHEMERAL"
|
||||
envKeyRotationDays = "NEXARCH_ACCOUNT_MODULE_KEY_ROTATION_DAYS"
|
||||
envKeyActivationSecs = "NEXARCH_ACCOUNT_MODULE_KEY_ACTIVATION_DELAY_SECONDS"
|
||||
envKeyRefreshSecs = "NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS"
|
||||
|
||||
defaultRotationDays = 30
|
||||
defaultActivationSecs = 120
|
||||
defaultRefreshSecs = 30
|
||||
)
|
||||
|
||||
type moduleKeyConfig struct {
|
||||
ephemeral bool
|
||||
rotationInterval time.Duration
|
||||
activationDelay time.Duration
|
||||
refreshInterval time.Duration
|
||||
}
|
||||
|
||||
func intEnv(getenv func(string) string, name string, def, min, max int) (int, error) {
|
||||
raw := getenv(name)
|
||||
if raw == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %q ist keine ganze zahl", name, raw)
|
||||
}
|
||||
if n < min || n > max {
|
||||
return 0, fmt.Errorf("%s: %d ausserhalb von %d..%d", name, n, min, max)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseModuleKeyConfig(getenv func(string) string) (moduleKeyConfig, error) {
|
||||
var c moduleKeyConfig
|
||||
switch getenv(envKeysEphemeral) {
|
||||
case "", "false":
|
||||
case "true":
|
||||
c.ephemeral = true
|
||||
default:
|
||||
return c, fmt.Errorf("%s: erlaubt sind true oder false", envKeysEphemeral)
|
||||
}
|
||||
days, err := intEnv(getenv, envKeyRotationDays, defaultRotationDays, 0, 3650)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
act, err := intEnv(getenv, envKeyActivationSecs, defaultActivationSecs, 60, 3600)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
refresh, err := intEnv(getenv, envKeyRefreshSecs, defaultRefreshSecs, 1, 300)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
if act < refresh {
|
||||
return c, fmt.Errorf("%s (%ds) darf nicht kuerzer sein als %s (%ds)", envKeyActivationSecs, act, envKeyRefreshSecs, refresh)
|
||||
}
|
||||
c.rotationInterval = time.Duration(days) * 24 * time.Hour
|
||||
c.activationDelay = time.Duration(act) * time.Second
|
||||
c.refreshInterval = time.Duration(refresh) * time.Second
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type kekCipher struct{ key kek.MasterKey }
|
||||
|
||||
func (c kekCipher) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
return kek.EncryptWithTenantKEK(c.key, plaintext)
|
||||
}
|
||||
|
||||
func (c kekCipher) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||
return kek.DecryptWithTenantKEK(c.key, ciphertext)
|
||||
}
|
||||
|
||||
func newModuleKeyManager(ctx context.Context, pool *pgxpool.Pool, getenv func(string) string, tokenTTL time.Duration) (*moduletrust.KeyManager, error) {
|
||||
cfg, err := parseModuleKeyConfig(getenv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.ephemeral {
|
||||
return moduletrust.NewKeyManager()
|
||||
}
|
||||
key, err := kek.LoadMasterKeyFromEnv(envKeyEncryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w (ohne persistente schluessel nur mit %s=true, dann ungueltig nach jedem neustart)", envKeyEncryptionKey, err, envKeysEphemeral)
|
||||
}
|
||||
return moduletrust.NewPersistentKeyManager(ctx, moduletrust.PersistentConfig{
|
||||
Store: moduletrust.NewPGKeyStore(pool),
|
||||
Cipher: kekCipher{key: key},
|
||||
TokenTTL: tokenTTL,
|
||||
ActivationDelay: cfg.activationDelay,
|
||||
RotationInterval: cfg.rotationInterval,
|
||||
RefreshInterval: cfg.refreshInterval,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func envMap(m map[string]string) func(string) string {
|
||||
return func(k string) string { return m[k] }
|
||||
}
|
||||
|
||||
func TestParseModuleKeyConfig_DefaultsAndBounds(t *testing.T) {
|
||||
c, err := parseModuleKeyConfig(envMap(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.ephemeral || c.rotationInterval != 30*24*time.Hour || c.activationDelay != 2*time.Minute || c.refreshInterval != 30*time.Second {
|
||||
t.Fatalf("standardwerte: %+v", c)
|
||||
}
|
||||
|
||||
bad := []map[string]string{
|
||||
{envKeysEphemeral: "ja"},
|
||||
{envKeyRotationDays: "-1"},
|
||||
{envKeyRotationDays: "abc"},
|
||||
{envKeyActivationSecs: "59"},
|
||||
{envKeyActivationSecs: "3601"},
|
||||
{envKeyRefreshSecs: "0"},
|
||||
{envKeyRefreshSecs: "301"},
|
||||
{envKeyActivationSecs: "60", envKeyRefreshSecs: "120"},
|
||||
}
|
||||
for _, m := range bad {
|
||||
if _, err := parseModuleKeyConfig(envMap(m)); err == nil {
|
||||
t.Errorf("konfiguration %v wurde akzeptiert", m)
|
||||
}
|
||||
}
|
||||
|
||||
c, err = parseModuleKeyConfig(envMap(map[string]string{envKeysEphemeral: "true", envKeyRotationDays: "0"}))
|
||||
if err != nil || !c.ephemeral || c.rotationInterval != 0 {
|
||||
t.Fatalf("ephemeral/rotation aus: %+v %v", c, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# API-16 – Prüfprotokoll: Persistente Modul-Signaturschlüssel und Rotation
|
||||
|
||||
Ticket: `core-kanban/tickets/API-16.md` (Planungs-Repo, Commit `bfea8f8`). Branch `feature/api-16-persistente-modul-signaturschluessel`, Basis `integration/gesamtprojekt` (6b9350b). Geprüft auf dem Entwicklungshost 204 (Wegwerf-Cluster `17/coredev`, seriell über die gemeinsame Sperre; der `archivdms`-Cluster blieb unberührt).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- **Ablage:** Tabelle `module_signing_keys` in der Mandanten-Datenbank (`migrations/tenant/0008_module_signing_keys`, `up` und `down`). Abweichung vom Auftragstext, der die Registry nannte: Der Kontodienst kennt nur die Mandanten-DSN und stellt Tokens je Mandant aus (Modell C, wie IAM-19). Alle Instanzen desselben Mandanten teilen damit dieselbe Tabelle, jeder Mandant hat eigene Schlüssel.
|
||||
- **Verschlüsselung:** Der private Schlüssel (Seed) wird mit `kek.EncryptWithTenantKEK` (AES-256-GCM, Muster aus API-10) verpackt, der Schlüssel kommt aus `NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY` (base64, 32 Byte). Beim Laden wird der öffentliche Schlüssel aus dem entpackten Seed neu berechnet und mit der Spalte verglichen (vertauschte Zeilen fallen auf).
|
||||
- **Instanzen:** Änderungen laufen in einer Transaktion unter `pg_advisory_xact_lock`. Beim gleichzeitigen Start entsteht genau ein Schlüssel. Jede Instanz lädt den Satz alle `REFRESH_SECONDS` neu.
|
||||
- **Rotation:** Ein neuer Schlüssel signiert erst ab `active_from = jetzt + Aktivierungsverzögerung` (Verifier-Caches kennen ihn dann; JWKS-`Cache-Control` ist 60 s). Der bisherige Schlüssel bekommt `retire_after = active_from + Token-TTL + 30 s Toleranz`, steht bis dahin in der JWKS und wird danach aus JWKS und Tabelle entfernt. Auslöser: automatisch nach `ROTATION_DAYS` (unter der Sperre, nur eine Instanz rotiert) oder per `account-api -rotate-module-key`.
|
||||
- **Fail closed:** Ist der Speicher nicht lesbar oder ein Schlüssel nicht entschlüsselbar, gibt es weder Token (503) noch JWKS (503, `no-store`). Ein veralteter Satz wird nach Ablauf des Aktualisierungsintervalls nicht weiterverwendet.
|
||||
- **Code:** `internal/moduletrust/{persist,pgstore,memstore}.go`, Anpassungen in `keys.go`/`issuer.go`, `internal/moduletoken/token.go` (503), `cmd/account-api/{main,modulekeys}.go`. Signaturen und Verifier unverändert, `NewKeyManager()` bleibt der Prozessspeicher-Variante vorbehalten.
|
||||
|
||||
## Umgebungsvariablen (Kontodienst)
|
||||
|
||||
| Variable | Standard | Zulässig | Bedeutung |
|
||||
|---|---|---|---|
|
||||
| `NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY` | – (Pflicht) | base64, 32 Byte | Verschlüsselung der privaten Schlüssel |
|
||||
| `NEXARCH_ACCOUNT_MODULE_KEYS_EPHEMERAL` | `false` | `true`/`false` | Nur Prozessspeicher (Entwicklung), Tokens sind nach jedem Neustart ungültig |
|
||||
| `NEXARCH_ACCOUNT_MODULE_KEY_ROTATION_DAYS` | 30 | 0–3650 (0 = aus) | Automatische Rotation nach Alter |
|
||||
| `NEXARCH_ACCOUNT_MODULE_KEY_ACTIVATION_DELAY_SECONDS` | 120 | 60–3600, ≥ Aktualisierungsintervall | Verzögerung bis der neue Schlüssel signiert |
|
||||
| `NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS` | 30 | 1–300 | Neuladeintervall je Instanz |
|
||||
|
||||
**Betriebshinweis (Bruch):** Ein bestehender Kontodienst startet nicht mehr ohne `NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY` (oder ausdrücklich `..._EPHEMERAL=true`). Die Tenant-Migration 0008 muss vorher angewendet sein. Der Verschlüsselungsschlüssel gehört wie die anderen Betriebsgeheimnisse in `/etc/nexarch/account-api.env` (nicht ins Repo).
|
||||
|
||||
## Prüfungen und Ergebnis
|
||||
|
||||
| Prüfung (Ticket) | Ergebnis |
|
||||
|---|---|
|
||||
| AK1/Prüfung 1: Neustart am echten Kontodienst, Token bleibt gültig, JWKS liefert dieselbe KID | bestanden (`scripts/e2e-module-keys.sh`, alter Prozess beendet und Port frei, neuer Prozess mit anderer PID) |
|
||||
| AK2/Prüfung 3: zwei Instanzen gegen dieselbe Datenbank | bestanden: gleiche KID, Token der einen Instanz wird mit der JWKS der anderen verifiziert, genau eine Zeile nach Start beider Instanzen; Unit- und Postgres-Test: 8 bzw. 6 parallele Starts ergeben genau einen Schlüssel |
|
||||
| AK3/Prüfung 2: Rotation mit Überlappung | bestanden am Prozess: nach der Rotation listen beide Instanzen 2 Schlüssel, signiert wird bis zur Aktivierung mit dem alten, danach mit dem neuen; der alte bleibt bis TTL + Toleranz in der JWKS und ist danach aus JWKS und Datenbank entfernt |
|
||||
| AK4/Prüfung 3: Ausfall des Speichers | bestanden: Tabelle nicht lesbar ergibt 503 für JWKS und Token, nach Wiederherstellung 200; falscher oder fehlender Verschlüsselungsschlüssel: Dienst startet nicht |
|
||||
| Klartext-Test | Chiffretext enthält weder Seed noch privaten Schlüssel (Unit- und Postgres-Test) |
|
||||
| Manipulation | vertauschte Chiffretexte zweier Zeilen und falscher Schlüssel werden beim Laden abgewiesen (fail closed) |
|
||||
| Automatische Rotation | drei Instanzen nach Ablauf des Intervalls ergeben genau eine Rotation |
|
||||
| Migration | `0008` `up`, `down` und erneutes `up` grün; `scripts/migrations-fresh-check.sh`: Core Mandant 18 (vorher 17), gemeinsame Mandanten-DB 88 Tabellen ohne Kollision |
|
||||
| Ende-zu-Ende Core→DMS | `scripts/e2e-core-dms.sh` grün (`TestCoreE2E_TokenJWKSAndPolicy`, 42,8 s) |
|
||||
| Regression | `moduletrust`, `moduletoken`, `oidc`, `contracttest`, `loadtest`, `resync`, `mtls`, `tenant`, `user`, `rbac`, `auth`, `kek` je einzeln nach Cluster-Reset grün; `go build ./...`, `go vet` Core, `check-port-defaults.sh`, `check-route-prefixes.sh` sauber |
|
||||
|
||||
Neue Tests: `internal/moduletrust/persist_test.go` (9), `pgstore_test.go` (2, gegen echte Datenbank), `internal/moduletoken/persistent_test.go` (1), `cmd/account-api/modulekeys_test.go` (1).
|
||||
|
||||
## Iterationen
|
||||
|
||||
- Produktivcode: 0 Fix-Runden (alle neuen Tests im ersten Lauf grün).
|
||||
- Testaufbau: 1 Fix-Runde. Der erste Lauf von `e2e-module-keys.sh` meldete den Neustart als bestanden, obwohl er keiner war: Das Skript startete den Kontodienst über eine Shell-Funktion im Hintergrund, `kill` traf nur den Zwischenprozess, der alte Kontodienst lief weiter (der zweite Start scheiterte am belegten Port), und der Test lief gegen den alten Prozess. Erkannt wurde das, als `e2e-core-dms.sh` an „address already in use" scheiterte. Behoben: Start ohne Zwischenprozess, das Skript prüft jetzt, dass der alte Prozess beendet, der Port frei und die neue PID eine andere ist. Die Reste der ersten Läufe (zwei Prozesse auf 204) habe ich beendet, danach lief alles grün und ohne Reste.
|
||||
|
||||
## Grenzen und offene Punkte
|
||||
|
||||
1. **Verschlüsselungsschlüssel:** Eine Neuverpackung bei Wechsel von `..._KEY_ENCRYPTION_KEY` fehlt (Nicht-Bestandteil laut Ticket). Ein Wechsel ohne Neuverpackung macht die Schlüssel unlesbar, der Dienst startet dann nicht.
|
||||
2. **Token-TTL:** Die Aufbewahrungsfrist des alten Schlüssels berechnet sich aus der TTL der laufenden Konfiguration. Wird die TTL verkürzt und sofort rotiert, können früher mit der längeren TTL ausgestellte Tokens den alten Schlüssel überleben (sie sind dann nicht mehr verifizierbar). Nach einer TTL-Verkürzung deshalb erst nach Ablauf der alten TTL rotieren.
|
||||
3. **Uhren:** Aktivierung und Ablauf hängen an der Zeit der Instanzen. Bei Abweichungen über die 30 s Toleranz hinaus sind Randfehler möglich (Zeitsynchronisation der Hosts vorausgesetzt).
|
||||
4. **Widerruf ausgestellter Modul-Tokens** bleibt offen (Ticket API-15/IAM-17), ebenso eine Oberfläche zur Rotation (hier nur der Aufruf `account-api -rotate-module-key` und die Rotation nach Alter).
|
||||
5. **Speicherort:** Die Schlüssel liegen je Mandanten-Datenbank; eine zentrale Registry-Ablage (Auftragstext) wäre eine eigene Architekturentscheidung, wenn ein Kontodienst mandantenübergreifend arbeiten soll.
|
||||
6. **Nicht geprüft:** Betrieb hinter dem Reverse Proxy und mit dem laufenden `dms-app`-Prozess (nur In-Process-Kette und Kontodienst-Prozesse); der Gesamtsystem-Smoke im Token-Modus mit persistenten Schlüsseln steht aus.
|
||||
7. **`gofmt`:** `internal/moduletrust/moduletrust_test.go` war schon vorher unformatiert und ist nicht Teil dieser Änderung.
|
||||
@@ -0,0 +1,76 @@
|
||||
package moduletoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
type passthroughCipher struct{}
|
||||
|
||||
func (passthroughCipher) Encrypt(pt []byte) ([]byte, error) { return append([]byte(nil), pt...), nil }
|
||||
func (passthroughCipher) Decrypt(ct []byte) ([]byte, error) { return append([]byte(nil), ct...), nil }
|
||||
|
||||
// Ist der Schluesselspeicher nicht lesbar, gibt es weder Token (503) noch JWKS (503) — nach Wiederanlauf wieder beides.
|
||||
func TestPersistentKeys_StoreOutageYields503ForTokenAndJWKS(t *testing.T) {
|
||||
now := time.Now()
|
||||
store := moduletrust.NewMemKeyStore()
|
||||
km, err := moduletrust.NewPersistentKeyManager(context.Background(), moduletrust.PersistentConfig{
|
||||
Store: store, Cipher: passthroughCipher{}, TokenTTL: time.Minute,
|
||||
ActivationDelay: time.Minute, RefreshInterval: time.Second,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions := auth.NewTokenIssuer("test-secret-fuer-sitzungen")
|
||||
mux := http.NewServeMux()
|
||||
RegisterJWKS(mux, km)
|
||||
RegisterToken(mux, NewHandler(km, &fakeUsers{u: user.User{ID: "u1", Status: user.StatusActive}}, &fakeRoles{role: rbac.RoleUser}, "acme", time.Minute),
|
||||
sessions, revHolder{&fakeRevocation{}})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
session, err := sessions.Issue("u1", "acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
call := func(method, path string, withCookie bool) int {
|
||||
req, _ := http.NewRequest(method, srv.URL+path, nil)
|
||||
if withCookie {
|
||||
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: session})
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
if got := call(http.MethodPost, TokenPath, true); got != http.StatusOK {
|
||||
t.Fatalf("token bei laufendem speicher: %d", got)
|
||||
}
|
||||
now = now.Add(5 * time.Second)
|
||||
store.SetFailing(true)
|
||||
if got := call(http.MethodPost, TokenPath, true); got != http.StatusServiceUnavailable {
|
||||
t.Fatalf("token bei speicherausfall: %d, erwartet 503", got)
|
||||
}
|
||||
if got := call(http.MethodGet, JWKSPath, false); got != http.StatusServiceUnavailable {
|
||||
t.Fatalf("jwks bei speicherausfall: %d, erwartet 503", got)
|
||||
}
|
||||
store.SetFailing(false)
|
||||
if got := call(http.MethodPost, TokenPath, true); got != http.StatusOK {
|
||||
t.Fatalf("token nach wiederanlauf: %d", got)
|
||||
}
|
||||
if got := call(http.MethodGet, JWKSPath, false); got != http.StatusOK {
|
||||
t.Fatalf("jwks nach wiederanlauf: %d", got)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,12 @@ func (h *Handler) Issue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
token, err := h.keys.IssueWithRole(claims.UserID, h.tenantSlug, string(role), h.ttl)
|
||||
if errors.Is(err, moduletrust.ErrKeyStoreUnavailable) {
|
||||
log.Printf("moduletoken: schluesselspeicher: %v", err)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(w, "schluesselspeicher nicht verfuegbar", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("moduletoken: signieren: %v", err)
|
||||
http.Error(w, "interner fehler", http.StatusInternalServerError)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -60,7 +61,13 @@ type jwksKey struct {
|
||||
// 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()
|
||||
set, err := m.PublicKeySetChecked()
|
||||
if err != nil {
|
||||
log.Printf("moduletrust: jwks: %v", err)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(w, "schluesselspeicher nicht verfuegbar", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
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)})
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KeyPair struct {
|
||||
@@ -28,7 +29,8 @@ type KeyPair struct {
|
||||
// Rotation ohne Ausfallzeit fuer andere Module).
|
||||
type KeyManager struct {
|
||||
mu sync.RWMutex
|
||||
keys []KeyPair // aeltestes zuerst, neuestes zuletzt
|
||||
keys []KeyPair // aeltestes zuerst, neuestes zuletzt
|
||||
p *persistence // nil: nur Prozessspeicher (NewKeyManager)
|
||||
}
|
||||
|
||||
func NewKeyManager() (*KeyManager, error) {
|
||||
@@ -62,12 +64,25 @@ func (m *KeyManager) Rotate() (KeyPair, error) {
|
||||
|
||||
// SigningKey liefert den aktuellen (neuesten) Schluessel zum Signieren neuer Tokens.
|
||||
func (m *KeyManager) SigningKey() (KeyPair, error) {
|
||||
if err := m.ensureFresh(); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.keys) == 0 {
|
||||
return KeyPair{}, fmt.Errorf("moduletrust: kein schluessel vorhanden")
|
||||
}
|
||||
return m.keys[len(m.keys)-1], nil
|
||||
if m.p == nil {
|
||||
return m.keys[len(m.keys)-1], nil
|
||||
}
|
||||
now := m.p.cfg.Now()
|
||||
for i := len(m.keys) - 1; i >= 0; i-- {
|
||||
meta := m.p.meta[m.keys[i].KID]
|
||||
if !meta.activeFrom.After(now) && !m.p.retired(meta, now) {
|
||||
return m.keys[i], nil
|
||||
}
|
||||
}
|
||||
return KeyPair{}, fmt.Errorf("%w: kein aktiver signierschluessel", ErrKeyStoreUnavailable)
|
||||
}
|
||||
|
||||
// PublicKeySet liefert ALLE bekannten oeffentlichen Schluessel (kid ->
|
||||
@@ -76,7 +91,14 @@ func (m *KeyManager) PublicKeySet() map[string]ed25519.PublicKey {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make(map[string]ed25519.PublicKey, len(m.keys))
|
||||
var now time.Time
|
||||
if m.p != nil {
|
||||
now = m.p.cfg.Now()
|
||||
}
|
||||
for _, k := range m.keys {
|
||||
if m.p != nil && m.p.retired(m.p.meta[k.KID], now) {
|
||||
continue
|
||||
}
|
||||
out[k.KID] = k.Public
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package moduletrust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MemKeyStore ist ein Schluesselspeicher im Prozessspeicher fuer Tests (mehrere KeyManager teilen sich eine Instanz).
|
||||
type MemKeyStore struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]StoredKey
|
||||
failing bool
|
||||
}
|
||||
|
||||
func NewMemKeyStore() *MemKeyStore {
|
||||
return &MemKeyStore{keys: map[string]StoredKey{}}
|
||||
}
|
||||
|
||||
func (s *MemKeyStore) SetFailing(v bool) {
|
||||
s.mu.Lock()
|
||||
s.failing = v
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *MemKeyStore) Snapshot() []StoredKey {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.sorted()
|
||||
}
|
||||
|
||||
// Tamper ersetzt den Chiffretext eines Schluessels (Test: Manipulation im Speicher).
|
||||
func (s *MemKeyStore) Tamper(kid string, ciphertext []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
k := s.keys[kid]
|
||||
k.PrivateCiphertext = ciphertext
|
||||
s.keys[kid] = k
|
||||
}
|
||||
|
||||
func (s *MemKeyStore) sorted() []StoredKey {
|
||||
out := make([]StoredKey, 0, len(s.keys))
|
||||
for _, k := range s.keys {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].KID < out[j].KID })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MemKeyStore) Load(context.Context) ([]StoredKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failing {
|
||||
return nil, errors.New("speicher nicht erreichbar")
|
||||
}
|
||||
return s.sorted(), nil
|
||||
}
|
||||
|
||||
func (s *MemKeyStore) Update(ctx context.Context, fn func(tx KeyTx) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failing {
|
||||
return errors.New("speicher nicht erreichbar")
|
||||
}
|
||||
work := make(map[string]StoredKey, len(s.keys))
|
||||
for k, v := range s.keys {
|
||||
work[k] = v
|
||||
}
|
||||
if err := fn(&memTx{keys: work}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.keys = work
|
||||
return nil
|
||||
}
|
||||
|
||||
type memTx struct{ keys map[string]StoredKey }
|
||||
|
||||
func (t *memTx) List(context.Context) ([]StoredKey, error) {
|
||||
out := make([]StoredKey, 0, len(t.keys))
|
||||
for _, k := range t.keys {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].KID < out[j].KID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *memTx) Insert(_ context.Context, k StoredKey) error {
|
||||
if _, ok := t.keys[k.KID]; ok {
|
||||
return errors.New("kid existiert bereits")
|
||||
}
|
||||
t.keys[k.KID] = k
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *memTx) SetRetireAfter(_ context.Context, kid string, at time.Time) error {
|
||||
k, ok := t.keys[kid]
|
||||
if !ok {
|
||||
return errors.New("kid unbekannt")
|
||||
}
|
||||
k.RetireAfter = &at
|
||||
t.keys[kid] = k
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *memTx) DeleteRetiredBefore(_ context.Context, at time.Time) error {
|
||||
for kid, k := range t.keys {
|
||||
if k.RetireAfter != nil && !k.RetireAfter.After(at) {
|
||||
delete(t.keys, kid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package moduletrust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrKeyStoreUnavailable = errors.New("moduletrust: schluesselspeicher nicht verfuegbar")
|
||||
|
||||
const (
|
||||
retireSkew = 30 * time.Second
|
||||
DefaultRefreshInterval = 30 * time.Second
|
||||
maintenanceTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type StoredKey struct {
|
||||
KID string
|
||||
Public ed25519.PublicKey
|
||||
PrivateCiphertext []byte
|
||||
CreatedAt time.Time
|
||||
ActiveFrom time.Time
|
||||
RetireAfter *time.Time
|
||||
}
|
||||
|
||||
type KeyStore interface {
|
||||
Load(ctx context.Context) ([]StoredKey, error)
|
||||
// Update fuehrt fn unter einer instanzuebergreifenden Sperre transaktional aus.
|
||||
Update(ctx context.Context, fn func(tx KeyTx) error) error
|
||||
}
|
||||
|
||||
type KeyTx interface {
|
||||
List(ctx context.Context) ([]StoredKey, error)
|
||||
Insert(ctx context.Context, k StoredKey) error
|
||||
SetRetireAfter(ctx context.Context, kid string, t time.Time) error
|
||||
DeleteRetiredBefore(ctx context.Context, t time.Time) error
|
||||
}
|
||||
|
||||
type SecretCipher interface {
|
||||
Encrypt(plaintext []byte) ([]byte, error)
|
||||
Decrypt(ciphertext []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
type PersistentConfig struct {
|
||||
Store KeyStore
|
||||
Cipher SecretCipher
|
||||
TokenTTL time.Duration
|
||||
ActivationDelay time.Duration
|
||||
RotationInterval time.Duration
|
||||
RefreshInterval time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type keyMeta struct {
|
||||
createdAt time.Time
|
||||
activeFrom time.Time
|
||||
retireAfter *time.Time
|
||||
}
|
||||
|
||||
type persistence struct {
|
||||
cfg PersistentConfig
|
||||
meta map[string]keyMeta
|
||||
lastLoad time.Time
|
||||
loaded bool
|
||||
}
|
||||
|
||||
func (c *PersistentConfig) validate() error {
|
||||
if c.Store == nil || c.Cipher == nil {
|
||||
return errors.New("moduletrust: store und cipher sind pflicht")
|
||||
}
|
||||
if c.TokenTTL <= 0 {
|
||||
return errors.New("moduletrust: token-ttl muss positiv sein")
|
||||
}
|
||||
if c.RefreshInterval <= 0 {
|
||||
c.RefreshInterval = DefaultRefreshInterval
|
||||
}
|
||||
if c.ActivationDelay < c.RefreshInterval {
|
||||
return fmt.Errorf("moduletrust: aktivierungsverzoegerung %s kuerzer als aktualisierungsintervall %s", c.ActivationDelay, c.RefreshInterval)
|
||||
}
|
||||
if c.RotationInterval < 0 {
|
||||
return errors.New("moduletrust: rotationsintervall darf nicht negativ sein")
|
||||
}
|
||||
if c.Now == nil {
|
||||
c.Now = time.Now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPersistentKeyManager laedt die Schluessel aus dem Speicher; ohne vorhandene Schluessel legt genau
|
||||
// eine Instanz (unter der Speichersperre) den ersten an.
|
||||
func NewPersistentKeyManager(ctx context.Context, cfg PersistentConfig) (*KeyManager, error) {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &KeyManager{p: &persistence{cfg: cfg, meta: map[string]keyMeta{}}}
|
||||
err := cfg.Store.Update(ctx, func(tx KeyTx) error {
|
||||
existing, err := tx.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return nil
|
||||
}
|
||||
now := cfg.Now()
|
||||
sk, _, err := m.newStoredKey(now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Insert(ctx, sk)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrKeyStoreUnavailable, err)
|
||||
}
|
||||
if _, err := m.load(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *KeyManager) newStoredKey(now, activeFrom time.Time) (StoredKey, KeyPair, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
return StoredKey{}, KeyPair{}, fmt.Errorf("schluesselpaar erzeugen: %w", err)
|
||||
}
|
||||
kidBytes := make([]byte, 8)
|
||||
if _, err := rand.Read(kidBytes); err != nil {
|
||||
return StoredKey{}, KeyPair{}, fmt.Errorf("kid erzeugen: %w", err)
|
||||
}
|
||||
ct, err := m.p.cfg.Cipher.Encrypt(priv.Seed())
|
||||
if err != nil {
|
||||
return StoredKey{}, KeyPair{}, fmt.Errorf("privaten schluessel verpacken: %w", err)
|
||||
}
|
||||
kid := hex.EncodeToString(kidBytes)
|
||||
return StoredKey{KID: kid, Public: pub, PrivateCiphertext: ct, CreatedAt: now, ActiveFrom: activeFrom},
|
||||
KeyPair{KID: kid, Private: priv, Public: pub}, nil
|
||||
}
|
||||
|
||||
func (m *KeyManager) decode(sk StoredKey) (KeyPair, error) {
|
||||
seed, err := m.p.cfg.Cipher.Decrypt(sk.PrivateCiphertext)
|
||||
if err != nil {
|
||||
return KeyPair{}, fmt.Errorf("schluessel %s entschluesseln: %w", sk.KID, err)
|
||||
}
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return KeyPair{}, fmt.Errorf("schluessel %s hat ungueltige laenge", sk.KID)
|
||||
}
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
if !pub.Equal(sk.Public) {
|
||||
return KeyPair{}, fmt.Errorf("schluessel %s: privater und oeffentlicher schluessel passen nicht zusammen", sk.KID)
|
||||
}
|
||||
return KeyPair{KID: sk.KID, Private: priv, Public: pub}, nil
|
||||
}
|
||||
|
||||
func (p *persistence) retired(meta keyMeta, now time.Time) bool {
|
||||
return meta.retireAfter != nil && !now.Before(*meta.retireAfter)
|
||||
}
|
||||
|
||||
// refresh laedt den Schluesselsatz neu (nur bei abgelaufenem Intervall oder force). Schlaegt Laden oder
|
||||
// Entschluesseln fehl, wird kein veralteter Satz weiterverwendet (fail closed).
|
||||
func (m *KeyManager) refresh(ctx context.Context, force bool) error {
|
||||
p := m.p
|
||||
m.mu.RLock()
|
||||
fresh := p.loaded && p.cfg.Now().Sub(p.lastLoad) < p.cfg.RefreshInterval
|
||||
m.mu.RUnlock()
|
||||
if fresh && !force {
|
||||
return nil
|
||||
}
|
||||
needMaintenance, err := m.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !needMaintenance {
|
||||
return nil
|
||||
}
|
||||
changed, err := m.maintain(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
_, err = m.load(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *KeyManager) load(ctx context.Context) (needMaintenance bool, err error) {
|
||||
p := m.p
|
||||
stored, err := p.cfg.Store.Load(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: %v", ErrKeyStoreUnavailable, err)
|
||||
}
|
||||
now := p.cfg.Now()
|
||||
sort.SliceStable(stored, func(i, j int) bool {
|
||||
if !stored[i].ActiveFrom.Equal(stored[j].ActiveFrom) {
|
||||
return stored[i].ActiveFrom.Before(stored[j].ActiveFrom)
|
||||
}
|
||||
return stored[i].CreatedAt.Before(stored[j].CreatedAt)
|
||||
})
|
||||
keys := make([]KeyPair, 0, len(stored))
|
||||
meta := make(map[string]keyMeta, len(stored))
|
||||
var newestCreated time.Time
|
||||
for _, sk := range stored {
|
||||
km := keyMeta{createdAt: sk.CreatedAt, activeFrom: sk.ActiveFrom, retireAfter: sk.RetireAfter}
|
||||
if p.retired(km, now) {
|
||||
needMaintenance = true
|
||||
continue
|
||||
}
|
||||
kp, err := m.decode(sk)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: %v", ErrKeyStoreUnavailable, err)
|
||||
}
|
||||
keys = append(keys, kp)
|
||||
meta[sk.KID] = km
|
||||
if sk.RetireAfter == nil && sk.CreatedAt.After(newestCreated) {
|
||||
newestCreated = sk.CreatedAt
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return false, fmt.Errorf("%w: keine gueltigen schluessel vorhanden", ErrKeyStoreUnavailable)
|
||||
}
|
||||
if p.cfg.RotationInterval > 0 && now.Sub(newestCreated) >= p.cfg.RotationInterval {
|
||||
needMaintenance = true
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.keys = keys
|
||||
p.meta = meta
|
||||
p.lastLoad = now
|
||||
p.loaded = true
|
||||
m.mu.Unlock()
|
||||
return needMaintenance, nil
|
||||
}
|
||||
|
||||
// maintain entfernt abgelaufene Schluessel und rotiert bei Erreichen des Rotationsintervalls;
|
||||
// die Entscheidung faellt unter der Speichersperre, damit parallele Instanzen nicht doppelt rotieren.
|
||||
func (m *KeyManager) maintain(ctx context.Context) (bool, error) {
|
||||
p := m.p
|
||||
changed := false
|
||||
err := p.cfg.Store.Update(ctx, func(tx KeyTx) error {
|
||||
now := p.cfg.Now()
|
||||
all, err := tx.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expired := false
|
||||
for _, sk := range all {
|
||||
if sk.RetireAfter != nil && !now.Before(*sk.RetireAfter) {
|
||||
expired = true
|
||||
}
|
||||
}
|
||||
if expired {
|
||||
if err := tx.DeleteRetiredBefore(ctx, now); err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if p.cfg.RotationInterval > 0 {
|
||||
var newest *StoredKey
|
||||
for i := range all {
|
||||
sk := all[i]
|
||||
if sk.RetireAfter != nil {
|
||||
continue
|
||||
}
|
||||
if newest == nil || sk.CreatedAt.After(newest.CreatedAt) {
|
||||
newest = &all[i]
|
||||
}
|
||||
}
|
||||
if newest == nil || now.Sub(newest.CreatedAt) >= p.cfg.RotationInterval {
|
||||
if _, err := m.rotateIn(ctx, tx, all, now); err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: %v", ErrKeyStoreUnavailable, err)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (m *KeyManager) rotateIn(ctx context.Context, tx KeyTx, existing []StoredKey, now time.Time) (KeyPair, error) {
|
||||
p := m.p
|
||||
activeFrom := now.Add(p.cfg.ActivationDelay)
|
||||
sk, kp, err := m.newStoredKey(now, activeFrom)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
retire := activeFrom.Add(p.cfg.TokenTTL + retireSkew)
|
||||
for _, old := range existing {
|
||||
if old.RetireAfter == nil {
|
||||
if err := tx.SetRetireAfter(ctx, old.KID, retire); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Insert(ctx, sk); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
// RotateContext erzeugt einen neuen Schluessel; er signiert erst nach der Aktivierungsverzoegerung, die
|
||||
// bisher aktiven Schluessel bleiben bis zum Ablauf ihrer Tokens in der JWKS.
|
||||
func (m *KeyManager) RotateContext(ctx context.Context) (KeyPair, error) {
|
||||
if m.p == nil {
|
||||
return m.Rotate()
|
||||
}
|
||||
var kp KeyPair
|
||||
err := m.p.cfg.Store.Update(ctx, func(tx KeyTx) error {
|
||||
all, err := tx.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kp, err = m.rotateIn(ctx, tx, all, m.p.cfg.Now())
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return KeyPair{}, fmt.Errorf("%w: %v", ErrKeyStoreUnavailable, err)
|
||||
}
|
||||
if _, err := m.load(ctx); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
func (m *KeyManager) ensureFresh() error {
|
||||
if m.p == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), maintenanceTimeout)
|
||||
defer cancel()
|
||||
return m.refresh(ctx, false)
|
||||
}
|
||||
|
||||
// PublicKeySetChecked liefert wie PublicKeySet den Satz fuer die JWKS, meldet aber einen nicht lesbaren
|
||||
// Schluesselspeicher als Fehler.
|
||||
func (m *KeyManager) PublicKeySetChecked() (map[string]ed25519.PublicKey, error) {
|
||||
if err := m.ensureFresh(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.PublicKeySet(), nil
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package moduletrust
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func newFakeClock() *fakeClock {
|
||||
return &fakeClock{t: time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
type testCipher struct{ key []byte }
|
||||
|
||||
func newTestCipher(t testing.TB) testCipher {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return testCipher{key: key}
|
||||
}
|
||||
|
||||
func (c testCipher) gcm() (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func (c testCipher) Encrypt(pt []byte) ([]byte, error) {
|
||||
g, err := c.gcm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, g.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.Seal(nonce, nonce, pt, nil), nil
|
||||
}
|
||||
|
||||
func (c testCipher) Decrypt(ct []byte) ([]byte, error) {
|
||||
g, err := c.gcm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ct) < g.NonceSize() {
|
||||
return nil, errors.New("chiffretext zu kurz")
|
||||
}
|
||||
return g.Open(nil, ct[:g.NonceSize()], ct[g.NonceSize():], nil)
|
||||
}
|
||||
|
||||
func testConfig(store KeyStore, c SecretCipher, clock *fakeClock) PersistentConfig {
|
||||
return PersistentConfig{
|
||||
Store: store, Cipher: c, TokenTTL: 10 * time.Minute,
|
||||
ActivationDelay: 2 * time.Minute, RefreshInterval: 30 * time.Second, Now: clock.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func newPersistent(t *testing.T, store KeyStore, c SecretCipher, clock *fakeClock, mods ...func(*PersistentConfig)) *KeyManager {
|
||||
t.Helper()
|
||||
cfg := testConfig(store, c, clock)
|
||||
for _, m := range mods {
|
||||
m(&cfg)
|
||||
}
|
||||
km, err := NewPersistentKeyManager(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("persistenten key-manager anlegen: %v", err)
|
||||
}
|
||||
return km
|
||||
}
|
||||
|
||||
func verifyWith(km *KeyManager, token string) (*Claims, error) {
|
||||
v := NewVerifier(time.Minute, func(context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
return km.PublicKeySetChecked()
|
||||
})
|
||||
return v.Verify(context.Background(), token)
|
||||
}
|
||||
|
||||
func kidOf(t *testing.T, token string) string {
|
||||
t.Helper()
|
||||
parsed, _, err := jwt.NewParser().ParseUnverified(token, &Claims{})
|
||||
if err != nil {
|
||||
t.Fatalf("token lesen: %v", err)
|
||||
}
|
||||
kid, _ := parsed.Header["kid"].(string)
|
||||
return kid
|
||||
}
|
||||
|
||||
func TestPersistent_RestartKeepsKIDAndTokens(t *testing.T) {
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
a := newPersistent(t, store, c, clock)
|
||||
tok, err := a.Issue("u1", "acme", 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kp1, _ := a.SigningKey()
|
||||
|
||||
b := newPersistent(t, store, c, clock)
|
||||
kp2, err := b.SigningKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kp1.KID != kp2.KID {
|
||||
t.Fatalf("kid nach neustart %s, erwartet %s", kp2.KID, kp1.KID)
|
||||
}
|
||||
if n := len(store.Snapshot()); n != 1 {
|
||||
t.Fatalf("schluessel im speicher = %d, erwartet 1", n)
|
||||
}
|
||||
claims, err := verifyWith(b, tok)
|
||||
if err != nil || claims.Subject != "u1" || claims.TenantSlug != "acme" {
|
||||
t.Fatalf("token vor dem neustart nicht gueltig: %v %+v", err, claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistent_ConcurrentStartCreatesExactlyOneKey(t *testing.T) {
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
const n = 8
|
||||
kids := make(chan string, n)
|
||||
errs := make(chan error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
km, err := NewPersistentKeyManager(context.Background(), testConfig(store, c, clock))
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
kp, err := km.SigningKey()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
kids <- kp.KID
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(kids)
|
||||
for err := range errs {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if got := len(store.Snapshot()); got != 1 {
|
||||
t.Fatalf("schluessel im speicher = %d, erwartet 1", got)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for kid := range kids {
|
||||
seen[kid] = true
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("instanzen signieren mit %d verschiedenen kids", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistent_RotationOverlap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
a := newPersistent(t, store, c, clock)
|
||||
key1, _ := a.SigningKey()
|
||||
tok1, _ := a.Issue("u1", "acme", 10*time.Minute)
|
||||
|
||||
clock.Advance(time.Hour)
|
||||
key2, err := a.RotateContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sk, _ := a.SigningKey(); sk.KID != key1.KID {
|
||||
t.Fatalf("neuer schluessel signiert vor der aktivierungsverzoegerung: %s", sk.KID)
|
||||
}
|
||||
if set := a.PublicKeySet(); len(set) != 2 {
|
||||
t.Fatalf("jwks direkt nach der rotation: %d schluessel, erwartet 2", len(set))
|
||||
}
|
||||
|
||||
clock.Advance(2 * time.Minute)
|
||||
tok2, err := a.Issue("u2", "acme", 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := kidOf(t, tok2); got != key2.KID {
|
||||
t.Fatalf("neues token mit kid %s, erwartet %s", got, key2.KID)
|
||||
}
|
||||
if _, err := verifyWith(a, tok1); err != nil {
|
||||
t.Fatalf("token des alten schluessels waehrend der ueberlappung ungueltig: %v", err)
|
||||
}
|
||||
if _, err := verifyWith(a, tok2); err != nil {
|
||||
t.Fatalf("token des neuen schluessels ungueltig: %v", err)
|
||||
}
|
||||
|
||||
clock.Advance(10*time.Minute + 29*time.Second)
|
||||
if set, err := a.PublicKeySetChecked(); err != nil || len(set) != 2 {
|
||||
t.Fatalf("alter schluessel vor ablauf von ttl und toleranz entfernt: %v %d", err, len(set))
|
||||
}
|
||||
clock.Advance(31 * time.Second)
|
||||
set, err := a.PublicKeySetChecked()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := set[key1.KID]; ok || len(set) != 1 {
|
||||
t.Fatalf("alter schluessel nach ablauf noch in der jwks: %v", set)
|
||||
}
|
||||
if snap := store.Snapshot(); len(snap) != 1 || snap[0].KID != key2.KID {
|
||||
t.Fatalf("abgelaufener schluessel nicht aus dem speicher entfernt: %d zeilen", len(snap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistent_OtherInstanceTakesOverRotation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
a := newPersistent(t, store, c, clock)
|
||||
b := newPersistent(t, store, c, clock)
|
||||
key2, err := a.RotateContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := b.PublicKeySet()[key2.KID]; ok {
|
||||
t.Fatal("b kennt den neuen schluessel vor dem aktualisierungsintervall")
|
||||
}
|
||||
clock.Advance(31 * time.Second)
|
||||
set, err := b.PublicKeySetChecked()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := set[key2.KID]; !ok {
|
||||
t.Fatal("b hat den neuen schluessel nach dem aktualisierungsintervall nicht uebernommen")
|
||||
}
|
||||
clock.Advance(2 * time.Minute)
|
||||
if sk, err := b.SigningKey(); err != nil || sk.KID != key2.KID {
|
||||
t.Fatalf("b signiert nicht mit dem neuen schluessel: %v %s", err, sk.KID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistent_FailsClosedWhenStoreUnavailable(t *testing.T) {
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
a := newPersistent(t, store, c, clock)
|
||||
clock.Advance(time.Minute)
|
||||
store.SetFailing(true)
|
||||
|
||||
if _, err := a.Issue("u1", "acme", time.Minute); !errors.Is(err, ErrKeyStoreUnavailable) {
|
||||
t.Fatalf("issue bei ausfall: %v, erwartet ErrKeyStoreUnavailable", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
a.ServeJWKS(rec, httptest.NewRequest(http.MethodGet, "/jwks", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("jwks bei ausfall: %d, erwartet 503", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("503 der jwks darf nicht gecacht werden: %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
if _, err := a.RotateContext(context.Background()); !errors.Is(err, ErrKeyStoreUnavailable) {
|
||||
t.Fatalf("rotation bei ausfall: %v", err)
|
||||
}
|
||||
|
||||
store.SetFailing(false)
|
||||
if _, err := a.Issue("u1", "acme", time.Minute); err != nil {
|
||||
t.Fatalf("issue nach wiederanlauf des speichers: %v", err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
a.ServeJWKS(rec, httptest.NewRequest(http.MethodGet, "/jwks", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("jwks nach wiederanlauf: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistent_PrivateKeyNeverStoredInPlain(t *testing.T) {
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
a := newPersistent(t, store, c, clock)
|
||||
if _, err := a.RotateContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := map[string]KeyPair{}
|
||||
a.mu.RLock()
|
||||
for _, kp := range a.keys {
|
||||
all[kp.KID] = kp
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
for _, sk := range store.Snapshot() {
|
||||
kp := all[sk.KID]
|
||||
if bytes.Contains(sk.PrivateCiphertext, kp.Private.Seed()) || bytes.Contains(sk.PrivateCiphertext, kp.Private) {
|
||||
t.Fatalf("privater schluessel %s im klartext gespeichert", sk.KID)
|
||||
}
|
||||
if len(sk.PrivateCiphertext) <= ed25519.SeedSize {
|
||||
t.Fatalf("chiffretext von %s zu kurz: %d", sk.KID, len(sk.PrivateCiphertext))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistent_WrongKeyOrTamperedRowFailsClosed(t *testing.T) {
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
a := newPersistent(t, store, c, clock)
|
||||
if _, err := a.RotateContext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := NewPersistentKeyManager(context.Background(), testConfig(store, newTestCipher(t), clock))
|
||||
if !errors.Is(err, ErrKeyStoreUnavailable) {
|
||||
t.Fatalf("start mit falschem verschluesselungsschluessel: %v", err)
|
||||
}
|
||||
|
||||
snap := store.Snapshot()
|
||||
if len(snap) != 2 {
|
||||
t.Fatalf("erwartet 2 schluessel, habe %d", len(snap))
|
||||
}
|
||||
store.Tamper(snap[0].KID, snap[1].PrivateCiphertext)
|
||||
store.Tamper(snap[1].KID, snap[0].PrivateCiphertext)
|
||||
if _, err := NewPersistentKeyManager(context.Background(), testConfig(store, c, clock)); !errors.Is(err, ErrKeyStoreUnavailable) {
|
||||
t.Fatalf("start mit vertauschten zeilen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistent_AutoRotationHappensExactlyOnceAcrossInstances(t *testing.T) {
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
withRotation := func(cfg *PersistentConfig) { cfg.RotationInterval = 24 * time.Hour }
|
||||
managers := []*KeyManager{
|
||||
newPersistent(t, store, c, clock, withRotation),
|
||||
newPersistent(t, store, c, clock, withRotation),
|
||||
newPersistent(t, store, c, clock, withRotation),
|
||||
}
|
||||
clock.Advance(25 * time.Hour)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, len(managers))
|
||||
for _, m := range managers {
|
||||
wg.Add(1)
|
||||
go func(m *KeyManager) {
|
||||
defer wg.Done()
|
||||
if _, err := m.SigningKey(); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}(m)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("signierschluessel: %v", err)
|
||||
}
|
||||
if got := len(store.Snapshot()); got != 2 {
|
||||
t.Fatalf("schluessel nach automatischer rotation = %d, erwartet 2 (genau eine rotation)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentConfig_Validation(t *testing.T) {
|
||||
store, clock, c := NewMemKeyStore(), newFakeClock(), newTestCipher(t)
|
||||
ctx := context.Background()
|
||||
cases := map[string]func(*PersistentConfig){
|
||||
"ohne store": func(cfg *PersistentConfig) { cfg.Store = nil },
|
||||
"ohne cipher": func(cfg *PersistentConfig) { cfg.Cipher = nil },
|
||||
"ttl null": func(cfg *PersistentConfig) { cfg.TokenTTL = 0 },
|
||||
"aktivierung zu kurz": func(cfg *PersistentConfig) { cfg.ActivationDelay = time.Second },
|
||||
"negatives intervall": func(cfg *PersistentConfig) { cfg.RotationInterval = -time.Hour },
|
||||
}
|
||||
for name, mod := range cases {
|
||||
cfg := testConfig(store, c, clock)
|
||||
mod(&cfg)
|
||||
if _, err := NewPersistentKeyManager(ctx, cfg); err == nil {
|
||||
t.Errorf("%s: konfiguration wurde akzeptiert", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package moduletrust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// advisoryLockKey serialisiert Schluesselaenderungen aller Instanzen einer Mandanten-Datenbank.
|
||||
const advisoryLockKey int64 = 0x4e5841524d4b53 // "NXARMKS"
|
||||
|
||||
type PGKeyStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGKeyStore(pool *pgxpool.Pool) *PGKeyStore {
|
||||
return &PGKeyStore{pool: pool}
|
||||
}
|
||||
|
||||
const selectKeys = `SELECT kid, public_key, private_key_ciphertext, created_at, active_from, retire_after
|
||||
FROM module_signing_keys ORDER BY active_from, created_at`
|
||||
|
||||
type querier interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
}
|
||||
|
||||
func listKeys(ctx context.Context, q querier) ([]StoredKey, error) {
|
||||
rows, err := q.Query(ctx, selectKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []StoredKey
|
||||
for rows.Next() {
|
||||
var k StoredKey
|
||||
var pub []byte
|
||||
if err := rows.Scan(&k.KID, &pub, &k.PrivateCiphertext, &k.CreatedAt, &k.ActiveFrom, &k.RetireAfter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.Public = pub
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGKeyStore) Load(ctx context.Context) ([]StoredKey, error) {
|
||||
return listKeys(ctx, s.pool)
|
||||
}
|
||||
|
||||
func (s *PGKeyStore) Update(ctx context.Context, fn func(tx KeyTx) error) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryLockKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(&pgKeyTx{tx: tx}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
type pgKeyTx struct {
|
||||
tx pgx.Tx
|
||||
}
|
||||
|
||||
func (t *pgKeyTx) List(ctx context.Context) ([]StoredKey, error) {
|
||||
return listKeys(ctx, t.tx)
|
||||
}
|
||||
|
||||
func (t *pgKeyTx) Insert(ctx context.Context, k StoredKey) error {
|
||||
_, err := t.tx.Exec(ctx, `INSERT INTO module_signing_keys (kid, public_key, private_key_ciphertext, created_at, active_from, retire_after)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`, k.KID, []byte(k.Public), k.PrivateCiphertext, k.CreatedAt, k.ActiveFrom, k.RetireAfter)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *pgKeyTx) SetRetireAfter(ctx context.Context, kid string, at time.Time) error {
|
||||
_, err := t.tx.Exec(ctx, `UPDATE module_signing_keys SET retire_after = $2 WHERE kid = $1`, kid, at)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *pgKeyTx) DeleteRetiredBefore(ctx context.Context, at time.Time) error {
|
||||
_, err := t.tx.Exec(ctx, `DELETE FROM module_signing_keys WHERE retire_after IS NOT NULL AND retire_after <= $1`, at)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package moduletrust
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const migrationDir = "../../migrations/tenant/"
|
||||
|
||||
func readMigration(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(migrationDir + name)
|
||||
if err != nil {
|
||||
t.Fatalf("migration lesen: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// setupKeyDB legt eine frische Datenbank an und wendet die API-16-Migration an; ohne TEST_ADMIN_DSN uebersprungen.
|
||||
func setupKeyDB(t *testing.T) (dsn string, open func() *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||
if adminDSN == "" {
|
||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("admin pool: %v", err)
|
||||
}
|
||||
suffix := make([]byte, 6)
|
||||
_, _ = rand.Read(suffix)
|
||||
dbName := "mt_keys_" + hex.EncodeToString(suffix)
|
||||
if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
||||
t.Fatalf("testdatenbank anlegen: %v", err)
|
||||
}
|
||||
dsn = strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
||||
|
||||
var pools []*pgxpool.Pool
|
||||
open = func() *pgxpool.Pool {
|
||||
p, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect testdatenbank: %v", err)
|
||||
}
|
||||
pools = append(pools, p)
|
||||
return p
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, p := range pools {
|
||||
p.Close()
|
||||
}
|
||||
_, _ = admin.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q WITH (FORCE)`, dbName))
|
||||
admin.Close()
|
||||
})
|
||||
if _, err := open().Exec(ctx, readMigration(t, "0008_module_signing_keys.up.sql")); err != nil {
|
||||
t.Fatalf("migration anwenden: %v", err)
|
||||
}
|
||||
return dsn, open
|
||||
}
|
||||
|
||||
func rowCount(t *testing.T, pool *pgxpool.Pool) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM module_signing_keys`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestPGStore_RestartConcurrentStartAndRotation(t *testing.T) {
|
||||
_, open := setupKeyDB(t)
|
||||
ctx := context.Background()
|
||||
clock, c := newFakeClock(), newTestCipher(t)
|
||||
|
||||
const n = 6
|
||||
errs := make(chan error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
pool := open()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := NewPersistentKeyManager(ctx, testConfig(NewPGKeyStore(pool), c, clock)); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("paralleler start: %v", err)
|
||||
}
|
||||
pool := open()
|
||||
if got := rowCount(t, pool); got != 1 {
|
||||
t.Fatalf("zeilen nach parallelem start = %d, erwartet 1", got)
|
||||
}
|
||||
|
||||
a, err := NewPersistentKeyManager(ctx, testConfig(NewPGKeyStore(open()), c, clock))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key1, _ := a.SigningKey()
|
||||
tok, _ := a.Issue("u1", "acme", 10*time.Minute)
|
||||
|
||||
restarted, err := NewPersistentKeyManager(ctx, testConfig(NewPGKeyStore(open()), c, clock))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sk, _ := restarted.SigningKey(); sk.KID != key1.KID {
|
||||
t.Fatalf("kid nach neustart %s, erwartet %s", sk.KID, key1.KID)
|
||||
}
|
||||
if _, err := verifyWith(restarted, tok); err != nil {
|
||||
t.Fatalf("token vor dem neustart ungueltig: %v", err)
|
||||
}
|
||||
|
||||
seed := key1.Private.Seed()
|
||||
rows, err := pool.Query(ctx, `SELECT private_key_ciphertext FROM module_signing_keys`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var ct []byte
|
||||
if err := rows.Scan(&ct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(ct, seed) || bytes.Contains(ct, key1.Private) {
|
||||
t.Fatal("privater schluessel im klartext in der datenbank")
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
key2, err := a.RotateContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rowCount(t, pool); got != 2 {
|
||||
t.Fatalf("zeilen nach rotation = %d, erwartet 2", got)
|
||||
}
|
||||
clock.Advance(2*time.Minute + 10*time.Minute + 31*time.Second + 31*time.Second)
|
||||
set, err := restarted.PublicKeySetChecked()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := set[key1.KID]; ok {
|
||||
t.Fatal("alter schluessel nach ablauf noch in der jwks")
|
||||
}
|
||||
if _, ok := set[key2.KID]; !ok {
|
||||
t.Fatal("neuer schluessel fehlt in der jwks")
|
||||
}
|
||||
if got := rowCount(t, pool); got != 1 {
|
||||
t.Fatalf("abgelaufener schluessel nicht aus der datenbank entfernt: %d zeilen", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGStore_MigrationDownAndUp(t *testing.T) {
|
||||
_, open := setupKeyDB(t)
|
||||
ctx := context.Background()
|
||||
pool := open()
|
||||
if _, err := pool.Exec(ctx, readMigration(t, "0008_module_signing_keys.down.sql")); err != nil {
|
||||
t.Fatalf("down: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `SELECT 1 FROM module_signing_keys`); err == nil {
|
||||
t.Fatal("tabelle nach down noch vorhanden")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, readMigration(t, "0008_module_signing_keys.up.sql")); err != nil {
|
||||
t.Fatalf("erneutes up: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS module_signing_keys;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- API-16: Persistente Ed25519-Signaturschluessel fuer Modul-JWTs je Mandanten-Datenbank.
|
||||
-- private_key_ciphertext enthaelt AUSSCHLIESSLICH den mit dem Schluessel aus der Umgebung
|
||||
-- (AES-256-GCM, Nonce vorangestellt) verpackten privaten Schluessel, nie Klartext.
|
||||
-- active_from: ab wann der Schluessel signiert (Verifier-Caches kennen ihn dann);
|
||||
-- retire_after: ab wann er nicht mehr in der JWKS steht (alle damit signierten Tokens abgelaufen).
|
||||
CREATE TABLE module_signing_keys (
|
||||
kid TEXT PRIMARY KEY,
|
||||
public_key BYTEA NOT NULL CHECK (octet_length(public_key) = 32),
|
||||
private_key_ciphertext BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
active_from TIMESTAMPTZ NOT NULL,
|
||||
retire_after TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX module_signing_keys_active_from_idx ON module_signing_keys (active_from);
|
||||
@@ -24,6 +24,7 @@ trap cleanup EXIT
|
||||
password="e2e-$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
policy_token="e2e-$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
jwt_secret="e2e-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
module_key_encryption_key="$(head -c 32 /dev/urandom | base64)"
|
||||
|
||||
"$cluster" resettenant
|
||||
for f in $(ls migrations/*.up.sql | sort); do
|
||||
@@ -38,6 +39,7 @@ go build -o "$work/policy-api" ./cmd/policy-api
|
||||
NEXARCH_E2E_TENANT_DSN="$TEST_TENANT_DSN" NEXARCH_E2E_PASSWORD="$password" go run ./scripts/e2e-seed
|
||||
|
||||
NEXARCH_ACCOUNT_TENANT_DSN="$TEST_TENANT_DSN" NEXARCH_ACCOUNT_TENANT_SLUG=e2e NEXARCH_ACCOUNT_JWT_SECRET="$jwt_secret" \
|
||||
NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY="$module_key_encryption_key" NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS=1 \
|
||||
NEXARCH_ACCOUNT_API_LISTEN_ADDR=127.0.0.1:8099 NEXARCH_ACCOUNT_MODULE_TOKEN_TTL_SECONDS=5 \
|
||||
"$work/account-api" >"$work/account.log" 2>&1 &
|
||||
pids+=($!)
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# API-16 Ende-zu-Ende auf dem Entwicklungshost (Wegwerf-Cluster 17/coredev): echte Kontodienst-Prozesse
|
||||
# pruefen persistente Modul-Signaturschluessel: Neustart, zwei Instanzen, Rotation mit Ueberlappung,
|
||||
# Ausfall des Schluesselspeichers (503) und falscher Verschluesselungsschluessel. Dauer rund 3 Minuten.
|
||||
# usage (im Repo-Root, innerhalb der gemeinsamen Sperre): scripts/e2e-module-keys.sh
|
||||
set -euo pipefail
|
||||
|
||||
cluster=/etc/nexarch-dev/core-cluster.sh
|
||||
set -a
|
||||
source /etc/nexarch-dev/core-dev.env
|
||||
source /etc/nexarch-dev/tenant-dev.env
|
||||
set +a
|
||||
export PATH="$PATH:/usr/local/go/bin"
|
||||
|
||||
work="$(mktemp -d /tmp/nexarch-e2e-keys-XXXXXX)"
|
||||
pids=()
|
||||
cleanup() {
|
||||
for p in "${pids[@]:-}"; do kill "$p" 2>/dev/null || true; done
|
||||
psql "$TEST_TENANT_DSN" -q -c "ALTER TABLE IF EXISTS module_signing_keys_x RENAME TO module_signing_keys" >/dev/null 2>&1 || true
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
fail() { echo "FAIL: $*" >&2; tail -15 "$work"/*.log >&2 2>/dev/null || true; exit 1; }
|
||||
pass() { echo "PASS: $*"; }
|
||||
|
||||
rand() { head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'; }
|
||||
password="e2e-$(rand 12)"
|
||||
jwt_secret="e2e-$(rand 16)"
|
||||
enc_key="$(head -c 32 /dev/urandom | base64)"
|
||||
other_key="$(head -c 32 /dev/urandom | base64)"
|
||||
|
||||
"$cluster" resettenant
|
||||
for f in $(ls migrations/tenant/*.up.sql | sort); do
|
||||
psql "$TEST_TENANT_DSN" -q -v ON_ERROR_STOP=1 -f "$f" >/dev/null
|
||||
done
|
||||
go build -o "$work/account-api" ./cmd/account-api
|
||||
go build -o "$work/verify" ./scripts/e2e-modulekeys-verify
|
||||
NEXARCH_E2E_TENANT_DSN="$TEST_TENANT_DSN" NEXARCH_E2E_PASSWORD="$password" go run ./scripts/e2e-seed
|
||||
|
||||
TTL=30
|
||||
ACT=60
|
||||
base_env=(NEXARCH_ACCOUNT_TENANT_DSN="$TEST_TENANT_DSN" NEXARCH_ACCOUNT_TENANT_SLUG=e2e NEXARCH_ACCOUNT_JWT_SECRET="$jwt_secret"
|
||||
NEXARCH_ACCOUNT_MODULE_TOKEN_TTL_SECONDS=$TTL NEXARCH_ACCOUNT_MODULE_KEY_ACTIVATION_DELAY_SECONDS=$ACT
|
||||
NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS=1 NEXARCH_ACCOUNT_MODULE_KEY_ROTATION_DAYS=0)
|
||||
# env ersetzt sich selbst durch das Programm: $! ist danach die PID des Kontodienstes (kein Zwischenprozess, der beim kill uebrig bleibt)
|
||||
LAST_PID=""
|
||||
start() { # port name [key]
|
||||
local key="${3:-$enc_key}"
|
||||
env "${base_env[@]}" NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY="$key" NEXARCH_ACCOUNT_API_LISTEN_ADDR="127.0.0.1:$1" \
|
||||
"$work/account-api" >"$work/$2.log" 2>&1 &
|
||||
LAST_PID=$!
|
||||
pids+=("$LAST_PID")
|
||||
}
|
||||
wait_up() { for _ in $(seq 1 60); do curl -fsS "http://127.0.0.1:$1/healthz" >/dev/null 2>&1 && return 0; sleep 0.2; done; fail "dienst auf $1 nicht erreichbar"; }
|
||||
kids() { curl -fsS "http://127.0.0.1:$1/auth/module-jwks" | grep -o '"kid":"[^"]*"' | cut -d'"' -f4 | sort | tr '\n' ' '; }
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
|
||||
login() { curl -fsS -c "$work/jar-$1" -H 'Content-Type: application/json' -d "{\"email\":\"alice@e2e.test\",\"password\":\"$password\"}" "http://127.0.0.1:$1/auth/login" >/dev/null; }
|
||||
mint() { curl -fsS -b "$work/jar-$1" -X POST "http://127.0.0.1:$1/auth/module-token" | grep -o '"token":"[^"]*"' | cut -d'"' -f4; }
|
||||
tokkid() { local seg; seg="$(echo "$1" | cut -d. -f1 | tr '_-' '/+')"; while [ $(( ${#seg} % 4 )) -ne 0 ]; do seg="$seg="; done; echo "$seg" | base64 -d 2>/dev/null | grep -o '"kid":"[^"]*"' | cut -d'"' -f4; }
|
||||
valid() { "$work/verify" "http://127.0.0.1:$1/auth/module-jwks" "$2" >/dev/null; }
|
||||
|
||||
start 8099 a; a_pid=$LAST_PID; wait_up 8099
|
||||
login 8099
|
||||
kid0="$(kids 8099 | tr -d ' ')"
|
||||
[ -n "$kid0" ] || fail "keine kid in der jwks"
|
||||
tok0="$(mint 8099)"
|
||||
[ "$(tokkid "$tok0")" = "$kid0" ] || fail "token traegt nicht die aktive kid"
|
||||
valid 8099 "$tok0" || fail "token direkt nach dem start ungueltig"
|
||||
pass "start: jwks liefert kid $kid0, token gueltig"
|
||||
|
||||
kill "$a_pid"; wait "$a_pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 50); do curl -fsS "http://127.0.0.1:8099/healthz" >/dev/null 2>&1 || break; sleep 0.1; done
|
||||
curl -fsS "http://127.0.0.1:8099/healthz" >/dev/null 2>&1 && fail "kontodienst auf 8099 laeuft nach dem beenden weiter (kein echter neustart)"
|
||||
start 8099 a2; a2_pid=$LAST_PID; wait_up 8099
|
||||
[ "$a2_pid" != "$a_pid" ] && kill -0 "$a2_pid" 2>/dev/null || fail "neu gestarteter kontodienst laeuft nicht"
|
||||
[ "$(kids 8099 | tr -d ' ')" = "$kid0" ] || fail "kid nach neustart geaendert"
|
||||
valid 8099 "$tok0" || fail "token vor dem neustart nach neustart ungueltig"
|
||||
pass "neustart (alter prozess beendet, neuer prozess $a2_pid): dieselbe kid, token vor dem neustart gueltig"
|
||||
|
||||
start 8098 b; wait_up 8098
|
||||
[ "$(kids 8098 | tr -d ' ')" = "$kid0" ] || fail "zweite instanz hat andere schluessel"
|
||||
login 8098
|
||||
tokb="$(mint 8098)"
|
||||
tok_a="$(mint 8099)"
|
||||
valid 8099 "$tokb" || fail "token der instanz b gegen jwks von a ungueltig"
|
||||
valid 8098 "$tok_a" || fail "token der instanz a gegen jwks von b ungueltig"
|
||||
[ "$(psql "$TEST_TENANT_DSN" -Atc 'SELECT count(*) FROM module_signing_keys')" = 1 ] || fail "mehr als ein schluessel nach start zweier instanzen"
|
||||
pass "zwei instanzen: gleiche schluessel, gegenseitig verifizierbar, genau ein schluessel in der datenbank"
|
||||
|
||||
rot_start=$(date +%s)
|
||||
env "${base_env[@]}" NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY="$enc_key" "$work/account-api" -rotate-module-key >"$work/rotate.log" 2>&1 || fail "rotation fehlgeschlagen"
|
||||
sleep 3
|
||||
[ "$(kids 8099 | wc -w)" = 2 ] || fail "a kennt den neuen schluessel nach 3 s nicht"
|
||||
[ "$(kids 8098 | wc -w)" = 2 ] || fail "b kennt den neuen schluessel nach 3 s nicht"
|
||||
tok_pending="$(mint 8099)"
|
||||
[ "$(tokkid "$tok_pending")" = "$kid0" ] || fail "neuer schluessel signiert vor der aktivierungsverzoegerung"
|
||||
pass "rotation: beide instanzen listen 2 schluessel, signiert wird bis zur aktivierung mit dem alten"
|
||||
|
||||
sleep $(( ACT + 3 ))
|
||||
tok_new="$(mint 8099)"
|
||||
kid1="$(tokkid "$tok_new")"
|
||||
[ -n "$kid1" ] && [ "$kid1" != "$kid0" ] || fail "nach der aktivierungsverzoegerung signiert weiter der alte schluessel"
|
||||
[ "$(tokkid "$(mint 8098)")" = "$kid1" ] || fail "instanz b signiert nicht mit dem neuen schluessel"
|
||||
valid 8098 "$tok_new" || fail "token mit neuer kid ungueltig"
|
||||
[ "$(kids 8099 | wc -w)" = 2 ] || fail "alter schluessel vor ablauf von ttl und toleranz entfernt"
|
||||
pass "nach aktivierung: neue kid $kid1, alter schluessel bleibt in der jwks"
|
||||
|
||||
wait_until=$(( rot_start + ACT + TTL + 30 + 8 ))
|
||||
now=$(date +%s)
|
||||
[ "$now" -lt "$wait_until" ] && sleep $(( wait_until - now ))
|
||||
[ "$(kids 8099 | tr -d ' ')" = "$kid1" ] || fail "alter schluessel nach ablauf noch in der jwks von a: $(kids 8099)"
|
||||
[ "$(kids 8098 | tr -d ' ')" = "$kid1" ] || fail "alter schluessel nach ablauf noch in der jwks von b"
|
||||
[ "$(psql "$TEST_TENANT_DSN" -Atc 'SELECT count(*) FROM module_signing_keys')" = 1 ] || fail "abgelaufener schluessel nicht aus der datenbank entfernt"
|
||||
tok_last="$(mint 8098)"
|
||||
valid 8099 "$tok_last" || fail "token nach dem entfernen des alten schluessels ungueltig"
|
||||
pass "nach ablauf von ttl und toleranz: alter schluessel aus jwks und datenbank entfernt"
|
||||
|
||||
psql "$TEST_TENANT_DSN" -q -c "ALTER TABLE module_signing_keys RENAME TO module_signing_keys_x" >/dev/null
|
||||
sleep 2
|
||||
[ "$(code "http://127.0.0.1:8099/auth/module-jwks")" = 503 ] || fail "jwks bei nicht lesbarem schluesselspeicher nicht 503"
|
||||
[ "$(code -b "$work/jar-8099" -X POST "http://127.0.0.1:8099/auth/module-token")" = 503 ] || fail "token bei nicht lesbarem schluesselspeicher nicht 503"
|
||||
psql "$TEST_TENANT_DSN" -q -c "ALTER TABLE module_signing_keys_x RENAME TO module_signing_keys" >/dev/null
|
||||
sleep 2
|
||||
[ "$(code "http://127.0.0.1:8099/auth/module-jwks")" = 200 ] || fail "jwks nach wiederanlauf nicht 200"
|
||||
[ "$(code -b "$work/jar-8099" -X POST "http://127.0.0.1:8099/auth/module-token")" = 200 ] || fail "token nach wiederanlauf nicht 200"
|
||||
pass "ausfall des schluesselspeichers: 503 fuer jwks und token, danach wieder 200"
|
||||
|
||||
start 8097 wrongkey "$other_key"; wrong_pid=$LAST_PID
|
||||
for _ in $(seq 1 50); do kill -0 "$wrong_pid" 2>/dev/null || break; sleep 0.2; done
|
||||
if kill -0 "$wrong_pid" 2>/dev/null; then fail "dienst mit falschem verschluesselungsschluessel laeuft"; fi
|
||||
grep -q "entschluesseln" "$work/wrongkey.log" || fail "keine meldung zum falschen schluessel: $(tail -3 "$work/wrongkey.log")"
|
||||
env "${base_env[@]}" NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY="" NEXARCH_ACCOUNT_API_LISTEN_ADDR=127.0.0.1:8096 "$work/account-api" >"$work/nokey.log" 2>&1 && fail "dienst ohne verschluesselungsschluessel startet"
|
||||
grep -q "MODULE_KEY_ENCRYPTION_KEY" "$work/nokey.log" || fail "keine meldung zum fehlenden schluessel"
|
||||
pass "falscher oder fehlender verschluesselungsschluessel: dienst startet nicht"
|
||||
|
||||
echo "ALLE PRUEFUNGEN BESTANDEN"
|
||||
@@ -0,0 +1,56 @@
|
||||
// e2e-modulekeys-verify prueft ein Modul-JWT gegen die JWKS eines Kontodienstes
|
||||
// (Hilfswerkzeug fuer scripts/e2e-module-keys.sh). usage: e2e-modulekeys-verify <jwks-url> <token>
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: e2e-modulekeys-verify <jwks-url> <token>")
|
||||
os.Exit(2)
|
||||
}
|
||||
url, token := os.Args[1], os.Args[2]
|
||||
v := moduletrust.NewVerifier(time.Minute, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("jwks status %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := moduletrust.ParseJWKS(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]ed25519.PublicKey, len(raw))
|
||||
for kid, k := range raw {
|
||||
out[kid] = ed25519.PublicKey(k)
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
claims, err := v.Verify(context.Background(), token)
|
||||
if err != nil {
|
||||
fmt.Println("INVALID:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("VALID sub=%s tenant=%s role=%s\n", claims.Subject, claims.TenantSlug, claims.Role)
|
||||
}
|
||||
Reference in new issue
Block a user