Mục tiêu học tập
- Hiểu vì sao không bao giờ được lưu password dạng plaintext hay hash thường (MD5/SHA-256)
- Nắm ba thuật toán hashing chuẩn cho password: bcrypt, argon2id, scrypt
- Phân biệt salt (per-user) và pepper (server secret), hiểu vai trò từng cái
- Đọc và viết code hashing/verify bằng Node.js và Python
- Tránh common mistakes: short salt, low work factor, custom crypto, timing attack
1. Vì sao plaintext password là thảm họa
Kịch bản breach
Hậu quả:
- Credential stuffing: 60-80% người dùng tái sử dụng password → attacker thử cùng password trên Gmail, Facebook, ngân hàng
- Phishing target list: attacker biết email + password pattern
- Legal liability: GDPR/CCPA phạt rất nặng nếu để lộ plaintext
- Reputation: công ty mất hoàn toàn niềm tin (Adobe 2013, Yahoo 2013, LinkedIn 2012)
Vì sao SHA-256/MD5 cũng không đủ
Hash thường được thiết kế cho tốc độ — đó chính xác là điều bạn KHÔNG muốn cho password.
SHA-256 trên GPU hiện đại (RTX 4090):
~10 tỷ hash/giây
Tấn công thực tế:
- Rainbow table: precomputed hash của top 10 triệu password phổ biến — lookup trong micro giây
- Brute force 8-char password: ~vài giờ trên một GPU
- Dictionary attack: thử từ điển + biến thể (P@ssw0rd, sunshine123) — vài phút
Hash thường (MD5, SHA-1, SHA-256, SHA-512) không có:
- Salt tự động → cùng password = cùng hash → rainbow table sống khỏe
- Work factor → không thể chậm lại khi GPU nhanh hơn
- Memory hardness → GPU/ASIC phá vỡ dễ dàng
2. Password hashing algorithms chuẩn
bcrypt (1999)
Đặc điểm:
- Dựa trên Blowfish cipher, có work factor điều chỉnh được (4-31, mặc định 10-12)
- Salt tự động sinh và embed trong output → không cần lưu riêng
- Giới hạn input 72 bytes (nên pre-hash bằng SHA-256 nếu password dài hơn)
- Đã chiến đấu qua 25+ năm — battle-tested
argon2id (2015, winner of Password Hashing Competition)
Đặc điểm:
- Memory-hard: cần nhiều RAM → GPU/ASIC tấn công đắt đỏ
- 3 variants: argon2d (GPU-resistant), argon2i (side-channel resistant), argon2id (hybrid, RECOMMENDED)
- OWASP recommended cho ứng dụng mới (2024)
- Parameters tunable: m (memory), t (iterations), p (parallelism)
scrypt (2009)
Đặc điểm:
- Memory-hard tương tự argon2 nhưng cũ hơn
- Dùng trong Litecoin, một số hệ thống Linux
- Vẫn an toàn nhưng argon2id được khuyến nghị hơn cho dự án mới
So sánh
| Thuật toán | Năm | Memory-hard | Work factor | Khi nào dùng |
|---|---|---|---|---|
| argon2id | 2015 | Có | m, t, p | Dự án mới (OWASP top pick) |
| bcrypt | 1999 | Không | cost (4-31) | Legacy hỗ trợ tốt, default trong nhiều framework |
| scrypt | 2009 | Có | N, r, p | Đã dùng từ trước, không cần thay |
| PBKDF2 | 2000 | Không | iterations | Khi cần FIPS-140 compliance (gov/finance) |
| SHA-256/MD5 | — | — | — | KHÔNG BAO GIỜ cho password |
Tham số khuyến nghị (2024-2025, OWASP)
bcrypt: cost = 12 (hoặc 13 nếu server mạnh)
argon2id: m=19456 (19 MiB), t=2, p=1 ← minimum
m=65536 (64 MiB), t=3, p=4 ← stronger
scrypt: N=2^17, r=8, p=1
PBKDF2-SHA256: iterations = 600,000
Rule of thumb: chọn tham số sao cho hash mất ~250-500ms trên server production.
3. Salt và Pepper
Salt — random per-user
Without salt:
alice: password="123456" → hash = abc123...
bob: password="123456" → hash = abc123... ← cùng hash!
With salt:
alice: salt="a1b2c3d4", password="123456" → hash(salt+pw) = xyz789...
bob: salt="z9y8x7w6", password="123456" → hash(salt+pw) = qwe456...
Yêu cầu của salt:
- Random (dùng CSPRNG:
crypto.randomBytes/os.urandom) - Unique per user (KHÔNG dùng username/email)
- Đủ dài: tối thiểu 16 bytes (128 bit)
- Lưu cùng hash (không cần secret) — bcrypt/argon2 tự embed
Salt không bí mật — kể cả khi attacker đọc được, họ vẫn phải brute force từng user riêng lẻ → rainbow table chết.
Pepper — server-side secret
Pepper bổ sung lớp bảo vệ: nếu DB bị leak mà secret store không bị leak, attacker không brute force được.
| Salt | Pepper | |
|---|---|---|
| Unique per user | Có | Không (server-wide) |
| Lưu ở đâu | Cùng hash trong DB | Env var / KMS / HSM |
| Bí mật không | Không | Có |
| Bắt buộc | Có | Optional (defense in depth) |
Lưu ý: Pepper là controversial. Nhiều chuyên gia (Thomas Ptacek) cho rằng nếu DB bị compromise thì server cũng thường compromise → pepper ít giá trị. Nhưng cho high-value targets (banking) thì vẫn nên có.
4. Code ví dụ
Node.js với bcrypt
// npm install bcrypt
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12; // 2^12 = 4096 iterations
// Đăng ký user
async function hashPassword(plaintext) {
// bcrypt tự generate salt và embed vào output
const hash = await bcrypt.hash(plaintext, SALT_ROUNDS);
// Lưu hash vào DB (KHÔNG lưu plaintext)
return hash;
}
// Đăng nhập
async function verifyPassword(plaintext, storedHash) {
// bcrypt.compare là constant-time → an toàn với timing attack
return await bcrypt.compare(plaintext, storedHash);
}
// Sử dụng
const hash = await hashPassword("MySecret123!");
// → "$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
const ok = await verifyPassword("MySecret123!", hash); // true
Node.js với argon2
// npm install argon2
import argon2 from "argon2";
async function hashPassword(plaintext) {
return await argon2.hash(plaintext, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3,
parallelism: 4,
});
}
async function verifyPassword(plaintext, storedHash) {
try {
return await argon2.verify(storedHash, plaintext);
} catch {
return false; // hash format không hợp lệ
}
}
Python với argon2-cffi
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
memory_cost=65536, # 64 MiB
time_cost=3,
parallelism=4,
)
# Đăng ký
hash = ph.hash("MySecret123!")
# → "$argon2id$v=19$m=65536,t=3,p=4$..."
# Đăng nhập
def verify(plaintext, stored_hash):
try:
ph.verify(stored_hash, plaintext)
# Nếu params cũ hơn current, rehash với params mới
if ph.check_needs_rehash(stored_hash):
new_hash = ph.hash(plaintext)
# → cập nhật DB với new_hash
return True
except VerifyMismatchError:
return False
Pepper với Node.js
import bcrypt from "bcrypt";
import crypto from "crypto";
const PEPPER = process.env.PASSWORD_PEPPER; // 32-byte random từ KMS
function applyPepper(password) {
// HMAC-SHA256 thay vì concat đơn giản → an toàn hơn
return crypto.createHmac("sha256", PEPPER).update(password).digest("hex");
}
async function hashPassword(plaintext) {
const peppered = applyPepper(plaintext);
return await bcrypt.hash(peppered, 12);
}
async function verifyPassword(plaintext, hash) {
const peppered = applyPepper(plaintext);
return await bcrypt.compare(peppered, hash);
}
5. Common mistakes
Mistake 1: Salt quá ngắn hoặc không random
// ❌ SAI
const salt = user.id.toString(); // không random
const salt = "myapp_salt"; // hardcoded
const salt = Date.now().toString(); // predictable
// ✅ ĐÚNG
import crypto from "crypto";
const salt = crypto.randomBytes(16); // 128-bit CSPRNG
// Hoặc dùng bcrypt/argon2 — chúng tự lo
Mistake 2: Work factor thấp / không tăng theo thời gian
// ❌ SAI: cost = 4 (chỉ 16 iterations, vài ms → brute force trong nháy mắt)
bcrypt.hash(pw, 4);
// ✅ ĐÚNG: cost = 12 (~250ms), tăng dần theo hardware
bcrypt.hash(pw, 12);
Định kỳ (1-2 năm) tăng work factor. Khi user login với hash cũ, rehash với cost mới.
Mistake 3: Custom crypto
// ❌ SAI: tự thiết kế hashing
function myHash(pw) {
return crypto.createHash("sha256")
.update(pw + "secret" + pw.length)
.digest("hex");
}
// ✅ ĐÚNG: dùng thư viện đã được audit
import bcrypt from "bcrypt";
Quy tắc vàng: Don't roll your own crypto. Kể cả chuyên gia cũng dùng thư viện chuẩn.
Mistake 4: Timing attack trong compare
// ❌ SAI: == thoát sớm khi byte khác nhau → leak length info
if (hash === storedHash) { ... }
// ✅ ĐÚNG: constant-time compare
// bcrypt.compare, argon2.verify đã constant-time built-in
// Nếu tự compare: dùng crypto.timingSafeEqual
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
Mistake 5: Log password
// ❌ SAI
logger.info(`Login attempt: ${email} / ${password}`);
logger.error({ user, requestBody }); // body có password!
// ✅ ĐÚNG
logger.info(`Login attempt: ${email}`);
// Redact password trong logger middleware
Mistake 6: Trả về error messages khác nhau
// ❌ SAI: leak thông tin user tồn tại hay không
if (!user) return "User not found";
if (!valid) return "Wrong password";
// ✅ ĐÚNG: cùng error
return "Invalid email or password";
6. Migration: từ MD5/SHA legacy lên bcrypt/argon2
Khi kế thừa hệ thống cũ với MD5 password, cách an toàn:
// Bước 1: Wrap legacy hash trong bcrypt
// migration_hash = bcrypt(md5(plaintext))
const legacyMD5 = user.password_md5;
const newHash = await bcrypt.hash(legacyMD5, 12);
// Bước 2: Lưu cờ "is_legacy_wrapped = true"
// Bước 3: Khi user login lần tới:
// - verify: bcrypt.compare(md5(input), stored)
// - Nếu đúng → rehash bằng bcrypt(input) thuần, set is_legacy_wrapped = false
Không bắt user reset password — UX kém. Migration ngầm khi họ login.
7. Câu hỏi ôn tập
-
Tại sao SHA-256 không phù hợp để lưu password kể cả khi có salt?
Xem đáp án
SHA-256 được thiết kế cho tốc độ (hashing file, blockchain). GPU hiện đại làm ~10 tỷ hash/giây → brute force password 8 ký tự trong vài giờ. Password hashing cần thuật toán chậm có chủ ý (work factor) và lý tưởng là memory-hard (argon2id, scrypt) để chống GPU/ASIC. Salt giúp chống rainbow table nhưng không chậm lại brute force per-user.
-
Salt và pepper khác nhau như thế nào, và pepper nên lưu ở đâu?
Xem đáp án
Salt là random per-user, lưu cùng hash trong DB, không cần bí mật — chống rainbow table và chống cùng-password-cùng-hash. Pepper là một secret server-wide, KHÔNG lưu trong DB mà lưu ở env var / AWS KMS / HSM. Nếu DB leak nhưng secret store còn an toàn, pepper khiến attacker không brute force được. Pepper là defense in depth, optional nhưng khuyến nghị cho high-value app.
-
OWASP 2024 khuyến nghị thuật toán nào cho dự án mới, và lý do?
Xem đáp án
argon2id — winner của Password Hashing Competition 2015, là hybrid của argon2d (chống GPU) và argon2i (chống side-channel). Memory-hard nên ASIC/GPU đắt đỏ. Tham số khuyến nghị: m=19456 KiB, t=2, p=1 (minimum) hoặc m=65536, t=3, p=4 (stronger). Nếu môi trường không hỗ trợ argon2 (Node.js cũ, hosting hạn chế), bcrypt cost=12 là lựa chọn dự phòng tốt.
-
Vì sao
bcrypt.compare()an toàn hơnhash1 === hash2?Xem đáp án
===so sánh byte-by-byte và thoát sớm khi gặp byte khác nhau → thời gian so sánh phụ thuộc vào vị trí byte sai. Attacker đo timing nhiều lần có thể đoán từng ký tự (timing attack).bcrypt.compare()(vàcrypto.timingSafeEqual) luôn so sánh hết toàn bộ độ dài → thời gian không đổi (constant-time) → không leak thông tin qua timing. -
Khi muốn nâng work factor (ví dụ bcrypt cost từ 10 lên 12), làm sao migrate mà không bắt user reset password?
Xem đáp án
Khi user login thành công với hash cũ, ngay sau khi verify đúng, gọi
bcrypt.hash(plaintext, 12)(cost mới) và update DB. User không thấy gì khác lạ. Sau vài tháng, hầu hết active users đã có hash mới. Inactive users vẫn có hash cũ (vẫn an toàn hơn không hash) hoặc force reset nếu inactive quá lâu. Argon2 có sẵncheck_needs_rehash()cho pattern này.
Bài tập thực hành
# 1. Setup project Node.js
mkdir password-lab && cd password-lab
npm init -y
npm install bcrypt argon2
# 2. Tạo file hash-demo.js
cat > hash-demo.js << 'EOF'
import bcrypt from "bcrypt";
import argon2 from "argon2";
const password = "MySecret123!";
console.time("bcrypt cost=12");
const bcryptHash = await bcrypt.hash(password, 12);
console.timeEnd("bcrypt cost=12");
console.log(bcryptHash);
console.time("argon2id");
const argonHash = await argon2.hash(password, { type: argon2.argon2id });
console.timeEnd("argon2id");
console.log(argonHash);
// Verify
console.log("bcrypt verify:", await bcrypt.compare(password, bcryptHash));
console.log("argon2 verify:", await argon2.verify(argonHash, password));
// Verify với password sai
console.log("wrong:", await bcrypt.compare("wrong", bcryptHash));
EOF
# package.json cần "type": "module"
node hash-demo.js
# 3. Đo thời gian với các cost khác nhau
# Thử cost = 4, 8, 10, 12, 14 — quan sát thời gian tăng theo cấp số nhân
# 4. Quan sát format hash:
# - Ký tự nào là algorithm version?
# - Ký tự nào là cost?
# - Bao nhiêu ký tự là salt? hash?
# 5. Thử migration legacy MD5 → bcrypt
# Tạo MD5 hash của password, wrap bằng bcrypt, verify