Hashing sandi
Sandi tidak pernah disimpan, tidak pernah dienkripsi, dan tidak pernah di-hash dengan SHA-256. Yang dipakai adalah fungsi yang sengaja lambat — dan seberapa lambat adalah keputusan yang harus kamu ambil sadar.
Intisari
- bcrypt aman dan tersedia; Argon2id lebih kuat terhadap serangan GPU dan direkomendasikan untuk sistem baru.
MD5,SHA-1,SHA-256bukan untuk sandi — terlalu cepat, jutaan tebakan per detik.- Salt dibuat otomatis dan disimpan di dalam hash. Kamu tidak perlu mengurusnya.
- bcrypt memotong sandi di 72 byte — pra-hash dengan SHA-256 kalau frasa sandi panjang diizinkan.
- Selalu jalankan pembandingan hash meski penggunanya tidak ada, supaya waktu responsnya tidak membocorkan siapa yang terdaftar.
bcrypt
import "golang.org/x/crypto/bcrypt"
func HashSandi(sandi string) (string, error) {
if len(sandi) > 72 {
return "", ErrSandiTerlaluPanjang // bcrypt memotong diam-diam
}
b, err := bcrypt.GenerateFromPassword([]byte(sandi), 12) // biaya 12
if err != nil {
return "", fmt.Errorf("hash sandi: %w", err)
}
return string(b), nil
}
func PeriksaSandi(hash, sandi string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(sandi)) == nil
}
$2a$12$N9qo8uLOickgx2ZMRZoMye.IjPeCLQK6IbnCtHwHz7Kk8vY7Zg9Wm
│ │ │ │
│ │ └─ salt (22 karakter) └─ hash
│ └──── biaya (2^12 iterasi)
└─────── varian algoritma
Salt sudah ada di dalamnya. Karena itu dua pengguna dengan sandi identik tetap punya hash yang berbeda, dan tabel pelangi tidak berguna. Kamu tidak perlu kolom salt terpisah — kalau sebuah tutorial menyuruhmu membuatnya, tutorial itu bicara tentang algoritma lain.
Memilih biaya
| Biaya | Waktu per hash | Untuk |
|---|---|---|
| 10 | ~50 ms | Terlalu rendah untuk sistem baru |
| 12 | ~250 ms | Titik awal yang wajar |
| 14 | ~1 dtk | Data sangat sensitif; perhatikan beban CPU |
Biaya hashing adalah beban CPU yang nyata di jalur login. Pada task Fargate 0,5 vCPU, biaya 12 berarti sekitar empat login per detik per task — dan selama itu CPU-nya tidak melayani hal lain. Karena itu rate limit di endpoint login bukan opsional (materi terpisah di fase ini): tanpa itu, siapa pun bisa menghabiskan CPU-mu hanya dengan mengirim sandi salah.
Argon2id
import "golang.org/x/crypto/argon2"
type ParamArgon struct {
Waktu uint32 // iterasi
Memori uint32 // KiB
Thread uint8
Panjang uint32
}
var argonBaku = ParamArgon{Waktu: 2, Memori: 64 * 1024, Thread: 4, Panjang: 32}
func HashArgon(sandi string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil { // crypto/rand, BUKAN math/rand
return "", err
}
h := argon2.IDKey([]byte(sandi), salt,
argonBaku.Waktu, argonBaku.Memori, argonBaku.Thread, argonBaku.Panjang)
// Simpan parameternya bersama hash: tanpa itu, menaikkan
// parameter nanti akan mematahkan semua sandi lama.
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonBaku.Memori, argonBaku.Waktu, argonBaku.Thread,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(h)), nil
}
| bcrypt | Argon2id | |
|---|---|---|
| Umur & keteruujian | Sejak 1999, sangat teruji | Pemenang kompetisi 2015 |
| Tahan GPU/ASIC | Sedang | Tinggi (memory-hard) |
| Batas panjang sandi | 72 byte | Tidak ada |
| Parameter | Satu angka | Tiga — harus dipilih dan disimpan |
| Rekomendasi | Aman; wajar untuk yang sudah ada | Untuk sistem baru |
Login yang tidak membocorkan apa pun
// Hash palsu berbiaya sama, dipakai saat penggunanya tidak ada.
var hashUmpan, _ = bcrypt.GenerateFromPassword([]byte("umpan"), 12)
func (s *Layanan) Masuk(ctx context.Context, email, sandi string) (Pengguna, error) {
u, err := s.simpan.DariEmail(ctx, email)
if errors.Is(err, ErrTidakDitemukan) {
// Tetap jalankan hashing supaya waktu responsnya sama —
// tanpa ini, penyerang bisa menebak email mana yang terdaftar
// hanya dengan mengukur waktu balasan.
_ = bcrypt.CompareHashAndPassword(hashUmpan, []byte(sandi))
return Pengguna{}, ErrKredensialSalah
}
if err != nil {
return Pengguna{}, err
}
if !PeriksaSandi(u.Hash, sandi) {
return Pengguna{}, ErrKredensialSalah // pesan yang SAMA
}
return u, nil
}
Pesan error harus identik untuk "email tidak ada" dan "sandi salah". Membedakannya memberi penyerang daftar akun yang valid — bahan untuk credential stuffing. Dan pesan yang sama saja tidak cukup kalau waktunya berbeda; itulah gunanya hash umpan di atas.
Menaikkan biaya tanpa mengganggu pengguna
func (s *Layanan) Masuk(...) (Pengguna, error) {
...
if !PeriksaSandi(u.Hash, sandi) {
return Pengguna{}, ErrKredensialSalah
}
// Sandinya benar — dan hanya di sini kita punya sandi polosnya.
// Kalau hash lama memakai biaya rendah, hash ulang sekarang.
if biaya, err := bcrypt.Cost([]byte(u.Hash)); err == nil && biaya < 12 {
if baru, err := HashSandi(sandi); err == nil {
_ = s.simpan.PerbaruiHash(ctx, u.ID, baru)
}
}
return u, nil
}
Aturan sandi yang benar-benar berguna
- Minimal 8 karakter — dan jangan batasi maksimumnya di bawah 64.
- Jangan wajibkan campuran karakter. Ia menghasilkan
Password1!, bukan sandi kuat. - Tolak sandi yang sudah bocor — periksa terhadap daftar sandi umum saat pendaftaran.
- Izinkan tempel dan pengelola sandi. Menonaktifkannya membuat orang memakai sandi lebih lemah.
- Jangan wajibkan penggantian berkala tanpa indikasi kebocoran; ia menghasilkan sandi yang lebih buruk.
- Tawarkan 2FA untuk akun yang menyentuh uang atau data pribadi.
Latihan: ukur waktu bcrypt.GenerateFromPassword di mesinmu untuk biaya 10, 12,
dan 14. Lalu tulis endpoint login dan ukur waktu responsnya untuk email yang ada versus tidak ada —
tanpa hash umpan, lalu dengan. Perbedaan yang terukur itu adalah kebocoran informasi yang
sungguhan.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.