Nội dung bài viết
  1. Tổng quan về bảo mật dữ liệu và kiến trúc Java Crypto
  2. Mã hóa đối xứng với AES
  3. Mã hóa bất đối xứng với RSA và ECC
  4. Hash, Password Hashing và chữ ký số
  5. Quản lý khóa với KeyStore
  6. Thực chiến Java Cryptography
  7. Các cạm bẫy bảo mật nghiêm trọng
  8. Tổng kết và lộ trình thực hành

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.

Mục tiêu của bài họ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.

Điểm cần phân biệt:
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ể.

Sơ đồ kiến trúc JCA/JCE
┌──────────────────────────────────────────┐
│          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
CipherMã hóa và giải mã dữ liệu.
MessageDigestTính giá trị băm của dữ liệu.
MacTạo và xác minh mã xác thực thông điệp như HMAC.
SignatureTạo và xác minh chữ ký số.
KeyGeneratorSinh khóa đối xứng.
KeyPairGeneratorSinh cặp khóa bất đối xứng.
SecretKeyFactoryChuyển đổi đặc tả khóa bí mật; hỗ trợ dẫn xuất khóa PBKDF2.
KeyFactoryTạ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.
SecureRandomCung 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

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

CMD / Terminal
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.

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

Quy tắc vàng:
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.

Luồng mã hóa đố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-128128 bit10An toàn cao, hiệu năng tốt.
AES-192192 bit12Hỗ trợ khóa dài hơn.
AES-256256 bit14Khô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ã.

Cấu trúc bản mã AES-GCM
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.
Lưu ý bảo mật quan trọ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.

Các thao tác với cặp khóa
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.

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

Ghi nhớ:
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.

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

Quy trình lưu mật khẩu an toàn
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ớ.

Thông số PBKDF2 cần được đánh giá định kỳ:
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:

  1. Người ký cung cấp thông điệp cho thuật toán chữ ký.
  2. Thuật toán sử dụng private key để tạo chữ ký theo cơ chế mật mã tương ứng.
  3. Người nhận lấy thông điệp, chữ ký và public key đã được xác thực.
  4. 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.

CMD / Terminal
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:

CMD / Terminal
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.

Phân biệt quan trọng:
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.

CMD / Terminal
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:

AesGcmDemo.java
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:

CMD / Terminal
javac AesGcmDemo.java
java AesGcmDemo

Kết quả mẫu:

Console Output — Minh họa
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ã.

AesGcmAadSnippet.java — Đoạn mã bổ sung
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.

Pbkdf2PasswordDemo.java
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:

CMD / Terminal
javac Pbkdf2PasswordDemo.java
java Pbkdf2PasswordDemo

Kết quả mẫu:

Console Output — Minh họa
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 ý về mật khẩu Unicode:
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.

DigitalSignatureDemo.java
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:

CMD / Terminal
javac DigitalSignatureDemo.java
java DigitalSignatureDemo

Kết quả mẫu:

Console Output — Minh họa
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.

RsaPssParameters.java — Ví dụ cấu hình
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ý

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ớ.

FileSha256.java
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:

CMD / Terminal
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

  1. 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.
  2. 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.
  3. 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.
  4. Thay đổi nội dung đã ký trong ví dụ RSA/Ed25519 và quan sát kết quả xác minh.
  5. Chạy SHA-256 trên một file, thay đổi nội dung file rồi tính lại hash.
  6. 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:

AntiPattern.java — KHÔNG SỬ DỤNG
// 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

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.

Checklist trước khi đưa vào production:
- [ ] 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

  1. Nền tảng: hiểu CIA Triad, phân biệt mã hóa, hàm băm, MAC và chữ ký số.
  2. JCA/JCE: thực hành Cipher, MessageDigest, Mac, Signature và SecureRandom.
  3. Mã hóa đối xứng: triển khai AES-GCM, định dạng payload và xác minh authentication tag.
  4. 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í.
  5. 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.
  6. 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.
  7. 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:

Đ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.

Kết luận cuối cùng:
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: