Ders 12 / 23
Parola Saklama
Parolanın geri döndürülemez biçimde saklanması: yavaş karma algoritmalarının iş ve bellek katsayıları, süre bütçesine göre parametre seçimi, saklama kaydının biçimi ve karşılaştırmanın sabit zamanlı yapılması.
İçindekiler
Önceki konu kimliğin nasıl doğrulandığını kurdu: temel şemadan oturuma, belirteçten çok etmenli doğrulamaya kadar akışların tamamı, sunucunun “bu istek gerçekten o kişiden mi geliyor” sorusuna verdiği yanıtlardı. Bu akışların hepsinin altında tek bir varsayım duruyordu — sunucu, kullanıcının bildiği bir şeyi doğrulayabiliyor. Bu konu o varsayımın kendisine bakıyor: sunucu parolayı doğrulayabilmek için ne saklıyor?
Kütüphane ödünç servisinde üyeler ve personel aynı sisteme parolayla giriyor. Sunucunun elindeki kayıt, bir gün başkasının eline geçebilir — yedek dosyası, hatalı bir dışa aktarım, ele geçirilmiş bir rapor hesabı. Parola saklamanın ölçütü bu andır: kayıt sızdığında ne kadar bilgi sızmış olur? Bu dersin tamamı, o anın maliyetini düşürmekle ilgilidir.
Doğrulanabilir Ama Geri Döndürülemez
Sunucunun parolayı doğrulayabilmesi için parolayı bilmesi gerekmez. Gereken, kullanıcının gönderdiği dizgiden aynı sonucu üretebilmektir. Bu yüzden parola düz metin olarak değil, tek yönlü karma (one-way hash) olarak saklanır: girdiden çıktıyı hesaplamak kolay, çıktıdan girdiye dönmek hesaplama açısından yapılamaz olsun.
Karma fonksiyonu kavramı Veri Yapıları kursunda tanıtılmıştı; oradaki amaç kova numarası üretmekti ve ölçüt hızdı. Parola saklamada ölçüt terse döner. Karma ne kadar hızlı hesaplanıyorsa, sızmış bir kayıt üzerinde aday parola denemek de o kadar hızlıdır. Doğrulamanın hızlı olması saldırganın işini kolaylaştırır; yavaş olması ise yalnızca giriş isteğini birkaç on milisaniye geciktirir. Bu asimetri, parola saklamanın tek tasarım kaldıracıdır.
Bu nedenle parola için genel amaçlı karma fonksiyonları değil, anahtar türetme
fonksiyonu (key derivation function) kullanılır. Bunlar iki ayarlanabilir katsayı taşır:
iş katsayısı (work factor), hesaplamanın kaç tur süreceğini; bellek katsayısı,
hesaplamanın ne kadar bellek tutacağını belirler. Belleği zorunlu kılan tasarıma bellek
sertliği (memory hardness) denir ve amacı, hesabı çok sayıda küçük işlem birimine
dağıtmayı pahalı hâle getirmektir. scrypt ve Argon2 bu sınıfın iki üyesidir; bu ders
node çalışma zamanında yerleşik olan scrypt ile ilerliyor.
Maliyetin Ölçülmesi
İki yaklaşımın farkı tartışılmaz, ölçülür. Aşağıdaki betik aynı makinede tek geçişli bir
karma ile farklı iş katsayılarındaki scrypt çağrısının doğrulama süresini karşılaştırır.
Son sütun, tek bir çekirdeğin saniyede kaç doğrulama yapabildiğidir.
cat > olcum.mjs <<'EOF' import { createHash, scryptSync, randomBytes } from "node:crypto"; const parola = "sahil-subesi-2026"; const tuz = randomBytes(16); function sure(kere, is) { is(); // isinma const basla = process.hrtime.bigint(); for (let i = 0; i < kere; i++) is(); return Number(process.hrtime.bigint() - basla) / kere / 1e6; // milisaniye } const satirlar = [ ["sha-256 (tek gecis)", "-", sure(500, () => createHash("sha256").update(tuz).update(parola).digest())], ]; for (const N of [2 ** 12, 2 ** 14, 2 ** 16, 2 ** 17]) { const r = 8, p = 1; satirlar.push([ `scrypt N=2^${Math.log2(N)} r=${r} p=${p}`, `${((128 * r * N) / 1024 / 1024).toFixed(0)} MiB`, sure(3, () => scryptSync(parola, tuz, 32, { N, r, p, maxmem: 512 * 1024 * 1024 })), ]); } console.log("algoritma | bellek | dogrulama | cekirdek basina giris/s"); console.log("-----------------------|---------|------------|------------------------"); for (const [ad, bellek, ms] of satirlar) { console.log( ad.padEnd(22) + " | " + bellek.padStart(7) + " | " + (ms < 1 ? ms.toFixed(4) : ms.toFixed(1)).padStart(7) + " ms | " + (1000 / ms).toFixed(0).padStart(22) ); } EOF node olcum.mjs
algoritma | bellek | dogrulama | cekirdek basina giris/s -----------------------|---------|------------|------------------------ sha-256 (tek gecis) | - | 0.0013 ms | 781504 scrypt N=2^12 r=8 p=1 | 4 MiB | 4.3 ms | 231 scrypt N=2^14 r=8 p=1 | 16 MiB | 17.6 ms | 57 scrypt N=2^16 r=8 p=1 | 64 MiB | 79.4 ms | 13 scrypt N=2^17 r=8 p=1 | 128 MiB | 159.5 ms | 6
Mutlak süreler makineye ve çalışma zamanı sürümüne göre değişir; buradaki değerler
node sürüm 24 ile tek bir masaüstü çekirdeğinde alınmıştır. Değişmeyen şey büyüklük
sırasıdır. Tek geçişli karma ile N=2^14 arasında yaklaşık on dört bin katlık bir fark
var. Bu fark, sızmış bir kayıt üzerinde aday deneyen tarafın maliyetiyle doğrudan
orantılıdır: hızlı karmayla saniyede yüz binlerce aday denenebilirken, yavaş karmayla
aynı donanımda onlarca aday denenebilir.
Bellek sütunu ikinci savunmayı gösterir. N=2^16 için her doğrulama 64 MiB tutar. Bu
miktar tek bir sunucu için önemsizdir; binlerce koşutlu hesap birimi üzerinde aynı işi
yapmak isteyen taraf için belirleyicidir. İş katsayısını artırmak hesabı yavaşlatır,
bellek katsayısını artırmak hesabı ucuz donanıma dağıtılamaz kılar.
İş Katsayısının Seçimi
Parametreler “ne kadar büyükse o kadar iyi” kuralıyla seçilmez. Doğrulama sunucunun kendi işidir; çok büyük bir katsayı, giriş isteklerinin sunucuyu kilitlemesine yol açar. Seçimin iki kısıtı vardır: giriş başına ayrılan süre bütçesi ve tepe yükte tutulacak bellek.
Aşağıdaki betik bu iki kısıtı birlikte uygular. Bütçe giriş başına 100 milisaniye, tepe yük saniyede yirmi giriş kabul edilmiştir; bellek sütunu, o katsayıda aynı anda yürüyecek doğrulamaların toplam tüketimidir.
cat > kalibre.mjs <<'EOF' import { scryptSync, randomBytes } from "node:crypto"; const BUTCE_MS = 100; // giris basina ayrilan sure const ESZAMANLI_GIRIS = 20; // saniyede beklenen tepe giris sayisi const tuz = randomBytes(16), r = 8, p = 1; function olc(N) { const ayar = { N, r, p, maxmem: 512 * 1024 * 1024 }; scryptSync("olcum", tuz, 32, ayar); const basla = process.hrtime.bigint(); scryptSync("olcum", tuz, 32, ayar); return Number(process.hrtime.bigint() - basla) / 1e6; } console.log(`butce: ${BUTCE_MS} ms/giris, tepe yuk: ${ESZAMANLI_GIRIS} giris/s`); console.log("N | dogrulama | tepe yukte bellek | butce"); console.log("------|-----------|-------------------|-------"); let secilen = null; for (const us of [12, 13, 14, 15, 16, 17]) { const N = 2 ** us, ms = olc(N); const bellek = (128 * r * N * Math.ceil(ms / 1000 * ESZAMANLI_GIRIS)) / 1024 / 1024; const uyar = ms <= BUTCE_MS ? "uygun" : "asildi"; if (ms <= BUTCE_MS) secilen = us; console.log(`2^${us} | ${ms.toFixed(1).padStart(6)} ms | ${bellek.toFixed(0).padStart(13)} MiB | ${uyar}`); } console.log("secilen: N=2^" + secilen); EOF node kalibre.mjs
butce: 100 ms/giris, tepe yuk: 20 giris/s N | dogrulama | tepe yukte bellek | butce ------|-----------|-------------------|------- 2^12 | 5.3 ms | 4 MiB | uygun 2^13 | 8.7 ms | 8 MiB | uygun 2^14 | 18.2 ms | 16 MiB | uygun 2^15 | 37.7 ms | 32 MiB | uygun 2^16 | 77.8 ms | 128 MiB | uygun 2^17 | 162.7 ms | 512 MiB | asildi secilen: N=2^16
Betiğin ürettiği sayılar makineye özgüdür; ürettiği karar yordamı değildir. Kural şudur: bütçeyi aşmayan en büyük katsayı seçilir ve seçim, kodun içine gömülü bir sabit değil, dağıtım yapılandırmasından okunan bir değer olur. Sunucu donanımı değiştiğinde yeniden ölçülür.
Bütçenin kendisi de bir karardır. Giriş, kullanıcının bekleyeceği bir işlemdir ve yüz milisaniye bu bekleyişte fark edilmez. Aynı hesap her istekte yapılmaz — yalnız girişte yapılır, sonrasında oturum ya da belirteç devreye girer. Doğrulamanın her API çağrısında tekrarlandığı bir kurulum, bu bütçeyi kullanılamaz hâle getirir; bu, parola karmasının API anahtarı doğrulaması yerine kullanılmaması gerektiğinin nedenidir.
Saklama Kaydının Biçimi
Kayıtta yalnız karma durmaz. Karmayı üreten algoritma ve parametreleri de saklanır; aksi hâlde parametreler yükseltildiğinde eski kayıtlar doğrulanamaz hâle gelir. Yerleşik biçim, alanları tek bir dizgide ayraçla birleştirmektir.
cat > parola.mjs <<'EOF' import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto"; const AYAR = { N: 2 ** 14, r: 8, p: 1, uzunluk: 32 }; function kaydet(parola) { const tuz = randomBytes(16); const ozet = scryptSync(parola.normalize("NFKC"), tuz, AYAR.uzunluk, AYAR); return ["scrypt", AYAR.N, AYAR.r, AYAR.p, tuz.toString("base64"), ozet.toString("base64")].join("$"); } function dogrula(parola, kayit) { const [alg, N, r, p, tuz64, ozet64] = kayit.split("$"); if (alg !== "scrypt") return false; const beklenen = Buffer.from(ozet64, "base64"); const uretilen = scryptSync(parola.normalize("NFKC"), Buffer.from(tuz64, "base64"), beklenen.length, { N: +N, r: +r, p: +p }); return timingSafeEqual(uretilen, beklenen); } const kayit = kaydet("sahil-subesi-2026"); console.log("saklanan kayit :", kayit); console.log("uzunluk :", kayit.length, "karakter"); console.log("dogru parola :", dogrula("sahil-subesi-2026", kayit)); console.log("yanlis parola :", dogrula("sahil-subesi-2025", kayit)); console.log("ikinci kayit :", kaydet("sahil-subesi-2026").slice(0, 24) + "..."); EOF node parola.mjs
saklanan kayit : scrypt$16384$8$1$/kFvW5D9IBMpOV7Sn9t0YQ==$JwrU7KOCzQiHUKCE8+Uazs9zrp1RhJgQVcYqhINzCsQ= uzunluk : 86 karakter dogru parola : true yanlis parola : false ikinci kayit : scrypt$16384$8$1$QVqPrxj...
Rastgele üretilen değerler her çalıştırmada değişir; kaydın uzunluğu ve alan düzeni değişmez. Kayıt üç bilgi taşır: hangi algoritma, hangi parametreler, hangi rastgele değer. Bu düzenin doğrudan sonucu, veritabanı sütununun sabit uzunlukta değil, biçim değişikliklerine yer bırakan genişlikte tanımlanmasıdır.
Son satır bu dersin sonraki konusuna işaret eder: aynı parola ikinci kez kaydedildiğinde
kayıt farklıdır. Bunu sağlayan randomBytes çağrısıdır ve nedeni sonraki dersin konusu.
normalize("NFKC") çağrısı ayrı bir gerekçeye dayanır. Aynı görünen bir metin farklı bayt
dizileriyle kodlanabilir; kayıt sırasında bir biçim, giriş sırasında öteki biçim gelirse
doğrulama başarısız olur. Kullanıcı için bu, “doğru parolayı yazdım ama kabul etmiyor”
durumudur. Normalleştirme kayıt ve doğrulama yollarının ikisinde birden aynı biçimde
uygulanır; yalnız birinde uygulanması sorunu çözmez, taşır.
Karşılaştırmanın Süresi
Doğrulamanın son adımı iki bayt dizisini karşılaştırmaktır ve bu adımın nasıl yazıldığı önemlidir. Erken dönen bir karşılaştırma, ilk farklı bayttan sonra durur — dolayısıyla çalışma süresi, adayın doğruyla kaç bayt boyunca örtüştüğünü ele verir.
cat > karsilastirma.mjs <<'EOF' import { randomBytes, timingSafeEqual } from "node:crypto"; const dogru = randomBytes(32); function erkenDonen(a, b) { // ilk farkta durur if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function olc(karsilastir, aday) { for (let i = 0; i < 2000; i++) karsilastir(dogru, aday); // isinma const basla = process.hrtime.bigint(); for (let i = 0; i < 200000; i++) karsilastir(dogru, aday); return Number(process.hrtime.bigint() - basla) / 200000; // nanosaniye } const ilkte = Buffer.from(dogru); ilkte[0] ^= 1; const sonda = Buffer.from(dogru); sonda[31] ^= 1; console.log("karsilastirma | ilk baytta ayrilan | son baytta ayrilan | oran"); console.log("----------------|--------------------|--------------------|------"); for (const [ad, fn] of [["erken donen", erkenDonen], ["timingSafeEqual", timingSafeEqual]]) { const a = olc(fn, ilkte), b = olc(fn, sonda); console.log( ad.padEnd(15) + " | " + (a.toFixed(1) + " ns").padStart(18) + " | " + (b.toFixed(1) + " ns").padStart(18) + " | " + (b / a).toFixed(2).padStart(5) ); } EOF node karsilastirma.mjs
karsilastirma | ilk baytta ayrilan | son baytta ayrilan | oran ----------------|--------------------|--------------------|------ erken donen | 3.4 ns | 22.4 ns | 6.60 timingSafeEqual | 32.9 ns | 30.6 ns | 0.93
Nanosaniye değerleri makineye göre değişir; oran sütunu değişmez. Erken dönen
karşılaştırmada son baytta ayrılan aday, ilk baytta ayrılandan altı kat uzun sürüyor —
yani süre, örtüşmenin uzunluğunu ölçen bir sinyaldir. timingSafeEqual her durumda tüm
baytları işlediği için oran bire yakın kalıyor.
Parolanın kendisinde bu sinyalin uzaktan kullanılabilirliği düşüktür, çünkü karşılaştırma öncesinde onlarca milisaniyelik bir karma hesabı vardır ve ağ gecikmesi sinyali gürültüye gömer. Ama aynı karşılaştırma kalıbı, karma hesabı olmayan yerlerde de kullanılır: oturum kimliği, sıfırlama belirteci, API anahtarı, imza doğrulaması. Bu yüzden kural gevşetilmez — gizli bir değerle yapılan her karşılaştırma sabit zamanlı yazılır. İstemci Güvenliği tarafında aynı kural istek belirteci karşılaştırması için konmuştu; sunucu tarafı da aynı kuralı uygular.
Parametrelerin Yükseltilmesi
Bugün seçilen katsayı, donanım ucuzladıkça yetersiz kalır. Yükseltme, kayıtta saklanan parametrelerin varlığı sayesinde kesintisiz yapılabilir: kullanıcı giriş yaptığında gönderdiği parola elde olur, doğrulama başarılıysa kayıt yeni parametrelerle yeniden üretilip yazılır. Bu, girişteki bir ek yazma işlemidir ve kullanıcıya görünmez.
Yükseltmenin sınırı şudur: hiç giriş yapmayan hesapların kayıtları eski parametrelerde kalır. Bu hesaplar için ikinci yol, eski karmanın üzerine yeni algoritmayı uygulayıp kayda iki katmanlı olduğunu yazmaktır. Kayıt biçiminin algoritma alanı taşıması, bu geçişi kayıt kaybı olmadan mümkün kılan şeydir.
Kabul edilmeyen üçüncü yol, parolaları düz metin olarak yeniden istemektir. Sistemin parolayı yeniden isteyebilmesi, onu bir yerde tutabildiği anlamına gelir; bu dersin tamamı bunun tersini kurar.
Özet
- Parola geri döndürülemez biçimde saklanır; sunucunun doğrulama için parolayı bilmesi gerekmez, aynı sonucu üretebilmesi yeterlidir.
- Genel amaçlı karma fonksiyonları parola için uygun değildir; iş ve bellek katsayısı ayarlanabilen anahtar türetme fonksiyonları kullanılır.
- İş katsayısı, giriş başına süre bütçesini aşmayan en büyük değer olarak ölçülerek seçilir; tepe yükteki bellek tüketimi ikinci kısıttır.
- Kayıt, karmanın yanında algoritmayı ve parametreleri de taşır; yükseltme başarılı girişte kaydın yeniden üretilmesiyle yapılır.
- Gizli bir değerle yapılan karşılaştırma sabit zamanlı yazılır; erken dönen karşılaştırmanın süresi örtüşme uzunluğunu ele verir.
Sonraki Adım
Bu derste her kayıt için randomBytes ile bir rastgele değer üretildi ve aynı parolanın
iki kaydı farklı çıktı. O değerin adı ve görevi henüz açıklanmadı. Sonraki ders tuzun ne
işe yaradığını, tuzsuz bir kurulumda aynı parolayı kullanan iki üyenin kaydının neden
birbirinin aynısı olduğunu ve bunun tek bir sızıntıdan kaç kullanıcı hakkında bilgi
çıkardığını gösteriyor. Aynı derste tuzdan ayrı ikinci bir değer olan biberin nerede
durduğu — veritabanının içinde değil, uygulamanın yapılandırmasında — ve bu ayrımın
sızıntı anında ne değiştirdiği ele alınıyor.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.