Penyimpanan Kata Sandi

Penyimpanan kata sandi yang benar dengan Argon2 dan bcrypt.

Daftar isi

Kata sandi yang bocor dari satu situs digunakan ulang penggunanya pada situs lain, sehingga cara aplikasi menyimpan kata sandi menentukan skala kerusakan ketika basis datanya dicuri. Penyimpanan yang benar tetap memungkinkan verifikasi saat login, tetapi membuat pemulihan kata sandi asli dari hash menjadi mahal sekali bagi penyerang. Topik ini membahas syarat penyimpanan yang layak, membandingkan Argon2id dengan hash cepat, memperkenalkan bcrypt sebagai pilihan yang mapan, dan menutupnya dengan alternatif yang dapat diterima, yaitu scrypt dan PBKDF2.

Syarat penyimpanan kata sandi#

Server tidak pernah membutuhkan kata sandi aslinya, melainkan hanya kemampuan menjawab apakah sebuah masukan sama dengan yang disetorkan sebelumnya. Kebutuhan itu dipenuhi oleh fungsi hash satu arah, yaitu fungsi yang mudah dihitung tetapi sulit dibalik. Hash saja tidak cukup, karena penyerang yang memegang tabel hash dapat menghitung hash untuk jutaan kandidat kata sandi lalu mencocokkannya. Tiga syarat menaikkan biaya pencocokan tersebut, yaitu garam (salt) berupa nilai acak unik per pengguna yang mencegah dua kata sandi yang sama menghasilkan hash yang sama, faktor kerja yang membuat setiap perhitungan mahal, dan sifat memori sulit (memory-hard) yang membuat perhitungan murah pada GPU menjadi mahal pula.

NIST SP 800-63B mewajibkan kata sandi disimpan dalam bentuk hash bergaram yang menggunakan fungsi yang disetujui, sedangkan OWASP Password Storage Cheat Sheet mengurutkan pilihannya secara lebih rinci, yaitu Argon2id terlebih dahulu, disusul scrypt, bcrypt, dan PBKDF2 sebagai pilihan yang dapat diterima. Sifat umum fungsi hash dibahas secara mendalam pada jalur Cryptography. Topik ini memfokuskan diri pada penggunaan yang benar di sisi aplikasi beserta keluaran nyatanya.

Membandingkan Argon2id dengan hash cepat#

Skrip berikut menyimpan kata sandi contoh dengan Argon2id pada parameter minimum OWASP, yaitu m=19456 KiB memori, t=2 putaran, dan p=1 jalur paralel, lalu memverifikasi kata sandi yang benar dan yang salah. Setelah itu, scrypt dan PBKDF2 disiapkan sebagai pembanding yang dapat diterima, dan bagian terakhir memperlihatkan mengapa MD5 serta SHA-256 polos tidak layak. Garam scrypt dan PBKDF2 dibuat tetap agar keluaran dapat diulang, sedangkan Argon2id menggunakan garam acak bawaan library sehingga keluarannya hanya memuat struktur hash dan hasil verifikasi.

python hash-kata-sandi.py
"""Membandingkan penyimpanan kata sandi yang benar dengan hash cepat yang lemah.

Argon2id dipakai dengan parameter minimum OWASP (m=19456 KiB, t=2, p=1),
disusul scrypt dan PBKDF2 sebagai pilihan yang dapat diterima. Garam scrypt
dan PBKDF2 dibuat tetap agar keluaran dapat diulang, sedangkan Argon2id
memakai garam acak bawaan library sehingga hanya struktur dan hasil
verifikasinya yang dicetak, bukan nilai hash utuhnya.
"""

import hashlib

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

KATA_SANDI = "kata-sandi-benar"

print("== Argon2id, pilihan pertama OWASP ==")
hasher = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
hash_argon2 = hasher.hash(KATA_SANDI)
hash_argon2_dua = hasher.hash(KATA_SANDI)
print("format hash   :", hash_argon2[:30] + "... (lanjutannya garam acak, tidak dicetak)")
print("panjang hash  :", len(hash_argon2), "karakter")
print("hash kedua identik:", hash_argon2 == hash_argon2_dua, "(garam acak per hash)")
try:
    hasher.verify(hash_argon2, KATA_SANDI)
    print("verifikasi kata sandi benar : True")
except VerifyMismatchError:
    print("verifikasi kata sandi benar : False")
try:
    hasher.verify(hash_argon2, "kata-sandi-salah")
    print("verifikasi kata sandi salah : True")
except VerifyMismatchError:
    print("verifikasi kata sandi salah : False (VerifyMismatchError dilempar)")

print()
print("== scrypt dan PBKDF2, pilihan yang dapat diterima ==")
garam = b"garam-tetap-16bt"  # 16 byte; pada praktiknya acak per pengguna
hash_scrypt = hashlib.scrypt(KATA_SANDI.encode(), salt=garam, n=2**14, r=8, p=1, dklen=32).hex()
hash_pbkdf2 = hashlib.pbkdf2_hmac("sha256", KATA_SANDI.encode(), garam, 600_000, dklen=32).hex()
print("scrypt (n=16384, r=8)  :", hash_scrypt[:16] + "...", f"({len(hash_scrypt)} digit heksadesimal)")
print("pbkdf2 (600000 iterasi):", hash_pbkdf2[:16] + "...", f"({len(hash_pbkdf2)} digit heksadesimal)")

print()
print("== Mengapa MD5 dan SHA-256 polos tidak layak ==")
print("md5   :", hashlib.md5(KATA_SANDI.encode()).hexdigest())
print("sha256:", hashlib.sha256(KATA_SANDI.encode()).hexdigest())
print("md5 tanpa garam untuk kata sandi yang sama selalu identik:",
      hashlib.md5(KATA_SANDI.encode()).digest() == hashlib.md5(KATA_SANDI.encode()).digest())
text Keluaran
== Argon2id, pilihan pertama OWASP ==
format hash   : $argon2id$v=19$m=19456,t=2,p=1... (lanjutannya garam acak, tidak dicetak)
panjang hash  : 97 karakter
hash kedua identik: False (garam acak per hash)
verifikasi kata sandi benar : True
verifikasi kata sandi salah : False (VerifyMismatchError dilempar)

== scrypt dan PBKDF2, pilihan yang dapat diterima ==
scrypt (n=16384, r=8)  : b7f2780c4e73cf66... (64 digit heksadesimal)
pbkdf2 (600000 iterasi): 1283a04dc36dcfc0... (64 digit heksadesimal)

== Mengapa MD5 dan SHA-256 polos tidak layak ==
md5   : accbdec6978162806bfb7b80d0fa4882
sha256: cd490fe55e8a118ffd36ba92d2b77b783da6704a89e6bcadd5a0aa8bf845bb85
md5 tanpa garam untuk kata sandi yang sama selalu identik: True

Bagian Argon2id memperlihatkan format $argon2id$v=19$m=19456,t=2,p=1$, yaitu versi algoritma beserta parameternya yang tertulis langsung di dalam string hash. Susunan ini membuat kenaikan parameter tidak merusak hash lama, karena library membaca parameter dari stringnya sendiri dan bukan dari konfigurasi saat ini. Garam acak membuat dua hash untuk kata sandi yang sama tetap berbeda, terlihat pada baris hash kedua identik: False, dan itulah yang menghalangi pengaitan antarpengguna yang menggunakan kata sandi sama. Verifikasi diserahkan kepada hasher.verify, yang menyelesaikan perbandingan dalam waktu konstan dan melempar VerifyMismatchError apabila masukan tidak cocok.

Bagian terakhir menunjukkan kegagalan hash cepat pada ketiga syarat. MD5 dan SHA-256 polos dihitung sangat cepat bahkan pada perangkat keras konsumen, tidak membawa garam, dan tidak memiliki faktor kerja, sehingga satu tabel pra-hitung cukup untuk seluruh basis data. Baris terakhir memperlihatkan bukti kecilnya, yaitu MD5 untuk kata sandi yang sama selalu menghasilkan nilai identik, sehingga kembaran kata sandi antarpengguna terbaca langsung dari kolom hash yang dicuri.

bcrypt pada proyek nyata#

Banyak proyek mapan masih menyimpan kata sandi dengan bcrypt, dan library bcrypt pada Python adalah antarmuka yang umum digunakan. Library tersebut tidak terpasang pada sandbox penulisan, sehingga contoh berikut diberi tanda tidak-dijalankan dan tidak menyertakan blok keluaran. Berkasnya tetap utuh dan dapat dijalankan pembaca setelah bcrypt dipasang, dan bentuk pemanggilannya sengaja dibuat sejajar dengan contoh Argon2id agar keduanya mudah dibandingkan.

python hash-bcrypt.py
# tidak-dijalankan
"""Contoh penyimpanan kata sandi dengan bcrypt.

Library bcrypt tidak terpasang pada sandbox penulisan, sehingga blok
ini tidak dieksekusi dan tidak menyertakan keluaran. Pada proyek nyata,
pasang terlebih dahulu dengan pip install bcrypt, kemudian berkas ini
dapat dijalankan apa adanya dengan python3 hash-bcrypt.py.

bcrypt menempati urutan ketiga rekomendasi OWASP, setelah Argon2id dan
scrypt, dengan catatan kata sandi terpotong pada 72 byte pertama.
"""

import bcrypt

KATA_SANDI = "kata-sandi-benar"

# hash dengan garam yang dibangkitkan otomatis, faktor kerja 12 putaran
hash_bcrypt = bcrypt.hashpw(KATA_SANDI.encode(), bcrypt.gensalt(rounds=12))
print("format hash :", hash_bcrypt.decode()[:7] + "...", "(60 karakter total, garam menyatu di dalamnya)")

# verifikasi membandingkan kata sandi dengan hash tanpa membocorkan waktunya
print("verifikasi kata sandi benar:", bcrypt.checkpw(KATA_SANDI.encode(), hash_bcrypt))
print("verifikasi kata sandi salah:", bcrypt.checkpw("kata-sandi-salah".encode(), hash_bcrypt))

String hash bcrypt berawalan $2b$12$, yaitu versi algoritma dan faktor kerja berupa logaritma basis dua dari jumlah putaran, disusul garam dan hash yang semuanya menyatu dalam satu string 60 karakter sebagaimana Argon2id. Faktor kerja dinaikkan dengan menaikkan angkanya, dan setiap kenaikan satu tingkat memperpanjang waktu perhitungan kira-kira dua kali. Fungsi checkpw melakukan verifikasi dengan perbandingan konstan waktu, sehingga pola penanganan hasilnya sama dengan contoh sebelumnya.

Satu catatan yang sering terlewat, yaitu bcrypt hanya memproses 72 byte pertama masukan, sehingga dua kata sandi yang berbeda setelah batas itu menghasilkan verifikasi yang sama. Beberapa kerangka kerja mengatasi batas ini dengan mempraproses masukan, tetapi kebijakan yang lebih sederhana adalah menetapkan panjang maksimum yang wajar dan memilih Argon2id apabila tersedia.

Kesalahan umum#

  • Menyimpan hash MD5 atau SHA-256 dengan alasan kompatibilitas sistem lama. Kedua algoritma itu dirancang cepat, sehingga basis data yang bocor segera dipecahkan secara massal. Migrasikan ke Argon2id dan tuntut penggantian kata sandi saat login berikutnya.
  • Menghitung garam secara deterministik dari data pengguna, misalnya dari username. Garam yang dapat diprediksi memunculkan kembali tabel pra-hitung, sehingga garam wajib acak dan unik untuk setiap pengguna.
  • Menaikkan parameter tanpa strategi migrasi. Hash lama tetap terbaca karena parameternya tertulis di dalam string hash, tetapi hash baru tidak muncul dengan sendirinya, sehingga hash dihitung ulang saat login bila check_needs_rehash menyatakan perlu.
  • Membandingkan hash dengan operator == pada kode buatan sendiri. Perbandingan yang berhenti lebih cepat pada perbedaan pertama membuka peluang pengukuran waktu, sehingga verifikasi diserahkan kepada fungsi library yang konstan waktunya.

Ringkasan#

  • Penyimpanan kata sandi yang layak memadukan garam acak per pengguna, faktor kerja, dan sifat memori sulit, sehingga verifikasi tetap murah bagi server tetapi mahal bagi penyerang.
  • Argon2id dengan parameter minimum OWASP m=19456, t=2, p=1 menjadi pilihan pertama, disusul scrypt, bcrypt, dan PBKDF2 sebagai pilihan yang dapat diterima.
  • Parameter yang tertulis pada string hash membuat kenaikan parameter tidak merusak hash lama, sedangkan garam acak membuat kembaran kata sandi tidak terbaca.
  • MD5 dan SHA-256 polos gagal pada ketiga syarat, yaitu cepat, tanpa garam, dan tanpa faktor kerja, sehingga keduanya tidak pernah layak untuk kata sandi.

Referensi#