Yunus Emre Alpak

Az Parça, Çok Güven — Vault'un Çekirdek Mimarisi · Vault Serisi 2/3

2026 · 03 · 269 dk okuma

Yeni bir servise başlarken çoğumuzun ilk yaptığı şey, özellik listesi çıkarıp her özelliğe en iyi aracı seçmektir. Vault'ta bunu yapmadım. Çünkü bir kasanın düşmanı karmaşıklıktır — ne kadar çok hareketli parça varsa, güvenliği o kadar zor kanıtlarsın. Bu yüzden önce nelerden vazgeçtiğimi anlatacağım.

Serinin ikincisi. Birincide "neden bir kasa" sorusunu açmıştık. Şimdi "nasıl kurdum" sorusuna geliyoruz — ama güvenlik söz konusu olduğunda "nasıl kurmadım" en azından o kadar önemli.

En tehlikeli alışkanlık: güveni karmaşıklıkla ödemek

Bir kasa yazıyorsan, her yeni bileşen sadece bir operasyonel yük değil — aynı zamanda yeni bir saldırı yüzeyi ve güvenliğini kanıtlaman gereken yeni bir şey. Bir mesaj kuyruğu, bir cache, bir arama motoru, ayrı bir nesne indeksi... Her biri kendi auth'unu, kendi ağ kuralını, kendi yedeğini, kendi güncelleme döngüsünü getirir. Daha bir satır güvenlik kodu yazmadan, korunacak yüzeyini iki katına çıkarmış olursun.

Vault'ta ilk gün kendime şunu söyledim:

"Bir kasada her fazladan parça, kanıtlaman gereken fazladan bir güven varsayımıdır."

Bu cümle bir aracı reddetmek için değil, eklerken gerekçesini iki kez sormak içindi. Cevap "çünkü modern" ise reddettim. Cevap "çünkü mevcut parçalar bunu güvenle yapamıyor" ise düşündüm.

Sonuç: Vault'un çekirdeği yalnızca üç parça. Go + Fiber (API), PostgreSQL (metadata, kullanıcılar, denetim) ve MinIO (S3 uyumlu nesne deposu). State tutan iki sistem var: Postgres ve MinIO. Başka hiçbir şey kalıcı durum tutmuyor. Kasanın korunacak yüzeyi bu kadar.

Katmanlı mimari ve görünen tek composition root

Go tarafında mimari klasik ama disiplinli: model → repository → service → handler. Her katmanın tek bir işi var.

Bağımlılıkları birbirine bağlamak için Wire/Fx/Dig gibi reflective DI framework'leri var. Vault'ta hiçbirini kullanmadım. Tüm bağımlılık grafiği cmd/api/main.go içinde, elle, gözönünde kuruluyor. Bir constructor diğerine elle veriliyor; sıralama gözle takip edilebiliyor. Bir kasada bu özellikle kıymetli: "şu şifreleyici şu handler'a gerçekten bağlı mı, yoksa bir yerde NoopEncryptor mı sızıyor" sorusunun cevabı tek dosyada, tek bakışta. Güvenlik kritik bir sistemde wiring'in görünür olması, sihirli olmasından çok daha değerli.

600 MB RAM'e düşmesin: baştan sona streaming

Vault'un en erken kararlarından biri şuydu: hiçbir dosya, hiçbir noktada tamamen belleğe alınmayacak. 600 MB'lık bir APK yüklenirken sunucunun 600 MB RAM ayırması hem ölçeklenmez hem de basit bir DoS vektörü.

Fiber'ı (fasthttp) bunun için iki ayarla zorladım:

app := fiber.New(fiber.Config{
    BodyLimit:                    int(cfg.MaxFileSizeBytes()) + 10*1024*1024,
    StreamRequestBody:            true,
    DisablePreParseMultipartForm: true,
})

Böylece yükleme yolu bir boru hattına dönüşüyor: istemci → (web arayüzünde) BFF proxy → API → şifreleyici → MinIO. Her halka bir öncekinin reader'ını okuyor, byte'lar hiçbir yerde yığılmıyor. İndirme de aynı mantıkla ters yönde akıyor.

Bu kararın güzel yan etkisi: şifreleme de doğal olarak streaming olmak zorunda kaldı. Bu da beni bir sonraki bölüme götürdü.

AES-256-GCM, ama neden 64 KB'lık parçalar?

Şifreleme Vault'un kalbi. ENCRYPTION_KEY ayarlıysa (64 hex karakter = 32 bayt, AES-256), dosya MinIO'ya yazılmadan önce şifreleniyor. Anahtar yoksa düz yazılıyor — ama üretimde anahtar her zaman var. Şifreleme/çözme API tüketicisine tamamen şeffaf: yükleyen ve indiren bunun farkında bile değil.

Naif yaklaşım "tüm dosyayı oku, tek seferde gcm.Seal ile şifrele" olurdu. İki sebepten yapmadım: (1) tüm dosyayı belleğe almak demek — streaming kararının tam tersi; (2) GCM'in pratik bir mesaj boyutu sınırı var. Çözüm: dosyayı 64 KB'lık düz metin parçalarına bölmek ve her parçayı ayrı şifrelemek.

Disk formatı şöyle:

[1B sürüm][12B taban nonce]  — sonra her parça için: [4B şifreli_uzunluk][şifreli metin + GCM etiketi]

Buradaki ince nokta nonce yönetimi. GCM'de aynı anahtarla aynı nonce'u iki kez kullanmak felakettir — şifrelemenin tüm garantisi çöker. Her parçaya rastgele yeni bir nonce üretmek yerine, dosya başında tek bir rastgele taban nonce üretip her parça için bir sayaçı onun son 8 baytına XOR'luyorum:

func deriveNonce(base []byte, counter uint64) []byte {
    nonce := make([]byte, nonceSize)
    copy(nonce, base)
    for i := 0; i < 8; i++ {
        nonce[nonceSize-1-i] ^= byte(counter >> (i * 8))
    }
    return nonce
}

Böylece dosya içindeki her parçanın nonce'u garantili biricik, ama yine de deterministik bir düzende — çözerken aynı sayaçla yeniden üretiliyor. Tüm işlem bir io.Pipe üzerinden akıyor: bir goroutine okuyup şifreliyor, diğer uç MinIO'ya yazıyor.

Ve parçalı olmanın bedava gelen bir hediyesi var: GCM her parçaya bir kimlik doğrulama etiketi ekliyor. Yani çözerken sadece gizlilik değil, bütünlük de doğrulanıyor. Diskteki şifreli veriye tek bir bit dokunulsa, o parçanın gcm.Open'ı hata döndürüyor ve indirme o noktada patlıyor — sessizce bozuk veri döndürmüyor. Bir kasa için "kurcalandığında fark et" özelliği gizlilik kadar değerli.

İki tüketici, tek yetki modeli: insan ve makine

Vault'a kim erişiyor? İki tip tüketici var ve birbirinden tamamen farklı davranıyorlar:

Bu ikisini tek bir auth modelinde buluşturdum ama farklı mekanizmalarla:

Üstüne iki katmanlı rol modeli:

Ve bir failsafe admin: ADMIN_EMAIL + ADMIN_PASSWORD ortam değişkenleri, başlangıçta bir admin hesabının her zaman var olduğunu garanti ediyor. Kasanın dışında kalmak diye bir senaryo olmasın.

Buradaki ders şu sezgiye dayanıyor: tutarlılık her yerde aynı mekanizmayı kullanmak değil, her tüketiciye ihtiyacıyla aynı çizgide bir mekanizma vermektir. İnsan kısa oturum ister, makine uzun ömürlü anahtar. İki yol, tek model.

Şema kod içinde: inline migration'lar

Veritabanı şemasını ayrı bir migration aracıyla değil, internal/database/migrations.go içinde sıralı SQL blokları olarak tutuyorum. Sunucu açıldığında migration'lar otomatik çalışıyor. Şema, çekirdek şemadan (projeler, dosyalar, paylaşım linkleri) başlayıp şifreleme bayrağı, kullanıcı/yetki, denetim, proje istekleri, sürümleme, uygulama metadata'sı, çoklu dosya paylaşımı derken adım adım büyüyor — her adım yeni bir sürüm bloğu.

Bu disiplinin özü: yeni bir migration aracı (ayrı bir state-ful süreç, ayrı bir versiyon tablosu yönetimi) eklemek yerine, var olan başlangıç akışının zaten yaptığı işi kullan. Tek kullanıcılı, tek instance'lı bir kasada bu fazlasıyla yeterli ve operasyonel yükü sıfır.

Yükleme anında sır taraması

Küçük ama anlamlı bir detay: yüklenen dosyalar, yükleme anında sır taramasından geçiyor (SCAN_ENABLED, varsayılan açık; SCAN_MAX_MB ile taranacak bayt sınırlı). Niyet ironik ama bilinçli: Vault sırları kontrollü taşımak için var, kazara taşımak için değil. Bir geliştirici yanlışlıkla içinde canlı bir API anahtarı olan bir dosya yüklerse, bunu en azından görmek isteriz.

Karar verirken kullandığım filtre

Vault'un çekirdeğinde her teknik karar üç soruyu geçmek zorundaydı:

  1. Bu eklenti yeni bir saldırı yüzeyi veya kalıcı durum getiriyor mu? (Yeni state-ful sistem? Korunacak yeni bir sır mı?)
  2. Bu karar geri alınabilir mi? (Kütüphane değişimi bir saat. Şifreleme formatı değişimi bir kabus — o yüzden formatı baştan sürüm baytıyla işaretledim.)
  3. Bu eklenti üç sütundan (şifreli, yetkili, izlenebilir) birine hizmet ediyor mu? (Hayır → ekleme.)

Bu filtreden geçen kararlar geriye dönüp baktığımda hâlâ doğru duruyor. Eklemediğim şeyler — ayrı bir arama motoru, bir cache katmanı, reflective DI — eklenmiş olsaydı bugün hâlâ onların güvenlik bedelini ödüyor olacaktım.

Sonraki yazıda

Üçüncü ve son yazıda Vault'un dış dünyaya açılan yüzüne geleceğiz: paylaşım linklerinin 48 karakterden 8'e nasıl kısaldığı, bir linkin nasıl birden çok dosyayı paketlediği, aynı isimli dosyanın nasıl otomatik sürüme dönüştüğü, ve Vault'un her anlamlı eylemi nasıl bir olaya çevirip dışarı yayınladığı — henüz kimin dinlediğini bilmeden.

Ama bu yazının ana mesajı şu kalsın: basit bir kasa, karmaşık bir kasadan daha güvenlidir. Çünkü güvenliği kanıtlamak, korunacak yüzey küçüldükçe kolaylaşır. Üç parça, tek şifreleme yolu, tek yetki modeli, görünür tek wiring. Az parça, çok güven.