Trong một hệ thống Java Enterprise, dữ liệu không chỉ cần được lưu trữ và truyền tải đúng cách mà còn phải được bảo vệ trước những nguy cơ như nghe lén, giả mạo thông điệp, đánh cắp mật khẩu, thay đổi dữ liệu và chiếm đoạt khóa bí mật. Những vấn đề này đòi hỏi lập trình viên phải hiểu sâu hơn việc gọi một hàm encrypt() hay decrypt().
Java Cryptography Architecture (JCA) và Java Cryptography Extension (JCE) cung cấp hệ sinh thái API tiêu chuẩn của Java để triển khai các cơ chế mật mã như mã hóa, giải mã, băm dữ liệu, sinh khóa, xác minh chữ ký số và quản lý chứng chỉ. Tuy nhiên, API chỉ cung cấp công cụ. Tính an toàn của hệ thống còn phụ thuộc vào cách lựa chọn thuật toán, quản lý khóa, tạo số ngẫu nhiên và thiết kế giao thức.
Hiểu bản chất của các thuật toán mật mã, nắm vững API JCA/JCE, viết được chương trình Java có thể chạy trực tiếp, kiểm chứng kết quả và tránh những sai lầm thường gặp khi xây dựng hệ thống bảo mật.
Phạm vi thực hành: - AES-256-GCM để mã hóa dữ liệu có xác thực. - PBKDF2-HMAC-SHA256 để lưu trữ và xác minh mật khẩu. - RSA và Ed25519 để tạo, xác minh chữ ký số. - SHA-256 để kiểm tra tính toàn vẹn của dữ liệu.
Môi trường khuyến nghị: JDK 17 trở lên. Các ví dụ sử dụng API chuẩn của Java, không yêu cầu thư viện mật mã bên ngoài.
1. Tổng quan về bảo mật dữ liệu và kiến trúc Java Crypto
Trước khi học API, chúng ta cần xác định chính xác điều gì cần được bảo vệ. Mã hóa không giải quyết mọi vấn đề bảo mật. Chẳng hạn, mã hóa một cơ sở dữ liệu không ngăn được người có quyền truy cập hợp lệ đọc dữ liệu; băm một thông điệp cũng không chứng minh ai là người tạo ra thông điệp đó.
1.1. CIA Triad — Tam giác bảo mật
CIA Triad là mô hình nền tảng mô tả ba mục tiêu cốt lõi của bảo mật thông tin: Confidentiality, Integrity và Availability.
| Thành phần | Ý nghĩa | Cơ chế thường dùng | Ví dụ trong Java |
|---|---|---|---|
| Confidentiality | Bí mật: chỉ chủ thể được phép mới đọc được dữ liệu. | Mã hóa, kiểm soát truy cập, quản lý khóa. | Cipher, SecretKey, TLS. |
| Integrity | Toàn vẹn: phát hiện dữ liệu bị thay đổi trái phép. | MAC, hàm băm, chữ ký số. | MessageDigest, Mac, Signature. |
| Availability | Sẵn sàng: hệ thống phục vụ được khi cần thiết. | Backup, dự phòng, giới hạn tài nguyên, chống DoS. | Timeout, rate limiting, HA, cơ chế phục hồi. |
Ví dụ, một ứng dụng lưu thông tin khách hàng trong cơ sở dữ liệu cần mã hóa các trường nhạy cảm để bảo vệ tính bí mật. Khi tải dữ liệu từ một nguồn bên ngoài, ứng dụng cần cơ chế xác thực tính toàn vẹn để phát hiện nội dung bị sửa đổi. Đồng thời, dịch vụ cần được giám sát, sao lưu và bảo vệ trước các cuộc tấn công làm cạn kiệt tài nguyên.
Mã hóa không tự động chứng minh nguồn gốc dữ liệu. Hàm băm thông thường không chứng minh dữ liệu đến từ một người cụ thể. Chữ ký số có thể xác minh nguồn ký khi khóa công khai được gắn với danh tính đáng tin cậy. Không có thuật toán mật mã đơn lẻ nào bảo đảm toàn bộ CIA Triad.
1.2. JCA, JCE và kiến trúc Provider-based
JCA là kiến trúc và tập API mật mã của Java. JCE cung cấp các API quan trọng cho mã hóa, giải mã và những chức năng mật mã liên quan. Trong các phiên bản Java hiện đại, JCE đã được tích hợp vào nền tảng Java; lập trình viên thường sử dụng các API JCA/JCE trực tiếp mà không cần cài một gói JCE riêng.
Điểm đặc biệt của kiến trúc này là cơ chế Cryptographic Service Provider. Ứng dụng yêu cầu một dịch vụ mật mã thông qua API chuẩn, còn Provider triển khai thuật toán cụ thể.
┌──────────────────────────────────────────┐
│ Java Application │
│ User Service / Payment / Authentication │
└────────────────────┬─────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ JCA / JCE APIs │
│ Cipher | Signature | MessageDigest │
│ KeyPairGenerator | KeyFactory | Mac │
│ SecretKeyFactory | SecureRandom │
└────────────────────┬─────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Provider Selection Mechanism │
│ Security configuration / Provider order │
└─────────────┬────────────────┬───────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────┐
│ SunJCE / SunEC │ │ Other Providers │
│ Standard algorithms │ │ Optional modules │
└─────────────────────┘ └──────────────────┘
Ví dụ, khi viết Cipher.getInstance("AES/GCM/NoPadding"), chương trình yêu cầu một implementation hỗ trợ transformation đó. JCA tìm Provider phù hợp theo cấu hình và khả năng hỗ trợ. Không nên giả định mọi Provider đều hỗ trợ cùng thuật toán, cùng tham số hoặc cùng hành vi mở rộng.
Các API thường gặp:
| API | Chức năng |
|---|---|
Cipher | Mã hóa và giải mã dữ liệu. |
MessageDigest | Tính giá trị băm của dữ liệu. |
Mac | Tạo và xác minh mã xác thực thông điệp như HMAC. |
Signature | Tạo và xác minh chữ ký số. |
KeyGenerator | Sinh khóa đối xứng. |
KeyPairGenerator | Sinh cặp khóa bất đối xứng. |
SecretKeyFactory | Chuyển đổi đặc tả khóa bí mật; hỗ trợ dẫn xuất khóa PBKDF2. |
KeyFactory | Tạo khóa từ các đặc tả khóa tương ứng. |
KeyStore | Đọc và quản lý khóa, chứng chỉ theo loại keystore. |
SecureRandom | Cung cấp số ngẫu nhiên phù hợp cho mục đích mật mã. |
Kiểm tra các Provider đang có trong JDK
import java.security.Provider;
import java.security.Security;
public class ProviderInfo {
public static void main(String[] args) {
for (Provider provider : Security.getProviders()) {
System.out.println(
provider.getName() + " - " +
provider.getVersionStr() + " - " +
provider.getInfo()
);
}
}
}
Chạy chương trình để xem các Provider được đăng ký trong môi trường hiện tại. Danh sách thực tế phụ thuộc vào bản phân phối JDK, cấu hình bảo mật và các Provider được cài đặt.
javac ProviderInfo.java java ProviderInfo
Trong ứng dụng thông thường, nên sử dụng API chuẩn và để Java lựa chọn Provider phù hợp. Chỉ chỉ định Provider cụ thể khi có yêu cầu kiến trúc, tuân thủ hoặc kiểm soát implementation rõ ràng. Việc chỉ định tên Provider không tự động làm thuật toán an toàn hơn.
1.3. SecureRandom và Random — Khác biệt mang tính sống còn
java.util.Random được thiết kế cho các mục đích tạo số giả ngẫu nhiên thông thường, chẳng hạn mô phỏng hoặc chọn phần tử. Trạng thái của nó có thể được suy đoán nếu kẻ tấn công thu thập đủ thông tin. Vì vậy, không sử dụng Random để sinh khóa mã hóa, IV, token phiên đăng nhập, nonce bảo mật hoặc salt.
java.security.SecureRandom được thiết kế cho mục đích mật mã. Nó sử dụng nguồn entropy và cơ chế sinh số ngẫu nhiên của implementation tương ứng để tạo các giá trị khó dự đoán.
import java.security.SecureRandom;
import java.util.HexFormat;
public class SecureRandomDemo {
public static void main(String[] args) {
SecureRandom secureRandom = new SecureRandom();
byte[] randomBytes = new byte[32];
secureRandom.nextBytes(randomBytes);
System.out.println(
"Random bytes (hex): " +
HexFormat.of().formatHex(randomBytes)
);
}
}
Chương trình tạo 32 byte ngẫu nhiên, tương đương 256 bit dữ liệu. Kết quả sẽ thay đổi mỗi lần chạy. Các byte này có thể được dùng làm salt hoặc nguyên liệu cho các mục đích phù hợp, nhưng không phải mọi byte ngẫu nhiên đều tự động là khóa hợp lệ cho mọi thuật toán.
Sử dụng
SecureRandom cho các giá trị liên quan đến bảo mật. Không tự khởi tạo nó bằng timestamp, ID người dùng hoặc một seed cố định do ứng dụng lựa chọn. Việc dùng seed cố định có thể làm mất khả năng khó dự đoán mà bạn cần.
2. Mã hóa đối xứng với AES
Mã hóa đối xứng sử dụng một khóa bí mật chung để mã hóa và giải mã dữ liệu. Đây là lựa chọn phù hợp khi cần xử lý lượng dữ liệu lớn vì thường nhanh hơn mã hóa bất đối xứng.
Plaintext + Secret Key + IV / Nonce
│
▼
AES Cipher
│
▼
Ciphertext + Authentication Tag
Ciphertext + Secret Key + IV / Nonce + Tag
│
▼
Xác minh và giải mã
│
▼
Plaintext
2.1. AES là gì?
AES (Advanced Encryption Standard) là thuật toán mã hóa khối được tiêu chuẩn hóa, xử lý dữ liệu theo khối 128 bit. AES hỗ trợ các kích thước khóa 128, 192 và 256 bit.
| Biến thể | Kích thước khóa | Số vòng mã hóa | Đặc điểm |
|---|---|---|---|
| AES-128 | 128 bit | 10 | An toàn cao, hiệu năng tốt. |
| AES-192 | 192 bit | 12 | Hỗ trợ khóa dài hơn. |
| AES-256 | 256 bit | 14 | Không gian khóa lớn, thường được lựa chọn cho dữ liệu nhạy cảm. |
Kích thước khóa không phải yếu tố duy nhất quyết định tính an toàn. Chọn AES-256 nhưng tái sử dụng nonce trong GCM, để lộ khóa hoặc không xác minh authentication tag vẫn có thể khiến hệ thống bị tấn công nghiêm trọng.
2.2. Cipher Mode, Padding, IV, Nonce và Salt
AES chỉ định thuật toán khối. Để mã hóa thông điệp có độ dài tùy ý, cần lựa chọn chế độ vận hành (mode). Cùng một thuật toán AES nhưng các mode khác nhau có thể tạo ra đặc tính bảo mật hoàn toàn khác nhau.
| Chế độ | Đặc điểm | Khuyến nghị |
|---|---|---|
| ECB | Các khối plaintext giống nhau có thể tạo ra các khối ciphertext giống nhau. | Không dùng để mã hóa dữ liệu ứng dụng thông thường. |
| CBC | Cần IV phù hợp và padding cho dữ liệu không đủ khối; bản thân không xác thực dữ liệu. | Chỉ dùng khi có yêu cầu tương thích và kết hợp MAC đúng cách. |
| CTR | Biến AES thành luồng mã hóa; không cung cấp xác thực dữ liệu. | Chỉ sử dụng với cơ chế xác thực được thiết kế chính xác. |
| GCM | Mã hóa kết hợp xác thực (AEAD), cung cấp authentication tag. | Ưu tiên cho ứng dụng mới khi được hỗ trợ. |
Tại sao ECB nguy hiểm? ECB xử lý mỗi khối độc lập. Nếu hai khối plaintext bằng nhau, với cùng khóa, chúng sẽ cho hai khối ciphertext bằng nhau. Mẫu cấu trúc của dữ liệu vì thế có thể bị lộ, ngay cả khi nội dung thực tế không đọc được trực tiếp.
IV (Initialization Vector) là giá trị khởi tạo được đưa vào quá trình mã hóa của một số mode. Nonce là giá trị được thiết kế để sử dụng một lần trong phạm vi và điều kiện của thuật toán. Hai khái niệm này có thể liên quan nhưng không hoàn toàn đồng nghĩa trong mọi thuật toán.
Đối với AES-GCM, nonce thường dài 96 bit (12 byte). Với một khóa, nonce không được tái sử dụng cho các lần mã hóa khác nhau. Vi phạm nguyên tắc này có thể phá vỡ tính bí mật và tính xác thực của GCM.
Salt là dữ liệu ngẫu nhiên hoặc duy nhất được đưa vào quá trình dẫn xuất khóa, đặc biệt là băm mật khẩu. Salt không cần giữ bí mật và không thay thế khóa mã hóa. Trong PBKDF2, mỗi mật khẩu nên có salt riêng biệt.
Padding bổ sung dữ liệu vào khối cuối cùng để đáp ứng yêu cầu của một số chế độ mã hóa khối. GCM không cần padding, vì vậy transformation chuẩn thường dùng là AES/GCM/NoPadding.
2.3. AES-GCM — Mã hóa kết hợp xác thực (AEAD)
AES-GCM không chỉ mã hóa dữ liệu mà còn tạo authentication tag để phát hiện việc sửa đổi ciphertext hoặc dữ liệu bổ sung đã xác thực. Đây là thuộc tính quan trọng trong hệ thống thanh toán, lưu trữ dữ liệu nhạy cảm và các dịch vụ backend.
GCM hỗ trợ AAD (Additional Authenticated Data): dữ liệu không được mã hóa nhưng vẫn được bảo vệ tính toàn vẹn và xác thực. Ví dụ, ứng dụng có thể đưa phiên bản định dạng, mã bản ghi hoặc metadata vào AAD để ngăn ciphertext bị chuyển sang ngữ cảnh khác. AAD phải được cung cấp chính xác khi giải mã.
Dữ liệu đầu vào: Plaintext Secret Key (256 bit) Nonce / IV (khuyến nghị 96 bit) AAD (tùy chọn) Dữ liệu đầu ra: Nonce || Ciphertext || Authentication Tag Khi giải mã: 1. Đọc nonce và ciphertext. 2. Đọc authentication tag. 3. Xác minh tag. 4. Chỉ sử dụng plaintext nếu xác minh thành công.
Không trả về hoặc sử dụng plaintext chưa được xác minh. Với JCA, quá trình giải mã GCM thường phát sinh
AEADBadTagException nếu tag không hợp lệ. Hãy coi toàn bộ thao tác giải mã thất bại như một lỗi xác thực và không tiết lộ chi tiết nội bộ cho người dùng cuối.
2.4. AES-CBC và những giới hạn bảo mật
CBC là chế độ mã hóa từng khối. Mỗi khối plaintext được kết hợp với ciphertext trước đó; khối đầu tiên sử dụng IV. IV của CBC cần được tạo ngẫu nhiên và khó dự đoán khi bắt đầu quá trình mã hóa.
Tuy nhiên, CBC không tự xác thực ciphertext. Kẻ tấn công có thể thay đổi dữ liệu mã hóa và khiến plaintext sau giải mã bị biến đổi. Trong một số kiến trúc, lỗi padding còn có thể tạo ra padding oracle, cho phép kẻ tấn công suy luận thông tin nhạy cảm.
Nếu buộc phải sử dụng CBC vì tương thích, hãy kết hợp một MAC an toàn, dùng mô hình Encrypt-then-MAC và xác minh MAC trước khi giải mã. Việc so sánh MAC phải được thực hiện bằng phương pháp phù hợp cho dữ liệu bí mật, đồng thời không để lộ khác biệt lỗi padding và lỗi xác thực. Đối với hệ thống mới, AEAD như AES-GCM thường là lựa chọn đơn giản và an toàn hơn.
3. Mã hóa bất đối xứng với RSA và ECC
Mã hóa bất đối xứng sử dụng một cặp khóa có quan hệ toán học: public key và private key. Public key có thể được chia sẻ; private key phải được bảo vệ nghiêm ngặt.
Cần phân biệt hai mục đích quan trọng: mã hóa bất đối xứng để bảo vệ bí mật và chữ ký số để xác minh nguồn gốc cùng tính toàn vẹn. Chúng có cách sử dụng cặp khóa khác nhau.
Mã hóa RSA:
Plaintext + Public Key
│
▼
Ciphertext
│
▼
Private Key + Ciphertext
│
▼
Plaintext
Chữ ký số:
Message + Private Key
│
▼
Signature
│
▼
Message + Public Key + Signature
│
▼
Valid / Invalid
3.1. Public Key và Private Key
Public key được sử dụng tùy theo thuật toán và mục đích: mã hóa dữ liệu bằng RSA, xác minh chữ ký RSA hoặc Ed25519, hay thiết lập khóa chung bằng ECDH. Private key được dùng cho những thao tác bí mật tương ứng, chẳng hạn giải mã RSA, ký số hoặc tham gia thiết lập khóa.
Không thể kết luận rằng mọi private key đều dùng để giải mã và mọi public key đều dùng để mã hóa. Chẳng hạn, Ed25519 là thuật toán chữ ký số, không phải thuật toán mã hóa dữ liệu. ECDH là cơ chế thỏa thuận khóa, không phải chữ ký số.
3.2. RSA — Thuật toán bất đối xứng truyền thống
RSA dựa trên các tính chất toán học của số nguyên lớn và bài toán phân tích thừa số. Độ an toàn của nó phụ thuộc vào kích thước khóa, cách sinh khóa, thuật toán padding và implementation.
Trong các ứng dụng mới, RSA 2048 bit thường được xem là mức tối thiểu thực dụng phổ biến cho nhiều mục đích, trong khi RSA 3072 bit có thể được chọn khi cần biên an toàn cao hơn hoặc thời gian bảo vệ dài hơn. Lựa chọn cụ thể phải phù hợp với chính sách bảo mật và thời hạn sử dụng dữ liệu.
Không sử dụng RSA trực tiếp trên các khối dữ liệu tùy ý bằng chế độ thô. Với mã hóa RSA, cần padding an toàn như RSA/ECB/OAEPWithSHA-256AndMGF1Padding, kèm cấu hình OAEP nhất quán. Tên transformation chứa chữ ECB ở đây là quy ước API cho RSA và không có nghĩa RSA đang sử dụng chế độ ECB của AES.
import java.security.KeyPair;
import java.security.KeyPairGenerator;
public class RsaKeyGeneration {
public static void main(String[] args) throws Exception {
KeyPairGenerator generator =
KeyPairGenerator.getInstance("RSA");
generator.initialize(3072);
KeyPair keyPair = generator.generateKeyPair();
System.out.println(
"Public key algorithm: " +
keyPair.getPublic().getAlgorithm()
);
System.out.println(
"Private key algorithm: " +
keyPair.getPrivate().getAlgorithm()
);
System.out.println(
"Public key format: " +
keyPair.getPublic().getFormat()
);
}
}
Chương trình sinh cặp khóa RSA 3072 bit. Khóa công khai có thể được xuất theo định dạng phù hợp, chẳng hạn X.509 SubjectPublicKeyInfo. Khóa riêng cần được bảo vệ; không nên in nội dung khóa riêng ra console trong ứng dụng thật.
3.3. ECC, ECDH, ECDSA và Ed25519
ECC (Elliptic Curve Cryptography) là nhóm kỹ thuật mật mã dựa trên các đường cong elliptic. Với mức an toàn tương đương, nhiều thuật toán ECC có thể sử dụng khóa nhỏ hơn RSA, giảm chi phí lưu trữ và truyền tải.
| Thuật toán | Mục đích | API Java phổ biến |
|---|---|---|
| RSA | Mã hóa có padding phù hợp hoặc chữ ký số. | KeyPairGenerator, Cipher, Signature. |
| ECDH | Thiết lập bí mật chung giữa các bên. | KeyPairGenerator, KeyAgreement. |
| ECDSA | Chữ ký số trên đường cong elliptic. | Signature. |
| Ed25519 | Chữ ký số dựa trên Edwards-curve. | KeyPairGenerator, Signature. |
Ed25519 là thuật toán chữ ký số hiện đại có API tương đối đơn giản. Trên các JDK hiện đại có hỗ trợ, bạn có thể sinh khóa bằng KeyPairGenerator.getInstance("Ed25519") và ký bằng Signature.getInstance("Ed25519").
Ed25519 không phải thuật toán mã hóa dữ liệu và không phải thuật toán trao đổi khóa. Nếu cần thiết lập bí mật chung bằng đường cong elliptic, hãy tìm hiểu ECDH. Nếu cần chữ ký số, hãy sử dụng ECDSA hoặc Ed25519 theo chính sách và yêu cầu tương thích của hệ thống.
RSA, ECDH, ECDSA và Ed25519 không thể thay thế trực tiếp cho nhau. Hãy chọn thuật toán dựa trên mục đích sử dụng, yêu cầu tương thích, chính sách mật mã và khả năng của JDK/Provider.
4. Hash, Password Hashing và chữ ký số
Hàm băm mật mã là hàm một chiều nhận dữ liệu đầu vào có độ dài tùy ý và tạo ra giá trị đầu ra có độ dài cố định. Hàm băm không sử dụng khóa bí mật theo cách của thuật toán mã hóa thông thường và không có thao tác giải mã để khôi phục dữ liệu gốc.
4.1. MessageDigest và SHA-256
SHA-256 thuộc họ SHA-2, tạo ra giá trị băm 256 bit, tương đương 32 byte. Nó hữu ích trong kiểm tra tính toàn vẹn, định danh nội dung và làm thành phần của nhiều giao thức mật mã.
Ví dụ, nếu một file có giá trị SHA-256 đã được xác minh từ một nguồn đáng tin cậy, bạn có thể tính lại hash của file nhận được và so sánh hai giá trị. Tuy nhiên, nếu kẻ tấn công có thể thay đổi cả file lẫn hash tham chiếu, phép so sánh không chứng minh được tính xác thực của nguồn.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public class Sha256Demo {
public static void main(String[] args) throws Exception {
String message = "Hello Java Cryptography";
MessageDigest digest =
MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(
message.getBytes(StandardCharsets.UTF_8)
);
System.out.println("Message: " + message);
System.out.println(
"SHA-256: " + HexFormat.of().formatHex(hash)
);
}
}
Kết quả hash của một thông điệp cố định sẽ không đổi nếu thuật toán và cách biểu diễn byte đầu vào giống nhau. Thay đổi dù chỉ một byte thường làm giá trị băm thay đổi đáng kể.
SHA-256 không phải mã hóa
| Đặc điểm | Mã hóa | Hàm băm |
|---|---|---|
| Khôi phục dữ liệu gốc | Có thể, nếu có khóa và tham số phù hợp. | Không có thao tác đảo ngược thực tế. |
| Khóa bí mật | Thông thường cần khóa. | SHA-256 thông thường không cần khóa. |
| Mục đích | Bảo vệ tính bí mật. | Kiểm tra tính toàn vẹn, định danh dữ liệu. |
| Phù hợp để lưu mật khẩu? | Không lưu mật khẩu bằng mã hóa có thể đảo ngược nếu không thực sự cần. | SHA-256 thuần không phù hợp để lưu mật khẩu. |
4.2. Password Hashing — Tại sao SHA-256 thuần không an toàn?
Mật khẩu thường có entropy thấp hơn khóa mật mã được sinh ngẫu nhiên. Kẻ tấn công có thể thử hàng triệu hoặc hàng tỷ mật khẩu ứng viên, băm từng mật khẩu rồi so sánh với giá trị bị đánh cắp. Vì SHA-256 được thiết kế để tính nhanh, việc thử mật khẩu bằng GPU hoặc phần cứng chuyên dụng có thể rất hiệu quả.
Để giảm nguy cơ này, hệ thống nên dùng thuật toán dẫn xuất khóa hoặc password hashing chuyên dụng, kết hợp salt riêng cho từng mật khẩu và tham số chi phí đủ cao.
Người dùng nhập mật khẩu
│
▼
Sinh Salt ngẫu nhiên riêng
│
▼
PBKDF2-HMAC-SHA256
Password + Salt
Iterations + Output Length
│
▼
Derived Hash / Verifier
│
▼
Lưu Salt + Iterations + Hash
Khi đăng nhập:
Password nhập lại + Salt đã lưu
│
▼
Tính lại PBKDF2
│
▼
So sánh an toàn với Hash đã lưu
Salt: làm cho cùng một mật khẩu không nhất thiết tạo ra cùng một giá trị lưu trữ khi mỗi tài khoản sử dụng salt riêng. Salt không cần giữ bí mật.
Iterations (Key Stretching): lặp lại quá trình tính toán để tăng chi phí cho mỗi lần thử mật khẩu. Số vòng quá thấp làm giảm khả năng chống dò mật khẩu; số vòng quá cao có thể làm hệ thống dễ bị DoS hoặc khiến đăng nhập quá chậm.
Output Length: độ dài đầu ra của thuật toán dẫn xuất khóa. Giá trị cần được lựa chọn theo thuật toán và chính sách triển khai; tăng độ dài đầu ra không thay thế việc chọn chi phí phù hợp.
PBKDF2 có sẵn trong JCA thông qua SecretKeyFactory và PBEKeySpec. Các ứng dụng mới cũng nên cân nhắc Argon2id hoặc scrypt khi có thư viện được kiểm định phù hợp, vì chúng có thể tăng khả năng chống dò mật khẩu bằng phần cứng chuyên dụng nhờ đặc tính tiêu tốn bộ nhớ.
Một cấu hình thực hành có thể bắt đầu ở mức 600.000 vòng PBKDF2-HMAC-SHA256, salt ngẫu nhiên 16 byte và đầu ra 256 bit, sau đó đo benchmark trên hạ tầng thực tế và điều chỉnh theo chính sách bảo mật hiện hành. Đây không phải con số tối ưu cho mọi hệ thống. Khi chính sách thay đổi, nên hỗ trợ nâng cấp tham số sau lần đăng nhập thành công.
4.3. Digital Signature — Chữ ký số
Chữ ký số cho phép người nhận kiểm tra liệu thông điệp có bị thay đổi kể từ khi ký hay không và liệu chữ ký có được tạo bằng private key tương ứng hay không. Để liên kết khóa công khai với danh tính thực tế, hệ thống còn cần cơ chế quản lý khóa, chứng chỉ hoặc một kênh phân phối public key đáng tin cậy.
Với RSA, ứng dụng thường sử dụng một thuật toán chữ ký như SHA256withRSA. Với Ed25519, API thực hiện quy trình chữ ký theo thuật toán Ed25519 mà không cần chỉ định riêng SHA-256 trong tên thuật toán.
Về mặt khái niệm, quy trình chữ ký số gồm các bước sau:
- Người ký cung cấp thông điệp cho thuật toán chữ ký.
- Thuật toán sử dụng private key để tạo chữ ký theo cơ chế mật mã tương ứng.
- Người nhận lấy thông điệp, chữ ký và public key đã được xác thực.
- Thuật toán xác minh trả về hợp lệ hoặc không hợp lệ.
Chữ ký số không mã hóa thông điệp. Nếu thông điệp chứa thông tin nhạy cảm, cần dùng một cơ chế bảo vệ tính bí mật riêng.
5. Quản lý khóa với KeyStore
Trong hệ thống thật, bảo vệ khóa thường quan trọng không kém việc chọn thuật toán. Nếu private key hoặc khóa AES bị lộ, kẻ tấn công có thể đọc dữ liệu, giả mạo chữ ký hoặc thực hiện các hành động trái phép tùy theo quyền hạn của khóa đó.
KeyStore là API Java dùng để quản lý các entry như private key kèm certificate chain, secret key và certificate. Nó cung cấp cơ chế lưu trữ, truy xuất và bảo vệ entry dựa trên loại keystore cùng cấu hình mật khẩu hoặc cơ chế bảo vệ được hỗ trợ.
5.1. Các loại Entry trong KeyStore
| Entry | Nội dung | Ứng dụng |
|---|---|---|
PrivateKeyEntry |
Private key và chuỗi chứng chỉ liên quan. | TLS server, ký số, danh tính dịch vụ. |
SecretKeyEntry |
Khóa bí mật đối xứng. | Mã hóa dữ liệu AES. |
TrustedCertificateEntry |
Chứng chỉ được lưu dưới dạng entry tin cậy. | Trust store và xác minh chứng chỉ. |
JDK hiện đại thường hỗ trợ định dạng PKCS12. Java cũng có định dạng JKS trong nhiều môi trường. Cần lựa chọn loại keystore phù hợp và không nhầm lẫn giữa một kho chứa private key với một trust store chỉ chứa các chứng chỉ tin cậy.
5.2. Tạo keystore PKCS12
Trong môi trường thực hành, có thể sử dụng công cụ keytool đi kèm JDK để tạo cặp khóa và lưu vào keystore. Ví dụ sau tạo một cặp khóa RSA phục vụ thử nghiệm. Chứng chỉ tự ký trong ví dụ chỉ nhằm kiểm tra thao tác lưu trữ, không tự động trở thành chứng chỉ đáng tin cậy cho môi trường production.
keytool -genkeypair ^ -alias demo-signing-key ^ -keyalg RSA ^ -keysize 3072 ^ -sigalg SHA256withRSA ^ -validity 365 ^ -storetype PKCS12 ^ -keystore demo-keystore.p12
Trên Linux hoặc macOS, có thể viết lệnh trên một dòng hoặc dùng dấu gạch chéo ngược để nối dòng. Công cụ sẽ yêu cầu nhập mật khẩu keystore và thông tin chứng chỉ.
Để liệt kê entry, sử dụng:
keytool -list -v -keystore demo-keystore.p12 -storetype PKCS12
Để đọc private key trong ứng dụng, cần mở keystore bằng mật khẩu thích hợp, lấy entry theo alias và cung cấp mật khẩu bảo vệ entry nếu có. Không nên đặt mật khẩu keystore trong source code hoặc đưa file keystore cùng khóa riêng vào kho mã nguồn.
Keystore không phải một giải pháp chống mọi hình thức đánh cắp khóa. Nếu kẻ tấn công có cả file keystore và mật khẩu, mức bảo vệ có thể bị phá vỡ. Với hệ thống quan trọng, nên cân nhắc KMS, HSM, secret manager, kiểm soát quyền truy cập và quy trình xoay vòng khóa.
6. Thực chiến Java Cryptography — Code hoàn chỉnh và kiểm chứng
Phần này xây dựng các chương trình độc lập có thể sao chép, biên dịch và chạy trực tiếp. Các ví dụ sử dụng Java 17 trở lên để tận dụng HexFormat và API mật mã hiện đại. Chương trình không cần Maven hoặc thư viện bên thứ ba.
6.0. Chuẩn bị môi trường
Kiểm tra phiên bản JDK trước khi bắt đầu. Các ví dụ được thiết kế để chạy trên JDK 17 hoặc mới hơn, với điều kiện Provider mặc định hỗ trợ những thuật toán được sử dụng.
java -version javac -version
Tạo một thư mục riêng cho từng ví dụ, lưu mã nguồn đúng tên file public class và chạy bằng javac cùng java. Những giá trị ngẫu nhiên như khóa, IV, salt và chữ ký thường khác nhau sau mỗi lần chạy; kết quả kiểm chứng quan trọng là các trạng thái thành công hoặc thất bại mong đợi.
6.1. Ví dụ 1 — AES-256-GCM mã hóa và giải mã chuỗi
Chương trình dưới đây thực hiện đầy đủ quy trình:
- Sinh khóa AES 256 bit bằng
KeyGenerator. - Sinh nonce ngẫu nhiên 12 byte bằng
SecureRandom. - Mã hóa chuỗi UTF-8 bằng AES-GCM với tag 128 bit.
- Đóng gói nonce và ciphertext kèm tag để lưu hoặc truyền tải.
- Giải mã và xác nhận dữ liệu khôi phục giống plaintext ban đầu.
- Sửa một byte của ciphertext để chứng minh tag có thể phát hiện thay đổi.
import javax.crypto.AEADBadTagException;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
public class AesGcmDemo {
private static final int KEY_SIZE = 256;
private static final int NONCE_SIZE = 12;
private static final int TAG_SIZE_BITS = 128;
private static final SecureRandom RANDOM =
new SecureRandom();
static byte[] encrypt(
SecretKey key,
byte[] plaintext) throws Exception {
byte[] nonce = new byte[NONCE_SIZE];
RANDOM.nextBytes(nonce);
Cipher cipher =
Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec parameters =
new GCMParameterSpec(TAG_SIZE_BITS, nonce);
cipher.init(Cipher.ENCRYPT_MODE, key, parameters);
byte[] ciphertext = cipher.doFinal(plaintext);
// Định dạng minh họa: nonce || ciphertext || tag.
// GCM trả ciphertext kèm authentication tag.
return ByteBuffer.allocate(
nonce.length + ciphertext.length)
.put(nonce)
.put(ciphertext)
.array();
}
static byte[] decrypt(
SecretKey key,
byte[] payload) throws Exception {
if (payload.length < NONCE_SIZE + 16) {
throw new IllegalArgumentException(
"Payload is too short"
);
}
ByteBuffer buffer = ByteBuffer.wrap(payload);
byte[] nonce = new byte[NONCE_SIZE];
buffer.get(nonce);
byte[] ciphertextAndTag =
new byte[buffer.remaining()];
buffer.get(ciphertextAndTag);
Cipher cipher =
Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec parameters =
new GCMParameterSpec(TAG_SIZE_BITS, nonce);
cipher.init(
Cipher.DECRYPT_MODE, key, parameters
);
// Chỉ nhận plaintext nếu tag hợp lệ.
return cipher.doFinal(ciphertextAndTag);
}
public static void main(String[] args) throws Exception {
KeyGenerator generator =
KeyGenerator.getInstance("AES");
generator.init(KEY_SIZE);
SecretKey key = generator.generateKey();
String message =
"Java Cryptography - AES-256-GCM";
byte[] plaintext =
message.getBytes(StandardCharsets.UTF_8);
byte[] payload = encrypt(key, plaintext);
System.out.println("Original: " + message);
System.out.println(
"Payload (Base64): " +
Base64.getEncoder().encodeToString(payload)
);
byte[] recovered = decrypt(key, payload);
System.out.println(
"Decrypted: " +
new String(recovered, StandardCharsets.UTF_8)
);
System.out.println(
"Match: " + Arrays.equals(plaintext, recovered)
);
// Thử sửa một byte thuộc ciphertext.
byte[] tampered = payload.clone();
tampered[NONCE_SIZE] ^= 0x01;
try {
decrypt(key, tampered);
System.out.println(
"Tampering detected: false"
);
} catch (AEADBadTagException ex) {
System.out.println(
"Tampering detected: true"
);
}
}
}
Biên dịch và thực thi:
javac AesGcmDemo.java java AesGcmDemo
Kết quả mẫu:
Original: Java Cryptography - AES-256-GCM Payload (Base64): [Base64 thay đổi theo mỗi lần chạy] Decrypted: Java Cryptography - AES-256-GCM Match: true Tampering detected: true
Dòng Base64 được rút gọn trong kết quả minh họa vì payload phụ thuộc vào khóa và nonce ngẫu nhiên. Khi chạy thực tế, chương trình sẽ in một chuỗi Base64 đầy đủ.
Giải thích những điểm bảo mật quan trọng
Thứ nhất, nonce được tạo mới cho mỗi lần mã hóa. Nonce được ghép vào payload để người nhận có thể giải mã mà không phải lưu nonce riêng ở một nơi khác. Nonce không cần bí mật.
Thứ hai, khóa được sinh ngẫu nhiên. Ví dụ lưu khóa trong biến để minh họa một vòng đời ngắn. Trong ứng dụng thật, khóa phải được quản lý bền vững qua một cơ chế bảo vệ thích hợp; nếu sinh khóa mới sau mỗi lần khởi động mà không lưu khóa cũ, dữ liệu đã mã hóa trước đó sẽ không thể giải mã bằng khóa mới.
Thứ ba, tag được xác minh trước khi dữ liệu được chấp nhận. Khi sửa ciphertext, chương trình mong đợi lỗi AEADBadTagException. Tầng ứng dụng không nên trả nội dung giải mã từng phần hoặc chi tiết ngoại lệ mật mã cho client.
Thứ tư, phải bảo đảm tính duy nhất của nonce với cùng một khóa. Dùng nonce ngẫu nhiên 96 bit là lựa chọn phổ biến, nhưng không loại bỏ hoàn toàn nguy cơ trùng lặp khi số lượng thông điệp tăng rất lớn. Hệ thống có lưu lượng cao cần giới hạn số lần sử dụng khóa, đánh giá xác suất trùng nonce hoặc thiết kế bộ đếm nonce có tính duy nhất được bảo đảm trong môi trường phân tán.
6.1.1. Mở rộng — Sử dụng AAD để xác thực metadata
Giả sử ciphertext được lưu cùng mã bản ghi. Nếu muốn bảo vệ liên kết giữa ciphertext và bản ghi, có thể đưa mã bản ghi vào AAD trong cả quá trình mã hóa lẫn giải mã.
byte[] aad = "record:12345:v1"
.getBytes(StandardCharsets.UTF_8);
Cipher cipher =
Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
key,
new GCMParameterSpec(128, nonce)
);
cipher.updateAAD(aad);
byte[] ciphertextAndTag =
cipher.doFinal(plaintext);
Khi giải mã, phải gọi updateAAD(aad) với đúng dữ liệu AAD trước doFinal(). Nếu metadata thay đổi, việc xác minh tag sẽ thất bại. AAD không được mã hóa nên không nên đưa bí mật vào trường này.
6.2. Ví dụ 2 — PBKDF2-HMAC-SHA256 lưu và xác minh mật khẩu
Trong ví dụ này, chương trình sinh salt ngẫu nhiên, dẫn xuất verifier từ mật khẩu bằng PBKDF2 và xác minh mật khẩu nhập lại. Salt và verifier được lưu cùng nhau; mật khẩu gốc không được lưu.
Định dạng lưu trữ cần bao gồm tên thuật toán, số vòng lặp, độ dài đầu ra, salt và verifier để có thể xác minh về sau. Các tham số này không phải bí mật, nhưng phải được kiểm tra giới hạn khi đọc để tránh dữ liệu cấu hình độc hại gây tiêu tốn CPU hoặc bộ nhớ quá mức.
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
public class Pbkdf2PasswordDemo {
private static final int SALT_LENGTH = 16;
private static final int HASH_BITS = 256;
private static final int ITERATIONS = 600_000;
private static final SecureRandom RANDOM =
new SecureRandom();
static byte[] derive(
char[] password,
byte[] salt,
int iterations) throws Exception {
PBEKeySpec spec = new PBEKeySpec(
password,
salt,
iterations,
HASH_BITS
);
try {
SecretKeyFactory factory =
SecretKeyFactory.getInstance(
"PBKDF2WithHmacSHA256"
);
return factory.generateSecret(spec).getEncoded();
} finally {
spec.clearPassword();
}
}
static boolean verify(
char[] password,
byte[] salt,
int iterations,
byte[] expectedHash) throws Exception {
byte[] actualHash =
derive(password, salt, iterations);
try {
return MessageDigest.isEqual(
expectedHash,
actualHash
);
} finally {
Arrays.fill(actualHash, (byte) 0);
}
}
public static void main(String[] args) throws Exception {
char[] originalPassword =
"CorrectHorseBatteryStaple!2026".toCharArray();
char[] wrongPassword =
"WrongPassword!".toCharArray();
byte[] salt = new byte[SALT_LENGTH];
RANDOM.nextBytes(salt);
byte[] hash = derive(
originalPassword,
salt,
ITERATIONS
);
System.out.println(
"Algorithm: PBKDF2WithHmacSHA256"
);
System.out.println(
"Iterations: " + ITERATIONS
);
System.out.println(
"Salt (Base64): " +
Base64.getEncoder().encodeToString(salt)
);
System.out.println(
"Hash (Base64): " +
Base64.getEncoder().encodeToString(hash)
);
System.out.println(
"Correct password: " +
verify(
originalPassword,
salt,
ITERATIONS,
hash
)
);
System.out.println(
"Wrong password: " +
verify(
wrongPassword,
salt,
ITERATIONS,
hash
)
);
Arrays.fill(originalPassword, '\\0');
Arrays.fill(wrongPassword, '\\0');
Arrays.fill(hash, (byte) 0);
}
}
Biên dịch và chạy:
javac Pbkdf2PasswordDemo.java java Pbkdf2PasswordDemo
Kết quả mẫu:
Algorithm: PBKDF2WithHmacSHA256 Iterations: 600000 Salt (Base64): [salt ngẫu nhiên] Hash (Base64): [verifier ngẫu nhiên] Correct password: true Wrong password: false
Salt và hash sẽ thay đổi khi chạy lại chương trình vì salt được sinh ngẫu nhiên. Với cùng mật khẩu nhưng salt khác nhau, verifier thường khác nhau. Khi xác minh, chương trình sử dụng đúng salt và tham số đã lưu để tính lại verifier.
Những điểm cần cải thiện khi đưa vào production
- Lưu thuật toán, phiên bản định dạng, số vòng lặp, salt và verifier cùng nhau trong một định dạng được xác định rõ.
- Kiểm tra nghiêm ngặt số vòng lặp và độ dài dữ liệu trước khi thực hiện PBKDF2.
- Dùng salt riêng cho mỗi mật khẩu, sinh bằng
SecureRandom. - Không log mật khẩu, salt hoặc verifier một cách không cần thiết. Salt và verifier không phải bí mật như mật khẩu, nhưng vẫn là dữ liệu nhạy cảm về mặt vận hành.
- Xóa bản sao mật khẩu khi không còn cần thiết. Java không bảo đảm xóa sạch mọi bản sao trong bộ nhớ, nên không thể coi
Arrays.fill()là một bảo đảm xóa tuyệt đối. - Đo thời gian xử lý trên hạ tầng thực tế để lựa chọn tham số phù hợp và tránh gây nghẽn khi có lượng đăng nhập lớn.
- Trong hệ thống mới, đánh giá Argon2id hoặc scrypt nếu có implementation được duy trì và kiểm định phù hợp.
Việc chuyển mật khẩu từ chuỗi ký tự sang đầu vào thuật toán có thể khác nhau giữa các implementation và ngôn ngữ. Hãy sử dụng API nhất quán, xác định rõ chính sách chuẩn hóa Unicode nếu cần, và không thay đổi chính sách đó một cách âm thầm đối với tài khoản đã tồn tại.
6.3. Ví dụ 3 — Tạo và xác minh chữ ký số RSA và Ed25519
Chương trình sau minh họa hai thuật toán chữ ký số. RSA sử dụng SHA256withRSA, còn Ed25519 sử dụng Ed25519. Chương trình sinh cặp khóa trong bộ nhớ, ký thông điệp bằng private key, xác minh bằng public key và thử xác minh lại sau khi thông điệp bị sửa.
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.util.Arrays;
import java.util.Base64;
public class DigitalSignatureDemo {
static void testAlgorithm(
String keyAlgorithm,
String signatureAlgorithm,
int keySize) throws Exception {
KeyPairGenerator generator =
KeyPairGenerator.getInstance(keyAlgorithm);
if (keySize > 0) {
generator.initialize(keySize);
}
KeyPair pair = generator.generateKeyPair();
byte[] message =
"Transfer amount: 1000".getBytes(
StandardCharsets.UTF_8
);
Signature signer =
Signature.getInstance(signatureAlgorithm);
signer.initSign(pair.getPrivate());
signer.update(message);
byte[] signatureBytes = signer.sign();
Signature verifier =
Signature.getInstance(signatureAlgorithm);
verifier.initVerify(pair.getPublic());
verifier.update(message);
boolean valid = verifier.verify(signatureBytes);
byte[] tamperedMessage =
"Transfer amount: 9000".getBytes(
StandardCharsets.UTF_8
);
Signature tamperedVerifier =
Signature.getInstance(signatureAlgorithm);
tamperedVerifier.initVerify(pair.getPublic());
tamperedVerifier.update(tamperedMessage);
boolean tamperedValid =
tamperedVerifier.verify(signatureBytes);
System.out.println("Algorithm: " + signatureAlgorithm);
System.out.println(
"Signature (Base64): " +
Base64.getEncoder().encodeToString(signatureBytes)
);
System.out.println("Valid signature: " + valid);
System.out.println(
"Tampered message accepted: " + tamperedValid
);
System.out.println();
}
public static void main(String[] args) throws Exception {
testAlgorithm("RSA", "SHA256withRSA", 3072);
testAlgorithm("Ed25519", "Ed25519", 0);
}
}
Biên dịch và chạy:
javac DigitalSignatureDemo.java java DigitalSignatureDemo
Kết quả mẫu:
Algorithm: SHA256withRSA Signature (Base64): [chữ ký RSA] Valid signature: true Tampered message accepted: false Algorithm: Ed25519 Signature (Base64): [chữ ký Ed25519] Valid signature: true Tampered message accepted: false
Chữ ký thực tế sẽ khác nhau tùy theo cặp khóa được sinh. Với RSA sử dụng cơ chế ký ngẫu nhiên như RSA-PSS, chữ ký cũng có thể thay đổi qua các lần ký cùng thông điệp. Ví dụ trên sử dụng SHA256withRSA để minh họa khả năng tương thích phổ biến; với thiết kế mới, hãy đánh giá RSA-PSS khi hệ thống và yêu cầu tương thích cho phép.
RSA-PSS trong ứng dụng mới
RSA-PSS là cơ chế chữ ký RSA có padding xác suất, được thiết kế để cung cấp các thuộc tính bảo mật phù hợp cho chữ ký số hiện đại. Trong Java, có thể cấu hình rõ ràng thông qua Signature.getInstance("RSASSA-PSS") và PSSParameterSpec. Tham số hash, MGF, hash của MGF, độ dài salt và trailer field cần được cấu hình nhất quán giữa bên ký và bên xác minh.
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
// Dùng cùng cấu hình khi ký và xác minh.
PSSParameterSpec pssSpec = new PSSParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
32,
1
);
Signature signature =
Signature.getInstance("RSASSA-PSS");
signature.setParameter(pssSpec);
// Sau đó gọi initSign(privateKey), update(...), sign()
// hoặc initVerify(publicKey), update(...), verify(...).
Đối với ứng dụng production, không được mặc định rằng việc xác minh thành công bằng một public key bất kỳ chứng minh danh tính của người ký. Public key phải được xác thực thông qua chứng chỉ, trust store, pinning được thiết kế phù hợp hoặc một cơ chế phân phối khóa đáng tin cậy.
6.3.1. Bẫy thường gặp khi xác minh chữ ký
- Ký nội dung đã được chuẩn hóa theo một cách nhưng xác minh nội dung có định dạng khác.
- Không xác thực public key hoặc chứng chỉ trước khi tin kết quả xác minh.
- Chỉ ký một phần dữ liệu trong khi các trường quan trọng khác có thể bị sửa đổi.
- Không có chính sách về thời hạn khóa, thu hồi chứng chỉ và xoay vòng khóa.
- Nhầm chữ ký số với mã hóa dữ liệu hoặc dùng Ed25519 để thực hiện thao tác mã hóa.
6.4. Ví dụ 4 — SHA-256 kiểm tra tính toàn vẹn của file
Ví dụ tiếp theo tính SHA-256 của một file theo dạng streaming. Cách đọc theo từng khối phù hợp hơn việc nạp toàn bộ file lớn vào bộ nhớ.
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
public class FileSha256 {
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println(
"Usage: java FileSha256 <file-path>"
);
System.exit(2);
}
Path file = Path.of(args[0]);
MessageDigest digest =
MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) {
digest.update(buffer, 0, count);
}
}
byte[] hash = digest.digest();
System.out.println("File: " + file);
System.out.println(
"SHA-256: " + HexFormat.of().formatHex(hash)
);
}
}
Biên dịch và chạy:
javac FileSha256.java java FileSha256 sample.txt
Để kết quả có ý nghĩa bảo mật, hãy so sánh với giá trị hash được cung cấp qua một kênh đáng tin cậy hoặc được bảo vệ bằng chữ ký số. Hash được tải cùng file từ một nguồn không đáng tin cậy có thể bị thay đổi đồng thời với file.
6.5. Bài tập thực hành mở rộng
- Thay đổi nội dung plaintext trong ví dụ AES-GCM và xác minh rằng chương trình giải mã khôi phục chính xác dữ liệu.
- Thay đổi một byte của payload, kiểm tra lỗi xác thực và bảo đảm không có plaintext không hợp lệ được sử dụng.
- Chạy ví dụ PBKDF2 nhiều lần để quan sát salt và verifier thay đổi trong khi xác minh mật khẩu chính xác vẫn thành công.
- Thay đổi nội dung đã ký trong ví dụ RSA/Ed25519 và quan sát kết quả xác minh.
- Chạy SHA-256 trên một file, thay đổi nội dung file rồi tính lại hash.
- Thay vì sinh khóa AES mỗi lần chạy, lưu khóa trong một keystore thử nghiệm và viết chương trình đọc lại khóa để giải mã dữ liệu đã lưu.
7. Các cạm bẫy bảo mật nghiêm trọng và Best Practices
Trong thực tế, sự cố mật mã thường không xuất phát từ việc thuật toán AES hoặc RSA bị phá vỡ về mặt toán học. Chúng thường bắt nguồn từ lỗi triển khai, quản lý khóa yếu, cấu hình không an toàn, sử dụng lại nonce hoặc việc ứng dụng tiết lộ thông tin nhạy cảm qua log và thông báo lỗi.
7.1. Những sai lầm cần tránh
| Sai lầm | Rủi ro | Cách khắc phục |
|---|---|---|
| Hardcode khóa trong source code. | Khóa có thể bị lộ qua Git, bản build hoặc log. | Dùng KMS, secret manager hoặc cơ chế quản lý khóa thích hợp. |
| Dùng AES-ECB. | Lộ mẫu cấu trúc của plaintext. | Ưu tiên AES-GCM hoặc AEAD phù hợp. |
| Tái sử dụng nonce trong GCM. | Có thể phá vỡ tính bí mật và tính xác thực. | Bảo đảm nonce duy nhất cho mỗi lần mã hóa với cùng khóa. |
| Dùng SHA-256 thuần để lưu mật khẩu. | Dễ bị dò mật khẩu ngoại tuyến. | Dùng Argon2id, scrypt hoặc PBKDF2 với tham số phù hợp. |
| Dùng MD5 hoặc SHA-1 cho mục đích bảo mật mới. | Không đáp ứng các yêu cầu chống va chạm hiện đại. | Ưu tiên SHA-256 hoặc thuật toán được chính sách hiện hành chấp thuận. |
| Dùng DES hoặc 3DES cho thiết kế mới. | DES có khóa quá ngắn; 3DES đã lỗi thời và bị hạn chế. | Ưu tiên AES với mode an toàn. |
Dùng Random tạo khóa. |
Giá trị có thể dự đoán. | Dùng SecureRandom hoặc API sinh khóa phù hợp. |
| Tự thiết kế thuật toán mã hóa. | Khó phát hiện lỗi mật mã và lỗi giao thức. | Dùng thuật toán chuẩn, API được duy trì và thư viện đáng tin cậy. |
| Để lộ thông báo lỗi giải mã chi tiết. | Có thể tạo oracle hoặc rò rỉ thông tin nội bộ. | Chuẩn hóa lỗi trả về bên ngoài, log nội bộ có kiểm soát. |
| Không có quy trình xoay vòng khóa. | Khó xử lý khi khóa bị lộ hoặc hết hạn. | Thiết kế key versioning, rotation và quy trình thu hồi. |
7.1.1. Hardcode key — Lỗi nguy hiểm nhất ở tầng vận hành
Đoạn mã sau là một ví dụ không an toàn:
// KHÔNG LÀM NHƯ THẾ NÀY!
private static final String AES_KEY =
"my-secret-key-123";
// Chuỗi trên cũng không phải khóa AES 128/192/256 bit hợp lệ
// theo dạng byte UTF-8 trong mọi trường hợp.
// Việc mã hóa hoặc che giấu chuỗi không giải quyết vấn đề quản lý khóa.
Khóa nhúng trong source code có thể bị trích xuất từ repository, file JAR, image container, log debug hoặc hệ thống build. Việc đổi tên biến hay mã hóa chuỗi khóa bằng một khóa khác nằm cùng ứng dụng không phải giải pháp thực sự.
Cách tiếp cận đúng là tách vòng đời khóa khỏi vòng đời ứng dụng. Ứng dụng lấy khóa từ một nguồn quản lý được kiểm soát quyền truy cập; quá trình phân phối, lưu trữ, sử dụng và xoay vòng khóa cần được thiết kế rõ ràng.
7.1.2. Không dùng hash để thay thế mã hóa
Hash không thể khôi phục dữ liệu gốc. Nếu ứng dụng cần hiển thị lại thông tin nhạy cảm sau khi lưu, cần dùng mã hóa phù hợp. Nếu ứng dụng chỉ cần xác minh mật khẩu, không nên lưu mật khẩu bằng mã hóa có thể đảo ngược; hãy dùng password hashing.
HMAC cũng khác hash thông thường: HMAC sử dụng khóa bí mật để tạo mã xác thực thông điệp. Người không có khóa không thể tạo HMAC hợp lệ theo cách thông thường. Tuy nhiên, các bên cùng sở hữu khóa HMAC đều có thể tạo mã xác thực, vì vậy HMAC không cung cấp thuộc tính chống chối bỏ như chữ ký số.
7.2. Best Practices cho Java Enterprise
1. Chọn thuật toán và tham số theo mục đích
- Mã hóa dữ liệu đối xứng: ưu tiên AES-GCM với nonce và authentication tag được quản lý đúng cách.
- Trao đổi khóa: sử dụng cơ chế chuẩn như ECDH hoặc giao thức đã được kiểm chứng.
- Chữ ký số: chọn RSA-PSS, Ed25519 hoặc thuật toán phù hợp với chính sách tương thích và quản lý khóa.
- Kiểm tra toàn vẹn: dùng SHA-256 hoặc thuật toán hash được chính sách chấp thuận.
- Lưu mật khẩu: dùng Argon2id, scrypt hoặc PBKDF2 với salt riêng và tham số chi phí thích hợp.
2. Phân loại khóa và giới hạn mục đích sử dụng
Không nên dùng cùng một khóa cho mọi mục đích. Khóa mã hóa dữ liệu, khóa ký số và khóa xác thực thông điệp nên có vòng đời và phạm vi sử dụng riêng. Nếu cần dẫn xuất nhiều khóa từ một khóa gốc, hãy dùng cơ chế dẫn xuất khóa chuẩn, có phân tách mục đích rõ ràng và gắn với giao thức cụ thể.
3. Quản lý khóa theo vòng đời
Mỗi khóa cần có thông tin về chủ sở hữu, mục đích, thời hạn, phiên bản, trạng thái và quy trình thu hồi. Hệ thống phải biết khóa nào đã mã hóa một bản ghi để có thể giải mã sau khi xoay vòng khóa. Không xóa khóa cũ trước khi đã xác định chính sách di trú hoặc giải mã dữ liệu phụ thuộc vào khóa đó.
4. Dùng TLS đúng cách khi truyền dữ liệu
Mã hóa dữ liệu ở tầng ứng dụng không thay thế TLS. TLS bảo vệ kênh truyền và hỗ trợ xác thực máy chủ theo cấu hình phù hợp. Không vô hiệu hóa kiểm tra chứng chỉ chỉ để làm cho kết nối hoạt động. Nếu cần bảo vệ dữ liệu đầu cuối, hãy thiết kế thêm cơ chế mã hóa ứng dụng dựa trên mô hình đe dọa thực tế.
5. Không tự thiết kế giao thức mật mã
Không nên tự ghép các thuật toán theo cảm tính, chẳng hạn mã hóa rồi băm theo một thứ tự tùy ý hoặc tạo nonce từ timestamp mà không phân tích tính duy nhất. Hãy ưu tiên giao thức và định dạng đã được đánh giá, dùng thư viện chuẩn, đồng thời kiểm tra các điều kiện lỗi và giới hạn đầu vào.
6. Giảm thiểu rò rỉ dữ liệu qua log và lỗi
Không log password, private key, token xác thực, plaintext nhạy cảm hoặc dữ liệu giải mã không cần thiết. Tránh trả stack trace và thông tin Provider nội bộ cho người dùng cuối. Cần phân biệt log phục vụ điều tra an ninh với log nghiệp vụ thông thường, đồng thời áp dụng kiểm soát truy cập và thời hạn lưu trữ.
7. Kiểm thử cả trường hợp thất bại
Kiểm thử không chỉ gồm việc mã hóa thành công và giải mã đúng. Cần kiểm tra ciphertext bị sửa, tag sai, khóa không đúng, nonce thiếu, dữ liệu đầu vào quá lớn, định dạng payload không hợp lệ, chứng chỉ hết hạn và khóa không còn được tin cậy. Các tình huống này thường tiết lộ lỗi thiết kế mà bài kiểm thử đường đi thành công không phát hiện được.
8. Đánh giá hiệu năng và giới hạn tài nguyên
PBKDF2 tiêu tốn CPU theo số vòng lặp; việc xử lý dữ liệu mã hóa cũng tiêu tốn bộ nhớ và thời gian. Cần đặt giới hạn kích thước payload, giới hạn số lần thử đăng nhập, theo dõi độ trễ và bảo vệ hệ thống trước các đầu vào độc hại. Không dùng các tham số chi phí cao một cách thiếu kiểm soát trong đường xử lý có thể bị gọi không giới hạn.
9. Xác minh yêu cầu tuân thủ
Một thuật toán được xem là mạnh về mặt mật mã không đồng nghĩa implementation cụ thể đã đáp ứng mọi yêu cầu tuân thủ. Nếu hệ thống chịu các quy định hoặc chính sách như FIPS, hãy xác minh đúng module mật mã, phiên bản, cấu hình và phạm vi chứng nhận được yêu cầu thay vì chỉ kiểm tra tên thuật toán.
- [ ] Không có khóa bí mật hardcode trong source code.
- [ ] Thuật toán và mode được lựa chọn theo mục đích.
- [ ] Nonce GCM không bị tái sử dụng với cùng khóa.
- [ ] Có cơ chế xác thực tính toàn vẹn cho ciphertext.
- [ ] Mật khẩu được lưu bằng password hashing chuyên dụng.
- [ ] Private key và secret key được bảo vệ bằng cơ chế phù hợp.
- [ ] Có quy trình xoay vòng, thu hồi và khôi phục khóa.
- [ ] Có kiểm thử lỗi xác thực, lỗi khóa và dữ liệu bị sửa đổi.
- [ ] Không rò rỉ dữ liệu nhạy cảm qua log hoặc thông báo lỗi.
- [ ] Có đánh giá bảo mật và benchmark theo môi trường triển khai.
8. Tổng kết và lộ trình thực hành
Java Cryptography Architecture và Java Cryptography Extension cung cấp nền tảng quan trọng để xây dựng các hệ thống Java có khả năng bảo vệ dữ liệu. Tuy nhiên, sử dụng đúng API mới chỉ là bước đầu. Lập trình viên cần hiểu mối quan hệ giữa thuật toán, khóa, nonce, salt, xác thực dữ liệu và vòng đời khóa để tránh những lỗi bảo mật có thể gây hậu quả nghiêm trọng.
| Nhu cầu | Công cụ Java | Ghi nhớ |
|---|---|---|
| Mã hóa dữ liệu nhạy cảm | Cipher + AES-GCM |
Quản lý khóa, nonce và tag. |
| Lưu mật khẩu | SecretKeyFactory + PBKDF2 |
Salt riêng, tham số chi phí đủ cao. |
| Kiểm tra tính toàn vẹn | MessageDigest + SHA-256 |
Hash không tự xác thực nguồn dữ liệu. |
| Xác thực thông điệp bằng khóa chung | Mac + HMAC |
Bảo vệ và phân phối khóa HMAC. |
| Ký số và xác minh | Signature + RSA-PSS hoặc Ed25519 |
Phải xác thực public key. |
| Lưu trữ khóa và chứng chỉ | KeyStore |
Không thay thế toàn bộ giải pháp quản lý khóa. |
| Sinh dữ liệu ngẫu nhiên | SecureRandom |
Không dùng Random cho dữ liệu bảo mật. |
8.1. Lộ trình học đề xuất
- Nền tảng: hiểu CIA Triad, phân biệt mã hóa, hàm băm, MAC và chữ ký số.
- JCA/JCE: thực hành
Cipher,MessageDigest,Mac,SignaturevàSecureRandom. - Mã hóa đối xứng: triển khai AES-GCM, định dạng payload và xác minh authentication tag.
- Mật khẩu: triển khai PBKDF2, lưu verifier cùng tham số và xây dựng cơ chế nâng cấp chi phí.
- Mật mã bất đối xứng: sinh khóa RSA/Ed25519, ký số, xác minh chữ ký và quản lý public key.
- Quản lý khóa: thực hành PKCS12, phân quyền truy cập, key versioning và xoay vòng khóa.
- Enterprise: kết hợp TLS, secret manager/KMS, kiểm thử bảo mật, giám sát và quy trình ứng phó khi khóa bị lộ.
8.2. Bài tập tổng hợp — Xây dựng Secure Data Service
Để củng cố kiến thức, hãy xây dựng một service Java nhỏ có các chức năng sau:
- Nhận dữ liệu đầu vào và mã hóa bằng AES-GCM.
- Lưu payload gồm phiên bản định dạng, key ID, nonce, ciphertext và authentication tag.
- Đọc lại payload, xác định đúng phiên bản khóa và giải mã an toàn.
- Phát hiện dữ liệu bị sửa đổi và trả lỗi xác thực tổng quát cho phía gọi.
- Lưu mật khẩu người dùng bằng PBKDF2 hoặc password hashing phù hợp.
- Ký một thông điệp xác nhận bằng private key và xác minh bằng public key đã được xác thực.
- Viết kiểm thử cho trường hợp hợp lệ, sai khóa, sai tag, sai mật khẩu và sai chữ ký.
Điểm khó nhất của bài tập không phải gọi API mã hóa. Đó là thiết kế vòng đời khóa, định dạng dữ liệu có khả năng nâng cấp, quy trình xử lý lỗi và cơ chế bảo đảm ứng dụng không vô tình chấp nhận dữ liệu chưa được xác thực.
Hãy ghi nhớ năm nguyên tắc: dùng thuật toán chuẩn; chọn đúng primitive cho đúng mục đích; quản lý khóa nghiêm ngặt; xác thực dữ liệu trước khi tin tưởng nó; và kiểm thử cả những trường hợp thất bại. Khi năm nguyên tắc này được thực hiện nhất quán, JCA/JCE sẽ trở thành nền tảng vững chắc để xây dựng ứng dụng Java an toàn, dễ bảo trì và đáp ứng yêu cầu của hệ thống Enterprise.
Tài liệu tham khảo chính thức để học sâu hơn:
- Java Cryptography Architecture (JCA) Reference Guide — kiến trúc Provider, API và dịch vụ mật mã.
- Java Cipher API — mã hóa, giải mã và transformation.
- Java Signature API — tạo và xác minh chữ ký số.
- Java KeyStore API — quản lý khóa và chứng chỉ.
- OWASP Cryptographic Storage Cheat Sheet — hướng dẫn lưu trữ mật mã an toàn.
- OWASP Password Storage Cheat Sheet — lựa chọn thuật toán và tham số lưu mật khẩu.