Encoding, hash, encryption, signing: bốn thứ hay bị nhầm

4 câu hỏi~20 phútTừ khóa: Base64, encoding, hash, encryption, signature, SecureRandom, Random, UUID

Bắt đầu Cryptography nhé. Trong shop-app, đồng nghiệp lưu số điện thoại khách hàng dạng Base64 vào DB và bảo "đã mã hóa rồi". Vậy có ổn không?

Không ổn. Base64 không phải mã hóa. Chương này dùng project shop-app xuyên suốt: lưu password, mã hóa card token và PII (dữ liệu cá nhân), ký webhook và JWT. Bài đầu tiên làm rõ bốn khái niệm hay bị trộn lẫn.

1. Bài toán

shop-app có bảng customer:

id | email              | phone_b64
1  | an@shop.example    | MDkwMTIzNDU2Nw==

Ai có quyền đọc DB (DBA, bản backup bị lộ, log SQL) đều thấy cột phone_b64.

2. Vấn đề

echo "MDkwMTIzNDU2Nw==" | base64 -d
# 0901234567

3. Giải pháp: phân biệt bốn thao tác

Thao tácMục đíchCó key?Đảo ngược?Ví dụ
EncodingĐổi định dạng biểu diễnKhôngCó, ai cũng làm đượcBase64, Hex, URL encoding, UTF-8
HashingTạo "dấu vân tay" cố định độ dài, kiểm tra toàn vẹnKhôngKhông (one-way)SHA-256, SHA-3
EncryptionGiữ bí mật (confidentiality)CóCó, chỉ người có keyAES-GCM, RSA-OAEP
Signing / MACChứng minh ai gửi và không bị sửa (integrity, authenticity)CóKhông cần đảo ngược, chỉ verifyHMAC-SHA256, RS256, ES256

Câu hỏi để chọn đúng:

4. Code từng bước

Bước 1: Dependency. Không cần gì thêm, JCA (Java Cryptography Architecture) có sẵn trong JDK: java.security, javax.crypto.

Bước 2: Config. Không có. Key thật sẽ đến từ biến môi trường / KMS (bài 13.6), ví dụ ${PII_KEY}.

Bước 3: Code Java, chạy cả bốn thao tác trên cùng một chuỗi:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;

public class FourOps {
    public static void main(String[] args) throws Exception {
        byte[] data = "0901234567".getBytes(StandardCharsets.UTF_8);

        // 1. Encoding: đảo ngược được, không bí mật
        String b64 = Base64.getEncoder().encodeToString(data);
        String back = new String(Base64.getDecoder().decode(b64), StandardCharsets.UTF_8);

        // 2. Hashing: một chiều, cùng input -> cùng output
        byte[] digest = MessageDigest.getInstance("SHA-256").digest(data);
        System.out.println(HexFormat.of().formatHex(digest));

        // 3. Encryption (AES-GCM): xem bài 13.4
        // 4. MAC / Signature: xem bài 13.2 và 13.5
    }
}

Randomness: mọi thứ trong crypto (key, IV, salt, token reset password) cần số ngẫu nhiên không đoán được.

import java.security.SecureRandom;

public final class Tokens {
    private static final SecureRandom RNG = new SecureRandom(); // thread-safe, tái sử dụng

    // Token reset password: 32 bytes = 256 bit
    public static String resetToken() {
        byte[] buf = new byte[32];
        RNG.nextBytes(buf);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
    }
}

Bước 4: Test bằng curl

curl -X POST https://api.shop.example/auth/forgot-password \
  -H "Content-Type: application/json" -d '{"email":"an@shop.example"}'
# Email nhận link: /reset?token=q3Zr...43 ký tự, không đoán được

5. Cách hoạt động

"0901234567"
   |-- Base64 ------------> "MDkwMTIzNDU2Nw=="   (decode lại được, không cần key)
   |-- SHA-256 -----------> 32 bytes cố định    (không quay lại được)
   |-- AES-GCM + key -----> ciphertext          (chỉ ai có key mới giải được)
   |-- HMAC + key --------> tag 32 bytes        (gửi kèm data, bên nhận verify)

Random (LCG, seed 48 bit):
  seed -> x1 -> x2 -> x3 ...   biết vài output -> tính ngược ra seed -> đoán hết token
SecureRandom:
  lấy entropy từ OS (/dev/urandom, CryptGenRandom) -> không đoán được

6. So sánh: nguồn ngẫu nhiên trong Java

java.util.RandomThreadLocalRandomSecureRandomUUID.randomUUID()
Đoán được?Có (LCG)CóKhông (CSPRNG)Không, dùng SecureRandom bên trong
Tốc độNhanhRất nhanhChậm hơn, đủ dùngNhanh
Entropy48 bit seed64 bitTùy độ dài bạn lấy122 bit (v4)
Dùng choGame, simulation, testShuffle, load balancingKey, IV, salt, tokenID; token tạm được nhưng 32 bytes SecureRandom tốt hơn

Lưu ý: UUID v1 chứa MAC address và timestamp, đoán được. UUID v7 (theo thời gian, hợp làm primary key) có phần đầu là timestamp, nên không dùng làm secret.

7. Khi nào nên dùng / không nên dùng

Vậy số điện thoại mình hash SHA-256 cho chắc, khỏi lo key luôn, được không? Nhưng chăm sóc khách hàng vẫn cần xem số để gọi lại.

Bạn tự trả lời rồi đó: nhân viên cần đọc lại số điện thoại, nên hash là sai công cụ. Hash một chiều, không lấy lại số gốc được. Thêm nữa, số điện thoại Việt Nam chỉ có khoảng 109 giá trị, SHA-256 tính cực nhanh, nên kẻ tấn công thử hết trong vài phút:

for phone in 0900000000..0999999999:
    if sha256(phone) == leaked_hash: found!

Cách đúng cho shop-app:

Mình đang dùng UUID.randomUUID() làm token reset password và lưu nguyên token vào bảng. Như vậy có sao không?

Bạn hiểu gần đúng, chỉ cần chỉnh hai điểm.

String token = Tokens.resetToken();          // gửi qua email
String stored = sha256Hex(token);           // lưu DB, kèm expires_at = now + 15 phút
// Khi user bấm link: tìm theo sha256Hex(tokenTrongUrl), kiểm tra hết hạn, dùng 1 lần rồi xóa

Phỏng vấn hỏi encoding, hashing, encryption khác nhau thế nào thì trả lời sao?

Q1. Encoding, hashing, encryption, signing khác nhau thế nào?

🇻🇳 Tiếng Việt:

Encoding như Base64 chỉ đổi cách biểu diễn, không có key, ai cũng decode được. Hashing như SHA-256 là hàm một chiều, dùng để kiểm tra toàn vẹn. Encryption như AES dùng key để giữ bí mật và giải mã lại được. Signing hoặc MAC dùng key để chứng minh dữ liệu không bị sửa và đến từ đúng người gửi.

🇬🇧 English:

Encoding such as Base64 only changes representation; there is no key and anyone can decode it. Hashing such as SHA-256 is a one-way function used for integrity checks. Encryption such as AES uses a key to keep data confidential and can be reversed by the key holder. Signing or a MAC uses a key to prove the data was not modified and came from the expected sender.

Q2. Vì sao không dùng java.util.Random để sinh token?

🇻🇳 Tiếng Việt:

Random là LCG với seed 48 bit, quan sát vài output là tính ra được seed và đoán các token tiếp theo. Với token, key, salt, IV em dùng SecureRandom, là CSPRNG lấy entropy từ hệ điều hành.

🇬🇧 English:

Random is a linear congruential generator with a 48-bit seed, so after observing a few outputs an attacker can recover the seed and predict future tokens. For tokens, keys, salts and IVs I use SecureRandom, a CSPRNG seeded from the operating system.

Q3. Lưu PII như số điện thoại thì hash hay encrypt?

🇻🇳 Tiếng Việt:

Nếu cần đọc lại thì phải encrypt, ví dụ AES-GCM với key trong KMS. Hash không dùng được vì không đảo ngược và không gian số điện thoại nhỏ nên dễ brute-force. Nếu cần tìm kiếm, em thêm một blind index bằng HMAC với key riêng.

🇬🇧 English:

If we need to read it back, we must encrypt it, for example with AES-GCM and a key held in a KMS. Plain hashing does not work because it is irreversible and the phone number space is small enough to brute-force. If we need lookups, I add a blind index computed with HMAC and a separate key.

Cách nhớ ngắn gọn


Tự kiểm tra

Base64 có phải encryption không? Vì sao?

Không. Không có key, ai cũng decode được. Nó chỉ đổi bytes thành ký tự ASCII.

Muốn chứng minh webhook không bị sửa thì dùng hash, encryption hay MAC?

MAC (HMAC) hoặc chữ ký số. Hash không có key nên kẻ sửa dữ liệu cũng tính lại được hash.

Vì sao SHA-256 số điện thoại không bảo vệ được dữ liệu?

Không gian chỉ khoảng 10^9 giá trị, SHA-256 rất nhanh, brute-force hết trong vài phút.

UUID v7 có dùng làm token bí mật được không?

Không nên. Phần đầu là timestamp, giảm độ ngẫu nhiên và lộ thời điểm tạo. Dùng SecureRandom 32 bytes.

Token reset password nên lưu thế nào trong DB?

Lưu SHA-256 của token, kèm thời hạn ngắn, dùng một lần rồi xóa.