Password manusia umumnya pendek dan dapat ditebak. Penyimpanan password dalam bentuk plain text atau hash cepat seperti MD5 membuat satu kebocoran database dapat berujung pada kompromi akun secara massal, terutama karena pengguna sering memakai password yang sama lintas layanan. Algoritma hashing password modern seperti bcrypt, scrypt, Argon2, dan PBKDF2 dirancang khusus untuk memperlambat tebakan: setiap percobaan memerlukan komputasi yang sengaja dibuat mahal. Pada chapter ini kita akan mempraktikkan PBKDF2 (tersedia di JDK), memahami konsep salt dan work factor, serta melihat mengapa MD5 dan SHA-1 tidak boleh dipakai untuk menyimpan password.
Mengapa Hash Cepat Berbahaya #
MD5 dan SHA-1 dirancang untuk verifikasi integritas file—kebutuhan yang menuntut kecepatan. GPU modern dapat menghitung miliaran hash MD5 per detik, sehingga database yang bocor dapat dipecahkan dalam hitungan jam menggunakan kamus password umum dan rainbow table. Hash cepat juga umumnya tidak memakai salt: dua pengguna dengan password yang sama akan memiliki hash identik, sehingga penyerang cukup memecahkan satu untuk memperoleh akses ke banyak akun.
import java.security.MessageDigest
fun insecureSha256(password: String): String {
val md = MessageDigest.getInstance("SHA-256")
return md.digest(password.toByteArray()).joinToString("") { "%02x".format(it) }
}
// Setiap panggilan insecureSha256("password") menghasilkan nilai yang sama
// — memungkinkan serangan rainbow table.Salt adalah nilai acak yang ditambahkan ke password sebelum di-hash. Salt yang berbeda per pengguna membuat dua password yang sama menghasilkan hash berbeda, sehingga rainbow table tidak dapat dipakai ulang.
PBKDF2: Work Factor yang Dapat Diatur #
PBKDF2 (Password-Based Key Derivation Function 2) bekerja dengan mengulang hash function ribuan kali. Setiap iterasi menambahkan biaya komputasi: 100.000 iterasi membuat satu tebakan menjadi 100.000 kali lebih lambat dibandingkan hash biasa. Biaya ini sama untuk pengguna sah maupun penyerang, namun pengguna sah hanya perlu satu verifikasi sementara penyerang harus menebak jutaan kombinasi.
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
fun hashPasswordPbkdf2(password: String, salt: ByteArray, iterations: Int = 100_000): ByteArray {
val spec = PBEKeySpec(password.toCharArray(), salt, iterations, 256)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
return factory.generateSecret(spec).encoded
}Parameter PBEKeySpec menerima password, salt, jumlah iterasi, dan panjang kunci yang diinginkan. Panjang 256 bit memberikan keamanan yang cukup untuk sebagian besar skenario.
Salt dan Verifikasi Konstan-Waktu #
Salt disimpan bersama hash—tidak perlu dirahasiakan. Verifikasi password melibatkan: hash ulang password dengan salt yang tersimpan, lalu membandingkan hasilnya dengan hash yang tersimpan. Perbandingan harus konstan-waktu untuk mencegah timing attack—serangan yang menyimpulkan hash berdasarkan waktu perbandingan.
// password_hashing.kt
// bcrypt, scrypt, Argon2 concepts. PBKDF2 (in JDK) as runnable demo.
// Why not MD5/SHA1: too fast, no salt, vulnerable to rainbow tables.
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import java.security.SecureRandom
import java.util.Base64
// --- Generate a cryptographically secure random salt ---
fun generateSalt(byteLength: Int = 16): ByteArray {
require(byteLength in 8..32) { "panjang salt di luar rentang aman" }
val bytes = ByteArray(byteLength)
// Note: In production, use no-arg SecureRandom(). Demo uses seeded SHA1PRNG.
val rng = SecureRandom.getInstance("SHA1PRNG").apply { setSeed("kotlin-course-stable-seed".toByteArray()) }
rng.nextBytes(bytes)
return bytes
}
// --- PBKDF2 hashing: slow, salted, parameterized ---
// iterations: 100,000+ recommended (2024); cost rises linearly for attacker too.
fun hashPasswordPbkdf2(password: String, salt: ByteArray, iterations: Int = 100_000): ByteArray {
val spec = PBEKeySpec(password.toCharArray(), salt, iterations, 256)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
return factory.generateSecret(spec).encoded
}
// --- Verify password against stored hash ---
fun verifyPassword(password: String, salt: ByteArray, expectedHash: ByteArray, iterations: Int = 100_000): Boolean {
val actual = hashPasswordPbkdf2(password, salt, iterations)
// Constant-time comparison to avoid timing attacks
if (actual.size != expectedHash.size) return false
var diff = 0
for (i in actual.indices) {
diff = diff or (actual[i].toInt() xor expectedHash[i].toInt())
}
return diff == 0
}
// --- Anti-pattern demo: plain SHA-256 (too fast, no salt, no work factor) ---
fun insecureSha256(password: String): String {
val md = java.security.MessageDigest.getInstance("SHA-256")
return md.digest(password.toByteArray()).joinToString("") { "%02x".format(it) }
}
// --- Encode salt+hash to a single storable string ---
fun encodeStorage(salt: ByteArray, hash: ByteArray, iterations: Int): String {
val encoder = Base64.getEncoder().withoutPadding()
return "pbkdf2\$sh256\$$iterations\$${encoder.encodeToString(salt)}\$${encoder.encodeToString(hash)}"
}
fun main() {
val salt = generateSalt(16)
println("Salt (base64) : ${Base64.getEncoder().withoutPadding().encodeToString(salt)}")
val hash = hashPasswordPbkdf2("rahasia-budi", salt, iterations = 100_000)
println("PBKDF2 hash (b64): ${Base64.getEncoder().withoutPadding().encodeToString(hash)}")
println("---")
println("verifyPassword('rahasia-budi', ...) = ${verifyPassword("rahasia-budi", salt, hash)}")
println("verifyPassword('salah-password', ...) = ${verifyPassword("salah-password", salt, hash)}")
println("---")
val stored = encodeStorage(salt, hash, iterations = 100_000)
println("Stored string: $stored")
println("---")
// Anti-pattern: same password -> same SHA-256 (no salt, fast)
println("insecureSha256('password') = ${insecureSha256("password")}")
println("insecureSha256('password') = ${insecureSha256("password")} (sama = rentan rainbow table)")
println("---")
// Effect of work factor: same password -> same hash, but cost rises linearly per iteration
// We do not time execution here; output is fully deterministic.
val hash10k = hashPasswordPbkdf2("rahasia", salt, iterations = 10_000)
val hash100k = hashPasswordPbkdf2("rahasia", salt, iterations = 100_000)
println("PBKDF2 10k iterations hash prefix: ${Base64.getEncoder().withoutPadding().encodeToString(hash10k).take(16)}...")
println("PBKDF2 100k iterations hash prefix: ${Base64.getEncoder().withoutPadding().encodeToString(hash100k).take(16)}...")
println("Kedua hash berbeda karena iterasi yang berbeda menghasilkan output yang berbeda.")
}Output yang Dihasilkan:
Salt (base64) : ETi18nYkpvD2cslKtxnciA
PBKDF2 hash (b64): cQBAGgNgf/R2jHwZ1tI+gltHcnKQP1Ps+TsLbBjyOMo
---
verifyPassword('rahasia-budi', ...) = true
verifyPassword('salah-password', ...) = false
---
Stored string: pbkdf2$sh256$100000$ETi18nYkpvD2cslKtxnciA$cQBAGgNgf/R2jHwZ1tI+gltHcnKQP1Ps+TsLbBjyOMo
---
insecureSha256('password') = 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
insecureSha256('password') = 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 (sama = rentan rainbow table)
---
PBKDF2 10k iterations hash prefix: zpOZOMyocI2YZIFN...
PBKDF2 100k iterations hash prefix: JjK/3v9aPT+QOiuQ...
Kedua hash berbeda karena iterasi yang berbeda menghasilkan output yang berbeda.Mari kita bedah bagian-bagian kunci dari kode di atas:
generateSalt: membangkitkan 16 byte acak denganSecureRandom. Demo memakai seed stabil agar output dapat diverifikasi—produksi harus memakaiSecureRandom()tanpa argumen. Salt yang berbeda per pengguna adalah kunci pencegahan rainbow table.hashPasswordPbkdf2: membangkitkan hash 256-bit dari kombinasi password + salt dengan 100.000 iterasi. Parameter iterasi dapat ditingkatkan seiring hardware menjadi lebih cepat—nilai 600.000 direkomendasikan mulai 2024.verifyPassword: membandingkan hash input dengan hash tersimpan menggunakan XOR bit-per-bit, lalu mengakumulasi perbedaan dalamdiff. Perbandingan ini selalu mengiterasi seluruh array—tidak ada early exit pada ketidakcocokan pertama—sehingga waktu eksekusi tidak membocorkan informasi posisi perbedaan.encodeStorage: memformat salt, hash, dan iterasi menjadi satu string yang dapat disimpan di database. Formatalgorithm$params$salt$hashmengikuti konvensi crypt(3) yang dipakai oleh banyak library, sehingga verifikasi dapat mengetahui algoritma dan parameter dari string tersimpan saja.insecureSha256: anti-pattern. Dua panggilan dengan input yang sama menghasilkan hash identik. Tanpa salt, penyerang dapat membangun tabel hash password umum sekali dan memakainya untuk semua database yang bocor.
bcrypt, scrypt, dan Argon2 #
PBKDF2 adalah opsi yang paling sederhana karena tersedia di JDK. Tiga algoritma lain menawarkan keunggulan tambahan:
- bcrypt: menambahkan parameter cost yang menggandakan waktu per peningkatan satu unit. Implementasi populer di Kotlin/Java:
org.mindrot:jbcrypt. Tidak tersedia di JDK—perlu dependency. - scrypt: selain CPU, memaksa penggunaan memori yang besar. Memori yang besar mencegah penyerang memparalelkan serangan pada GPU/ASIC murah.
- Argon2: pemenang Password Hashing Competition 2015. Tiga parameter—memori, iterasi, paralelisme—memberi kontrol halus. Implementasi Kotlin/Java:
de.mkammerer:argon2-jvm.
// import org.mindrot.jbcrypt.BCrypt
//
// val hash = BCrypt.hashpw("password", BCrypt.gensalt(12)) // cost=12
// val cocok = BCrypt.checkpw("password", hash)Kode di atas tidak dapat dijalankan tanpa menambah dependency org.mindrot:jbcrypt. Konsep parameter cost dan verifikasi tetap sama dengan PBKDF2: work factor memperlambat penyerang tanpa memberatkan pengguna sah secara signifikan.
Rotasi Parameter Iterasi #
Karena hardware semakin cepat, parameter iterasi perlu dinaikkan seiring waktu. Pola umum: saat pengguna login, verifikasi password dengan parameter lama; bila cocok, hash ulang dengan parameter baru dan perbarui database. Cara ini memungkinkan migrasi bertahap tanpa memaksa reset password massal.
fun verifikasiDanMigrasi(password: String, stored: String): Boolean {
val (algo, params, salt, hash) = parseStored(stored)
if (!verifyWith(password, algo, salt, hash, params)) return false
if (params.iterations < MIN_ITERATIONS) {
val newHash = hashPasswordPbkdf2(password, salt, MIN_ITERATIONS)
// Simpan newHash ke database
}
return true
}Referensi Lanjutan #
- OWASP Cheat Sheet Series — panduan OWASP untuk penyimpanan kata sandi pengguna dengan algoritma hashing yang tepat
Penutup #
Password hashing yang kuat memerlukan tiga komponen: algoritma khusus (PBKDF2/bcrypt/scrypt/Argon2), salt unik per pengguna, dan work factor yang cukup tinggi. Tanpa ketiganya, database yang bocor dapat dipecahkan dalam hitungan jam. Chapter berikutnya akan membahas message digest dan tanda tangan digital—penggunaan hash function untuk memverifikasi integritas dan keaslian data antar pihak.