Mert Özen Her Satırda Daha İleri
Güvenlik Veri Gizliliği ve KVKK (geliştirici için) — #03

Kişisel Veriyi Korumak: Always Encrypted, Uygulama Seviyesinde Şifreleme, Pseudonymization, Maskeleme ve Test Verisi

Mert Özen 15 Kas 2026 12 dk 6 görüntülenme
Kişisel Veriyi Korumak: Always Encrypted, Uygulama Seviyesinde Şifreleme, Pseudonymization, Maskeleme ve Test Verisi

Hassas bir kişisel veri alanını korumanın yolu, tehdide göre katman seçmektir: TDE diski, Always Encrypted DBA'yı, uygulama seviyesinde AES-GCM backup ve replica'yı korur, maskeleme ise yalnız ekranı. Bookstore'da fatura kimlik numarasını purpose'a bağlı AES-GCM ile şifreliyor, HMAC blind index ile arıyor, destek ekibine maskeli gösteriyor ve test ortamını Bogus ile sahte kontrol haneli sentetik veriye taşıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Entity Framework Core 10.0.15 (SQLite), Bogus 35.6 ve xUnit 2.9 ile derlenip denenmiştir. SQL Server'a özgü Always Encrypted ve TDE (veritabanı dosyalarını diskte şifreleme) bu yazıda çalıştırılmadı, Microsoft Learn dokümantasyonundan anlatıldı.

Bu seri bir geliştiricinin bakışıdır, hukuki tavsiye değildir. Hangi verinin hangi tedbirle korunacağına güvenlik ve hukuk ekipleri birlikte karar verir.

Önce Tahmin Et

Bookstore'un siparişlerindeki fatura kimlik numarası sütununu şifreledin; veritabanında yalnız anlamsız bir metin duruyor. Bir veritabanı kullanıcısı bu şifreli değeri kopyalayıp başka bir sütuna, örneğin müşterinin telefon alanına yapıştırırsa ve uygulama o alanı okuyup çözmeye çalışırsa ne olur? İkinci soru: destek ekibine kimlik numarasının yalnız son iki hanesini gösteriyorsun; bu bir güvenlik kontrolü mü, yoksa bir arayüz kararı mı?

Şifreleme Katmanları: Neyi Kimden Koruyor

KVKK'nın (6698 sayılı Kişisel Verilerin Korunması Kanunu) 12. maddesi veri sorumlusundan, verinin hukuka aykırı işlenmesini ve erişilmesini önlemek ve muhafazasını sağlamak için uygun güvenlik düzeyini sağlayan teknik ve idari tedbirleri almasını istiyor. Kanun hangi algoritmayı kullanacağını söylemez; tedbirin neyi kimden koruduğunu senin göstermen gerekir. Önceki yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) envanterinde BillingNationalId alanını SensitiveData olarak işaretlemiştik. Bu alanı korumanın dört yolu var ve her biri farklı bir tehdide karşı:

KatmanNeye karşı korurNeye karşı korumazAramaAnahtar nerede
TDEÇalınan disk, veri dosyası ve backupVeritabanına bağlanan herkesNormalSunucuda
Always EncryptedDBA (veritabanı yöneticisi), cloud operatörü, sysadminUygulamanın kendisi ve log'larıDeterministic ile yalnız eşitlik; enclave ile daha fazlasıVeritabanı dışında, istemci tarafında
Uygulama seviyesinde AEAD (bütünlüğü de doğrulayan şifreleme)DBA, backup, replica, exportUygulamanın kendisi ve log'larıYalnız blind index ile eşitlikUygulamanın secret store'unda
Dynamic Data MaskingEkranda yanlışlıkla görünmeSorgu yazabilen kullanıcıNormalAnahtar yok

TDE ve Dynamic Data Masking'i SQL Server Derinlemesine serisinin "SQL Server'da Güvenlik Modeli: Login, User, Schema, Role, Least Privilege ve Row-Level Security" yazısında çalıştırarak göstermiştik; orada maskeli bir e-posta sütununun WHERE ve LIKE ile karakter karakter tahmin edilebildiğini görmüştük. Always Encrypted'ı bu seride çalıştırmadım, aşağıdakiler Microsoft Learn'deki dokümana dayanıyor. Sütunlar istemcideki driver tarafından şifrelenir; column encryption key veritabanında şifreli durur, onu şifreleyen column master key ise Azure Key Vault, Windows sertifika deposu ya da bir HSM (donanım güvenlik modülü) gibi veritabanı dışındaki bir key store'dadır. Deterministic şifreleme eşitlik araması, IN, GROUP BY ve DISTINCT yapabilir ama az sayıda farklı değeri olan sütunlarda desen sızdırır; randomized şifrelemede secure enclave olmadan arama yoktur. Dokümandaki kısıtların ikisi Bookstore için önemli: Dynamic Data Masking uygulanmış bir sütun Always Encrypted ile şifrelenemiyor ve default constraint'i olan sütunlar desteklenmiyor.

Bookstore'un ihtiyacı için uygulama seviyesinde AEAD'yi seçiyoruz: veritabanı motorundan bağımsız, SQLite'ta da SQL Server'da da aynı çalışır ve anahtar veritabanına hiç uğramaz. Bedeli, aramayı kendimizin çözmesi.

Uygulama Seviyesinde Şifreleme: AES-GCM ve Purpose

AES (standart simetrik şifreleme algoritması) ile GCM (bütünlüğü de doğrulayan bir çalışma modu) birleşimi olan AES-GCM'in nonce kuralları, anahtar uzunluğu ve envelope encryption Geliştirici için Kriptografi serisinin "Symmetric Encryption ve AEAD: AES-GCM, ChaCha20-Poly1305, Nonce Yönetimi ve Envelope Encryption" yazısında ayrıntılı anlatıldı; burada o primitive'i bir kişisel veri alanına uyguluyoruz:

public sealed class PiiProtector(PiiProtectionOptions options)
{
    private const int NonceSize = 12;
    private const int TagSize = 16;

    // Şifreli metin biçimi: "<keyId>.<base64(nonce | ciphertext | tag)>"
    public string Encrypt(string plaintext, string purpose)
    {
        byte[] key = options.EncryptionKeys[options.ActiveKeyId];
        byte[] input = Encoding.UTF8.GetBytes(plaintext);
        byte[] output = new byte[NonceSize + input.Length + TagSize];
        Span<byte> nonce = output.AsSpan(0, NonceSize);
        RandomNumberGenerator.Fill(nonce);

        using var aes = new AesGcm(key, TagSize);
        aes.Encrypt(nonce, input, output.AsSpan(NonceSize, input.Length), output.AsSpan(NonceSize + input.Length), Encoding.UTF8.GetBytes(purpose));
        return $"{options.ActiveKeyId}.{Convert.ToBase64String(output)}";
    }

    public string Decrypt(string protectedValue, string purpose)
    {
        int dot = protectedValue.IndexOf('.');
        byte[] key = options.EncryptionKeys[protectedValue[..dot]];
        byte[] data = Convert.FromBase64String(protectedValue[(dot + 1)..]);
        int length = data.Length - NonceSize - TagSize;
        byte[] plaintext = new byte[length];

        using var aes = new AesGcm(key, TagSize);
        aes.Decrypt(data.AsSpan(0, NonceSize), data.AsSpan(NonceSize, length), data.AsSpan(NonceSize + length), plaintext, Encoding.UTF8.GetBytes(purpose));
        return Encoding.UTF8.GetString(plaintext);
    }
}

Üç tasarım kararı var. Birincisi, şifreli metnin başındaki anahtar kimliği (k1.): anahtar döndürüldüğünde eski satırlar eski anahtarla okunmaya devam eder, yenileri aktif anahtarla yazılır. İkincisi, her değer için rastgele bir nonce; aynı kimlik numarası iki kez şifrelendiğinde iki farklı metin çıkar ve sütuna bakan biri aynı kişinin iki siparişini eşleştiremez. Üçüncüsü, purpose parametresi: AES-GCM'in associated data alanına sütunun adını veriyoruz. Şifrelenmez ama tag'e girer; başka bir amaçla çözmeye çalışınca doğrulama başarısız olur. Giriş sorusunun cevabı bu.

Anahtarlar PiiProtectionOptions ile geliyor ve hiçbiri veritabanında ya da appsettings.json'da durmuyor. Demo komutu anahtarları her çalıştırmada bellekte üretiyor; production'da bir secret store'dan gelirler. Bunun nasıl kurulacağını Configuration ve Secret Management serisinde anlattık.

Blind Index ile Arama

Rastgele nonce eşitlik aramasını da imkânsız kılıyor: muhasebe "bu kimlik numarasının siparişlerini bul" dediğinde WHERE ile karşılaştıracak sabit bir değer yok. Çözüm, ayrı bir anahtarla hesaplanan bir HMAC (anahtarlı hash) değeri, yani blind index:

// Eşitlik araması için anahtarlı, deterministik hash; anahtar olmadan yeniden hesaplanamaz.
public string BlindIndex(string normalized, string purpose)
{
    byte[] mac = HMACSHA256.HashData(options.BlindIndexKey, Encoding.UTF8.GetBytes($"{purpose}:{normalized}"));
    return Convert.ToHexStringLower(mac.AsSpan(0, 16));
}

public void SetBillingNationalId(Order order, string nationalId)
{
    order.BillingNationalIdEncrypted = protector.Encrypt(nationalId, Purpose);
    order.BillingNationalIdIndex = protector.BlindIndex(nationalId, Purpose);
}

public Task<List<int>> FindOrderIdsAsync(string nationalId, CancellationToken ct = default)
{
    string index = protector.BlindIndex(nationalId, Purpose);
    return db.Orders.Where(o => o.BillingNationalIdIndex == index).Select(o => o.Id).ToListAsync(ct);
}

Demo komutu üç sentetik müşteri ve sipariş yazıyor, sonra veritabanını uygulamanın dışından okuyan birinin gördüğünü, blind index aramasını ve rollere göre görünümü basıyor:

cd tools/Bookstore.PrivacyLab
dotnet run -- protect
Ada Testwell     ada.testwell.000@example.com   fake id 99906863934 valid=False
Grace Demoray    grace.demoray.001@example.com  fake id 23515709358 valid=False
Alan Sampleton   alan.sampleton.002@example.com fake id 80171107056 valid=False
raw row: 1 | k1.X7TCmnOGfoZiEW5y9ZfbyR3UpNCAjR1D9kPJbO8+eHvu35PAllRb | 6edb142b53daf80ff35e7058040b550b
orders for 23515709358: [2]
StoreStaff: SupportOrderView { OrderId = 2, BillingName = Grace Demoray, NationalId = *********58 }
Accounting: SupportOrderView { OrderId = 2, BillingName = Grace Demoray, NationalId = 23515709358 }
wrong purpose: AuthenticationTagMismatchException: The computed authentication tag did not match the input authentication tag.

Kimlik numaraları sahte: 11 haneli ama son kontrol hanesi bilerek yanlış, valid=False bunu gösteriyor. raw row satırı backup'ı, read replica'yı ya da veritabanına doğrudan bağlanan birinin gördüğünü temsil ediyor: şifreli metin ve 32 karakterlik bir index. Son satır, şifreli değerin başka bir sütunun amacıyla çözülemediğini gösteriyor.

Blind index'in de bir bedeli var. 01. yazıda gördüğümüz gibi kimlik numarasının değer uzayı küçük; HMAC anahtarı sızarsa saldırgan bütün olası değerlerin index'ini birkaç dakikada hesaplayıp tabloyu geri eşleştirebilir. Bu yüzden index anahtarı şifreleme anahtarından ayrı tutuluyor ve en az onun kadar korunuyor. Index'i 16 bayta kısaltmak depolamayı azaltıyor ama bu uzunlukta çakışma pratikte görülmez; daha kısa bir index bilinçli olarak çakışma üretir ve aramadan sonra şifreyi çözüp doğrulamayı gerektirir.

Destek Ekibine Maskeli Göstermek

Muhasebe faturayı kesmek için numaranın tamamına ihtiyaç duyuyor, destek ekibi ise yalnız müşteriyle konuşurken doğrulama yapmak için son iki haneye. Maskeleme, rol kontrolünden sonra uygulamada yapılıyor:

// Muhasebe fatura için numaranın tamamını görür; mağaza personeli yalnız son iki haneyi.
public async Task<SupportOrderView?> GetForRoleAsync(int orderId, string role, CancellationToken ct = default)
{
    Order? order = await db.Orders.AsNoTracking().SingleOrDefaultAsync(o => o.Id == orderId, ct);
    if (order is null)
    {
        return null;
    }

    string? nationalId = order.BillingNationalIdEncrypted is { } encrypted
        ? protector.Decrypt(encrypted, Purpose)
        : null;
    return new SupportOrderView(order.Id, order.BillingName, role == "Accounting" ? nationalId : Mask(nationalId));
}

public static string? Mask(string? value) =>
    value is null ? null : new string('*', value.Length - 2) + value[^2..];

Giriş sorusunun ikinci yarısının cevabı: maskeleme tek başına bir güvenlik kontrolü değil, yetkilendirmenin sonucunu ekrana yansıtan bir arayüz kararı. Güvenliği sağlayan, numaranın tamamının yalnız Accounting rolüne dönmesi ve bu kararın sunucuda verilmesi. Maskelemeyi Angular tarafında yapmak, numaranın tamamını tarayıcıya gönderip CSS ile gizlemek demektir. Gerçek uygulamada role parametresi elle verilmez, kimliği doğrulanmış kullanıcının claim'lerinden gelir; bu yetkilendirme modelini Authentication ve Authorization serisinde kurduk.

Pseudonymization mı, Anonymization mı

Blind index aslında bir pseudonym (takma ad): anahtarı bilen, index'ten kişiye dönebilir. GDPR (Avrupa Birliği Genel Veri Koruma Tüzüğü) Art. 4(5) pseudonymization'ı, ek bilgi olmadan verinin belirli bir kişiye bağlanamayacağı biçimde işlenmesi olarak tanımlıyor ve o ek bilginin ayrı tutulmasını, teknik ve idari tedbirlerle korunmasını şart koşuyor; Recital 26 da böyle verinin hâlâ kişisel veri olduğunu söylüyor. KVKK tarafında Kişisel Verilerin Silinmesi, Yok Edilmesi veya Anonim Hale Getirilmesi Hakkında Yönetmelik'in 10. maddesi anonim hâle getirmeyi, verinin başka verilerle eşleştirilse dahi kişiyle ilişkilendirilemeyecek hâle getirilmesi olarak tanımlıyor ve bunun tersine mühendislik ve veri eşleştirme gibi yöntemlerle geri döndürülemeyecek şekilde yapılmasını istiyor. Anahtarı Bookstore'da duran bir HMAC bu çıtayı geçmez.

Pratik sonuç: analytics'e müşteri numarası yerine HMAC'li bir pseudonym göndermek riski azaltır ve iyi bir uygulamadır, ama o veri setini kişisel veri olmaktan çıkarmaz. Envanterde, erişim talebinde ve silme planında yer alır. Gerçekten anonim bir rapor genellikle satır düzeyinde değil, toplam düzeyinde üretilir: il bazında sipariş sayısı gibi, küçük grupları gizleyerek. Tek bir ilçede tek bir müşteri varsa o satır kişiyi ele verir.

Test ve Staging Verisi: Bogus ile Sentetik Veri

Production veritabanının bir kopyasını staging'e almak, bütün bu korumaları kişisel verinin daha az korunan bir kopyasıyla atlatmak demek. Önceki yazıdaki envanterde staging bir kopya olarak yazılmak zorunda kalırdı. İki seçenek var: production'dan maskelenmiş bir kopya ya da hiç gerçek satır içermeyen sentetik veri. Maskelenmiş kopyada her yeni sütunun maskeleme script'ine eklenmesi gerekir ve unutulan sütun sızar; sentetik veride bu risk yok. Bookstore için Bogus ile sentetik veri üretiyoruz:

public static List<(Customer Customer, string FakeNationalId)> Customers(int count, int seed = 2026)
{
    Randomizer.Seed = new Random(seed);
    var faker = new Faker();
    List<(Customer, string)> result = [];
    for (int i = 0; i < count; i++)
    {
        (string first, string last, string city) = faker.PickRandom(FakePeople.All);
        var customer = new Customer
        {
            FullName = $"{first} {last}",
            Email = $"{first}.{last}.{i:000}".ToLowerInvariant() + "@example.com",
            Phone = $"+90 000 000 {i / 100 % 100:00} {i % 100:00}",
            City = city,
            AdultConfirmedAtUtc = faker.Date.Past(2, new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc)),
            CreatedAtUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
        };
        result.Add((customer, FakeNationalId(faker)));
    }
    return result;
}

// SAHTE test değeri: 11 hane, son kontrol hanesi bilerek yanlış; gerçek bir kimlik numarası olamaz.
public static string FakeNationalId(Faker faker)
{
    string firstNine = faker.Random.Int(1, 9) + faker.Random.ReplaceNumbers("########");
    string firstTen = firstNine + NationalIdChecksum.ExpectedTenthDigit(firstNine);
    int wrongLast = (NationalIdChecksum.ExpectedEleventhDigit(firstTen) + faker.Random.Int(1, 9)) % 10;
    return firstTen + wrongLast;
}

Bogus'un kendi isim ve e-posta üreticilerini bilerek kullanmıyorum: e-posta üreticisi gerçek posta sağlayıcılarının alan adlarını kullanabiliyor ve rastgele üretilen bir ad soyad gerçek bir kişiyle çakışabilir. İsimler sabit bir uydurma listeden, adresler ayrılmış example.com alan adından, kimlik numaraları da kontrol hanesi bozuk sayılardan geliyor. Bu kuralları da testler sabitliyor:

[Fact]
public void Synthetic_national_ids_never_pass_the_checksum()
{
    var faker = new Faker();
    for (int i = 0; i < 10_000; i++)
    {
        string fake = SyntheticData.FakeNationalId(faker);
        Assert.Equal(11, fake.Length);
        Assert.False(NationalIdChecksum.IsValid(fake), fake);
    }
}

[Fact]
public void Ciphertext_does_not_decrypt_under_another_purpose()
{
    var protector = new PiiProtector(Options("k1", "k1"));
    string ciphertext = protector.Encrypt("00000000000", "Orders.BillingNationalId");

    Assert.ThrowsAny<CryptographicException>(() => protector.Decrypt(ciphertext, "Customers.Phone"));
}
dotnet test
Passed!  - Failed:     0, Passed:    11, Skipped:     0, Total:    11, Duration: 356 ms - Bookstore.Privacy.Tests.dll (net10.0)

Üretimde Dikkat

Anahtar yönetimi bu tasarımın en zayıf halkası. Anahtarlar Key Vault gibi bir secret store'da durmalı, uygulama onlara managed identity ile erişmeli ve erişim log'lanmalı. Veri anahtarını bir key encryption key ile sarmalayan envelope encryption'ı Kriptografi serisinin aynı yazısında gösterdik. Anahtar döndürme iki adımlı: yeni anahtar aktif olur ve yeni yazılar onunla şifrelenir, sonra bir background job eski anahtarla şifrelenmiş satırları okuyup yeniden yazar; son satır da yeni anahtara geçtiğinde eski anahtar kaldırılır. Blind index anahtarını döndürmek daha pahalı, çünkü bütün index'lerin yeniden hesaplanması gerekir; bunu baştan planla.

İkinci nokta Entity Framework Core (EF Core) ile entegrasyon. Şifrelemeyi bir value converter'a koymak cazip, ama EF Core modeli context tipi başına bir kez oluşturup cache'liyor; converter'a constructor'dan anahtar geçirirsen ilk context'in anahtarı bütün context'lerde kalır. Bu örnekte şifrelemeyi açık bir servis (NationalIdVault) üzerinden yapıyoruz; kimin hangi alanı çözdüğü de böylece kodda tek yerde görünüyor. Üçüncü nokta log'lar: EnableSensitiveDataLogging açıkken EF Core parametre değerlerini log'a yazar ve şifrelemeden önceki düz değer orada görünebilir. Çözülmüş değeri taşıyan DTO'ların (katmanlar arasında veri taşıyan sınıflar) log'a düşmemesi için Logging serisindeki redaction ayarı burada da geçerli.

Sık Düşülen Tuzaklar

En pahalı tuzak, anahtarı şifreli veriyle aynı veritabanında tutmak: bir Keys tablosu ya da aynı sunucudaki bir configuration tablosu. Backup'ı çalan, anahtarı da çalmış olur ve şifreleme yalnız bir adım fazlası olur. İkincisi, maskelemeyi güvenlik kontrolü sanmak: SQL Server Derinlemesine serisinde gördüğümüz gibi Dynamic Data Masking sorgu yazabilen birine karşı korumaz, uygulamadaki maskeleme de verinin tamamı istemciye gidiyorsa hiçbir şey korumaz. Üçüncüsü, deterministik şifreleme ya da anahtarsız hash ile "aranabilir şifreleme" yaptığını düşünmek; küçük değer uzaylı alanlarda bu, 01. yazıdaki hash örneğiyle aynı şekilde geri çevrilir. Dördüncüsü, staging'e "bir kereliğine" production kopyası almak ve o kopyanın aylarca orada kalması.

Bu Yazının Dayandığı Kaynaklar

Erişim tarihi 15 Kasım 2026.

  • 6698 sayılı Kanun md. 12: mevzuat.gov.tr/mevzuatmetin/1.5.6698.pdf
  • Kişisel Verilerin Silinmesi, Yok Edilmesi veya Anonim Hale Getirilmesi Hakkında Yönetmelik md. 10: kvkk.gov.tr/Icerik/5441
  • GDPR Art. 4(5) ve Recital 26: eur-lex.europa.eu/eli/reg/2016/679/oj
  • Always Encrypted (Microsoft Learn): learn.microsoft.com/sql/relational-databases/security/encryption/always-encrypted-database-engine

Küçük Bir Deneme

★☆☆ protect komutunu iki kez çalıştır ve aynı kimlik numarasının şifreli metninin değiştiğini, blind index'in ise her çalıştırmada farklı olduğunu gör. Index neden çalıştırmalar arasında değişiyor?

★★☆ Customer.Phone alanını da PiiProtector ile şifrele ve envanter testinin, alanın sınıfını SensitiveData'ya çevirdiğinde ne beklediğini düşün. Telefonla arama gerekiyorsa normalizasyonu (boşluklar, ülke kodu) blind index'ten önce nerede yapacaksın?

★★★ Eski anahtarla (k1) şifrelenmiş satırları bulup aktif anahtarla (k2) yeniden yazan, kesilip yeniden başlatıldığında kaldığı yerden devam eden bir re-encryption job'ı yaz ve son satır taşındığında k1'in güvenle kaldırılabileceğini gösteren bir test ekle.

Veriyi korumak, onu ne zaman bırakacağını bilmeyi gereksiz kılmıyor. Bir sonraki yazıda "hesabımı sil" talebini işliyoruz: kişisel alanları silmek, yasal süre boyunca saklanan siparişleri ayırmak, soft delete'in neden yetmediği, backup'tan geri yüklemede silinmiş müşterilerin geri gelmesi ve periyodik imha job'ı.