Hash, HMAC ve Key Derivation: SHA-2, SHA-3, HMAC, HKDF ve Hangisi Ne Zaman
Bir mesajın anahtarı bilen tarafça üretildiğini kanıtlamak için SHA256(secret + mesaj) değil HMAC kullanılır, çünkü SHA-2 length extension'a açıktır; anahtarlar ise tek bir master key'den HKDF ile amaca özel türetilir. Hash'in üç garantisini, SHA-2 ile SHA-3 seçimini, Bookstore'un ödeme webhook'unu HMAC, timestamp penceresi, FixedTimeEquals ve secret rotasyonuyla doğrulamayı, HKDF'i, Argon2id seçeneklerini ve encoding tuzaklarını gerçek çıktılarla anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve xUnit v3 4.0 ile derlenip denenmiştir; çıktılar Ubuntu 24.04 (OpenSSL 3.0) üzerinde alınmıştır.
Önce Tahmin Et
Bookstore'un ödeme sağlayıcısı, bir ödeme tamamlandığında POST /webhooks/payments endpoint'ine bir event gönderiyor. Sağlayıcı panelinden bir secret aldık; sağlayıcı her isteğe bu secret ile hesaplanmış bir imza ekliyor. Ekipten biri doğrulamayı şöyle yazmış:
// İlk sürüm: üç ayrı hata var
string expected = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret + json)));
bool valid = expected == request.Headers["Payment-Signature"];
Testler geçiyor, sağlayıcının gönderdiği event'ler kabul ediliyor. Bu iki satırda kaç sorun görüyorsun? Birincisi yapı: secret ile mesajı yan yana koyup hash'lemek bir MAC (Message Authentication Code; mesajı anahtarı bilen birinin ürettiğini kanıtlayan kısa değer) değil. İkincisi karşılaştırma: hex string'leri == ile karşılaştırmak hem süre sızdırıyor hem de büyük/küçük harf farkında tutarsız davranıyor. Üçüncüsü girdi: json değişkeni büyük ihtimalle body'nin deserialize edilip yeniden üretilmiş hâli, yani sağlayıcının imzaladığı byte'lar değil. Bu yazıda bu üç sorunu sırayla çözüyoruz; ardından tek bir master key'den amaca özel anahtarlar türetmeye, yani HKDF'e (HMAC tabanlı anahtar türetme fonksiyonu) geçiyoruz.
Hash'in Garanti Ettiği Üç Şey
Bir kriptografik hash fonksiyonu, herhangi uzunluktaki girdiyi sabit uzunlukta bir özete çeviriyor ve üç özellik vaat ediyor. Preimage resistance: özetten girdiyi bulmak pratikte imkânsız. Second preimage resistance: elindeki bir girdiyle aynı özeti veren başka bir girdi bulmak pratikte imkânsız. Collision resistance: aynı özeti veren herhangi iki girdi bulmak pratikte imkânsız. Pratikteki karşılıkları şöyle: ilki bir parola hash'inin neden geri çevrilemediğini, ikincisi indirdiğin dosyanın yayımlanan checksum'la eşleşmesinin neden anlamlı olduğunu, üçüncüsü de imza algoritmalarının veriyi değil özetini imzalayabilmesinin neden güvenli olduğunu açıklıyor.
Bookstore'un gece çalışan katalog dışa aktarımını düşün: 50.000 satırlık bir CSV (virgülle ayrılmış değerler) dosyası, dağıtım ortaklarına bir dosya sunucusu üzerinden gidiyor. Dosyanın yanına SHA (Secure Hash Algorithm; standart hash algoritmalarının ortak adı) ailesinden SHA-256 özetini koyuyoruz. Büyük dosyayı belleğe almadan, stream üzerinden hash'liyoruz:
await using (FileStream stream = File.OpenRead(path))
{
byte[] digest = await SHA256.HashDataAsync(stream);
Console.WriteLine($"{stream.Length} bytes sha256={Convert.ToHexStringLower(digest)}");
}
// İlk satırdaki tek bir fiyatı değiştir: 101.00 -> 111.00
byte[] bytes = await File.ReadAllBytesAsync(path);
bytes[bytes.AsSpan().IndexOf("101.00"u8) + 1] = (byte)'1';
Console.WriteLine($"one byte changed sha256={Convert.ToHexStringLower(SHA256.HashData(bytes))}");
$ dotnet run --project src/Bookstore.CryptoLab -- catalog-checksum
1051394 bytes sha256=78c70e6eb00750f9fd515742d0bbb9f213352db6c64b6fa4291014d32613b1d0
one byte changed sha256=7ad2ec316a8e3c72c70ed5311afc1a57e67c18818bc9afd566aad7459e327703
Bir megabaytlık dosyada tek byte değişti ve özetin tamamı değişti. Windows'ta bu komutu çalıştırırsan dosya 1.101.394 byte olur ve özet de farklı çıkar: WriteAllLinesAsync satır sonu olarak Environment.NewLine kullanıyor, Windows'ta bu \r\n. Hash fonksiyonu metni değil byte'ları işler; bu ayrıntıya yazının sonunda tekrar döneceğiz.
Checksum'ın neyi korumadığını da net söylemek gerekiyor. Dosyayı ele geçiren biri, fiyatı değiştirip yeni özeti hesaplayabilir ve ikisini birlikte yerine koyabilir; hash'in anahtarı yok, herkes hesaplar. Checksum kazara bozulmayı yakalar, kasıtlı değişikliği yakalamaz. Kasıtlı değişikliğe karşı ya özeti dosyadan ayrı ve güvenilir bir kanaldan yayımlarsın ya da bir MAC veya imza kullanırsın.
SHA-2 mi, SHA-3 mü?
SHA-2 ailesi (SHA-256, SHA-384, SHA-512) bugün varsayılan seçim; her platformda var, donanım hızlandırması yaygın ve bilinen pratik bir zayıflığı yok. 2015'te standartlaşan SHA-3 ise tamamen farklı bir iç yapıya, sponge construction'a dayanıyor; SHA-2'de bir gün yapısal bir sorun bulunursa yedek olarak hazır duruyor. .NET 8 ile gelen SHA3_256, SHA3_384 ve SHA3_512 class'ları, serinin ilk yazısında gördüğümüz gibi platforma bağlı: Linux'ta OpenSSL 1.1.1 ve sonrasında, Windows'ta Windows 11 build 25324 ve sonrasında var, macOS'ta yok.
Pratik kural: bir protokol ya da karşı taraf SHA-3 istemiyorsa SHA-256 kullan. SHA-3 kullanman gerekiyorsa çağrıyı SHA3_256.IsSupported kontrolüne bağla ve desteklenmeyen platformda ne olacağına açılışta karar ver. Hangi algoritmayı kullandığını da veriyle birlikte sakla; ileride değiştirmek zorunda kalırsan eski kayıtları okuyabilmen buna bağlı. Bu fikre, crypto agility'ye, serinin post-quantum yazısında geniş yer vereceğiz.
Neden SHA256(secret + mesaj) Değil: Length Extension ve HMAC
SHA-256 ve SHA-512, girdiyi bloklar hâlinde işleyen ve her bloktan sonra bir iç durum tutan Merkle–Damgård yapısını kullanıyor. Çıktı, son bloktan sonraki iç durumun kendisi. Bunun sonucu şu: SHA256(secret + mesaj) değerini bilen biri, secret'ı hiç bilmeden, mesajın sonuna kendi seçtiği byte'ları eklenmiş bir mesajın geçerli özetini hesaplayabiliyor. Bu saldırı sınıfının adı length extension. Ayrıntısına girmiyoruz; bilmen gereken, "secret'ı başa koyup hash'lemek" yapısının SHA-2 ile bir MAC olarak güvenli olmadığı. SHA-3 ve kesilmiş varyantlar (SHA-384, SHA-512/256) bu özelliğe sahip değil, ama doğru çözüm farklı bir hash seçmek değil, bu iş için tasarlanmış yapıyı kullanmak.
O yapı HMAC (Hash-based Message Authentication Code; hash tabanlı mesaj doğrulama kodu). 1997'de RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 2104 ile tanımlanan HMAC, anahtarı iki farklı sabitle karıştırıp iki aşamalı bir hash hesaplıyor; iç hash'in çıktısı dış hash tarafından tekrar sarıldığı için length extension işe yaramıyor. .NET'te tek satır:
byte[] mac = HMACSHA256.HashData(secret, message);
Anahtar uzunluğu için kural basit: en az hash çıktısı kadar, yani HMAC-SHA256 için en az 32 rastgele byte. Daha kısa anahtar HMAC'i bozmaz ama güvenliği anahtarın uzunluğuna indirir. Parola gibi insan tarafından seçilmiş bir değeri doğrudan HMAC anahtarı yapma; aşağıda göreceğimiz gibi parolalar ayrı bir dünya.
Ödeme Webhook'unu HMAC ile Doğrulamak
Webhook endpoint'inin kendisi, yani body'yi ham hâliyle okumak, event id'si ile tekrar işlemeyi engellemek ve imzası doğru ama içeriği tutarsız event'i reddetmek, API Güvenliği serisinin webhook'ları ve dış servis çağrılarını anlatan yazısında anlatıldı; makineler arası imzalı istekler de aynı serinin machine-to-machine authentication yazısında. Burada kripto tarafına odaklanıyoruz: imzalanan byte'lar, MAC hesabı, karşılaştırma ve secret rotasyonu. Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı); kod Bookstore.Crypto library'sinin Webhooks klasöründe.
Sağlayıcıyla anlaşılan format şu: header'da bir Unix timestamp ve bir ya da daha fazla v1 imzası; imzalanan byte'lar, timestamp, bir nokta ve body'nin kendisi:
// Header biçimi: "t=<unix saniye>,v1=<hex HMAC-SHA256>[,v1=<hex>...]"
// İmzalanan byte'lar: "<t>." ve ardından request body, geldiği gibi
public static class PaymentWebhookSignature
{
public static string CreateHeader(ReadOnlySpan<byte> secret, long timestamp, ReadOnlySpan<byte> body)
{
byte[] mac = ComputeMac(secret, timestamp, body);
return $"t={timestamp},v1={Convert.ToHexStringLower(mac)}";
}
internal static byte[] ComputeMac(ReadOnlySpan<byte> secret, long timestamp, ReadOnlySpan<byte> body)
{
byte[] prefix = Encoding.ASCII.GetBytes(timestamp.ToString(CultureInfo.InvariantCulture) + ".");
byte[] signed = new byte[prefix.Length + body.Length];
prefix.CopyTo(signed, 0);
body.CopyTo(signed.AsSpan(prefix.Length));
return HMACSHA256.HashData(secret, signed);
}
}
Timestamp'i imzanın içine almak önemli: timestamp yalnız header'da dursaydı, eski bir isteği yeni bir timestamp'le tekrar göndermek mümkün olurdu. Doğrulama tarafı:
public sealed class PaymentWebhookVerifier(IReadOnlyList<byte[]> secrets, TimeProvider time, TimeSpan tolerance)
{
public WebhookVerdict Verify(string? header, ReadOnlySpan<byte> rawBody)
{
if (!TryParse(header, out long timestamp, out List<byte[]> candidates))
{
return WebhookVerdict.MalformedHeader;
}
DateTimeOffset sentAt = DateTimeOffset.FromUnixTimeSeconds(timestamp);
if ((time.GetUtcNow() - sentAt).Duration() > tolerance)
{
return WebhookVerdict.StaleTimestamp;
}
// Güncel secret ve rotasyon sırasında bir öncekisi
foreach (byte[] secret in secrets)
{
byte[] expected = PaymentWebhookSignature.ComputeMac(secret, timestamp, rawBody);
foreach (byte[] candidate in candidates)
{
if (Tags.AreEqual(expected, candidate))
{
return WebhookVerdict.Valid;
}
}
}
return WebhookVerdict.BadSignature;
}
}
Dört karar var. Header'daki hex değerleri byte'a çeviriyoruz ve yalnız tam 32 byte'lık olanları aday kabul ediyoruz; kısaltılmış bir imza parse aşamasında eleniyor. Zaman farkını Duration() ile mutlak değer olarak ölçüyoruz, yani gelecekten gelen timestamp de reddediliyor. Karşılaştırma serinin ilk yazısındaki Tags.AreEqual, yani FixedTimeEquals. Son olarak birden fazla secret kabul ediyoruz: sağlayıcı panelinde secret'ı yenilediğinde, sağlayıcının yeni secret'a geçtiği an ile senin deploy'un arasında bir pencere oluyor. Rotasyon süresince eski secret listede kalıyor, bitince çıkarılıyor.
TimeProvider'ı constructor'dan almak testte saati sabitlememizi sağlıyor. Lab komutu, sağlayıcıyı da taklit ederek beş durumu deniyor. Buradaki secret'lar dev-only-payment-webhook-secret-v2 gibi açıkça sahte, okunabilir değerler; böylece senin makinende de aynı imza çıkıyor:
$ dotnet run --project src/Bookstore.CryptoLab -- webhook
Payment-Signature: t=1794648600,v1=50f7edb849549930fcea2bd4d83b1d251cb61d3854f4cb5d375f03d7a60a8ce4
as sent : Valid
amount changed : BadSignature
10 minutes old : StaleTimestamp
previous secret : Valid
truncated v1 : MalformedHeader
Testlerden biri, Önce Tahmin Et'teki üçüncü sorunu doğrudan gösteriyor. Aynı veri, iki nokta üst üsteden sonra tek bir boşlukla yeniden üretildiğinde imza tutmuyor:
[Fact]
public void Re_serialized_json_is_not_the_signed_body()
{
// Aynı veri, farklı byte'lar: iki noktadan sonra bir boşluk
byte[] reformatted = Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(Body).Replace("\"amount\":", "\"amount\": "));
Assert.Equal(WebhookVerdict.BadSignature, Verifier(Current).Verify(Header(Current, Now), reformatted));
}
Bu yüzden endpoint'te body'yi önce byte olarak okuyup imzayı doğruluyor, ancak ondan sonra deserialize ediyoruz. Model binding'in ürettiği nesneyi tekrar serialize edip imzalamak, alan sırası, boşluk ya da sayı biçimi yüzünden ilk fırsatta kırılır. İmzalanacak byte'ları belirleme sorununa canonicalization deniyor ve beşinci yazıda kendi imzalı formatımızı tasarlarken tekrar karşımıza çıkacak.
$ dotnet test
Test run summary: Passed!
total: 18
failed: 0
succeeded: 18
skipped: 0
duration: 1s 315ms
HKDF ile Tek Master Key'den Amaca Özel Anahtarlar
Bookstore'un birden fazla anahtara ihtiyacı var: bir sonraki yazıda Customer.Phone alanını şifrelemek için bir anahtar, aynı alanda arama yapabilmek için ayrı bir blind index anahtarı, kurumsal müşterilere giden bildirim webhook'larını imzalamak için de her kurum için ayrı bir anahtar. Her biri için ayrı secret üretip saklamak mümkün, ama kurum sayısı arttıkça yönetmesi zorlaşıyor. Alternatif, tek bir güçlü master key'i güvenli yerde tutup diğer anahtarları ondan türetmek.
Bunun için tasarlanmış araç HKDF (HMAC-based Key Derivation Function; RFC 5869 ile tanımlanan, HMAC tabanlı anahtar türetme fonksiyonu). İki adımı var. Extract, girdideki anahtar materyalini (IKM: Input Keying Material; türetmenin başladığı ham anahtar) isteğe bağlı bir salt ile tekdüze rastgele bir ara anahtara çeviriyor. Expand, bu ara anahtardan info parametresine bağlı olarak istenen uzunlukta çıktı üretiyor. info burada asıl işi yapan parametre: aynı master key'den farklı info ile türetilen anahtarlar birbirinden bağımsız.
public static class KeyPurposes
{
public const string CustomerPhoneEncryption = "bookstore/customer-phone/encryption/v1";
public const string CustomerPhoneBlindIndex = "bookstore/customer-phone/blind-index/v1";
public static string InstitutionWebhook(Guid institutionId) =>
$"bookstore/institution-webhook/{institutionId:N}/v1";
}
// Tek bir master key'den amaca bağlı alt anahtarlar türetir (HKDF, RFC 5869)
public sealed class KeyDerivation
{
private readonly byte[] _masterKey;
public KeyDerivation(ReadOnlySpan<byte> masterKey)
{
if (masterKey.Length < 32)
{
throw new ArgumentException("The master key must be at least 256 bits.", nameof(masterKey));
}
_masterKey = masterKey.ToArray();
}
public byte[] DeriveKey(string purpose, int length = 32)
{
ArgumentException.ThrowIfNullOrWhiteSpace(purpose);
return HKDF.DeriveKey(HashAlgorithmName.SHA256, _masterKey, length, info: Encoding.UTF8.GetBytes(purpose));
}
}
$ dotnet run --project src/Bookstore.CryptoLab -- derive-keys
72d54ad3aac6ab20... bookstore/customer-phone/encryption/v1
72d54ad3aac6ab20... bookstore/customer-phone/encryption/v1
28b0ea64c3d3131e... bookstore/customer-phone/blind-index/v1
2be7072ef45f76c4... bookstore/institution-webhook/0197f00100007000800000000000a001/v1
1ea4910b4a274bc7... bookstore/institution-webhook/0197f00100007000800000000000a002/v1
Aynı amaç her zaman aynı anahtarı veriyor, bu yüzden türetilen anahtarı hiçbir yerde saklamak gerekmiyor; ihtiyaç anında yeniden hesaplanıyor. Farklı amaçlar ve farklı kurumlar birbiriyle ilişkisiz anahtarlar alıyor. Lab'daki master key dev-only-bookstore-master-key-32 metninin byte'ları; çıktıların herkeste aynı olması için seçilmiş, açıkça sahte bir değer. Production'da master key 32 rastgele byte'tır ve bir secret store'da durur; nasıl saklanacağı ve dağıtılacağı Configuration ve Secret Management serisinin konusu.
Salt'ı neden vermediğimizi açıklayalım. RFC 5869, salt'ın HKDF'i belirgin şekilde güçlendirdiğini, gizli olmak zorunda olmadığını ve yeniden kullanılabileceğini söylüyor; girdinin zaten güçlü bir anahtar olduğu durumda ise extract adımının atlanabileceğini de belirtiyor. Bizim master key'imiz zaten tekdüze rastgele 32 byte; salt'sız kullanım burada güvenli. Dördüncü yazıda ECDH (elliptic curve üzerinde iki tarafın ortak bir sır ürettiği key agreement) ile üretilen ortak sırrı anahtara çevirirken durum farklı olacak: o değer tekdüze rastgele değil, orada extract adımı ve salt şart.
info içindeki /v1 sonekini boşuna koymadık. Bir gün türetme şemasını değiştirmen gerekirse, örneğin uzunluğu ya da hash'i, v2 ile türetilen anahtarlar v1'dekilerle karışmaz ve eski verileri okumaya devam edebilirsin. API Güvenliği serisinin machine-to-machine authentication yazısında her API key için imza secret'ını HMACSHA256(master, "signing:" + keyId) ile türetmiştik; bu, HKDF'in expand adımının sadeleştirilmiş bir hâli. Yeni kodda doğrudan HKDF kullanmak, hem standart hem de uzunluk ve salt gibi ayrıntıları senin yerine doğru yönetiyor.
Parolalar Ayrı Bir Dünya: PBKDF2, Argon2id ve KMAC
HKDF hızlı bir fonksiyon ve bu, güçlü anahtarlar için doğru bir özellik. Parolalar için ise tam tersi gerekiyor: parolanın entropisi düşük olduğu için saldırgan olası parolaları tek tek denemeye çalışır ve her denemenin pahalı olmasını isteriz. Bu yüzden parola tabanlı key derivation fonksiyonları bilerek yavaş: PBKDF2 (Password-Based Key Derivation Function 2; parolayı binlerce kez HMAC'ten geçirerek anahtar türeten fonksiyon) iterasyon sayısıyla, Argon2id ise ek olarak bellek ihtiyacıyla maliyeti artırıyor. PBKDF2'nin .NET'teki kullanımı (Rfc2898DeriveBytes.Pbkdf2), iterasyon sayıları ve ASP.NET Core Identity'nin PasswordHasher'ı Web Güvenliği (OWASP Top 10) serisinin "OWASP A04: Cryptographic Failures (2)" yazısında anlatıldı.
Oradaki önemli not burada da geçerli: OWASP'ın parola saklama rehberinin ilk tercihi Argon2id, ama .NET'in kendi kripto library'sinde Argon2 yok. Üçüncü parti bir paket seçmek zorundasın ve seçimin kendisi bir güvenlik kararı. Bugün NuGet'teki başlıca seçenekler: tamamen managed yazılmış Konscious.Security.Cryptography.Argon2 (son sürümü 1.3.1, Haziran 2024), libsodium'u saran NSec.Cryptography ve Geralt (ikisi de 2026'da sürüm almış, serbest kullanıma izin veren MIT (Massachusetts Institute of Technology'nin adını taşıyan kısa açık kaynak lisansı) lisanslı). Managed paket kurulumu kolay ama performansı ve bakımı tek bir projenin elinde; libsodium sarmalayıcıları iyi incelenmiş bir native kütüphaneye dayanıyor ama her hedef platform için native binary taşıyor. Hangisini seçersen seç, parametreleri (bellek, iterasyon, paralellik) hash'le birlikte sakla ve paketi bağımlılık taramasına dahil et.
Son bir not, .NET 9 ile gelen KMAC (SHA-3 tabanlı MAC; ABD standartlar enstitüsünün 2016 tarihli bir yayınında tanımlandı): HMAC'in SHA-3 dünyasındaki karşılığı ve ek olarak bir customization string alıyor. Aynı anahtarla farklı bağlamlarda farklı MAC üretmek için bu parametreyi kullanabilirsin:
byte[] a = Kmac256.HashData(key, data, outputLength: 32, customizationString: "Bookstore.Catalog"u8);
byte[] b = Kmac256.HashData(key, data, outputLength: 32, customizationString: "Bookstore.Ordering"u8);
$ dotnet run --project src/Bookstore.CryptoLab -- kmac
Catalog : b399bfaeb6ce4c64d05cb643f9d7dfdd...
Ordering : e8ae5f9734dc9ae833e732482840cd1b...
KMAC platforma bağlı: Linux'ta OpenSSL 3.0 ve sonrası, Windows'ta Windows 11 build 26016 ve sonrası gerekiyor, macOS'ta yok. Komut bu yüzden önce Kmac256.IsSupported'a bakıyor. Karşı taraf özellikle KMAC istemiyorsa HMAC-SHA256 her yerde çalışan, daha güvenli varsayılan.
Üretimde Dikkat
Webhook doğrulamasında başarısız istekleri sebebiyle logla: BadSignature ile StaleTimestamp çok farklı şeyler söylüyor. Ani bir StaleTimestamp artışı genelde saldırı değil, sunucunun saatinin kaymasıdır; NTP (Network Time Protocol; sunucu saatini zaman sunucularıyla eşitleyen protokol) senkronizasyonunu izle. BadSignature artışı ise ya secret rotasyonunun yarım kaldığını ya da birinin endpoint'i denediğini gösterir. Log'a imzayı ve secret'ı değil, sonucu ve event id'sini yaz.
Secret rotasyonunu bir prosedür olarak yaz: yeni secret'ı listeye ekleyip deploy et, sağlayıcı panelinde yeni secret'a geç, birkaç gün sonra eskisini listeden çıkar. Doğrulayıcının birden fazla secret kabul etmesi bu prosedürü kesintisiz yapıyor; tek secret kabul eden bir doğrulayıcıda rotasyon her zaman kısa bir kesinti demek.
Master key'den türetilen anahtarlar master key kadar değerli: master key sızarsa türetilen her anahtar da sızmış sayılır. Bu yüzden master key'in rotasyonu, türetilen anahtarların rotasyonunu da kapsar. Bir sonraki yazıda şifreli veriye anahtar sürümünü yazarak bu rotasyonu yönetilebilir hâle getireceğiz.
Performans tarafında HMAC-SHA256 ve HKDF ucuz; saniyede binlerce çağrı sorun değil. Türetilen anahtarı her istekte yeniden hesaplamak yerine kısa süreli cache'lemek mümkün, ama cache'in kendisi de anahtar materyali tutuyor; bellekte gereğinden uzun kalmasın.
Sık Düşülen Tuzaklar
Birinci tuzak, hash'i şifreleme sanmak. "Telefon numarasını SHA-256 ile şifreledik" cümlesi iki kez yanlış: hash geri çevrilemez, yani şifreleme değil; ama telefon numarası gibi küçük bir değer kümesinde bütün olasılıkları hash'leyip karşılaştırmak birkaç dakika sürer, yani gizlilik de sağlamıyor. Gizlilik için encryption, arama için anahtarlı bir blind index gerekiyor; ikisi de bir sonraki yazının konusu.
İkincisi, tek bir anahtarı birden fazla amaçla kullanmak. Aynı secret'la hem webhook imzalayıp hem alan şifrelemek, bir kullanımdaki zayıflığı diğerine taşır ve rotasyonu imkânsızlaştırır. Her amaç için HKDF ile ayrı anahtar türet.
Üçüncüsü, string'i byte'a çevirirken encoding'i düşünmemek. Hash ve MAC byte üzerinde çalışır; aynı görünen iki metin farklı byte'lara dönüşebilir:
string composed = "Café"; // é tek bir code point (NFC)
string decomposed = "Café"; // e + birleşen aksan (NFD)
Console.WriteLine($"equal as string (ordinal): {string.Equals(composed, decomposed, StringComparison.Ordinal)}");
Console.WriteLine($"UTF-8 NFC : {Hash(Encoding.UTF8.GetBytes(composed))}");
Console.WriteLine($"UTF-8 NFD : {Hash(Encoding.UTF8.GetBytes(decomposed))}");
Console.WriteLine($"UTF-16 NFC : {Hash(Encoding.Unicode.GetBytes(composed))}");
Console.WriteLine($"UTF-8 NFD normalized to NFC: {Hash(Encoding.UTF8.GetBytes(decomposed.Normalize(NormalizationForm.FormC)))}");
$ dotnet run --project src/Bookstore.CryptoLab -- encoding
equal as string (ordinal): False
UTF-8 NFC : 73473dcc12b76308
UTF-8 NFD : c42cc7a1ca08364b
UTF-16 NFC : e3cb7a9fbd017ef0
UTF-8 NFD normalized to NFC: 73473dcc12b76308
Ekranda ikisi de "Café". macOS'tan gelen dosya adları ya da bazı klavyelerden gelen girdiler NFD (Normalization Form D; harf ve aksanın ayrı karakter olarak tutulduğu biçim) olabiliyor. Kuralın iki parçası var: encoding'i her zaman açıkça Encoding.UTF8 olarak ver, Encoding.Default ya da Encoding.Unicode'a güvenme; insan girdisini hash'lemeden önce de Normalize(NormalizationForm.FormC) ile tek bir biçime getir. Webhook'ta ise tersi geçerli: orada metne hiç dokunmuyor, gelen byte'ları olduğu gibi kullanıyoruz.
Dördüncüsü, MAC'i hex string olarak karşılaştırmak ya da gelen imzanın uzunluğuna göre kesmek; ikisini de ilk yazıda gördük. Gelen değeri byte'a çevir, uzunluğunu kontrol et, FixedTimeEquals ile karşılaştır.
Küçük Bir Deneme
★☆☆ PaymentWebhookVerifier'ın toleransını 5 dakikadan 30 saniyeye indir ve testleri çalıştır. Hangi test kırıldı? Gerçek hayatta bu kadar dar bir pencerenin hangi durumda meşru istekleri reddedeceğini düşün.
★★☆ Kurumsal müşterilere giden bildirim webhook'ları için bir InstitutionWebhookSigner yaz: anahtarı KeyPurposes.InstitutionWebhook(institutionId) ile türetsin, header'ı PaymentWebhookSignature ile aynı formatta üretsin. İki farklı kurum için üretilen imzaların birbirinin doğrulayıcısında reddedildiğini gösteren bir test ekle.
★★★ KeyPurposes'a v2 şeması ekle: aynı amaç için SHA-512 ile 64 byte türeten bir sürüm. Bir anahtarın hangi şemayla türetildiğini bilmeyen kodun v1 ile v2'yi karıştırmasını engelleyecek bir tip tasarla (ör. DerivedKey(string Purpose, int Version, byte[] Material)).
Bir sonraki yazıda HKDF ile türettiğimiz bookstore/customer-phone/encryption/v1 anahtarını kullanıyoruz: AES (Advanced Encryption Standard; bugünün standart blok şifresi) tabanlı AES-GCM ve ChaCha20-Poly1305 ile authenticated encryption, nonce yönetimi, şifreli değeri müşteri kaydına associated data ile bağlamak, envelope encryption ve Customer.Phone alanını Entity Framework Core (EF Core; .NET'in veritabanı erişim kütüphanesi) value converter ile şifrelemek.