mTLS ve Internal Certificate'lar: ASP.NET Core'da Client Certificate Authentication ve Kendi CA'nı İşletmek
mTLS'te istemci de bir certificate ve private key ile attığı imzayla kimliğini kanıtlar; public CA'lar client authentication EKU'sunu bıraktığı için bunun için offline root'lu kendi internal CA'n gerekir. ASP.NET Core'da AddCertificate ile CustomRootTrust, issuer ve CN tabanlı partner eşlemesi kuruyor, Kestrel'in TLS katmanındaki sessiz reddini, revocation'ı ve Nginx'in certificate'ı header ile iletmesinin private key olmadan nasıl taklit edilebildiğini gerçek koşularla görüyoruz.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402, ASP.NET Core runtime 10.0.15), Microsoft.AspNetCore.Authentication.Certificate 10.0.15, OpenSSL 3.5, Nginx 1.30.6 ve step-ca 0.30.2 ile Docker 29 üzerinde denenmiştir; Windows'a özgü davranışlar bu ortamda çalıştırılmamıştır.
Önce Tahmin Et
Bookstore, bir kitap dağıtıcısı olan Acme Books ile sipariş entegrasyonu kuruyor. Acme'nin sistemi siparişleri Bookstore.Api'nin /api/partner/orders endpoint'ine gönderecek ve kimliğini bir API key'i yerine client certificate ile kanıtlayacak. Yani TLS iki yönlü çalışacak: mTLS (mutual TLS). Ekip TLS'i Nginx'te sonlandırıyor; Nginx client certificate'ı doğruluyor ve certificate'ı bir header ile Kestrel'e iletiyor. Kestrel aynı Docker ağında, 8080 portunda düz HTTP dinliyor.
Tahmin et: aynı ağdaki başka bir container'a erişimi olan biri, Acme'nin private key'ine hiç sahip olmadan, Bookstore'a Acme adına sipariş gönderebilir mi? Bunun için neye ihtiyacı var? Yazının ortasında deneyeceğiz.
mTLS Ne Zaman, Neden Internal CA?
Normal TLS'te yalnızca sunucu certificate gösterir. mTLS'te sunucu handshake sırasında bir CertificateRequest gönderir, istemci de kendi certificate'ını ve private key'iyle attığı bir imzayı (CertificateVerify) yollar. Kimlik, HTTP katmanına gelmeden önce kanıtlanmış olur. Üç yerde öne çıkar. Birincisi service-to-service trafik: cluster içindeki servislerin birbirini tanıması. İkincisi B2B (business-to-business) entegrasyonlar: Acme gibi bir partner'ın sabit bir sistemden bağlanması. Üçüncüsü cihazlar: bir depo tarayıcısının ya da kiosk'un fabrikada yüklenmiş kimliğiyle bağlanması. Bearer token'dan farkı, sırrın hiç gönderilmemesi. Certificate herkese açık bir belge; kanıt, private key ile atılan imza ve key makineden çıkmıyor.
Peki certificate'ı kim verecek? Public CA'lar (sertifika veren güvenilir kuruluş) bu işten çekiliyor. Üçüncü yazıda gördüğümüz gibi Let's Encrypt, 11 Şubat 2026'dan beri varsayılan profilinde client authentication EKU'sunu (sertifikanın hangi amaçla kullanılabileceğini belirten alan) koymuyor. Chrome Root Program Policy'nin 1.8 sürümü ise 15 Mart 2027'den itibaren verilen public TLS certificate'larının yalnızca server authentication EKU'su taşımasını şart koşuyor. Public PKI (sertifika ve anahtarların yönetildiği güven altyapısı) web sunucuları içindir; istemci kimliği için kendi CA'n gerekiyor. Bunun bir avantajı da var: güvendiğin tek CA kendi CA'n olunca, "bu certificate'ı kim verdi" sorusunun cevabı bir yetkilendirme kararına dönüşüyor.

Internal CA: Offline Root, Online Intermediate
Internal CA'nın temel kuralı public CA'larınkiyle aynı: root key'i yalnızca intermediate imzalamak için kullanılır ve sonra offline'a, yani ağa bağlı olmayan bir makineye ya da HSM'ye (anahtarları güvenli donanımda saklayan cihaz) kaldırılır. Günlük certificate'ları intermediate verir. Intermediate'ın key'i sızarsa onu iptal edip root ile yenisini imzalarsın; root sızarsa bütün güveni sıfırdan dağıtmak zorunda kalırsın. Bookstore'un hiyerarşisini OpenSSL ile kurdum:
#!/bin/sh
# Bookstore internal CA: offline root -> partner issuing CA -> kısa ömürlü client certificate'lar
set -e
cd /certs
# 1) root: 10 yıl, offline tutulur, yalnızca intermediate imzalar
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-384 -nodes -keyout root.key -out root.crt -days 3650 \
-subj "/O=Bookstore/CN=Bookstore Internal Root CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:1" -addext "keyUsage=critical,keyCertSign,cRLSign"
# 2) partner'lar için issuing CA: 1 yıl, altına başka CA açamaz
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout issuing.key -out issuing.csr \
-subj "/O=Bookstore/CN=Bookstore Partner Issuing CA 1"
printf "basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n" > issuing.ext
openssl x509 -req -in issuing.csr -CA root.crt -CAkey root.key -CAcreateserial -days 365 -out issuing.crt -extfile issuing.ext
# 3) issuing CA'dan leaf certificate'lar
# leaf: ad subject gün eku [san]
leaf() {
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "$1.key" -out "$1.csr" -subj "$2"
printf "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=%s\n" "$4" > "$1.ext"
[ -n "$5" ] && printf "subjectAltName=%s\n" "$5" >> "$1.ext"
openssl x509 -req -in "$1.csr" -CA issuing.crt -CAkey issuing.key -CAcreateserial -days "$3" -out "$1.crt" -extfile "$1.ext"
}
leaf acme-books "/O=Acme Books/CN=acme-books" 7 clientAuth
leaf other-books "/O=Other Books/CN=other-books" 7 clientAuth
leaf partner-api "/O=Bookstore/CN=partner-api.bookstore.example" 30 serverAuth "DNS:partner-api.bookstore.example"
cat partner-api.crt issuing.crt > partner-api.fullchain.pem
$ openssl verify -CAfile root.crt -untrusted issuing.crt acme-books.crt partner-api.crt
acme-books.crt: OK
partner-api.crt: OK
$ openssl verify -CAfile root.crt acme-books.crt
O=Acme Books, CN=acme-books
error 20 at 0 depth lookup: unable to get local issuer certificate
error acme-books.crt: verification failed
$ openssl x509 -in acme-books.crt -noout -issuer -dates -ext basicConstraints,keyUsage,extendedKeyUsage
issuer=O=Bookstore, CN=Bookstore Partner Issuing CA 1
notBefore=Nov 13 22:50:16 2026 GMT
notAfter=Nov 20 22:50:16 2026 GMT
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage:
TLS Web Client Authentication
Üç tasarım kararı var. pathlen:0, issuing CA'nın key'i çalınsa bile altına yeni bir CA açılamamasını sağlıyor. Partner'lar için ayrı bir issuing CA var; servisler arası certificate'lar başka bir intermediate'tan çıkacak ve uygulama "bu certificate partner CA'sından mı" diye bakabilecek. Leaf'ler 7 günlük. İkinci verify komutu da önemli bir şeyi gösteriyor: yalnızca root'u bilen taraf, intermediate'ı görmeden zinciri kuramıyor. Bu, istemcinin handshake'te intermediate'ı da göndermesi ya da sunucunun onu ayrıca bilmesi gerektiği anlamına geliyor.
Gerçek hayatta bu script'in elle çalıştırılmasını istemezsin. step-ca, HashiCorp Vault'un PKI engine'i, Active Directory Certificate Services veya bulut sağlayıcılarının private CA servisleri bu işi otomatikleştiriyor. Hangisini seçeceğin altyapına bağlı. Örnek olarak step-ca'yı container'da başlattım; JWK (kriptografik anahtarın JSON formatı) ve ACME (sertifika alma ve yenilemeyi otomatikleştiren protokol) provisioner'larıyla geliyor:
$ step ca certificate acme-books acme-books.crt acme-books.key --provisioner admin --provisioner-password-file /tmp/prov-pass
✔ Certificate: acme-books.crt
✔ Private Key: acme-books.key
$ step certificate inspect acme-books.crt --short
X.509v3 TLS Certificate (ECDSA P-256) [Serial: 3391...3031]
Subject: acme-books
Issuer: Bookstore Internal CA Intermediate CA
Provisioner: admin [ID: 04Pc...-xoo]
Valid from: 2026-11-13T22:52:46Z
to: 2026-11-14T22:53:46Z
$ step ca renew acme-books.crt acme-books.key --force
Your certificate has been saved in acme-books.crt.
Varsayılan ömür 24 saat ve certificate hem server hem client authentication EKU'su taşıyor. Yenileme, mevcut certificate ile CA'ya mTLS üzerinden yapılıyor; yani bir kez kimlik alan servis, bir sonraki certificate'ı kendi başına alabiliyor. ACME provisioner'ı sayesinde, serinin önceki yazılarındaki Certbot ve cert-manager da bu CA'ya Let's Encrypt'e konuşur gibi konuşabiliyor. Bir uyarı: container'ın hızlı kurulum modu root key'ini de aynı makinede üretiyor. Production'da root'u offline üretip step-ca'ya yalnızca intermediate'ı vermelisin.
Kestrel'de Client Certificate Authentication
Önce TLS'in Kestrel'de bittiği durumu kuralım. Endpoint certificate istiyor:
{
"Kestrel": {
"Endpoints": {
"Partner": {
"Url": "https://*:8443",
"ClientCertificateMode": "RequireCertificate",
"Certificate": {
"Path": "/certs/partner-api.fullchain.pem",
"KeyPath": "/certs/partner-api.key"
}
}
}
}
}
ClientCertificateMode'un diğer değerleri AllowCertificate (istenir ama zorunlu değil) ve DelayCertificate (handshake'te istenmez, uygulama ihtiyaç duyduğunda ister). Doğrulamayı Microsoft.AspNetCore.Authentication.Certificate paketindeki authentication handler yapıyor:
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
options.CustomTrustStore = [internalRoot];
options.AdditionalChainCertificates = [partnerIssuingCa];
options.RevocationMode = mtls.RevocationMode;
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
X509Certificate2 certificate = context.ClientCertificate;
string partnerId = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
// zincir geçerli; kimlik = CN, ama yalnızca partner issuing CA'dan ve kayıtlıysa
if (certificate.Issuer != partnerIssuingCa.Subject || !mtls.Partners.Contains(partnerId))
{
context.Fail($"certificate '{certificate.Subject}' is not a registered partner");
return Task.CompletedTask;
}
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("partner_id", partnerId), new Claim(ClaimTypes.Name, certificate.Subject)],
context.Scheme.Name));
context.Success();
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Partner", policy => policy.RequireClaim("partner_id"));
app.UseAuthentication();
app.UseAuthorization();
app.MapPost("/api/partner/orders", (ClaimsPrincipal user) =>
$"order accepted for partner {user.FindFirstValue("partner_id")}\n")
.RequireAuthorization("Partner");
Handler varsayılan olarak işletim sisteminin trust store'una bakıyor. CustomRootTrust ile yalnızca internal root'a güveniyor; böylece herhangi bir public CA'dan alınmış bir certificate bu endpoint'te işe yaramıyor. Intermediate'ı AdditionalChainCertificates ile verdim, çünkü yukarıdaki verify denemesinin gösterdiği gibi zincir onsuz kurulamıyor. Kimlik eşlemesi bilinçli olarak thumbprint'e değil, issuer ve CN'ye (sertifikadaki sahip adı alanı) dayanıyor. Thumbprint her yenilemede değişir ve 7 günlük certificate'larla her hafta config güncellemek gerekirdi. Burada kayıt defteri, hangi partner'ın kabul edildiğini söyleyen Partners listesi. Bir partner'la çalışmayı bıraktığında onu listeden çıkarırsın ve certificate'ı ömrü bitmeden etkisiz kalır.
İstemci tarafı, dördüncü yazıdaki CustomRootTrust istemcisinin üstüne client certificate'ı ekliyor:
X509Certificate2 clientCertificate = X509Certificate2.CreateFromPemFile(
$"/certs/{certificateName}.crt", $"/certs/{certificateName}.key");
X509Certificate2 issuingCa = X509CertificateLoader.LoadCertificateFromFile("/certs/issuing.crt");
// sunucu zinciri kurabilsin diye leaf + intermediate gönder
ssl.ClientCertificateContext = SslStreamCertificateContext.Create(clientCertificate, [issuingCa], offline: true);
using HttpClient client = new(new SocketsHttpHandler { SslOptions = ssl });
SslStreamCertificateContext, certificate'ı zinciriyle birlikte göndermeyi sağlıyor. Yalnızca ClientCertificates koleksiyonuna leaf'i eklersen intermediate'ın gönderilip gönderilmeyeceği istemcinin store'una kalır. Windows'ta PEM (sertifika ve anahtarlar için Base64 metin formatı) dosyasından yüklenen ephemeral key için dördüncü yazıdaki PKCS (public key kriptografisi için standartlar ailesi) #12 dönüşümü burada da gerekebilir; bunu Windows'ta denemedim.
Üç Hata, Üç Ayar
İlk koşuda iki istek de, certificate'sız olan da Acme'ninki de, aynı hatayla düştü:
$ dotnet Bookstore.PartnerClient.dll https://partner-api.bookstore.example:8443/api/partner/orders acme-books
[acme-books] An error occurred while sending the request. The response ended prematurely. (ResponseEnded)
Sunucu log'unda Information seviyesinde hiçbir şey yoktu. Kestrel'in log seviyesini Debug'a çekince sebep çıktı:
dbug: Microsoft.AspNetCore.Server.Kestrel.Https.Internal.HttpsConnectionMiddleware[1]
Failed to authenticate HTTPS connection.
System.Security.Authentication.AuthenticationException: The remote certificate was rejected by the provided RemoteCertificateValidationCallback.
Kestrel, client certificate'ı TLS katmanında işletim sisteminin trust store'una göre doğruluyor. Internal root orada olmadığı için handshake daha HTTP'ye gelmeden kopuyor ve authentication handler hiç çalışmıyor. İki seçenek var: internal root'u sunucunun sistem store'una eklemek (o zaman makinedeki her şey bu root'a güvenir) ya da TLS katmanında certificate'ı kabul edip doğrulamayı handler'a bırakmak:
builder.WebHost.ConfigureKestrel(kestrel => kestrel.ConfigureHttpsDefaults(https =>
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
// TLS katmanı certificate'ı kabul eder; doğrulamayı authentication handler yapar
https.AllowAnyClientCertificate();
}));
Bu ayar tek başına tehlikeli görünse de, endpoint RequireAuthorization ile korunduğu sürece doğrulama handler'da yapılıyor. İkinci koşu yine başarısız oldu, bu sefer 403 ile:
[acme-books] 403
warn: Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationHandler[2]
Certificate validation failed, subject was CN=acme-books, O=Acme Books. RevocationStatusUnknown unable to get certificate CRL, OfflineRevocation unable to get certificate CRL
Handler'ın RevocationMode varsayılanı Online. Certificate'larımızda CRL (iptal edilmiş sertifikaların listesi) adresi yok, bu yüzden iptal durumu bilinemiyor ve handler güvenli tarafta kalıp reddediyor. Burada da iki yol var. Birincisi CRL yayınlamak: issuing CA'ya bir crlDistributionPoints eklersin, CRL'i iç ağda bir HTTP adresinden sunarsın ve düzenli yenilersin. İkincisi kısa ömür: 7 günlük, hatta step-ca'daki gibi 24 saatlik certificate'larla iptal ihtiyacı büyük ölçüde ortadan kalkıyor. Partner'ı acilen kesmek gerekirse de Partners listesi var. İkinci yazıda public tarafta OCSP'nin (sertifikanın iptal durumunu anlık sorgulama protokolü) neden bırakıldığını görmüştük; aynı mantık burada da geçerli. Ben ikinci yolu seçip RevocationMode = NoCheck verdim ve üçüncü koşuda üç farklı certificate denedim:
[acme-books] 200 order accepted for partner acme-books
[other-books] 403
[fake-acme] 403
info: Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationHandler[7]
Certificate was not authenticated. Failure message: certificate 'CN=other-books, O=Other Books' is not a registered partner
warn: Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationHandler[1]
Self signed certificate rejected, subject was CN=acme-books, O=Acme Books.
other-books'un certificate'ı aynı CA'dan ve geçerli, ama kayıtlı bir partner değil. fake-acme ise birinin kendi ürettiği, CN'si birebir acme-books olan self-signed bir certificate; zincir kurulamadığı için reddediliyor. CN'nin bir anlam taşıması tamamen CA'ya güvenmene bağlı. Bir dikkat noktası: handler, doğrulama başarısız olduğunda 401 değil 403 döndürüyor. Microsoft dokümanı bunu, certificate'ın handshake'te alınmış olması ve isteğin bu noktada yeniden denenememesiyle açıklıyor.
Nginx'te TLS Sonlanırsa: Certificate Forwarding
Şimdi Önce Tahmin Et'teki kurulum. Nginx client certificate'ı istiyor, doğruluyor ve URL encode edilmiş PEM olarak bir header'a koyuyor:
server {
listen 443 ssl;
server_name partner-api.bookstore.example;
ssl_certificate /certs/partner-api.fullchain.pem;
ssl_certificate_key /certs/partner-api.key;
ssl_verify_client on;
ssl_client_certificate /certs/partner-ca-bundle.pem;
ssl_verify_depth 2;
location / {
proxy_pass http://api:8080;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
}
}
Uygulama tarafında AddCertificateForwarding header'ı okuyup certificate'a çeviriyor ve handler onu sanki TLS'ten gelmiş gibi doğruluyor:
// handler yalnızca HTTPS isteklerde çalışır: şema proxy'den gelmeli
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.29.0.0/16"));
});
builder.Services.AddCertificateForwarding(options =>
{
options.CertificateHeader = mtls.ForwardedHeader;
options.HeaderConverter = value => X509Certificate2.CreateFromPem(WebUtility.UrlDecode(value));
});
app.UseForwardedHeaders();
app.UseCertificateForwarding();
app.UseAuthentication();
app.UseAuthorization();
İlk denemede forwarded header kısmı yoktu ve Acme 403 aldı. Log'daki sebep, bir önceki yazının konusuna bağlanıyor: Not https, skipping certificate authentication. Handler yalnızca Request.IsHttps doğruysa çalışıyor. Kestrel isteği düz HTTP olarak gördüğü için handler certificate'a hiç bakmıyor. X-Forwarded-Proto eklenince sonuç doğru:
$ curl -sS --cacert root.crt --cert acme-books.crt --key acme-books.key -X POST https://partner-api.bookstore.example/api/partner/orders
order accepted for partner acme-books
$ curl -sS --cacert root.crt -X POST https://partner-api.bookstore.example/api/partner/orders | grep -o "<title>.*</title>"
<title>400 No required SSL certificate was sent</title>
Şimdi Önce Tahmin Et'in sorusu. Aynı ağdaki bir container'dan, Nginx'i atlayıp doğrudan Kestrel'e bir istek gönderdim. Header'a koyduğum şey Acme'nin certificate'ı; private key'i değil, yalnızca certificate:
$ CERT=$(tr -d '\n' < acme-books.crt | sed -e 's/+/%2B/g' -e 's/ /%20/g')
$ curl -sS -X POST -H "X-Forwarded-Proto: https" -H "X-Client-Cert: $CERT" http://api:8080/api/partner/orders
order accepted for partner acme-books
Sipariş Acme adına kabul edildi. Cevap: evet, gönderebilir ve tek ihtiyacı Acme'nin certificate'ı. Certificate gizli bir şey değil. Partner onu e-postayla gönderir, log'lara düşer, TLS 1.2'de handshake sırasında şifrelenmeden gider. mTLS'in güvencesi, karşı tarafın o certificate'ın private key'iyle imza atabilmesi; bu imzayı yalnızca TLS'i sonlandıran Nginx gördü. Header'a geçtikten sonra elimizde imza yok, yalnızca "Nginx bunu doğruladı" iddiası var. Bu iddiaya ise header'ı yazabilen herkes sahip çıkabilir. AddCertificateForwarding header'ın nereden geldiğine bakmıyor; KnownIPNetworks burada bütün Docker ağını kapsadığı için sahte X-Forwarded-Proto da kabul edildi.
Korunmanın yolları birlikte uygulanmalı. Kestrel'e yalnızca Nginx ulaşabilmeli: ayrı bir ağ, Kubernetes'te bir NetworkPolicy, ya da Kestrel'in yalnızca loopback'te dinlemesi. KnownIPNetworks bütün subnet'i değil, yalnızca proxy'nin adresini içermeli. Header'ı Nginx her zaman kendisi yazmalı. Bunu da denedim: ssl_verify_client optional ile istemci certificate göndermeyip header'ı kendisi koyduğunda, Nginx'in proxy_set_header'ı boş değerle header'ı hiç iletmedi ve istek 403 aldı. Kurulum izin veriyorsa en güçlü çözüm, Nginx ile Kestrel arasını da mTLS yapmak ya da TLS'i doğrudan Kestrel'de sonlandırmak.
Service Mesh'in Otomatik mTLS'i
Servis sayısı arttıkça her servise certificate dağıtmak, yenilemek ve HttpClient'larını ayarlamak ayrı bir iş yüküne dönüşür. Istio ve Linkerd gibi service mesh'ler bunu uygulamanın dışına taşıyor. Her pod'un yanındaki proxy (veya Istio'nun ambient modunda node düzeyindeki bileşen) kendi certificate'ını mesh'in CA'sından alıyor ve pod'lar arası trafiği otomatik olarak mTLS ile şifreliyor. Kimlik IP adresi değil, pod'un Kubernetes service account'u; Istio bunu SPIFFE (servislere standart kimlik veren açık standart) formatında bir URI (bir kaynağı tanımlayan adres, URL'nin genel hâli) olarak certificate'a yazıyor. Certificate'lar varsayılan olarak 24 saatlik ve kendiliğinden yenileniyor. Uygulama düz HTTP konuşmaya devam ediyor, yetkilendirme de "orders servisi yalnızca checkout service account'undan istek kabul eder" gibi mesh policy'leriyle yapılıyor. Bedeli, her isteğin yolunda bir proxy daha ve işletilmesi gereken bir mesh. Cluster içi trafik için bu yazıdaki el yapımı kurulumun yerini mesh alabilir; Acme gibi dışarıdan gelen bir partner için ise yine bir internal CA ve buradaki handler gerekir.
Üretimde Dikkat
Partner onboarding'ini bir süreç olarak tasarla. Partner kendi key'ini kendi tarafında üretir ve sana yalnızca CSR (certificate imzalama isteği) gönderir; private key hiçbir zaman e-postayla dolaşmaz. Sen CSR'ı partner CA'sı ile imzalarsın. Kısa ömürlü certificate kullanıyorsan partner'ın yenilemeyi otomatikleştirebileceği bir yol (ACME veya mevcut certificate ile yenileme) sun; aksi halde haftalık manuel bir iş yaratmış olursun.
Certificate authentication'ın sonuçlarını cache'le. Handler her istekte zinciri yeniden kuruyor. AddCertificateCache varsayılan olarak kapalı; açarsan OnCertificateValidated'ın sonucunun yalnızca certificate'a bağlı olması gerekir. Partner listesinden çıkarma gibi bir karar, cache süresi kadar gecikmeli uygulanır.
Hataları görünür yap. TLS katmanındaki reddi Kestrel yalnızca Debug seviyesinde yazıyor; partner "bağlanamıyoruz" dediğinde log'da hiçbir iz bulamazsın. Handler'ın OnAuthenticationFailed event'ini ve Kestrel'in HTTPS (HTTP Secure) log kategorisini production'da da okunabilir bir seviyede tut. Hangi partner'ın certificate'ının ne zaman bittiğini de izle; serinin son yazısının konusu.
Root'u planla. 10 yıllık bir root, ekipte kimsenin hatırlamadığı bir tarihte bitecek. Root'un bitişini, intermediate'ın yıllık yenilemesini ve CA key'lerinin nerede saklandığını yazılı bir runbook'ta tut.
Sık Düşülen Tuzaklar
Forwarded certificate header'ına koşulsuz güvenmek: Yukarıdaki sahte sipariş. Certificate header'ı, proxy'den başka hiçbir yerden ulaşılamayan bir uygulamada anlamlı. Kestrel'in portu dışarıya ya da paylaşılan bir ağa açıksa, herkes istediği partner'ın kimliğine bürünebilir.
Thumbprint'e göre yetkilendirmek: Thumbprint certificate'ın hash'i ve her yenilemede değişiyor. 7 günlük certificate'la thumbprint listesi tutan bir sistem, her hafta bir partner'ın kesilmesine sebep olur. Kimliği issuer ile subject'e bağla, issuer'ı da kendi kontrolündeki bir CA ile sınırla.
Sistem trust store'una güvenmek: ChainTrustValidationMode'u varsayılan (System) bırakırsan, public bir CA'dan client authentication EKU'lu certificate almış herkes zincir doğrulamasını geçer. Senin partner listen bunu son anda yakalasa bile, CN'si kayıtlı bir partner'la aynı olan bir certificate'ı başka bir CA'dan almak mümkün olabilir. Issuer kontrolü ve CustomRootTrust birlikte çalışmalı.
Revocation'ı yanlış anlamak: RevocationMode.Online CRL adresi olmayan certificate'larda her isteği reddeder; yukarıda gördük. Bunu "geçici olarak" NoCheck yapıp uzun ömürlü certificate'larla bırakmak ise sızan bir key'i yıllarca geçerli tutar. NoCheck, ancak kısa ömür ve uygulama düzeyinde bir izin listesi ile birlikte mantıklı.
Intermediate'ı unutmak: Sunucu intermediate'ı bilmiyorsa ve istemci de göndermiyorsa zincir kurulamaz. Birinci yazıdaki sunucu tarafı eksik intermediate sorununun istemci tarafındaki aynası bu.
Küçük Bir Deneme
★☆☆ Bu yazıdaki ca.sh'ı çalıştır ve partner-api.crt'yi client certificate olarak kullanmayı dene. Handler ne diyor? (İpucu: ValidateCertificateUse ve EKU.)
★★☆ Issuing CA'ya bir crlDistributionPoints ekle, openssl ca ile acme-books'u iptal eden bir CRL üret ve onu bir Nginx container'ından HTTP ile sun. RevocationMode'u Online'a geri al: iptal edilen certificate nasıl bir log satırıyla reddediliyor, CRL sunucusu kapalıyken ne oluyor?
★★★ Forwarding kurulumunu güvenli hale getir: KnownIPNetworks'ü yalnızca Nginx'in IP'sine indir, Kestrel'i ayrı bir Docker ağına al ve sahte header denemesini hem başka bir container'dan hem Nginx üzerinden tekrarla. Sonra Nginx ile Kestrel arasını da mTLS yap ve AddCertificateForwarding'e hâlâ ihtiyaç olup olmadığını tartış.
Artık hem sunucuların hem istemcilerin certificate'ları var ve hepsinin bir bitiş tarihi. Serinin son yazısında bunları bir envantere dökeceğiz: expiry alert'leri, TLS config testleri ve gerçek olaylardan çıkan bir runbook.