Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #20

node:crypto ile Günlük Kriptografi: randomUUID, Hash, HMAC, scrypt ile Parola, timingSafeEqual ve Web Crypto

Mert Özen 15 Kas 2026 14 dk 6 görüntülenme
node:crypto ile Günlük Kriptografi: randomUUID, Hash, HMAC, scrypt ile Parola, timingSafeEqual ve Web Crypto

Node.js'te webhook imzası ham body üzerinden createHmac ile hesaplanıp timingSafeEqual ile karşılaştırılır, parolalar scrypt ya da yerleşik Argon2id ile saklanır, hassas alanlar her seferinde yeni IV'li AES-256-GCM ile şifrelenir. Gerçek çıktılarla JSON.stringify yüzünden tutmayan imza, saniyede iki milyonu aşan SHA-256'ya karşı 169 ms'lik scrypt, 16 eşzamanlı girişte 622 MB bellek ve sabit IV'den anahtarsız çözülen telefon numarası.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS; OpenSSL ile gelen node:crypto) ve TypeScript 7.0.2 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ödeme sağlayıcısı, bir ödeme tamamlandığında API'ye bir webhook gönderiyor ve gövdeyi paylaşılan bir gizli anahtarla imzalıyor: HMAC (gizli bir anahtarla hesaplanan hash; mesajın değişmediğini ve gönderenin anahtarı bildiğini gösterir). Bizim handler'ımız imzayı express.json()'ın parse ettiği req.body üzerinden yeniden hesaplıyor:

import { createHmac } from "node:crypto";

const secret = "test-webhook-secret-not-real";

// Ödeme sağlayıcısı tam olarak gönderdiği byte'ları imzalıyor (boşluklar dahil)
const rawBody = '{"id": "pay-evt-1001", "type": "payment.succeeded", "orderId": "1001", "amountMinor": 34990, "currency": "TRY"}';
const sent = createHmac("sha256", secret).update(rawBody).digest("hex");

// Bizim handler req.body'yi express.json()'dan alıyor ve tekrar imzalıyor
const parsed = JSON.parse(rawBody);
const ours = createHmac("sha256", secret).update(JSON.stringify(parsed)).digest("hex");

console.log(sent === ours ? "signature OK" : "signature mismatch");

Gövde de anahtar da aynı. İmzalar eşleşir mi?

signature mismatch

Eşleşmedi. Sağlayıcı, gönderdiği byte'ları imzaladı; o byte'larda iki nokta üst üsteden sonra boşluk var. JSON.stringify aynı object'i boşluksuz yazdı ve tek bir byte farkı HMAC'i tamamen değiştirdi. JSON'un key sırası, sayıların yazılışı ve Unicode kaçışları da aynı sorunu çıkarır. Doğru olan, imzayı istekle gelen ham byte'lar üzerinden doğrulamak. Bu yazıda node:crypto ile bir backend'in her gün ihtiyaç duyduğu işleri yapıyoruz: rastgele değer üretmek, hash ve HMAC, webhook imzası, parola saklamak ve tek bir alanı şifrelemek. Kavramların kendisi Geliştirici için Kriptografi serisinin 02. ve 03. yazılarında; burada Node tarafındaki karşılıklarını görüyoruz.

Rastgele Değerler: randomUUID, randomBytes ve randomInt

Token, parola sıfırlama kodu ya da sipariş numarası üretirken Math.random() kullanılmaz: V8'in (Node'un JavaScript engine'i) bu fonksiyonu hızlı bir sayı üreteci, ama çıktısı tahmin edilebilir. node:crypto'daki fonksiyonlar işletim sisteminin kriptografik rastgele sayı üretecini kullanıyor:

import { randomBytes, randomInt, randomUUID, randomUUIDv7 } from "node:crypto";

// Bunların hepsi işletim sisteminin kriptografik rastgele sayı üretecinden gelir; Math.random() gelmez
console.log("randomUUID   ", randomUUID());
console.log("randomUUIDv7 ", randomUUIDv7(), randomUUIDv7());
console.log("token        ", randomBytes(32).toString("base64url"));
console.log("order code   ", randomInt(100_000, 1_000_000));
randomUUID    e26efb07-954d-4549-854e-cb11a0bab4c5
randomUUIDv7  01a1de25-2f15-7344-8943-393df98cc1be 01a1de25-2f15-7644-9723-182a624bfa27
token         PAspyu04GR1QsTSuwuXARrAX1F607U9mFJvbuKoIAaM
order code    725815

randomUUID 4. sürüm UUID (128 bitlik benzersiz kimlik) üretiyor; .NET'teki Guid.NewGuid(). 26.1 ile gelen randomUUIDv7 ise ilk 48 bitine milisaniye cinsinden zamanı koyuyor; iki değer aynı önekle başlıyor, bu yüzden veritabanında primary key olarak kullanıldığında index'e sırayla ekleniyor (.NET 9'daki Guid.CreateVersion7()). Dokümana göre içindeki saat monoton değil, yani sıranın her zaman kesin olduğuna güvenme. randomBytes(32).toString("base64url") URL'de kullanılabilen 256 bitlik bir token; randomInt ise belirli bir aralıkta, modulo hatası olmadan eşit dağılımlı bir sayı veriyor.

Hash ve HMAC

createHash("sha256") bir veriden sabit uzunlukta bir parmak izi üretir: dosyanın değişip değişmediğini anlamak, cache key'i ya da ETag üretmek için. Tek seferlik işler için 25.5'ten beri stable olan crypto.hash("sha256", data) daha kısa. Hash'in bir anahtarı yok; veriyi değiştiren biri hash'i de yeniden hesaplayabilir. Bu yüzden "bu mesajı anahtarı bilen biri gönderdi" demek için HMAC gerekir: createHmac("sha256", secret). .NET'teki karşılıkları SHA256.HashData ve HMACSHA256.HashData.

Bookstore: Ödeme Webhook'unun İmzasını Doğrulamak

Sağlayıcı imzayı x-payment-signature: t=<unix-time>,sig=<hex> header'ında gönderiyor; imza `${t}.${body}` üzerinden hesaplanıyor. Zamanı imzaya katmak, eski bir isteğin olduğu gibi tekrar gönderilmesini (replay) engellemenin yolu:

// Ödeme sağlayıcısının gönderdiği header: "t=<unix saniye>,sig=<`${t}.${ham gövde}`'nin hex HMAC-SHA256'sı>"
export const SIGNATURE_HEADER = "x-payment-signature";
const TOLERANCE_SECONDS = 300;

export type SignatureCheck = { ok: true } | { ok: false; reason: "missing" | "malformed" | "expired" | "mismatch" };

export function signPayload(rawBody: Buffer, secret: string, timestamp: number): string {
    const sig = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
    return `t=${timestamp},sig=${sig}`;
}

// İmzayı gelen byte'ların kendisi üzerinden doğrular, asla yeniden serialize edilmiş JSON üzerinden değil
export function verifyWebhookSignature(rawBody: Buffer, header: string | undefined, secret: string, nowSeconds = Math.floor(Date.now() / 1000)): SignatureCheck {
    if (!header) {
        return { ok: false, reason: "missing" };
    }
    const match = /^t=(\d{1,12}),sig=([0-9a-f]{64})$/.exec(header);
    if (!match) {
        return { ok: false, reason: "malformed" };
    }
    const timestamp = Number(match[1]);
    // Eski ama geçerli bir imza, tekrar gönderilmiş bir istektir: yalnız birkaç dakikalık saat farkını kabul et
    if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) {
        return { ok: false, reason: "expired" };
    }
    const expected = Buffer.from(signPayload(rawBody, secret, timestamp).split("sig=")[1], "hex");
    const received = Buffer.from(match[2], "hex");
    // Sabit süre: karşılaştırma ilk byte yanlışken de son byte yanlışken de aynı süreyi alır
    return timingSafeEqual(expected, received) ? { ok: true } : { ok: false, reason: "mismatch" };
}

Karşılaştırmayı === ile değil timingSafeEqual ile yapıyoruz. === ilk farklı karakterde durur; saldırgan, cevap sürelerini ölçerek doğru imzayı byte byte tahmin etmeye çalışabilir. timingSafeEqual her zaman bütün byte'ları karşılaştırır. Tek şartı, iki buffer'ın aynı uzunlukta olması:

ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH: Input buffers must have the same byte length

Bu yüzden kod, header'ı önce regex ile 64 hex karaktere sabitliyor; uzunluk zaten bilinen bir değer ve karşılaştırma hiçbir zaman exception fırlatmıyor. Route'un kendisi 18'deki kargo endpoint'inden farklı olarak bearer token istemiyor; kimlik doğrulama imzanın kendisi:

// POST /api/payments/webhook: bearer token yok; kimlik doğrulama HMAC imzasının kendisi
export function paymentWebhookRouter(secret: string, handle: PaymentEventHandler): Router {
    // express.raw body'yi Buffer olarak tutar: imza parse edilmiş object'i değil byte'ları kapsar
    return Router().post("/", express.raw({ type: "application/json", limit: "64kb" }), async (req, res) => {
        const body: Buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
        const check = verifyWebhookSignature(body, req.get(SIGNATURE_HEADER), secret);
        if (!check.ok) {
            logger.warn({ reason: check.reason }, "webhook signature rejected");
            throw new HttpError(401, "invalid webhook signature");
        }
        let json: unknown;
        try {
            json = JSON.parse(body.toString("utf8"));
        } catch {
            throw new HttpError(400, "webhook body is not valid JSON");
        }
        const parsed = paymentEventSchema.safeParse(json);
        if (!parsed.success) {
            throw new HttpError(400, "unexpected webhook payload");
        }
        const isNew = await handle(parsed.data);
        // Tekrar gelenlere de 200: sağlayıcı elimizde olan bir event'i göndermeyi bırakmalı
        res.status(200).json({ received: true, duplicate: !isNew });
    });
}

Bu router app.ts'te express.json()'dan önce kayıtlı. Sıra önemli: express.json() önce çalışsaydı body stream'ini okuyup tüketirdi ve ham byte'lar kaybolurdu. Aynı event iki kez gelirse 200 dönüyoruz ama ikinci kez işlemiyoruz; sağlayıcılar teslimatı garanti etmek için webhook'ları tekrar gönderir. Event id'leri şimdilik bellekte, production'da benzersiz key'li bir tabloda tutulmalı. Testler:

✔ accepts a signed event once and reports the retry as a duplicate (12.441125ms)
✔ rejects a changed body, a wrong secret and an old timestamp (3.429583ms)
✔ round-trips and never produces the same ciphertext twice (0.813375ms)
✔ fails for another row's associated data (0.242167ms)
✔ rejects keys that are not 32 bytes (0.084291ms)
✔ verifies the right password and rejects a wrong one (498.916042ms)
✔ uses a new salt every time (316.726166ms)
✔ treats composed and decomposed Turkish letters as the same password (320.82675ms)
✔ asks for a re-hash of hashes made with weaker parameters (0.180167ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0

Parolalar: scrypt, Argon2 ve Neden SHA-256 Değil

Parolayı düz metin olarak saklamak yanlış, ama hızlı bir hash ile saklamak da neredeyse aynı derecede yanlış. Veritabanı çalınırsa saldırgan, sözlükteki her kelimenin hash'ini hesaplayıp karşılaştırır. Ne kadar hızlı deneyebildiğini ölçtük (tek CPU çekirdeği, bu makinede):

md5                                    0.0004 ms/hash    2,326,128 hashes/s
sha1                                   0.0004 ms/hash    2,512,878 hashes/s
sha256                                 0.0004 ms/hash    2,313,792 hashes/s
scrypt N=2^14 r=8 p=1 (default)       20.6062 ms/hash           49 hashes/s
scrypt N=2^17 r=8 p=1 (OWASP)        168.7707 ms/hash            6 hashes/s
argon2id m=19 MiB t=2 p=1 (OWASP)     11.1777 ms/hash           89 hashes/s

md5, sha1 ve sha256 tek çekirdekte saniyede iki milyonu aşıyor; GPU'da (ekran kartı işlemcisi) bu sayı katlanarak artar. Parola hash'leri bilerek yavaş ve bellek tüketen fonksiyonlar. OWASP'ın (web güvenliği üzerine açık kaynak çalışmalar yürüten vakıf) Password Storage Cheat Sheet'i ilk tercih olarak Argon2id'yi (en az 19 MiB bellek, 2 tur), o yoksa scrypt'i (N=2^17, r=8, p=1) öneriyor. İki not: Node 24.7'den beri crypto.argon2() yerleşik, artık native bir npm paketine gerek yok. scrypt'in Node'daki varsayılan ayarları (N=2^14, r=8, p=1) ise OWASP'ın aynı N için istediği p=5'in altında. ASP.NET Core Identity'nin PasswordHasher'ı PBKDF2 (parola tabanlı anahtar türetme fonksiyonu) kullanıyor; iterasyon sayısı yüksek tutulduğunda bu da kabul edilen bir seçenek.

Bookstore'da scrypt'i OWASP ayarıyla kullanıyoruz ve ayarları hash'in yanına yazıyoruz:

// OWASP Password Storage Cheat Sheet: N=2^17 (128 MiB), r=8, p=1 ile scrypt
const CURRENT = { logN: 17, r: 8, p: 1 };
const KEY_LENGTH = 32;

function scryptAsync(password: string, salt: Buffer, options: ScryptOptions): Promise<Buffer> {
    return new Promise((resolve, reject) =>
        scrypt(password, salt, KEY_LENGTH, options, (err, key) => (err ? reject(err) : resolve(key))),
    );
}

function options(logN: number, r: number, p: number): ScryptOptions {
    const N = 2 ** logN;
    // Varsayılan maxmem 32 MiB; scrypt yaklaşık 128 * N * r byte ister
    return { N, r, p, maxmem: 256 * N * r };
}

// PHC tarzı string: parametreler hash'le birlikte saklanır, böylece sonradan artırılabilir
export async function hashPassword(password: string): Promise<string> {
    const salt = randomBytes(16);
    const { logN, r, p } = CURRENT;
    const key = await scryptAsync(password.normalize("NFC"), salt, options(logN, r, p));
    return `$scrypt$ln=${logN},r=${r},p=${p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
}

const STORED = /^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$([\w-]+)\$([\w-]+)$/;

export async function verifyPassword(password: string, stored: string): Promise<boolean> {
    const match = STORED.exec(stored);
    if (!match) {
        return false;
    }
    const [, logN, r, p, salt, hash] = match;
    const expected = Buffer.from(hash, "base64url");
    const actual = await scryptAsync(password.normalize("NFC"), Buffer.from(salt, "base64url"), options(Number(logN), Number(r), Number(p)));
    return expected.length === actual.length && timingSafeEqual(expected, actual);
}

// Saklanan hash bugünkünden zayıf parametrelerle üretildiyse true: bir sonraki girişte tekrar hash'le
export function needsRehash(stored: string): boolean {
    const match = STORED.exec(stored);
    return !match || Number(match[1]) < CURRENT.logN || Number(match[2]) < CURRENT.r || Number(match[3]) < CURRENT.p;
}

Saklanan değer PHC (Password Hashing Competition'ın önerdiği) biçimine benziyor: $scrypt$ln=17,r=8,p=1$<salt>$<hash>. Her parola kendi rastgele salt'ını alıyor; aynı parolayı kullanan iki müşterinin hash'i farklı. Ayarlar string'in içinde olduğu için yarın N'yi artırdığında eski hash'ler doğrulanmaya devam ediyor; needsRehash true dönerse kullanıcının bir sonraki girişinde parolayı yeni ayarla tekrar hash'liyorsun. normalize("NFC") ise aynı görünen Türkçe karakterin iki farklı Unicode yazımının aynı parola sayılmasını sağlıyor; bunu bir testle doğruladık.

Bu ayarın bir maliyeti var. 16 kullanıcı aynı anda giriş yaptığında:

// Aynı anda 16 giriş: süreye, belleğe ve event loop'a ne olur?
const stored = await hashPassword("correct horse battery staple");
const delay = monitorEventLoopDelay({ resolution: 10 });
let peak = 0;
const sampler = setInterval(() => (peak = Math.max(peak, process.memoryUsage.rss())), 10);
delay.enable();
const started = performance.now();
const results = await Promise.all(Array.from({ length: 16 }, () => verifyPassword("correct horse battery staple", stored)));
const elapsed = performance.now() - started;
delay.disable();
clearInterval(sampler);
console.log(`16 verifications: all ${results.every(Boolean)}, ${Math.round(elapsed)} ms, peak RSS ${Math.round(peak / 1e6)} MB, max event loop delay ${Math.round(delay.max / 1e6)} ms`);
16 verifications: all true, 739 ms, peak RSS 622 MB, max event loop delay 12 ms

Event loop neredeyse hiç beklemedi: scrypt'in async sürümü 06'da gördüğümüz libuv thread pool'unda çalışıyor. Ama her hash 128 MiB bellek istiyor ve dört thread aynı anda çalıştığı için RSS (process'in fiziksel bellekte kapladığı alan) 622 MB'a çıktı. UV_THREADPOOL_SIZE=8 ile süre 543 ms'ye indi, RSS 1159 MB'a çıktı. Container'a bellek sınırı koyarken bu sayıyı hesaba kat; scryptSync ise asla kullanma, çünkü her çağrı event loop'u 170 ms durdurur.

AES-256-GCM ile Alan Şifreleme

Kurumsal Sipariş'te teslimat için müşterinin telefon numarasını saklıyoruz. Veritabanı yedeği yanlış ellere geçse bile bu alanın okunmasını istemiyoruz. Bunun için AES (Advanced Encryption Standard; en yaygın simetrik şifreleme algoritması) ve GCM (şifrelemeyle birlikte bütünlük kontrolü de yapan çalışma modu) kullanıyoruz:

// associatedData ciphertext'i kendi satırına bağlar: başka bir müşteriye kopyalanan değer çözülemez
export function encryptField(plaintext: string, key: Buffer, associatedData: string): string {
    // Her değer için yeni, rastgele 96 bitlik bir IV; aynı anahtarla bir IV'yi asla tekrar kullanma
    const iv = randomBytes(12);
    const cipher = createCipheriv("aes-256-gcm", key, iv);
    cipher.setAAD(Buffer.from(associatedData, "utf8"));
    const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
    return [VERSION, ...[iv, ciphertext, cipher.getAuthTag()].map((part) => part.toString("base64url"))].join(".");
}

export function decryptField(value: string, key: Buffer, associatedData: string): string {
    const [version, iv, ciphertext, tag] = value.split(".");
    if (version !== VERSION || !iv || !ciphertext || !tag) {
        throw new Error("not an encrypted field value");
    }
    const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url"));
    decipher.setAAD(Buffer.from(associatedData, "utf8"));
    decipher.setAuthTag(Buffer.from(tag, "base64url"));
    // Ciphertext'in, tag'in ya da associated data'nın tek bir biti değiştiyse final() hata fırlatır
    return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64url")), decipher.final()]).toString("utf8");
}
stored:    v1.mqSBB8NPcwl3E4jd.9WDgB_QYMjxkiZpkoosI5iE.BdJ-TBLRgZA9rzFq149Z3A
decrypted: +90 500 000 00 01
again:     v1.EBeXUGZaYluxlOJk.vtQ-Hm9Bygc2wV1i2klGsHE.nfLdlhqJgioYfXUHxiNKyw
tampered ciphertext: Unsupported state or unable to authenticate data
copied to alan     : Unsupported state or unable to authenticate data

Aynı numara iki kez şifrelendi, iki farklı değer çıktı: her şifrelemede yeni bir IV (initialization vector; her şifrelemede değişmesi gereken başlangıç değeri) kullanılıyor. Ciphertext'te tek bir karakter değiştiğinde final() hata fırlattı; GCM'in authentication tag'i bunu yakalıyor. associatedData ise şifrelenmeyen ama imzaya katılan bir değer; satırın kimliğini buraya koyunca Ada'nın şifreli numarası Alan'ın satırına kopyalanırsa çözülemiyor.

GCM'in en tehlikeli hatası, aynı anahtarla aynı IV'yi iki kez kullanmak. Bunu bir config dosyasındaki sabit bir IV ile deneyelim:

// Hata: her değer için sabit tek bir IV (örneğin config dosyasındaki bir sabit)
const key = randomBytes(32);
const fixedIv = Buffer.alloc(12, 7);
function encrypt(text: string): Buffer {
    const cipher = createCipheriv("aes-256-gcm", key, fixedIv);
    return Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
}

const adaPhone = encrypt("+90 500 000 00 01");
const alanPhone = encrypt("+90 500 999 12 34");

// GCM bir key stream ile şifreler (CTR modu): aynı anahtar + aynı IV = aynı key stream.
// İki ciphertext'in XOR'u = iki düz metnin XOR'u; birini bilmek diğerini açığa çıkarır
const knownAda = Buffer.from("+90 500 000 00 01", "utf8");
const recovered = Buffer.from(adaPhone.map((byte, i) => byte ^ alanPhone[i] ^ knownAda[i]));
console.log("recovered without the key:", recovered.toString("utf8"));
recovered without the key: +90 500 999 12 34

Anahtar hiç kullanılmadan Alan'ın numarası çıktı. GCM aslında CTR (sayaç modu) üzerine kurulu; aynı anahtar ve IV aynı key stream'i üretiyor ve iki ciphertext'in XOR'u (bit bit "ya biri ya öteki" işlemi) iki düz metnin XOR'una eşit oluyor. Kural basit: IV her şifrelemede randomBytes(12) ile üretilir ve ciphertext'in yanında saklanır, gizli değildir. Rastgele IV ile tek bir anahtarla yapılabilecek şifreleme sayısının da bir sınırı var (yaklaşık 2^32); Geliştirici için Kriptografi serisinin 03. yazısı bu sınırı ve anahtar değiştirmeyi anlatıyor. Anahtarın kendisi kodda ya da repo'da değil, configuration'dan ve tercihen bir secret store'dan gelir; parseKey 32 byte olmayan bir anahtarı başlangıçta reddediyor.

node:crypto mu, Web Crypto mu?

Node'da iki kriptografi API'si var. node:crypto Node'a özgü, çoğu fonksiyonu senkron ve geniş. globalThis.crypto.subtle ise tarayıcıdaki Web Crypto API'si: async, daha dar, ama aynı kod tarayıcıda, Deno'da, Bun'da ve edge runtime'larda çalışıyor. Aynı HMAC'i ikisiyle hesaplayalım:

const secret = "test-webhook-secret-not-real";
const payload = '1763190000.{"id":"pay-evt-1001","type":"payment.succeeded"}';

// node:crypto: senkron, yalnız Node
const viaNode = createHmac("sha256", secret).update(payload).digest("hex");

// Web Crypto (globalThis.crypto.subtle): async, tarayıcılarda, Deno'da, Bun'da ve edge runtime'larda aynı API
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const viaWeb = Buffer.from(signature).toString("hex");

console.log(viaNode === viaWeb ? "same signature" : "different");
// verify() karşılaştırmayı senin yerine sabit sürede yapar
console.log("verify:", await crypto.subtle.verify("HMAC", key, Buffer.from(viaNode, "hex"), encoder.encode(payload)));
same signature
verify: true

Sonuç aynı. Sunucu kodunda node:crypto daha pratik (scrypt ve Argon2 Web Crypto'da yok); kodu tarayıcıyla ya da başka bir runtime'la paylaşacaksan Web Crypto. Web Crypto'nun verify()'ı karşılaştırmayı kendisi sabit sürede yapıyor.

Üretimde Dikkat

Gizli anahtarlar (webhook secret, alan şifreleme anahtarı) 14'teki configuration ile, ortam değişkeninden ya da bir secret store'dan gelir; loadConfig PAYMENT_WEBHOOK_SECRET yoksa API'yi başlatmıyor. Log'a asla yazılmaz; 14'teki pino redaction'ına bu alanları da ekle.

Anahtar rotation'ını baştan düşün. Şifreli değerdeki v1 öneki ve parola hash'indeki parametreler bunun için var: yeni anahtar ya da yeni ayar geldiğinde eski değerler okunmaya devam eder, yenileri yeni ayarla yazılır.

Parola hash'inin maliyetini kapasite planına kat. Giriş endpoint'ine 15'teki rate limit'i mutlaka uygula; yoksa saldırgan senin CPU'nu ve belleğini senin aleyhine kullanır.

Sık Düşülen Tuzaklar

Math.random() ile token üretmek. Tahmin edilebilir; randomBytes ya da randomUUID kullan.

İmzayı === ile karşılaştırmak. Zamanlama farkı bilgi sızdırır; timingSafeEqual kullan ve uzunlukları önceden sabitle.

İmzayı parse edilmiş JSON üzerinden doğrulamak. Ham byte'ları kullan; route'u express.json()'dan önce kaydet.

Parolayı md5, sha1 ya da salt'sız sha256 ile saklamak. scrypt ya da Argon2id kullan.

Sabit IV ile AES-GCM. Bir kez tekrar etmek, iki değeri birden açığa çıkarabilir.

Küçük Bir Deneme

★☆☆ labs/20/guess.ts'i, imzayı rawBody üzerinden hesaplayacak şekilde düzelt ve sonucu timingSafeEqual ile karşılaştır.

★★☆ passwords.ts'e Argon2id desteği ekle: yeni hash'ler $argon2id$v=19$m=19456,t=2,p=1$... biçiminde yazılsın, eski scrypt hash'leri doğrulanmaya devam etsin ve needsRehash onlar için true dönsün.

★★★ fieldEncryption.ts'e key rotation ekle: değerin önekinde anahtarın kimliği olsun (v1.k2.), çözme işlemi eski anahtarları da tanısın, yeni değerler hep güncel anahtarla yazılsın. Eski anahtarla şifrelenmiş bir değeri okuyup yeniden yazan bir test yaz.

Sıradaki yazıda CPU'yu yoran işlere dönüyoruz: worker_threads, transferable'lar ve Piscina ile event loop'u bloklamadan büyük bir katalog dosyasını işleyeceğiz.