Passkey'ler 1: WebAuthn ve FIDO2 Nasıl Çalışır? RP ID, Challenge, Attestation ve Assertion
Passkey phishing'e dayanıklıdır çünkü tarayıcı credential'ı yalnız kayıtlı RP ID'nin origin'lerinde kullanır ve imza, origin ile challenge'ı taşıyan clientDataJSON'u kapsar. Bookstore lab'ında yakaladığımız gerçek attestation ve assertion verisini CBOR ile çözüyor, imzayı elle doğruluyor, UV, BE ve BS bayraklarını, synced ve device-bound passkey farkını ve hesap kurtarma tasarımını anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.14, System.Formats.Cbor 10.0.14 ve Chrome 156'nın sanal authenticator'ı ile denenmiştir.
Önce Tahmin Et
Ada'nın Bookstore hesabında bir passkey var. Bir saldırgan Bookstore'un giriş sayfasının birebir kopyasını başka bir alan adında yayınlıyor ve Ada'ya "siparişin gecikti, giriş yap" diye bir link gönderiyor. Ada sahte sayfada "passkey ile giriş"e basıyor. TOTP (Time-based One-Time Password) olsaydı kodu yazacak ve saldırgan onu anında gerçek siteye aktaracaktı. Passkey'de ne olur?
origin -> http://127.0.0.1:19170
passkey login -> error: SecurityError This is an invalid domain.
Tarayıcı isteği daha authenticator'a bile götürmüyor. Lab'da "başka site"yi aynı uygulamayı localhost yerine 127.0.0.1 üzerinden açarak taklit ettim; passkey localhost için kayıtlı, sayfanın origin'i ise başka. Saldırgan bir şekilde imzayı ele geçirse bile işe yaramıyor, çünkü imzanın içinde sayfanın origin'i var:
signature valid: True
signature valid with another origin: False
Bu yazı, passkey'lerin bu özelliği nereden aldığını açıyor: public key credential, RP (Relying Party) ID, challenge, user verification, attestation ve assertion. Örnekler Bookstore lab'ında gerçek bir kayıt ve girişten yakalanmış WebAuthn (Web Authentication) verisi; bir sonraki yazıda aynı akışı ASP.NET Core Identity 10 ve Angular ile uçtan uca kuracağız.
Parola Yerine Key Çifti
Passkey, bir web sitesine bağlı bir public key credential'dır. Kayıt sırasında authenticator (telefonun secure enclave'i, Windows Hello, bir parola yöneticisi ya da USB (Universal Serial Bus) güvenlik anahtarı) yeni bir key çifti üretir. Private key authenticator'da kalır; sunucu yalnız public key'i saklar. Girişte sunucu rastgele bir challenge gönderir, authenticator onu private key ile imzalar, sunucu imzayı public key ile doğrular. Sunucunun veritabanı çalınsa bile elde giriş yapmaya yarayacak bir sır yok; public key'ler zaten public.
FIDO2 (Fast IDentity Online 2) bu modelin iki parçalı adıdır: tarayıcı ile web sitesi arasındaki W3C (World Wide Web Consortium) standardı WebAuthn ve tarayıcı ile harici bir authenticator arasındaki CTAP2 (Client to Authenticator Protocol 2). WebAuthn Level 3, 25 Ağustos 2026'da W3C Recommendation oldu; JSON (JavaScript Object Notation) ile options ve credential serileştirme (parseCreationOptionsFromJSON), hints ve related origins gibi eklemeler Level 3 ile geldi. "Passkey" terimi spesifikasyonda discoverable credential'ın gündelik adı olarak geçiyor.
RP ID ve Origin: Phishing Direnci Nereden Geliyor?
Her credential bir RP ID'ye bağlıdır. RP ID bir alan adıdır (Bookstore için bookstore.example, lab'da localhost) ve tarayıcı bir kuralı zorlar: RP ID, sayfanın origin'inin alan adına eşit ya da onun kayıtlı bir üst alan adı olmalı. shop.bookstore.example üzerindeki bir sayfa bookstore.example RP ID'sini kullanabilir; bookstore-login.example kullanamaz. Kontrolü JavaScript değil tarayıcı yaptığı için sahte sayfanın kodu bunu aşamaz. Yukarıdaki SecurityError bu kuraldan geliyor.
İkinci katman imzanın içeriği. Authenticator'ın döndürdüğü authenticatorData'nın ilk 32 byte'ı RP ID'nin Secure Hash Algorithm 256 (SHA-256) hash'i; tarayıcının ürettiği clientDataJSON ise origin'i ve challenge'ı taşır. İmza ikisini birden kapsar. Lab'da yakaladığım veriyi çözen küçük bir inspector yazdım:
static void PrintAuthenticatorData(byte[] data)
{
// rpIdHash (32) | flags (1) | signCount (4) | ...
bool rpMatches = data.AsSpan(0, 32).SequenceEqual(SHA256.HashData("localhost"u8));
byte flags = data[32];
string[] names = ["UP", "RFU1", "UV", "BE", "BS", "RFU2", "AT", "ED"];
string set = string.Join(" ", names.Where((_, bit) => (flags & (1 << bit)) != 0));
uint signCount = (uint)(data[33] << 24 | data[34] << 16 | data[35] << 8 | data[36]);
Console.WriteLine($"authenticatorData: {data.Length} bytes, rpIdHash == SHA-256(\"localhost\"): {rpMatches}, flags=0x{flags:x2} [{set}], signCount={signCount}");
}
Sunucu tarafında doğrulama sırası şu: clientDataJSON'daki type beklenen tür mü (webauthn.create ya da webauthn.get), challenge bu oturum için üretilen değer mi, origin izin verilen origin'lerden biri mi; rpIdHash kendi RP ID'nin hash'i mi; flag'ler beklendiği gibi mi; ve en sonda imza. ASP.NET Core Identity 10 bu adımların hepsini yapıyor; bir sonraki yazıda hangi ayarların onları etkilediğini göreceğiz. Burada önemli olan, phishing direncinin kullanıcının dikkatine değil protokolün yapısına dayanması.
Challenge ve User Verification
Challenge, sunucunun her tören (ceremony) için ürettiği tek kullanımlık rastgele değer. Identity'nin varsayılanı 32 byte. Challenge olmasa yakalanmış bir assertion tekrar gönderilebilirdi; bu yüzden sunucu challenge'ı kendisi üretip saklar ve cevapta birebir aynısını bekler. Bunu bir sonraki yazıda Identity'ye yakalanmış eski bir assertion'ı göndererek deneyeceğiz.
Authenticator iki farklı şey kanıtlayabilir. UP (User Presence) "biri cihaza dokundu" demek. UV (User Verification) "cihazın sahibi parmak izi, yüz ya da PIN (Personal Identification Number) ile doğrulandı" demek. UV'li bir passkey tek başına iki factor taşır: sahip olduğun cihaz ve cihazın açılması için bildiğin ya da olduğun şey. Bu yüzden passkey'i parolanın yanına ikinci factor olarak değil, parolanın yerine birincil yöntem olarak düşünmek gerekiyor. Sunucu userVerification: "required" isterse UV bayrağı olmayan bir cevabı reddeder.
Kayıt: Attestation

Kayıt, sunucunun ürettiği creation options ile başlar. Lab'daki gerçek çıktı:
{"rp":{"name":"localhost","id":"localhost"},"user":{"id":"NDQ3ZjY0NmItZmI0Mi00YWFiLTk3NjYtMTZlMjcwYzk3ZTBk","name":"ada@bookstore.test","displayName":"ada@bookstore.test"},"challenge":"tIH5wpkAq7J-cDdENtx03Fpa5mCU6BPRa9PhMboNtoI","pubKeyCredParams":[{"type":"public-key","alg":-7},{"type":"public-key","alg":-37},{"type":"public-key","alg":-35},{"type":"public-key","alg":-38},{"type":"public-key","alg":-39},{"type":"public-key","alg":-257},{"type":"public-key","alg":-36},{"type":"public-key","alg":-258},{"type":"public-key","alg":-259}],"timeout":120000,"excludeCredentials":[],"authenticatorSelection":{"residentKey":"required","requireResidentKey":true,"userVerification":"required"},"hints":[],"attestationFormats":[]}
user.id kullanıcının e-postası değil, Base64Url kodlanmış Identity kullanıcı id'si; WebAuthn bu alana kişisel veri konmamasını ister, çünkü authenticator onu saklar. pubKeyCredParams sunucunun kabul ettiği algoritmalar, tercih sırasıyla; -7 COSE (CBOR Object Signing and Encryption) kayıt defterinde ES256, yani P-256 eğrisi üzerinde ECDSA (Elliptic Curve Digital Signature Algorithm) ve SHA-256. residentKey: "required", credential'ın authenticator'da kullanıcı bilgisiyle birlikte saklanmasını, yani discoverable olmasını istiyor.
Tarayıcı bu options'la authenticator'dan yeni bir credential ister ve cevabı sunucuya gönderir. Cevabın içindeki attestationObject CBOR (Concise Binary Object Representation) ile kodlanmış bir map; inspector onu System.Formats.Cbor ile açıyor:
== registration (attestation)
clientDataJSON: {"type":"webauthn.create","challenge":"tIH5wpkAq7J-cDdENtx03Fpa5mCU6BPRa9PhMboNtoI","origin":"http://localhost:19170","crossOrigin":false}
fmt: none
attStmt entries: 0
authenticatorData: 164 bytes, rpIdHash == SHA-256("localhost"): True, flags=0x45 [UP UV AT], signCount=1
aaguid: 01020304-0506-0708-0102-030405060708
credentialId: 32 bytes
COSE key: kty=2 alg=-7 crv=1 x=32 bytes y=32 bytes
Satır satır: clientDataJSON'daki challenge, options'taki challenge'ın aynısı ve origin uygulamanın origin'i. fmt: none, authenticator'ın kendi modelini kanıtlayan bir attestation statement göndermediği anlamına geliyor. AT (Attested credential data) bayrağı, authenticatorData'nın sonunda yeni credential'ın bilgisi olduğunu söylüyor: authenticator modelini tanımlayan AAGUID (Authenticator Attestation Globally Unique Identifier), credential id ve COSE formatında public key. Public key bir EC2 (Elliptic Curve) key'i (kty=2), P-256 eğrisi üzerinde (crv=1), 32'şer byte'lık x ve y koordinatlarıyla. Buradaki AAGUID Chrome'un sanal authenticator'ına ait; gerçek cihazlar kendi model kimliklerini döndürür.
Attestation, "bu key gerçekten şu modeldeki bir cihazda üretildi" kanıtıdır ve üretici sertifikalarıyla doğrulanır. Tüketici uygulamalarının çoğu buna ihtiyaç duymaz ve none ile yetinir; Identity de varsayılan olarak attestation statement'ı doğrulamıyor. Kurumsal bir ortamda yalnız belirli güvenlik anahtarlarına izin vermek istiyorsan attestation doğrulaması ve bir güven deposu (FIDO (Fast IDentity Online) Metadata Service gibi) gerekir.
Giriş: Assertion
Girişte sunucu yeni bir challenge üretir. allowCredentials boş bırakılırsa tarayıcı bu RP ID için kayıtlı discoverable credential'ları kullanıcıya sunar; kullanıcı adını önceden sormaya gerek kalmaz:
{"challenge":"QZ4MrCdIZWBlTJ8khN-jVnT57EnaGXG974iGxD3qwrQ","timeout":120000,"rpId":"localhost","allowCredentials":[],"userVerification":"required","hints":[]}
Authenticator'ın döndürdüğü assertion'ı çözünce:
== sign-in (assertion)
clientDataJSON: {"type":"webauthn.get","challenge":"1uDa5TXLAKJnRnv4-10pzL33lH_7N71sGPDBqAbaW6U","origin":"http://localhost:19170","crossOrigin":false}
authenticatorData: 37 bytes, rpIdHash == SHA-256("localhost"): True, flags=0x05 [UP UV], signCount=2
signature valid: True
signature valid with another origin: False
Bu kez authenticatorData yalnız 37 byte: RP ID hash'i, bayraklar ve sayaç; yeni credential olmadığı için AT yok. İmzanın neyi kapsadığını görmek için doğrulamayı elle yaptım:
// İmza authenticatorData || SHA-256(clientDataJSON) üzerindedir
using ECDsa publicKey = ECDsa.Create();
publicKey.ImportSubjectPublicKeyInfo(Decode(attResponse, "publicKey"), out _);
byte[] signature = Decode(asResponse, "signature");
Console.WriteLine($"signature valid: {Verify(assertionAuthData, clientData)}");
// Bir phishing sitesi clientDataJSON içinde farklı bir origin üretirdi
byte[] phished = Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(clientData).Replace("http://localhost:19170", "https://bookstore-login.example"));
Console.WriteLine($"signature valid with another origin: {Verify(assertionAuthData, phished)}");
bool Verify(byte[] authenticatorData, byte[] clientDataJson)
{
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
return publicKey.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
}
Origin'deki tek bir değişiklik imzayı geçersiz kılıyor. Sahte bir sayfa, tarayıcının kendi origin'ini clientDataJSON'a yazmasını engelleyemez; doğru origin'li bir imza üretmek için private key'e ihtiyacı var ve private key authenticator'dan çıkmıyor. signCount kayıtta 1, girişte 2: authenticator her kullanımda sayacı artırıyor. Sunucu daha küçük ya da eşit bir değer görürse credential'ın klonlanmış olabileceğinden şüphelenebilir.
Discoverable, Synced ve Device-Bound Passkey'ler
Bayraklardaki BE (Backup Eligible) ve BS (Backup State) bitleri passkey'in türünü söyler. BE kapalıysa credential cihaza bağlıdır (device-bound): bir USB güvenlik anahtarı ya da lab'daki sanal authenticator gibi. BE açıksa credential bir parola yöneticisi ya da platform hesabı üzerinden başka cihazlara senkronlanabilir; BS de şu an yedeklenmiş olup olmadığını gösterir. Synced passkey'lerde sayaç genellikle hep 0 döner, çünkü aynı key birden çok cihazda kullanılır ve ortak bir sayaç tutulamaz; sayaç kontrolünü bu durumda devre dışı bırakmak gerekir.
Bu ayrım uyum gereksinimlerinde önem kazanır. NIST (National Institute of Standards and Technology) SP (Special Publication) 800-63B-4, senkronlanabilir authenticator'ları AAL2'de (Authentication Assurance Level 2) kabul ediyor ama AAL3 (Authentication Assurance Level 3)'te yasaklıyor, çünkü private key'in dışa aktarılabilir olmasını gerektiriyorlar. Bookstore gibi bir tüketici uygulaması için synced passkey en iyi kullanıcı deneyimi ve yeterli güvence; bir bankanın yönetici paneli için device-bound güvenlik anahtarı istenebilir.
Hesap Kurtarma: Passkey Kaybolunca Ne Olacak?
Passkey'in gücü aynı zamanda riskidir: private key yalnız authenticator'da. Synced passkey'ler telefonun kaybolmasını kendiliğinden tolere eder; yeni telefona platform hesabıyla giriş yapınca passkey geri gelir. Device-bound bir passkey ise cihazla birlikte gider. Kurtarma tasarımı için birkaç kural:
Kullanıcıyı en az iki passkey kaydetmeye teşvik et (örneğin telefon ve laptop) ve IsBackedUp bilgisi false olan tek bir passkey'i varsa uyar. Kurtarma yolunun gücü, hesabın gücünü belirler: passkey'li bir hesabı e-postayla gelen bir link ile tamamen sıfırlanabilir yaparsan, saldırgan için en kolay yol passkey değil e-posta olur. Recovery code'lar (5. yazı) passkey-only hesaplar için iyi bir ara yol. Kurtarma işleminden sonra eski passkey'leri gözden geçirmeyi iste ve kullanıcıya bildirim gönder.
Üretimde Dikkat
RP ID'yi baştan doğru seç; sonradan değiştirmek bütün passkey'leri geçersiz kılar. login.bookstore.example yerine bookstore.example seçmek tüm alt alan adlarında kullanıma izin verir, ama aynı zamanda herhangi bir alt alan adını ele geçiren birinin de bu RP ID ile tören başlatabilmesi demek. Alt alan adlarında güvenilmeyen içerik barındırma.
WebAuthn güvenli bağlam (secure context) ister: HTTPS (HTTP Secure) ya da localhost. Lab'ın http://localhost üzerinde çalışmasının sebebi bu; production'da HTTPS zorunlu. Tarayıcı ve platform desteğini PublicKeyCredential.getClientCapabilities() ile çalışma anında sorgulayabilirsin; bunu yazmadan önce hedef tarayıcılarında doğrula.
Ölçek tarafında passkey doğrulaması bir imza doğrulaması ve bir veritabanı okuması; parola hash'inden (PBKDF2: Password-Based Key Derivation Function 2, 100.000 iterasyon) çok daha ucuz. Giriş sayfası yoğun bir sistemde CPU (Central Processing Unit) kullanımında fark edilir bir düşüş beklenebilir.
Sık Düşülen Tuzaklar
Birincisi, passkey'i parolanın yanına ikinci factor olarak eklemek ve kullanıcıya iki şey sormak. UV'li bir passkey zaten çok faktörlü; parola + passkey kullanıcıyı yorar ve parolanın phishing riskini geri getirir.
İkincisi, challenge'ı istemcide üretmek ya da istemciden gelen bir değerle doğrulamak. Challenge sunucuda üretilir, sunucuda saklanır ve bir kez kullanılır.
Üçüncüsü, user.id'ye e-posta ya da kullanıcı adı koymak. Bu değer authenticator'da saklanır ve kullanıcının kişisel verisini sızdırabilir; rastgele ya da opak bir id kullan.
Dördüncüsü, synced passkey'lerin 0 olan sayacını "klonlanmış" diye yorumlayıp kullanıcıyı kilitlemek. Beşincisi, tek bir device-bound passkey ile kurtarma yolu olmayan hesaplar oluşturmak.
Küçük Bir Deneme
★☆☆ Inspector'ı kendi yakaladığın verilerle çalıştır. authenticatorData'daki flag byte'ını elle çözüp UP, UV ve AT bitlerinin hangi değerlere karşılık geldiğini yaz.
★★☆ Inspector'a assertion'daki clientDataJSON'un challenge'ını değiştirip imzayı yeniden doğrulayan bir satır ekle. Sonucu origin değişikliğiyle karşılaştır ve sunucunun neden ayrıca challenge'ı da kontrol etmesi gerektiğini açıkla.
★★★ Chrome DevTools Protocol'deki sanal authenticator'ı hasResidentKey: false ile yeniden oluştur ve kayıt dene. residentKey: "required" isteyen options'la ne olduğunu, ardından preferred ile ne değiştiğini gözlemle ve "kullanıcı adı sormadan giriş" deneyimini neden kaybettiğini açıkla.
Protokolün parçalarını gördük. Bir sonraki yazıda bunları ASP.NET Core Identity 10'un passkey API (Application Programming Interface)'leriyle birleştiriyoruz: Angular istemciden kayıt ve passwordless giriş, conditional UI (User Interface), attestation state'in güvenliği ve Identity'nin sınırları.