CRL double-gate en mTLS : révoquer un cert quand le client est déjà connecté

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 TLS
  • VerifyConnection — 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.

  1. Gate 1 — handshake-time via VerifyConnection : bloque les nouvelles connexions
  2. 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

GateQuandProtège contre
VerifyConnectionHandshake TLSNouvelles connexions avec cert révoqué
HTTP middlewareChaque requêteConnexions keep-alive avec cert révoqué entre-temps
CRL monotonic checkReload CRLRollback 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.

Commentaires (0)