ADR-0001. Chứng chỉ license ký Ed25519, xác minh offline qua Redis
| Trường | Giá trị |
|---|---|
| Status | Accepted |
| Date | 2026-03-20 |
| Deciders | Phat Nguyen |
| Supersedes | - |
Bối cảnh
- Mọi request trong mọi service (sale, commerce, inventory, …) đều cần biết trạng thái license, tier và tính năng của một merchant / user.
- Một lệnh gọi HTTP đồng bộ tới licensing ở mỗi request sẽ ràng buộc mọi service vào độ sẵn sàng của licensing và làm tăng độ trễ trên đường đi nóng (hot path).
- Trạng thái license thay đổi hiếm khi (issue / suspend / renew / revoke), nhưng được đọc liên tục - đặc tính đọc nhiều, ghi ít.
- Dữ liệu phải chống giả mạo (consumer không được phép giả ra payload "PERPETUAL/all-features") và phải bảo mật (cấu hình tính năng không được đọc ra từ một bản dump Redis bị rò rỉ).
Quyết định
Chúng ta sẽ để licensing ký một chứng chỉ tự chứa cho mỗi license và cache nó trong Redis tại lic:certs:<entityType>:<entityId>. Consumer xác minh chứng chỉ offline qua LicenseMiddleware của @nx/core - chúng không bao giờ gọi licensing tại thời điểm chạy.
Lớp bọc của chứng chỉ là aes-256-gcm+ed25519:
ILicenseCertificatePayload(status, tier, features, giới hạn kích hoạt, thời điểm hết hạn) được mã hoá AES-256-GCM bằngAPP_ENV_APPLICATION_SECRET.- Phần ciphertext được ký Ed25519 bằng
APP_ENV_LICENSING_ED25519_PRIVATE_KEY; consumer xác minh bằng public key đóng gói sẵn trong@nx/core. - Payload mang
certExpiresAt; key Redis cũng được đặtEX = APP_ENV_LICENSING_CERT_TTL_SECONDS(mặc định 86400).
Licensing publish lại sau mỗi thay đổi vòng đời (issue / suspend / reinstate / renew / revoke) và khi hết hạn theo cơ chế lazy. LicenseMiddleware hoạt động theo nguyên tắc fail-open: chứng chỉ thiếu / hết hạn / không xác minh được sẽ cho ra một license context null ("không xác định", chứ không phải "chưa có license").
Hệ quả
| Ưu | Nhược |
|---|---|
| Không ràng buộc tại thời điểm chạy - consumer không gọi licensing | Mỗi lần publish license phải làm crypto bất đối xứng + AES (rẻ, nhưng vẫn có chi phí thật) |
Đường đọc chỉ là một lệnh Redis GET + xác minh cục bộ | APP_ENV_APPLICATION_SECRET dùng chung phải được phân phối tới mọi verifier (secret đối xứng lan ra nhiều nơi) |
| Chống giả mạo (Ed25519) + bảo mật (AES) | Xoay vòng keypair đòi hỏi việc thay public key có phối hợp + publish lại |
| Trạng thái cũ bị giới hạn bởi TTL ngay cả khi bỏ lỡ một lần publish | Sửa tính năng qua CRUD không publish lại - chứng chỉ có thể trễ so với DB cho tới khi có một hành động vòng đời |
| Fail-open tránh việc licensing gặp sự cố kéo sập mọi service | Fail-open nghĩa là Redis gặp sự cố sẽ âm thầm làm suy giảm kiểm soát - các cổng kiểm tra phải xử lý null một cách có chủ ý |
Phương án đã cân nhắc
| Phương án | Ưu | Nhược | Vì sao loại |
|---|---|---|---|
| Gọi HTTP validate đồng bộ ở mỗi request | Luôn lấy dữ liệu mới nhất | Ràng buộc mọi service vào độ sẵn sàng + độ trễ của licensing | Chi phí trên đường đi nóng & phạm vi ảnh hưởng không chấp nhận được |
| Dùng JWT (bất đối xứng) thay cho lớp bọc AES+Ed25519 | Chuẩn, nhiều công cụ hỗ trợ | Payload đọc được (base64) - cấu hình tính năng bị lộ | Vi phạm yêu cầu bảo mật |
| Chứng chỉ chỉ dùng HMAC đối xứng | Đơn giản hơn, một secret duy nhất | Bất kỳ consumer nào giữ secret đều giả mạo được chứng chỉ | Cần tách bạch tin cậy giữa issuer và verifier |
| Broadcast thay đổi license qua Kafka | Theo mô hình đẩy, event-sourced | Thêm phụ thuộc vào broker cho một nhu cầu vốn là đọc theo yêu cầu; consumer phải lưu trạng thái | Quá phức tạp cho hồ sơ đọc nhiều/ghi ít; cache Redis là đủ |
Tham chiếu
licensing/src/services/licensing/licensing-base.service.ts-publishCertificate()core/src/services/license/certs/signer.ts-LicenseCertSignerHelper.signcore/src/services/license/certs/verifier.ts-LicenseCertVerifierHelper.verifycore/src/middlewares/license/license.middleware.ts-LicenseProvider(fail-open)core/src/services/license/certs/types.ts-CertificateDefinitions.FULL_FORM = aes-256-gcm+ed25519