Aller au contenu

ADR-0013 — JWT asymétrique (RS256), kid + JWKS et rotation des clés de signature

Date : 2026-06-14 (clarifiée 2026-06-21) Catégorie : A (convention de sécurité transverse — réalise le palier 2 d'ADR-0011) Décideur : Lead architecte DYNORS Statut : Acceptée — palier 1 livré 2026-06, palier 2 à venir (split clarifié 2026-06-21) Réalise : ADR-0011 §3 « Reste à faire — palier 2 : signature asymétrique RS256/EdDSA (dynors-auth JWKS), rotation ». Active le slot Codex existant TAIL_AUTH_KEY_MODE=RS256_ASYMMETRIC. S'appuie sur : ADR-0008 (isolement de secret par app — analogie), IDENTITE_APPLICATIVE_3_LAYERS.md (audience = tech code).


⚠️ Split des paliers (clarification 2026-06-21 — incohérence index résolue)

L'ADR couvre deux paliers distincts dont l'état réel est différent. L'index a été corrigé pour refléter cette réalité.

Palier 1 — Multi-algorithmes statique (LIVRÉ)

  • JwtKeys (core/security) supporte HS256, RS256, ES256, EdDSA (parsing PEM PKCS#8 / X.509 via JCE standard).
  • JwtTokenProvider peut signer et valider en asymétrique avec clé statique injectée par config.
  • JwtProductionReadinessValidator allowlist d'algorithmes (refus none, exigence source de clé en RS256).
  • ✅ Slots Codex TAIL_AUTH_KEY_MODE, TAIL_AUTH_JWKS_URL, TAIL_AUTH_JWT_SECRET présents dans le seed.
  • ✅ Tests JwtTokenProviderAsymmetricTest, JwtProductionReadinessValidatorTest présents.

Statut palier 1 : Acceptée — livrée. Une app peut basculer en RS256 statique dès maintenant via Codex.

Palier 2 — JWKS dynamique + multi-kid + dynors-auth émetteur (EN COURS)

MAJ 2026-06-30 — état réévalué sur le code réel (l'inventaire « 0 fichier » ci-dessous était périmé).

  • dynors-auth (l'émetteur centralisé) : existe (dynors-platform/auth) — JwtIssuer, KeyProvider, JwksService, AuthController, tests JwtRotationTest/KeyProviderTest/JwtIssuerTest.
  • JWKS endpoint : JwksService expose les clés publiques RSA (kty/use/alg/kid/n/e), multi-kid prêt pour la rotation.
  • Multi-clés par kid : côté validateurs, JwtKeys.publicKeysByKid + JwtTokenProvider posent et résolvent le kid (carte statique public-keys).
  • Consommateur JWKS distant (cache TTL, refresh sur kid inconnu) : livré 2026-06-30JwksClient (dynors-security), câblé dans JwtTokenProvider.resolveVerificationKey (JWKS distant prioritaire, repli public-keys hors-ligne) ; JwtProductionReadinessValidator accepte jwks-url comme source de clé prod ; tests JwksClientTest + JwtTokenProviderJwksTest.
  • Durcissement allowlist (item 6) : livré 2026-06-30dynors.security.jwt.allow-hs256-in-prod (défaut true transition) ; à false, JwtProductionReadinessValidator refuse HS256 en prod (warning « palier transitoire » tant que toléré). Tests dédiés.
  • Runbook bascule par cercle (item 5) : documenté 2026-06-30 — procédure overlap pilotée par TAIL_AUTH_KEY_MODE dans docs/security/GUIDE_CONFIG_JWT_ASYMETRIQUE.md §7.
  • Exécution de la bascule par cercle : reste l'opération (générer/publier les kid au JWKS, basculer émetteur puis validateurs, retirer JWT_SECRET, poser allow-hs256-in-prod=false) + le branchement de la rotation Vault versionnée au déploiement.

Statut palier 2 : En cours — briques livrées, reste l'exécution ops. L'émetteur dédié dynors-auth (clé privée lue par lui seul) + la consommation JWKS + le verrou anti-HS256 ferment l'écart A1 dès qu'un cercle est basculé en RS256_ASYMMETRIC avec jwks-url et allow-hs256-in-prod=false. Il ne reste plus de code de bibliothèque à écrire — la migration est désormais une opération par cercle (runbook §7).

Conséquence pratique

Capacité Palier 1 (livré) Palier 2 (à livrer)
Algorithme RS256 supporté côté apps
Rotation de clé sans coupure ❌ (clé statique unique) ✅ (overlap kid)
Émetteur unique séparé des validateurs ❌ (clé privée potentiellement partagée) ✅ (dynors-auth exclusif)
Écart A1 fermé cryptographiquement
JWKS B2B exposable

Recommandation 2026-06-21 : prioriser le palier 2 (livraison dynors-auth JWKS) dans le même train que l'ADR-0001 FISCAL, car les deux bloquent le passage en mode « plateforme mature » (auth centralisée + multi-juridictions).


Contexte

ADR-0011 a rendu l'aud obligatoire mais a laissé la signature en HS256 symétrique : le secret ${JWT_SECRET} est partagé entre l'émetteur et tous les validateurs. Conséquence (écart A1 de l'audit 2026-06-14, docs/security/AUDIT_SECURITE_VAULT_SIRRAT_2026-06-14.md) : tout backend qui valide peut aussi forger un token pour n'importe quel realm/rôle. L'objectif « dynors-auth seul émetteur » n'est donc pas cryptographiquement tenu — seulement par convention.

Le Codex SIRRAT a déjà anticipé la cible (constantes présentes, SIRRAT_CODEX_REFERENCE.md) :

Constante Codex Rôle
TAIL_AUTH_KEY_MODE HS256_SHARED (transitoire) | RS256_ASYMMETRIC (cible)
TAIL_AUTH_JWKS_URL URL JWKS de dynors-auth (mode RS256)
TAIL_AUTH_JWT_ISSUER / TAIL_AUTH_AUDIENCE iss / aud attendus
TAIL_AUTH_JWT_SECRET (secret_refprotected:auth.jwt.secret) secret HS256 (mode transitoire)
TAIL_AUTH_JWT_EXPIRY_S / _REFRESH_EXPIRY_S / _REFRESH_ENABLED TTL + rotation refresh

Le point d'attention demandé : la gestion des constantes de cryptage doit être prévue en aval — c.-à-d. couvrir la rotation des clés (sans coupure) et rester un seul dictionnaire réutilisable par les futurs consommateurs (entitlements, webhooks signés…).

Décision

1. Algorithme cible : RS256 (asymétrique), émetteur unique

  • dynors-auth signe avec une clé privée ; les apps valident avec la clé publique. Un validateur ne détient aucun secret de forge → A1 fermé.
  • RS256 retenu plutôt qu'EdDSA pour la compatibilité JWKS/outillage (validation éventuelle par des partenaires B2B, librairies, introspection). EdDSA reste une option future (clés plus courtes) si le périmètre reste 100 % interne — non bloquant, l'allowlist (§4) autorise les deux.
  • Le transit service-à-service garde le modèle HKDF par app (ADR-0008) — inchangé. Deux plans de confiance distincts : identité utilisateur = asymétrique/JWKS ; machine-à-machine = HKDF symétrique.

2. kid + JWKS (le « en aval » : rotation sans coupure)

  • Chaque JWT porte un kid (key id) dans son en-tête.
  • Les validateurs résolvent la clé publique par kid depuis un JWKS :
  • cible : TAIL_AUTH_JWKS_URL (endpoint dynors-auth, cache TTL) ;
  • mode hors-ligne / dégradé : un ou plusieurs PEM publics injectés en config (public-keys), indexés par kid.
  • Le JWKS expose plusieurs clés simultanément → la rotation se fait par recouvrement, exactement comme le mode DUAL du transit SLY (ADR-0008) :
  • publier la nouvelle clé (nouveau kid) dans le JWKS, l'émetteur signe encore avec l'ancienne ;
  • basculer l'émetteur sur le nouveau kid ;
  • retirer l'ancienne clé du JWKS après expiration des derniers tokens (TTL access).

3. Convention Vault versionnée (matériel secret)

vault://dynors/{cercle}/platform/auth#jwt-private-{kid}      # clé privée par kid (émetteur)
vault://dynors/{cercle}/platform/auth#jwt-current-kid        # pointeur kid courant
  • Clé privée : Vault uniquement, lecture par dynors-auth seul (policy dédiée).
  • Clé publique / kid / JWKS : non secrets (diffusables) — config Codex, pas Vault.
  • Réutilisable tel quel par tout futur signataire (entitlements, webhooks) : vault://dynors/{cercle}/{scope}/{categorie}#{champ}-{kid}.

4. Allowlist d'algorithmes (contrat unique, dynors-security)

  • dynors-security impose une liste blanche : RS256 (+ EdDSA autorisé), HS256 toléré uniquement en HS256_SHARED transitoire. none toujours rejeté.
  • En prod, JwtProductionReadinessValidator :
  • RS256_ASYMMETRIC → exige une source de clé publique (jwks-url ou public-keys) + kid ;
  • HS256_SHARED → exige secret ≥ 32 octets, non prévisible (déjà en place), et log un avertissement « palier transitoire » ;
  • audience = tech code layer 3 (inchangé, ADR-0011).

5. Les constantes de crypto sont centralisées, jamais par-app libre

  • L'app ne choisit pas l'algorithme ni ne détient de clé privée (sauf l'émetteur). Elle consomme les constantes Codex (TAIL_AUTH_KEY_MODE, TAIL_AUTH_JWKS_URL, TAIL_AUTH_AUDIENCE…), défaut au niveau groupe, override par cercle.
  • Mapping propriétés dynors.security.jwt ↔ Codex :
Propriété core Constante Codex
algorithm (HS256/RS256) dérivé de TAIL_AUTH_KEY_MODE
jwks-url TAIL_AUTH_JWKS_URL
public-keys[kid] (mode hors-ligne) dérivé JWKS
secret TAIL_AUTH_JWT_SECRET (mode HS256 transitoire)
issuer / audience / expiration TAIL_AUTH_JWT_ISSUER / _AUDIENCE / _JWT_EXPIRY_S

Conséquences

Bénéfices

  • Un validateur compromis ne peut plus forger d'identité (clé publique seule).
  • Rotation sans coupure (kid + JWKS overlap) — prévu avant la première mise en prod.
  • Un seul dictionnaire de constantes crypto, réutilisable en aval (entitlements, webhooks).
  • Cohérent avec ADR-0008 (même logique d'overlap/migration).

Coûts / contraintes

  • dynors-auth doit exposer un JWKS + gérer le cycle de vie des kid (génération, publication, retrait) — opération plateforme.
  • Les validateurs doivent résoudre par kid (cache JWKS) — évolution de JwtTokenProvider d'une clé unique vers un résolveur multi-clés.
  • Migration ordonnée HS256 → RS256 par cercle (overlap), pilotée par TAIL_AUTH_KEY_MODE.

Composants impactés

  • core/.../security : SecurityProperties.jwt (key-mode, jwks-url, public-keys par kid), JwtKeys (cache JWKS + résolution par kid), JwtTokenProvider (pose kid à l'émission, résout par kid à la validation), JwtProductionReadinessValidator (allowlist + sources de clé).
  • dynors-auth (futur) : JWKS endpoint, signature par kid, rotation.
  • Codex SIRRAT : constantes déjà présentes — defaults groupe à figer.
  • Vault : chemins versionnés …#jwt-private-{kid}.

Alternatives considérées

A — Rester HS256 + clé par realm. Écartée : un validateur garde un secret de forge pour son realm ; ne supprime pas A1, complexifie la distribution de N secrets.

B — HKDF par audience (réutiliser ADR-0008 pour le JWT). Cohérente mais : un validateur peut forger des tokens pour sa propre audience, et l'émetteur (racine) forge tout. Acceptable pour le transit machine, insuffisant pour l'identité utilisateur qui traverse des frontières de confiance (B2B). Conservée pour le transit, pas pour l'identité.

C — EdDSA au lieu de RS256. Techniquement supérieure (clés courtes, rapide), mais outillage JWKS/B2B moins universel. Autorisée par l'allowlist, non retenue par défaut pour l'instant.

D — Mono-clé sans kid. Écartée : rend la rotation impossible sans coupure — précisément le point « prévu en aval » à ne pas rater.

Plan d'exécution

  1. ✅ (Fait, base) Mode asymétrique opt-in dans dynors-security (clé statique) — JwtKeys, JwtTokenProvider, validateur prod, tests (JwtTokenProviderAsymmetricTest).
  2. Multi-clés par kid : public-keys map + résolution par en-tête kid (JwtKeys.publicKeysByKid).
  3. Consommateur JWKS (TAIL_AUTH_JWKS_URL, cache TTL + refresh rotation) côté validateurs — JwksClient (2026-06-30).
  4. dynors-auth : JWKS endpoint (JwksService) + signature par kid (JwtIssuer) + provider de clés (KeyProvider, tests rotation). Rotation Vault versionnée : à brancher en déploiement.
  5. ✅ Runbook bascule par cercle via TAIL_AUTH_KEY_MODE (overlap HS256↔RS256) — GUIDE_CONFIG_JWT_ASYMETRIQUE.md §7 (exécution ops par cercle à dérouler).
  6. ✅ Allowlist durcie : allow-hs256-in-prod=false → HS256 refusé en prod (JwtProductionReadinessValidator, 2026-06-30).

Suivi

Critère de succès : un token signé par dynors-auth (kid courant) est validé par toute app du realm ; un validateur ne peut pas émettre de token accepté ailleurs ; une rotation de kid ne provoque aucune coupure (overlap). Test : rejouer un token de l'ancien kid pendant la fenêtre d'overlap → accepté ; après retrait → rejeté.

Références

  • Palier 1 : ADR-0011 (audience/realm)
  • Isolement de secret (analogie) : ADR-0008 (transit SLY HKDF)
  • Audit / écart A1 : docs/security/AUDIT_SECURITE_VAULT_SIRRAT_2026-06-14.md
  • Guide config : docs/security/GUIDE_CONFIG_JWT_ASYMETRIQUE.md
  • Constantes : SIRRAT_CODEX_REFERENCE.md (TAIL_AUTH_*), IDENTITE_APPLICATIVE_3_LAYERS.md
  • Code : core/.../security/jwt/{JwtKeys,JwtTokenProvider}.java, config/JwtProductionReadinessValidator.java