Dans l'article précédent, on a vu comment servir trois audiences mTLS sur un seul port avec du SNI routing, et comment le cert binding protège contre le replay de session. Mais il reste un trou : la révocation.
Tu révoques un certificat client. Tu mets à jour ta CRL. Le problème : le client est
déjà connecté en TCP keep-alive. Son handshake TLS est passé il y a 10 minutes.
tls.Config.VerifyConnection ne s'exécute qu'au handshake. Le client continue
d'envoyer des requêtes avec un cert révoqué, et ton serveur les accepte.
Pourquoi VerifyConnection ne suffit pas
En Go, tls.Config offre deux hooks de validation :
VerifyPeerCertificate— appelé pendant le handshake, avant la fin de la connexion TLSVerifyConnection— appelé après le handshake complet, une seule fois
Les deux ne s'exécutent qu'au handshake. Avec HTTP/1.1 keep-alive ou HTTP/2 multiplexing, un même handshake peut servir des centaines de requêtes sur plusieurs minutes. Pendant ce temps, la CRL peut changer.
// Ce check ne s'execute qu'une fois par connexion TLS
tlsConfig := &tls.Config{
VerifyConnection: func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return nil
}
serial := cs.PeerCertificates[0].SerialNumber
if crlStore.IsRevoked(serial) {
return fmt.Errorf("certificate %s is revoked", serial)
}
return nil
},
}
Ce code bloque les nouvelles connexions avec un cert révoqué. Il ne bloque pas les connexions existantes.
Le pattern double-gate
La solution : vérifier la CRL à deux endroits.
- Gate 1 — handshake-time via
VerifyConnection: bloque les nouvelles connexions - Gate 2 — request-time via un middleware HTTP : vérifie le serial du peer cert à chaque requête
// Gate 2 : middleware HTTP
func crlMiddleware(crlStore *CRLStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
next.ServeHTTP(w, r)
return
}
serial := r.TLS.PeerCertificates[0].SerialNumber
if crlStore.IsRevoked(serial) {
http.Error(w, "Certificate revoked", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
Le middleware accède au r.TLS.PeerCertificates — les certificats présentés
lors du handshake de la connexion qui porte cette requête. Même si le handshake date
de 10 minutes, le serial est toujours accessible.
Coût : un lookup dans le CRL store par requête. Si le store est en mémoire (un
map[string]bool protégé par un sync.RWMutex), c'est
quelques nanosecondes.
Hot-reload de la CRL
Pour que le double-gate soit efficace, la CRL en mémoire doit être à jour. Deux approches :
Polling périodique
func (s *CRLStore) startPolling(ctx context.Context, url string, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.reload(url); err != nil {
slog.Error("CRL reload failed", "error", err)
}
case <-ctx.Done():
return
}
}
}
Simple, mais le délai entre la révocation et la prise en compte est au maximum l'intervalle de polling. Pour un service financier, 30 secondes c'est peut-être trop.
Pubsub interne
L'émetteur de la CRL publie un event sur un canal interne (NATS, Redis Pub/Sub, PostgreSQL NOTIFY). Le CRL store s'abonne et recharge immédiatement. Latence sub-seconde entre la révocation et le rejet de la première requête.
Le piège du rollback CRL
Un piège que j'ai vu en audit : la source HTTP de la CRL répond parfois avec un vieux contenu (cache CDN, rollback de déploiement, race condition sur le fichier). Si ton CRL store remplace naïvement la CRL en mémoire par la CRL téléchargée, un rollback réactive des certificats révoqués.
La solution : vérifier que le CRL Number est monotone croissant.
func (s *CRLStore) reload(url string) error {
newCRL, err := fetchCRL(url)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
// Monotonic check : le nouveau CRL Number doit etre > l'ancien
if s.currentNumber != nil && newCRL.Number.Cmp(s.currentNumber) <= 0 {
slog.Warn("CRL rollback detected",
"current", s.currentNumber,
"received", newCRL.Number,
)
return fmt.Errorf("CRL number %s <= current %s: rollback rejected",
newCRL.Number, s.currentNumber)
}
s.revokedSerials = buildRevokedMap(newCRL)
s.currentNumber = newCRL.Number
return nil
}
Le check newCRL.Number > cached.Number est la seule protection contre
un rollback attack sur la CRL. Sans ça, un attaquant qui contrôle la source CRL
(ou le cache en amont) peut réactiver n'importe quel certificat.
En résumé : les deux gates et leurs rôles
| Gate | Quand | Protège contre |
|---|---|---|
| VerifyConnection | Handshake TLS | Nouvelles connexions avec cert révoqué |
| HTTP middleware | Chaque requête | Connexions keep-alive avec cert révoqué entre-temps |
| CRL monotonic check | Reload CRL | Rollback attack / cache stale |
Conclusion
La révocation en mTLS est un sujet où "ça a l'air de marcher" cache souvent un trou de plusieurs minutes. Le double-gate — handshake + middleware — est le pattern minimal pour une révocation effective. Le hot-reload avec check monotone est le pattern pour une révocation rapide sans risque de rollback.
On a couvert les couches réseau et transport du service. Le prochain article plonge dans l'architecture applicative : comment gérer les side-effects dans un système CQRS/Event Sourcing — le pubsub bridge pour les outcomes de commandes et l'audit log atomique. C'est le sujet du prochain article.