Bài 4 — A04:2025 Cryptographic Failures: hash mật khẩu, số ngẫu nhiên an toàn, mã hoá có xác thực, quản lý key và TLS

Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 3 — A03 Software Supply Chain Failures · Bài 4 — A04 Cryptographic Failures (bài này) · Tiếp: Bài 5 — A05 Injection →

Cryptographic Failures (lỗi mật mã — thiếu mã hoá, mã hoá yếu, dùng sai, lộ key) là mục duy nhất của Top 10 mà code sai vẫn chạy đúng. Một hàm sinh token bằng Math.random() vẫn trả về chuỗi trông ngẫu nhiên; một field mã hoá bằng AES ở chế độ không có xác thực vẫn giải mã ra đúng dữ liệu; một bảng mật khẩu băm bằng SHA-256 vẫn cho người dùng đăng nhập bình thường. Test xanh, QC không thấy gì, production chạy nhiều năm — cho tới ngày database bị lộ hoặc ai đó đo được độ trễ của phép so sánh. Vì thế câu hỏi interviewer muốn nghe ở mức Senior/Tech Lead không phải "AES là gì", mà là: với từng loại dữ liệu bí mật, em chọn công cụ nào, key nằm ở đâu, đổi key thế nào, và em biết hệ thống đang dùng gì bằng cách nào.

Vì sao bài này dùng khung riêng, và vì sao không có lab. Bài về một nhóm rủi ro, không phải một công nghệ, nên dùng khung riêng của series (10 mục cố định) thay cho 9 câu hỏi của CLAUDE.md. Bài này không có lab (quyết định của series): các nhận định về hành vi dựa trên tài liệu chính thức (OWASP, docs và mã nguồn Laravel, docs NestJS, docs Node.js, mã nguồn Moodle) và được đánh dấu nguồn; chỗ nào cần lab mới khẳng định được thì ghi "chưa verify" — danh sách tổng hợp ở cuối bài. Trọng tâm là phòng thủ: mỗi cơ chế được giải thích ở mức "giả định nào trong code bị sai và hệ quả là gì", không có hướng dẫn tấn công.

1. Mục này là gì trong 2025

1.1 Định nghĩa của OWASP và các câu hỏi tự kiểm

1. OWASP mô tả A04:2025 là các thất bại liên quan tới "the lack of cryptography, insufficiently strong cryptography, leaking of cryptographic keys, and related errors" — thiếu mật mã, mật mã không đủ mạnh, lộ key và các lỗi liên quan. Trang chính thức bắt đầu bằng hai lớp dữ liệu cần bảo vệ: dữ liệu đang truyền (in transit) phải được mã hoá ở tầng transport, và dữ liệu nhạy cảm đang lưu (at rest) — mật khẩu, số thẻ, hồ sơ sức khoẻ, thông tin cá nhân, bí mật kinh doanh, đặc biệt khi thuộc phạm vi GDPR hay PCI DSS — cần thêm lớp bảo vệ riêng.

2. Với dữ liệu nhạy cảm, OWASP đưa ra một danh sách câu hỏi tự kiểm. Em gom chúng thành năm nhóm để dễ nhớ khi review:

NhómCâu hỏi của OWASP (diễn giải)
Thuật toán, giao thứcCó thuật toán/giao thức cũ hoặc yếu nào đang dùng, mặc định hoặc trong code cũ? Có dùng hash đã lỗi thời (MD5, SHA1), hoặc hash không dành cho mật mã ở chỗ cần hash mật mã? Thuật toán có thể bị hạ cấp (downgrade) hoặc bỏ qua không?
KeyCó dùng key mặc định, key yếu, key dùng lại cho nhiều việc, thiếu quản lý và xoay vòng (rotation) key? Key có bị commit vào repository không? Có dùng mật khẩu làm key mà không qua hàm dẫn xuất key (KDF — key derivation function) không?
Chế độ mã hoá, IVIV (initialization vector — giá trị khởi tạo) có bị bỏ qua, dùng lại, hoặc sinh không đủ an toàn cho chế độ đang dùng? Có dùng chế độ không an toàn như ECB? Có dùng mã hoá thường ở chỗ cần mã hoá có xác thực (authenticated encryption)?
Ngẫu nhiênCó dùng nguồn ngẫu nhiên không được thiết kế cho mật mã? Nếu đã chọn đúng hàm, dev có tự seed lại bằng giá trị dễ đoán, đè lên cơ chế seed mạnh có sẵn không?
Kênh truyền, kênh phụMã hoá có bị bỏ không ép buộc (thiếu header bảo mật như HSTS)? Certificate của server và chuỗi tin cậy (trust chain) có được kiểm đúng? Thông báo lỗi mật mã hoặc thông tin kênh phụ (side channel) có bị khai thác được, ví dụ kiểu padding oracle?

3. Điểm cần nói rõ khi phỏng vấn: OWASP 2025 nhấn mạnh số ngẫu nhiên. Phần Background ghi rằng ba trong các CWE phổ biến nhất của mục này liên quan tới bộ sinh số giả ngẫu nhiên yếu: CWE-331 (Insufficient Entropy), CWE-1241 (Use of Predictable Algorithm in Random Number Generator), CWE-338 (Use of Cryptographically Weak PRNG), cùng với CWE-327 (Use of a Broken or Risky Cryptographic Algorithm). Tức là lỗi hay gặp nhất trong dữ liệu không phải "chọn sai AES-128 hay AES-256", mà là token, mã OTP, link reset mật khẩu được sinh bằng hàm random thông thường.

1.2 Thay đổi so với 2021

1. Thứ hạng: từ hạng 2 (A02:2021) tụt xuống hạng 4. Như Bài 0 đã phân tích, tụt hạng chủ yếu vì Security Misconfiguration và Software Supply Chain Failures vượt lên, không phải vì lỗi mật mã giảm.

2. Tên giữ nguyên, hướng giữ nguyên: năm 2017 mục này tên là "Sensitive Data Exposure" (lộ dữ liệu nhạy cảm — một triệu chứng); năm 2021 đổi thành "Cryptographic Failures" để chỉ vào nguyên nhân gốc. Năm 2025 giữ tên đó. Chi tiết về phân biệt root cause và symptom: Bài 0.

3. Phần "How to prevent" 2025 có ba điểm mới đáng chú ý so với cách nói quen thuộc: (a) với TLS, OWASP yêu cầu "protocols >= TLS 1.2 only, with forward secrecy (FS) ciphers, drop support for cipher block chaining (CBC) ciphers, support quantum key change algorithms"; (b) "Store your most sensitive keys in a hardware or cloud-based HSM"; (c) chuẩn bị cho mật mã hậu lượng tử (PQC — post-quantum cryptography) "so that high risk systems are safe no later than the end of 2030", dẫn theo lộ trình của ENISA. Danh sách thuật toán hash mật khẩu được nêu: Argon2, yescrypt, scrypt, PBKDF2-HMAC-SHA-512; bcrypt được nhắc riêng như trường hợp "legacy systems".

1.3 Bảng điểm chính thức

CWE ánh xạIncidence tối đaIncidence TBCoverage tối đaCoverage TBExploit TB (có trọng số)Impact TB (có trọng số)Tổng lần xuất hiệnTổng CVE
3213,77%3,80%100,00%47,74%7,233,901.665.3482.185

Đọc bảng thế nào (cách tính các cột đã giải thích ở Bài 0):

  1. Incidence TB 3,80%, tối đa 13,77%: trong các ứng dụng được kiểm, trung bình 3,8% có ít nhất một CWE của nhóm này; CWE phổ biến nhất trong nhóm xuất hiện ở 13,77% ứng dụng. Con số không cao bằng A01 hay A02, nhưng 1.665.348 lần xuất hiện cho thấy khi đã có thì có rất nhiều chỗ — một hàm random sai thường được copy khắp codebase.
  2. Exploit 7,23 nhưng Impact chỉ 3,90: điểm exploit cao (dễ khai thác khi đã có lỗi) mà impact thấp tương đối. Cách hiểu hợp lý: nhiều CWE trong nhóm (entropy thấp, PRNG yếu) tự nó không lộ dữ liệu ngay — chúng là bước đệm cho tấn công khác (đoán token reset, chiếm phiên). Đừng đọc "impact 3,90" thành "không nghiêm trọng": khi bảng mật khẩu băm yếu bị lộ, thiệt hại lan sang mọi hệ thống khác nơi người dùng dùng lại mật khẩu.
  3. Coverage TB 47,74%: chưa tới một nửa ứng dụng được kiểm cho các CWE này — lỗi mật mã khó phát hiện bằng công cụ tự động (mục 6), nên con số thật có thể cao hơn.
Cách nói khi phỏng vấn: "A04 năm 2025 có ba mươi hai CWE, trung bình gần bốn phần trăm ứng dụng dính, nhưng tổng số lần xuất hiện lên tới hơn một triệu sáu — tức là khi đã sai thì sai ở rất nhiều chỗ, vì một hàm sinh token sai thường bị copy khắp nơi. Điểm khai thác cao mà điểm thiệt hại trung bình, vì nhiều lỗi trong nhóm là bước đệm, ví dụ token đoán được dẫn tới chiếm tài khoản. Và chưa tới một nửa ứng dụng được kiểm cho nhóm này, nên số thật có thể cao hơn."

1.4 Gom 32 CWE theo cơ chế

Cơ chếCWECâu hỏi khi review
Mật khẩu lưu saiCWE-261 Weak Encoding for Password, CWE-759 One-Way Hash without a Salt, CWE-760 Hash with a Predictable Salt, CWE-916 Password Hash With Insufficient Computational Effort, CWE-328 Reversible One-Way HashMật khẩu được băm bằng hàm chậm có salt ngẫu nhiên, hay bằng hash nhanh / mã hoá / encode?
Ngẫu nhiên yếuCWE-330, 331, 332, 334, 335, 336, 337, 338, 340, 342, 1241Token, mã, ID bí mật sinh bằng CSPRNG chưa? Đủ dài chưa? Có seed thủ công không?
Thuật toán, độ mạnh, chế độCWE-326 Inadequate Encryption Strength, CWE-327 Broken or Risky Algorithm, CWE-329 Not Using a Random IV with CBC, CWE-780 RSA without OAEP, CWE-1240 Primitive with a Risky Implementation, CWE-325 Missing Required Cryptographic StepCó tự ghép primitive không? Có dùng chế độ có xác thực không?
Quản lý keyCWE-320 Key Management Errors, CWE-321 Hard-coded Cryptographic Key, CWE-322 Key Exchange without Entity Authentication, CWE-323 Reusing a Nonce, Key Pair, CWE-324 Key Past its Expiration DateKey nằm ở đâu, ai đọc được, đổi thế nào, nonce có lặp không?
Kênh truyền, xác minhCWE-319 Cleartext Transmission, CWE-523 Unprotected Transport of Credentials, CWE-296 Improper Following of Chain of Trust, CWE-347 Improper Verification of Cryptographic Signature, CWE-757 Algorithm DowngradeMọi kết nối có TLS và có kiểm certificate? Chữ ký có được kiểm với thuật toán cố định?

1.5 Ranh giới với A02, A07, A08, A01

2. Bài toán gốc — vì sao lỗi mật mã không tự lộ ra

1. Mật mã hỏng một cách im lặng. Phần lớn bug làm hệ thống chạy sai và có người thấy. Bug mật mã làm hệ thống chạy đúng với người dùng bình thường và chỉ sai trước một đối thủ. Unit test kiểm "mã hoá rồi giải mã ra lại bản gốc" — test đó xanh cả với ECB, với IV cố định, với key in thẳng trong code. Không có test nào tự nhiên kiểm "kẻ khác có đoán được token không". Vì vậy kiểm soát duy nhất có hiệu quả là quy tắc chọn công cụ (mục 2.6) và review theo mẫu (mục 6.1), không phải kiểm thử chức năng.

2. Dev ghép primitive thành giao thức. Thư viện chuẩn cho sẵn primitive (khối cơ bản): AES, SHA-256, HMAC, random. Lỗi thường không nằm ở primitive mà ở cách ghép: AES ở chế độ CTR mà không có MAC; MAC tính trên bản rõ thay vì bản mã; so sánh MAC bằng ===; dùng SHA-256 của mật khẩu làm key. Cheat sheet Cryptographic Storage của OWASP nói thẳng: chế độ mã hoá có yêu cầu về IV "should be handled automatically by the library" — tức là nên dùng API cấp cao (Laravel Crypt, libsodium secretbox) thay vì tự ghép.

3. Mặc định già đi. Thuật toán đúng hôm nay sẽ yếu sau 5–10 năm: MD5 rồi SHA-1 cho chữ ký; bcrypt cost 10 từng là mặc định của PHP, nay mặc định cao hơn; CBC trong TLS từng phổ biến, nay OWASP yêu cầu bỏ. Code viết năm 2015 không sai lúc viết — nó sai vì thời gian trôi. Hệ quả cho tech lead: thiết kế phải có crypto agility (khả năng đổi thuật toán mà không viết lại hệ thống) — định dạng lưu phải ghi kèm thuật toán và tham số (chuỗi $argon2id$v=19$m=…, trường key_id cạnh bản mã), để bản cũ và bản mới sống chung trong lúc chuyển.

4. Key là vấn đề vận hành, không phải vấn đề code. Code mã hoá có thể đúng hoàn toàn mà hệ thống vẫn hỏng vì key nằm trong .env commit lên Git, key giống nhau ở staging và production, key không bao giờ đổi vì "đổi thì dữ liệu cũ đọc không được". Cheat sheet OWASP nhấn mạnh: code và quy trình xoay key phải có trước khi cần — "so that keys can be quickly rotated in the event of a compromise".

5. Nhầm bốn khái niệm. Encoding (base64 — ai cũng đảo ngược được, không có bí mật), hashing (một chiều, không có key), encryption (hai chiều, cần key), signing/MAC (chứng minh toàn vẹn và nguồn gốc, không che nội dung). Nhiều lỗi trong CWE-261, CWE-328 bắt nguồn từ nhầm lẫn này: "mật khẩu đã được mã hoá base64", "JWT đã được mã hoá nên để thông tin nhạy cảm trong payload được" (JWT thường chỉ được ký, payload chỉ là base64url).

6. Bộ chọn công cụ. Mọi quyết định mật mã trong một app web bắt đầu bằng hai câu hỏi: "Có cần đọc lại bản gốc không?" và "Giá trị này có entropy cao (sinh ngẫu nhiên) hay thấp (do người chọn)?". Sơ đồ dưới là quy tắc em dùng khi review.

flowchart TD
  Q0["Dữ liệu bí mật cần bảo vệ"] --> Q1{"Cần đọc lại bản gốc?"}
  Q1 -- "Có" --> E["Mã hoá có xác thực (AEAD)<br/>Laravel Crypt / AES-256-GCM<br/>key từ KMS hoặc secret store"]
  Q1 -- "Không, chỉ cần so khớp" --> Q2{"Giá trị do người chọn<br/>(entropy thấp)?"}
  Q2 -- "Có: mật khẩu, PIN" --> H["Hash chậm có salt<br/>Argon2id / scrypt / bcrypt"]
  Q2 -- "Không: token sinh ngẫu nhiên" --> T["CSPRNG ≥ 128 bit<br/>lưu SHA-256 của token"]
  Q0 --> Q3{"Cần chứng minh<br/>không bị sửa?"}
  Q3 -- "Hai bên chung secret" --> M["HMAC-SHA256<br/>so sánh constant-time"]
  Q3 -- "Bên kiểm không được ký" --> S["Chữ ký số (Ed25519, ECDSA, RSA-PSS)<br/>ghim thuật toán"]
Cách nói khi phỏng vấn: "Trước khi chọn thuật toán em hỏi hai câu. Một, có cần đọc lại bản gốc không — nếu có thì mã hoá có xác thực với key nằm ngoài database. Hai, nếu chỉ cần so khớp thì giá trị đó do người chọn hay do máy sinh ngẫu nhiên — mật khẩu do người chọn nên phải băm bằng hàm chậm có salt, còn token ngẫu nhiên đủ dài thì băm SHA-256 là đủ, vì không ai đoán nổi không gian hai trăm năm mươi sáu bit. Cần chứng minh dữ liệu không bị sửa thì dùng HMAC hoặc chữ ký số, và luôn so sánh constant-time."

3. Cơ chế — mỗi dạng lỗi sai ở giả định nào

Mỗi tiểu mục: giả định sai trong code, hệ quả, ví dụ số hoặc kịch bản của OWASP, và một sơ đồ. Không có chuỗi tấn công cụ thể — chỉ cơ chế ở mức đủ để nhận ra khi review.

3.1 Mật khẩu lưu bằng hash nhanh, không salt, hoặc mã hoá thay vì băm

1. Giả định sai: "hash là một chiều nên lưu SHA-256 của mật khẩu là an toàn". Hash một chiều đúng là không đảo ngược được, nhưng kẻ có bảng hash không cần đảo ngược — họ đoán: băm từng ứng viên rồi so. Câu hỏi thật là mỗi lần đoán tốn bao nhiêu. SHA-256, MD5, SHA-1 được thiết kế để nhanh; cheat sheet Password Storage nói rõ hash nhanh như SHA-256 "are not suitable for password storage because they allow attackers to perform large numbers of guesses quickly".

2. Ví dụ số (minh hoạ, không phải số đo): mật khẩu 8 ký tự chữ thường có 268 ≈ 2,1 × 1011 khả năng. Giả sử một cụm GPU tính được 1010 hash nhanh mỗi giây (giả định minh hoạ): vét cạn mất khoảng 21 giây. Nếu mỗi lần đoán bị ép tốn chi phí gấp 105 lần (đó là việc của work factor trong Argon2id, bcrypt), cùng không gian đó mất khoảng 24 ngày — và với Argon2id, GPU còn bị giới hạn bởi bộ nhớ, không chỉ bởi tính toán. Hàm chậm không làm mật khẩu yếu thành mạnh; nó đổi "vài giây" thành "vài tuần" cho mỗi tài khoản, đủ để mật khẩu trung bình không bị lộ hàng loạt.

3. Salt giải quyết một chuyện khác: không có salt (CWE-759) hoặc salt đoán được (CWE-760), hai người cùng mật khẩu có cùng hash, và kẻ tấn công dùng bảng tính sẵn (rainbow table) cho mọi tài khoản cùng lúc. Salt ngẫu nhiên riêng cho mỗi mật khẩu buộc phải đoán từng tài khoản một. Kịch bản #2 của OWASP mô tả đúng hai tầng này: hash không salt bị lộ qua bảng tính sẵn; hash nhanh dù có salt vẫn bị GPU phá.

4. Mã hoá mật khẩu thay vì băm (CWE-261, CWE-328) còn tệ hơn theo một nghĩa: ai có key thì có tất cả mật khẩu cùng lúc, và key thường nằm cùng server với database. Cheat sheet: mật khẩu "should be securely hashed … rather than encrypted". Sự cố Adobe 2013 (mục 8.1) là ví dụ kinh điển.

5. Pepper (một secret chung cho mọi mật khẩu, lưu ngoài database) là lớp thêm: nếu chỉ database lộ mà pepper không lộ, hash vô dụng với kẻ tấn công. Cheat sheet coi pepper là "defense in depth (though alone, it provides no additional secure characteristics)" và cảnh báo: đổi pepper buộc người dùng đặt lại mật khẩu, vì không thể tính lại hash khi không có mật khẩu gốc.

flowchart TD
  A["Database bị lộ: có bảng user + hash"] --> B{"Hash loại gì?"}
  B -- "Không salt" --> C["Bảng tính sẵn dùng cho mọi tài khoản<br/>cùng mật khẩu = cùng hash"]
  B -- "Hash nhanh có salt" --> D["Đoán từng tài khoản<br/>nhưng mỗi lần đoán rất rẻ"]
  B -- "Mã hoá bằng key trên cùng server" --> E["Lộ key = lộ toàn bộ mật khẩu"]
  B -- "Argon2id / bcrypt có salt + cost đo" --> F["Mỗi lần đoán đắt<br/>chỉ mật khẩu rất yếu bị lộ"]
  F --> G["+ pepper ngoài DB: chỉ DB lộ thì hash vô dụng"]

3.2 Số ngẫu nhiên không đủ an toàn — nhóm CWE phổ biến nhất năm 2025

1. Giả định sai: "chuỗi trông ngẫu nhiên thì không đoán được". Hàm random thông thường (Math.random() trong JavaScript, mt_rand()/rand() trong PHP, uniqid() dựa trên thời gian) là PRNG thống kê: tốt cho mô phỏng, xáo trộn câu hỏi quiz, chọn A/B test — nhưng trạng thái bên trong nhỏ và có thể suy ra từ một số output quan sát được, hoặc seed lấy từ thời gian. Cheat sheet Cryptographic Storage liệt kê cho Node.js: hàm không an toàn là Math.random(); hàm an toàn là crypto.randomBytes(), crypto.randomInt(), crypto.randomUUID().

2. Hệ quả: token reset mật khẩu, mã xác nhận email, link mời, API key, session ID, mã OTP — tất cả là "mật khẩu tạm thời". Nếu đoán được, kẻ tấn công không cần biết mật khẩu thật. Đó là lý do OWASP xếp ba CWE về PRNG lên đầu danh sách phổ biến.

3. Ba biến thể dễ lọt review: (a) dùng đúng CSPRNG nhưng không gian quá nhỏ (CWE-334): mã 6 chữ số chỉ có 106 khả năng — chấp nhận được cho OTP chỉ khi có giới hạn số lần thử và hết hạn ngắn (đó là A07), không chấp nhận được cho link reset sống 24 giờ không giới hạn thử; (b) seed thủ công (CWE-335, 337): gọi mt_srand(time()) hoặc tự viết PRNG có seed — OWASP nói "Most modern APIs do not require the developer to seed the CSPRNG"; (c) dùng ID tuần tự hoặc UUID phiên bản 1 (gồm timestamp và địa chỉ MAC) làm bí mật — cheat sheet ghi UUIDv1 "are not random".

4. Ví dụ số: token 32 byte từ CSPRNG có 2256 khả năng. Một token 16 ký tự base62 sinh đúng cách có khoảng 6216 ≈ 4,8 × 1028 ≈ 295 khả năng — đủ cho link sống ngắn. Còn một "token" gồm user ID cộng timestamp giây cộng mt_rand(1000, 9999) thì khi biết thời điểm gửi email trong khoảng một phút, không gian còn khoảng 60 × 9.000 = 540.000 khả năng — mức đó đoán được qua mạng nếu không có giới hạn tốc độ.

flowchart TD
  A["Cần một giá trị bí mật<br/>(token reset, API key, OTP, session)"] --> B{"Sinh bằng gì?"}
  B -- "Math.random / mt_rand / uniqid / time" --> C["PRNG thống kê<br/>trạng thái suy ra được"]
  B -- "CSPRNG: random_bytes, Str::random,<br/>crypto.randomBytes" --> D{"Không gian đủ lớn?"}
  D -- "Không: 6 chữ số, sống lâu,<br/>không giới hạn thử" --> E["Đoán được qua mạng"]
  D -- "Có: ≥ 128 bit, hoặc OTP ngắn hạn<br/>+ giới hạn số lần thử" --> F["An toàn"]
  C --> G["Đoán được token của người khác"]

3.3 Mã hoá không có xác thực — bản mã bị sửa mà không ai biết

1. Giả định sai: "dữ liệu đã mã hoá thì không ai đọc được, cũng không ai sửa được". Mã hoá thuần (CBC, CTR) chỉ cho bí mật (confidentiality), không cho toàn vẹn (integrity). Với CTR, lật một bit trong bản mã lật đúng bit đó trong bản rõ sau giải mã; với CBC, sửa một khối làm thay đổi có kiểm soát ở khối kế tiếp. Ứng dụng giải mã ra dữ liệu "hợp lệ về cú pháp" mà không biết nó đã bị sửa — tính chất này gọi là malleability (dễ uốn nắn).

2. Padding oracle là hệ quả nổi tiếng nhất: nếu server giải mã CBC rồi trả lỗi khác nhau cho "padding sai" và "dữ liệu sai" (hoặc thời gian phản hồi khác nhau), mỗi phản hồi tiết lộ một chút thông tin, và tích luỹ đủ có thể khôi phục bản rõ mà không cần key. OWASP đưa nó vào danh sách tự kiểm ("cryptographic error messages or side channel information exploitable, for example in the form of padding oracle attacks"). Gốc rễ không phải CBC, mà là giải mã trước khi xác thực.

3. ECB là trường hợp xấu nhất: cùng khối bản rõ cho cùng khối bản mã, nên cấu trúc dữ liệu lộ ra qua bản mã. Cheat sheet: "ECB should not be used outside of very specific circumstances".

4. Cách sửa có hai trường phái hợp lệ: (a) dùng chế độ AEAD (authenticated encryption with associated data — mã hoá có xác thực kèm dữ liệu liên kết) như GCM, CCM, ChaCha20-Poly1305 — cheat sheet gọi đây là "first preference"; (b) nếu chỉ có CBC/CTR thì Encrypt-then-MAC: mã hoá trước, tính HMAC trên IV và bản mã, khi nhận thì kiểm MAC constant-time trước, sai là dừng, không bao giờ giải mã. Laravel mặc định đi theo (b) với AES-256-CBC + HMAC-SHA256, và hỗ trợ (a) với AES-256-GCM (mục 4.3, 5.5).

flowchart TD
  A["Nhận bản mã từ client / DB / cookie"] --> B{"Có xác thực?"}
  B -- "Không (CBC/CTR thuần)" --> C["Giải mã luôn"]
  C --> D["Bản rõ đã bị sửa vẫn được dùng<br/>lỗi padding khác lỗi dữ liệu = oracle"]
  B -- "Encrypt-then-MAC" --> E{"MAC đúng?<br/>(so sánh constant-time)"}
  B -- "AEAD (GCM)" --> F{"Tag đúng?"}
  E -- "Sai" --> X["Từ chối, một thông báo lỗi chung,<br/>không giải mã"]
  F -- "Sai" --> X
  E -- "Đúng" --> G["Giải mã"]
  F -- "Đúng" --> G

3.4 Nonce/IV dùng lại với cùng key

1. Giả định sai: "IV chỉ là tham số kỹ thuật, để cố định cho dễ giải mã". OWASP 2025: "In all cases, the IV should never be used twice for a fixed key." Với chế độ dựa trên counter (CTR, và GCM bên trong), dùng lại cặp (key, nonce) nghĩa là dùng lại cùng một keystream; khi đó kết hợp hai bản mã sẽ khử keystream và còn lại quan hệ giữa hai bản rõ. Với GCM, lặp nonce còn làm lộ thông tin cho phép giả mạo tag — mất cả bí mật lẫn toàn vẹn.

2. Nonce lặp xảy ra thế nào trong thực tế: IV hard-code (Buffer.alloc(16) toàn số 0); IV dẫn xuất từ ID bản ghi (hai môi trường cùng key thì trùng); counter lưu trong bộ nhớ và reset khi process khởi động lại; nonce ngẫu nhiên 96 bit với cùng một key dùng cho quá nhiều lần mã hoá. Điểm cuối là lý do cheat sheet yêu cầu xoay key "before reaching the usage limits of the selected encryption mode" và dẫn tới NIST SP 800-38D cho giới hạn của GCM — với app web thông thường (mã hoá vài triệu field) thì còn xa giới hạn, nhưng với hệ thống mã hoá mỗi request thì phải tính (số liệu giới hạn cụ thể: xem NIST SP 800-38D, chưa trích ở đây).

3. Phân biệt cần nói chính xác: OWASP 2025 ghi IV "must be chosen appropriate for the mode of operation" — CBC cần IV không đoán trước được (sinh bằng CSPRNG); chế độ dùng nonce (GCM, CTR) chỉ cần không lặp, không bắt buộc ngẫu nhiên ("For modes that require a nonce, the IV does not need a CSPRNG"). Trong thực tế dùng nonce ngẫu nhiên 96 bit cho GCM là cách đơn giản nhất để không lặp khi nhiều server cùng mã hoá.

flowchart TD
  A["Mã hoá bằng key K"] --> B{"Nonce lấy từ đâu?"}
  B -- "Hằng số / ID bản ghi /<br/>counter reset khi restart" --> C["Cặp (K, nonce) lặp lại"]
  C --> D["CTR/GCM: keystream lặp<br/>GCM: mất cả xác thực"]
  B -- "CSPRNG 96 bit mỗi lần mã hoá" --> E{"Số lần mã hoá với K<br/>còn dưới giới hạn của chế độ?"}
  E -- "Có" --> F["An toàn"]
  E -- "Gần giới hạn" --> G["Xoay key: K mới cho dữ liệu mới"]

3.5 Key nằm sai chỗ, dùng sai việc, không bao giờ đổi

1. Hard-coded key (CWE-321): key nằm trong code, commit lên Git, có mặt trong mọi bản clone, mọi image Docker, mọi log CI. Xoá khỏi code ở commit sau không xoá được khỏi lịch sử. OWASP: "Are crypto keys checked into source code repositories?".

2. Key dùng chung cho nhiều mục đích: cùng một secret vừa mã hoá cookie, vừa ký URL, vừa làm HMAC webhook. Lộ ở một chỗ (ví dụ secret webhook phải đưa cho đối tác) là lộ cả ba. Laravel dùng APP_KEY cho cả Crypt, cookie và signed URL — đó là thiết kế có chủ ý cho đơn giản, nhưng có nghĩa không bao giờ đưa APP_KEY cho hệ thống khác; cần secret chung với đối tác thì sinh secret riêng.

3. Mật khẩu dùng làm key (OWASP: "passwords being used as cryptographic keys in the absence of a password based key derivation function"): sha256("MyCompany@2024") có 32 byte đúng độ dài key AES-256, nhưng entropy chỉ bằng mật khẩu gốc. Key phải sinh từ CSPRNG; nếu bắt buộc xuất phát từ mật khẩu thì qua KDF chậm (Argon2id, scrypt, PBKDF2) với salt ngẫu nhiên.

4. Key không bao giờ đổi (CWE-324): vì không có cơ chế giải mã bằng key cũ, đổi key nghĩa là mất dữ liệu cũ — nên không ai dám đổi, kể cả khi người giữ key đã nghỉ việc. Cheat sheet liệt kê lý do phải xoay: key bị lộ hoặc nghi lộ (kể cả "someone who had access to the key leaving the organization"), hết thời hạn dùng (cryptoperiod), gần giới hạn của chế độ mã hoá, thuật toán bị phát hiện yếu.

flowchart TD
  A["Một secret: APP_KEY / JWT secret / encryption key"] --> B{"Nằm ở đâu?"}
  B -- "Trong code / repo / image" --> C["Ai có repo hoặc image là có key<br/>xoá commit không xoá được lịch sử"]
  B -- "Biến môi trường từ secret store" --> D{"Dùng cho mấy việc?"}
  D -- "Nhiều việc, đưa cả cho đối tác" --> E["Lộ một chỗ = lộ mọi chỗ"]
  D -- "Một việc, một secret" --> F{"Có quy trình xoay?"}
  F -- "Không" --> G["Không dám đổi khi người giữ key nghỉ"]
  F -- "Có: key_id + key cũ để giải mã" --> H["Đổi được trong giờ hành chính"]

3.6 So sánh không constant-time, và kiểm chữ ký với thuật toán do token tự khai

1. Timing side channel: so sánh chuỗi thông thường (===, ==, strcmp) dừng ngay ở ký tự khác đầu tiên. Thời gian phản hồi vì thế phụ thuộc vào số ký tự khớp ở đầu chuỗi. Khi so sánh một chữ ký HMAC, API key hay token với giá trị do client gửi, sai khác này về lý thuyết cho phép dò từng phần. Trên mạng thật, nhiễu lớn hơn chênh lệch rất nhiều, nhưng phòng thủ rẻ đến mức không có lý do không làm: PHP hash_equals(), Node crypto.timingSafeEqual(). Docs Node mô tả hàm này "suitable for comparing HMAC digests or secret values like authentication cookies or capability URLs". Laravel dùng hash_equals trong cả kiểm MAC của Encrypter lẫn kiểm chữ ký của signed URL (đã đọc mã nguồn, mục 4.4).

2. JWT và thuật toán do token tự khai: header JWT có trường alg. Nếu thư viện đọc alg từ token để quyết định cách kiểm, kẻ gửi token kiểm soát cách nó được kiểm. Cheat sheet JWT của OWASP nêu hai dạng: (a) unsecured JWT với "alg":"none" từng được một số thư viện chấp nhận mặc định; (b) key type confusion (nhầm loại key) — hệ thống ký bằng khoá bất đối xứng, nhưng nếu bộ kiểm chấp nhận cả thuật toán MAC thì khoá công khai có thể bị dùng nhầm làm secret HMAC. Cách phòng thủ cheat sheet nêu: "hardcode the accepted algorithms and do not mix public-key digital signatures algorithms and MAC algorithms".

3. Secret HMAC yếu: cheat sheet JWT yêu cầu secret ít nhất bằng độ dài output (256 bit cho HS256) và liệt kê các dạng không chấp nhận: dùng mật khẩu/passphrase, secret hard-code, nguồn ngẫu nhiên không an toàn, thiếu entropy. Một JWT ký bằng secret yếu bị lộ là đủ để đoán secret offline — rồi ký token tuỳ ý.

flowchart TD
  A["Nhận JWT"] --> B{"Thuật toán kiểm lấy từ đâu?"}
  B -- "Từ header alg của token" --> C["Kẻ gửi chọn cách kiểm<br/>none / nhầm public key thành HMAC secret"]
  B -- "Danh sách cố định ở server<br/>(chỉ RS256 hoặc chỉ EdDSA)" --> D{"Chữ ký đúng với key<br/>của thuật toán đó?"}
  D -- "Sai" --> X["401"]
  D -- "Đúng" --> E{"exp, iss, aud đúng?"}
  E -- "Sai" --> X
  E -- "Đúng" --> F["Claim đáng tin về nguồn gốc<br/>(quyền vẫn kiểm theo A01)"]

3.7 Kênh truyền: không TLS, TLS cũ, hoặc tắt kiểm certificate

1. Kịch bản #1 của OWASP: site không ép TLS cho mọi trang hoặc hỗ trợ mã hoá yếu; kẻ theo dõi mạng (ví dụ Wi-Fi công cộng) hạ kết nối từ HTTPS xuống HTTP, lấy cookie phiên và chiếm phiên đã đăng nhập — hoặc sửa dữ liệu đang truyền, như người nhận của một lệnh chuyển tiền. Phần phòng thủ ở trình duyệt (HSTS, cookie Secure) đã ở Bài 2.

2. Phiên bản và cipher: cheat sheet TLS: "Web applications must default to TLS 1.3 and may support TLS 1.2 for compatibility. TLS 1.0 and TLS 1.1 are formally deprecated by RFC 8996 … and must be disabled." Với TLS 1.3 dùng các suite AEAD chuẩn (AES-GCM hoặc ChaCha20-Poly1305); với TLS 1.2 ưu tiên suite ECDHE có mã hoá xác thực (cho forward secrecy — lộ private key sau này không giải mã được phiên cũ) và tránh suite CBC.

3. Lỗi nguy hiểm nhất lại nằm ở phía client: tắt kiểm certificate (CWE-296). Khi gọi API đối tác, payment gateway, S3 hay database, dev gặp lỗi certificate ở môi trường dev và tắt kiểm "tạm thời" — rồi cờ đó đi lên production. Kết nối vẫn được mã hoá, nhưng mã hoá với bất kỳ ai đứng giữa. Cheat sheet TLS nói rõ: TLS chỉ bí mật giữa hai đầu, và nếu client chấp nhận certificate của kẻ tấn công thì kẻ đó đọc được dữ liệu.

4. Kết nối nội bộ: TLS thường kết thúc ở load balancer; từ LB tới app node, app tới database, Redis, NFS thường là bản rõ trong mạng nội bộ. OWASP 2025 yêu cầu "Encrypt all data in transit" và cảnh báo về giao thức không mã hoá; quyết định có mã hoá chặng nội bộ hay không là trade-off theo mô hình mạng (mục 5.3, 7.1).

flowchart TD
  U["Trình duyệt"] -- "HTTPS (TLS 1.2/1.3, HSTS)" --> LB["Nginx LB: kết thúc TLS"]
  LB -- "chặng nội bộ: bản rõ hay TLS/mTLS?" --> APP["App node"]
  APP -- "TLS + kiểm certificate?" --> DB["Database / Redis"]
  APP -- "HTTPS ra ngoài: kiểm certificate?" --> EXT["API đối tác / S3"]
  EXT -.-> W["Tắt verify = mã hoá với bất kỳ ai đứng giữa"]

3.8 Hình động: ba loại bí mật, ba công cụ khác nhau

Gom các tiểu mục trên thành một luồng phòng thủ: mỗi dữ liệu bí mật đi qua bốn bước — phân loại (có cần đọc lại không, entropy cao hay thấp), chọn công cụ, chọn key/tham số, quyết định lưu gì. Cột cuối trả lời câu hỏi quan trọng nhất: nếu database bị lộ nguyên vẹn, kẻ tấn công có gì trong tay.

Ba loại dữ liệu bí mật đi qua bốn bước chọn công cụ mật mã Bốn cột là bốn bước: phân loại, chọn công cụ, key và tham số, lưu gì. Hàng A là mật khẩu người dùng: chỉ cần xác minh, không cần đọc lại, entropy thấp, nên dùng hash chậm Argon2id hoặc bcrypt với salt ngẫu nhiên và cost đã đo, lưu chuỗi hash tự mô tả; nếu database lộ, kẻ tấn công chỉ còn cách đoán chậm từng tài khoản. Hàng B là token API của đối tác mà hệ thống phải đọc lại để gọi API: dùng mã hoá có xác thực AES-256-GCM, key dữ liệu lấy từ KMS, nonce 96 bit mới mỗi lần, lưu nonce, bản mã, tag và key id; nếu chỉ database lộ, bản mã vô dụng vì key ở chỗ khác. Hàng C là token reset mật khẩu: sinh 32 byte bằng CSPRNG, hết hạn 60 phút và dùng một lần, chỉ lưu SHA-256 của token; nếu database lộ, không đảo ngược được hash của giá trị ngẫu nhiên 256 bit. Dữ liệu 1. Phân loại 2. Công cụ 3. Key / tham số 4. Lưu gì DB bị lộ Kẻ tấn công có A: mật khẩungười dùng chọn B: token APIcủa đối tác C: token resetmật khẩu chỉ xác minh,entropy thấp Argon2idhoặc bcrypt salt ngẫu nhiêncost đã đo $argon2id$…(tự mô tả) phải đọc lạibản gốc AES-256-GCM(AEAD) key từ KMS,nonce 96 bit mới nonce + bản mã+ tag + key_id chỉ so khớp,entropy cao CSPRNG32 byte hết hạn 60 phút,dùng 1 lần SHA-256của token bảng hashbản mãhash token Chỉ đoán chậmtừng tài khoản Bản mã vô dụngkey ở chỗ khác Không đảo ngược2^256, sống 60 phút Cột 1 (viền xanh) quyết định mọi thứ phía sau: nhầm token ngẫu nhiên thành mật khẩu thì tốn CPU vô ích, nhầm mật khẩu thành token (băm SHA-256) thì DB lộ là lộ mật khẩu. Cột đỏ là bài kiểm: giả định DB đã lộ.
Hình 1 — Ba loại bí mật trong cùng một app LMS. Chấm xanh lá (A): mật khẩu người dùng — chỉ cần xác minh, entropy thấp, nên băm chậm có salt; DB lộ thì kẻ tấn công chỉ còn đoán chậm từng tài khoản. Chấm xanh dương (B): token API của đối tác (ví dụ hệ thống quản lý sinh viên của trường) mà app phải đọc lại để gọi API — mã hoá AEAD với key nằm ngoài DB; DB lộ một mình thì bản mã vô dụng. Chấm tím (C): token reset mật khẩu — sinh ngẫu nhiên 256 bit, chỉ lưu SHA-256; hash nhanh là đủ vì không gian đoán quá lớn. Tên hệ thống và tham số là minh hoạ.
Cách nói khi phỏng vấn: "Em kiểm thiết kế mật mã bằng một câu hỏi: nếu database bị lộ nguyên vẹn thì kẻ tấn công có gì. Mật khẩu thì họ chỉ có hash chậm, phải đoán từng tài khoản. Token của đối tác thì họ có bản mã nhưng key nằm ở KMS, không nằm trong database. Token reset thì họ chỉ có hash của một giá trị ngẫu nhiên hai trăm năm mươi sáu bit đã hết hạn. Và bước quan trọng nhất là phân loại đúng từ đầu: băm mật khẩu bằng SHA-256 như token là sai, mà mã hoá token như dữ liệu cần đọc lại cũng là thừa."

4. Mẫu code sai và mẫu code đúng (Laravel + NestJS)

Mỗi tiểu mục có: mẫu sai (ngắn, để nhận ra khi review), mẫu đúng (giải thích kỹ), và một sơ đồ cho luồng phòng thủ. Hành vi Laravel được đối chiếu với mã nguồn laravel/framework (các file Encrypter.php, BcryptHasher.php, ArgonHasher.php, SessionGuard.php, EloquentUserProvider.php, UrlGenerator.php, Str.php, config/hashing.php, config/app.php — bản đọc ngày 27/10/2026) và docs Laravel (Hashing, Encryption, Authentication). NestJS theo docs NestJS (trang Encryption and Hashing), docs @nestjs/jwt và docs Node.js crypto (tra qua Context7). Tên bảng, biến, giá trị là minh hoạ.

4.1 Hash mật khẩu: thuật toán chậm, cost đã đo, rehash khi đăng nhập

// ===== SAI (cả hai stack): hash nhanh, hoặc mã hoá thay vì băm =====
$user->password = hash('sha256', $request->password);          // Laravel
$user->password = Crypt::encryptString($request->password);    // ai có APP_KEY là có mọi mật khẩu
const hash = createHash('sha256').update(password).digest('hex'); // NestJS
if (hash === user.passwordHash) { /* ... */ }                     // còn thêm so sánh không constant-time

// ===== ĐÚNG: Laravel =====
// .env — chọn thuật toán và cost bằng cấu hình, không bằng code
HASH_DRIVER=argon2id
ARGON_MEMORY=65536      # KiB = 64 MiB (giá trị trong config/hashing.php của framework)
ARGON_TIME=4
ARGON_THREADS=1
# config/hashing.php: 'rehash_on_login' => true (mặc định)

// app/Models/User.php
protected function casts(): array
{
    return ['password' => 'hashed'];       // gán chuỗi thô → tự Hash::make
}

// Đăng nhập: guard tự Hash::check, tự rehash nếu cost đã đổi
if (Auth::attempt(['email' => $email, 'password' => $password])) {
    $request->session()->regenerate();
}

// ===== ĐÚNG: NestJS (theo docs NestJS hiện tại) =====
import { PasswordHasher } from '@nestjs/authentication';

@Injectable()
export class CredentialsService {
  constructor(
    private readonly hasher: PasswordHasher,          // scrypt N=2^17, r=8, p=1
    private readonly users: UsersRepository,
  ) {}

  async verify(email: string, password: string) {
    const user = await this.users.findByEmail(email);
    const ok = await this.hasher.verify(password, user?.passwordHash); // undefined → kiểm hash giả
    if (ok && this.hasher.needsRehash(user!.passwordHash)) {
      await this.users.updatePasswordHash(user!.id, await this.hasher.hash(password));
    }
    return ok ? user : null;
  }
}

Giải thích mẫu đúng, từng điểm:

  1. Mặc định của Laravel là bcrypt, không phải Argon2id. config/hashing.php của framework có 'driver' => env('HASH_DRIVER', 'bcrypt'), 'rounds' => env('BCRYPT_ROUNDS', 12); driver hỗ trợ là bcrypt, argon, argon2id. OWASP xếp bcrypt vào nhóm "legacy systems" — vẫn chấp nhận được với cost ≥ 10 và giới hạn 72 byte; app mới có thể chọn argon2id. Lưu ý argon là Argon2i (ArgonHasher::algorithm() trả PASSWORD_ARGON2I), không phải Argon2id — OWASP khuyên Argon2id, và hai cấu hình tối thiểu đầu tiên của cheat sheet ghi rõ "Do not use with Argon2i".
  2. Tham số Argon2 có hai bộ mặc định khác nhau — phải biết mình đang dùng bộ nào. Trong config/hashing.php: memory 65536 KiB (64 MiB), time 4, threads 1 — trên mức tối thiểu của OWASP (19 MiB, t=2, p=1). Nhưng thuộc tính mặc định của class ArgonHasher là memory 1024 (1 MiB), time 2, threads 2, và ví dụ "Adjusting The Argon2 Work Factor" trong docs Laravel cũng dùng 'memory' => 1024. Ai copy ví dụ đó vào Hash::make($pw, [...]) hoặc tự new ArgonHasher() sẽ có cost thấp hơn mức tối thiểu OWASP gần 19 lần về bộ nhớ. Quy tắc review: tham số hash chỉ đến từ config, không truyền tay.
  3. Rehash khi đăng nhập là cách nâng cost không cần người dùng làm gì. Mã nguồn: SessionGuard sau khi kiểm đúng mật khẩu gọi rehashPasswordIfRequired(); EloquentUserProvider::rehashPasswordIfRequired() gọi needsRehash() và nếu cần thì forceFill hash mới rồi save(). Cheat sheet OWASP mô tả đúng cách này ("wait until the user next authenticates, then re-hash") và cảnh báo hệ quả: người không đăng nhập lại thì giữ hash cũ mãi — mục 7.1 bàn cách xử lý với dữ liệu nhiều năm.
  4. HASH_VERIFY mặc định bật. Docs Laravel: Hash::check kiểm hash có được tạo bằng thuật toán đang cấu hình không; khác thì ném RuntimeException. Hệ quả thực tế: đổi HASH_DRIVER từ bcrypt sang argon2id trên hệ thống có sẵn user thì mọi lần đăng nhập của user cũ sẽ lỗi cho tới khi đặt HASH_VERIFY=false trong giai đoạn chuyển. Đây là một quyết định có trade-off: tắt kiểm thuật toán để chuyển êm, rồi bật lại khi tỷ lệ hash cũ đủ thấp (đo bằng truy vấn đếm tiền tố $2y$).
  5. bcrypt và giới hạn 72 byte. OWASP: "enforce a maximum password length of 72 bytes" cho bcrypt. Laravel có 'limit' => env('BCRYPT_LIMIT', null) — mặc định null nghĩa là Laravel không kiểm; đặt 72 thì BcryptHasher::make() ném InvalidArgumentException khi strlen vượt giới hạn. Chú ý byte khác ký tự: rule validate max:72 đếm ký tự, còn bcrypt đếm byte UTF-8. Chữ "ệ" là 3 byte; một passphrase tiếng Việt 30 ký tự có 15 nguyên âm mang dấu dài khoảng 45–60 byte tuỳ dấu — vẫn dưới 72, nhưng passphrase 40 ký tự có dấu thì vượt. Dùng argon2id thì không có giới hạn này. Docs NestJS cũng nhắc: gói bcrypt "reads only the first 72 bytes of a password".
  6. NestJS: PasswordHasher trong docs hiện tại. Theo trang Encryption and Hashing của docs NestJS: PasswordHasher (gói @nestjs/authentication) dùng scrypt của node:crypto với tham số OWASP khuyên (N=217, r=8, p=1 — khoảng 128 MiB mỗi lần băm), chạy trên thread pool của libuv nên không chặn event loop; hash() trả chuỗi tự mô tả $scrypt$ln=17,r=8,p=1$<salt>$<hash>; verify() so sánh constant-time và nhận undefined cho user không tồn tại (kiểm một hash giả để thời gian phản hồi không lộ tài khoản nào tồn tại); mật khẩu được chuẩn hoá Unicode NFKC trước khi băm; needsRehash() cho biết hash được tạo với tham số yếu hơn hiện tại. Docs cũng nêu lựa chọn khác: gói argon2 hoặc bcrypt, cả hai là native module.
  7. Laravel chống dò tài khoản bằng thời gian theo cách khác: Timebox. Mã nguồn SessionGuard: phần kiểm credentials chạy trong Timebox::call(..., $this->timeboxDuration) với mặc định 200.000 micro giây (200 ms); đăng nhập thành công thì returnEarly(), thất bại thì chờ cho đủ 200 ms. Khi user không tồn tại, hasValidCredentials trả false ngay mà không băm. Hệ quả cần tính: nếu một lần băm dài hơn 200 ms (Argon2id cost cao trên CPU yếu), "sai mật khẩu" mất hơn 200 ms còn "không có user" mất đúng 200 ms — khoảng chênh lộ ra. Khi tăng cost, đo thời gian băm và giữ nó dưới thời lượng timebox (đây là suy luận từ mã nguồn, chưa đo — mục 10).
  8. Không che được: hash chậm không bảo vệ mật khẩu yếu khỏi đoán online qua form đăng nhập (đó là rate limit và khoá tài khoản — A07, và bài phạm vi rate limit); không bảo vệ mật khẩu bị đánh cắp trước khi băm (log request ghi body, APM ghi tham số — mục 6.1).
flowchart TD
  A["POST /login: email + mật khẩu"] --> B["Tìm user theo email"]
  B --> C{"Có user?"}
  C -- "Không" --> D["Laravel: chờ đủ timebox 200 ms<br/>Nest PasswordHasher: kiểm hash giả"]
  C -- "Có" --> E["Hash::check / hasher.verify<br/>(constant-time)"]
  E -- "Sai" --> D
  D --> F["Thông báo lỗi chung"]
  E -- "Đúng" --> G{"needsRehash?<br/>(cost/thuật toán đã đổi)"}
  G -- "Có" --> H["Băm lại bằng tham số mới, lưu"]
  G -- "Không" --> I["Tạo phiên"]
  H --> I
Cách nói khi phỏng vấn: "Mật khẩu em băm bằng hàm chậm có salt, chọn qua cấu hình chứ không trong code. Laravel mặc định bcrypt cost mười hai, app mới em chọn argon2id, và cẩn thận vì driver tên argon thật ra là argon2i, còn ví dụ trong docs dùng một megabyte bộ nhớ, thấp hơn mức tối thiểu của OWASP. Muốn tăng cost thì bật rehash khi đăng nhập: đăng nhập đúng xong, framework thấy hash cũ yếu hơn thì băm lại. Với bcrypt em giới hạn bảy mươi hai byte, không phải bảy mươi hai ký tự, vì tiếng Việt có dấu chiếm nhiều byte. Bên NestJS docs hiện khuyên PasswordHasher dùng scrypt, có sẵn so sánh constant-time và hash giả cho user không tồn tại."

4.2 Token bí mật: sinh bằng CSPRNG, chỉ lưu hash, có hạn và dùng một lần

// ===== SAI: Laravel — token đoán được, lưu bản rõ =====
$token = md5($user->id . time() . mt_rand());          // trạng thái đoán được, md5 không thêm entropy
DB::table('invites')->insert(['token' => $token]);       // DB lộ = dùng được token ngay

// ===== SAI: NestJS =====
const token = Math.random().toString(36).slice(2);       // PRNG thống kê, ~52 bit, đoán được trạng thái

// ===== ĐÚNG: Laravel =====
$plain = Str::random(64);                                 // random_bytes() bên trong
DB::table('invites')->insert([
    'token_hash' => hash('sha256', $plain),               // hash nhanh là đủ: entropy đã cao
    'user_id'    => $user->id,
    'expires_at' => now()->addMinutes(60),
]);
Mail::to($user)->send(new InviteMail($plain));            // bản rõ chỉ đi qua email, không vào DB/log

// Khi người dùng bấm link:
$row = DB::table('invites')
    ->where('token_hash', hash('sha256', $request->query('token', '')))
    ->where('expires_at', '>', now())
    ->first();
abort_if(! $row, 404);
DB::table('invites')->where('id', $row->id)->delete();    // dùng một lần

// ===== ĐÚNG: NestJS =====
import { randomBytes, createHash } from 'node:crypto';
const plain = randomBytes(32).toString('base64url');      // 256 bit
const tokenHash = createHash('sha256').update(plain).digest('hex');
await this.invites.insert({ tokenHash, userId, expiresAt: addMinutes(new Date(), 60) });

Giải thích mẫu đúng:

  1. Str::random() dùng CSPRNG. Mã nguồn Str::random(): lấy random_bytes(), mã hoá base64, bỏ ký tự / + =, cắt đủ độ dài. 64 ký tự trong bảng 62 ký tự là khoảng 381 bit — dư thừa. Cảnh báo duy nhất: Str::createRandomStringsUsing() cho phép thay hàm sinh (dùng trong test); nếu lời gọi đó lọt vào code chạy production, mọi Str::random() đều đoán được. Grep nó khi review.
  2. Vì sao SHA-256 ở đây đúng, trong khi ở 4.1 là sai. Đây là phân biệt quan trọng nhất của bài. Hash nhanh hỏng với mật khẩu vì không gian mật khẩu nhỏ (người chọn) nên đoán vét được. Token 256 bit từ CSPRNG có không gian 2256: dù mỗi lần đoán rẻ đến đâu cũng không vét được. Nên lưu SHA-256 của token là đủ — và nhanh, quan trọng khi tra token ở mỗi request (API key). Dùng Argon2id cho token chỉ tốn CPU, không thêm an toàn.
  3. Vì sao không lưu bản rõ: DB lộ (backup, replica, dump cho dev) thì token bản rõ dùng được ngay; hash của token thì không. Cùng nguyên tắc với API key cho đối tác: hiển thị một lần khi tạo, lưu hash, cho phép thu hồi.
  4. Hạn dùng và dùng một lần giới hạn cửa sổ lạm dụng nếu token lộ qua kênh khác (email bị đọc, link bị chia sẻ, header Referer). Đó là kiểm soát nghiệp vụ, không phải mật mã — nhưng thiếu nó thì mật mã mạnh cũng không cứu được.
  5. NestJS: cheat sheet OWASP liệt kê cho Node.js đúng ba hàm an toàn: crypto.randomBytes(), crypto.randomInt(), crypto.randomUUID(). Cần mã số (OTP 6 chữ số) thì dùng randomInt(0, 1_000_000), không dùng Math.floor(Math.random() * 1e6).
  6. Không che được: mã 6 chữ số dù sinh bằng CSPRNG vẫn chỉ có 106 khả năng — bắt buộc đi kèm giới hạn số lần thử và hạn ngắn (A07). Token đúng mà link gửi qua HTTP thường thì lộ trên đường truyền (mục 4.7).
flowchart TD
  A["Tạo token: CSPRNG 32 byte"] --> B["Lưu SHA-256(token) + user_id + expires_at"]
  A --> C["Gửi bản rõ qua email (HTTPS link)"]
  C --> D["Người dùng bấm link"]
  D --> E["Tính SHA-256(token nhận được)"]
  E --> F{"Có bản ghi khớp<br/>và chưa hết hạn?"}
  F -- "Không" --> X["404, cùng một phản hồi"]
  F -- "Có" --> G["Xoá bản ghi (dùng một lần)<br/>thực hiện hành động"]
Cách nói khi phỏng vấn: "Token reset, link mời hay API key em coi như mật khẩu tạm thời, nên sinh bằng bộ sinh số ngẫu nhiên dành cho mật mã: random bytes bên PHP, Str random của Laravel, crypto randomBytes bên Node — không bao giờ Math random. Em chỉ lưu SHA-256 của token, kèm hạn dùng và xoá sau khi dùng. Hash nhanh ở đây đúng, vì token có hai trăm năm mươi sáu bit ngẫu nhiên, không ai vét nổi — khác hẳn mật khẩu do người chọn, nơi không gian nhỏ nên phải dùng hàm chậm."

4.3 Mã hoá field nhạy cảm: AEAD, IV mới mỗi lần, key nằm ngoài DB

// ===== SAI: NestJS — key từ mật khẩu cố định, salt cố định, chế độ không xác thực, IV cố định =====
const key = scryptSync('company-secret', 'salt', 32);
const iv = Buffer.alloc(16, 0);                           // IV lặp cho mọi bản ghi
const c = createCipheriv('aes-256-ctr', key, iv);          // CTR: không có tag, sửa bản mã không bị phát hiện

// ===== ĐÚNG: Laravel — dùng Crypt / cast có sẵn, không tự ghép =====
// app/Models/SchoolIntegration.php
protected function casts(): array
{
    return ['api_token' => 'encrypted'];                   // tự Crypt::encryptString / decryptString
}
// (tuỳ chọn) config/app.php: 'cipher' => 'aes-256-gcm'   — mặc định 'AES-256-CBC' + HMAC-SHA256

// ===== ĐÚNG: NestJS — AES-256-GCM, nonce 96 bit ngẫu nhiên, AAD gắn ngữ cảnh, key_id để xoay =====
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

export function seal(plain: string, key: Buffer, keyId: string, aad: string) {
  const iv = randomBytes(12);
  const c = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
  c.setAAD(Buffer.from(aad));                              // vd: `school_integrations:${id}:api_token`
  const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
  return [keyId, iv, ct, c.getAuthTag()].map(String).join('.'); // minh hoạ; thực tế base64url từng phần
}

export function open(keyId: string, iv: Buffer, ct: Buffer, tag: Buffer, aad: string, keys: Map<string, Buffer>) {
  const key = keys.get(keyId);
  if (!key) throw new Error('unknown key');
  const d = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
  d.setAAD(Buffer.from(aad));
  d.setAuthTag(tag);                                       // tag sai độ dài → throw
  return Buffer.concat([d.update(ct), d.final()]).toString('utf8'); // tag sai → final() throw
}

Giải thích mẫu đúng:

  1. Laravel: dùng thứ có sẵn. Docs Laravel: mọi giá trị mã hoá bằng Crypt được ký bằng MAC "so that their underlying value cannot be modified or tampered with once encrypted". Mã nguồn Encrypter::encrypt(): sinh IV mới bằng random_bytes(openssl_cipher_iv_length(...)) mỗi lần; với cipher không AEAD (CBC) tính hash_hmac('sha256', $iv.$value, $key) — tức là Encrypt-then-MAC, MAC phủ cả IV và bản mã; payload là JSON {iv, value, mac, tag} mã hoá base64. Khi giải mã với CBC: kiểm MAC bằng hash_equals trước, sai thì ném DecryptException('The MAC is invalid.') mà không gọi openssl_decrypt — đây chính là thứ tự chặn padding oracle ở mục 3.3. Với GCM: tag phải dài đúng 16 byte (ensureTagIsValid), sai thì ném lỗi chung.
  2. Cast encrypted mã hoá khi ghi và giải mã khi đọc thuộc tính model. Hệ quả cần nói: field đã mã hoá không tìm kiếm, không sắp xếp, không index được trong SQL (mỗi lần mã hoá ra bản mã khác vì IV khác). Cần tra cứu theo giá trị (ví dụ tìm theo số CCCD) thì lưu thêm một cột "blind index" là HMAC của giá trị với một key riêng — và chấp nhận rằng cột đó cho phép so khớp bằng nhau.
  3. NestJS: docs không cho sẵn lớp bọc. Docs NestJS nói rõ Nest "does not provide an additional package on top of" node:crypto, và ví dụ trong docs dùng aes-256-ctr với key dẫn xuất từ một chuỗi cố định và salt 'salt' — đó là ví dụ minh hoạ API, không phải mẫu để mang vào production: CTR không có xác thực. Mẫu đúng ở trên dùng GCM: docs Node.js decipher.setAuthTag() — nếu không có tag hoặc bản mã bị sửa thì decipher.final() ném lỗi, "indicating that the cipher text should be discarded due to failed authentication"; nếu độ dài tag không khớp authTagLength thì setAuthTag() ném lỗi — đặt authTagLength: 16 để không nhận tag bị rút ngắn.
  4. AAD (associated data) gắn bản mã với ngữ cảnh. AAD không bị mã hoá nhưng được xác thực. Đặt AAD là "bảng + id + cột" thì bản mã của bản ghi A copy sang bản ghi B sẽ giải mã thất bại — chặn kiểu tráo bản mã giữa các dòng mà mã hoá thuần không phát hiện.
  5. key_id lưu cạnh bản mã là điều kiện để xoay key (mục 4.5): giải mã chọn đúng key theo id, không phải thử lần lượt.
  6. Đổi cipher của Laravel từ CBC sang GCM không trong suốt. Theo mã nguồn, Encrypter giải mã mọi payload bằng một cipher hiện tại cho tất cả key (kể cả previous keys). Payload CBC cũ không có tag nên với cipher GCM sẽ ném lỗi ở ensureTagIsValid. Chuyển cipher cần một job đọc bằng Encrypter cũ và ghi lại bằng Encrypter mới, và mọi cookie đang có (cũng mã hoá bằng Crypt) sẽ mất hiệu lực — người dùng bị đăng xuất. Mục 5.5 bàn có nên chuyển không.
  7. Không che được: mã hoá ở tầng app không bảo vệ khi chính app bị chiếm (app có key để giải mã); không bảo vệ khi key nằm cùng chỗ với dữ liệu (ví dụ .env nằm trong cùng backup với dump DB).
flowchart TD
  A["Ghi field nhạy cảm"] --> B["Lấy key hiện tại + key_id<br/>(từ secret store / KMS)"]
  B --> C["IV/nonce mới bằng CSPRNG"]
  C --> D["AES-256-GCM với AAD = bảng:id:cột"]
  D --> E["Lưu key_id + nonce + bản mã + tag"]
  E --> F["Đọc: chọn key theo key_id"]
  F --> G{"Tag đúng (gồm cả AAD)?"}
  G -- "Không" --> X["Lỗi chung, ghi log sự kiện, không trả dữ liệu"]
  G -- "Có" --> H["Trả bản rõ"]
Cách nói khi phỏng vấn: "Field cần đọc lại em mã hoá bằng chế độ có xác thực. Ở Laravel em dùng Crypt hoặc cast encrypted có sẵn: framework sinh IV mới mỗi lần, mặc định là AES-256-CBC cộng HMAC theo kiểu mã hoá rồi mới ký, và khi giải mã thì kiểm MAC trước, sai là dừng. Ở NestJS không có lớp bọc, nên em viết một module nhỏ dùng AES-256-GCM, nonce ngẫu nhiên mười hai byte, tag mười sáu byte, thêm dữ liệu liên kết là tên bảng và id để bản mã không bê sang dòng khác được, và lưu key id cạnh bản mã để còn xoay key. Ví dụ trong docs NestJS dùng chế độ CTR không có xác thực, em không copy nguyên."

4.4 So sánh bí mật constant-time: chữ ký webhook, signed URL

// ===== SAI: so sánh thường =====
if ($request->header('X-Signature') === hash_hmac('sha256', $body, $secret)) { /* ... */ }  // Laravel
if (req.headers['x-signature'] === expected) { /* ... */ }                                  // NestJS

// ===== ĐÚNG: Laravel — webhook từ hệ thống của trường =====
$expected = hash_hmac('sha256', $request->header('X-Timestamp') . '.' . $request->getContent(),
                      config('services.school.webhook_secret'));       // secret riêng, KHÔNG phải APP_KEY
abort_unless(hash_equals($expected, (string) $request->header('X-Signature')), 401);
abort_if(abs(time() - (int) $request->header('X-Timestamp')) > 300, 401); // chống phát lại quá 5 phút

// Signed URL có sẵn: route('report.download', ...) ký bằng HMAC-SHA256 + APP_KEY
$url = URL::temporarySignedRoute('report.download', now()->addMinutes(30), ['report' => $id]);
// route: ->middleware('signed')

// ===== ĐÚNG: NestJS =====
import { createHmac, timingSafeEqual } from 'node:crypto';
const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest();   // Buffer 32 byte
const given = Buffer.from(String(req.headers['x-signature'] ?? ''), 'hex');
const ok = given.length === expected.length && timingSafeEqual(given, expected);

Giải thích:

  1. hash_equals / timingSafeEqual so sánh toàn bộ chuỗi với thời gian không phụ thuộc vị trí ký tự khác đầu tiên. Docs Node: hai input phải cùng độ dài byte, nếu không hàm ném lỗi — nên kiểm độ dài trước (độ dài của chữ ký đúng là cố định 32 byte, kiểm độ dài không lộ gì có ích). Docs Node cũng cảnh báo: dùng hàm này "does not guarantee that the surrounding code is timing-safe".
  2. Laravel đã làm sẵn ở hai chỗ quan trọng (đọc mã nguồn): Encrypter::validMacForKey() dùng hash_equals; UrlGenerator::hasCorrectSignature() tính hash_hmac('sha256', $original, $key) và so bằng hash_equals, thử lần lượt các key mà key resolver trả về (array_any). Signed URL cũng bỏ tham số signature khỏi chuỗi được ký và kiểm expires riêng (signatureHasNotExpired).
  3. Ký cả timestamp, kiểm cửa sổ thời gian: HMAC đúng chỉ chứng minh nội dung không bị sửa, không chứng minh request không bị gửi lại. Ký timestamp.body và từ chối khi lệch quá 5 phút giới hạn phát lại; muốn chặn hoàn toàn thì lưu id sự kiện đã xử lý (idempotency — bài idempotency key). Bài đồng bộ điểm với hệ thống của trường có luồng webhook ký HMAC đầy đủ.
  4. Ký trên raw body, không trên JSON đã parse rồi serialize lại — thứ tự key, khoảng trắng khác là chữ ký khác. NestJS cần bật raw body cho route webhook.
  5. Secret webhook riêng cho mỗi đối tác: lộ secret của một trường không ảnh hưởng trường khác, và không bao giờ là APP_KEY (mục 3.5).
flowchart TD
  A["Webhook đến: header X-Timestamp, X-Signature + raw body"] --> B["Tính HMAC-SHA256(secret riêng, ts.body)"]
  B --> C{"Độ dài khớp và<br/>so sánh constant-time khớp?"}
  C -- "Không" --> X["401"]
  C -- "Có" --> D{"Lệch giữa giờ server và ts<br/>≤ 300 giây?"}
  D -- "Không" --> X
  D -- "Có" --> E{"event_id đã xử lý?"}
  E -- "Rồi" --> F["200, bỏ qua (idempotent)"]
  E -- "Chưa" --> G["Xử lý"]
Cách nói khi phỏng vấn: "Mọi phép so sánh với một giá trị bí mật — chữ ký webhook, API key, token — em dùng hàm so sánh thời gian cố định: hash equals bên PHP, timing safe equal bên Node, nhớ kiểm độ dài trước vì hàm của Node ném lỗi khi lệch độ dài. Laravel đã làm vậy sẵn cho cookie mã hoá và signed URL. Với webhook em ký cả timestamp và body thô, từ chối nếu lệch quá năm phút, và lưu id sự kiện để chặn phát lại. Mỗi đối tác một secret riêng, không bao giờ dùng APP_KEY."

4.5 Key: nằm ở đâu, xoay thế nào

# ===== SAI =====
# config/services.php
'school' => ['webhook_secret' => 'sk_live_3f9a...'],     // secret trong repo
# .env.production commit vào Git, APP_KEY giống nhau ở staging và production

# ===== ĐÚNG: Laravel — xoay APP_KEY không đăng xuất mọi người =====
# Bước 1: sinh key mới (không ghi đè file, chỉ in ra)
php artisan key:generate --show
# Bước 2: cập nhật secret store của môi trường (không phải file trong repo)
APP_KEY="base64:<key mới>"
APP_PREVIOUS_KEYS="base64:<key cũ>"           # có thể là danh sách cách nhau bởi dấu phẩy
# Bước 3: deploy; dữ liệu mới mã hoá bằng key mới, dữ liệu cũ vẫn giải mã được bằng key cũ
# Bước 4: chạy job đọc-ghi lại các cột cast 'encrypted' để chuyển sang key mới
# Bước 5: sau khi job xong + qua thời gian sống tối đa của cookie/signed URL: bỏ key cũ khỏi APP_PREVIOUS_KEYS

// ===== ĐÚNG: NestJS — key nạp từ secret store, nhiều phiên bản theo key_id =====
// ConfigModule đọc biến môi trường do secret store/orchestrator bơm vào lúc chạy
const keys = new Map<string, Buffer>([
  ['k2026-10', Buffer.from(process.env.FIELD_KEY_2026_10!, 'base64')],   // key hiện tại: mã hoá
  ['k2026-04', Buffer.from(process.env.FIELD_KEY_2026_04!, 'base64')],   // key cũ: chỉ giải mã
]);
const CURRENT_KEY_ID = 'k2026-10';

Giải thích:

  1. APP_PREVIOUS_KEYS (docs Laravel, mục "Gracefully Rotating Encryption Keys"): đổi APP_KEY mà không khai báo key cũ thì "all authenticated user sessions will be logged out" (vì mọi cookie, gồm cookie phiên, được mã hoá bằng key) và dữ liệu mã hoá cũ không giải mã được nữa. Khai báo key cũ thì Laravel "will always use the current encryption key when encrypting values" và khi giải mã thì thử key hiện tại rồi tới các key cũ. Mã nguồn config/app.php: 'previous_keys' đọc APP_PREVIOUS_KEYS, tách theo dấu phẩy; Encrypter::getAllKeys() trả [key hiện tại, ...previousKeys].
  2. Chi tiết từ mã nguồn đáng biết: với CBC, Encrypter::decrypt() kiểm MAC với từng key để tìm key đúng rồi mới giải mã; với GCM thì thử giải mã lần lượt từng key. Có nhiều previous keys thì mỗi lần giải mã thất bại tốn nhiều phép tính hơn — không đáng kể với 2–3 key, nhưng là lý do không để danh sách dài mãi.
  3. Vì sao vẫn cần job ghi lại (bước 4): cheat sheet OWASP nêu hai cách xử lý dữ liệu cũ khi xoay key — giải mã và mã hoá lại bằng key mới, hoặc đánh dấu mỗi bản ghi bằng id của key và giữ nhiều key — và khuyên cách thứ nhất "should generally be preferred" vì đơn giản hoá cả code lẫn quản lý key. APP_PREVIOUS_KEYS là cầu nối cho giai đoạn chuyển, không phải trạng thái cuối. Cheat sheet cũng nhắc: giữ key đã nghỉ thêm một thời gian "in case old backups of copies of the data need to be decrypted" — nghĩa là key cũ phải được lưu ở nơi an toàn cùng vòng đời với backup, không phải xoá ngay.
  4. Key nằm ở đâu — ba mức, theo cheat sheet "Key Storage": (a) HSM vật lý hoặc ảo; (b) key vault như AWS KMS, Azure Key Vault; (c) tối thiểu là biến môi trường bơm từ secret store lúc chạy (GitLab CI protected variable, Docker secret, Vault) — không bao giờ trong repo hay image. OWASP 2025 yêu cầu key nhạy cảm nhất nằm trong HSM (phần cứng hoặc cloud). Mục 5.4 bàn trade-off.
  5. Envelope encryption (mã hoá phong bì — cheat sheet mục "Encrypting Stored Keys"): DEK (data encryption key) mã hoá dữ liệu, KEK (key encryption key) nằm trong KMS mã hoá DEK; DEK đã mã hoá lưu cạnh dữ liệu. Xoay KEK chỉ cần mã hoá lại DEK, không cần mã hoá lại toàn bộ dữ liệu. Cheat sheet lưu ý: nếu KEK và DEK không tách được (ví dụ shared hosting), giá trị của cách này rất hạn chế.
sequenceDiagram
  participant Ops as Người vận hành
  participant SS as Secret store
  participant App as App (blue/green)
  participant DB as Database
  Ops->>SS: APP_KEY = mới, APP_PREVIOUS_KEYS = cũ
  Ops->>App: deploy (cả blue và green cùng bộ key)
  App->>DB: ghi mới bằng key mới
  App->>DB: đọc cũ, giải mã bằng key cũ
  Ops->>App: chạy job đọc rồi ghi lại cột encrypted
  Ops->>SS: hết TTL cookie/URL ký, bỏ key cũ khỏi danh sách
  Ops->>SS: lưu trữ key cũ theo vòng đời backup
Cách nói khi phỏng vấn: "Key không bao giờ nằm trong repo hay image, nó được bơm từ secret store lúc chạy, và mỗi việc một secret riêng. Để xoay APP_KEY của Laravel em đặt key mới làm key chính, key cũ vào danh sách previous keys: dữ liệu mới mã hoá bằng key mới, dữ liệu cũ vẫn đọc được, người dùng không bị đăng xuất. Sau đó chạy job mã hoá lại các cột đã mã hoá, đợi hết hạn cookie và URL ký rồi mới bỏ key cũ khỏi danh sách, nhưng vẫn lưu trữ nó theo vòng đời backup. Với dữ liệu lớn em dùng mã hoá phong bì để xoay key chủ trong KMS mà không phải mã hoá lại toàn bộ dữ liệu."

4.6 JWT: ghim thuật toán, key đủ mạnh, sống ngắn

// ===== SAI: NestJS — không ghim thuật toán, secret yếu, sống lâu =====
JwtModule.register({ secret: 'lms-secret', signOptions: { expiresIn: '30d' } });
// verify không chỉ định algorithms; payload chứa email, số điện thoại

// ===== ĐÚNG: NestJS — @nestjs/jwt =====
JwtModule.register({
  privateKey: process.env.JWT_PRIVATE_KEY,               // PEM, chỉ dịch vụ phát token có
  publicKey: process.env.JWT_PUBLIC_KEY,                 // dịch vụ chỉ kiểm thì chỉ cần public key
  signOptions: { algorithm: 'RS256', expiresIn: '15m', issuer: 'lms-auth', audience: 'lms-api' },
  verifyOptions: { algorithms: ['RS256'], issuer: 'lms-auth', audience: 'lms-api' },
});

// Nếu dùng HMAC (một dịch vụ vừa ký vừa kiểm):
// secret = randomBytes(32) (≥ 256 bit) từ secret store; verifyOptions: { algorithms: ['HS256'] }

// ===== ĐÚNG: Laravel — firebase/php-jwt: thuật toán gắn với key =====
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$claims = JWT::decode($jwt, new Key($publicKey, 'RS256'));   // không đọc thuật toán từ header

Giải thích:

  1. verifyOptions.algorithms (docs @nestjs/jwt): tuỳ chọn mặc định cho mọi lời gọi verify()/verifyAsync(), được gộp dưới tuỳ chọn truyền theo từng lời gọi (per-call thắng). Ví dụ cấu hình trong docs dùng đúng cặp signOptions: { algorithm: 'RS256' } + verifyOptions: { algorithms: ['RS256'] }. Ghim danh sách thuật toán là cách cheat sheet JWT khuyên để chặn cả none lẫn nhầm loại key: không trộn thuật toán chữ ký công khai với thuật toán MAC.
  2. Chọn RS256/EdDSA hay HS256: HMAC đơn giản, nhanh, nhưng ai kiểm được thì cũng ký được — chỉ hợp khi một dịch vụ vừa phát vừa kiểm. Có nhiều dịch vụ kiểm (Laravel app, NestJS portal, worker) thì chữ ký bất đối xứng: chỉ dịch vụ auth giữ private key, các dịch vụ khác giữ public key — lộ một dịch vụ kiểm không cho quyền phát token. Cheat sheet JWT xếp RSASSA-PKCS1-v1_5 (RS256) là "Traditional / Not recommended" cho thiết kế mới và HMAC-SHA2 là "Recommended" — nghĩa là với hệ thống mới, cân nhắc ES256/EdDSA hoặc RSA-PSS thay RS256 nếu thư viện ở cả hai phía hỗ trợ (chưa đối chiếu hỗ trợ của từng thư viện — mục 10).
  3. Secret HMAC ≥ 256 bit từ CSPRNG (cheat sheet JWT: secret ít nhất bằng độ dài output; không dùng passphrase, không hard-code). 'lms-secret' ở mẫu sai đoán được offline từ bất kỳ token nào bị lộ.
  4. Payload JWT không bí mật. JWT ký (JWS) chỉ base64url payload; ai cầm token cũng đọc được. Không đặt email, số điện thoại, điểm số vào payload; chỉ sub, thời hạn và claim cần cho định tuyến.
  5. Thời hạn ngắn vì JWT không thu hồi được trước hạn — phần thu hồi, quyền trong claim: Bài 1.
  6. PHP: thư viện firebase/php-jwt từ bản 6 yêu cầu truyền đối tượng Key gắn thuật toán, nên thuật toán do server quyết định chứ không do header token (theo README của thư viện, chưa chạy — mục 10).
flowchart TD
  A["Dịch vụ auth: giữ private key"] -- "ký RS256, exp 15 phút" --> T["JWT"]
  T --> B["Laravel app: chỉ public key<br/>algorithms = RS256"]
  T --> C["NestJS portal: chỉ public key<br/>algorithms = RS256"]
  B --> D{"alg trong token thuộc danh sách<br/>và chữ ký đúng?"}
  C --> D
  D -- "Không" --> X["401"]
  D -- "Có" --> E["Kiểm exp, iss, aud rồi mới kiểm quyền (A01)"]
Cách nói khi phỏng vấn: "Với JWT em luôn ghim thuật toán ở phía kiểm, không để token tự khai báo, vì đó là gốc của hai lỗi kinh điển: chấp nhận alg none và nhầm public key thành secret HMAC. Nhiều dịch vụ cùng kiểm thì em dùng chữ ký bất đối xứng, chỉ dịch vụ auth có private key. Dùng HMAC thì secret phải là hai trăm năm mươi sáu bit ngẫu nhiên. Token sống ngắn, và payload không chứa dữ liệu cá nhân vì ai cầm token cũng đọc được."

4.7 TLS: cấu hình ở LB, kiểm certificate ở mọi client

# ===== SAI =====
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;                     # Nginx: còn bật phiên bản đã bị RFC 8996 loại
Http::withoutVerifying()->post($url, $data);             // Laravel HTTP client: tắt kiểm certificate
new https.Agent({ rejectUnauthorized: false });          // Node: tắt kiểm certificate
NODE_TLS_REJECT_UNAUTHORIZED=0                           # Node: tắt cho cả process
DB_SSLMODE=prefer                                        # PostgreSQL: thử TLS, không kiểm cert, lặng lẽ rơi về bản rõ

# ===== ĐÚNG: Nginx LB (theo hướng "intermediate" của Mozilla SSL Configuration Generator) =====
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_tickets off;
# HSTS, cookie Secure: xem Bài 2 (A02)

# ===== ĐÚNG: client ra ngoài và tới database =====
Http::timeout(10)->post($url, $data);                    // Laravel: mặc định kiểm certificate, giữ nguyên
DB_SSLMODE=verify-full                                   # PostgreSQL: kiểm CA + tên host
DB_SSLROOTCERT=/run/secrets/db-ca.pem
// NestJS + node-postgres: ssl: { ca: fs.readFileSync('/run/secrets/db-ca.pem'), rejectUnauthorized: true }

Giải thích:

  1. Phiên bản: chỉ TLS 1.2 và 1.3 (cheat sheet TLS: 1.0, 1.1 "must be disabled"; OWASP 2025: ">= TLS 1.2 only"). Danh sách cipher TLS 1.2 ở trên chỉ gồm suite ECDHE (forward secrecy) và AEAD (GCM, ChaCha20-Poly1305), không có suite CBC — khớp yêu cầu "drop support for CBC ciphers" của OWASP 2025. TLS 1.3 có bộ cipher riêng, đều AEAD. Danh sách cipher cụ thể lấy theo hướng "intermediate" của Mozilla — chưa chạy công cụ kiểm (mục 10); OWASP nói "Check everything with a tool" (mục 6.2).
  2. Hai đầu của cùng một vấn đề: cấu hình server quyết định cái gì được phép; kiểm certificate ở client quyết định đang nói chuyện với ai. Một app có Nginx đạt điểm cao vẫn có thể gửi token đối tác qua withoutVerifying(). Quy tắc review: grep withoutVerifying, verify => false, rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED, CURLOPT_SSL_VERIFYPEER — mỗi chỗ phải có lý do bằng văn bản hoặc bị gỡ. Cần tin một CA nội bộ thì thêm CA (verify => '/path/ca.pem', ca: ...), không tắt kiểm.
  3. sslmode của PostgreSQL: prefer thử TLS nhưng không kiểm certificate và rơi về kết nối không mã hoá nếu server không hỗ trợ — kẻ đứng giữa chỉ cần từ chối TLS. verify-full kiểm cả chuỗi CA lẫn tên host. (Ngữ nghĩa theo docs libpq; giá trị mặc định trong config/database.php của Laravel 13 chưa đối chiếu lại — mục 10.)
  4. Không che được: TLS bảo vệ dữ liệu trên đường truyền, không bảo vệ khi tới nơi. Cheat sheet TLS mục "Prevent Caching of Sensitive Data": response nhạy cảm cần Cache-Control: no-store để không bị lưu ở cache trình duyệt hay proxy; OWASP 2025 thêm cả cache ở CDN, web server và cache ứng dụng ("eg: Redis").
flowchart TD
  A["Mọi kết nối mang dữ liệu nhạy cảm"] --> B{"Phía nào?"}
  B -- "Server (Nginx LB)" --> C["TLS 1.2/1.3, ECDHE + AEAD<br/>HSTS (A02), kiểm bằng công cụ"]
  B -- "Client (HTTP ra ngoài, DB, Redis)" --> D{"Kiểm certificate?"}
  D -- "Tắt để chạy được ở dev" --> E["Mã hoá với bất kỳ ai đứng giữa"]
  D -- "Bật, CA nội bộ được thêm vào" --> F["Đúng đối tác, đúng server"]
  C --> G["Response nhạy cảm: Cache-Control no-store"]
Cách nói khi phỏng vấn: "TLS có hai nửa. Nửa server em cấu hình ở load balancer: chỉ TLS một chấm hai và một chấm ba, chỉ các bộ mã có forward secrecy và mã hoá có xác thực, bỏ hết CBC, rồi kiểm bằng công cụ chứ không tin cấu hình. Nửa client hay bị quên hơn: mọi chỗ gọi ra ngoài hay gọi database phải kiểm certificate; gặp CA nội bộ thì thêm CA chứ không tắt kiểm. Với PostgreSQL em dùng chế độ verify-full, vì chế độ prefer không kiểm gì và có thể lặng lẽ rơi về bản rõ."

5. Phòng thủ nhiều lớp — mỗi lớp chặn gì, không chặn gì

5.1 Bảng tổng hợp

LớpChặn đượcKhông chặn đượcChi phí chính
Phân loại dữ liệu + "không lưu thì không mất"Lưu thừa dữ liệu nhạy cảm (OWASP: "Data that is not retained cannot be stolen")Dữ liệu buộc phải lưuThời gian với PO/BA, cập nhật khi thêm tính năng
Hash mật khẩu chậm + salt (+ pepper)Lộ mật khẩu hàng loạt khi DB lộMật khẩu rất yếu; đoán online; mật khẩu lộ trước khi băm (log)CPU/RAM mỗi lần đăng nhập; độ trễ login
CSPRNG cho mọi tokenĐoán token reset, mời, API keyKhông gian nhỏ không giới hạn thử; token lộ qua kênh khácGần như bằng 0
AEAD cho field nhạy cảmDB/backup lộ một mình; sửa hoặc tráo bản mãApp bị chiếm (app có key); key nằm cùng chỗ dữ liệuMất khả năng tìm kiếm/index trên field; quy trình xoay key
Key ngoài repo, mỗi việc một key, có xoayLộ repo/image; người nghỉ việc còn giữ keyMáy chạy app bị chiếm (key nằm trong bộ nhớ)Secret store, runbook, diễn tập
KMS/HSM + envelope encryptionTrích xuất key gốc; xoay KEK không mã hoá lại dữ liệuKẻ có quyền gọi API giải mã của KMSPhí theo request, độ trễ mạng, phụ thuộc nhà cung cấp
So sánh constant-time, ghim thuật toán chữ kýDò chữ ký theo thời gian; alg none, nhầm loại keySecret yếu; key lộGần như bằng 0
TLS 1.2/1.3 + kiểm certificate ở mọi clientNghe lén, sửa dữ liệu trên đường truyền, hạ cấpDữ liệu sau khi tới nơi (cache, log)Quản lý certificate, cấu hình chặng nội bộ

5.2 Argon2id hay bcrypt — và cái giá về bộ nhớ khi login dồn dập

1. Trường phái "theo khuyến nghị mới nhất": OWASP Password Storage đặt Argon2id lên đầu, scrypt thứ hai, bcrypt "should only be used for password storage in legacy systems where Argon2 and scrypt are not available". Lý do: Argon2id và scrypt memory-hard (tốn bộ nhớ), làm GPU/ASIC đắt hơn nhiều khi đoán song song; bcrypt chỉ tốn CPU và có giới hạn 72 byte.

2. Trường phái "theo mặc định của framework": Laravel vẫn mặc định bcrypt cost 12, nhiều hệ thống PHP lớn vẫn dùng bcrypt, và OWASP vẫn chấp nhận bcrypt cost ≥ 10. Đổi thuật toán có chi phí thật: HASH_VERIFY, giai đoạn hai loại hash sống chung, test lại mọi luồng đăng nhập. Một hệ thống đang chạy bcrypt cost 12 không có lỗ hổng cần vá gấp — nó là ứng viên nâng cấp có kế hoạch.

3. Cái giá mà ít ai tính: bộ nhớ nhân với số login đồng thời. Ví dụ minh hoạ, không phải số đo: một app node PHP-FPM có pm.max_children = 50. Với Argon2id theo config mặc định của Laravel (64 MiB), lúc 50 worker cùng xử lý đăng nhập (sáng thứ Hai, đầu giờ thi) cần 50 × 64 MiB = 3.200 MiB ≈ 3,1 GiB chỉ cho việc băm — có thể vượt RAM còn trống và gây OOM. Với mức tối thiểu OWASP 19 MiB: 50 × 19 = 950 MiB. Với bcrypt: bộ nhớ không đáng kể nhưng CPU bị chiếm. Cheat sheet cảnh báo đúng điểm này: work factor quá cao "could be used by an attacker to carry out a denial of service attack by exhausting the server's CPU with a large number of login attempts" — và với Argon2id thì là cả RAM.

4. Ví dụ số về CPU (minh hoạ): nếu một lần băm mất 250 ms trên một core, một core xử lý tối đa 4 lần đăng nhập/giây. Hệ thống có 5 app node × 4 core = 20 core thì trần lý thuyết là 80 lần đăng nhập/giây nếu không làm gì khác. Đợt đăng nhập dồn dập 200 lần/giây (hoặc một bot thử mật khẩu) đủ làm nghẽn toàn bộ. Cách tính này là lý do endpoint đăng nhập luôn cần rate limit riêng và pool worker riêng nếu có thể (bài cách tính con số rate limit).

5. Cách chốt của em: app mới — Argon2id với tham số trong khoảng OWASP (ví dụ m=19–64 MiB, t=2–4), đo thời gian băm trên máy production thật (cheat sheet: "calculating a hash should take less than one second", và với Laravel nên dưới thời lượng timebox 200 ms), tính bộ nhớ đỉnh = số worker × m; app đang chạy bcrypt — giữ, tăng cost theo đo đạc, lên lịch chuyển khi có dịp (đổi hệ thống đăng nhập, nâng framework). Ghi quyết định vào ADR kèm con số đo.

Bộ nhớ đỉnh cho việc băm ≈ số worker xử lý login đồng thời × tham số memory của Argon2id
Ví dụ: 50 worker × 64 MiB ≈ 3,1 GiB ;  50 worker × 19 MiB ≈ 0,93 GiB
Trần login/giây ≈ tổng số core ÷ thời gian một lần băm (giây)
Ví dụ: 20 core ÷ 0,25 s = 80 login/giây
Cách nói khi phỏng vấn: "Argon2id an toàn hơn vì tốn bộ nhớ, nhưng cái giá đó trả ở mỗi lần đăng nhập. Em nhân số worker có thể cùng xử lý đăng nhập với lượng bộ nhớ mỗi lần băm: năm mươi worker nhân sáu mươi tư megabyte là hơn ba gigabyte, đủ làm một node hết RAM lúc sáng đầu giờ thi. Về CPU, nếu một lần băm mất một phần tư giây thì mỗi core chỉ được bốn lần đăng nhập mỗi giây, hai mươi core là tám mươi. Nên em chọn tham số bằng đo đạc trên máy thật, và luôn có rate limit riêng cho endpoint đăng nhập."

5.3 Mã hoá ở tầng nào: app, database hay đĩa

Cheat sheet Cryptographic Storage liệt kê bốn tầng có thể mã hoá — ứng dụng, database (ví dụ TDE), filesystem (LUKS, BitLocker), phần cứng — và nói rõ lựa chọn "will depend on the threat model": mã hoá phần cứng chống mất trộm máy chủ nhưng "will provide no protection if an attacker is able to compromise the server remotely".

TầngChống đượcKhông chống đượcPhù hợp khi
Đĩa / volume (LUKS, ổ mã hoá của cloud)Mất ổ, snapshot đĩa lọt ra ngoàiAi đọc được qua DB/appMặc định nên bật; gần như miễn phí trên cloud
Database (TDE)File dữ liệu, backup file lọt raAi có tài khoản DB, SQL injectionYêu cầu tuân thủ; DB thương mại có sẵn
Ứng dụng (field-level, AEAD)DB dump, replica, backup logic lọt ra; DBA tò mòApp bị chiếmVài field thật sự nhạy cảm (token đối tác, số giấy tờ)
Object storage (S3/MinIO SSE)Mất đĩa phía nhà cung cấpAi có access key hợp lệBật mặc định; kết hợp bucket private + URL ký (Bài 2 mục 4.8)

Nhận định: mã hoá đĩa và SSE là điều kiện cần rẻ, không phải câu trả lời cho A04 — interviewer hỏi "dữ liệu có mã hoá không" mà chỉ trả lời "có, ổ đĩa mã hoá" là bỏ qua đúng kịch bản phổ biến nhất (dump DB lọt ra từ một kênh hợp lệ). Mã hoá tầng app thì đắt (mất tìm kiếm, phải quản lý key), nên chỉ dành cho số ít field đã phân loại.

5.4 Key trong biến môi trường hay trong KMS/HSM

1. Biến môi trường từ secret store (GitLab CI protected variable, Docker secret, Vault): đơn giản, không phụ thuộc mạng lúc chạy, không tốn phí theo request. Điểm yếu: key nằm trong bộ nhớ và môi trường của process — ai có shell trên container (hoặc một lỗi đọc /proc/self/environ, một trang debug lộ biến môi trường — A02) là có key gốc; xoay key cần deploy lại.

2. KMS/HSM với envelope encryption: key gốc không bao giờ rời KMS; app chỉ có DEK (hoặc gọi KMS để giải mã DEK). Lộ app vẫn nguy hiểm (kẻ tấn công gọi KMS thay app) nhưng có log kiểm toán từng lần giải mã, có thể thu hồi quyền ngay, và key gốc không bị sao chép đi. Chi phí: phí theo request, độ trễ mạng (nên cache DEK đã giải mã trong bộ nhớ với TTL ngắn), phụ thuộc nhà cung cấp — exit strategy là giữ định dạng bản mã có key_id để có thể chuyển sang KMS khác bằng job mã hoá lại DEK.

3. Chọn theo bối cảnh: team nhỏ, chạy VPS, dữ liệu nhạy cảm vừa phải → biến môi trường từ secret store + quy trình xoay có diễn tập là mức hợp lý. Dữ liệu thuộc PCI DSS, hồ sơ sức khoẻ, hoặc yêu cầu kiểm toán ai giải mã khi nào → KMS/HSM như OWASP 2025 yêu cầu cho "most sensitive keys".

5.5 Laravel mặc định AES-256-CBC + HMAC: có cần chuyển sang GCM?

1. Lập luận "chuyển": OWASP 2025 ghi "Avoid deprecated cryptographic functions, block building methods and padding schemes, such as MD5, SHA1, Cipher Block Chaining Mode (CBC)…" và "Always use authenticated encryption instead of just encryption". Scanner tuân thủ có thể gắn cờ chữ "CBC" trong config. Laravel hỗ trợ aes-256-gcm ngay trong Encrypter.

2. Lập luận "chưa cần gấp": cheat sheet Cryptographic Storage chấp nhận CBC khi kèm xác thực riêng theo Encrypt-then-MAC. Mã nguồn Laravel đúng là Encrypt-then-MAC (HMAC-SHA256 trên IV + bản mã), kiểm MAC bằng hash_equals trước khi giải mã, và trả một loại lỗi chung — tức là đã chặn đúng cơ chế làm CBC nguy hiểm (padding oracle, sửa bản mã). Chuyển cipher thì mọi cookie mất hiệu lực và phải mã hoá lại toàn bộ cột encrypted (mục 4.3).

3. Cách chốt: dự án mới đặt 'cipher' => 'aes-256-gcm' từ ngày đầu (không tốn gì). Dự án đang chạy: ghi nhận trong ADR rằng cấu trúc hiện tại là CBC + HMAC Encrypt-then-MAC, không phải "CBC trần", và gộp việc chuyển cipher vào lần xoay APP_KEY có kế hoạch kế tiếp, vì hai việc cùng cần một job mã hoá lại và cùng chấp nhận đăng xuất người dùng.

5.6 Mật mã hậu lượng tử: bắt đầu từ danh mục, không phải từ thuật toán

OWASP 2025 yêu cầu chuẩn bị PQC để hệ thống rủi ro cao an toàn "no later than the end of 2030", dẫn ENISA và việc NIST công bố ba chuẩn PQC đầu tiên (08/2024). Với một team backend, việc làm được ngay không phải tự cài thuật toán mới mà là: (a) danh mục mật mã — hệ thống dùng thuật toán gì, ở đâu, key nào (TLS ở LB, JWT, chữ ký webhook, mã hoá field, SSH, VPN); (b) crypto agility — định dạng lưu có ghi thuật toán và key_id (mục 2, 4.3); (c) theo dõi hỗ trợ trao đổi khoá lai (hybrid, cổ điển + hậu lượng tử) trong TLS của OpenSSL/Nginx/trình duyệt để bật ở LB khi sẵn sàng. Mối lo "thu bây giờ, giải mã sau" (harvest now, decrypt later) chủ yếu chạm vào dữ liệu cần giữ bí mật nhiều năm; mã hoá đối xứng AES-256 và hash mật khẩu ít bị ảnh hưởng hơn so với trao đổi khoá và chữ ký bất đối xứng. Mức hỗ trợ cụ thể của từng phiên bản phần mềm: chưa verify (mục 10).

5.7 Nếu chỉ có một sprint: thứ tự ưu tiên

  1. Grep và sửa mọi chỗ sinh bí mật bằng PRNG thường (Math.random, mt_rand, rand, uniqid, str_shuffle) — nhóm CWE phổ biến nhất năm 2025, sửa rẻ nhất.
  2. Quét secret trong repo và lịch sử Git; xoay mọi secret tìm thấy (xoá commit không đủ).
  3. Kiểm cách lưu mật khẩu (thuật toán, cost, rehash on login); nếu là hash nhanh → chuyển bằng cách bọc lớp (mục 8.2) + rehash khi đăng nhập.
  4. Grep mọi chỗ tắt kiểm certificate; quét cấu hình TLS ở LB bằng công cụ.
  5. Đặt algorithms cho mọi chỗ kiểm JWT; đổi secret JWT yếu.
  6. Viết runbook xoay APP_KEY/secret JWT và diễn tập ở staging.

6. Phát hiện & vận hành

6.1 Code review: mẫu cần soi

Thấy trong diffCâu hỏi
md5(, sha1(, hash('sha256', createHash(Băm cái gì? Mật khẩu → sai. Token ngẫu nhiên, checksum file, khoá cache → được.
Math.random, mt_rand, rand(, uniqid, str_shuffle, Str::createRandomStringsUsingGiá trị có dùng làm bí mật không (token, mã, tên file khó đoán)?
openssl_encrypt, createCipheriv, -ecb, -cbc, -ctrSao không dùng Crypt/module chung? IV từ đâu? Có tag/MAC? Kiểm trước khi giải mã?
=== hoặc == giữa chữ ký/token và inputĐổi sang hash_equals / timingSafeEqual.
jwt.verify(, JwtModule.register không có algorithms; secret: '...'Thuật toán ghim chưa? Secret từ đâu, dài bao nhiêu?
withoutVerifying, verify => false, rejectUnauthorized: false, sslmode=prefer|disableVì sao tắt? Thêm CA thay vì tắt.
Chuỗi dạng key/secret trong code hoặc file config commitChuyển sang secret store, xoay secret đã lộ.
Log/APM ghi request body ở route login, đăng ký, đổi mật khẩu, webhookMật khẩu và token có bị ghi bản rõ trước khi băm? (Laravel có attribute #[\SensitiveParameter] trên các hàm hash/encrypt để giấu giá trị khỏi stack trace — nhưng không giấu khỏi log do mình tự ghi.)

6.2 Kiểm tra tự động

6.3 Metric và vận hành

1. Phân bố thuật toán hash mật khẩu — đo tiến độ chuyển đổi (ví dụ cho bảng users của Laravel; Moodle là mdl_user.password):

SELECT CASE
         WHEN password LIKE '$argon2id$%' THEN 'argon2id'
         WHEN password LIKE '$2y$12$%'    THEN 'bcrypt cost 12'
         WHEN password LIKE '$2y$%'       THEN 'bcrypt cost khác'
         WHEN password LIKE '$6$%'        THEN 'sha512-crypt'
         ELSE 'khác (kiểm tra ngay)'
       END AS loai,
       count(*) AS so_user,
       max(last_login_at) AS lan_dang_nhap_gan_nhat
FROM users
GROUP BY 1
ORDER BY 2 DESC;
Cách nói khi phỏng vấn: "Em theo dõi tiến độ nâng cấp hash bằng một truy vấn đếm user theo tiền tố của chuỗi hash, vì chuỗi hash tự ghi thuật toán và cost ở đầu. Nhóm nào còn thuật toán cũ, em xem lần đăng nhập gần nhất của họ: người vẫn đăng nhập sẽ tự được băm lại, còn người lâu không đăng nhập thì em quyết định buộc đặt lại mật khẩu. Nhóm 'khác' phải bằng không, nếu không là có chỗ nào đó lưu mật khẩu sai cách. (Truy vấn này là minh hoạ, chưa chạy trên database thật.)"

2. Thời gian băm p95 ở endpoint đăng nhập và bộ nhớ đỉnh của worker — tăng cost thì xem hai số này trước, sau (mục 5.2).

3. Tỷ lệ DecryptException / lỗi xác thực tag theo phút: tăng đột ngột sau deploy thường là lệch key giữa blue và green, hoặc quên APP_PREVIOUS_KEYS; tăng không gắn với deploy có thể là ai đó đang sửa cookie/bản mã — đưa vào alert, không chỉ log.

4. Ngày hết hạn certificate của mọi domain và mọi certificate nội bộ (DB, Redis, mTLS): alert trước nhiều tuần (ngưỡng tuỳ quy trình gia hạn — với ACME tự động thì alert khi gia hạn tự động thất bại).

5. Tuổi của key (ngày tạo của APP_KEY, secret JWT, secret webhook từng đối tác) trong danh mục bí mật — cheat sheet gọi là cryptoperiod; quá hạn là ticket, không phải sự cố.

6. Runbook "nghi lộ key" viết sẵn: xoay key nào, theo thứ tự nào, ai được báo, chấp nhận đăng xuất toàn bộ hay không. Cheat sheet nhấn mạnh code và quy trình xoay phải có trước khi cần.

7. Áp dụng vào hệ thống trong CV

Nguồn: cv-v3.md. Dấu * là giả định không có trong CV — khi phỏng vấn phải nói theo hệ thống thật, không theo bài này.

7.1 LMS Uni — Moodle, student portal NestJS dùng chung database, MySQL, Redis, Nginx LB, S3

1. Moodle lưu mật khẩu thế nào phụ thuộc phiên bản — đã đọc mã nguồn. Nhánh MOODLE_35_STABLE và MOODLE_402_STABLE: hash_internal_user_password() dùng password_hash($password, PASSWORD_DEFAULT) — tức bcrypt với cost mặc định của PHP (cost 4 chỉ khi gọi với $fasthash). Từ MOODLE_403_STABLE (4.3) tới nhánh chính hiện tại: dùng crypt() với SHA-512 ($6$), rounds=10000 (5000 khi fasthash), salt 16 ký tự từ random_bytes(16); hash bcrypt cũ được nhận diện bằng regex ^\$2y\$ và được băm lại sang định dạng mới khi người dùng đăng nhập đúng; có hỗ trợ pepper qua $CFG->passwordpeppers (pepper mới nhất phải đạt entropy tối thiểu PEPPER_ENTROPY = 112, đăng nhập với pepper cũ thì tự băm lại theo pepper mới), độ dài mật khẩu tối đa MAX_PASSWORD_CHARACTERS = 128.

2. Đặt cạnh khuyến nghị OWASP: SHA-512 crypt không có trong danh sách của OWASP (Argon2id, scrypt, bcrypt, PBKDF2). Đây là lựa chọn của Moodle, lý do chưa đối chiếu (mục 10) — khi phỏng vấn em không phán xét, chỉ nói đúng sự thật: "LMS chạy Moodle phiên bản X*, nên hash là bcrypt / SHA-512 crypt; nếu chạy 4.3 trở lên em bật pepper và để pepper trong config.php ngoài database". Lưu ý vận hành với 5 app node: config.php (chứa pepper) phải giống hệt trên mọi node, giống yêu cầu APP_KEY giống nhau giữa blue và green — lệch pepper giữa các node nghĩa là đăng nhập lúc được lúc không tuỳ node.

3. Student portal NestJS dùng chung database với Moodle (CV nêu). Nếu portal tự xác thực bằng cột password của Moodle* thì nó phải hiểu cả $2y$ lẫn $6$, biết pepper, và không được tự băm lại theo cách khác Moodle — hai hệ thống cùng ghi hash là nguồn lỗi khó tìm. Thiết kế an toàn hơn: portal không chạm vào mật khẩu, mà xác thực qua Moodle (SSO, hoặc web service token do Moodle cấp)* rồi phát token riêng của portal. Đây là câu interviewer có thể đào khi nghe "dùng chung database".

4. Link tải báo cáo xác thực bằng JWT (CV nêu): ghim thuật toán ở phía kiểm, secret ≥ 256 bit từ secret store, exp ngắn, payload chỉ chứa id báo cáo và id người nhận (phần ràng buộc quyền: Bài 1); link đi qua email nên có thể bị chuyển tiếp — hạn ngắn là kiểm soát chính. Một phương án đơn giản hơn JWT cho trường hợp này là signed URL của chính dịch vụ phát link, hoặc token ngẫu nhiên lưu hash như mục 4.2 (thu hồi được).

5. Đường truyền nội bộ. TLS kết thúc ở Nginx LB (đã phân tích phần cookie và sslproxy ở Bài 2 mục 7.1). Phía sau LB có ít nhất bốn chặng mang dữ liệu nhạy cảm: LB → app node (cookie phiên, mật khẩu trong form đăng nhập), app → MySQL primary/replica (dữ liệu sinh viên, hash mật khẩu), primary → replica (replication), app → Redis session. Nếu các chặng này là bản rõ trong mạng riêng* thì rủi ro phụ thuộc vào việc mạng đó có thật sự riêng không: chung VPC với hệ thống khác, VPS ở nhà cung cấp khác nhau nối qua Internet* là hai trường hợp rất khác. Trade-off: bật TLS cho MySQL/Redis có chi phí cấu hình certificate và một ít CPU; mạng riêng ảo (WireGuard) giữa các VPS mã hoá mọi chặng một lần. Câu trả lời tốt trong phỏng vấn là nêu được mình đã chấp nhận rủi ro nào, vì sao, chứ không phải "chặng nào cũng TLS".

6. MySQL 8 và caching_sha2_password (CV nêu ở phần nâng cấp thời Topica): plugin xác thực này cần kết nối an toàn (TLS) hoặc trao đổi khoá RSA cho lần xác thực đầy đủ — chính là A04 ở mức giao thức: không để mật khẩu DB đi bản rõ (CWE-523). Chi tiết theo docs MySQL, chưa verify trong lab (mục 10).

7. AWS S3: mã hoá phía server chống mất đĩa của nhà cung cấp, không chống ai có access key; file bài nộp, báo cáo nên ở bucket private và phát bằng URL ký có hạn (Bài 2 mục 4.8). URL ký của S3 là HMAC bằng secret key — secret key đó cũng thuộc danh mục bí mật cần xoay.

7.2 LMS Micro-learning — Laravel + Livewire, NestJS, PostgreSQL, Redis, MinIO, GitLab CI, blue-green

1. Xoay APP_KEY trong blue-green — một chi tiết dễ sai. CV có bullet "bản cũ và bản mới chạy song song an toàn trong lúc blue-green chuyển traffic hoặc rollback". Áp vào xoay key: nếu green chạy key mới và mã hoá cookie bằng key mới, rồi phải rollback về blue (chỉ có key cũ), blue không giải mã được cookie mới → người dùng bị đăng xuất, và field encrypted ghi trong lúc green chạy sẽ không đọc được trên blue. Cách làm tương thích ngược, suy ra từ mã nguồn Encrypter (chưa chạy thử — mục 10): pha 1 deploy cả hai màu với key chính vẫn là key cũ và key mới nằm trong APP_PREVIOUS_KEYS (cả hai màu đọc được dữ liệu mã hoá bằng key mới, nhưng chưa ai mã hoá bằng nó); pha 2 đổi key chính sang key mới, key cũ vào APP_PREVIOUS_KEYS — lúc này rollback về bản pha 1 vẫn đọc được mọi thứ. Đây là expand–contract áp vào key, cùng tư duy với migration database.

2. Redis cache: CV có chiến lược cache Redis cho nhiều loại dữ liệu. OWASP 2025 nhắc tắt cache cho response nhạy cảm kể cả cache ứng dụng như Redis. Với LMS: điểm số, thông tin cá nhân của sinh viên nếu được cache thì Redis trở thành bản sao thứ hai của dữ liệu nhạy cảm, thường ít được bảo vệ hơn database (không TLS*, không ACL*). Phân loại trước khi cache: dữ liệu công khai trong khoá học cache thoải mái; dữ liệu cá nhân thì TTL ngắn, key không chứa thông tin cá nhân, hoặc không cache.

3. PostgreSQL, MinIO, MongoDB: nếu chạy trên host khác app* thì kết nối dùng TLS với verify-full (mục 4.7); MinIO có mã hoá phía server và URL ký như S3 (chi tiết cấu hình KMS của MinIO chưa đối chiếu — mục 10).

4. GitLab CI: APP_KEY, secret JWT, secret webhook là biến protected + masked theo môi trường, không nằm trong .env commit; image build một lần dùng cho 3 môi trường (CV) nghĩa là image không được chứa secret — secret bơm lúc chạy theo môi trường. Đây vừa là A04 (CWE-321) vừa là điều kiện để "build một lần" đúng nghĩa.

7.3 Trả lời mẫu

Câu hỏi: "Hệ thống của anh bảo vệ dữ liệu nhạy cảm thế nào?"
Trả lời mẫu: "Em bắt đầu từ phân loại: mật khẩu, token của đối tác, thông tin cá nhân sinh viên, điểm số, mỗi loại một cách. Mật khẩu ở bên Moodle thì do Moodle băm, em biết rõ phiên bản đang chạy dùng thuật toán gì và có bật pepper; bên Laravel em dùng hash của framework với cost đã đo và rehash khi đăng nhập. Token sinh bằng bộ sinh số ngẫu nhiên mật mã và chỉ lưu hash. Field cần đọc lại thì mã hoá có xác thực, key không nằm trong repo hay image mà bơm từ biến bảo vệ của CI theo môi trường. Đường truyền thì TLS một chấm hai trở lên ở load balancer; các chặng nội bộ em nói rõ chặng nào đang bản rõ trong mạng riêng và vì sao chấp nhận. Và em có quy trình xoay APP_KEY theo hai pha để không vỡ blue-green khi rollback."

8. Anti-patterns & bài học thực tế

8.1 Sự cố công khai: điều gì sai và kiểm soát nào giúp

Sự cốĐiều gì sai (theo nguồn)Kiểm soát nào giúp
Adobe, 2013Mật khẩu được mã hoá bằng 3DES ở chế độ ECB thay vì băm; gợi ý mật khẩu lưu bản rõ. ECB cho cùng mật khẩu ra cùng bản mã, nên gom nhóm theo bản mã kết hợp gợi ý làm lộ hàng loạt; khoảng 153 triệu bản ghi (nguồn thứ cấp).Băm chậm có salt (4.1); không lưu dữ liệu không cần (gợi ý mật khẩu); không dùng ECB.
LinkedIn, 2012Hash SHA-1 không salt; khoảng 6,5 triệu hash bị đăng công khai năm 2012, quy mô thật (khoảng 117 triệu tài khoản) lộ ra năm 2016 (bài phân tích trên arXiv).Hash chậm có salt; quy trình nâng cấp hash cũ (bọc lớp + rehash khi đăng nhập).
Debian OpenSSL, 2006–2008 (CVE-2008-0166)Một thay đổi trong gói OpenSSL của Debian làm bộ sinh số ngẫu nhiên gần như mất nguồn entropy, nên key SSH/SSL sinh trên các hệ thống bị ảnh hưởng thuộc một tập nhỏ, đoán được. Phải sinh lại mọi key.Không sửa code mật mã của thư viện; danh mục key để biết phải sinh lại những gì; quy trình xoay key có sẵn.
Thư viện JWT chấp nhận alg: none và nhầm loại key, 2015Nhiều thư viện đọc thuật toán từ header token (Auth0 công bố; cheat sheet JWT của OWASP dẫn lại).Ghim algorithms ở phía kiểm (4.6); nâng thư viện.

Điểm chung: không sự cố nào cần "phá AES". Tất cả là chọn sai công cụ (mã hoá thay vì băm, hash nhanh), dùng sai cách (ECB, đọc thuật toán từ input), hoặc nguồn ngẫu nhiên hỏng — đúng các nhóm CWE ở mục 1.4.

8.2 Anti-pattern thường gặp khi review

  1. "Đã mã hoá base64." Base64 là encoding, không có key. Gặp trong code lưu mật khẩu SMTP, token đối tác.
  2. Tự viết "thuật toán mã hoá riêng cho chắc". Cheat sheet có hẳn mục "Custom Algorithms": không. Dùng API cấp cao.
  3. Dùng APP_KEY / secret JWT cho việc khác (ký webhook gửi đối tác, làm salt, làm key HMAC cho blind index) — mỗi việc một secret.
  4. Nâng hash cũ bằng cách bắt mọi người đổi mật khẩu cùng lúc — gây tải hỗ trợ lớn và giống dấu hiệu bị tấn công (cheat sheet nói đúng điều này). Cách cheat sheet gợi ý cho hash nhanh cũ: bọc lớp — băm hash cũ bằng thuật toán mới ngay (ví dụ bcrypt(md5($password))), khi người dùng đăng nhập thì thay bằng hash trực tiếp; lưu ý cheat sheet cảnh báo hash bọc lớp có thể dễ phá hơn hash trực tiếp, nên phải thay dần.
  5. Pre-hash SHA-512 trước bcrypt để "vượt 72 byte": cheat sheet gọi bcrypt(base64(sha512($password))) là "dangerous practice" (password shucking); nếu bắt buộc pre-hash thì dùng HMAC với pepper. Đơn giản hơn: dùng Argon2id.
  6. Tắt kiểm certificate "tạm thời" ở dev rồi đi lên production qua biến môi trường dùng chung.
  7. Cache response chứa dữ liệu cá nhân ở CDN hoặc Redis vì "trang chậm" — dữ liệu nhạy cảm có thêm một bản sao kém bảo vệ.
  8. Không có danh mục mật mã: khi có tin "thuật toán X bị phá" hoặc "key Y nghi lộ", không ai trả lời được hệ thống dùng nó ở đâu.
  9. Coi mã hoá đĩa là xong A04. Đĩa mã hoá không chống dump DB qua tài khoản hợp lệ (mục 5.3).

9. Câu hỏi phụ interviewer hay hỏi tiếp

Câu hỏiTrả lời ngắn
Hashing khác encryption thế nào, khi nào dùng cái nào?Hash một chiều, không key — dùng khi chỉ cần so khớp. Encryption hai chiều, cần key — dùng khi phải đọc lại. Mật khẩu: hash chậm. Token đối tác cần gọi API: mã hoá.
Salt khác pepper?Salt ngẫu nhiên riêng mỗi mật khẩu, lưu cùng hash, chống bảng tính sẵn. Pepper là secret chung, lưu ngoài DB, làm hash vô dụng nếu chỉ DB lộ; đổi pepper buộc đặt lại mật khẩu.
Vì sao lưu SHA-256 của token được mà mật khẩu thì không?Khác ở entropy: token 256 bit ngẫu nhiên không vét được dù hash nhanh; mật khẩu do người chọn có không gian nhỏ, cần hàm chậm để mỗi lần đoán đắt.
bcrypt có giới hạn gì?72 byte (byte, không phải ký tự — tiếng Việt có dấu tốn 2–3 byte/ký tự), chỉ tốn CPU. OWASP coi là lựa chọn cho hệ thống cũ, cost ≥ 10.
Tăng cost hash mà user cũ thì sao?Rehash khi đăng nhập (Laravel rehash_on_login, Nest needsRehash); user lâu không đăng nhập thì buộc đặt lại hoặc bọc lớp.
CBC có an toàn không? Laravel dùng CBC mà?CBC trần thì không (sửa bản mã, padding oracle). Laravel dùng CBC + HMAC Encrypt-then-MAC, kiểm MAC constant-time trước khi giải mã — chặn đúng các cơ chế đó. App mới vẫn nên chọn GCM.
Nonce reuse trong GCM nguy hiểm thế nào?Lặp (key, nonce) làm lặp keystream — lộ quan hệ giữa hai bản rõ — và làm mất khả năng chống giả mạo tag. Nonce ngẫu nhiên 96 bit mỗi lần, xoay key trước giới hạn của chế độ.
Xoay APP_KEY thế nào không đăng xuất người dùng?APP_PREVIOUS_KEYS; với blue-green làm hai pha (key mới vào danh sách cũ trước, đổi key chính sau); job mã hoá lại cột encrypted; giữ key cũ theo vòng đời backup.
JWT: HS256 hay RS256?Một dịch vụ vừa ký vừa kiểm: HS256 với secret 256 bit. Nhiều dịch vụ kiểm: bất đối xứng, chỉ auth service giữ private key. Luôn ghim algorithms.
TLS kết thúc ở LB, sau LB có cần mã hoá không?Tuỳ mạng: mạng riêng thật sự (một VPC, có firewall) thì nhiều team chấp nhận bản rõ; VPS nhiều nhà cung cấp nối qua Internet thì phải mã hoá (TLS từng dịch vụ hoặc WireGuard). Quan trọng là quyết định có ghi lại.

10. Chưa verify và Nguồn

Chưa verify

Bài này không có lab. Ngoài nguyên tắc chung đó, các điểm sau đặc biệt cần lab hoặc đối chiếu thêm mới khẳng định được:

  1. Hành vi Laravel suy ra từ đọc mã nguồn, chưa chạy: giá trị mặc định trong config/hashing.php (bcrypt 12; argon 65536/4/1) so với mặc định của class ArgonHasher (1024/2/2); HASH_VERIFY ném lỗi khi đổi driver; BCRYPT_LIMIT; Timebox 200 ms và nhận định "hash lâu hơn timebox thì lộ chênh lệch thời gian"; đổi app.cipher từ CBC sang GCM làm hỏng payload cũ. Bản mã nguồn đọc ngày 27/10/2026, chưa đối chiếu tag phiên bản Laravel 13 cụ thể.
  2. Xoay APP_KEY hai pha cho blue-green (7.2): suy luận từ Encrypter::getAllKeys() và docs, chưa chạy với rollback thật. Key resolver của signed URL có trả cả previous keys hay không: suy ra từ UrlGenerator (nhận mảng key), chưa đọc RoutingServiceProvider.
  3. @nestjs/authentication / PasswordHasher: theo docs NestJS (trang Encryption and Hashing) bản đọc ngày 27/10/2026; chưa cài, chưa kiểm phiên bản gói và hành vi hash giả cho user không tồn tại.
  4. Node.js authTagLength chặn tag bị rút ngắn: theo docs Node crypto (Context7), chưa chạy.
  5. firebase/php-jwt bản 6 trở lên bắt buộc Key gắn thuật toán: theo hiểu biết về README thư viện, chưa tra lại bằng Context7, chưa chạy.
  6. Danh sách cipher Nginx (4.7): theo hướng "intermediate" của Mozilla SSL Configuration Generator, chép theo hiểu biết, chưa chạy testssl.sh hay SSL Labs; mức hỗ trợ trao đổi khoá lai hậu lượng tử trong OpenSSL/Nginx hiện tại: chưa đối chiếu.
  7. Giá trị mặc định sslmode trong config/database.php của Laravel 13: chưa đối chiếu; ngữ nghĩa prefer/verify-full theo docs libpq, chưa chạy trên container PostgreSQL của repo.
  8. Moodle: mã nguồn các nhánh 3.5, 4.2, 4.3, 4.5 và nhánh chính đã đọc (đọc ngày 27/10/2026); lý do Moodle 4.3 chuyển từ bcrypt sang SHA-512 crypt chưa đối chiếu tracker; cost bcrypt mặc định của PHP theo từng phiên bản (10 trước PHP 8.4, 12 từ PHP 8.4) theo hiểu biết, chưa kiểm; LMS Uni đang chạy phiên bản Moodle nào: CV không nêu.
  9. MySQL caching_sha2_password cần TLS hoặc RSA cho xác thực đầy đủ: theo hiểu biết về docs MySQL 8, chưa tra lại, chưa chạy.
  10. Semgrep có nhóm rule mật mã; tính năng secret detection của GitLab; MinIO KMS/SSE: nêu ở mức khái niệm, chưa đối chiếu docs phiên bản hiện tại.
  11. Số liệu sự cố (mục 8.1): Adobe ~153 triệu bản ghi, LinkedIn 6,5 triệu / ~117 triệu — từ nguồn thứ cấp và bài arXiv, không tự kiểm; cơ chế Debian OpenSSL theo NVD/Debian DSA-1571.
  12. Mọi con số capacity ở mục 5.2 (50 worker, 250 ms mỗi lần băm, 20 core) là minh hoạ, chưa đo; tốc độ GPU ở mục 3.1 là giả định minh hoạ.
  13. Các giả định * ở mục 7 về mạng nội bộ, cách portal xác thực, vị trí database, Redis/MinIO có TLS hay không — CV không nêu.

Nguồn

Phụ lục lab: không áp dụng — series A01–A10 không có lab (quyết định của series).

Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 3 — A03 Software Supply Chain Failures · Bài 4 — A04 Cryptographic Failures (bài này) · Tiếp: Bài 5 — A05 Injection →