TLS 1.3 Handshake'i: Sertifika Zinciri, SNI, ALPN ve Post-Quantum Key Exchange
TLS 1.3, key_share'i ClientHello'ya koyarak şifreli kanalı tek RTT'de kurar; ECDHE forward secrecy, X25519MLKEM768 ise post-quantum koruma sağlar. openssl s_client ile handshake, 285'ten 1.525 byte'a büyüyen ClientHello, sertifika zinciri hataları, SNI/ECH/ALPN, 0-RTT ve .NET'in macOS'ta TLS 1.2'de kalması.
Sürüm notu: Bu yazıdaki komutlar OpenSSL 4.0.3 ve curl 8.7.1 ile, kodlar .NET 10.0.13 ile macOS'ta ve mcr.microsoft.com/dotnet/aspnet:10.0 Linux container'ında denenmiştir; TLS (Transport Layer Security) anlatımı RFC (Request for Comments) 9846'yı (TLS 1.3, Temmuz 2026) esas alır. Tarayıcı durumu Kasım 2026 itibarıyla yazılmıştır.
Önce Tahmin Et
Bookstore API'sini (Application Programming Interface) bir Linux container'ında çalıştırıyorsun ve curl ile bağlanınca TLS 1.3 görüyorsun. Aynı sunucuya bir Mac üzerinde çalışan .NET 10 uygulamasından HttpClient ile bağlanıyorsun. Hangi TLS sürümü konuşulur? Sunucu 1.3 destekliyor, istemci .NET 10; cevap açık gibi görünüyor.
İkinci soru: OpenSSL 4.0.3'ün varsayılan ayarlarıyla bir TLS bağlantısı açınca ilk mesaj, yani ClientHello, kaç byte olur? Birkaç yüz mü, bir buçuk kilobayt mı? İkisinin cevabı da bu yazının içinde ve ikisi de gerçek ölçüm.
Üç Hedef ve 1-RTT Handshake
TLS üç şey sağlar. Confidentiality: aradaki kimse içeriği okuyamaz. Integrity: içerik yolda değiştirilirse fark edilir. Authentication: konuştuğun sunucu gerçekten iddia ettiği sunucudur. İlk ikisi simetrik şifreleme ve bir AEAD (Authenticated Encryption with Associated Data) algoritmasıyla, üçüncüsü sertifikalar ve dijital imzalarla sağlanır. Handshake'in işi, iki tarafın hiç karşılaşmadan ortak bir gizli anahtar üzerinde anlaşması ve bu sırada sunucunun kimliğini kanıtlaması.
TLS 1.3 ilk olarak 2018'de RFC 8446 olarak yayımlandı. Temmuz 2026'da yayımlanan RFC 9846, RFC 8446'yı ve TLS 1.2'nin RFC 5246'sını resmen obsolete etti; protokolün kablodaki hâli aynı, değişen metnin netleştirilmesi ve birikmiş düzeltmeler. Handshake'in mesajlarını görmek için OpenSSL'in s_client'ını container'daki Kestrel'e bağladım:
$ openssl s_client -connect 127.0.0.1:5592 -servername localhost -CAfile certs/ca.crt -alpn h2,http/1.1 -tls1_3
Connecting to 127.0.0.1
depth=1 CN=Bookstore Local Root CA
verify return:1
depth=0 CN=bookstore.localhost
verify return:1
CONNECTED(00000003)
---
Certificate chain
0 s:CN=bookstore.localhost
i:CN=Bookstore Local Root CA
a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
v:NotBefore: Nov 8 09:39:16 2026 GMT; NotAfter: Dec 8 09:39:16 2026 GMT
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: ecdsa_secp256r1_sha256
Peer Temp Key: X25519, 253 bits
---
SSL handshake has read 824 bytes and written 1567 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
ALPN protocol: h2
Early data was not sent
Verify return code: 0 (ok)
---
ECH: NOT CONFIGURED: -103

TLS 1.2'de istemci önce desteklediği algoritmaları söylüyor, sunucu birini seçiyor, ardından anahtar değişimi ikinci bir tur gerektiriyordu: TCP'den (Transmission Control Protocol) sonra iki RTT (round-trip time). TLS 1.3 tahminde bulunur: istemci ClientHello'ya en olası grup için kendi anahtar payını (key_share) doğrudan koyar. Sunucu ServerHello'da kendi payını gönderir; bu andan itibaren iki taraf ortak anahtarı hesaplayabildiği için sunucunun geri kalan mesajları (EncryptedExtensions, Certificate, CertificateVerify, Finished) zaten şifreli gider. İstemci Finished'ını ve ilk HTTP (Hypertext Transfer Protocol) isteğini aynı uçuşta gönderir: bir RTT. Bunu -msg ile mesaj mesaj görmek mümkün:
$ openssl s_client -connect 127.0.0.1:5595 -CAfile certs/ca.crt -msg | grep -E "ClientHello|ServerHello,|Certificate$|CertificateVerify|Finished|Negotiated"
>>> TLS 1.3, Handshake [length 05f5], ClientHello
<<< TLS 1.3, Handshake [length 01c8], Certificate
<<< TLS 1.3, Handshake [length 004f], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, Handshake [length 0034], Finished
Negotiated TLS1.3 group: X25519MLKEM768
Bu ikinci çıktı farklı bir sunucudan: aynı sertifikayla yerelde çalıştırdığım OpenSSL 4.0.3 s_server. Neden ayrı bir sunucuya ihtiyaç duyduğumu bir sonraki bölüm açıklıyor. DevTools'un Security paneli de aynı bilgileri (protokol, key exchange grubu, cipher) tarayıcı tarafında gösterir.
ECDHE, Forward Secrecy ve Post-Quantum Key Exchange
Peer Temp Key: X25519 satırı anahtar değişiminin ECDHE (Elliptic Curve Diffie-Hellman Ephemeral) ile yapıldığını söylüyor. "Ephemeral" kelimesi kritik: her bağlantı için yeni, geçici bir anahtar çifti üretilir ve handshake bitince atılır. Sunucunun sertifikasındaki uzun ömürlü private key yalnızca imza atmak için kullanılır, oturum anahtarını türetmek için değil. Bu yüzden private key bir gün çalınsa bile geçmişte kaydedilmiş trafik çözülemez; buna forward secrecy denir. TLS 1.3, bu özelliği sağlamayan RSA (Rivest–Shamir–Adleman) anahtar taşımasını tamamen kaldırdı.
Forward secrecy'nin bir açığı var: bugün kaydedilen trafik, ileride yeterince güçlü bir kuantum bilgisayarla eliptik eğri matematiği kırılırsa çözülebilir ("harvest now, decrypt later"). Bu yüzden anahtar değişimi hybrid bir yapıya geçti: klasik X25519 (Curve25519 üzerinde Diffie-Hellman) ile kuantum dirençli Module-Lattice-Based Key-Encapsulation Mechanism (ML-KEM) birlikte kullanılır ve ikisinden biri sağlam kaldığı sürece oturum güvende kalır. Google Security Blog'a göre Chrome 131'den beri X25519MLKEM768'i varsayılan olarak kullanıyor; diğer büyük tarayıcılar da aynı grubu destekliyor. OpenSSL 4.0.3'ün bildiği gruplar:
$ openssl list -tls-groups
secp256r1:secp384r1:secp521r1:x25519:x448:brainpoolP256r1tls13:brainpoolP384r1tls13:brainpoolP512r1tls13:curveSM2:ffdhe2048:ffdhe3072:ffdhe4096:ffdhe6144:ffdhe8192:MLKEM512:MLKEM768:MLKEM1024:SecP256r1MLKEM768:X25519MLKEM768:SecP384r1MLKEM1024:curveSM2MLKEM768
Container'daki Kestrel'in çıktısında grup X25519'du, çünkü Ubuntu tabanlı .NET image'ındaki OpenSSL sürümü ML-KEM'i bilmiyor; istemcinin gönderdiği hybrid pay yok sayıldı ve klasik X25519'a düşüldü. Yerel OpenSSL 4 sunucusuyla ise varsayılan ayarlar hybrid grubu seçti. Bedeli boyutta. ClientHello'yu yalnızca X25519 ile ve varsayılanla karşılaştırınca:
$ openssl s_client -connect 127.0.0.1:5595 -CAfile certs/ca.crt -groups X25519 -msg | grep -E "ClientHello|ServerHello"
>>> TLS 1.3, Handshake [length 011d], ClientHello
<<< TLS 1.3, Handshake [length 007a], ServerHello
$ openssl s_client -connect 127.0.0.1:5595 -CAfile certs/ca.crt -msg | grep -E "ClientHello|ServerHello,"
>>> TLS 1.3, Handshake [length 05f5], ClientHello
ClientHello 285 byte'tan 1.525 byte'a çıktı, ServerHello ise (ilk çıktıdaki 04ba) 1.210 byte. Tahmin bölümündeki ikinci sorunun cevabı bu: ML-KEM'in public key'i ve ciphertext'i X25519'un 32 byte'ına karşı bir kilobayttan büyük. ClientHello artık tek bir TCP segment'ine sığmayabiliyor; bu yüzden ilk dağıtımlarda, büyük ClientHello'yu beklemeyen bazı middlebox'lar bağlantıları kopardı. Önemli bir ayrıntı: post-quantum olan yalnızca anahtar değişimi. Sertifikadaki imza hâlâ klasik ECDSA (Elliptic Curve Digital Signature Algorithm); sertifikaların post-quantum imzaya geçişi ayrı ve daha yavaş ilerleyen bir süreç.
Sertifika Zinciri ve Doğrulama
Sunucu kimliğini bir sertifikayla kanıtlar. Sertifika "bu public key bookstore.localhost'a aittir" der ve bir CA (Certificate Authority) tarafından imzalanır. İstemci imzayı, güvendiği bir root CA'ya ulaşana kadar zincir boyunca kontrol eder; sonra adın eşleştiğine ve sürenin geçerli olduğuna bakar. Yukarıdaki depth=1 ve depth=0 satırları bu zincir: önce root, sonra sunucunun sertifikası. Gerçek dünyada arada genelde bir intermediate CA vardır ve sunucu onu da göndermek zorundadır; root'u ise istemcinin trust store'u sağlar.
Doğrulamanın başarısız olduğu iki yaygın durumu yerel sunucuda denedim. Önce CA'yı tanıtmadan, sonra yanlış bir isimle:
$ openssl s_client -connect 127.0.0.1:5595 -servername localhost
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
Verification error: unable to verify the first certificate
Verify return code: 21 (unable to verify the first certificate)
$ openssl s_client -connect 127.0.0.1:5595 -CAfile certs/ca.crt -verify_hostname shop.example
verify error:num=62:hostname mismatch
Verification error: hostname mismatch
Verify return code: 62 (hostname mismatch)
Production'da en sık görülen sertifika hatası birincisinin bir türevi: sunucu intermediate sertifikayı göndermiyor. Tarayıcılar eksik intermediate'i cache'ten veya AIA (Authority Information Access) ile bulabildiği için site tarayıcıda çalışıyor gibi görünür, ama HttpClient veya curl ile bağlanan servisler hata alır. Sertifika alma, yenileme ve zincir operasyonu, TLS'in eski adı SSL (Secure Sockets Layer) ve HTTPS (HTTP Secure) ile birlikte ilerideki DevOps "SSL/TLS Certificate'ları ve HTTPS" serisinin konusu; burada protokol tarafında kalıyoruz.
SNI, ECH ve ALPN
Aynı IP (Internet Protocol) adresinde birden çok alan adı barındıran bir sunucu, handshake başında hangi sertifikayı göndereceğini bilmek zorunda. İstemci bunu ClientHello'daki SNI (Server Name Indication) extension'ıyla söyler: -servername localhost tam olarak bunu yapıyor. Sorun, ClientHello'nun şifresiz olması: ağdaki herkes hangi siteye bağlandığını SNI'dan okuyabilir. ECH (Encrypted Client Hello, RFC 9849, Mart 2026) bunu çözer: istemci asıl ClientHello'yu, sunucunun DNS'teki (Domain Name System) HTTPS kaydında yayımladığı bir public key ile şifreler ve dışarıya yalnızca ortak bir "ön yüz" ismi görünür. OpenSSL 4'ün çıktısındaki ECH: NOT CONFIGURED satırı, istemcinin ECH desteği olduğunu ama bu bağlantıda kullanılacak bir yapılandırma olmadığını söylüyor. ECH'in anahtarı DNS'ten geldiği için pratikte şifreli bir DNS kanalıyla (önceki yazıdaki DoH) birlikte anlam kazanır.
ALPN (Application-Layer Protocol Negotiation) ise aynı handshake içinde hangi uygulama protokolünün konuşulacağını belirler. İstemci h2,http/1.1 önerdi, Kestrel h2'yi seçti: ALPN protocol: h2. HTTP/2'nin TLS üzerinde ek bir tur olmadan başlayabilmesi bu sayede; sekizinci yazıda ayrıntısıyla göreceğiz.
0-RTT ve Replay Riski
TLS 1.3'te daha önce bağlandığın bir sunucuya dönerken, önceki oturumdan kalan bir PSK (pre-shared key) ile ilk uçuşta uygulama verisi de gönderebilirsin: 0-RTT veya early data. OpenSSL ile denedim; önce bir oturum açıp bileti kaydettim, sonra bileti kullanarak ClientHello'yla birlikte bir HTTP isteği gönderdim:
$ openssl s_client -connect 127.0.0.1:5595 -CAfile certs/ca.crt -sess_out logs/sess.pem
$ openssl s_client -connect 127.0.0.1:5595 -CAfile certs/ca.crt -sess_in logs/sess.pem -early_data logs/early.txt
Reused, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Early data was accepted
Max Early Data: 16384
Sunucunun loguna isteğin satırları handshake bitmeden düştü. Bedeli güvenlikte: early data'nın forward secrecy'si zayıftır ve bir saldırgan kaydettiği early data'yı sunucuya tekrar gönderebilir (replay). Bu yüzden 0-RTT yalnızca tekrarlanması zararsız, yani idempotent ve yan etkisiz istekler için uygundur: bir GET /api/books evet, bir POST /api/orders asla. Method semantiğini ve idempotency'yi altıncı yazıda ele alıyoruz.
.NET: TLS İşletim Sisteminden Gelir
.NET kendi TLS implementasyonunu taşımaz; SslStream işletim sisteminin kütüphanesini kullanır: Windows'ta SChannel, Linux'ta OpenSSL, macOS'ta Apple'ın kütüphanesi. Tahmin bölümündeki soruyu cevaplamak için hangi protokolün konuşulduğunu raporlayan küçük bir probe yazdım:
public static async Task ProbeAsync(string host, int port, string caPath)
{
var root = X509CertificateLoader.LoadCertificateFromFile(caPath);
using var tcp = new TcpClient();
await tcp.ConnectAsync(host, port);
await using var tls = new SslStream(tcp.GetStream());
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
{
TargetHost = "localhost", // SNI ve sertifika adı kontrolü için
ApplicationProtocols = [SslApplicationProtocol.Http2, SslApplicationProtocol.Http11],
CertificateChainPolicy = new X509ChainPolicy
{
TrustMode = X509ChainTrustMode.CustomRootTrust,
CustomTrustStore = { root },
RevocationMode = X509RevocationMode.NoCheck,
},
});
Console.WriteLine($"{host}:{port} protocol={tls.SslProtocol} cipher={tls.NegotiatedCipherSuite} alpn={tls.NegotiatedApplicationProtocol}");
Console.WriteLine($" server certificate: {tls.RemoteCertificate?.Subject}, issuer: {tls.RemoteCertificate?.Issuer}");
}
$ dotnet Bookstore.Net.Lab.dll tls 127.0.0.1 5592 certs/ca.crt
127.0.0.1:5592 protocol=Tls12 cipher=TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 alpn=h2
server certificate: CN=bookstore.localhost, issuer: CN=Bookstore Local Root CA
$ curl -sv --cacert certs/ca.crt https://127.0.0.1:5592/api/books 2>&1 | grep "SSL connection"
* SSL connection using TLSv1.3 / AEAD-AES256-GCM-SHA384 / [blank] / UNDEF
Aynı sunucu, aynı makine: curl TLS 1.3, .NET TLS 1.2. Sunucu tarafında da durum aynı; Kestrel'i doğrudan macOS'ta çalıştırınca curl bile TLS 1.2 ile bağlandı (SSL connection using TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384). .NET'in macOS'ta kullandığı Apple API'si TLS 1.3 desteklemiyor; konu dotnet/runtime deposunda 1979 numaralı açık issue'da takip ediliyor ve daha yeni bir Apple API'sine geçiş üzerinde çalışılıyor. Linux container'ında ve Windows'ta .NET TLS 1.3 konuşur. Ders: TLS davranışın kodundan değil platformdan gelir; geliştirme makinende gördüğünü production'a genelleme, hedef platformda ölç.
Probe'daki CustomRootTrust, yerel geliştirmede sertifika sorununu çözmenin doğru yolu: işletim sisteminin trust store'una dokunmadan yalnızca bu bağlantı için kendi CA'na güveniyorsun ve zincir ile isim kontrolü çalışmaya devam ediyor. Sık görülen alternatif HttpClientHandler.DangerousAcceptAnyServerCertificateValidator ise doğrulamayı tamamen kapatır; adındaki "Dangerous" bir uyarı. Yalnızca yerel bir test ortamında, kontrolünde olan bir sunucuya karşı kullanılabilir; production koduna girmesi, ortadaki herkesin trafiği okuyabilmesi demek. Şifrelemenin uygulama tarafındaki hataları Web Güvenliği serisinin OWASP (Open Worldwide Application Security Project) Top 10 bölümündeki "OWASP A04: Cryptographic Failures (1) — Encoding, Hashing, Encryption ve HTTPS" yazısında.
Üretimde Dikkat
TLS'i mümkün olduğunca platforma ve varsayılanlara bırak. .NET'te SslProtocols'u elle sabitlemek, işletim sistemi yeni bir sürümü desteklediğinde bile uygulamanı eski sürümde tutar. Microsoft'un önerisi de sürümü işletim sistemine bırakmak.
Sertifika süresini ve zincirini izle. Süresi dolan sertifika, en çok yaşanan ve en kolay önlenen kesinti. Zincirin tam gönderildiğini tarayıcıyla değil, openssl s_client -showcerts veya curl ile doğrula.
Handshake maliyetini bağlantı yeniden kullanımıyla öde. Her TLS handshake'i bir RTT ve kayda değer CPU (Central Processing Unit) demek; önceki yazılarda gördüğümüz gibi yeniden kullanılan bağlantılar bu maliyeti tek seferlik yapar. Post-quantum hybrid ile ClientHello büyüdüğü için yeni bağlantının maliyeti biraz daha arttı.
Ağ cihazlarını post-quantum'a hazırla. Bin byte'ı aşan ClientHello'yu parse edemeyen eski bir proxy veya firewall, bağlantıları sessizce koparabilir. Kurumsal ağda bağlantı sorunları yaşayan istemcileri teşhis ederken bunu aklında tut.
Sık Düşülen Tuzaklar
Doğrulamayı kapatıp unutmak. DangerousAcceptAnyServerCertificateValidator veya curl -k yerel bir kolaylık olarak başlar ve production'a sızar.
Intermediate sertifikayı göndermemek. Tarayıcıda çalışır, servisten servise çağrılarda kırılır.
Platform farkını görmemek. macOS'ta geliştirip Linux'ta çalıştırdığın uygulama farklı TLS sürümü konuşur; ölçümünü hedef platformda yap.
0-RTT'yi her isteğe açmak. Early data replay edilebilir; yan etkisi olan isteklerde kullanılmamalı.
"Post-quantum" ifadesini fazla genellemek. Bugün korunan şey anahtar değişimi; sertifika imzaları hâlâ klasik algoritmalarla atılıyor.
Küçük Bir Deneme
★☆☆ Sık kullandığın bir siteye openssl s_client -connect example.com:443 -servername example.com kalıbıyla, kendi alan adını yazarak bağlan. Hangi grup, hangi cipher ve hangi sertifika zinciri geliyor? Tarayıcının Security panelindeki bilgilerle karşılaştır.
★★☆ openssl s_client'ı -groups X25519 ve varsayılan ayarla yerel bir sunucuya bağla ve SSL handshake has read ... and written ... satırlarını karşılaştır. Fark nereden geliyor?
★★★ Bu yazıdaki TlsProbe'u bir Linux container'ında çalıştır ve aynı sunucuya bağlan. SslProtocol ve NegotiatedCipherSuite değerleri macOS'takilerden nasıl farklı?
Şifreli kanal kuruldu. Artık kanalın içindeki asıl konuşmaya geçebiliriz. Bir sonraki yazıda HTTP/1.1'i nc ve openssl s_client ile elle yazıp kablodan okuyoruz: request ve response formatı, Content-Length ile chunked framing, method semantiği ve keep-alive.