Anıl Şenocak Anıl Şenocak
Yazılım Mühendisi
Cover image
2026-07-12 0 toplam yorum
Spring Boot'ta Oracle `DBMS_CRYPTO` ile Sütun Düzeyinde Şifreleme / Şifre Çözme

Çoğu "veri tabanı şifrelendi" hikayesi Şeffaf Veri Şifreleme (TDE - Transparent Data Encryption) aşamasında durur; veri dosyaları, redo log'ları ve yedekler disk üzerinde şifrelenir, böylece ham .dbf dosyalarını veya bir teyp yedeğini çalan bir saldırgan yalnızca şifreli metin elde eder. Bu gereklidir ancak yeterli değildir. Bir DBA, salt okunur erişime sahip bir analist, bir servis hesabını oltalama (phishing) yoluyla ele geçirmiş bir saldırgan ya da SELECT * sorgusu çalıştıran yetkisiz bir araç veri tabanına bir bağlantı açtığı anda, TDE onlar için her şeyi zaten çözmüştür. Tüm "şifreleme" sınırı sütunda değil, depolama katmanında yer alır.

Düzenlemeye tabi veriler için (KVKK/GDPR kapsamındaki T.C. kimlik numaraları, telefon numaraları, adresler; PCI-DSS kapsamındaki kart sahibi verileri; HIPAA kapsamındaki PHI), bu açık kritik önem taşır: En yaygın veri sızıntısı yolu ham disk hırsızlığı değil, meşru bir veri tabanı bağlantısı üzerinden gerçekleşen sızıntılardır. Bunun çözümü sütun düzeyinde şifrelemedir (column-level encryption); yani firstname / lastname sütunlarında fiziksel olarak saklanan baytlar şifreli metindir ve şifre çözme işlemi yalnızca doğru anahtar sunulduğunda gerçekleşir. SELECT firstname FROM users sorgusunu çalıştıran bir DBA, Anıl değerini değil, HEXTORAW('A8C1...') değerini görür.

GitHub - senocak/Spring-Oracle-Column-Level-Encryption-Decryption: Spring Oracle Column Level

Bunu yapmanın ilkel yolu, uygulamada şifreleme yapmaktır: her repository.save() işleminden önce bir crypto.encrypt(...) yardımcı fonksiyonunu ve her findBy*() işleminden sonra bir crypto.decrypt(...) fonksiyonunu çağırmak. Bu yaklaşım basit CRUD işlemleri için çalışsa da hızlıca çıkmaza girer:

  • Her sorgu yolunda bunu hatırlamak gerekir. Yerel (native) sorgular, raporlama işleri, toplu içe aktarıcılar (batch importers), tek seferlik JdbcTemplate kullanımları, ham EntityManager.createQuery çağrıları — bunların her biri sarmalayıcıyı (wrapper) unutmak ve yanlışlıkla düz metin kaydetmek ya da şifreli metni bozuk veri olarak okumak için birer fırsattır.
  • WHERE / LIKE / ORDER BY işlemleri bozulur. Veri tabanı yalnızca görebildiği verileri karşılaştırabilir. Eğer sütun şifreli metinse, bir LIKE '%anıl%' koşulu hiçbir şeyle eşleşmez. Bu durumda ya tüm satırları uygulamaya çekip bellekte filtrelemeniz gerekir ya da yalnızca deterministik bir şifreleyici ile tam eşleşme durumlarında çalışan zahmetli bir "arama terimini şifrele ve şifreli metni karşılaştır" mekanizması kurmanız gerekir.
  • Veri tabanı taşımaları (migrations) ve anlık düzeltmeler zorlaşır. Bir Flyway betiği veya tek seferlik bir UPDATE çalıştıran herkesin şifreleme kuralını bilmesi ve bunu manuel olarak uygulaması gerekir.
  • Sızıntı yüzeyi geniştir. Her Kotlin çağrı noktası, her test verisi hazırlayıcı (test fixture), her başlangıç verisi yükleyici (seed data loader) gözden geçirilmelidir. Bir tanesini kaçırırsanız, o sütun sonsuza dek yarı şifreli kalır.

Çözüm

Bu projenin çözdüğü sorun: Şifreleme sınırı, veri tabanının içine, düz metin ile şifreli metnin buluştuğu yegane yer olan bir çift SQL fonksiyonuna (encrypt_text / decrypt_text) indirgenir. Ardından Hibernate'in @ColumnTransformer anotasyonu, uygulamanın bu fonksiyonları bypass etmesini fiziksel olarak imkansız hale getirir: Hibernate'in ürettiği (ve LIKE filtrelemesi için kullanılan JPA Criteria koşulları da dahil olmak üzere) her sorgu, ORM düzeyinde bu fonksiyonları çağıracak şekilde yeniden yazılır.

Uygulama kodu, Kotlin String değerlerini sanki sütun şifrelenmemiş gibi aynen okur ve yazar; WHERE firstname LIKE '%anıl%' sorgusu şifresi çözülmüş değer üzerinde çalışır çünkü Hibernate'in gönderdiği SQL WHERE LOWER(decrypt_text(firstname)) LIKE ? şeklindedir. Yedekler, dışa aktarımlar, replikalar ve bir DBA'in IDE'sinden çalıştıracağı SELECT * sorgularının hepsi şifreli metin görür. Düz metni okumanın tek yolu anahtara sahip olan bir oturum (session) açmaktır ve anahtar uygulama kodunda kesinlikle görünmez.

Bu proje, uygulama kodunu şifrelemeden tamamen habersiz tutarken, sütun düzeyinde şifrelemeyi veri tabanının içine nasıl aktaracağınızı gösterir. Spring/Kotlin/JPA katmanı düz Kotlin String değerlerini okur ve yazar; şifreli metin yalnızca veri tabanı sütununda yaşar ve şifreleme/şifre çözme adımı, veri tabanının her okuma ve yazma işleminde çağırdığı bir çift SQL fonksiyonunun içinde gerçekleşir.

Entity (varlık) sınıfı, bir encrypt_text(text) / decrypt_text(raw) fonksiyon çiftini tanımlar ve bunları @ColumnTransformer aracılığıyla sütuna bağlar. Hibernate daha sonra üretilen her sorguyu bu fonksiyonları otomatik olarak çağıracak şekilde yeniden yazar.

Temel Fikir

Hibernate’in @ColumnTransformer anotasyonu, okuma ve yazma işlemlerinde bir sütunun etrafına SQL ifadeleri eklemenize olanak tanır. Hibernate'in SELECT firstname FROM users üreteceği her yerde, bunun yerine SELECT decrypt_text(firstname) FROM users üretilir ve INSERT ... VALUES (?) üreteceği her yerde INSERT ... VALUES (encrypt_text(?)) üretilir. Kritik bir şekilde bu durum, Hibernate tarafından üretilen her sorgu için geçerlidir (filtreleme için kullanılan JPA Criteria sorguları dahil). Böylece firstname üzerindeki bir LIKE '%anıl%' araması, şeffaf bir şekilde şifresi çözülmüş değer üzerinde bir LIKE aramasına dönüşür.

Entity tanımı tüm sözleşmeyi oluşturur:

@Entity
@Table(name = "users")
class User(
    @Column(name = "firstname", nullable = false, columnDefinition = "RAW(2000)")
    @ColumnTransformer( 
        read  = "decrypt_text(firstname)",
        write = "encrypt_text(?)"
    )
    var firstname: String,

    @Column(name = "lastname", nullable = false, columnDefinition = "RAW(2000)")
    @ColumnTransformer(
        read  = "decrypt_text(lastname)",
        write = "encrypt_text(?)"
    )
    var lastname: String
) : BaseDomain()

Bu noktadan itibaren, uygulamanın geri kalanı (UserRepository, /users denetleyicisi (controller), LIKE koşullarını oluşturan JPA Specification sınıfı) sanki firstname ve lastname sıradan VARCHAR sütunlarıymış gibi yazılır.

Bir İsteğin Uçtan Uca Akışı

GET /users?firstname=Anıl3&lastname=Senocak3 isteği şunları tetikler:

  • Denetleyici (Controller), root.get("firstname") ve root.get("lastname") alanlarına karşı iki LIKE koşulu içeren bir JPA Specification nesnesi oluşturur.
  • Hibernate sorguyu planlar. @ColumnTransformer(read = ...) kullanımı nedeniyle, yansıtılan (projected) sütunlar ve koşul (predicate) sütunları, ham sütunu decrypt_text(...) ile sarmalayacak şekilde yeniden yazılır. Ortaya çıkan SQL şuna benzer:
SELECT decrypt_text(firstname), decrypt_text(lastname), id, created_at
FROM   users
WHERE  LOWER(decrypt_text(firstname)) LIKE ?
AND  LOWER(decrypt_text(lastname))  LIKE ?
  • Veri tabanı satır başına decrypt_text fonksiyonunu çalıştırır, sonucu bağlama parametresi (bind parameter) ile karşılaştırır ve ağ üzerinden düz metni döndürür.
  • JPA, çözülmüş dize değerleri ile User örneklerini (instances) doldurur (hydrate); Jackson bunları JSON olarak serileştirir.

Ekleme (Insert) işlemleri ise bunun tam tersi şekilde çalışır. CryptoApplication içindeki init bloğu:

userRepository.save(User(firstname = "Anıl1", lastname = "Senocak1"))

sunucu tarafında şuna yol açar:

INSERT INTO users (firstname, lastname, created_at, id)  
VALUES (encrypt_text(?), encrypt_text(?), ?, ?)

Düz metin hiçbir zaman sütuna kaydedilmez. Veri tabanı diskindeyken (at rest), SELECT firstname FROM users sorgusu şifreli metin baytlarını döndürür.

DBMS_CRYPTO Kurulumu

DBMS_CRYPTO, temel şifreleme işlemlerini sunan yerleşik bir Oracle paketidir. Çalışma şemasına (schema) açıkça yetki verilmesi gerekir:

GRANT EXECUTE ON DBMS_CRYPTO TO your_schema;  
-- örn. GRANT EXECUTE ON DBMS_CRYPTO TO SYSTEM;

Ardından, entity sınıfının beklediği iki sarmalayıcı fonksiyonu tanımlayın. PKCS#5 dolgulu (padding) CBC modunda AES-128, mantıklı bir simetrik varsayılandır. Aşağıdaki 16 baytlık anahtar yalnızca demo amaçlıdır — gerçek bir dağıtımda (deployment) neye ihtiyaç duyulduğunu görmek için Key management bölümüne bakın.

SQL komut satırında hızlı bir doğrulama testi:

CREATE OR REPLACE FUNCTION encrypt_text(p_text VARCHAR2)  
    RETURN RAW  
AS  
BEGIN  
    RETURN DBMS_CRYPTO.ENCRYPT(  
        src => UTL_RAW.CAST_TO_RAW(p_text),  
        typ => DBMS_CRYPTO.ENCRYPT_AES128
             + DBMS_CRYPTO.CHAIN_CBC  
             + DBMS_CRYPTO.PAD_PKCS5,  
        key => UTL_RAW.CAST_TO_RAW('1234567890123456')
    );  
END;

CREATE OR REPLACE FUNCTION decrypt_text(p_raw RAW)
    RETURN VARCHAR2  
AS  
BEGIN  
    RETURN UTL_RAW.CAST_TO_VARCHAR2(  
        DBMS_CRYPTO.DECRYPT(  
            src => p_raw,  
            typ => DBMS_CRYPTO.ENCRYPT_AES128
                 + DBMS_CRYPTO.CHAIN_CBC  
                 + DBMS_CRYPTO.PAD_PKCS5,  
            key => UTL_RAW.CAST_TO_RAW('1234567890123456')  
        )  
    );  
END;

Sütun türü RAW(2000)'dir (entity üzerindeki columnDefinition aracılığıyla tanımlanmıştır), çünkü DBMS_CRYPTO.ENCRYPT fonksiyonu RAW döndürür ve şifreli metin metinsel değil, ikilidir (binary). 2000, Oracle'daki maksimum satır içi (inline) RAW uzunluğudur; daha büyük veri boyutları için BLOB kullanın ve fonksiyon imzalarını BLOB giriş/çıkış alacak şekilde ayarlayın.

Şifre Seçim Kılavuzu

DBMS_CRYPTO.ENCRYPT fonksiyonu, üç sabitin bit düzeyinde toplamı (bitwise sum) olan bir typ argümanı alır: algoritma + zincirleme modu + dolgu (padding). Yukarıdaki kombinasyon şöyledir:

Bölüm Sabit Anlamı
Algoritma `ENCRYPT_AES128` 16 baytlık anahtarla AES. Daha uzunsa `ENCRYPT_AES192` / `ENCRYPT_AES256`.
Zincirleme `CHAIN_CBC` CBC modu. IV varsayılan olarak tamamen sıfırlardan oluşur — aşağıdaki *Key management* bölümüne bakın.
Dolgu `PAD_PKCS5` Rastgele uzunluktaki düz metinlerin düzgün bir şekilde şifrelenmesi için PKCS#5/7 dolgusu.

Anahtar Yönetimi

encrypt_text('1234567890123456') içindeki 16 baytlık dize demo için bir geçici değerdir. Gerçek senaryolar için:

  • Anahtarlar CREATE FUNCTION kaynak kodunda yer almamalıdır. Oracle Wallet / Key Vault kullanın veya oturum başlangıcında bir sır yöneticisinden (secret manager) çekip fonksiyonların okuduğu bir bağlam (context) değişkeninde saklayın.
  • Statik bir anahtar ve statik bir IV (burada iv argümanı geçilmediği için varsayılan tamamen sıfırlardan oluşan IV kullanılmıştır) ile AES-128 CBC, deterministik şifreli metin üretir — yani aynı düz metin aynı baytlara şifrelenir. Bu durum, eşitlik sızıntısına yol açar ve frekans analizine olanak tanır. Eşitlik araması bir gereksinim değilse, şifreleme başına rastgele bir IV geçirin ve bunu şifreli metinle birlikte saklayın veya kimliği doğrulanmış şifreleme (authenticated encryption) için ENCRYPT_AES_GCM moduna geçin.
  • Sütunları versiyonlanmış bir anahtar etiketiyle yeniden şifreleyerek anahtarları döndürün (rotate) — decrypt_text fonksiyonunun doğru anahtarı seçebilmesi için versiyon baytını saklanan şifreli metinde tutun.

Entegrasyon Testinin Doğruladığı Şeyler

CryptoApplicationTests, canlı uygulamayı gerçek bir veri tabanına karşı çalıştıran ve iki özelliği doğrulayan (assert) bir @SpringBootTest sınıfıdır:

1. JPA üzerinden çift yönlü doğruluk. Üç User satırı kaydedin, bunları userRepository.findAll() aracılığıyla geri okuyun ve dize değerlerinin yazılanla tamamen aynı olduğunu doğrulayın.

2. Depolanan şifreli metin (Ciphertext at rest). Hibernate'i devre dışı bırakıp users tablosuna karşı doğrudan ham JDBC sorguları çalıştırın; firstname/lastname sütunlarında fiziksel olarak saklanan baytların düz metin baytlarına eşit olmadığını ve düz metinden (şifreleme blok dolgusu nedeniyle) daha uzun olduğunu doğrulayın. Bu kontrol, şifrelemenin işlevsiz bir şablondan ibaret olmayıp gerçekten çalıştığını kanıtlar.

Üçüncü bir test ise Criteria tabanlı /users?firstname=...&lastname=... filtresini uçtan uca çalıştırır. Buna, şifresi çözülmüş değerler üzerindeki büyük/küçük harfe duyarsız (case-insensitive) eşleştirmeler de dahildir. Böylece read = "decrypt_text(...)" dönüşümünün yalnızca getirilen sütunlar için değil, Hibernate tarafından oluşturulan koşullar (predicates) için de devreye girdiğini kanıtlar.

Yerelde Çalıştırma

  • Oracle Free'yi başlatın (tek seferlik): docker compose up -d. localhost:1522 adresini dinler, servis FREEPDB1, kullanıcı bilgileri system / testpassword. SYSTEM kullanmak istemiyorsanız parolanızı ayarlamak ve çalışma şeması oluşturmak üzere konteyner içi adımlar için docker-compose.yml içindeki yorumlara bakın.
version: '3.7'  
services:  
  oracle-free2:  
    image: container-registry.oracle.com/database/free:23.9.0.0  
    restart: unless-stopped  
    ports:  
      - "1522:1521"  
    environment:  
      - ORACLE_PWD=testpassword
jdbc:oracle:thin:system/testpassword@//localhost:1522/FREEPDB1
SELECT encrypt_text('hello') FROM dual; -- → EBB7C703E675DB3DA397038B4C17823C
SELECT decrypt_text('EBB7C703E675DB3DA397038B4C17823C') FROM dual; -- → hello
  • Oracle'a sys dba olarak bağlanın ve şunu çalıştırın:
jdbc:oracle:thin:sys/testpassword@//localhost:1522/FREEPDB1?internal_logon=sysdba

ardından şifreleme izinlerini SYSTEM kullanıcısına (veya seçtiğiniz şemaya) vermek için şunu çalıştırın:

GRANT EXECUTE ON DBMS_CRYPTO TO SYSTEM;
  • SYSTEM (veya seçtiğiniz şema) olarak bağlanın ve test edin:
jdbc:oracle:thin:system/testpassword@//localhost:1522/FREEPDB1
SELECT encrypt_text('hello') FROM dual; -- → EBB7C703E675DB3DA397038B4C17823C  
SELECT decrypt_text('EBB7C703E675DB3DA397038B4C17823C') FROM dual; -- → hello
  • Şifreleme fonksiyonlarını oluşturun (tek seferlik): yukarıdaki _DBMS_CRYPTO_ kurulumu bölümünde yer alan CREATE OR REPLACE FUNCTION bloklarını, uygulamanızın bağlandığı şemada (örneğin SYSTEM) çalıştırın. Bunların veri tabanı başına yalnızca bir kez oluşturulması gerekir, her uygulama çalışmasında değil.
  • Hedef şemanızda şifreleme fonksiyonlarını oluşturun (yukarıdaki _DBMS_CRYPTO_ kurulumu altındaki SQL bloğu). Entity sınıfının @ColumnTransformer anotasyonu bunları nitelenmemiş adla (unqualified name) çağırır, bu nedenle bağlanan kullanıcının varsayılan arama yolundan (search path) erişilebilir olmalıdırlar.
  • Uygulamayı çalıştırın: ./gradlew bootRun. ddl-auto=create-drop özelliği, users tablosunu firstname/lastname alanları RAW(2000) olacak şekilde (yeniden) oluşturacaktır. ApplicationReadyEvent üç satır başlangıç verisi (seed) ekler. Sunucu :8082 portuna bağlanır.
  • Uç noktaya istek atın: GET http://localhost:8082/users?firstname=Anıl3&lastname=Senocak3 (src/main/resources/requests.http dosyasına bakın).

Veri kaynağı (datasource) varsayılan değerlerini ORACLE_URL / ORACLE_USERNAME / ORACLE_PASSWORD ortam değişkenleriyle (env vars) geçersiz kılabilirsiniz.

Dikkat Edilmesi Gerekenler ve Ödünleşimler (Tradeoffs)

  • Şifrelenmiş sütunlardaki indeksler, kardeş (sibling) bir sütunda deterministik bir kör indeks (blind index) (örneğin, küçük harfe dönüştürülmüş düz metnin HMAC'i) saklamadığınız ve sorgulamayı buna göre yapmadığınız sürece kullanışsızdır. Mevcut kurulum, her LIKE işlemi için tüm tablonun şifresinin çözülmesini zorunlu kılar; bu durum demo için uygun olsa da büyük ölçekte sorun yaratır.
  • İşlemci (CPU) maliyeti satır başınadır. Seçilen her satır için bir DBMS_CRYPTO.DECRYPT çağrısı yapılır. Sık erişilen (hot) tablolardaki sorgu planlarını izleyin; decrypt_text(col) üzerinde fonksiyon tabanlı bir indeks (function-based index) oluşturmak, durağan verileri şifreleme amacını tamamen ortadan kaldırır.
  • application.yml dosyasındaki **ddl-auto=create-drop** ayarı demo için pratiktir ancak canlı ortamda (production) kesinlikle isteyeceğiniz bir şey değildir; bunun yerine validate seçeneğine geçin ve şemayı açıkça tanımlanmış taşımalar (explicit migrations) (Flyway / Liquibase) ile yönetin. encrypt_text / decrypt_text fonksiyonları, kendilerine başvuran herhangi bir sütun DDL işleminden önce, aynı taşıma araçları aracılığıyla dağıtılmalıdır.
  • **RAW(2000)** üst sınırı, şifreli metni yaklaşık 2000 baytı aşacak olan düz metinlerin bir BLOB sütununa ve buna karşılık gelen BLOB döndüren fonksiyon imzalarına ihtiyaç duyduğu anlamına gelir.
  • **NULL** yönetimi: yukarıdaki sarmalayıcılar, NULL düz metni Oracle'ın NULL aritmetiği ile aynı şekilde ele alır: encrypt_text(NULL) çağrısı NULL döndürür (hiçbir satır şifrelenmez) ve decrypt_text(NULL) çağrısı NULL döndürür. JPA sütunlarındaki nullable = false tanımı, boş değer girilemeyeceğini (non-null) fiilen zorunlu kılan katmandır.

GitHub - senocak/Spring-Oracle-Column-Level-Encryption-Decryption: Spring Oracle Column Level

Keyifli kodlamalar!

Yorumlar
Henüz yorum yok. İlk yorumu sen yaz.
Yorum Bırak
Adınız (isteğe bağlı)
Yorumunuzu yazın...
0/500