mTLS en Go : mes tests de refus passaient pour la mauvaise raison

Sommaire (10 parties)

J'écris en Go un petit serveur HTTPS qui exige un certificat de chaque client. Mes tests vérifient qu'il refuse les mauvais clients. Les sept tests de refus passaient du premier coup. Mon client légitime, lui, était refusé comme les autres.

TLS est le protocole qui chiffre les connexions HTTPS, celui du cadenas du navigateur. Avec le mTLS, pour « TLS mutuel », le serveur montre son certificat au client, et le client montre le sien. Le serveur est le programme qui répond, le client celui qui se connecte. Un certificat est une pièce d'identité numérique, garantie par une autorité de certification (AC). L'AC signe le certificat avec une clé secrète qu'elle est seule à posséder. N'importe qui peut ensuite vérifier cette signature avec la clé publique qui lui correspond. Le serveur ne laisse passer que les clients dont le certificat est signé par une AC qu'il connaît.

Chaque test lance ce serveur et lui envoie un client. Au premier lancement, les sept clients défectueux étaient tous refusés : tests verts, donc réussis. Le client légitime l'était aussi, et le serveur n'avait même pas regardé le certificat des clients. Les sept refus venaient d'ailleurs.

Cet article raconte cinq pièges des tests mTLS en Go. Dans les deux premiers, un test de refus passe sans que le serveur ait jugé. Dans le troisième, un client refusé se croit accepté. Les deux derniers concernent la révocation. Je les ai vérifiés dans le code de Go 1.26.10 et par des tests qui lancent un vrai serveur et de vrais clients. Il existe deux versions de TLS encore en service, la 1.2 et la plus récente, la 1.3, et les pièges changent de l'une à l'autre. Je les numérote dans les titres.

$ git clone https://github.com/ohugonnot/mtls-refuse
$ cd mtls-refuse && go test -race -v ./e2e

ohugonnot/mtls-refuse

Un serveur Go qui exige un certificat client, et une petite AC qui fabrique des certificats défectueux à la demande. Chaque test lance un vrai serveur et un vrai client, puis lit ce que le serveur écrit dans son journal. Chaque ligne ci-dessous dit ce qui est prouvé, et par quoi. « Mutation » veut dire : on casse le code exprès pour voir si un test échoue.

  • VérifiéUn client légitime passe. Sept clients défectueux sont refusés, chacun pour la raison attendue, en TLS 1.2 comme en TLS 1.3 : TestMatrix.
  • VérifiéLes tests échouent quand on casse le code de dix façons, une à la fois. Essai du 6 novembre 2026 sur Go 1.24.13 et 1.26.10 : ./mutations.sh.
  • VérifiéLa CI (le robot de GitHub qui relance les tests à chaque modification) est verte sur Go 1.24 et 1.26 : exécution 42303001046.
  • Non vérifiéOCSP (un service qui répond en direct « ce certificat est-il révoqué ? »), les autorités intermédiaires, HTTP/2 et les systèmes autres que Linux : le dépôt ne les traite pas, ou ne les teste pas.

Piège 1 : un refus qui passe ne dit pas qui a refusé

Mes tests joignaient le serveur par son adresse IP, 127.0.0.1, qui désigne l'ordinateur lui-même. Or un certificat ne vaut que pour les noms et adresses qu'il porte. Celui du serveur portait le nom localhost, qui désigne la même machine, mais pas cette adresse. Le client Go vérifie le certificat du serveur avant de présenter le sien. Il l'a rejeté. L'erreur : cannot validate certificate for 127.0.0.1 because it doesn't contain any IP SANs. Traduction : le certificat ne contient aucune adresse IP. Le serveur n'a jamais vu un seul client.

Mes sept tests de refus attendaient une erreur, et ils en recevaient une. Ils passaient. Seul le client légitime, qui attendait un succès, a échoué. Sans ce client légitime, tout aurait été vert.

Deux règles en découlent. Il faut un témoin : un client légitime qui doit passer. Il faut aussi confirmer chaque refus par la raison que le serveur écrit dans son journal, pas par l'erreur du client. La fonction suivante attend, jusqu'à une seconde, que le journal contienne un texte donné :

func (e *env) waitLog(want string) bool {
	for i := 0; i < 100; i++ {
		if strings.Contains(e.logs.String(), want) {
			return true
		}
		time.Sleep(10 * time.Millisecond)
	}
	return false
}

Code source : e2e/helpers_test.go, lignes 49 à 57

Mes tests forment une matrice : un tableau de cas, un client défectueux par ligne. Chaque ligne nomme le texte qu'elle attend dans le journal : « unknown authority », « is after », « révoqué ». Si le client est refusé pour une autre cause, le test échoue et affiche l'erreur du client et le journal du serveur.

Ce que Go vérifie tout seul, et ce qu'il ne vérifie jamais

La configuration du serveur, un objet tls.Config de la bibliothèque standard, tient en quelques lignes :

func ServerConfig(server tls.Certificate, clientCAs *x509.CertPool, rev *Revocation) *tls.Config {
	return &tls.Config{
		Certificates:     []tls.Certificate{server},
		ClientAuth:       tls.RequireAndVerifyClientCert,
		ClientCAs:        clientCAs,
		MinVersion:       tls.VersionTLS12,
		VerifyConnection: rev.checkState,
	}
}

Code source : gate/gate.go, lignes 111 à 119

RequireAndVerifyClientCert dit à Go d'exiger un certificat, et ClientCAs lui donne les AC de confiance pour le comparer. Go vérifie alors la signature, les dates et l'usage client. Chaque certificat déclare s'il sert à un client, à un serveur ou aux deux. La dernière ligne, VerifyConnection, branche ma propre fonction de contrôle, que je détaille plus bas. Le tableau montre, pour chacun des huit clients de test du dépôt, ce que le serveur écrit. Ce sont les clients corrigés, ceux du piège 2 plus bas : ils envoient toujours leur certificat. Les messages sont ceux de Go, en anglais, avec ma traduction.

ClientCe que le serveur écrit dans son journal
Légitime (témoin)rien : le serveur répond normalement
Aucun certificatclient didn't provide a certificate (le client n'a rien présenté)
Autre ACcertificate signed by unknown authority (AC inconnue)
AC jumelle : une AC qui porte le même nom que la vraie, mais une autre paire de clésunknown authority … ECDSA verification failure (la signature ne se vérifie pas ; ECDSA est l'algorithme de mes certificats de test)
Certificat expirécertificate has expired or is not yet valid: current time … is after … (l'heure actuelle est après la date de fin)
Certificat pas encore validecertificate has expired or is not yet valid: current time … is before … (l'heure actuelle est avant la date de début)
Certificat réservé aux serveurscertificate specifies an incompatible key usage (l'usage déclaré ne convient pas)
Certificat révoquécertificat 101 révoqué (mon message, pas celui de Go ; 101 est le numéro de série du certificat)

La ligne « Certificat révoqué » est la seule que Go n'écrit pas lui-même. Un certificat peut être déclaré mort avant sa date de fin : clé volée, employé parti. C'est la révocation. L'AC publie alors une liste de révocation (CRL), qui énumère les certificats annulés. Elle les désigne par leur numéro de série, un nombre que l'AC donne à chaque certificat qu'elle signe. x509.Certificate.Verify est la fonction de Go qui vérifie un certificat. Sa documentation avertit en une ligne : « WARNING: this function doesn't do any revocation checking ». Autrement dit, elle ne contrôle jamais la révocation.

Le code de crypto/tls, le paquet de Go qui fait TLS, ne contient lui non plus aucun contrôle de révocation. Il transmet seulement une réponse OCSP « agrafée », jointe par le serveur à son certificat, sans la juger. Ce contrôle est donc à écrire toi-même, et le reste de l'article en traite.

Une remarque sur le contexte : Let's Encrypt, l'AC publique gratuite, a annoncé qu'il ne délivrerait plus de certificats client en 2026. Pour un mTLS, tu as donc ta propre AC et ta propre liste. Les dates sont dans les sources.

Piège 2 : le client Go qui se tait au lieu de présenter son certificat

Mon test « certificat d'une autre autorité » passait lui aussi pour une mauvaise raison. Le serveur écrivait « le client n'a fourni aucun certificat ». Le certificat n'était jamais arrivé.

Quand le serveur demande un certificat, il joint la liste des noms des AC qu'il accepte. Le client Go a un champ de configuration, Certificates, qui liste ses propres certificats. Avec ce champ, Go compare le nom de l'émetteur de chaque certificat à la liste du serveur. Aucun nom ne correspond : le client n'envoie rien, sans erreur (crypto/tls/handshake_client.go, ligne 1286 : « No acceptable certificate found. Don't send a certificate. »).

Ce champ filtre, donc il fausse mes tests. Il existe une autre option, GetClientCertificate, qui ne filtre rien : on lui donne un certificat, il l'envoie. Mes clients de test l'utilisent :

	if cert != nil {
		cfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return cert, nil }
	}

Code source : e2e/helpers_test.go, lignes 110 à 112

Il reste une faiblesse dans ce filtre : il compare des noms. Or une AC jumelle, définie dans le tableau plus haut, porte le même nom que ta vraie AC. Son certificat passe donc le filtre du client. Le serveur vérifie alors la signature avec la clé de la vraie AC, et refuse le certificat. C'est le seul cas de la matrice dont le refus vient d'une signature qui ne se vérifie pas. Son journal contient « ECDSA verification failure ». Je l'ai lu, mais le test ne l'exige pas : il n'exige que « unknown authority », comme pour l'autre AC. Ici, mon test est moins précis que le journal qu'il lit.

Piège 3 : en TLS 1.3, le client refusé se connecte sans erreur

Imagine un test qui présente un certificat expiré avec tls.Dial, la fonction Go qui ouvre une connexion TLS, et qui attend une erreur. En TLS 1.2, elle arrive. En TLS 1.3, Dial réussit.

La poignée de main est l'échange qui installe la connexion chiffrée. En TLS 1.3, le client envoie son certificat et considère qu'il a fini, sans attendre que le serveur l'ait jugé. Le serveur juge ensuite et envoie une alerte, un message d'erreur de TLS. Le client ne la voit qu'à sa première lecture, c'est-à-dire le premier moment où il lit la réponse du serveur. Dans mon test, elle porte le texte « remote error: tls: expired certificate ».

Deux discussions publiques du projet Go, appelées issues, décrivent ce comportement. La #32202 s'intitule « Dial to server with invalid client certificates succeeds and allows writes on tip, but not in 1.12 ». Traduction : Dial réussit avec un certificat client invalide, et l'écriture passe, sur la version de développement mais pas en 1.12. La #56371 s'intitule « client tls 1.3 handshake doesn't return bad certificate error ». Traduction : la poignée de main TLS 1.3 ne renvoie pas d'erreur de certificat. Conséquence pratique : un client, ou un test, qui s'arrête à Dial se croit accepté. Le test TestTLS13RefusalArrivesAfterHandshake vérifie les deux comportements, celui de TLS 1.2 et celui de TLS 1.3.

Piège 4 : une révocation mal placée saute la reprise de session

Après une première connexion, le serveur remet au client un billet. Au retour, le client présente ce billet, et les deux se reconnaissent sans refaire toute la poignée de main. C'est la reprise de session, ou reprise tout court. Go propose deux fonctions pour brancher un contrôle de certificat. Leur documentation dit qu'elles diffèrent sur les reprises :

  • VerifyPeerCertificate n'est pas appelée sur une reprise (« This callback is not invoked on resumed connections ») ;
  • VerifyConnection, celle de ma configuration, tourne pour toutes les connexions, reprises comprises (« This callback will run for all connections, including resumptions »).

Si tu mets la révocation dans VerifyPeerCertificate, un certificat révoqué entre deux connexions repasse par la reprise. Le billet garde la chaîne du client : son certificat, puis celui de l'AC qui l'a signé. À la reprise, Go vérifie que les dates de ces certificats courent encore. Il vérifie aussi que cette AC figure toujours parmi les AC de confiance. Sinon, il écarte le billet et refait une poignée de main complète (handshake_server_tls13.go, lignes 370 à 381). La révocation, il ne la regarde pas, ni ici ni ailleurs dans crypto/tls.

Le test le montre en trois connexions. La première est complète. La deuxième est une reprise, et le test le constate avec DidResume, un indicateur de Go qui dit si la connexion est une reprise. Sans ce contrôle, le test pourrait réussir sans avoir jamais testé de reprise. Puis je révoque le certificat, et la troisième connexion est refusée avec « révoqué » dans le journal. Pour la mutation, j'ai déplacé ma vérification de VerifyConnection vers VerifyPeerCertificate. Un seul test a échoué : celui-là.

Une connexion qui reste ouverte (keep-alive) pose le même problème : elle ne refait aucune poignée de main. Le livre Zero Trust Networks (2e édition, O'Reilly 2024) en donne le principe au chapitre 3, sans parler de TLS. Il traite de la sécurité « zéro confiance », où aucun appareil n'est cru sur parole. Voici sa phrase : « The act of validating an authenticated session is really an authorization decision ». Traduction : revalider une connexion déjà authentifiée est en réalité une décision d'autorisation, pas une simple vérification d'identité. Le livre dit « session » ; dans mon cas, c'est la connexion restée ouverte.

Mon test TestKeepAliveNeedsPerRequestCheck le mesure, en TLS 1.2. Sans vérification à chaque requête, la connexion ouverte continue de recevoir le code 200 (« ça marche ») après la révocation. Avec un petit middleware (gate.Middleware), un morceau de code qui s'exécute avant chaque requête, la requête suivante reçoit 403 (« interdit »). Le principe de ce double contrôle, à la poignée de main puis à chaque requête, est détaillé dans mon article sur la CRL double-gate.

Piège 5 : la liste de révocation se vérifie elle aussi

Reste la liste. Un serveur qui charge n'importe quelle liste se laisse tromper : une fausse liste révoque tes clients, une vieille liste réactive un certificat révoqué. Mon code garde en mémoire la dernière liste de chaque AC, dans un registre de révocation. Il applique cinq règles. Les quatre premières écartent un cas douteux :

  • une liste signée par une autre clé que celle d'une AC de confiance, même sous le bon nom d'AC (TestForgedCRLIsRejectedAndChangesNothing) ;
  • une liste dont le numéro d'ordre est inférieur ou égal à celui de la liste en place. Chaque liste porte un tel numéro, qui n'a rien à voir avec le numéro de série d'un certificat. Un cache placé entre toi et l'AC peut resservir une vieille liste vide (TestRolledBackCRLIsRejected) ;
  • une liste périmée : sa date de prochaine mise à jour est passée, donc on refuse tout (TestStaleCRLFailsClosed) ;
  • l'absence de liste pour une AC : on refuse aussi (TestNoCRLFailsClosed).

Les deux derniers choix ferment la porte quand l'information manque. Le prix : si la liste cesse de se recharger, tes clients légitimes sont coupés. Je l'assume pour un serveur qui protège quelque chose.

La cinquième règle est celle que mon article précédent sur la CRL ne respectait pas. Il retenait un certificat révoqué par son seul numéro de série. Or deux AC différentes peuvent émettre le même numéro. Le registre du dépôt range donc chaque liste sous l'identité de son AC, c'est-à-dire sa clé publique. Il cherche le numéro de série dans la seule liste de l'AC qui a signé le certificat. Dans le code, leaf est le certificat du client et issuer celui de son AC, le deuxième de la chaîne :

	leaf, issuer := chain[0], chain[1]
	r.mu.RLock()
	defer r.mu.RUnlock()
	l := r.lists[idOf(issuer)]
	if l == nil {
		return errors.New("aucune liste de révocation pour cette autorité")
	}
	if !l.nextUpdate.After(r.now()) {
		return errors.New("liste de révocation périmée")
	}
	if l.revoked[leaf.SerialNumber.String()] {
		return fmt.Errorf("certificat %s révoqué", leaf.SerialNumber)
	}

Code source : gate/gate.go, lignes 86 à 98

TestSameSerialInTwoAuthorities crée deux AC de même nom, chacune avec un certificat de numéro 7, et révoque seulement celui de la première. Le certificat de la première AC est refusé, celui de la seconde passe. Le numéro de série seul les aurait confondus.

Mon avis : un test de refus doit écrire sa raison

Mon avis : un test mTLS qui se contente de vérifier qu'une erreur est survenue ne prouve presque rien. Il passe quand le client oublie son certificat, quand le nom du serveur ne correspond pas, quand le certificat du serveur est expiré. Chaque refus doit nommer la phrase que le serveur écrit.

Le premier réflexe est donc d'écrire la raison. Le second est de casser le code pour voir. Le dépôt contient dix mutations : retirer l'exigence du certificat, déplacer la vérification, ignorer la signature de la liste. Elles sont toutes attrapées. Celle qui déplace la révocation dans VerifyPeerCertificate ne l'est que par le test de reprise. Sans cette mutation, j'aurais cru la matrice complète.

Conclusion

Dans les pièges 1 à 3, le client reçoit une erreur, ou rien, et conclut trop vite. Le serveur, lui, a écrit ce qu'il a décidé. Dans les pièges 4 et 5, c'est le serveur qui acceptait à tort. Un test qui échoue quand on casse le code le montre. Je ne crois plus un test de refus avant d'avoir lu la raison du serveur et vu le test échouer sur une mutation.

Les livres derrière cet article

  • Zero Trust Networks, 2nd Edition Rais, Morillo, Gilman et Barth, O'Reilly 2024.

    Repris : la phrase du chapitre 3 sur la validation d'une session authentifiée, qui est une décision d'autorisation. Absent du livre : la révocation des certificats clients en pratique. Les sigles « CRL » et « OCSP » n'y figurent nulle part.

Sources

Documentation consultée le 6 novembre 2026 :

Dans le grand livre du développeur

Un livre gratuit où chaque idée est racontée une seule fois, à sa place, du silicium au jugement humain.

Commentaires (0)