Chapter sebelumnya menunjukkan bahwa autentikasi membandingkan hash sandi yang dikirim dengan nilai tersimpan di basis data. Pertanyaan berikutnya: bagaimana nilai itu dihasilkan agar kebocoran basis data tidak serta-merta membongkar seluruh akun — jawabannya satu tanpa pengecualian: kata sandi tidak pernah disimpan dalam bentuk aslinya, bukan dienkripsi, melainkan di-hash dengan algoritma yang sengaja lambat. Pada Chapter ini kita bedah alasan di baliknya, peran salt, cara kerja PasswordEncoder di Spring Security, lalu membuktikannya dengan eksperimen JDK murni.
Mengapa Hash, Bukan Enkripsi #
Enkripsi dan hashing sering tertukar, padahal bertolak belakang. Enkripsi bersifat dua arah: dengan kunci yang tepat, sandi tersandi dikembalikan ke bentuk aslinya. Hash bersifat satu arah: fungsi hash memetakan sandi menjadi cerna tetap tanpa jalan kembali. Untuk data yang memang harus dibaca kembali, enkripsi tepat; untuk kata sandi, kemampuan itu justru risikonya.
| Aspek | Enkripsi | Hash |
|---|---|---|
| Arah | dua arah (reversible) | satu arah |
| Kebutuhan kunci | wajib | tidak ada |
| Verifikasi sandi | dekripsi lalu bandingkan | hash ulang lalu bandingkan |
| Kebocoran kunci | seluruh sandi terbuka | tidak berlaku |
Verifikasi sandi tidak butuh bentuk aslinya — server meng-hash ulang sandi yang dikirim lalu membandingkan hasilnya dengan nilai tersimpan; tidak ada alasan menyimpan sesuatu yang bisa dikembalikan menjadi sandi polos.
Salt dan Biaya Komputasi #
Hash polos masih menyisakan dua celah. Hash bersifat deterministik: dua pengguna bersandi sandiSama123 menghasilkan cerna identik — satu akun yang dibongkar membuka akun lainnya. Sandi lemah juga bisa dipecahkan dengan rainbow table (tabel prahitung jutaan sandi umum). Kedua celah ditutup salt: byte acak unik per pengguna yang dicampurkan ke input hash dan disimpan bersama hasilnya, sehingga tabel prahitung tidak berlaku.
Celah ketiga adalah kecepatan. SHA-256 dirancang secepat mungkin untuk verifikasi berkas — GPU modern menghitung miliaran cerna per detik, sehingga tebakan sandi teruji sama cepatnya. Algoritma kata sandi sengaja memperlambat diri lewat work factor: bcrypt memutar cost, PBKDF2 menggiling sandi lewat ribuan putaran HMAC, Argon2id menambah beban memori. OWASP menempatkan Argon2id sebagai pilihan utama, disusul bcrypt, scrypt, dan PBKDF2.
PasswordEncoder di Spring Security #
Spring Security merangkum semuanya dalam antarmuka PasswordEncoder: encode(sandi) untuk mendaftarkan akun, matches(sandi, tersimpan) untuk login. Implementasi yang dianjurkan, DelegatingPasswordEncoder, membaca awalan nilai tersimpan seperti {bcrypt} atau {pbkdf2} lalu mendelegasikan ke encoder yang sesuai — akun lama tetap terverifikasi dengan algoritma lama, sehingga migrasi berjalan tanpa reset massal. Konfigurasinya satu baris — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}Dua catatan menyertainya: kolom penyimpanan harus cukup panjang (bcrypt ±60 karakter), dan hasil encode() tidak boleh dibandingkan antar-baris — salt acak membuat dua kali encode("sandiSama123") menghasilkan string berbeda yang sama-sama lolos matches(). Satu-satunya cara menguji adalah matches().
Eksperimen dengan JDK #
BCrypt tidak tersedia di JDK, tetapi PBKDF2 ada lewat javax.crypto. Program berikut membandingkan penyimpanan polos, SHA-256 tanpa salt, lalu PBKDF2-HMAC-SHA256 dengan salt per pengguna dan 120.000 iterasi (salt deterministik agar keluaran dapat diverifikasi; di produksi gunakan SecureRandom):
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
public class HashKataSandi {
static final int ITERASI = 120_000;
static final int PANJANG_KUNCI_BIT = 256;
public static void main(String[] args) throws Exception {
System.out.println("== 1. Penyimpanan polos (dilarang di produksi) ==");
Map<String, String> tabelPolos = new LinkedHashMap<>();
tabelPolos.put("budi", "sandiSama123");
tabelPolos.put("siti", "sandiSama123");
for (Map.Entry<String, String> e : tabelPolos.entrySet()) {
System.out.println("tabel: " + e.getKey() + " -> " + e.getValue());
}
System.out.println("sandi terbaca langsung; satu kebocoran membongkar semua akun");
System.out.println();
System.out.println("== 2. SHA-256 tanpa salt (masih tidak memadai) ==");
String d1 = hex(sha256("sandiSama123"));
String d2 = hex(sha256("sandiSama123"));
String d3 = hex(sha256("sandiBaru456"));
System.out.println("sandiSama123 -> " + d1);
System.out.println("sandiSama123 -> " + d2);
System.out.println("sandiBaru456 -> " + d3);
System.out.println("dua sandi sama menghasilkan hash sama: " + d1.equals(d2));
System.out.println("hash siap dipecahkan tabel prahitung (rainbow table)");
System.out.println();
System.out.println("== 3. PBKDF2 dengan salt per pengguna ==");
String simpanBudi = simpan("budi", "sandiSama123");
String simpanSiti = simpan("siti", "sandiSama123");
System.out.println("budi: " + simpanBudi);
System.out.println("siti: " + simpanSiti);
String hashBudi = potong(simpanBudi);
String hashSiti = potong(simpanSiti);
System.out.println("sandi sama, hash tetap beda : " + !hashBudi.equals(hashSiti));
System.out.println("biaya per tebakan : " + ITERASI
+ " putaran HMAC-SHA256");
System.out.println();
System.out.println("== 4. Verifikasi tanpa pernah membaca sandi polos ==");
cek("budi", "sandiSama123", simpanBudi);
cek("budi", "tebakanSalah", simpanBudi);
cek("siti", "sandiSama123", simpanSiti);
}
// ---- Fungsi kunci ----
// Hash cepat: cocok untuk checksum berkas, TIDAK untuk kata sandi.
static byte[] sha256(String teks) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(teks.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 wajib tersedia di JVM", e);
}
}
// Salt deterministik per pengguna agar contoh ini dapat diuji ulang.
// Di produksi: SecureRandom.getInstanceStrong() saat pendaftaran.
static byte[] saltUntuk(String pengguna) {
return sha256("salt$" + pengguna);
}
// PBKDF2: sandi + salt digiling ITERASI kali menjadi kunci PANJANG_KUNCI_BIT.
static byte[] pbkdf2(String sandi, byte[] salt) throws Exception {
PBEKeySpec spec = new PBEKeySpec(sandi.toCharArray(), salt,
ITERASI, PANJANG_KUNCI_BIT);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();
} finally {
spec.clearPassword(); // bersihkan salinan sandi di memori
}
}
// Format penyimpanan mandiri: algoritma:iterasi:salt:hash.
// Mirip awalan {bcrypt} milik DelegatingPasswordEncoder.
static String simpan(String pengguna, String sandi) throws Exception {
byte[] salt = saltUntuk(pengguna);
byte[] hash = pbkdf2(sandi, salt);
return "pbkdf2:" + ITERASI + ":" + hex(salt) + ":" + hex(hash);
}
static void cek(String pengguna, String sandi, String tersimpan) throws Exception {
String[] bagian = tersimpan.split(":", 4);
int iterasi = Integer.parseInt(bagian[1]);
byte[] salt = unhex(bagian[2]);
byte[] tersimpanHash = unhex(bagian[3]);
PBEKeySpec spec = new PBEKeySpec(sandi.toCharArray(), salt,
iterasi, tersimpanHash.length * 8);
byte[] hitungUlang;
try {
hitungUlang = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();
} finally {
spec.clearPassword();
}
// MessageDigest.isEqual membandingkan konstanta-waktu, bukan equals().
boolean cocok = MessageDigest.isEqual(hitungUlang, tersimpanHash);
System.out.printf("%-6s sandi=%-14s -> %s%n", pengguna, sandi,
cocok ? "COCOK" : "TIDAK COCOK");
}
static String potong(String tersimpan) {
return tersimpan.split(":", 4)[3];
}
static String hex(byte[] b) {
return HexFormat.of().formatHex(b);
}
static byte[] unhex(String s) {
return HexFormat.of().parseHex(s);
}
}Output yang Dihasilkan:
== 1. Penyimpanan polos (dilarang di produksi) ==
tabel: budi -> sandiSama123
tabel: siti -> sandiSama123
sandi terbaca langsung; satu kebocoran membongkar semua akun
== 2. SHA-256 tanpa salt (masih tidak memadai) ==
sandiSama123 -> cb080e072c3f6deff37f197ff97769cca1b3d07b57f45ca2e61528d9521d2ade
sandiSama123 -> cb080e072c3f6deff37f197ff97769cca1b3d07b57f45ca2e61528d9521d2ade
sandiBaru456 -> 158e99038dd46ef0700eda4002727b399ecb81d618ef25c59250d04b03efc0b9
dua sandi sama menghasilkan hash sama: true
hash siap dipecahkan tabel prahitung (rainbow table)
== 3. PBKDF2 dengan salt per pengguna ==
budi: pbkdf2:120000:cce57e6bbecfaaa89e096af10d345800a49515d7eb810c0940d7fa636585db47:7d7feb1525b27d620c847a8d46fa5ea550e19595bc2abfc3b91c930095dda50a
siti: pbkdf2:120000:28a92d7ef0b183b3c936849c96d894f891f0faaa50a22963e7a540d7fbb04e03:6257bfb0f6dd210bb4893430b1ddfd0b71d5fe8b366b810ed5bf314eaa37a628
sandi sama, hash tetap beda : true
biaya per tebakan : 120000 putaran HMAC-SHA256
== 4. Verifikasi tanpa pernah membaca sandi polos ==
budi sandi=sandiSama123 -> COCOK
budi sandi=tebakanSalah -> TIDAK COCOK
siti sandi=sandiSama123 -> COCOKMari kita bedah eksperimen tersebut:
- Bagian 1–2 (polos dan SHA-256): pola yang salah ditampilkan sengaja — tabel polos membocorkan sandi seketika, dan
dua sandi sama menghasilkan hash sama: truemembuktikan hash cepat tanpa salt hanya menukar masalah. saltUntuk(pengguna): salt 32-byte diturunkan dari nama pengguna agar contoh dapat diuji ulang; di produksi,SecureRandommenghasilkan salt acak sekali pakai — salt memang disimpan bersama hash, bukan dirahasiakan.pbkdf2(sandi, salt):PBEKeySpecmembawa sandi, salt, iterasi, dan panjang kunci 256-bit;SecretKeyFactoryPBKDF2WithHmacSHA256menggilingnya 120.000 putaran HMAC-SHA256 — tiap tebakan penyerang membayar biaya yang sama. BlokfinallymemanggilclearPassword()untuk membersihkan salinan sandi di memori.simpan(...): formatpbkdf2:120000:<salt>:<hash>meniruDelegatingPasswordEncoder— algoritma dan parameternya tersimpan bersama nilainya, sehingga iterasi dapat dinaikkan kelak tanpa mengubah skema. Keluaransandi sama, hash tetap beda : truemuncul karena salt Budi dan Siti berbeda.cek(...): mem-parsing format tersimpan, menghitung ulang PBKDF2 dengan salt dan iterasi baris itu, lalu membandingkan hasilnya denganMessageDigest.isEqual— perbandingan konstanta-waktu yang tidak membocorkan posisi byte berbeda pertama.
Fondasi keamanan kini berdiri: identitas terbentuk, izin dievaluasi, kredensial tersimpan benar. Saatnya menyusun layanan aplikasi untuk dunia luar — merangkai REST API lengkap dengan Spring Boot, topik Chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
HashKataSandi.java
Unduh berkasimport javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
public class HashKataSandi {
static final int ITERASI = 120_000;
static final int PANJANG_KUNCI_BIT = 256;
public static void main(String[] args) throws Exception {
System.out.println("== 1. Penyimpanan polos (dilarang di produksi) ==");
Map<String, String> tabelPolos = new LinkedHashMap<>();
tabelPolos.put("budi", "sandiSama123");
tabelPolos.put("siti", "sandiSama123");
for (Map.Entry<String, String> e : tabelPolos.entrySet()) {
System.out.println("tabel: " + e.getKey() + " -> " + e.getValue());
}
System.out.println("sandi terbaca langsung; satu kebocoran membongkar semua akun");
System.out.println();
System.out.println("== 2. SHA-256 tanpa salt (masih tidak memadai) ==");
String d1 = hex(sha256("sandiSama123"));
String d2 = hex(sha256("sandiSama123"));
String d3 = hex(sha256("sandiBaru456"));
System.out.println("sandiSama123 -> " + d1);
System.out.println("sandiSama123 -> " + d2);
System.out.println("sandiBaru456 -> " + d3);
System.out.println("dua sandi sama menghasilkan hash sama: " + d1.equals(d2));
System.out.println("hash siap dipecahkan tabel prahitung (rainbow table)");
System.out.println();
System.out.println("== 3. PBKDF2 dengan salt per pengguna ==");
String simpanBudi = simpan("budi", "sandiSama123");
String simpanSiti = simpan("siti", "sandiSama123");
System.out.println("budi: " + simpanBudi);
System.out.println("siti: " + simpanSiti);
String hashBudi = potong(simpanBudi);
String hashSiti = potong(simpanSiti);
System.out.println("sandi sama, hash tetap beda : " + !hashBudi.equals(hashSiti));
System.out.println("biaya per tebakan : " + ITERASI
+ " putaran HMAC-SHA256");
System.out.println();
System.out.println("== 4. Verifikasi tanpa pernah membaca sandi polos ==");
cek("budi", "sandiSama123", simpanBudi);
cek("budi", "tebakanSalah", simpanBudi);
cek("siti", "sandiSama123", simpanSiti);
}
// ---- Fungsi kunci ----
// Hash cepat: cocok untuk checksum berkas, TIDAK untuk kata sandi.
static byte[] sha256(String teks) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(teks.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 wajib tersedia di JVM", e);
}
}
// Salt deterministik per pengguna agar contoh ini dapat diuji ulang.
// Di produksi: SecureRandom.getInstanceStrong() saat pendaftaran.
static byte[] saltUntuk(String pengguna) {
return sha256("salt$" + pengguna);
}
// PBKDF2: sandi + salt digiling ITERASI kali menjadi kunci PANJANG_KUNCI_BIT.
static byte[] pbkdf2(String sandi, byte[] salt) throws Exception {
PBEKeySpec spec = new PBEKeySpec(sandi.toCharArray(), salt,
ITERASI, PANJANG_KUNCI_BIT);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();
} finally {
spec.clearPassword(); // bersihkan salinan sandi di memori
}
}
// Format penyimpanan mandiri: algoritma:iterasi:salt:hash.
// Mirip awalan {bcrypt} milik DelegatingPasswordEncoder.
static String simpan(String pengguna, String sandi) throws Exception {
byte[] salt = saltUntuk(pengguna);
byte[] hash = pbkdf2(sandi, salt);
return "pbkdf2:" + ITERASI + ":" + hex(salt) + ":" + hex(hash);
}
static void cek(String pengguna, String sandi, String tersimpan) throws Exception {
String[] bagian = tersimpan.split(":", 4);
int iterasi = Integer.parseInt(bagian[1]);
byte[] salt = unhex(bagian[2]);
byte[] tersimpanHash = unhex(bagian[3]);
PBEKeySpec spec = new PBEKeySpec(sandi.toCharArray(), salt,
iterasi, tersimpanHash.length * 8);
byte[] hitungUlang;
try {
hitungUlang = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();
} finally {
spec.clearPassword();
}
// MessageDigest.isEqual membandingkan konstanta-waktu, bukan equals().
boolean cocok = MessageDigest.isEqual(hitungUlang, tersimpanHash);
System.out.printf("%-6s sandi=%-14s -> %s%n", pengguna, sandi,
cocok ? "COCOK" : "TIDAK COCOK");
}
static String potong(String tersimpan) {
return tersimpan.split(":", 4)[3];
}
static String hex(byte[] b) {
return HexFormat.of().formatHex(b);
}
static byte[] unhex(String s) {
return HexFormat.of().parseHex(s);
}
}