İçeriğe geç
academia.sh

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.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat