JWKS-Schluesselverwaltung
Jede MDS 2.0-Antwort traegt einen MDS-JWT-Header -- einen RS256-signierten JWT, der nachweist, dass der Response-Body-sha256 von Ihrem Subaccount stammt. Die oeffentliche Seite dieses Schluesselpaars wird unter .well-known/jwks.json veroeffentlicht. Dieser Artikel behandelt Speicherung, Rotation und Wiederherstellung dieser Schluessel.
Speicherung
Schluessel liegen in der mds_jwks_keys-Tabelle, eine oder mehrere Zeilen pro Subaccount:
| Spalte | Inhalt |
|---|---|
id | UUID |
subaccount_id | FK zum Betreiber-Subaccount |
kid | Im JWT-Header und in der JWKS-Antwort sichtbare Key-ID |
algorithm | Heute immer RS256 |
public_key_pem | PEM-kodierter SPKI-Public-Key |
private_key_pem | PEM-kodierter PKCS#8-Private-Key |
is_active | Genau eine Zeile pro Subaccount hat is_active = true |
created_at | Erstellungszeit |
rotated_at | Wann der Schluessel aufgehoert hat aktiv zu sein (null, falls noch aktiv) |
expires_at | Wann der Schluessel das JWKS-Karenzfenster verlaesst |
Speicherung des privaten Schluessels
private_key_pem wird derzeit unverschluesselt in Supabase gespeichert. Eine Migration zu pgsodium / Supabase Vault steht vor dem Rollout an einen zahlenden Enterprise-Kunden auf der Roadmap. Behandeln Sie die Zeile als sensibel -- nicht in Exporte, nicht verschluesselte Backups oder geteilte Kopien an Staedte aufnehmen.
Lazy-Minting
Beim ersten Aufruf von /api/mds/{subaccountId}/.well-known/jwks.json oder eines /provider/v2/*-Endpunkts macht getOrCreateActiveJwksKey() Folgendes:
- Pruefen, ob in
mds_jwks_keyseine Zeile mitis_active = trueexistiert. - Falls nicht, frisches RS256-Schluesselpaar via Nodes
crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })generieren. - PEM in JWK-Format konvertieren (
n,e,kty,alg,kid,use). - Zeile mit frisch generierter
kideinfuegen (Format:mds-<YYYY-MM>-<short_hash>). - Die aktive Zeile an den Aufrufer zurueckgeben.
Damit kann ein Betreiber komplett ohne manuelle Schluesseleinrichtung onboarden -- das erste Ping des Stadt-Validators loest die Erstellung aus.
Rotation
Jaehrliche Rotation ist die empfohlene Kadenz und entspricht der OMF-Vorgabe.
Zur Rotation:
Neuen Schluessel erzeugen
Neue Zeile mit is_active = true und frischer kid einfuegen. Das Schluesselpaar wird serverseitig generiert; Sie beruehren nie den privaten Schluessel.
Vorherigen Schluessel zurueckstufen
is_active = false und rotated_at = now() auf der vorherigen aktiven Zeile setzen. expires_at = now() + 7 Tage, damit Staedte ihren JWKS-Cache aktualisieren koennen.
Bestaetigen, dass JWKS beide veroeffentlicht
/.well-known/jwks.json aufrufen und bestaetigen, dass beide kids im keys-Array erscheinen. Vor der Rotation signierte JWTs verifizieren weiterhin; neue Antworten nutzen den neuen Schluessel.
Nach dem Karenzfenster
Die zurueckgestufte Zeile loeschen (oder expires_at = now() markieren). Sie faellt automatisch aus der JWKS.
JWKS-Antwortform
{
"keys": [
{
"kty": "RSA",
"kid": "mds-2026-05-a7c2",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQ...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "mds-2025-05-9f1d",
"use": "sig",
"alg": "RS256",
"n": "1vyx8wagoeb2X...",
"e": "AQAB"
}
]
}
Staedte waehlen den richtigen Schluessel, indem sie die kid des JWT-Headers gegen die kids in der JWKS abgleichen. Wir veroeffentlichen aktive + Karenzfenster-Schluessel; rotierte und abgelaufene Schluessel werden nie veroeffentlicht.
Einen JWT lokal verifizieren
Der OMF-mds-provider-validator macht das automatisch, aber Sie koennen manuell verifizieren:
# Eine Antwort abrufen
curl -i -H "Authorization: Bearer <token>" \
https://fleets.levyelectric.com/api/mds/<subaccountId>/provider/v2/vehicles/status > resp.txt
# Den JWT extrahieren
JWT=$(grep -i '^MDS-JWT:' resp.txt | awk '{print $2}' | tr -d '\r')
# JWKS holen
curl https://fleets.levyelectric.com/api/mds/<subaccountId>/.well-known/jwks.json > jwks.json
# Mit mkjwk oder jose CLI verifizieren
jose jws ver -i <(echo "$JWT") -k <(jq '.keys[0]' jwks.json)
Eine saubere Verifikation gibt payload verified plus die JWT-Payload (Issuer, Audience, Body-sha256, iat, exp) zurueck.
Wiederherstellung bei privatem Schluessel-Leak
Falls der private Schluessel exponiert wird (versehentlich geloggt, an eine Stadt geteilt usw.):
- Sofort ein neues Schluesselpaar erzeugen (Schritte oben).
- Die geleakte Zeile auf
expires_at = now()setzen -- sie faellt sofort aus der JWKS, sodass keine laufenden Anfragen mehr damit verifizieren. - Staedte benachrichtigen, die JWT-Verifikation nutzen (sie holen die JWKS still neu).
mds_policy_auditund Sentry auf anomale Anfragen im Leak-Fenster pruefen.
Bearer-Token-Auth ist von JWKS-Leaks unberuehrt -- der JWT ist eine zusaetzliche Integritaetsschicht, nicht das Primaer-Credential. Staedte, die nur Bearer validieren, sehen keine Auswirkung.
Fehlerbehebung
MDS-JWT-Header fehlt in einer Antwort --mds_jwks_keyshat keine Zeile mitis_active = true./.well-known/jwks.jsondirekt aufrufen, um eine Lazy-Mint zu erzwingen.- JWKS gibt
{"keys": []}zurueck -- wahrscheinlich dieselbe Ursache. Die Lazy-Mint sollte sie beim naechsten Lesevorgang fuellen. - JWT-Verifikation scheitert mit "kid mismatch" -- der JWKS-Cache der Stadt ist veraltet. Sie sollte neu holen und es erneut versuchen. Falls weiterhin fehlschlagend, bestaetigen, dass die
kidder aktiven Zeile dem im Response-Header gefuehrten Wert entspricht. - JWT-Verifikation scheitert mit "signature invalid" -- der oeffentliche Schluessel in JWKS passt nicht zum privaten, der den JWT signiert hat. Wahrscheinlich zwei konkurrierende Schreibvorgaenge bei der Lazy-Mint; aeltere Zeile deaktivieren, um es zu klaeren.
Was kommt als naechstes?
- MDS Provider-Einrichtung -- vollstaendige Endpunkt-Referenz.
- Fehlerbehebung -- wenn JWTs oder JWKS Fehler machen.