Mert Özen Her Satırda Daha İleri
Güvenlik Web Güvenliği (OWASP Top 10) — #08

OWASP A04: Cryptographic Failures (2) — Parola Saklama, Data Protection ve Secret Yönetimi

Mert Özen 6 Kas 2026 9 dk 3 görüntülenme
OWASP A04: Cryptographic Failures (2) — Parola Saklama, Data Protection ve Secret Yönetimi

ASP.NET Core Identity'nin PasswordHasher'ı bir parolayı tam olarak nasıl saklıyor? Hash'in başlığını çözüp PBKDF2-HMAC-SHA512 ve 100.000 iterasyonu görüyor, OWASP'ın 220.000 önerisine SuccessRehashNeeded ile geçiyor, Data Protection API ile süreli ve imzalı paylaşım link'i üretiyor, secret'ları user-secrets ve ortam değişkenleriyle koddan ayırıyoruz.

Sürüm notu: Bu yazı OWASP Top 10:2025'e göre yazılmıştır; kodlar ASP.NET Core 10 (.NET 10) ile denenmiştir.

Önce Tahmin Et

ASP.NET Core Identity kullanan bir projenin AspNetUsers tablosuna baktığında PasswordHash sütunundaki değerlerin neredeyse hepsinin AQAAAAIAAYagAAAAE ile başladığını görürsün. Bu ortak başlangıç ne anlatıyor? Aynı parolayı iki kez hash'lersen aynı değer mi çıkar? Peki Identity'nin varsayılan ayarı bugünkü OWASP önerisini karşılıyor mu?

Önceki yazıda MD5'in parola için neden yanlış olduğunu görmüştük. Bu yazıda A04:2025 Cryptographic Failures'ın ikinci yarısındayız: parolayı doğru saklamak, uygulama içinde veri korumak için Data Protection API ve secret'ların kodun dışında durması. Örnekleri yalnız kendi makinende çalıştır; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Parolayı Saklamanın Doğru Yolu

Parolayı şifrelemezsin, hash'lersin; ama her hash değil. Parola hash'inin üç özelliği olmalı. Her kullanıcı için rastgele bir salt, böylece aynı parola aynı değeri üretmez ve hazır tablolar işe yaramaz. Bilerek yavaş bir algoritma, böylece her tahmin saldırgana pahalıya patlar. Ve ayarlanabilir bir maliyet, böylece donanım hızlandıkça maliyeti artırabilirsin.

OWASP Password Storage Cheat Sheet'in bugünkü sıralaması şöyle: ilk tercih Argon2id (en az 19 MiB bellek, 2 iterasyon, 1 paralellik), sonra scrypt (N=2^17, r=8, p=1), eski sistemler için bcrypt (work factor en az 10; girdi 72 byte ile sınırlı), FIPS uyumluluğu gerekiyorsa PBKDF2. PBKDF2 için önerilen iterasyon sayıları HMAC-SHA-256 ile 600.000, HMAC-SHA-512 ile 220.000.

Identity'nin PasswordHasher'ını Açalım

Identity'nin hasher'ını kullanmak için bütün Identity'yi kurmana gerek yok; Microsoft.Extensions.Identity.Core paketi yeterli. Küçük bir console uygulaması:

dotnet new console -o PasswordLab -f net10.0
cd PasswordLab
dotnet add package Microsoft.Extensions.Identity.Core
using System.Buffers.Binary;
using System.Diagnostics;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;

AppUser ada = new AppUser("ada");
string password = "correct horse battery staple";

PasswordHasher<AppUser> defaultHasher = new PasswordHasher<AppUser>();
string hash1 = defaultHasher.HashPassword(ada, password);
string hash2 = defaultHasher.HashPassword(ada, password);
Console.WriteLine(hash1);
Console.WriteLine(hash2);
PrintHeader(hash1);

PasswordHasher<AppUser> strongHasher = new PasswordHasher<AppUser>(
    Options.Create(new PasswordHasherOptions { IterationCount = 220_000 }));

Console.WriteLine(strongHasher.VerifyHashedPassword(ada, hash1, password));
Console.WriteLine(strongHasher.VerifyHashedPassword(ada, hash1, "wrong password"));

string hash3 = strongHasher.HashPassword(ada, password);
PrintHeader(hash3);
Console.WriteLine(strongHasher.VerifyHashedPassword(ada, hash3, password));

Measure("100k", defaultHasher, ada, password);
Measure("220k", strongHasher, ada, password);

static void PrintHeader(string hash)
{
    byte[] bytes = Convert.FromBase64String(hash);
    uint prf = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(1, 4));
    uint iterations = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(5, 4));
    uint saltBytes = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(9, 4));
    // prf: 0 = HMACSHA1, 1 = HMACSHA256, 2 = HMACSHA512
    Console.WriteLine($"format=0x{bytes[0]:X2} prf={prf} iterations={iterations} salt={saltBytes * 8} bit total={bytes.Length} bytes");
}

static void Measure(string label, PasswordHasher<AppUser> hasher, AppUser user, string password)
{
    string hash = hasher.HashPassword(user, password);
    Stopwatch watch = Stopwatch.StartNew();
    hasher.VerifyHashedPassword(user, hash, password);
    Console.WriteLine($"{label}: {watch.ElapsedMilliseconds} ms per verify");
}

record AppUser(string UserName);
AQAAAAIAAYagAAAAENxBa5Edz1gOe+czVLc5xnj3cw8VGOpreqnVGXaBxD0VtL8InpE1mqBugHNLeB4tCw==
AQAAAAIAAYagAAAAECFZdMynZ+Zrnb+Ca9aDGtmelcMC74H7Ay4IxOTlbxDKppgOQ8Ugi3LAncURZ1lpsg==
format=0x01 prf=2 iterations=100000 salt=128 bit total=61 bytes
SuccessRehashNeeded
Failed
format=0x01 prf=2 iterations=220000 salt=128 bit total=61 bytes
Success
100k: 28 ms per verify
220k: 61 ms per verify

Aynı parola iki kez hash'lendi ve iki farklı değer çıktı; salt her seferinde yeni üretiliyor. Ortak başlangıcın sırrı da çıktının üçüncü satırında. Base64'ü çözülen değer bir başlık taşıyor: ilk byte 0x01 format sürümü (IdentityV3), sonraki dört byte PRF (2, yani HMAC-SHA512), sonraki dört byte iterasyon sayısı (100.000), sonraki dört byte salt uzunluğu (16 byte, 128 bit). Ardından salt ve 32 byte'lık türetilmiş anahtar geliyor; toplam 61 byte. Algoritma ve maliyet hash'in içinde yazdığı için ayarı değiştirdiğinde eski hash'ler doğrulanmaya devam ediyor.

Bu varsayılan .NET 7'den .NET 10'a kadar aynı: PBKDF2-HMAC-SHA512, 100.000 iterasyon. .NET 6 ve öncesinde PBKDF2-HMAC-SHA256 ve 10.000 iterasyondu. Açılıştaki son sorunun cevabı da burada: OWASP'ın HMAC-SHA-512 için bugünkü önerisi 220.000, Identity'nin varsayılanı bunun altında kalıyor.

OWASP Önerisine Yükseltmek

Yükseltme tek satır. Gerçek bir uygulamada options'ı DI üzerinden verirsin:

builder.Services.Configure<PasswordHasherOptions>(options =>
{
    options.IterationCount = 220_000;
});

Web projesinde Microsoft.Extensions.Identity.Core paketini eklemene gerek yok, ASP.NET Core shared framework'ünde zaten var; eklersen restore NU1510 uyarısıyla "bu paket otomatik olarak mevcut" diyor.

Çıktıdaki en değerli satır SuccessRehashNeeded. 220.000'e ayarlı hasher, 100.000 ile üretilmiş eski hash'i doğruladı ama "parola doğru, yalnız bu hash'i yenile" dedi. Identity'nin giriş akışı bu sonucu görünce hash'i yeni ayarla yeniden üretip kaydeder. Yani kimseden parola değiştirmesini istemeden, kullanıcılar giriş yaptıkça tablo kendiliğinden güncelleniyor. Yanlış parola ise beklendiği gibi Failed döndü.

Maliyetin bedelini de ölç. Benim makinemde (Apple M5) bir doğrulama 100.000 iterasyonda 28 ms, 220.000'de 61 ms sürdü. Bu bedeli her girişte bir kez ödersin; tahmin yapan saldırgan ise her denemede öder. Yine de giriş endpoint'inin CPU'yu tüketebileceğini unutma; rate limiting ile birlikte düşün.

Argon2id kullanmak istersen .NET'in kendi kriptografi library'sinde Argon2 yok; üçüncü parti bir paket gerekir ve IPasswordHasher<TUser> interface'ini kendin uygularsın. PBKDF2'yi doğru iterasyonla kullanmak çoğu proje için makul bir denge.

Data Protection API

Bookstore'da bir siparişi e-posta ile paylaşmak için link üretmek istiyoruz. Link'teki sipariş numarası değiştirilememeli ve link 15 dakika sonra geçersiz olmalı. Kendi şifreleme kodunu yazmak yerine ASP.NET Core'un zaten kullandığı Data Protection API'yi kullanıyoruz; authentication cookie'leri ve antiforgery token'ları da bu API ile korunur.

using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDataProtection()
    .SetApplicationName("Bookstore")
    .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "keys")));

var app = builder.Build();

app.MapPost("/orders/{id:int}/share-link", (int id, IDataProtectionProvider provider) =>
{
    ITimeLimitedDataProtector protector = provider
        .CreateProtector("Bookstore.OrderShare.v1")
        .ToTimeLimitedDataProtector();

    string token = protector.Protect(id.ToString(), TimeSpan.FromMinutes(15));
    return Results.Ok(new { url = $"/shared/orders/{token}" });
});

app.MapGet("/shared/orders/{token}", (string token, IDataProtectionProvider provider) =>
{
    ITimeLimitedDataProtector protector = provider
        .CreateProtector("Bookstore.OrderShare.v1")
        .ToTimeLimitedDataProtector();

    try
    {
        string orderId = protector.Unprotect(token, out DateTimeOffset expiresAt);
        return Results.Ok(new { orderId = int.Parse(orderId), expiresAt });
    }
    catch (CryptographicException)
    {
        return Results.Problem(statusCode: 400, title: "Invalid or expired link");
    }
});

app.MapGet("/payment/status", (IConfiguration configuration) =>
{
    IConfigurationRoot root = (IConfigurationRoot)configuration;
    string? source = root.Providers
        .Reverse()
        .FirstOrDefault(p => p.TryGet("Payment:ApiKey", out _))
        ?.ToString();

    return Results.Ok(new { configured = source is not null, source });
});

app.Run();
curl -i -X POST http://localhost:5126/orders/42/share-link
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:07:34 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"url":"/shared/orders/CfDJ8JGrubDBKElBugN-RlTw8ZmZD0D64N-eKGUvs_hsUOj2RM9Bn4PHNuiSD9GVwr5uQeCJW-F0TDVbKyCOcB_FB2ZEPPXDO2sS5Xsdb-e7Wv9B6ex9TxiwSoJNijbWTnZqcA"}
curl -i http://localhost:5126/shared/orders/CfDJ8JGrubDBKElBugN-RlTw8ZmZD0D64N-eKGUvs_hsUOj2RM9Bn4PHNuiSD9GVwr5uQeCJW-F0TDVbKyCOcB_FB2ZEPPXDO2sS5Xsdb-e7Wv9B6ex9TxiwSoJNijbWTnZqcA
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:07:41 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"orderId":42,"expiresAt":"2026-11-06T19:22:35.418763+00:00"}

Token'ın sondan ikinci karakterini değiştirip aynı isteği tekrar attım:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:07:41 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid or expired link","status":400}

Token hem şifreli hem imzalı. Tek karakterlik değişiklik CryptographicException üretti; süresi geçmiş bir token da aynı yoldan reddedilir. CreateProtector'a verdiğin purpose string'i ayrıca bir izolasyon sağlar: "Bookstore.OrderShare.v1" ile korunan veri başka bir purpose ile açılamaz. Sona eklediğim v1, ileride formatı değiştirirsem eskisiyle karışmasın diye.

Uygulama ilk açıldığında log'da şu satır vardı:

warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[35]
      No XML encryptor configured. Key {b0b9ab91-28c1-4149-ba03-7e4654f0f199} may be persisted to storage in unencrypted form.

keys klasöründeki XML dosyası da aynısını söylüyor (anahtar değerini kırptım):

<key id="b0b9ab91-28c1-4149-ba03-7e4654f0f199" version="1">
  <creationDate>2026-11-06T19:07:32.474999Z</creationDate>
  <activationDate>2026-11-06T19:07:32.474999Z</activationDate>
  <expirationDate>2027-02-04T19:07:32.474999Z</expirationDate>
  <descriptor deserializerType="...">
    <descriptor>
      <encryption algorithm="AES_256_CBC" />
      <validation algorithm="HMACSHA256" />
      <masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
        <!-- Warning: the key below is in an unencrypted form. -->
        <value>...</value>
      </masterKey>
    </descriptor>
  </descriptor>
</key>

Buradan üç şey öğreniyoruz. Anahtar 90 gün geçerli ve AES-256-CBC ile HMAC-SHA256 kullanıyor; rotasyonu framework kendi yapıyor. macOS'ta anahtar dosyası varsayılan olarak şifresiz yazıldı; Production'da ProtectKeysWithCertificate gibi bir yöntemle anahtarları da korumalısın. Ve uygulaman birden fazla sunucuda çalışıyorsa hepsi aynı anahtar deposunu ve aynı SetApplicationName değerini paylaşmalı; yoksa bir sunucunun ürettiği cookie diğerinde açılmaz.

Secret'lar Nerede Durmalı?

Bookstore'un ödeme sağlayıcısına bağlanmak için bir API anahtarı var. Bu anahtar appsettings.json'a, koda ya da Git'e girmemeli. Git & GitHub serisinde gördüğümüz gibi commit'lenmiş bir secret'ı sonradan silmek yetmez, geçmişte durmaya devam eder; tek gerçek çözüm anahtarı iptal edip yenisini üretmektir. Yukarıdaki /payment/status endpoint'i değeri göstermeden yalnız hangi configuration provider'dan geldiğini söylüyor. Önce hiçbir şey ayarlamadan:

{"configured":false,"source":null}

Geliştirme makinesinde user-secrets kullanıyoruz:

dotnet user-secrets init
dotnet user-secrets set "Payment:ApiKey" "dev-only-pay-key-0000"
Set UserSecretsId to '35813713-c735-45f8-a2df-fc24db1de9c3' for MSBuild project '.../Bookstore.Api/Bookstore.Api.csproj'.
Successfully saved Payment:ApiKey to the secret store.
{"configured":true,"source":"JsonConfigurationProvider for 'secrets.json' (Optional)"}

Değer proje klasöründe değil, kullanıcı profilinde (macOS ve Linux'ta ~/.microsoft/usersecrets/<id>/secrets.json) duruyor, yani yanlışlıkla commit'lenemez. Ama şifreli değil ve yalnız Development ortamında yükleniyor. Aynı uygulamayı Production ortamında başlattığımda cevap yine {"configured":false,"source":null} oldu; bu bilinçli bir tasarım.

Sunucuda en basit yol ortam değişkeni. İç içe anahtarlarda : yerine çift alt çizgi kullanılır:

export Payment__ApiKey=dev-only-from-env
{"configured":true,"source":"EnvironmentVariablesConfigurationProvider"}

Ortam değişkeni user-secrets'ı ezdi, çünkü varsayılan sırada daha sonra ekleniyor. Bir adım ötesi bir vault: Azure Key Vault, AWS Secrets Manager ya da HashiCorp Vault gibi bir hizmet anahtarları merkezi tutar, erişimi kayıt altına alır ve rotasyonu kolaylaştırır. Uygulama vault'a kendi kimliğiyle (managed identity gibi) bağlanırsa, ortada saklanması gereken ilk secret da kalmaz.

Sık Düşülen Tuzaklar

Birincisi, eski bir Identity kurulumunda iterasyon ayarına hiç bakmamak. .NET 6'dan yükseltilmiş bir projede eski hash'ler 10.000 iterasyonla durabilir; rehash akışı çalışsa bile giriş yapmayan kullanıcıların hash'i eski kalır.

İkincisi, Data Protection anahtarlarını kalıcı bir yere yazmamak. Container'da anahtarlar container ile birlikte kaybolursa her deploy'da kullanıcıların oturumu düşer ve ürettiğin bütün link'ler geçersiz olur.

Üçüncüsü, Data Protection'ı uzun süreli veri saklamak için kullanmak. Anahtarlar rotasyona girer; yıllarca açılması gereken bir veriyi bu API ile korursan anahtar yönetimini çok dikkatli yapman gerekir.

Dördüncüsü, secret'ı loglamak ya da hata mesajında göstermek. Yukarıdaki endpoint bilerek değeri değil, kaynağını döndürüyor. appsettings.Development.json'a "nasılsa geliştirme" diye gerçek anahtar yazmak da aynı sızıntının başka bir yolu.

Kontrol Listesi

Parolaları yalnız salt'lı ve yavaş bir algoritmayla sakla; Identity kullanıyorsan PasswordHasherOptions.IterationCount'u en az 220.000'e çıkar ve SuccessRehashNeeded akışının çalıştığını doğrula. Kendi parola hash'ini yazma.

Uygulama içi koruma için Data Protection API kullan, her kullanım için ayrı bir purpose ver, anahtarları kalıcı ve paylaşılan bir yerde tut ve Production'da onları da şifrele. Secret'ları geliştirmede user-secrets'ta, sunucuda ortam değişkeninde ya da tercihen bir vault'ta tut; repo'da secret tarayan bir kontrol çalıştır ve sızan anahtarı hemen iptal et.

Küçük Bir Deneme

★☆☆ PasswordLab'da IterationCount'u 600.000 yapıp doğrulama süresini ölç. Bulduğun süreyi giriş sayfan için kabul edilebilir bulur muydun?

★★☆ Paylaşım link'ini TimeSpan.FromSeconds(10) ile üret, 10 saniye bekle ve aynı link'in 400 döndüğünü gör. Sonra "Bookstore.OrderShare.v2" purpose'ıyla açmayı dene.

★★★ Lab'ı durdur, keys klasörünü başka bir yere taşı, uygulamayı yeniden başlat ve eski link'i dene. Ardından klasörü geri koyup tekrar dene; iki sunucunun aynı anahtar deposunu paylaşmasının neden gerektiğini kendi gözünle gör.

Bir sonraki yazıda A05:2025 Injection'a geçiyoruz: aynı arama endpoint'ini ADO.NET, Dapper ve EF Core ile yazıp string birleştirmenin ve parametrenin farkını çalışan kodla göreceğiz, ardından komut satırına giden girdiyi ele alacağız.