Digital Signature: RSA-PSS, ECDSA, Doğrulama ve İmzalı Lisans Dosyası
Doğrulayan tarafın imzalayamaması gerekiyorsa, örneğin müşterinin sunucusunda çalışan bir lisans kontrolünde, MAC değil digital signature kullanılır: private key imzalar, uygulamaya giden public key yalnız doğrular. RSA-PSS ile PKCS#1 v1.5'i, ECDSA'da nonce'un önemini, P1363 ve DER formatlarını, canonicalization sorununu, Bookstore'un keyId ile döndürülebilen imzalı lisans dosyasını ve Ed25519'un .NET'teki durumunu 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 kurumsal müşterilerinden bazıları, örneğin internet bağlantısı kısıtlı okullar, katalog ve satın alma onayı modüllerini kendi sunucularında offline çalıştırıyor. Kurulumun kaç birimde ve kaç kullanıcıyla çalışacağını bir lisans dosyası belirliyor. İlk tasarım şöyle: lisans bir JSON dosyası, yanında da JSON'un HMAC'i (anahtarlı hash ile üretilen doğrulama kodu); HMAC anahtarı uygulamanın içine gömülü, uygulama açılışta HMAC'i yeniden hesaplayıp karşılaştırıyor.
Testler geçiyor, elle değiştirilen lisans reddediliyor. Bu tasarımda neyi gözden kaçırdık? Doğrulama için gereken anahtar, lisans üretmek için gereken anahtarla aynı. Uygulamanın kurulu olduğu her sunucuda o anahtar var; bir decompiler ile onu çıkaran biri istediği kadar kullanıcıya izin veren geçerli bir lisans üretebilir. Doğrulayan tarafın imzalayamaması gerekiyor ve bu, MAC'in (Message Authentication Code; paylaşılan anahtarla üretilen doğrulama kodu) değil digital signature'ın sağladığı bir özellik. Bu yazıda RSA (en yaygın public key algoritması) ve ECDSA (elliptic curve tabanlı imza algoritması) ile imzalamayı, imza formatlarını, "hangi byte'ları imzalıyorum" sorusunu ve Bookstore'un imzalı lisans dosyasını kuruyoruz.
İmza ile MAC Arasındaki Fark
MAC'te iki taraf aynı secret'ı paylaşıyor; MAC'i üretebilen herkes doğrulayabilir, doğrulayabilen herkes üretebilir. Digital signature'da imza private key ile üretiliyor ve public key ile doğrulanıyor. Uygulamanın içine yalnız public key giriyor, private key Bookstore'da kalıyor. Bunu kodla görelim:
using ECDsa issuer = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using ECDsa shipped = ECDsa.Create();
shipped.ImportFromPem(issuer.ExportSubjectPublicKeyInfoPem());
byte[] data = "license"u8.ToArray();
byte[] signature = issuer.SignData(data, HashAlgorithmName.SHA256);
Console.WriteLine($"verify with the shipped public key: {shipped.VerifyData(data, signature, HashAlgorithmName.SHA256)}");
try
{
shipped.SignData(data, HashAlgorithmName.SHA256);
}
catch (CryptographicException ex)
{
Console.WriteLine($"sign with the shipped public key: {ex.GetType().Name}");
}
$ dotnet run --project src/Bookstore.CryptoLab -- public-key-sign
verify with the shipped public key: True
sign with the shipped public key: OpenSslCryptographicException
Uygulamaya giden public key doğruluyor ama imzalayamıyor. Exception tipinin adı Linux'ta OpenSSL'den geliyor; Windows'ta tip farklı ama yine bir CryptographicException. Serinin ilk yazısındaki tabloya dönersek, inkâr edilemezlik (non-repudiation) de buradan çıkıyor: bir lisansı yalnız private key sahibi üretebildiği için "bu lisansı biz vermedik" diyemezsin.
RSA-PSS ve PKCS#1 v1.5 İmzaları
RSA ile imzalarken de bir padding seçiyorsun. İki seçenek var: PKCS (Public-Key Cryptography Standards; RSA'nın kurallarını yazan standart dizisi) #1 v1.5 imza padding'i ve PSS (Probabilistic Signature Scheme; rastgelelik içeren, güvenliği kanıtlanmış RSA imza padding'i). Bir önceki yazıda şifreleme padding'i olarak v1.5'ten kaçınmamız gerektiğini söylemiştik; imza tarafındaki v1.5 aynı şekilde kırık değil ve JWT'deki (JSON Web Token; imzalı token biçimi) RS256 gibi yaygın algoritmalarda hâlâ kullanılıyor. Ama iki tarafı da sen kontrol ediyorsan PSS seç: tasarımı daha sağlam ve güvenlik kanıtı daha güçlü.
byte[] pss1 = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
byte[] pss2 = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
byte[] v15a = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
byte[] v15b = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
$ dotnet run --project src/Bookstore.CryptoLab -- signatures
RSA-3072 PSS : 384 bytes, two signatures equal: False
RSA-3072 PKCS#1 1.5: 384 bytes, two signatures equal: True
ECDSA P-256, 1000 signatures: IEEE P1363 lengths [64], DER lengths [69, 70, 71, 72]
P1363 signature verified as DER: False
PSS aynı veri için her seferinde farklı imza üretiyor, v1.5 ise deterministik. Bunun pratik bir sonucu var: imzayı bir cache key'i ya da "bu belge daha önce imzalandı mı" kontrolü için kullanma; PSS'te aynı belgenin iki imzası birbirine eşit değil. İkisi de RSA-3072 ile 384 byte; ECDSA'nın 64 byte'ı ile karşılaştır.
ECDSA ve Nonce'un Kritikliği
ECDSA her imza için gizli, tek kullanımlık bir rastgele sayı kullanıyor. Bu sayı iki farklı imzada tekrar ederse ya da tahmin edilebilir olursa, iki imzadan private key'in kendisi hesaplanabiliyor. 2010'da bir oyun konsolu üreticisinin firmware imzalamada bu sayıyı sabit tutması ve imzalama anahtarının bu yüzden ortaya çıkması, bu hatanın en bilinen örneği.
.NET'te bu sayıya hiç dokunmuyorsun: SignData onu platformun kripto kütüphanesine, yani CNG'ye (Windows'un kripto kütüphanesi) ya da OpenSSL'e bırakıyor. Bu yazının bir dersi de bu: ECDSA'yı kendi matematiğinle ya da "daha hızlı" diye bulduğun küçük bir kütüphaneyle yazma. Üçüncü yazıdaki GCM (AES'in kimlik doğrulamalı şifreleme modu) nonce'u gibi, burada da tek bir tekrar her şeyi bozuyor; fark, bu sefer kaybedilenin bir mesaj değil imzalama anahtarı olması.
İmza Formatları: IEEE P1363 ve DER
Bir ECDSA imzası iki sayıdan oluşuyor: r ve s. Bunları byte dizisine çevirmenin iki yaygın yolu var. IEEE (uluslararası mühendislik standartları kuruluşu) P1363 (sabit uzunluklu, iki sayıyı yan yana koyan format) P-256 için her zaman 64 byte. DER (Distinguished Encoding Rules; sertifikalardaki veri yapılarının standart ikili kodlaması) ise iki sayıyı bir yapı içine koyuyor; sayının baştaki sıfır byte'larına göre uzunluk değişiyor. Yukarıdaki çıktıda 1000 imzada P1363 hep 64 byte, DER ise 69 ile 72 byte arasında.
.NET'te DSASignatureFormat parametresiyle hangisini istediğini söylüyorsun. Varsayılan P1363; X.509 sertifikaları ve CMS (Cryptographic Message Syntax; imzalı ve şifreli mesajlar için standart yapı) DER kullanıyor, JWS (JSON Web Signature; JSON verisini imzalamak için standart format) P1363 kullanıyor. Yanlış formatla doğrulama hata vermiyor, yalnız false dönüyor; çıktının son satırı bunu gösteriyor. Başka bir sistemle imza alışverişi yapıyorsan formatı sözleşmede açıkça yaz; "imza doğrulanmıyor" hatalarının önemli bir kısmı yanlış formattan geliyor.
Canonicalization: Hangi Byte'ları İmzalıyorsun?
İmza byte'ları kapsıyor, anlamı değil. {"maxUsers":50} ile { "maxUsers": 50 } aynı veriyi taşıyor ama farklı byte'lar; birinin imzası diğerini doğrulamıyor. JSON'u imzalamanın iki yolu var. Birincisi, imzalamadan önce JSON'u kurallı bir biçime getirmek (canonicalization): alan sırası, boşluklar, sayı ve string biçimi için kesin kurallar. Bu yol hem karmaşık hem de iki tarafın aynı kuralları birebir uygulamasına bağlı. İkincisi, byte'ları olduğu gibi taşımak: imzalayan taraf JSON'u bir kez serialize ediyor, o byte'ları Base64Url ile metne çevirip imzalıyor; doğrulayan taraf aynı metni imzaya karşı kontrol ediyor ve JSON'u ancak ondan sonra parse ediyor. Hiçbir şey yeniden serialize edilmiyor.
JWS ikinci yolu kullanıyor: header.payload.signature. JWS'in ayrıntıları ve algoritma seçimi Authentication ve Authorization serisinin JWT yazısında anlatıldı. Bizim lisans formatımız aynı fikri daha küçük bir yüzeyle uyguluyor: algoritma alanı yok, çünkü algoritma sabit; böylece doğrulayıcıyı başka bir algoritmayı kabul etmeye ikna etme riski de yok.
İmzalı Lisans Dosyası
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı); lisans kodu Bookstore.Crypto library'sinin Licensing klasöründe. Lisans dört parçalı tek satırlık bir metin: format adı, imzalayan anahtarın kimliği, payload ve imza. İmza ilk üç parçanın tamamını kapsıyor, yani keyId'yi değiştirmek de imzayı bozuyor:
// Lisans metni: "BSL1.<keyId>.<payload>.<signature>", payload ve imza Base64Url.
// İmza "BSL1.<keyId>.<payload>" metnini yazıldığı gibi kapsar: hiçbir şey yeniden serialize edilmez.
public static class LicenseFormat
{
public const string Prefix = "BSL1";
internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
internal static byte[] SigningInput(string keyId, string payload) =>
Encoding.ASCII.GetBytes($"{Prefix}.{keyId}.{payload}");
}
public sealed class LicenseIssuer(ECDsa signingKey, string keyId)
{
public string Issue(LicenseClaims claims)
{
string payload = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(claims, LicenseFormat.Json));
byte[] signature = signingKey.SignData(
LicenseFormat.SigningInput(keyId, payload), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return $"{LicenseFormat.Prefix}.{keyId}.{payload}.{Base64Url.EncodeToString(signature)}";
}
}
Doğrulayıcı uygulamanın içinde çalışıyor ve yalnız public key'leri biliyor. Sıra önemli: önce format, sonra anahtar, sonra imza, en son içerik ve tarih:
public LicenseCheck Verify(string licenseText)
{
string[] parts = licenseText.Trim().Split('.');
if (parts.Length != 4 || parts[0] != LicenseFormat.Prefix)
{
return new(LicenseStatus.Malformed);
}
if (!publicKeys.TryGetValue(parts[1], out ECDsa? publicKey))
{
return new(LicenseStatus.UnknownKey);
}
byte[] signature;
try
{
signature = Base64Url.DecodeFromChars(parts[3]);
}
catch (FormatException)
{
return new(LicenseStatus.Malformed);
}
// Önce gelen byte'lar üzerindeki imza, ancak ondan sonra JSON
if (!publicKey.VerifyData(LicenseFormat.SigningInput(parts[1], parts[2]), signature,
HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
{
return new(LicenseStatus.BadSignature);
}
LicenseClaims? claims = JsonSerializer.Deserialize<LicenseClaims>(Base64Url.DecodeFromChars(parts[2]), LicenseFormat.Json);
if (claims is null)
{
return new(LicenseStatus.Malformed);
}
DateTimeOffset now = time.GetUtcNow();
if (now < claims.IssuedAt)
{
return new(LicenseStatus.NotYetValid, claims);
}
return now >= claims.ExpiresAt ? new(LicenseStatus.Expired, claims) : new(LicenseStatus.Valid, claims);
}
İmzayı JSON'dan önce doğrulamanın sebebi, imzası doğrulanmamış girdiyi parser'a hiç vermemek; parser'daki bir hata ya da beklenmedik bir alan, doğrulama öncesinde bir saldırı yüzeyi açmasın. Lab komutu bir lisans üretiyor ve altı farklı durumu deniyor. Anahtar rotasyonu için uygulamada iki public key var: bookstore-license-2026 ve bookstore-license-2027:
$ dotnet run --project src/Bookstore.CryptoLab -- license
license file: 486 chars
BSL1.bookstore-license-2026.eyJsaWNlbnNlSWQiOiJMSUMt...2YWwiXX0.wurMscyRQMXT...
payload: {"licenseId":"LIC-2026-0042","institutionId":"0197f001-0000-7000-8000-00000000a001","insti...
as issued : Valid (LIC-2026-0042, 50 users)
maxUsers 50 -> 500 : BadSignature
same claims re-serialized : BadSignature
keyId switched to 2027 : BadSignature
keyId unknown : UnknownKey
checked in October 2027 : Expired (LIC-2026-0042, 50 users)
renewed with the 2027 key : Valid (LIC-2027-0042, 50 users)
Satırları tek tek okuyalım. Kullanıcı sayısını 50'den 500'e çıkarmak imzayı bozuyor. Aynı claim'leri parse edip girintili JSON olarak yeniden serialize etmek de imzayı bozuyor; canonicalization bölümündeki sorun bu, ve formatımız onu yeniden serialize etmeyerek çözüyor. keyId'yi 2027 anahtarına çevirmek, doğru anahtar yerine yanlış anahtarla doğrulama denemesi oluyor ve reddediliyor. Süresi dolan lisans için Expired dönüyoruz ama claim'leri de veriyoruz; uygulama "lisansın 1 Eylül 2027'de doldu" gibi anlamlı bir mesaj gösterebilsin. Son satırda yeni yılın lisansı yeni anahtarla imzalanmış ve uygulamada o anahtarın public key'i zaten olduğu için kabul ediliyor.
Testler, lab'ın gösterdiği durumların yanında iki önemli şeyi daha kilitliyor: başka bir private key ile üretilmiş, aynı keyId'yi taşıyan sahte bir lisans reddediliyor ve yalnız public key ile lisans üretmeye çalışmak exception veriyor:
[Fact]
public void Signature_from_another_private_key_is_rejected()
{
using ECDsa attacker = ECDsa.Create(ECCurve.NamedCurves.nistP256);
string forged = new LicenseIssuer(attacker, "test-2026").Issue(Claims with { MaxUsers = 5000 });
Assert.Equal(LicenseStatus.BadSignature, Verify(forged).Status);
}
$ dotnet test
Test run summary: Passed!
total: 46
failed: 0
succeeded: 46
skipped: 0
duration: 3s 823ms
Ed25519 ve EdDSA: BCL'de Durum
Modern protokollerde ECDSA'nın yerini giderek Ed25519 (Curve25519 ailesinden bir eğri üzerinde tanımlı EdDSA imzası) alıyor: imza deterministik, yani yukarıdaki nonce sorunu yapı gereği yok; anahtar ve imza küçük, hızlı ve doğru gerçeklemesi daha kolay. .NET'in kendi kripto library'sinde ise Ed25519 imzası için bir class yok. Bunu yazarken .NET 10 ve yeni çıkan .NET 11 runtime'larındaki public tipleri reflection ile listeleyerek kontrol ettim: .NET 11 X25519 (Curve25519 üzerinde tanımlı key agreement) ile anahtar paylaşımını ekledi, ama EdDSA imzası iki sürümde de yok.
Ed25519 gerekiyorsa, örneğin karşı taraf yalnız onu kabul ediyorsa, üçüncü parti bir kütüphane seçmen gerekiyor. Yaygın iki seçenek: libsodium'u saran NSec.Cryptography ve tamamen managed yazılmış BouncyCastle.Cryptography (son kararlı sürümü 2.7.0, Temmuz 2026, serbest kullanıma izin veren MIT (Massachusetts Institute of Technology'nin adını taşıyan kısa açık kaynak lisansı) lisanslı). Değerlendirme ölçütleri ikinci yazıdaki Argon2id seçimiyle aynı: native bağımlılık mı managed kod mu, bakımın sürekliliği, güvenlik açığı geçmişi ve bağımlılık taramasına dahil edilmesi. İki tarafı da sen kontrol ediyorsan P-256 ile ECDSA hiçbir ek bağımlılık gerektirmeden her platformda çalışıyor; Bookstore'un lisansı için seçimimiz bu.
Üretimde Dikkat
İmzalama private key'i bu sistemin en değerli parçası: sızarsa herkes istediği lisansı üretebilir ve bunu geri almanın tek yolu bütün kurulumlara yeni bir public key dağıtmak. Bu yüzden private key'i lisans üreten servisin dışına hiç çıkarma; mümkünse bir HSM (Hardware Security Module; anahtarı dışarı vermeden imzalayan donanım) ya da Key Vault'un imzalama işlemini kullan. Lisans sayısı az olduğu için imzalamanın birkaç saniye sürmesi sorun değil.
Anahtar rotasyonunu baştan tasarla. Uygulama bir sonraki yılın public key'ini şimdiden taşırsa, eski anahtar emekliye ayrıldığında kurulumları güncellemek gerekmiyor; yalnız yeni lisanslar yeni anahtarla imzalanıyor. Bir anahtar sızarsa ise o keyId'yi reddeden bir güncelleme yayınlaman gerekiyor; offline kurulumlarda bu güncellemenin ulaşması zaman alacağı için sızıntı riskini baştan düşük tut.
Offline sistemde zaman güvenilir değil: sunucunun saatini geri alan biri süresi dolmuş lisansı geçerli gösterebilir. Bunu tamamen engellemek mümkün değil, ama zorlaştırabilirsin: uygulamanın gördüğü en ileri tarihi saklayıp saat bundan geri giderse uyarı vermek gibi. Benzer şekilde imza lisansın bütünlüğünü korur, uygulamanın kendisini korumaz; uygulamanın binary'sini değiştirip lisans kontrolünü atlayan biri imzayla durdurulamaz. Lisans imzasının amacı dürüst müşterinin kazara ya da bilerek lisansı değiştirmesini önlemek ve sözleşmeyi teknik olarak yansıtmak; tersine mühendisliğe karşı tek başına bir savunma değil.
Doğrulama sonucunu logla ama lisansın tamamını değil: LicenseId, keyId ve sonuç yeterli. BadSignature bir kurulumda tekrar tekrar görülüyorsa ya dosya bozuk ya da biri lisansı değiştirmeye çalışıyor; destek ekibinin bunu bilmesi gerekiyor.
Sık Düşülen Tuzaklar
Birinci tuzak, Önce Tahmin Et'teki: doğrulama için simetrik bir anahtarı uygulamanın içine gömmek. Doğrulayan taraf güvenilir değilse, örneğin müşterinin sunucusunda çalışıyorsa, MAC değil imza kullan.
İkincisi, imzayı doğrulamadan önce içeriği kullanmak: lisanstaki expiresAt'e bakıp "süresi dolmuş, imzaya bakmaya gerek yok" demek bile yanlış sırada bir karar. Her zaman önce imza.
Üçüncüsü, imzalanan byte'ları yeniden üretmek: JSON'u parse edip tekrar serialize ederek doğrulamak. Alan sırası, boşluk ya da sayı biçimi değiştiği anda doğru lisans reddedilir; daha kötüsü, iki farklı parser'ın farklı yorumladığı bir JSON'da imzalanan ile kullanılan şey farklı olabilir.
Dördüncüsü, imza formatını karıştırmak: DER bekleyen bir sisteme P1363 imza göndermek ya da tersi. Hata yerine false aldığın için sorun "imza geçersiz" gibi görünür; formatı kodda ve dokümanda açıkça yaz.
Beşincisi, algoritmayı girdiden okumak. Lisans metninde "hangi algoritmayla doğrula" diye bir alan olsaydı, saldırgan o alanı daha zayıf bir algoritmaya ya da bir MAC'e çevirmeyi deneyebilirdi; JWT dünyasında bu sınıfın bilinen örnekleri var. Algoritmayı anahtarla birlikte sabitle; keyId yalnız hangi public key'in kullanılacağını seçsin.
Küçük Bir Deneme
★☆☆ LicenseIssuer'ı DER formatıyla imzalayacak, LicenseVerifier'ı ise P1363 bekleyecek şekilde değiştir. Hangi test kırıldı ve verdiği sonuç BadSignature mı, exception mı?
★★☆ Lisans doğrulamasına "geriye giden saat" kontrolü ekle: doğrulayıcı gördüğü en ileri zamanı bir dosyada saklasın ve TimeProvider'ın verdiği zaman bundan 24 saatten fazla gerideyse yeni bir ClockRolledBack durumu dönsün. Bunu bir testle göster.
★★★ Aynı lisans formatını RSA-PSS ile çalışacak şekilde genelleştir: keyId'ye göre doğru doğrulayıcıyı seçen bir yapı kur (P-256 ECDSA için bir, RSA-3072 PSS için bir), ama algoritmayı lisans metninden değil, uygulamaya gömülü keyId → algoritma eşlemesinden okusun. Bir RSA anahtar kimliğine ECDSA imzası koyan bir lisansın reddedildiğini test et.
Bu yazıyla serinin temel primitive'leri tamamlandı: hash ve MAC, simetrik şifreleme, asymmetric şifreleme ve key agreement, digital signature. Bir sonraki yazıda bu public key'lerin kime ait olduğunu kanıtlayan yapıya geçiyoruz: X.509 sertifikaları, chain doğrulaması ve ASP.NET Core'da mTLS.