Dans les articles précédents de cette série, on a couvert le PKCS#12, le timing oracle, le lockout et le CSRF. Tout ça sur un service d'authentification qui tourne en mTLS. Parlons de la couche TLS elle-même.
Le service sert trois audiences distinctes : une UI web pour les utilisateurs finaux (pas de client cert), une API pour les services internes (mTLS obligatoire), et une interface d'administration (mTLS obligatoire avec des CA différentes). Trois niveaux de confiance, sur un seul port.
Un listener, trois configs via SNI
La solution historique : trois ports, trois listeners, trois tls.Config.
Ca marche, mais ça triple la surface réseau exposée et complique le déploiement
(firewall rules, load balancer config, cert management).
L'alternative : un seul listener TLS avec du routage par SNI (Server Name Indication).
Le client envoie le hostname dans le ClientHello, le serveur choisit la config TLS
correspondante — y compris le niveau de ClientAuth.
func buildTLSConfig() *tls.Config {
return &tls.Config{
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
switch hello.ServerName {
case "app.internal":
return &tls.Config{
ClientAuth: tls.NoClientCert,
Certificates: []tls.Certificate{appCert},
}, nil
case "api.internal":
return &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: serviceCAPool,
Certificates: []tls.Certificate{apiCert},
}, nil
case "admin.internal":
return &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: adminCAPool,
Certificates: []tls.Certificate{adminCert},
}, nil
default:
return nil, fmt.Errorf("unknown SNI: %s", hello.ServerName)
}
},
}
}
Chaque audience a son propre certificat serveur, son propre CA pool pour la validation client, et son propre niveau d'exigence. Un seul port ouvert.
Le piège Host header vs SNI
Le SNI est dans le ClientHello TLS. Le Host header est dans la requête HTTP,
après le handshake. Rien ne force les deux à être identiques.
Sans validation, un client peut faire un handshake SNI vers app.internal
(pas de client cert requis) puis envoyer une requête HTTP avec
Host: api.internal. Si ton routeur HTTP se fie au Host header pour le
dispatch, la requête arrive au handler API sans mTLS.
Le fix : middleware qui compare SNI et Host, avec un 421 Misdirected Request en cas de mismatch :
func sniHostGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sni := r.TLS.ServerName
host := r.Host
// Retirer le port si present
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if host != sni {
http.Error(w, "Misdirected Request", 421)
return
}
next.ServeHTTP(w, r)
})
}
Avantages et inconvénients vs ports multiples
| Critère | Multi-port | Multi-SNI |
|---|---|---|
| Surface réseau | 3 ports ouverts | 1 port |
| Firewall rules | 3 règles | 1 règle |
| Certificats | 1 cert par port (simple) | 1 cert multi-SAN ou 3 certs |
| Isolation | Forte (port = frontière) | Moyenne (code = frontière) |
| Déploiement | Plus de config | Plus simple |
| Debugging | tcpdump par port | tcpdump + filtre SNI |
Mon avis : le multi-SNI est le bon choix quand les audiences partagent le même process et le même cycle de déploiement. Si les audiences ont des lifecycles différents (scaling indépendant, équipes différentes), les ports séparés sont préférables.
Session cert binding : empêcher le replay de cookie
Passons au deuxième sujet de cet article : le cert binding. Le scénario : un attaquant vole un cookie de session (XSS, interception réseau, malware). Il le replay sur sa propre connexion mTLS.
Sans cert binding, le cookie est valide. L'attaquant a la session de la victime. Avec le cert binding, le serveur vérifie que le certificat client présenté dans le handshake est le même que celui qui a créé la session.
// A la creation de la session
func createSession(r *http.Request, user *User) *Session {
session := &Session{
UserID: user.ID,
CreatedAt: time.Now(),
}
// Stamper le serial du peer cert
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
session.CertSerial = r.TLS.PeerCertificates[0].SerialNumber.String()
}
return session
}
// A chaque requete authentifiee
func validateCertBinding(session *Session, r *http.Request) bool {
if session.CertSerial == "" {
return true // session creee sans cert (audience web)
}
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
return false // session liee a un cert, mais pas de cert presente
}
currentSerial := r.TLS.PeerCertificates[0].SerialNumber.String()
return session.CertSerial == currentSerial
}
Si le serial ne match pas : session rejetée. Ca couvre aussi le replay cross-listener —
un cookie volé sur l'audience api.internal ne peut pas être utilisé sur
admin.internal si les certs clients sont différents.
Les limites du cert binding
Soyons honnêtes sur ce que ça ne couvre pas :
- Si l'attaquant a la clé privée du cert, le binding ne sert à rien. Mais à ce stade, l'attaquant peut s'authentifier frais — le binding n'est pas le bottleneck.
- Sessions sans cert (audience web sans mTLS) ne bénéficient pas du binding. Pour celles-ci, les protections classiques (cookie HttpOnly, Secure, SameSite) restent la seule ligne de défense.
- Rotation de cert — si l'utilisateur renouvelle son cert, le serial change. La session est invalidée. C'est le comportement voulu, mais il faut que le flow de renouvellement soit fluide.
Conclusion
Le multi-SNI simplifie le déploiement sans sacrifier l'isolation des niveaux d'authentification, à condition de valider SNI == Host. Le cert binding ajoute une couche de protection contre le vol de session qui ne coûte presque rien en complexité — un champ de plus dans la session, un check de plus au middleware.
Mais le mTLS a un problème que le cert binding ne résout pas : que se passe-t-il quand un certificat est révoqué et que le client est déjà connecté en TCP keep-alive ? Le handshake est passé, la CRL a changé, mais le client continue de servir. C'est le sujet du prochain article.