EFEF Core Handbook

UZMAN

Veri Koruma — Encryption, Hashing & Masking

Production'da hassas veri (TC kimlik, kart no, şifre) saklama kaçınılmaz. Üç farklı strateji: Encrypt (şifrele/çöz — geri dönüşümlü), Hash (tek yönlü özetle — şifreler için), Mask (görünümü gizle — loglama/raporlama için).

Veritabanı sağlayıcısı Bu sayfadaki eşleşen örnekleri seçilen sağlayıcıya göre gösterir.

Strateji Karşılaştırması

Strateji Geri Dönüşüm Kullanım Alanı Performans
Encryption Decrypt edilebilir Kart no, TC kimlik, adres Orta
Hashing Tek yönlü Şifre, PIN, güvenlik sorusu Hızlı
Masking Orijinal saklanır Loglama, API response, raporlama Hızlı

1. Application-Level Encryption (Value Converter ile)

GÜVENLİK: AES-CBC modu (varsayılan) padding oracle saldırısına açıktır. Production'da AES-GCM kullanın — hem şifreleme hem authentication sağlar.

// Encrypt/Decrypt servisi — AES-GCM (Authenticated Encryption)
public interface IEncryptionService
{
    string Encrypt(string plainText);
    string Decrypt(string cipherText);
}

public class AesGcmEncryptionService : IEncryptionService
{
    private readonly byte[] _key;
    
    public AesGcmEncryptionService(IConfiguration config)
    {
        _key = Convert.FromBase64String(config["Encryption:Key"]!);
        if (_key.Length != 32)
            throw new ArgumentException("Encryption key must be 256 bits (32 bytes).");
    }

    public string Encrypt(string plainText)
    {
        var plainBytes = Encoding.UTF8.GetBytes(plainText);
        var nonce = new byte[AesGcm.NonceByteSizes.MaxSize];  // 12 bytes
        RandomNumberGenerator.Fill(nonce);
        
        var cipherBytes = new byte[plainBytes.Length];
        var tag = new byte[AesGcm.TagByteSizes.MaxSize];      // 16 bytes

        using var aes = new AesGcm(_key, AesGcm.TagByteSizes.MaxSize);
        aes.Encrypt(nonce, plainBytes, cipherBytes, tag);

        // Nonce (12) + Tag (16) + CipherText birlikte saklanır
        var result = new byte[nonce.Length + tag.Length + cipherBytes.Length];
        Buffer.BlockCopy(nonce, 0, result, 0, nonce.Length);
        Buffer.BlockCopy(tag, 0, result, nonce.Length, tag.Length);
        Buffer.BlockCopy(cipherBytes, 0, result, nonce.Length + tag.Length, cipherBytes.Length);
        return Convert.ToBase64String(result);
    }

    public string Decrypt(string cipherText)
    {
        var fullBytes = Convert.FromBase64String(cipherText);
        if (fullBytes.Length < 29)  // Nonce(12) + Tag(16) + en az 1 byte
            throw new CryptographicException("Invalid cipher text.");

        var nonce = fullBytes[..12];
        var tag = fullBytes[12..28];
        var cipherBytes = fullBytes[28..];
        var plainBytes = new byte[cipherBytes.Length];

        using var aes = new AesGcm(_key, AesGcm.TagByteSizes.MaxSize);
        aes.Decrypt(nonce, cipherBytes, tag, plainBytes);
        // Tampered veri → CryptographicException otomatik fırlatılır (GCM avantajı)
        
        return Encoding.UTF8.GetString(plainBytes);
    }
}
// ⚠️ Production'da key yönetimi için Azure Key Vault veya AWS KMS kullanın.
// Hardcoded key veya appsettings.json'da açık key ASLA production'a gitmesin.
// 💡 .NET 8+: AesGcm(key, tagSizeInBytes) constructor'ı kullanılmalı (eski overload deprecated).
// Entity Configuration — Value Converter olarak uygula
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    private readonly IEncryptionService _encryption;
    
    public CustomerConfiguration(IEncryptionService encryption)
        => _encryption = encryption;

    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.Property(c => c.NationalId)
            .HasMaxLength(500)  // Şifreli metin daha uzun olur!
            .HasConversion(
                v => _encryption.Encrypt(v),     // DB'ye yazarken şifrele
                v => _encryption.Decrypt(v));     // DB'den okurken çöz

        builder.Property(c => c.CreditCardNumber)
            .HasMaxLength(500)
            .HasConversion(
                v => _encryption.Encrypt(v),
                v => _encryption.Decrypt(v));
    }
}
-- DB'de şifreli olarak saklanır:
SELECT [NationalId] FROM [Customers] WHERE [Id] = 1;
-- Sonuç: 'FmK3x9...(base64 şifreli metin)...'

-- ⚠️ WHERE ile şifreli sütunda arama YAPILAMAZ:
-- WHERE NationalId = '12345678901'  ← ÇALIŞMAZ (DB'de şifreli)
-- DB'de şifreli olarak saklanır:
SELECT national_id FROM customers WHERE id = 1;
-- Sonuç: 'FmK3x9...(base64 şifreli metin)...'

-- ⚠️ WHERE ile şifreli sütunda arama YAPILAMAZ:
-- WHERE national_id = '12345678901'  ← ÇALIŞMAZ (DB'de şifreli)

Kısıtlamalar:

  • Şifreli sütunda WHERE, ORDER BY, INDEX kullanılamaz (DB şifreli metni görür)
  • Arama gerekiyorsa → ayrıca hash'lenmiş bir lookup sütunu ekle
  • Key yönetimi kritik — Azure Key Vault veya AWS KMS kullan

Update İşlemlerinde Ne Olur?

Value Converter decrypt/re-encrypt işlemini otomatik halleder:

// Update senaryosu — geliştirici perspektifi:
var customer = await context.Customers.FindAsync(1);
// Bu noktada customer.NationalId = "12345678901" (çözülmüş hali — Value Converter çözdü)

customer.NationalId = "98765432101";  // Yeni değeri plain-text olarak ata
await context.SaveChangesAsync();
// EF otomatik olarak: Encrypt("98765432101") → DB'ye şifreli yazar

Perde arkasında olan:

READ DB: "FmK3x9...base64..." ↓ Value Converter (Decrypt) C#: "12345678901" UPDATE C#: customer.NationalId = "98765432101" ↓ Value Converter (Encrypt) SQL: UPDATE Customers SET NationalId = 'Xk7mP...(yeni)' WHERE Id = 1

Key Rotation (Anahtar Değişimi) durumunda tüm kayıtlar yeniden şifrelenmelidir:

// Key Rotation — mevcut verileri yeni anahtarla yeniden şifrele
public async Task RotateEncryptionKey(
    IEncryptionService oldService,
    IEncryptionService newService)
{
    var customers = await context.Customers.ToListAsync();
    // Value Converter eski key ile decrypt etti → plain-text elimizde

    // Yeni key'li converter'a geçiş yapıldıktan sonra SaveChanges
    // yeni key ile encrypt eder
    foreach (var c in customers)
    {
        // Property'yi "dirty" işaretle (değer aynı olsa bile yeniden yazılsın)
        context.Entry(c).Property(x => x.NationalId).IsModified = true;
        context.Entry(c).Property(x => x.CreditCardNumber).IsModified = true;
    }

    await context.SaveChangesAsync(); // Yeni converter ile re-encrypt
}

Partial Update dikkat:

  • Eğer sadece customer.Name değiştirirseniz, EF şifreli sütunlara dokunmaz (zaten IsModified = false)
  • ExecuteUpdateAsync() kullanıyorsanız Value Converter çalışmaz — ham SQL üretilir, şifrelemeyi kendiniz yapmalısınız:
// ❌ YANLIŞ — Value Converter ExecuteUpdate'te çalışmaz!
await context.Customers
    .Where(c => c.Id == 1)
    .ExecuteUpdateAsync(s => s.SetProperty(c => c.NationalId, "98765432101"));
// DB'ye plain-text "98765432101" yazılır!

// ✅ DOĞRU — Manuel şifrele
var encrypted = encryptionService.Encrypt("98765432101");
await context.Customers
    .Where(c => c.Id == 1)
    .ExecuteUpdateAsync(s => s.SetProperty(c => c.NationalId, encrypted));

2. SQL Server Always Encrypted (Provider Seviyesi)

// Connection string'e eklenir — EF kodu değişmez!
"Server=.;Database=MyDb;Column Encryption Setting=enabled;"

// SQL Server tarafında sütun tanımı:
// ALTER TABLE Customers ALTER COLUMN NationalId NVARCHAR(11)
//     ENCRYPTED WITH (ENCRYPTION_TYPE = DETERMINISTIC,
//     ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256',
//     COLUMN_ENCRYPTION_KEY = MyCEK);
Encryption Type WHERE Desteği Kullanım
Deterministic Eşitlik (=) Arama gereken sütunlar (TC kimlik)
Randomized Maksimum güvenlik (kart no, adres)

Always Encrypted'da şifreleme/çözme client tarafında olur — DB sunucusu bile veriyi göremez.


3. Hashing (Tek Yönlü — Şifre Saklama)

// ⚠️ Hashing bir Value Converter DEĞİLDİR — çünkü geri çözülemez.
// Entity'de hash ve salt birlikte saklanır:
public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = null!;
    public string PasswordHash { get; set; } = null!;  // Şifrenin hash'i
    public string PasswordSalt { get; set; } = null!;  // Tuz
}

// Hash oluşturma (kayıt sırasında)
public static (string hash, string salt) HashPassword(string password)
{
    var salt = RandomNumberGenerator.GetBytes(32);
    var hash = Rfc2898DeriveBytes.Pbkdf2(
        password,
        salt,
        iterations: 100_000,
        HashAlgorithmName.SHA256,
        outputLength: 32);

    return (Convert.ToBase64String(hash), Convert.ToBase64String(salt));
}

// Doğrulama (login sırasında)
public static bool VerifyPassword(string password, string storedHash, string storedSalt)
{
    var salt = Convert.FromBase64String(storedSalt);
    var hash = Rfc2898DeriveBytes.Pbkdf2(
        password, salt, 100_000, HashAlgorithmName.SHA256, 32);

    return CryptographicOperations.FixedTimeEquals(
        hash, Convert.FromBase64String(storedHash));
}
// EF Configuration — sadece sütun boyutu ayarlanır
builder.Property(u => u.PasswordHash).HasMaxLength(88).IsRequired();   // Base64(32 byte)
builder.Property(u => u.PasswordSalt).HasMaxLength(88).IsRequired();
builder.Property(u => u.Email).HasMaxLength(256);
builder.HasIndex(u => u.Email).IsUnique();  // Login lookup için

ASLA yapma:

  • MD5/SHA1 kullanma (zayıf)
  • Salt'sız hash'leme (rainbow table saldırısı)
  • Şifreyi plain-text saklama
    Kullan: Pbkdf2, BCrypt, Argon2id

Hash'lenmiş Veriyi Güncelleme (Şifre Değiştirme)

Hash tek yönlüdür — eski hash'i "açıp" düzenleyemezsin. Güncelleme = eski şifreyi doğrula + yeni hash oluştur.

public async Task<bool> ChangePassword(int userId, string currentPassword, string newPassword)
{
    var user = await context.Users.FindAsync(userId);
    if (user == null) return false;

    // 1️⃣ Önce mevcut şifreyi doğrula (eski hash ile karşılaştır)
    if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt))
        return false;  // Eski şifre yanlış → reddet

    // 2️⃣ Yeni şifreyi hash'le (yeni salt ile)
    var (newHash, newSalt) = HashPassword(newPassword);

    // 3️⃣ Hash ve salt'ı güncelle
    user.PasswordHash = newHash;
    user.PasswordSalt = newSalt;

    await context.SaveChangesAsync();
    return true;
}

SQL çıktısı:

-- EF'in ürettiği UPDATE:
UPDATE [Users]
SET [PasswordHash] = @p0,   -- Yeni hash (tamamen farklı bir string)
    [PasswordSalt] = @p1    -- Yeni salt (her seferinde random)
WHERE [Id] = @p2;
-- EF'in ürettiği UPDATE:
UPDATE users
SET password_hash = @p0,   -- Yeni hash (tamamen farklı bir string)
    password_salt = @p1    -- Yeni salt (her seferinde random)
WHERE id = @p2;

Neden yeni salt?
Her şifre değişikliğinde yeni salt üretilir. Aynı şifre bile farklı hash üretir → rainbow table saldırısını önler.

Aynı şifre "MyPass123":
  Salt_1 + "MyPass123" → Hash: "a8f4e2..."
  Salt_2 + "MyPass123" → Hash: "7c9b1d..."  ← Tamamen farklı!

Admin tarafından şifre sıfırlama (eski şifre bilinmiyor):

public async Task AdminResetPassword(int userId, string temporaryPassword)
{
    var user = await context.Users.FindAsync(userId);
    var (hash, salt) = HashPassword(temporaryPassword);
    user.PasswordHash = hash;
    user.PasswordSalt = salt;
    user.MustChangePassword = true;  // İlk login'de değiştirmeye zorla
    await context.SaveChangesAsync();
}

4. Dynamic Data Masking (SQL Server)

// EF Core'da HasComment ile belgelenir, masking SQL tarafında uygulanır
builder.Property(c => c.Email)
    .HasColumnType("nvarchar(256)")
    .HasComment("MASKED WITH (FUNCTION = 'email()')");

builder.Property(c => c.CreditCardNumber)
    .HasColumnType("nvarchar(20)")
    .HasComment("MASKED WITH (FUNCTION = 'partial(0,\"XXXX-XXXX-XXXX-\",4)')");
-- Migration sonrası SQL ile masking ekle:
ALTER TABLE Customers ALTER COLUMN Email ADD MASKED WITH (FUNCTION = 'email()');
ALTER TABLE Customers ALTER COLUMN Phone ADD MASKED WITH (FUNCTION = 'partial(0,"***-",4)');
ALTER TABLE Customers ALTER COLUMN CreditCardNumber ADD MASKED WITH (FUNCTION = 'partial(0,"XXXX-XXXX-XXXX-",4)');

-- Yetkisiz kullanıcının gördüğü:
-- Email: [email protected]
-- Phone: ***-5678
-- CreditCard: XXXX-XXXX-XXXX-1234

-- Yetkili kullanıcı (UNMASK izni olan) orijinal veriyi görür:
GRANT UNMASK TO [AppAdminUser];
-- PostgreSQL'de native dynamic masking yok, VIEW + RLS ile benzer sonuç:
CREATE VIEW customers_masked AS
SELECT id, name,
       LEFT(email, 1) || '***@' || SPLIT_PART(email, '@', 2) AS email,
       '***-' || RIGHT(phone, 4) AS phone,
       'XXXX-XXXX-XXXX-' || RIGHT(credit_card_number, 4) AS credit_card_number
FROM customers;

-- Row Level Security ile erişim kontrolü:
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON customers_masked TO app_user;
-- Admin kullanıcı orijinal tabloyu doğrudan görebilir

5. Application-Level Masking (Projection ile)

// API response'da masking — DB'ye dokunmadan
var customers = await context.Customers
    .Select(c => new CustomerDto
    {
        Id = c.Id,
        Name = c.Name,
        Email = MaskEmail(c.Email),           // a***@gmail.com
        Phone = MaskPhone(c.Phone),           // ***-5678
        CardLast4 = c.CreditCardNumber[^4..]  // Sadece son 4 hane
    })
    .ToListAsync();

// Masking helper
static string MaskEmail(string email)
{
    var parts = email.Split('@');
    return $"{parts[0][0]}***@{parts[1]}";
}

Hangi Stratejiyi Ne Zaman Kullan?

Senaryo Strateji Neden
Şifre saklama Hash (Pbkdf2/Argon2) Geri çözülmemeli
TC Kimlik / Kart No (arama gerekli) Always Encrypted (Deterministic) DB seviyesi, EF'e şeffaf
TC Kimlik / Kart No (arama gereksiz) App Encryption (Value Converter) Basit, provider bağımsız
API response gizleme App Masking (Projection) DB'ye dokunmaz, esnek
Rapor/BI kullanıcısı kısıtlama Dynamic Data Masking DBA yönetir, kod gerektirmez
Log'larda hassas veri App Masking Serialize öncesi maskeleme