Kata sandi menempati titik rawan yang unik: nilai yang dipilih manusia (mudah ditebak) tapi berfungsi sebagai kunci akses penuh ke akun. Aplikasi kita harus bisa memverifikasi kata sandi saat pengguna login, tanpa pernah menyimpan kata sandi itu sendiri — sebab basis data yang dibocorkan pihak ketiga akan membawa seluruh kata sandi pengguna beserta aksesnya. Solusinya adalah menyimpan hash, tetapi tidak sembarang hash: bab sebelumnya kita memakai SHA-256 untuk sidik data, dan di bab ini justru kita pelajari mengapa SHA-256 polos adalah kesalahan serius untuk kata sandi. Jawabannya bernama PBKDF2 — hash yang sengaja dibuat lambat — dikombinasikan dengan salt yang unik per pengguna.

Mengapa SHA-256 Polos Gagal #

Hash kriptografi dirancang cepat — kecepatan itu justru kebutuhan untuk memverifikasi integritas berkas gigabyte. Namun untuk kata sandi, kecepatan yang sama berpihak pada penyerang. Kartu GPU modern menghitung miliaran hash SHA-256 per detik; kamus kata sandi umum berisi ratusan juta kandidat, artinya seluruh kamus tersapu dalam hitungan detik. Pemilik basis data curian tinggal menjalankan kamus terhadap tiap baris hash, dan setiap kecocokan membuka satu akun.

Masalah kedua adalah rainbow table: tabel pra-hitung yang memetakan jutaan kata sandi umum langsung ke hash-nya. Dua juta pengguna yang memilih "password123" menghasilkan dua juta baris hash yang identik — satu entri rainbow table membuka dua juta akun sekaligus, dan pola hash berulang juga membocorkan informasi tentang pengguna mana yang memakai kata sandi sama. Dua kelemahan ini — kecepatan dan pola berulang — menuntut dua penawar yang berbeda.

Salt Unik per Pengguna dan Bukti Eksekusinya #

Salt adalah data acak yang ditambahkan ke proses hashing agar kata sandi sama menghasilkan hash berbeda — dan setiap pengguna mendapat salt sendiri yang disimpan terbuka bersama hash-nya. Salt tidak perlu dirahasiakan karena tugasnya bukan menyembunyikan, melainkan mempersulit: tabel pra-hitung harus dibangun ulang per salt, dan hash berulang tidak lagi terlihat. Program berikut mensimulasikan registrasi dua pengguna dengan kata sandi persis sama, lalu memverifikasi login dengan PBKDF2WithHmacSHA256 dari pustaka javax.crypto:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

public class KataSandiPbkdf2 {

    public static void main(String[] args) throws Exception {
        // Salt tetap HANYA untuk demo deterministik.
        // Produksi: salt acak 16 byte dari SecureRandom per pengguna.
        byte[] saltBudi = "garam-budi-2026".getBytes(StandardCharsets.UTF_8);
        byte[] saltSiti = "garam-siti-2026".getBytes(StandardCharsets.UTF_8);
        String sandiSama = "rahasiaKuat123";

        byte[] hashBudi = hashPbkdf2(sandiSama, saltBudi);
        byte[] hashSiti = hashPbkdf2(sandiSama, saltSiti);

        System.out.println("=== Registrasi: kata sandi sama, salt berbeda ===");
        System.out.println("Budi : " + HexFormat.of().formatHex(hashBudi));
        System.out.println("Siti : " + HexFormat.of().formatHex(hashSiti));
        System.out.println("Hash berbeda meski kata sandi sama: salt unik per pengguna.");

        System.out.println();
        System.out.println("=== Verifikasi dengan MessageDigest.isEqual ===");
        verifikasi(sandiSama, saltBudi, hashBudi);
        verifikasi("tebakanSalah", saltBudi, hashBudi);
        verifikasi(sandiSama, saltSiti, hashSiti);
    }

    static void verifikasi(String sandiMasukan, byte[] salt, byte[] hashTersimpan)
            throws Exception {
        byte[] hashBaru = hashPbkdf2(sandiMasukan, salt);
        boolean cocok = MessageDigest.isEqual(hashBaru, hashTersimpan);
        System.out.println("sandi [" + sandiMasukan + "] -> "
                + (cocok ? "COCOK" : "TIDAK COCOK"));
    }

    // PBKDF2: hash lambat yang sengaja diulang ribuan kali.
    // Iterasi 1000 dipilih agar demo cepat; produksi memakai ratusan ribu.
    static byte[] hashPbkdf2(String sandi, byte[] salt) throws Exception {
        PBEKeySpec spesifikasi = new PBEKeySpec(
                sandi.toCharArray(), salt, 1000, 256);
        SecretKeyFactory pabrik = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        return pabrik.generateSecret(spesifikasi).getEncoded();
    }
}

Output yang Dihasilkan:

text
=== Registrasi: kata sandi sama, salt berbeda ===
Budi : 169d62d26ff2624f875abd8ad0ac0fbe07c6fafa02a7eb32dc7d1a4cbb1e6c1c
Siti : 60a7da3dfeb7b09e042e61cbb636389b9859a93405b376748250b57e4cd249ec
Hash berbeda meski kata sandi sama: salt unik per pengguna.

=== Verifikasi dengan MessageDigest.isEqual ===
sandi [rahasiaKuat123] -> COCOK
sandi [tebakanSalah] -> TIDAK COCOK
sandi [rahasiaKuat123] -> COCOK

Output membuktikan dua sifat sekaligus. Dua pengguna dengan kata sandi identik menghasilkan hash yang sama sekali berbeda — 169d62d2... milik Budi dan 60a7da3d... milik Siti — karena salt mereka berbeda; penyerang yang membaca basis data tidak bisa lagi menyimpulkan bahwa keduanya memakai kata sandi sama, dan rainbow table generik langsung tidak terpakai. Bagian verifikasi menunjukkan sisi operasional: hash dihitung ulang dari masukan login plus salt tersimpan, lalu dibandingkan — masukan benar menghasilkan COCOK, tebakan salah ditolak.

Bedah baris-per-baris untuk method yang bekerja keras:

  1. Baris 38-39 (new PBEKeySpec(sandi.toCharArray(), salt, 1000, 256)): satu objek spesifikasi membawa seluruh parameter — kata sandi, salt, jumlah iterasi, dan panjang kunci hasil (256 bit). Angka 1000 iterasi dipilih agar demo selesai dalam sekejap; angka produksi dibahas di bagian berikutnya.
  2. Baris 40 (SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")): pola getInstance dari JCA — kita meminta algoritme berdasar nama, provider menyediakan implementasinya. Nama ini tersedia di JDK bawaan tanpa dependency apa pun.
  3. Baris 41 (generateSecret(spesifikasi).getEncoded()): PBKDF2 secara formal adalah fungsi derivasi kunci — menghasilkan kunci dari kata sandi — dan kita memakai kunci 256-bit itu sebagai “hash” yang disimpan. Perbedaan nama ini tidak mengubah perannya: nilai tetap satu arah dan tetap terverifikasi.
  4. Baris 25 (MessageDigest.isEqual(hashBaru, hashTersimpan)): perbandingan memakai fungsi pustaka yang berjalan dalam waktu konstan — dibahas tuntas di bagian akhir bab ini.

PBKDF2 dan Biaya Iterasi #

Penawar kedua — kecepatan — dijawab oleh parameter iterasi. PBKDF2 mengulang fungsi hash dasar ribuan hingga jutaan kali: satu verifikasi login tetap terasa instan bagi pengguna (milidetik), tetapi satu tebakan penyerang kini berbiaya sama mahalnya, dan sapuan kamus yang tadinya satu detik menjadi berbulan-bulan. Biaya verifikasi bisa kita naikkan seiring hardware makin cepat — cukup mengubah parameter iterasi, tanpa mengganti algoritme.

Berapa iterasi yang tepat? Panduan OWASP Password Storage Cheat Sheet untuk PBKDF2-HMAC-SHA256 saat ini menyarankan ratusan ribu iterasi, dan angka rekomendasi itu naik terus dari masa ke masa. Aturan praktisnya: pilih iterasi sehingga verifikasi normal memakan waktu sekitar 100-500 milidetik pada perangkat keras produksi — cukup lambat untuk merontokkan sapuan kamus, cukup cepat agar login tidak terasa berat. Untuk akun dengan nilai tinggi, tambahkan verifikasi dua faktor agar satu faktor yang jatuh tidak langsung membuka pintu.

Perbandingan Waktu Konstan #

Verifikasi kata sandi punya satu kebutuhan yang tidak terlihat: perbandingan hash tidak boleh membocorkan seberapa banyak awalan yang benar. Perbandingan Arrays.equals() atau String.equals() berhenti pada byte pertama yang berbeda — penyerang yang bisa mengukur waktu respons (mikrodetik pun cukup dengan statistik yang tepat) bisa menyusun hash tebakan byte demi byte. MessageDigest.isEqual() menutup celah itu: fungsi ini membandingkan seluruh array selalu dalam jumlah langkah sama, apa pun isi kedua array, sehingga durasi verifikasi tidak membawa informasi yang bisa dipetakan penyerang.

Rekor yang disimpan di basis data kini berbentuk tiga kolom: salt, hash PBKDF2, dan parameter iterasi. Bila kita kelak menaikkan iterasi, hash dihitung ulang saat pengguna login berikutnya dan rekor diperbarui secara diam-diam — pola rehash on login yang menjaga biaya verifikasi tetap sepadan tanpa memaksa reset massal. Hash kata sandi menjaga sesuatu yang diketahui pengguna; bab berikutnya bergerak ke kriptografi data itu sendiri — Message Digests dan Signatures: sidik jari berkas, HMAC untuk autentikasi pesan, dan konsep tanda tangan digital dengan pasangan kunci.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

KataSandiPbkdf2.java

Unduh berkas
java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

public class KataSandiPbkdf2 {

    public static void main(String[] args) throws Exception {
        // Salt tetap HANYA untuk demo deterministik.
        // Produksi: salt acak 16 byte dari SecureRandom per pengguna.
        byte[] saltBudi = "garam-budi-2026".getBytes(StandardCharsets.UTF_8);
        byte[] saltSiti = "garam-siti-2026".getBytes(StandardCharsets.UTF_8);
        String sandiSama = "rahasiaKuat123";

        byte[] hashBudi = hashPbkdf2(sandiSama, saltBudi);
        byte[] hashSiti = hashPbkdf2(sandiSama, saltSiti);

        System.out.println("=== Registrasi: kata sandi sama, salt berbeda ===");
        System.out.println("Budi : " + HexFormat.of().formatHex(hashBudi));
        System.out.println("Siti : " + HexFormat.of().formatHex(hashSiti));
        System.out.println("Hash berbeda meski kata sandi sama: salt unik per pengguna.");

        System.out.println();
        System.out.println("=== Verifikasi dengan MessageDigest.isEqual ===");
        verifikasi(sandiSama, saltBudi, hashBudi);
        verifikasi("tebakanSalah", saltBudi, hashBudi);
        verifikasi(sandiSama, saltSiti, hashSiti);
    }

    static void verifikasi(String sandiMasukan, byte[] salt, byte[] hashTersimpan)
            throws Exception {
        byte[] hashBaru = hashPbkdf2(sandiMasukan, salt);
        boolean cocok = MessageDigest.isEqual(hashBaru, hashTersimpan);
        System.out.println("sandi [" + sandiMasukan + "] -> "
                + (cocok ? "COCOK" : "TIDAK COCOK"));
    }

    // PBKDF2: hash lambat yang sengaja diulang ribuan kali.
    // Iterasi 1000 dipilih agar demo cepat; produksi memakai ratusan ribu.
    static byte[] hashPbkdf2(String sandi, byte[] salt) throws Exception {
        PBEKeySpec spesifikasi = new PBEKeySpec(
                sandi.toCharArray(), salt, 1000, 256);
        SecretKeyFactory pabrik = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        return pabrik.generateSecret(spesifikasi).getEncoded();
    }
}