Mert Özen Her Satırda Daha İleri
Güvenlik Geliştirici için Kriptografi — #03

Symmetric Encryption ve AEAD: AES-GCM, ChaCha20-Poly1305, Nonce Yönetimi ve Envelope Encryption

Mert Özen 14 Kas 2026 16 dk 1 görüntülenme
Symmetric Encryption ve AEAD: AES-GCM, ChaCha20-Poly1305, Nonce Yönetimi ve Envelope Encryption

Bir alanı bugün doğru şifrelemek için AES-GCM gibi bir AEAD modu, her şifrelemede yeni bir nonce ve şifreli değeri bağlamına bağlayan associated data gerekir; anahtar rotasyonu için de versiyonlu bir format ve envelope encryption. ECB ve CBC'nin sorunlarını, nonce sınırını, AES-KWP ile DEK sarmayı, Bookstore'un Customer.Phone alanını EF Core value converter ve blind index ile şifrelemeyi ve Always Encrypted karşılaştırmasını gerçek çıktılarla anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Entity Framework Core 10.0.15 (SQLite provider) 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 müşteri tablosunda telefon numaraları düz metin duruyor ve KVKK (Kişisel Verilerin Korunması Kanunu) denetimi öncesinde "bu alanı şifreleyelim" kararı çıktı. İlk taslak şöyle: Aes.Create(), anahtar ve IV (Initialization Vector; şifrelemenin başlangıç değeri) configuration'dan geliyor, mod varsayılan bırakılmış, çıktı Base64 olarak kolona yazılıyor. Kod çalışıyor, veritabanında artık okunabilir numara yok.

Şimdi iki soru: aynı telefon numarasına sahip iki müşterinin kolondaki değerleri ne olur? Veritabanına yazma yetkisi olan biri, Ada'nın şifreli telefonunu Alan'ın satırına kopyalarsa uygulama bunu fark eder mi? İlk sorunun cevabı "aynı değer", çünkü sabit IV ile aynı girdi her zaman aynı çıktıyı verir; şifreli kolon hangi müşterilerin aynı numarayı paylaştığını söyler. İkincisinin cevabı "hayır", çünkü bu mod yalnız gizlilik sağlar, bütünlük sağlamaz. Bu yazıda bu iki açığı kapatan yapıyı kuruyoruz: AES (Advanced Encryption Standard; bugünün standart blok şifresi) tabanlı AES-GCM ile authenticated encryption, her şifrelemede yeni nonce, şifreli değeri bağlamına bağlayan associated data, rotasyona hazır bir format, envelope encryption ve hepsini Customer.Phone alanına taşıyan bir Entity Framework Core (EF Core; .NET'in veritabanı erişim kütüphanesi) value converter.

Block Cipher Mode'larına Kısa Bir Bakış

AES tek başına yalnız 16 byte'lık bir bloğu şifreler. Daha uzun veriyi şifrelemek için blokların nasıl zincirleneceğini söyleyen bir mode of operation gerekir. Üç klasik mod ve sorunları:

ECB (Electronic Codebook; her bloğu bağımsız şifreleyen mod) her bloğu aynı anahtarla ayrı ayrı şifreler; aynı girdi bloğu her zaman aynı çıktı bloğunu verir. CBC (Cipher Block Chaining; her bloğu bir öncekinin çıktısıyla karıştıran mod) bunu rastgele bir IV ve zincirleme ile çözer, ama bütünlük sağlamaz: şifreli metindeki değişiklik fark edilmeden çözülmüş metne yansıyabilir ve padding hatalarına verilen farklı cevaplar yıllar içinde ciddi saldırılara kapı açtı. CTR (Counter; bir sayacı şifreleyip çıktıyı veriyle XOR (bit düzeyinde dışlayıcı veya) işleminden geçiren mod) bloğu stream cipher'a çevirir, hızlıdır, ama yine bütünlük yoktur. Dört aynı bloğu üç modla şifreleyelim:

// Dört aynı 16 byte'lık blok
byte[] plaintext = Encoding.ASCII.GetBytes(string.Concat(Enumerable.Repeat("ISBN 9780441013", 4).Select(s => s + "|")));
byte[] key = RandomNumberGenerator.GetBytes(32);
using Aes aes = Aes.Create();
aes.Key = key;

Print("ECB", aes.EncryptEcb(plaintext, PaddingMode.None));
Print("CBC", aes.EncryptCbc(plaintext, RandomNumberGenerator.GetBytes(16), PaddingMode.None));

using AesGcm gcm = new(key, tagSizeInBytes: 16);
byte[] ciphertext = new byte[plaintext.Length];
gcm.Encrypt(RandomNumberGenerator.GetBytes(12), plaintext, ciphertext, new byte[16]);
Print("GCM", ciphertext);
$ dotnet run --project src/Bookstore.CryptoLab -- modes
ECB: 9d5d97e8 9d5d97e8 9d5d97e8 9d5d97e8
CBC: 7abf9aaf 00454848 1248ca26 c09bd38d
GCM: c5438ced ad92ff5d b1496a62 e193971f

ECB'de dört blok aynı; bir tablodaki tekrar eden değerler, bir görseldeki şekiller şifreli hâlde de görünür. CBC'de ve GCM (Galois/Counter Mode; CTR şifrelemesine bir doğrulama tag'i ekleyen mod) çıktısında bloklar birbirinden farklı. Ama GCM'i ayıran şey bu çıktıda görünmüyor: GCM bir AEAD (Authenticated Encryption with Associated Data; gizlilik ve bütünlüğü birlikte sağlayan encryption) modu. Şifrelerken bir tag üretiyor, çözerken tag'i kontrol ediyor ve tek bir bit bile değiştiyse hiçbir şey döndürmeden exception fırlatıyor. Bugün yeni kodda doğru varsayılan budur; CBC ya da CTR'yi ancak eski bir formatla uyumluluk gerekiyorsa ve ayrı bir HMAC (anahtarlı hash ile üretilen doğrulama kodu; bir önceki yazının konusu) ile birlikte kullanırsın.

AEAD: AesGcm ve ChaCha20Poly1305

.NET'te iki AEAD class'ı var ve ikisinin de API'si aynı biçimde: nonce, plaintext, çıktı buffer'ı, tag buffer'ı ve isteğe bağlı associated data. AesGcm'in constructor'ı .NET 8'den beri tag boyutunu açıkça istiyor; eski constructor derleyicide uyarı veriyor:

warning SYSLIB0053: 'AesGcm.AesGcm(byte[])' is obsolete: 'AesGcm should indicate the required tag size for encryption and decryption. Use a constructor that accepts the tag size.'

Tag boyutu için 16 byte kullan. Microsoft Learn'deki platform tablosuna göre Windows ve Linux 12 ile 16 byte arası tag'i kabul ediyor, Apple platformları yalnız 16 byte; kısaltılmış tag'in hem güvenlik payı düşük hem de taşınabilirliği yok.

ChaCha20Poly1305 aynı işi AES yerine ChaCha20 stream cipher'ı ve Poly1305 MAC'iyle (mesajın değişmediğini kanıtlayan doğrulama kodu) yapıyor. Anahtar her zaman 32 byte, nonce 12 byte, tag 16 byte; ayarlanacak bir şey yok:

using ChaCha20Poly1305 chacha = new(key);
chacha.Encrypt(nonce, plaintext, ciphertext, tag, "Customer.Phone"u8);

byte[] roundTrip = new byte[ciphertext.Length];
chacha.Decrypt(nonce, ciphertext, tag, roundTrip, "Customer.Phone"u8);
$ dotnet run --project src/Bookstore.CryptoLab -- chacha
ChaCha20-Poly1305: 13 + 16 tag bytes, round trip: +905550100001

Hangisini seçmeli? Modern x64 sunucularda AES için donanım komutları olduğundan AES-GCM hızlı ve her platformda var. ChaCha20-Poly1305 ise AES donanım desteği olmayan cihazlarda daha hızlı ve yazılımda sabit zamanlı çalışması kolay; ama Windows'ta build 20142 öncesinde yok. Nonce kuralları ikisinde de aynı derecede katı. Bookstore sunucu tarafında AES-GCM kullanıyor; serinin ilk yazısındaki CryptoRequirements listesinde AES-GCM'in olmasının sebebi de bu.

Nonce: Neden Tek Kullanımlık ve Kaç Mesaj

GCM'in en kritik kuralı şu: aynı anahtarla aynı nonce asla iki kez kullanılmaz. Kullanılırsa iki sorun birden çıkıyor. GCM içeride CTR kullandığı için aynı nonce iki mesajda aynı anahtar akışını üretiyor; iki şifreli metni yan yana koyan biri, iki düz metnin XOR'unu elde ediyor. Daha kötüsü, aynı nonce ile üretilmiş iki tag'den doğrulama anahtarı hakkında bilgi çıkarılabiliyor ve bu da sahte tag üretmenin yolunu açıyor. Yani tek bir tekrar hem gizliliği hem bütünlüğü bozuyor.

Nonce'u nasıl üreteceğiz? İki yol var. Sayaç: her şifrelemede bir artırılan değer; tekrar etmez ama sayacın kalıcı ve tek bir yerde tutulması gerekir, birden çok instance'ta çalışan bir web uygulaması için zor. Rastgele: her şifrelemede RandomNumberGenerator'dan 12 byte. Durum tutmak gerekmez, ama 96 bit'lik uzayda rastgele seçimin de bir çakışma olasılığı var. NIST (ABD Ulusal Standartlar ve Teknoloji Enstitüsü), GCM'i tanımlayan 800-38D numaralı özel yayınında, rastgele nonce kullanıldığında bir anahtarla yapılan şifreleme sayısını 2^32 ile sınırlıyor. Bu yaklaşık 4,3 milyar şifreleme; Bookstore'un telefon alanı için uzak bir sınır, ama yüksek hacimli bir log ya da event şifrelemesinde ulaşılabilir. Çözüm, anahtarı sınıra yaklaşmadan döndürmek; formatımızdaki keyId tam olarak bunun için.

Associated Data ve Rotasyona Hazır Bir Format

Associated data, şifrelenmeyen ama tag'e dahil edilen ek byte'lar. Çözerken aynı byte'ları vermezsen tag tutmaz. Bununla şifreli değeri bağlamına bağlıyoruz: Ada'nın telefonu için müşteri id'sini associated data olarak verirsek, o değer Alan'ın satırına kopyalandığında çözülemez. Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı); şifreleme kodu Bookstore.Crypto library'sinin Fields klasöründe:

// Düzen: [sürüm][keyId][nonce: 12][ciphertext: n][tag: 16]
public sealed class FieldEncryptor(FieldKeyRingProvider keys)
{
    public const byte FormatVersion = 1;
    private const int HeaderSize = 2;
    private const int NonceSize = 12;
    private const int TagSize = 16;

    public FieldEncryptor(FieldKeyRing keyRing) : this(new FieldKeyRingProvider(keyRing))
    {
    }

    public byte[] Encrypt(ReadOnlySpan<byte> plaintext, ReadOnlySpan<byte> associatedData)
    {
        FieldKeyRing keyRing = keys.Current;
        byte keyId = keyRing.ActiveKeyId;
        byte[] payload = new byte[HeaderSize + NonceSize + plaintext.Length + TagSize];
        payload[0] = FormatVersion;
        payload[1] = keyId;

        Span<byte> nonce = payload.AsSpan(HeaderSize, NonceSize);
        Span<byte> ciphertext = payload.AsSpan(HeaderSize + NonceSize, plaintext.Length);
        Span<byte> tag = payload.AsSpan(HeaderSize + NonceSize + plaintext.Length, TagSize);
        RandomNumberGenerator.Fill(nonce);

        using AesGcm aes = new(keyRing.Get(keyId), TagSize);
        aes.Encrypt(nonce, plaintext, ciphertext, tag, BuildAad(payload.AsSpan(0, HeaderSize), associatedData));
        return payload;
    }

    public byte[] Decrypt(ReadOnlySpan<byte> payload, ReadOnlySpan<byte> associatedData)
    {
        if (payload.Length < HeaderSize + NonceSize + TagSize || payload[0] != FormatVersion)
        {
            throw new CryptographicException("Unknown field encryption format.");
        }

        int length = payload.Length - HeaderSize - NonceSize - TagSize;
        byte[] plaintext = new byte[length];
        using AesGcm aes = new(keys.Current.Get(payload[1]), TagSize);
        aes.Decrypt(
            payload.Slice(HeaderSize, NonceSize),
            payload.Slice(HeaderSize + NonceSize, length),
            payload.Slice(HeaderSize + NonceSize + length, TagSize),
            plaintext,
            BuildAad(payload[..HeaderSize], associatedData));
        return plaintext;
    }

    // Başlık da doğrulanıyor: sürümü ya da keyId'yi değiştirmek tag'i bozar
    private static byte[] BuildAad(ReadOnlySpan<byte> header, ReadOnlySpan<byte> associatedData) => [.. header, .. associatedData];
}

Formatın her byte'ının bir görevi var. Sürüm byte'ı, ileride algoritmayı ya da düzeni değiştirirsek eski kayıtları tanımamızı sağlıyor. keyId, kaydın hangi anahtarla şifrelendiğini söylüyor; rotasyondan sonra eski kayıtlar eski anahtarla çözülmeye devam ediyor. Nonce şifreli değerle birlikte saklanıyor, çünkü gizli değil, yalnız tek kullanımlık olması gerekiyor. Başlığı da associated data'ya kattığımız için biri keyId byte'ını değiştirip kaydı başka bir anahtarla çözdürmeye çalışırsa tag tutmuyor. Bunun bedeli küçük: 13 byte'lık bir telefon numarası 43 byte oluyor.

$ dotnet run --project src/Bookstore.CryptoLab -- aead
plaintext 13 bytes -> payload 43 bytes (2 header + 12 nonce + 13 ciphertext + 16 tag)
same input again, different payload: True
decrypted for Ada: +905550100001
moved to Alan's row: AuthenticationTagMismatchException
one ciphertext bit flipped: AuthenticationTagMismatchException
keyId byte changed: KeyNotFoundException: Field key 2 is not in the key ring.

Son satırda key ring'de 2 numaralı anahtar olmadığı için doğrulamaya hiç gelmeden durduk. İki anahtarın da bulunduğu durum testte: keyId'yi 1'den 2'ye çevirmek, nonce'u, şifreli metni ya da tag'i değiştirmek, hepsi AuthenticationTagMismatchException veriyor:

[Theory]
[InlineData(1)]   // başlıktaki keyId
[InlineData(5)]   // nonce
[InlineData(20)]  // ciphertext
[InlineData(42)]  // tag
public void Any_changed_byte_is_rejected(int index)
{
    byte[] payload = Encryptor(1).Encrypt(Phone, Ada);
    payload[index] ^= index == 1 ? (byte)0x03 : (byte)0x01;   // keyId 1 -> 2, var olan bir anahtar
    Assert.Throws<AuthenticationTagMismatchException>(() => Encryptor(1).Decrypt(payload, Ada));
}

Envelope Encryption: DEK, KEK ve AES Key Wrap

Şimdiye kadar anahtarı, bir önceki yazıdaki gibi master key'den HKDF (HMAC tabanlı anahtar türetme fonksiyonu) ile türettik. Bu çalışıyor ama bir sınırı var: master key'i döndürmek, onunla türetilen her anahtarla şifrelenmiş her kaydı yeniden şifrelemek demek. Milyonlarca satırlık bir tabloda bu saatler süren bir iş. Envelope encryption bu sorunu iki katmanla çözüyor. Veriyi DEK (Data Encryption Key; veriyi şifreleyen anahtar) şifreliyor. DEK'in kendisi ise KEK (Key Encryption Key; anahtarları şifreleyen anahtar) ile sarılıp veritabanında saklanıyor. KEK ise Key Vault ya da bir HSM (Hardware Security Module; anahtarı dışarı vermeden kripto işlemi yapan donanım) içinde duruyor ve oradan hiç çıkmıyor; uygulama açılışta sarılmış DEK'i gönderip açılmış hâlini alıyor.

03-envelope-tr.png

DEK'i sarmak için .NET 10 ile gelen AES-KWP (AES Key Wrap with Padding; anahtar sarma algoritması) kullanıyoruz; algoritma RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 5649 ile tanımlı. Neden AES-GCM değil? Key wrap, rastgele ve kısa bir anahtarı sarmak için tasarlanmış deterministik bir yapı; nonce gerektirmiyor, dolayısıyla nonce yönetimi hatasına da yer bırakmıyor. Çıktısı girdiden yalnız 8 byte uzun:

// Saklandığı hâliyle bir data key (DEK): bir KEK ile sarılmış
public sealed record WrappedDataKey(byte KeyId, string KekId, byte[] WrappedKey);

public static class EnvelopeKeys
{
    public static WrappedDataKey CreateDataKey(byte keyId, string kekId, ReadOnlySpan<byte> kek)
    {
        byte[] dek = RandomNumberGenerator.GetBytes(32);
        try
        {
            return new WrappedDataKey(keyId, kekId, Wrap(dek, kek));
        }
        finally
        {
            CryptographicOperations.ZeroMemory(dek);
        }
    }

    public static byte[] Unwrap(WrappedDataKey dataKey, ReadOnlySpan<byte> kek)
    {
        using Aes aes = Aes.Create();
        aes.SetKey(kek);
        return aes.DecryptKeyWrapPadded(dataKey.WrappedKey);
    }

    // KEK rotasyonu: veri olduğu gibi kalır, yalnız 40 byte'lık sarılmış anahtarlar değişir
    public static WrappedDataKey Rewrap(WrappedDataKey dataKey, ReadOnlySpan<byte> oldKek, string newKekId, ReadOnlySpan<byte> newKek)
    {
        byte[] dek = Unwrap(dataKey, oldKek);
        try
        {
            return dataKey with { KekId = newKekId, WrappedKey = Wrap(dek, newKek) };
        }
        finally
        {
            CryptographicOperations.ZeroMemory(dek);
        }
    }

    private static byte[] Wrap(ReadOnlySpan<byte> dek, ReadOnlySpan<byte> kek)
    {
        using Aes aes = Aes.Create();
        aes.SetKey(kek);
        return aes.EncryptKeyWrapPadded(dek);
    }
}
$ dotnet run --project src/Bookstore.CryptoLab -- envelope
DEK 2 wrapped by kek-2026: 40 bytes 81432ce3107662e6...
DEK 2 wrapped by kek-2027: 40 bytes 490024644793d6ff...
same DEK after KEK rotation: True
unwrap with the retired KEK: CryptographicException

Lab'da KEK'i Key Vault yerine geçecek şekilde master key'den türetiyoruz; production'da KEK'e hiç dokunmazsın, Key Vault ya da HSM'in wrap/unwrap işlemlerini çağırırsın ve KEK servisten çıkmaz. KEK'in nerede durduğu, kimin erişebildiği ve nasıl döndürüldüğü Configuration ve Secret Management serisinin konusu. Burada önemli olan iki rotasyonun farkı: KEK rotasyonu yalnız 40 byte'lık sarılmış anahtarları yeniden sarıyor, veri hiç değişmiyor. DEK rotasyonu ise yeni bir keyId'yi aktif yapıyor; eski kayıtlar eski DEK'le okunmaya devam ediyor ve arka planda yeni DEK'e taşınıyor.

Customer.Phone'u EF Core Value Converter ile Şifrelemek

Şimdi hepsini Bookstore'un müşteri modeline bağlıyoruz. Yeni Bookstore.Customers projesinde Customer entity'si ve BookstoreContext var; veritabanı bu yazıda SQLite. Phone bellekte düz metin, veritabanında şifreli; dönüşümü bir value converter yapıyor:

// Associated data = kolon adı: şifreli değeri satıra değil kolona bağlar
public sealed class EncryptedStringConverter(FieldEncryptor encryptor, string column)
    : ValueConverter<string, string>(
        plaintext => encryptor.EncryptString(plaintext, column),
        payload => encryptor.DecryptString(payload, column));
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(customer =>
    {
        customer.Property(c => c.Name).HasMaxLength(200);
        customer.Property(c => c.Phone)
            .HasConversion(new EncryptedStringConverter(encryptor, "Customer.Phone"));
        customer.Property(c => c.PhoneIndex).HasMaxLength(24);
        customer.HasIndex(c => c.PhoneIndex);
    });
}

Burada dürüst olmak gerekiyor: value converter yalnız değeri görüyor, entity'yi görmüyor. Yani müşteri id'sini associated data olarak veremiyoruz; verebildiğimiz en iyi bağlam kolon adı. Bu, şifreli değerin başka bir kolona taşınmasını engelliyor ama aynı kolonda başka bir satıra taşınmasını engellemiyor. Bunu gizlemek yerine bir testle belgeledik:

[Fact]
public async Task Converter_binds_to_the_column_not_the_row()
{
    (Customer ada, Customer alan) = await SeedAsync();
    await using BookstoreContext db = NewContext();

    // Veritabanına yazma yetkisi olan biri Alan'ın şifreli değerini Ada'nın satırına kopyalıyor
    await db.Database.ExecuteSqlAsync(
        $"UPDATE Customers SET Phone = (SELECT Phone FROM Customers WHERE Id = {alan.Id}) WHERE Id = {ada.Id}", Ct);

    Customer reloaded = await db.Customers.SingleAsync(c => c.Id == ada.Id, Ct);
    Assert.Equal("+905550100002", reloaded.Phone);   // hatasız çözülüyor: bilinen sınır
}

Bu kabul edilebilir mi? Telefon alanı için evet: bu saldırı veritabanına yazma yetkisi gerektiriyor ve o yetkiye sahip biri zaten çok daha fazlasını yapabilir. Satır bağlamanın gerçekten gerektiği alanlarda, örneğin bir ödeme talimatında, şifrelemeyi converter yerine domain kodunda, entity id'sini associated data olarak vererek yapmak gerekiyor; lab'daki aead komutu bunun nasıl davrandığını gösterdi. Şeffaflık ile bağlam arasında bilinçli bir seçim yapıyorsun.

Lab komutu iki müşteri ekliyor, kolonların veritabanındaki hâlini SQL ile okuyor, telefonla arama yapıyor, sonra DEK rotasyonu yapıp eski kayıtları yeniden şifreliyor:

$ dotnet run --project src/Bookstore.CryptoLab -- customers
Ada   Phone=AQHsFgs6FzT5BJffgpwL4L59... (60 chars) PhoneIndex=YFjLBVUw3HSlSwExbaQb+A==
Alan  Phone=AQFHf3OFJ4sWJoYZ7D5C8WVm... (60 chars) PhoneIndex=3uGlghWi8bXO3EiyJK2Bqg==
lookup by phone -> Ada +905550100001
after adding Grace: Ada=key 1, Alan=key 1, Grace=key 2
after re-encryption: Ada=key 2, Alan=key 2, Grace=key 2

Her Base64 değerin AQ ile başlaması tesadüf değil: ilk iki byte sürüm (1) ve keyId (1). Rotasyon için yeni bir encryptor oluşturmuyoruz, aynı encryptor'ın key ring'ini FieldKeyRingProvider.Replace ile değiştiriyoruz; sebebini Sık Düşülen Tuzaklar'da gerçek bir hatayla anlatacağım. Yeniden şifreleme için Phone property'sini modified olarak işaretleyip kaydetmek yetiyor; converter değeri aktif anahtarla yeniden yazıyor. Gerçek bir tabloda bunu tek seferde değil, küçük batch'ler hâlinde ve keyId'ye göre filtreleyerek yapan bir background job yazarsın.

Aranabilirlik: Blind Index ve Always Encrypted

Rastgele nonce'lu şifrelemenin bedeli aranabilirliği kaybetmek: aynı telefon her seferinde farklı şifreli değer veriyor, WHERE Phone = @phone artık çalışmıyor. Müşteri hizmetleri ise arayan müşteriyi telefonundan bulmak istiyor. Çözüm, şifreli kolonun yanına bir blind index koymak: normalize edilmiş değerin anahtarlı HMAC'i. Anahtarı bir önceki yazıdaki KeyPurposes.CustomerPhoneBlindIndex ile türetiyoruz, yani şifreleme anahtarından bağımsız:

// Şifreli bir alan için anahtarlı, deterministik arama değeri (yalnız eşitlik araması)
public sealed class BlindIndex(byte[] key, int lengthInBytes = 16)
{
    public string Compute(string normalizedValue)
    {
        byte[] mac = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(normalizedValue));
        return Convert.ToBase64String(mac, 0, lengthInBytes);
    }
}

Bu yapının üç sonucu var. Birincisi, normalizasyon kritik: lab'ın ilk sürümünde 0090 555 010 0001 biçimindeki aramayı +00905550100001'e çeviriyordum ve arama "Sequence contains no elements" ile boş döndü. Kaydederken ve ararken aynı normalizasyon fonksiyonu kullanılmalı. İkincisi, blind index yalnız eşitlik araması sağlıyor; "555 ile başlayanlar" gibi bir sorgu mümkün değil. Üçüncüsü, blind index bir bilgi sızdırıyor: aynı telefona sahip müşteriler aynı index değerine sahip. Anahtarsız bir hash'ten farkı, saldırganın bütün olası numaraları deneyerek değeri geri bulamaması; HMAC anahtarı olmadan bu mümkün değil. Index'i 16 byte'a kısaltmak da bilinçli: birkaç yanlış eşleşmeyi uygulama tarafında elemek, tam uzunlukta bir değeri saklamaktan daha az bilgi bırakıyor.

SQL Server kullanıyorsan aynı ihtiyacın bir de veritabanı tarafı çözümü var: Always Encrypted. Şifreleme ve çözme işini client driver (Microsoft.Data.SqlClient) yapıyor; kolonu şifreleyen CEK (Column Encryption Key; kolon şifreleme anahtarı) veritabanında, onu saran CMK (Column Master Key; CEK'leri şifreleyen ana anahtar) ise Key Vault'ta ya da sertifika deposunda duruyor. Bu, bizim envelope yapımızın aynısı. Deterministic modda eşitlik araması mümkün ama aynı değerler aynı şifreli değeri veriyor; randomized modda arama yok. Secure enclave destekli sürüm daha zengin sorgulara izin veriyor. Always Encrypted'ın avantajı uygulama kodunun neredeyse hiç değişmemesi; dezavantajları ise SQL Server ve SqlClient'a bağlı olmak, desteklenmeyen sorgu ve veri tipi sınırlamaları ve şifrelemenin veritabanının dışında yapılması gerektiği için mevcut kolonu şifrelemenin ya da anahtarı döndürmenin veriyi uygulamaya taşıyıp geri yazmayı gerektirmesi. Uygulama seviyesindeki şifreleme ise veritabanından bağımsız ve formatı tamamen senin kontrolünde.

Üretimde Dikkat

Anahtar sayısını ve kullanımını izle. Rastgele nonce ile bir anahtar başına 2^32 şifreleme sınırına uzak olsan bile anahtarları düzenli döndürmek, bir anahtarın sızması durumunda etkilenen veri miktarını sınırlar. Kaç kaydın hangi keyId'de olduğunu gösteren basit bir sorgu ya da metrik, rotasyonun gerçekten bittiğini görmenin tek yolu; eski DEK'i key ring'den ancak bu sayı sıfır olduğunda çıkar.

Uygulama açılışında key ring'i kur ve doğrula: her DEK'in açıldığını, aktif keyId'nin ring'de olduğunu kontrol et. Key Vault erişimi yoksa uygulama açılmamalı; şifreli kolonları okuyamayan bir uygulamanın açık kalması, hatanın ilk müşteri isteğinde ortaya çıkması demek.

Performans: AES-GCM, AES donanım komutlarıyla çok hızlı; telefon gibi küçük alanlarda maliyetin neredeyse tamamı AesGcm instance'ını oluşturmak. Çok yoğun bir yolda anahtar başına instance'ı cache'lemek mümkün, ama AesGcm thread-safe değil; ya thread başına ya da bir pool içinde tut. Ölçmeden optimize etme.

Log ve hata mesajlarına dikkat: AuthenticationTagMismatchException loglanırken payload'ı loga yazma. Hangi kayıt, hangi keyId ve hangi kolon olduğu yeterli; bir tag hatası ya veri bozulması ya da kurcalama işaretidir ve ikisi de alarm gerektirir.

Sık Düşülen Tuzaklar

Birincisi, EF Core model cache'ini unutmak. Lab'ın ilk sürümünde rotasyonu, aktif anahtarı 2 olan yeni bir FieldEncryptor ile yeni bir context oluşturarak yaptım. Çıktı şuydu:

after adding Grace: Ada=key 1, Alan=key 1, Grace=key 1
after re-encryption: Ada=key 1, Alan=key 1, Grace=key 1

Grace yeni anahtarla şifrelenmemişti. EF Core modeli context tipi başına bir kez kurup cache'liyor; OnModelCreating'de oluşturulan converter, ilk context'in encryptor'ını process boyunca tutuyor. Sonraki context'lere verdiğin encryptor'ın converter açısından hiçbir anlamı yok. Çözüm, encryptor'ı singleton yapmak ve rotasyonu onun içinde, key ring'i değiştirerek yapmak; FieldKeyRingProvider bunun için var.

İkincisi, nonce'u sabit tutmak ya da anahtardan türetmek. "Her kayıt için id'den nonce üretelim" fikri, aynı kayıt güncellendiğinde aynı nonce'un tekrar kullanılması demek. Nonce her şifrelemede yeniden üretilmeli.

Üçüncüsü, tag doğrulanmadan çözülmüş veriyi kullanmak. .NET'in AEAD API'leri bunu zaten engelliyor; tag tutmazsa çıktı buffer'ı temizleniyor ve exception fırlıyor. Ama CBC ile kendi "şifrele, sonra HMAC'le" yapını kuruyorsan, HMAC'i çözmeden önce doğrula.

Dördüncüsü, şifreli kolon için yanlış uzunluk. Base64 ve 30 byte'lık ek yük nedeniyle 13 karakterlik telefon 60 karakter oluyor. Kolonu düz metnin uzunluğuna göre tanımlarsan SQL Server'da kayıtlar kesilir ya da hata alırsın.

Beşincisi, blind index anahtarını şifreleme anahtarıyla aynı yapmak. Bir önceki yazıdaki kural: her amaç için ayrı anahtar.

Küçük Bir Deneme

★☆☆ BlindIndex'in uzunluğunu 16 byte'tan 4 byte'a indir ve 100.000 rastgele telefon numarası için index üret. Kaç çakışma oldu? Uzunluk ile sızan bilgi arasındaki dengeyi bu sayıyla tartış.

★★☆ Satır bağlamalı bir sürüm yaz: Customer'a SetPhone(string phone, FieldEncryptor encryptor, BlindIndex index) ekle ve associated data olarak Id'yi kullan; converter'ı kaldır. Converter_binds_to_the_column_not_the_row testini bu sürüm için yeniden yaz ve artık AuthenticationTagMismatchException beklediğini göster.

★★★ Yeniden şifreleme işini bir background job olarak yaz: keyId'si aktif anahtar olmayan kayıtları 500'lük batch'ler hâlinde bulsun, yeniden şifrelesin ve her batch'te ilerlemeyi loglasın. keyId'ye göre filtrelemek için ne eklemen gerekiyor: payload'dan okumak mı, ayrı bir kolon mu?

Bir sonraki yazıda simetrik anahtarın çözemediği soruya geçiyoruz: anahtarı daha önce hiç konuşmadığın bir tarafa nasıl ulaştırırsın? RSA (en yaygın public key algoritması) ile OAEP (RSA şifrelemesi için güvenli padding şeması), elliptic curve'ler, ECDH (iki tarafın ortak bir sır ürettiği elliptic curve key agreement) ile key agreement ve Bookstore'un tedarikçiye gönderdiği satın alma siparişi dosyasını yalnız tedarikçinin açabileceği şekilde hybrid encryption ile şifrelemek.