Gerbang pertama dari Chapter sebelumnya bertumpu pada satu kolom data yang paling sensitif: sandi pengguna. Menyimpan sandi dalam bentuk aslinya berarti satu kebocoran database membuka semua akun sekaligus — apalagi sandi yang sama sering dipakai ulang di banyak layanan. Solusinya adalah menyimpan hash sandi: hasil olahan satu arah yang bisa diperiksa kecocokannya tanpa pernah menyimpan sandi aslinya. Node.js membawa fungsi hash khusus sandi ini langsung dari pabrik lewat node:crypto, tanpa package tambahan.
Hashing Bukan Enkripsi #
Dua teknik ini sering tertukar padahal tujuannya berlawanan. Enkripsi dirancang untuk bisa dibuka kembali oleh pihak yang memegang kunci; hashing dirancang agar tidak ada jalan kembali — dari hasil hash, sandi asli tidak bisa dihitung balik kecuali dengan menebak satu per satu.
| Aspek | Enkripsi | Hashing |
|---|---|---|
| Arah | Dua arah: bisa didekripsi dengan kunci | Satu arah: tidak bisa dibalik |
| Kunci | Wajib ada dan harus dijaga kerahasiaannya | Tidak ada kunci dekripsi |
| Kapan dipakai | Data yang kelak harus dibaca lagi (isi pesan, berkas cadangan) | Data yang cukup dicocokkan (sandi) |
| Contoh di Node.js | createCipheriv | scryptSync |
Verifikasi sandi memanfaatkan sifat satu arah itu. Saat pendaftaran, server menyimpan hash(sandi). Saat login, server menghitung hash(sandi_masukan) lalu membandingkan hasilnya dengan yang tersimpan. Sandi asli tidak pernah perlu disimpan ataupun dibaca kembali.
Salt Menetralkan Sandi yang Sama #
Hash tanpa bahan tambahan punya kelemahan: sandi yang sama selalu menghasilkan hash yang sama. Pembaca database yang tidak berhak langsung tahu dua pengguna memakai sandi identik, dan bisa menyiapkan tabel hash jadi (rainbow table) berisi jutaan sandi umum. Salt memutus kedua masalah itu — nilai acak per pengguna yang ikut menjadi bahan olahan hash dan disimpan berampingan dengannya:
- dua pengguna bersandi sama menghasilkan hash berbeda karena salt-nya berbeda;
- tabel hash jadi menjadi tidak terpakai karena setiap salt menuntut perhitungan ulang dari nol.
Salt tidak perlu dirahasiakan. Kekuatannya ada pada keunikan per pengguna, bukan pada kerahasiaannya, sehingga salt aman disimpan dalam string yang sama dengan hash.
scrypt dan Parameter Biaya #
scrypt adalah fungsi hash sandi yang sengaja dibuat mahal — kebalikan dari hash cepat seperti sha256. Mesin penyerang yang menebak miliaran sandi dipaksa membayar memori dan waktu untuk setiap tebakan, sehingga tebakan massal melambat drastis. Mahalnya diatur parameter N (di dokumentasi Node.js disebut cost): nilai bakunya 16384, dan angkanya lazim dinaikkan seiring bertambahnya kekuatan perangkat dari tahun ke tahun. Pada chapter ini kita memakai scryptSync agar alurnya ringkas; catatan penutup di bawah membahas versi asinkron untuk server produksi.
Satu Berkas: Hash, Simpan, Verifikasi #
Berkas berikut merangkai siklus lengkapnya: menghasilkan rekaman salt:hash, memverifikasi sandi yang benar dan yang salah, lalu membuktikan dua efek salt sekaligus. Salt pada contoh sengaja dikunci agar hasilnya bisa dibandingkan antar pembaca; kode produksi memakai randomBytes sebagaimana dicatat di komentar.
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
const PANJANG_HASH = 32; // byte hasil akhir scrypt
// Hasil simpan berbentuk "salt:hash" seperti baris di kolom password database.
function hashSandi(sandi, salt) {
const hash = scryptSync(sandi, salt, PANJANG_HASH).toString('hex');
return `${salt}:${hash}`;
}
function verifikasiSandi(sandi, rekaman) {
const [salt, hashHex] = rekaman.split(':');
const hashBaru = scryptSync(sandi, salt, PANJANG_HASH);
const hashTersimpan = Buffer.from(hashHex, 'hex');
const samaPanjang = hashBaru.length === hashTersimpan.length;
return samaPanjang && timingSafeEqual(hashBaru, hashTersimpan);
}
// Salt contoh ini dikunci supaya output latihan selalu sama.
// Kode produksi memakai: randomBytes(16).toString('hex')
const saltBudi = '5f8a3c91d2e4b6071a9c3e5d7f2b4a68';
const saltSiti = '0c2e7a41f9b3d586c1e0a4d2b7f3965c';
const rekamanBudi = hashSandi('sandi-budi-2026', saltBudi);
console.log('Rekaman Budi (salt:hash):');
console.log(rekamanBudi);
console.log('Panjang salt (hex):', saltBudi.length, 'karakter');
console.log('Panjang hash (hex):', rekamanBudi.split(':')[1].length, 'karakter');
console.log('Verifikasi sandi benar:', verifikasiSandi('sandi-budi-2026', rekamanBudi));
console.log('Verifikasi sandi salah:', verifikasiSandi('sandi-salah', rekamanBudi));
// Sandi yang sama pun menghasilkan hash berbeda bila salt-nya beda.
const rekamanSiti = hashSandi('sandi-budi-2026', saltSiti);
console.log('Sandi sama + salt beda :', rekamanBudi.split(':')[1] !== rekamanSiti.split(':')[1]);
// randomBytes menghasilkan salt baru yang tidak pernah terpakai dua kali.
console.log('Panjang salt acak :', randomBytes(16).toString('hex').length, 'karakter');
// timingSafeEqual menolak membandingkan buffer dengan panjang berbeda.
try {
timingSafeEqual(Buffer.from('abc'), Buffer.from('abcd'));
} catch (e) {
console.log('Error panjang berbeda :', e.code);
}Output yang Dihasilkan:
Rekaman Budi (salt:hash):
5f8a3c91d2e4b6071a9c3e5d7f2b4a68:2a9ebaf42bbaa0d4fb4ee23dc25150f4c588cebc1cb8d8c1912032147623ef39
Panjang salt (hex): 32 karakter
Panjang hash (hex): 64 karakter
Verifikasi sandi benar: true
Verifikasi sandi salah: false
Sandi sama + salt beda : true
Panjang salt acak : 32 karakter
Error panjang berbeda : ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHMari kita bedah bagian intinya baris-per-baris:
- Baris 6-9 (
hashSandi):scryptSync(sandi, salt, PANJANG_HASH)mengolah pasangan sandi-salt menjadi 32 byte yang deterministik — masukan yang sama selalu menghasilkan hash yang sama, sehingga verifikasi memungkinkan. Hasil dikemas sebagai satu stringsalt:hashsiap disimpan ke kolom database. - Baris 11-17 (
verifikasiSandi): rekaman diurai kembali menjadi salt dan hash tersimpan, sandi masukan di-hash ulang memakai salt yang sama, lalu hasilnya dibandingkan lewattimingSafeEqual— bukan===. Perbandingan===berhenti di byte pertama yang berbeda sehingga lama pemeriksaan membocorkan berapa banyak awal sandi yang benar;timingSafeEqualmemindai seluruh buffer dengan durasi tetap. - Baris 15 (
samaPanjang):timingSafeEqualmelemparRangeErrorbila kedua buffer beda panjang, sehingga panjang diperiksa lebih dulu. Baris 41-45 memperlihatkan kode errorERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHyang muncul bila pengaman ini dilewati. - Baris 24-28: rekaman Budi tercetak penuh — format
salt:hashtampak jelas dengan salt 32 karakter hex (16 byte) dan hash 64 karakter hex (32 byte). - Baris 33-35: sandi yang sama diolah dengan salt berbeda menghasilkan hash berbeda. Ini bukti langsung manfaat salt yang dibahas di awal chapter.
Rekaman salt:hash kini siap menggantikan kolom sandi di database. Pertanyaan berikutnya muncul begitu login berhasil: bagaimana server mengingat bahwa Budi sudah masuk pada permintaan-permintaan berikutnya? Jawaban pertama untuk itu adalah session bersama cookie — materi Chapter Session dan Cookie.
Dokumentasi resmi untuk fungsi-fungsi yang dipakai: scryptSync
, timingSafeEqual
, dan randomBytes
pada Node.js API.