TLS 1.3 en profondeur#
Prérequis : linux/15_cryptographie.md couvre TLS basique : HTTPS, certificats X.509, commandes OpenSSL. Ce chapitre plonge dans le protocole lui-même — comment le handshake fonctionne cryptographiquement, pourquoi TLS 1.3 est supérieur à ses prédécesseurs, et comment configurer TLS correctement en production.
Handshake TLS 1.3 — un seul aller-retour#
TLS 1.3 (RFC 8446, publié en 2018) réduit le handshake à 1 RTT (Round Trip Time), contre 2 en TLS 1.2. Cette optimisation est possible car l’échange de clé et la négociation des paramètres cryptographiques sont fusionnés dans les deux premiers messages.
Messages du handshake#
1. ClientHello (client → serveur)
Versions supportées :
supported_versions=TLS 1.3Groupes de courbes supportés :
supported_groups=x25519, secp256r1, secp384r1Clés publiques éphémères du client (key_shares) pour les groupes préférés
Suites cryptographiques :
cipher_suites=TLS_AES_256_GCM_SHA384, TLS_AES_128_GCM_SHA256, TLS_CHACHA20_POLY1305_SHA256Extensions :
server_name(SNI),signature_algorithms,alpn
2. ServerHello (serveur → client)
Suite choisie et groupe ECDHE choisi
Clé publique éphémère du serveur (key_share)
À ce stade, les deux parties calculent le
handshake_secretvia ECDHE + HKDF.Tous les messages suivants sont chiffrés avec des clés dérivées du
handshake_secret.
3. EncryptedExtensions (serveur → client, chiffré)
Extensions non liées à la négociation de clé (ALPN sélectionné, max_fragment_length…)
4. Certificate (serveur → client, chiffré)
Certificat X.509 du serveur (chaîne jusqu’à la CA racine de confiance)
5. CertificateVerify (serveur → client, chiffré)
Signature de la transcription du handshake avec la clé privée du serveur
Prouve que le serveur possède bien la clé privée correspondant au certificat
6. Finished (serveur → client, chiffré)
MAC sur la transcription complète du handshake
7. Finished (client → serveur, chiffré)
MAC du client sur la transcription
Dès l’étape 7, les deux parties dérivent les clés de session applicative (application_traffic_secret) et la communication chiffrée peut commencer.
Pas de négociation de version
En TLS 1.3, le champ legacy_version de ClientHello vaut toujours 0x0303 (TLS 1.2) pour la rétrocompatibilité avec les middleboxes. La vraie version est dans l’extension supported_versions. Les serveurs TLS 1.3 ignorent legacy_version.
Différences TLS 1.2 / TLS 1.3#
Ce qui a été supprimé dans TLS 1.3#
Élément supprimé |
Raison |
|---|---|
RSA key exchange statique |
Pas de forward secrecy : une fuite de la clé privée déchiffre toutes les sessions passées |
DHE sans courbes elliptiques |
Lent, paramètres historiquement faibles |
Cipher suites avec RC4 |
RC4 cassé (BEAST, invariants de biais) |
Cipher suites avec 3DES |
Vulnérable à SWEET32 (birthday attack sur les blocs de 64 bits) |
Cipher suites avec MD5 et SHA-1 dans HMAC |
Fonctions de hachage faibles |
Compression TLS |
CRIME attack (compression + oracle) |
Renégociation |
Complexité, vecteur d’attaque |
Cipher suites exportées |
Fragilisation intentionnelle des clés à 40 bits (loi américaine des années 90) |
Suites cryptographiques TLS 1.3#
TLS 1.3 ne définit que 5 cipher suites (toutes sûres) :
TLS_AES_128_GCM_SHA256TLS_AES_256_GCM_SHA384TLS_CHACHA20_POLY1305_SHA256TLS_AES_128_CCM_SHA256TLS_AES_128_CCM_8_SHA256
Contre plus de 300 suites en TLS 1.2 (dont beaucoup obsolètes).
0-RTT (Early Data)#
TLS 1.3 introduit le mode 0-RTT : si le client et le serveur ont déjà communiqué, le client peut envoyer des données applicatives dès le premier message du handshake, en utilisant un resumption secret issu de la session précédente.
Risque : attaques de rejeu (replay attacks). Les données 0-RTT ne sont pas protégées contre le rejeu : un attaquant qui capture le message initial peut le rejouer sur le serveur. La règle : utiliser 0-RTT uniquement pour des requêtes idempotentes (GET), jamais pour des opérations avec effets de bord (POST d’un virement).
ECDHE et forward secrecy systématique#
TLS 1.3 impose que toutes les sessions utilisent ECDHE (Elliptic Curve Diffie-Hellman Ephemeral). Le qualificatif éphémère est fondamental :
Une nouvelle paire de clés DH est générée à chaque handshake.
Ces clés sont détruites une fois la session établie.
Conséquence : si la clé privée long terme du serveur (celle du certificat) est compromise, elle ne permet pas de déchiffrer les sessions passées — il aurait fallu aussi compromettre les clés éphémères, qui n’existent plus.
C’est la perfect forward secrecy (PFS).
# Simulation ECDHE sur P-256 : échange Diffie-Hellman, dérivation du shared secret
from cryptography.hazmat.primitives.asymmetric.ec import generate_private_key, ECDH, SECP256R1
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
curve = SECP256R1()
# Côté client : génération de clé éphémère
client_private_key = generate_private_key(curve)
client_public_key = client_private_key.public_key()
# Côté serveur : génération de clé éphémère
server_private_key = generate_private_key(curve)
server_public_key = server_private_key.public_key()
# Échange des clés publiques (transmises en clair dans ClientHello/ServerHello)
# Calcul du shared secret (ECDH)
shared_secret_client = client_private_key.exchange(ECDH(), server_public_key)
shared_secret_server = server_private_key.exchange(ECDH(), client_public_key)
print(f"Shared secret (côté client) : {shared_secret_client.hex()}")
print(f"Shared secret (côté serveur): {shared_secret_server.hex()}")
print(f"Identiques : {shared_secret_client == shared_secret_server}")
# Dérivation des clés de session via HKDF (comme dans TLS 1.3)
def derive_session_key(shared_secret: bytes, label: str, length: int = 32) -> bytes:
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=length,
salt=None,
info=label.encode(),
)
return hkdf.derive(shared_secret)
client_write_key = derive_session_key(shared_secret_client, "tls13 client write key")
server_write_key = derive_session_key(shared_secret_client, "tls13 server write key")
print(f"\nClé d'écriture client (dérivée) : {client_write_key.hex()}")
print(f"Clé d'écriture serveur (dérivée): {server_write_key.hex()}")
Shared secret (côté client) : a791c9ec41a39d34e67ab27707211430f3f99655d3b1690a90a018bb6235b2e3
Shared secret (côté serveur): a791c9ec41a39d34e67ab27707211430f3f99655d3b1690a90a018bb6235b2e3
Identiques : True
Clé d'écriture client (dérivée) : c16a8cd29c7a0e98d4ff6b8bcdae0d6cf98829a192e692442e60ec01068fa294
Clé d'écriture serveur (dérivée): 6ca6300ea37bdd20020fecb1a6d2e0ccb29cd3c21f4890941d3c19a5f941dccc
# Simulation de la forward secrecy
# Démonstration : compromettre la clé long terme ne déchiffre pas les sessions passées
import secrets as sec_module
# Session 1 : clés éphémères générées pour cette session
ephemeral_1_client = generate_private_key(curve)
ephemeral_1_server = generate_private_key(curve)
shared_1 = ephemeral_1_client.exchange(ECDH(), ephemeral_1_server.public_key())
session_key_1 = derive_session_key(shared_1, "session-1")
# Session 2 : nouvelles clés éphémères indépendantes
ephemeral_2_client = generate_private_key(curve)
ephemeral_2_server = generate_private_key(curve)
shared_2 = ephemeral_2_client.exchange(ECDH(), ephemeral_2_server.public_key())
session_key_2 = derive_session_key(shared_2, "session-2")
# Clé long terme du serveur (certificat)
long_term_private = generate_private_key(curve)
long_term_public = long_term_private.public_key()
# Simulation d'une "fuite" de la clé long terme
leaked_long_term = long_term_private # l'attaquant l'obtient
# L'attaquant tente de retrouver les clés de session à partir de la clé long terme
# Il ne peut pas : les clés éphémères ont été détruites après chaque session
# La clé long terme ne permet pas de recalculer shared_1 ni shared_2
print("=== Démonstration de la forward secrecy ===")
print(f"Clé session 1 : {session_key_1.hex()[:32]}...")
print(f"Clé session 2 : {session_key_2.hex()[:32]}...")
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
lt_hex = long_term_public.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo).hex()
print(f"\nClé long terme fuité (clé publique, DER hex) : {lt_hex[:40]}...")
=== Démonstration de la forward secrecy ===
Clé session 1 : be57fcc7a886c40a1aaa1403d7a9ed1d...
Clé session 2 : e3a8666516de72b48649e9acae481238...
Clé long terme fuité (clé publique, DER hex) : 3059301306072a8648ce3d020106082a8648ce3d...
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)
# Version corrigée de la démonstration forward secrecy
from cryptography.hazmat.primitives.asymmetric.ec import generate_private_key, ECDH, SECP256R1
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
curve = SECP256R1()
def derive_key(shared_secret: bytes, label: str) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=label.encode()).derive(shared_secret)
# Session 1 : clés éphémères
e1c = generate_private_key(curve); e1s = generate_private_key(curve)
session_key_1 = derive_key(e1c.exchange(ECDH(), e1s.public_key()), "session-1")
# Session 2 : clés éphémères différentes (nouvelle session)
e2c = generate_private_key(curve); e2s = generate_private_key(curve)
session_key_2 = derive_key(e2c.exchange(ECDH(), e2s.public_key()), "session-2")
# Clé long terme du serveur (utilisée pour signer le CertificateVerify)
long_term = generate_private_key(curve)
# Après les sessions, les clés éphémères sont "détruites" (hors de portée)
# L'attaquant compromet la clé long terme APRÈS les sessions
# Il ne peut pas recalculer session_key_1 ni session_key_2 car les clés éphémères sont perdues
print("Forward secrecy — Résumé :")
print(f" Clé session 1 : {session_key_1.hex()[:32]}...")
print(f" Clé session 2 : {session_key_2.hex()[:32]}...")
print(f" Les deux sont indépendantes et ne peuvent pas être recalculées")
print(f" depuis la clé long terme compromise.")
print()
print(" → Même avec la clé privée du certificat serveur,")
print(" un attaquant passif qui a capturé le trafic passé")
print(" ne peut pas déchiffrer les sessions précédentes.")
Forward secrecy — Résumé :
Clé session 1 : fd1bbbce1564d13eebf8ba8e33251577...
Clé session 2 : b813d5d0945fa04cc788a54fae65fd32...
Les deux sont indépendantes et ne peuvent pas être recalculées
depuis la clé long terme compromise.
→ Même avec la clé privée du certificat serveur,
un attaquant passif qui a capturé le trafic passé
ne peut pas déchiffrer les sessions précédentes.
mTLS — Mutual TLS#
Dans TLS standard, seul le serveur s’authentifie (via son certificat). Dans mTLS (mutual TLS), le client s’authentifie également avec son propre certificat X.509.
Handshake mTLS#
Après EncryptedExtensions, le serveur envoie un message CertificateRequest spécifiant les CAs acceptées. Le client répond avec son Certificate et CertificateVerify.
Cas d’usage#
Microservices et service mesh : dans Istio ou Linkerd, les sidecars Envoy gèrent automatiquement le mTLS entre services. Les certificats sont émis par une CA interne (SPIFFE/SPIRE), renouvelés automatiquement toutes les heures. Le développeur n’a rien à faire : le mesh garantit que toute communication inter-service est mutuellement authentifiée.
API machine-to-machine : les partenaires bancaires, les PSP, ou les services gouvernementaux utilisent souvent mTLS pour authentifier les systèmes appelants sans jeton OAuth.
mTLS et rotation des certificats
Le principal défi opérationnel de mTLS est la rotation des certificats clients : dans un cluster de 1000 pods, chaque pod a son certificat. SPIFFE/SPIRE automatise ce cycle de vie : les certificats expirent en quelques heures et sont renouvelés avant expiration via le workload API.
SNI, ALPN et Virtual Hosting TLS#
SNI — Server Name Indication#
Sans SNI, un serveur HTTPS ne peut héberger qu’un seul certificat par adresse IP : au moment du handshake, le client envoie sa requête HTTP chiffrée avant que le serveur ait pu sélectionner le bon certificat.
SNI (RFC 6066) résout ce problème en ajoutant une extension server_name dans le ClientHello (en clair, avant chiffrement) indiquant le hostname demandé. Le serveur sélectionne le certificat correspondant avant de répondre.
Limitation : le SNI est visible pour les observateurs réseau passifs. ESNI puis ECH (Encrypted Client Hello, RFC en cours de finalisation) chiffrent le ClientHello entier pour masquer le SNI.
ALPN — Application-Layer Protocol Negotiation#
ALPN (RFC 7301) permet de négocier le protocole applicatif pendant le handshake TLS, évitant un aller-retour supplémentaire. Exemples :
h2→ HTTP/2h3→ HTTP/3 (QUIC)http/1.1→ HTTP/1.1acme-tls/1→ ACME challenge TLS-ALPN
Certificate Transparency#
Certificate Transparency (CT, RFC 9162) est un mécanisme d’audit public des certificats TLS émis. Les CAs sont tenues de journaliser chaque certificat émis dans des logs CT publics avant qu’il ne soit accepté par les navigateurs (depuis Chrome 68, 2018).
Fonctionnement :
La CA émet un certificat et le soumet à ≥2 logs CT.
Chaque log retourne un Signed Certificate Timestamp (SCT).
Les SCTs sont incorporés dans le certificat (ou envoyés via TLS ou OCSP).
Le navigateur vérifie la présence de SCTs valides avant d’accepter le certificat.
Intérêt : un certificat frauduleux pour votre domaine (émis par une CA compromise) est visible publiquement dans les logs CT. Des outils comme crt.sh permettent de surveiller les émissions pour son domaine.
Attaques historiques et pourquoi TLS 1.3 les élimine#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)
# Heatmap : comparaison TLS 1.0/1.1/1.2/1.3 × propriétés de sécurité
versions = ["TLS 1.0", "TLS 1.1", "TLS 1.2", "TLS 1.3"]
properties = [
"Forward secrecy\ndisponible",
"Forward secrecy\nobligatoire",
"0-RTT disponible",
"Cipher suites\nsûres uniquement",
"Handshake 1-RTT",
"Résistant BEAST",
"Résistant POODLE",
"Résistant HEARTBLEED",
"Résistant DROWN",
"Résistant CRIME",
]
# Matrice : 1 = oui, 0 = non, 0.5 = partiel
# rows = propriétés, cols = versions
matrix = np.array([
[0.5, 0.5, 0.5, 1.0], # Forward secrecy disponible
[0.0, 0.0, 0.0, 1.0], # Forward secrecy obligatoire
[0.0, 0.0, 0.0, 1.0], # 0-RTT disponible (TLS 1.3)
[0.0, 0.0, 0.5, 1.0], # Cipher suites sûres uniquement
[0.0, 0.0, 0.0, 1.0], # Handshake 1-RTT
[0.0, 1.0, 1.0, 1.0], # Résistant BEAST (patché en 1.1)
[0.0, 0.0, 0.5, 1.0], # Résistant POODLE (SSLv3 → TLS fallback)
[0.0, 0.0, 0.5, 1.0], # Résistant HEARTBLEED (implem., pas protocole)
[0.0, 0.0, 0.5, 1.0], # Résistant DROWN (SSLv2 cross-protocol)
[0.5, 0.5, 1.0, 1.0], # Résistant CRIME (compression désactivée)
])
fig, ax = plt.subplots(figsize=(9, 7))
sns.heatmap(
matrix,
xticklabels=versions,
yticklabels=properties,
annot=True, fmt=".1f",
cmap="RdYlGn",
linewidths=0.5,
linecolor="#ecf0f1",
vmin=0, vmax=1,
cbar_kws={"label": "0 = non / 0.5 = partiel / 1 = oui"},
ax=ax
)
ax.set_title("Propriétés de sécurité par version TLS", fontsize=12, fontweight="bold")
ax.set_xlabel("Version TLS", fontsize=10)
ax.set_ylabel("Propriété", fontsize=10)
ax.tick_params(axis="x", rotation=0)
ax.tick_params(axis="y", rotation=0)
plt.savefig("tls_versions_heatmap.png", dpi=120, bbox_inches="tight")
plt.show()
Les attaques historiques en détail#
BEAST (2011, TLS 1.0) Exploitation du mode CBC avec IV prédictible en TLS 1.0 : un attaquant actif pouvait décrypter des cookies session. Corrigé en TLS 1.1 (IV aléatoire par enregistrement). TLS 1.3 supprime CBC.
POODLE (2014, SSLv3)
Padding Oracle On Downgraded Legacy Encryption : exploit du padding CBC de SSLv3. L’attaquant forçait un downgrade de TLS vers SSLv3 via un « dance of the protocols ». TLS 1.3 supprime les downgrades via supported_versions et le mécanisme de Downgrade Sentinel.
HEARTBLEED (2014, OpenSSL) Pas une faille de protocole mais d’implémentation : le bug dans l’extension Heartbeat d’OpenSSL (CVE-2014-0160) permettait de lire 64 Ko de mémoire du processus serveur — potentiellement la clé privée. TLS 1.3 ne rend pas Heartbleed impossible en théorie (c’est un bug d’implémentation) mais la forward secrecy limite les dégâts : même avec la clé privée extraite, les sessions passées restent protégées.
DROWN (2016, SSLv2) Decrypting RSA with Obsolete and Weakened eNcryption : un serveur supportant encore SSLv2 avec le même certificat/clé qu’un serveur TLS exposait ce dernier. Un attaquant pouvait déchiffrer des sessions TLS modernes via des oracles SSLv2. TLS 1.3 supprime le RSA key exchange et impose ECDHE.
Chiffres sur HEARTBLEED
Selon les estimations de 2014, environ 17 % des serveurs HTTPS étaient vulnérables à Heartbleed lors de sa divulgation. La clé privée extraite permettait non seulement de déchiffrer les futures sessions mais aussi — sans forward secrecy — toutes les sessions passées capturées. C’est l’argument le plus fort pour l’adoption de TLS 1.3 avec ECDHE obligatoire.
Configuration sécurisée#
nginx — TLS 1.3 uniquement#
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/ssl/certs/app.example.com.fullchain.pem;
ssl_certificate_key /etc/ssl/private/app.example.com.key;
# TLS 1.3 uniquement (TLS 1.2 uniquement si compatibilité requise)
ssl_protocols TLSv1.3;
# Cipher suites TLS 1.3 — nginx les gère automatiquement
# Pour TLS 1.2 fallback éventuel :
# ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# ssl_prefer_server_ciphers off; # TLS 1.3 : le client choisit
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/certs/ca-chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
# Session tickets désactivés pour la forward secrecy stricte
ssl_session_tickets off;
ssl_session_cache off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Autres en-têtes de sécurité
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
}
Vérification avec openssl s_client#
# Tester TLS 1.3
openssl s_client -connect app.example.com:443 -tls1_3 < /dev/null
# Vérifier le certificat CT (SCTs)
openssl s_client -connect app.example.com:443 -status < /dev/null 2>&1 | grep -A 10 "OCSP Response"
# Afficher la chaîne de certificats complète
openssl s_client -connect app.example.com:443 -showcerts < /dev/null
# Tester que TLS 1.2 est refusé (si TLS 1.3 only)
openssl s_client -connect app.example.com:443 -tls1_2 < /dev/null
# Résultat attendu : "no protocols available" ou handshake failure
# Vérifier le cipher suite négocié
openssl s_client -connect app.example.com:443 < /dev/null 2>&1 | grep -E "Protocol|Cipher"
HSTS — HTTP Strict Transport Security#
HSTS (max-age=63072000; includeSubDomains; preload) indique au navigateur de refuser toute connexion HTTP vers le domaine pendant 2 ans (63 072 000 secondes). Le flag preload permet d’inscrire le domaine dans la liste HSTS preload des navigateurs, éliminant la vulnérabilité lors de la toute première visite (TOFU — Trust On First Use).
HSTS et sous-domaines
includeSubDomains applique HSTS à tous les sous-domaines. Vérifiez que tous vos sous-domaines supportent HTTPS avant d’activer ce flag — un sous-domaine HTTP deviendrait inaccessible depuis les navigateurs ayant mémorisé l’en-tête.
OCSP Stapling#
OCSP (Online Certificate Status Protocol) permet de vérifier si un certificat est révoqué. Sans stapling, le navigateur interroge le serveur OCSP de la CA lors de chaque connexion — lenteur et vie privée. Avec OCSP stapling, le serveur interroge lui-même le serveur OCSP et inclut la réponse signée dans le handshake TLS. Le client obtient la preuve de validité sans requête externe.
Certificate Pinning#
Le certificate pinning consiste à n’accepter qu’un ensemble précis de certificats ou de clés publiques (HPKP — HTTP Public Key Pinning, désormais déprécié dans les navigateurs, ou pinning dans les applications mobiles).
Risques :
Si le certificat piqué expire ou est révoqué et que le pinning n’est pas mis à jour, l’application devient inaccessible — risque opérationnel majeur.
HPKP a été retiré de Chrome en 2018 après plusieurs incidents de DoS auto-infligé.
Recommandation actuelle : préférer Certificate Transparency (surveillance des émissions) plutôt que le pinning pour les applications web. Le pinning reste pertinent dans des applications mobiles ou des clients embarqués avec un cycle de mise à jour maîtrisé.
Résumé#
TLS 1.3 réduit le handshake à 1 RTT en fusionnant la négociation des paramètres et l’échange de clé ECDHE dans les deux premiers messages, réduisant la latence par rapport à TLS 1.2.
TLS 1.3 supprime tous les algorithmes faibles : RSA key exchange, RC4, 3DES, CBC avec HMAC MD5/SHA-1, compression — ne restent que 5 cipher suites, toutes sûres.
ECDHE éphémère est obligatoire en TLS 1.3, ce qui garantit la forward secrecy systématique : la compromission de la clé long terme du serveur ne permet pas de déchiffrer les sessions passées.
Le mode 0-RTT est une optimisation de latence pour les reconnexions, mais il expose aux attaques de rejeu — à limiter aux requêtes idempotentes.
mTLS authentifie les deux parties via des certificats X.509 mutuels ; il est utilisé dans les service meshes (Istio, Linkerd) pour sécuriser les communications inter-microservices sans configuration applicative.
SNI permet le virtual hosting TLS (plusieurs domaines sur une même IP) ; ECH (Encrypted Client Hello) va plus loin en chiffrant le ClientHello pour masquer le SNI aux observateurs réseau.
Certificate Transparency rend visible toute émission de certificat dans des logs publics auditables, permettant de détecter rapidement des certificats frauduleux pour son domaine.
Les attaques historiques (BEAST, POODLE, DROWN) exploitaient des algorithmes ou des modes aujourd’hui supprimés de TLS 1.3 ; HEARTBLEED était un bug d’implémentation dont la forward secrecy limite les conséquences.
La configuration nginx recommandée active TLS 1.3 uniquement, désactive les session tickets pour la forward secrecy stricte, active l’OCSP stapling et impose HSTS avec preload.
Le certificate pinning est déconseillé pour les applications web (risque de DoS auto-infligé) ; Certificate Transparency et la surveillance des logs CT (
crt.sh, alertes automatiques) constituent une alternative plus robuste.