Mã hoá cơ bản với JCA
Sau bài này, bạn sẽ:
- Hiểu và sử dụng được
MessageDigestđể hash dữ liệu (SHA-256, SHA-512) - Biết cách dùng
SecureRandomđể tạo số ngẫu nhiên an toàn - Hiểu vai trò của salt trong việc bảo vệ mật khẩu
- Sử dụng HMAC để xác minh tính toàn vẹn dữ liệu
- Phân biệt được hashing, encryption và encoding
Hashing vs Encryption vs Encoding
Trước khi đi sâu, cần phân biệt rõ 3 khái niệm thường bị nhầm lẫn:
| Đặc điểm | Hashing | Encryption | Encoding |
|---|---|---|---|
| Mục đích | Xác minh tính toàn vẹn | Bảo mật dữ liệu | Chuyển đổi format |
| Chiều | Một chiều (không thể giải mã) | Hai chiều (mã hoá ↔ giải mã) | Hai chiều (encode ↔ decode) |
| Khoá | Không cần | Cần khoá | Không cần |
| Ví dụ | SHA-256, bcrypt | AES, RSA | Base64, URL encoding |
| Use case | Lưu mật khẩu, checksum | Truyền dữ liệu bí mật | Truyền dữ liệu qua mạng |
Base64 không phải mã hoá. Bất kỳ ai cũng có thể decode Base64. Không bao giờ dùng encoding để bảo vệ dữ liệu nhạy cảm.
So sánh trực quan
MessageDigest — Hashing
Hash là gì?
Hash function nhận input bất kỳ và tạo ra output có độ dài cố định (digest). Cùng input luôn cho cùng output, nhưng không thể tìm lại input từ output.
Sử dụng MessageDigest
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class HashingExample {
public static void main(String[] args) throws NoSuchAlgorithmException {
String input = "Hello World";
// Tạo MessageDigest với thuật toán SHA-256
MessageDigest md = MessageDigest.getInstance("SHA-256");
// Tính hash
byte[] hashBytes = md.digest(input.getBytes());
// Chuyển sang hex string để hiển thị
String hexHash = HexFormat.of().formatHex(hashBytes);
System.out.println("SHA-256: " + hexHash);
// Output: SHA-256: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e
}
}
So sánh các thuật toán hash
| Thuật toán | Độ dài output | Bảo mật | Hiệu suất | Khuyến nghị |
|---|---|---|---|---|
| MD5 | 128 bit | Đã bị phá | Nhanh nhất | Không dùng cho bảo mật |
| SHA-1 | 160 bit | Đã bị phá | Nhanh | Không dùng cho bảo mật |
| SHA-256 | 256 bit | An toàn | Trung bình | Khuyến nghị |
| SHA-512 | 512 bit | An toàn nhất | Chậm hơn | Khi cần bảo mật cao |
So sánh trực quan các thuật toán hash
import java.security.MessageDigest;
import java.util.HexFormat;
public class CompareHashAlgorithms {
public static void main(String[] args) throws Exception {
String input = "Java Security";
String[] algorithms = {"MD5", "SHA-1", "SHA-256", "SHA-512"};
for (String algo : algorithms) {
MessageDigest md = MessageDigest.getInstance(algo);
byte[] hash = md.digest(input.getBytes());
System.out.printf("%-10s (%3d bit): %s%n",
algo, hash.length * 8, HexFormat.of().formatHex(hash));
}
}
}
MD5 (128 bit): 5d41402abc4b2a76b9719d911017c592
SHA-1 (160 bit): 2ef7bde608ce5404e97d5f042f95f89f1c232871
SHA-256 (256 bit): 7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069
SHA-512 (512 bit): 861844d6704e8573fec34d967e20bcfe...
Hashing file lớn (Streaming)
Khi hash file lớn, không nên đọc toàn bộ file vào memory:
import java.io.FileInputStream;
import java.io.IOException;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
public class FileHasher {
public static String hashFile(String filePath) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
// Dùng DigestInputStream để hash từng chunk
try (var fis = new FileInputStream(filePath);
var dis = new DigestInputStream(fis, md)) {
byte[] buffer = new byte[8192];
while (dis.read(buffer) != -1) {} // Đọc và hash từng chunk
}
return HexFormat.of().formatHex(md.digest());
}
public static void main(String[] args) throws Exception {
String hash = hashFile("pom.xml");
System.out.println("File hash: " + hash);
}
}
SecureRandom — Số ngẫu nhiên an toàn
Tại sao không dùng java.util.Random?
Random dùng thuật toán Linear Congruential Generator — nếu biết seed, có thể dự đoán toàn bộ chuỗi số. Điều này nguy hiểm khi tạo khoá, token, hoặc salt.
// ❌ Sai — Random có thể bị dự đoán
Random random = new Random();
byte[] key = new byte[16];
random.nextBytes(key); // Không an toàn cho bảo mật!
// ✅ Đúng — SecureRandom dùng entropy từ OS
SecureRandom secureRandom = new SecureRandom();
byte[] key2 = new byte[16];
secureRandom.nextBytes(key2); // An toàn cho bảo mật
Sử dụng SecureRandom
import java.security.SecureRandom;
import java.util.HexFormat;
public class SecureRandomExample {
public static void main(String[] args) throws Exception {
// Cách 1: Constructor mặc định
SecureRandom sr1 = new SecureRandom();
// Cách 2: Chỉ định thuật toán
SecureRandom sr2 = SecureRandom.getInstance("NativePRNG");
// Cách 3: Instance mạnh nhất (có thể chậm)
SecureRandom sr3 = SecureRandom.getInstanceStrong();
// Tạo token ngẫu nhiên 32 bytes
byte[] token = new byte[32];
sr1.nextBytes(token);
System.out.println("Token: " + HexFormat.of().formatHex(token));
// Tạo số nguyên ngẫu nhiên trong khoảng [0, 100)
int randomInt = sr1.nextInt(100);
System.out.println("Random int: " + randomInt);
}
}
getInstanceStrong() có thể block trên Linux nếu entropy pool cạn. Trong production, dùng new SecureRandom() cho hầu hết trường hợp — nó đủ an toàn và không bị block.
SecureRandom: /dev/random vs /dev/urandom
Trên Linux, SecureRandom lấy entropy từ hai nguồn:
| Nguồn | Đặc điểm | Khi nào block |
|---|---|---|
/dev/random | Entropy pool thực, chất lượng cao | Block khi entropy cạn |
/dev/urandom | Dùng CSPRNG, không block | Không bao giờ block |
// getInstanceStrong() có thể dùng /dev/random → BLOCK trên server ít entropy
SecureRandom strong = SecureRandom.getInstanceStrong();
// Constructor mặc định dùng /dev/urandom → KHÔNG block
SecureRandom safe = new SecureRandom(); // Đủ an toàn cho 99% use cases
digest() method tự động reset state của MessageDigest. Nếu gọi digest() lần thứ hai mà không update() trước, kết quả sẽ là hash của chuỗi rỗng, không phải hash lần trước!
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update("Hello".getBytes());
byte[] hash1 = md.digest(); // SHA-256 of "Hello"
// ⚠️ digest() đã reset! Gọi lại = hash of empty
byte[] hash2 = md.digest(); // SHA-256 of "" (empty!) — KHÔNG phải "Hello"
// ✅ Phải update() lại trước khi digest()
md.update("Hello".getBytes());
byte[] hash3 = md.digest(); // SHA-256 of "Hello" — đúng
MessageDigest Thread Safety
MessageDigest KHÔNG thread-safe. Nếu nhiều thread dùng chung một instance, kết quả sẽ sai.
// ❌ Sai — chia sẻ MessageDigest giữa các thread
private static final MessageDigest SHARED_MD =
MessageDigest.getInstance("SHA-256");
public String hash(String input) {
// Race condition! update() và digest() không atomic
return HexFormat.of().formatHex(
SHARED_MD.digest(input.getBytes()));
}
// ✅ Đúng — tạo mới mỗi lần dùng
public String hash(String input) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(input.getBytes()));
}
// ✅ Đúng — dùng ThreadLocal
private static final ThreadLocal<MessageDigest> MD_LOCAL =
ThreadLocal.withInitial(() -> {
try { return MessageDigest.getInstance("SHA-256"); }
catch (Exception e) { throw new RuntimeException(e); }
});
Bảo vệ mật khẩu với Salt + Hash
Tại sao chỉ hash là chưa đủ?
Nếu chỉ hash mật khẩu, attacker có thể dùng Rainbow Table — bảng chứa hash đã tính sẵn cho hàng triệu mật khẩu phổ biến.
Mật khẩu: "password123"
SHA-256: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
↑ Attacker chỉ cần tra bảng rainbow table!
Salt là gì?
Salt là chuỗi ngẫu nhiên được thêm vào mật khẩu trước khi hash. Mỗi user có salt riêng, nên cùng mật khẩu sẽ có hash khác nhau.
Implement Salt + Hash
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
public class PasswordHasher {
private static final int SALT_LENGTH = 16; // 128 bits
/**
* Hash mật khẩu với salt ngẫu nhiên.
* Trả về "salt:hash" dạng Base64.
*/
public static String hashPassword(String password) throws Exception {
// Tạo salt ngẫu nhiên
SecureRandom sr = new SecureRandom();
byte[] salt = new byte[SALT_LENGTH];
sr.nextBytes(salt);
// Hash: SHA-256(salt + password)
byte[] hash = hashWithSalt(password, salt);
// Lưu dạng: base64(salt):base64(hash)
return Base64.getEncoder().encodeToString(salt) + ":" +
Base64.getEncoder().encodeToString(hash);
}
/**
* Xác minh mật khẩu với hash đã lưu.
*/
public static boolean verifyPassword(String password, String stored)
throws Exception {
// Tách salt và hash
String[] parts = stored.split(":");
byte[] salt = Base64.getDecoder().decode(parts[0]);
byte[] expectedHash = Base64.getDecoder().decode(parts[1]);
// Hash mật khẩu nhập vào với cùng salt
byte[] actualHash = hashWithSalt(password, salt);
// So sánh (constant-time để tránh timing attack)
return MessageDigest.isEqual(expectedHash, actualHash);
}
private static byte[] hashWithSalt(String password, byte[] salt)
throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(salt);
return md.digest(password.getBytes("UTF-8"));
}
public static void main(String[] args) throws Exception {
// Đăng ký: hash và lưu mật khẩu
String storedHash = hashPassword("MySecretPassword123");
System.out.println("Stored: " + storedHash);
// Đăng nhập: xác minh mật khẩu
System.out.println("Correct: " + verifyPassword("MySecretPassword123", storedHash));
System.out.println("Wrong: " + verifyPassword("WrongPassword", storedHash));
}
}
Stored: dG9rZW5zYWx0MTIzNDU=:a3Jlc3VsdGhhc2guLi4=
Correct: true
Wrong: false
SHA-256 + salt là tốt cho học tập, nhưng trong production nên dùng bcrypt, scrypt, hoặc Argon2 — các thuật toán được thiết kế riêng cho password hashing, có tính năng chống brute-force (work factor).
// Với jBCrypt library
String hashed = BCrypt.hashpw("password", BCrypt.gensalt(12));
boolean match = BCrypt.checkpw("password", hashed);
So sánh Key Derivation Functions (KDF)
| Thuật toán | Tính năng | Có sẵn trong JDK | Khuyến nghị |
|---|---|---|---|
| PBKDF2 | Salt + iterations (work factor) | ✅ SecretKeyFactory | OK, nhưng GPU-friendly |
| bcrypt | Salt + work factor, memory-hard | ❌ cần jBCrypt | Tốt cho password |
| scrypt | Salt + work factor + memory cost | ❌ cần Bouncy Castle | Tốt hơn bcrypt |
| Argon2 | Winner Password Hashing Competition | ❌ cần Bouncy Castle | Khuyến nghị nhất |
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public static byte[] pbkdf2Hash(char[] password, byte[] salt) throws Exception {
PBEKeySpec spec = new PBEKeySpec(password, salt,
600_000, // iterations — OWASP khuyến nghị 600,000 cho SHA-256
256 // key length in bits
);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
return factory.generateSecret(spec).getEncoded();
}
Salt Best Practices
- Unique per user: Mỗi user phải có salt riêng — nếu dùng chung, hai user cùng mật khẩu sẽ có cùng hash
- Tối thiểu 16 bytes (128 bits): Đủ lớn để rainbow table không khả thi
- Lưu cùng hash: Salt không cần bí mật, lưu kèm hash (ví dụ
salt:hash) - Tạo bằng
SecureRandom: Không dùngRandom, không dùng timestamp, không dùng username
Password Hashing — Quy trình hoàn chỉnh
HexFormat.of().formatHex() trả về lowercase hex string. Nếu đề thi cho so sánh với uppercase hash string, kết quả sẽ là false!
byte[] hash = md.digest("test".getBytes());
String hex = HexFormat.of().formatHex(hash);
// hex = "9f86d081884c..." (lowercase)
// ❌ Sai — so sánh case-sensitive
hex.equals("9F86D081884C..."); // false!
// ✅ Đúng — so sánh case-insensitive
hex.equalsIgnoreCase("9F86D081884C..."); // true
MessageDigest implements one-way hash function. Method digest() thực hiện final hash computation và reset engine. Nếu cần hash nhiều lần cùng data, phải gọi update() lại trước mỗi digest().
Tham khảo: java.security.MessageDigest — Java 21 API
HMAC — Hash-based Message Authentication Code
HMAC là gì?
HMAC kết hợp hashing với một secret key để vừa xác minh tính toàn vẹn, vừa xác thực nguồn gốc dữ liệu.
| So sánh | Hash thường | HMAC |
|---|---|---|
| Input | Message | Message + Secret Key |
| Xác minh | Tính toàn vẹn | Toàn vẹn + Xác thực |
| Use case | Checksum file | API authentication, JWT |
Sử dụng HMAC
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;
public class HMACExample {
public static void main(String[] args) throws Exception {
String message = "Transfer $1000 to account 12345";
String secretKey = "my-secret-key-here";
// Tạo HMAC-SHA256
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
secretKey.getBytes(), "HmacSHA256");
mac.init(keySpec);
byte[] hmacBytes = mac.doFinal(message.getBytes());
String hmacHex = HexFormat.of().formatHex(hmacBytes);
System.out.println("HMAC: " + hmacHex);
// Xác minh: tính lại HMAC và so sánh
mac.init(keySpec);
byte[] verifyBytes = mac.doFinal(message.getBytes());
boolean valid = MessageDigest.isEqual(hmacBytes, verifyBytes);
System.out.println("Valid: " + valid);
// Nếu message bị sửa đổi
mac.init(keySpec);
byte[] tamperedBytes = mac.doFinal(
"Transfer $9999 to account 12345".getBytes());
boolean tampered = MessageDigest.isEqual(hmacBytes, tamperedBytes);
System.out.println("Tampered message valid: " + tampered);
}
}
HMAC: 7a8f3b...
Valid: true
Tampered message valid: false
- API Authentication: Webhook signatures (Stripe, GitHub, Slack)
- JWT: Phần signature của JSON Web Token
- Data Integrity: Xác minh file download không bị sửa đổi
Quy trình xác minh HMAC giữa Sender và Receiver
Lỗi thường gặp
1. Dùng String.equals() để so sánh hash:
// ❌ Sai — vulnerable to timing attack
if (expectedHash.equals(actualHash)) { ... }
// ✅ Đúng — constant-time comparison
if (MessageDigest.isEqual(expectedBytes, actualBytes)) { ... }
2. Không dùng salt khi hash mật khẩu:
// ❌ Sai — dễ bị rainbow table attack
String hash = sha256(password);
// ✅ Đúng — mỗi user có salt riêng
String hash = sha256(salt + password);
3. Tái sử dụng salt:
// ❌ Sai — dùng chung salt cho tất cả user
static final byte[] GLOBAL_SALT = "fixed-salt".getBytes();
// ✅ Đúng — tạo salt ngẫu nhiên cho mỗi user
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
4. Dùng Random thay vì SecureRandom:
// ❌ Sai — có thể dự đoán được
byte[] salt = new byte[16];
new Random().nextBytes(salt);
// ✅ Đúng — an toàn cho mã hoá
new SecureRandom().nextBytes(salt);
Bài tập
Bài 1: File Integrity Checker [Cơ bản]
Viết chương trình nhận đường dẫn file và thuật toán hash (MD5, SHA-256, SHA-512), sau đó in ra hash của file đó. Sử dụng DigestInputStream để hỗ trợ file lớn.
Gợi ý
Tham khảo phần "Hashing file lớn" ở trên. Nhận tham số từ command line args.
Bài 2: Hệ thống đăng ký/đăng nhập [Trung bình]
Xây dựng class UserAuthService với:
register(String username, String password)— hash password với salt, lưu vàoMap<String, String>login(String username, String password)— xác minh passwordchangePassword(String username, String oldPass, String newPass)— đổi mật khẩu
Gợi ý
Lưu Map<String, String> với key là username, value là "salt:hash". Tham khảo class PasswordHasher ở trên.
Bài 3: HMAC-based API Signature [Thách thức]
Implement hệ thống xác minh API request:
- Mỗi request gồm:
method,url,timestamp,body - Tính HMAC-SHA256 từ string:
method|url|timestamp|bodyvới secret key - Kiểm tra request không quá 5 phút (chống replay attack)
- Viết cả phần tạo signature (client) và xác minh (server)
Gợi ý
Dùng System.currentTimeMillis() cho timestamp. Server kiểm tra currentTime - requestTime < 5 * 60 * 1000.
Tóm tắt
| Khái niệm | Mô tả |
|---|---|
| Hashing | Chuyển đổi một chiều, output cố định (SHA-256, SHA-512) |
| Salt | Chuỗi ngẫu nhiên thêm vào trước khi hash, chống rainbow table |
| SecureRandom | Tạo số ngẫu nhiên an toàn cho mã hoá, thay thế Random |
| HMAC | Hash + Secret Key, xác minh toàn vẹn và xác thực nguồn gốc |
| Constant-time comparison | MessageDigest.isEqual() — chống timing attack |
Key takeaways:
- Hashing là một chiều, encryption là hai chiều — đừng nhầm lẫn
- Luôn dùng salt khi hash mật khẩu, mỗi user có salt riêng
- Dùng SecureRandom, không bao giờ dùng
Randomcho bảo mật - So sánh hash bằng constant-time method để tránh timing attack
- Production: dùng bcrypt/Argon2 thay vì SHA-256 cho password
Tiếp theo
Ở bài tiếp theo, chúng ta sẽ tìm hiểu Mã hoá nâng cao & Chữ ký số — học cách mã hoá/giải mã dữ liệu với AES, RSA và tạo chữ ký số.