Chuyển tới nội dung chính

Mã hoá cơ bản với JCA

Mục tiêu bài học

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ểmHashingEncryptionEncoding
Mục đíchXác minh tính toàn vẹnBảo mật dữ liệuChuyển đổi format
ChiềuMộ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ầnCần khoáKhông cần
Ví dụSHA-256, bcryptAES, RSABase64, URL encoding
Use caseLưu mật khẩu, checksumTruyền dữ liệu bí mậtTruyền dữ liệu qua mạng
Encoding KHÔNG phải bảo mật

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

HashingExample.java
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 outputBảo mậtHiệu suấtKhuyến nghị
MD5128 bitĐã bị pháNhanh nhấtKhông dùng cho bảo mật
SHA-1160 bitĐã bị pháNhanhKhông dùng cho bảo mật
SHA-256256 bitAn toànTrung bìnhKhuyến nghị
SHA-512512 bitAn toàn nhấtChậm hơnKhi cần bảo mật cao

So sánh trực quan các thuật toán hash

CompareHashAlgorithms.java
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));
}
}
}
Output
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:

FileHasher.java
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

SecureRandomExample.java
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);
}
}
Hiệu suất SecureRandom

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ểmKhi nào block
/dev/randomEntropy pool thực, chất lượng caoBlock khi entropy cạn
/dev/urandomDùng CSPRNG, không blockKhô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
OCP Trap — MessageDigest reuse

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

PasswordHasher.java
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));
}
}
Output
Stored: dG9rZW5zYWx0MTIzNDU=:a3Jlc3VsdGhhc2guLi4=
Correct: true
Wrong: false
Trong production, dùng bcrypt hoặc Argon2

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ánTính năngCó sẵn trong JDKKhuyến nghị
PBKDF2Salt + iterations (work factor)SecretKeyFactoryOK, nhưng GPU-friendly
bcryptSalt + work factor, memory-hard❌ cần jBCryptTốt cho password
scryptSalt + work factor + memory cost❌ cần Bouncy CastleTốt hơn bcrypt
Argon2Winner Password Hashing Competition❌ cần Bouncy CastleKhuyến nghị nhất
PBKDF2 — Có sẵn trong JDK
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ùng Random, không dùng timestamp, không dùng username

Password Hashing — Quy trình hoàn chỉnh

OCP Trap — HexFormat

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
📖 Theo Oracle Docs — MessageDigest

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ánhHash thườngHMAC
InputMessageMessage + Secret Key
Xác minhTính toàn vẹnToàn vẹn + Xác thực
Use caseChecksum fileAPI authentication, JWT

Sử dụng HMAC

HMACExample.java
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);
}
}
Output
HMAC: 7a8f3b...
Valid: true
Tampered message valid: false
HMAC trong thực tế
  • 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

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ào Map<String, String>
  • login(String username, String password) — xác minh password
  • changePassword(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|body vớ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ệmMô tả
HashingChuyển đổi một chiều, output cố định (SHA-256, SHA-512)
SaltChuỗi ngẫu nhiên thêm vào trước khi hash, chống rainbow table
SecureRandomTạo số ngẫu nhiên an toàn cho mã hoá, thay thế Random
HMACHash + Secret Key, xác minh toàn vẹn và xác thực nguồn gốc
Constant-time comparisonMessageDigest.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 Random cho 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ố.

Bài 3: Mã hoá nâng cao & Chữ ký số