Setiap kali pengguna mendaftar, aplikasi Anda menyimpan satu rahasia yang membuka akunnya. Jika kolom password berisi teks apa adanya, satu kebocoran database langsung membuka seluruh akun, dan kerugiannya menjalar ke situs lain tempat pengguna memakai password yang sama. Praktik yang benar adalah menyimpan hasil hash password beserta salt. Chapter ini membedah cara kerja hash dan salt, menunjukkan mengapa SHA-256 justru kurang cocok untuk password, lalu memakai bcrypt sebagai standar industri.
Bahaya Menyimpan Password Apa Adanya #
Penyimpanan plaintext membuat siapa pun yang bisa membaca tabel pengguna dapat masuk sebagai siapa saja. Serangan credential stuffing memperparahnya: penyerang mencoba jutaan pasangan email dan password hasil kebocoran situs lain secara otomatis. Dengan hash, setiap tebakan harus melewati fungsi yang mahal. Tabel berikut membandingkan tiga bentuk penyimpanan dari sudut pandang penyerang:
| Bentuk penyimpanan | Yang terjadi saat database bocor |
|---|---|
| Plaintext | Semua password langsung terbaca dan dapat dipakai di situs lain |
| Enkripsi dua arah | Penyerang yang juga menemukan kuncinya dapat mengembalikan semuanya |
| Hash + salt | Penyerang harus menebak tiap password satu per satu dengan komputasi mahal |
Hash Bukan Enkripsi #
Hash dan enkripsi sering tertukar padahal berbeda mendasar. Enkripsi bersifat dua arah: dengan kunci yang tepat, ciphertext dapat dikembalikan menjadi teks asli, sehingga cocok untuk data yang memang perlu dibaca kembali. Hash bersifat satu arah: input sepanjang apa pun berubah menjadi sidik jari berpanjang tetap. Hash bersifat deterministik: input sama selalu menghasilkan output sama, sehingga server memverifikasi password dengan menghitung ulang lalu membandingkan hasilnya, tanpa menyimpan teks aslinya.
Salt Membedakan Hash Pengguna yang Sama #
Tanpa salt, dua pengguna dengan password sama menghasilkan hash yang sama persis, sehingga penyerang cukup mengelompokkan korban dari kolom hash. Penyerang juga memakai rainbow table: kumpulan hash yang sudah dihitung untuk jutaan password populer. Salt mematahkan keduanya: data acak unik per pengguna yang digabungkan ke password sebelum di-hash. Salt tidak perlu dirahasiakan, disimpan bersama hash, dan membuat password sama menghasilkan hash berbeda.
Membedah Salt dan Hash dengan SHA-256 #
Program password-demo.go memakai crypto/sha256 untuk membedah struktur salt + hash secara langsung: bagian pertama memakai salt tetap agar output dapat diprediksi, bagian kedua memakai salt acak dari crypto/rand seperti implementasi sungguhan, dan bagian ketiga menghitung jumlah iterasi bcrypt.
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
)
// hashDenganSalt menghitung SHA-256 dari salt + password, lalu mengembalikan hex.
// SHA-256 dipakai di sini hanya untuk membedah struktur salt + hash.
func hashDenganSalt(salt, password string) string {
jumlah := sha256.Sum256([]byte(salt + password))
return hex.EncodeToString(jumlah[:])
}
// verifikasi menghitung ulang hash lalu membandingkan dengan waktu-konstan.
func verifikasi(salt, hashTersimpan, password string) bool {
hashBaru := hashDenganSalt(salt, password)
sama := subtle.ConstantTimeCompare([]byte(hashBaru), []byte(hashTersimpan))
return sama == 1
}
// saltAcak membuat salt 16 byte dari crypto/rand dalam bentuk hex.
func saltAcak() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func main() {
password := "sandiRahasia123"
// Bagian 1: salt tetap supaya output contoh dapat diprediksi.
saltTetap := "garam-demo-0123456789"
hash := hashDenganSalt(saltTetap, password)
fmt.Println("== Bagian 1: struktur salt + hash ==")
fmt.Println("password :", password)
fmt.Println("salt :", saltTetap)
fmt.Println("hash :", hash)
fmt.Println("hash untuk password lain berbeda :", hash != hashDenganSalt(saltTetap, "passwordSalah"))
// Bagian 2: salt acak seperti implementasi sungguhan.
salt, err := saltAcak()
if err != nil {
log.Fatal(err)
}
hashAcak := hashDenganSalt(salt, password)
saltKedua, err := saltAcak()
if err != nil {
log.Fatal(err)
}
fmt.Println()
fmt.Println("== Bagian 2: salt acak + verifikasi ==")
fmt.Println("panjang salt :", len(salt), "karakter hex")
fmt.Println("password benar :", verifikasi(salt, hashAcak, "sandiRahasia123"))
fmt.Println("password salah :", verifikasi(salt, hashAcak, "passwordSalah"))
fmt.Println("salt beda, hash beda :", hashAcak != hashDenganSalt(saltKedua, password))
// Bagian 3: hubungan cost bcrypt dengan jumlah iterasi (2^cost).
fmt.Println()
fmt.Println("== Bagian 3: cost bcrypt ==")
for _, cost := range []int{10, 12, 14} {
note := ""
if cost == 10 {
note = " (bcrypt.DefaultCost)"
}
fmt.Printf("cost %d : %d iterasi%s\n", cost, 1<<cost, note)
}
}Output yang Dihasilkan:
== Bagian 1: struktur salt + hash ==
password : sandiRahasia123
salt : garam-demo-0123456789
hash : 86bf8ac9c39d252fed8188830815b8f436cd4367751f3efc3165fc194f5d6ce0
hash untuk password lain berbeda : true
== Bagian 2: salt acak + verifikasi ==
panjang salt : 32 karakter hex
password benar : true
password salah : false
salt beda, hash beda : true
== Bagian 3: cost bcrypt ==
cost 10 : 1024 iterasi (bcrypt.DefaultCost)
cost 12 : 4096 iterasi
cost 14 : 16384 iterasiMari kita telaah baris demi baris program di atas:
- Baris 12-17 (
hashDenganSalt): menggabungkansalt + password, menghitungsha256.Sum256, lalu menyandikan hasilnya menjadi hex. - Baris 19-24 (
verifikasi): menghitung ulang hash lalu membandingkannya dengansubtle.ConstantTimeCompare— hasilnya 1 hanya bila string sama, tanpa sinyal waktu bagi penyerang. - Baris 26-33 (
saltAcak): mengisi 16 byte daricrypto/randlalu menyandikannya menjadi hex. - Baris 39-45: salt tetap membuat hash yang tercetak selalu sama; password berbeda terbukti menghasilkan hash berbeda.
- Baris 47-62: verifikasi memakai salt acak. Hash acak sengaja tidak dicetak karena berubah tiap eksekusi; hanya hasil perbandingan yang deterministik.
- Baris 64-73:
1<<costmenghitung jumlah iterasi bcrypt; setiap kenaikan cost satu menggandakan kerja.
Bcrypt: Hash yang Sengaja Lambat #
Bcrypt adalah fungsi hash password berbasis cipher Blowfish: lambat secara terkendali, salt otomatis, dan teruji puluhan tahun. Package golang.org/x/crypto/bcrypt membuat pemakaiannya singkat:
// Membutuhkan dependency: go get golang.org/x/crypto/bcrypt
hash, err := bcrypt.GenerateFromPassword([]byte("sandiRahasia123"), bcrypt.DefaultCost)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(hash)) // baris ini mencetak hash yang berbeda pada tiap eksekusi
err = bcrypt.CompareHashAndPassword(hash, []byte("sandiRahasia123"))
fmt.Println("password benar:", err == nil)
err = bcrypt.CompareHashAndPassword(hash, []byte("passwordSalah"))
fmt.Println("password salah:", err == nil)GenerateFromPassword membuat salt acak otomatis dan menyerapkannya ke string hasil, sehingga tidak perlu kolom salt terpisah. CompareHashAndPassword mengurai salt dari hash lalu memverifikasi dengan aman; password salah menghasilkan error yang dapat diperiksa dengan errors.Is(err, bcrypt.ErrMismatchedHashAndPassword). Dua baris verifikasi terakhir di atas dijalankan sungguhan pada module sementara di luar bundle chapter:
password benar: true
password salah: falseCost mengendalikan kecepatan: tiap kenaikan satu menggandakan pekerjaan. Pilih cost yang membuat satu verifikasi memakan waktu sekitar 100-250 milidetik di server Anda.
Argon2id, Pepper, dan Kebijakan Panjang Password #
Argon2id dari package golang.org/x/crypto/argon2 pemenang Password Hashing Competition dan pilihan pertama OWASP, memakai memori besar sehingga GPU penyerang tidak lagi efisien. Sebagai lapisan tambahan, pepper adalah nilai rahasia global yang digabungkan ke setiap password sebelum di-hash. Berbeda dari salt, pepper disimpan terpisah dari database (misalnya environment variable), sehingga salinan database saja tidak cukup untuk menguji tebakan.
Kebijakan password melengkapi algoritma: tetapkan panjang minimum delapan karakter, tolak password dari daftar kebocoran umum, dan jangan paksa rotasi berkala karena kebijakan tersebut hanya mendorong pola lemah. Hashing menjawab cara menyimpan rahasia secara aman; tugas berikutnya adalah membuat server mengingat bahwa Budi sudah login — materi session management pada chapter berikutnya.
Referensi #
- Go Security — praktik keamanan resmi Go.
- Package bcrypt — dokumentasi resmi GenerateFromPassword dan CompareHashAndPassword.
- OWASP Password Storage Cheat Sheet — urutan rekomendasi algoritma hashing.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
password-demo.go
Unduh berkaspackage main
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
)
// hashDenganSalt menghitung SHA-256 dari salt + password, lalu mengembalikan hex.
// SHA-256 dipakai di sini hanya untuk membedah struktur salt + hash.
func hashDenganSalt(salt, password string) string {
jumlah := sha256.Sum256([]byte(salt + password))
return hex.EncodeToString(jumlah[:])
}
// verifikasi menghitung ulang hash lalu membandingkan dengan waktu-konstan.
func verifikasi(salt, hashTersimpan, password string) bool {
hashBaru := hashDenganSalt(salt, password)
sama := subtle.ConstantTimeCompare([]byte(hashBaru), []byte(hashTersimpan))
return sama == 1
}
// saltAcak membuat salt 16 byte dari crypto/rand dalam bentuk hex.
func saltAcak() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func main() {
password := "sandiRahasia123"
// Bagian 1: salt tetap supaya output contoh dapat diprediksi.
saltTetap := "garam-demo-0123456789"
hash := hashDenganSalt(saltTetap, password)
fmt.Println("== Bagian 1: struktur salt + hash ==")
fmt.Println("password :", password)
fmt.Println("salt :", saltTetap)
fmt.Println("hash :", hash)
fmt.Println("hash untuk password lain berbeda :", hash != hashDenganSalt(saltTetap, "passwordSalah"))
// Bagian 2: salt acak seperti implementasi sungguhan.
salt, err := saltAcak()
if err != nil {
log.Fatal(err)
}
hashAcak := hashDenganSalt(salt, password)
saltKedua, err := saltAcak()
if err != nil {
log.Fatal(err)
}
fmt.Println()
fmt.Println("== Bagian 2: salt acak + verifikasi ==")
fmt.Println("panjang salt :", len(salt), "karakter hex")
fmt.Println("password benar :", verifikasi(salt, hashAcak, "sandiRahasia123"))
fmt.Println("password salah :", verifikasi(salt, hashAcak, "passwordSalah"))
fmt.Println("salt beda, hash beda :", hashAcak != hashDenganSalt(saltKedua, password))
// Bagian 3: hubungan cost bcrypt dengan jumlah iterasi (2^cost).
fmt.Println()
fmt.Println("== Bagian 3: cost bcrypt ==")
for _, cost := range []int{10, 12, 14} {
note := ""
if cost == 10 {
note = " (bcrypt.DefaultCost)"
}
fmt.Printf("cost %d : %d iterasi%s\n", cost, 1<<cost, note)
}
}