Licensing cho app desktop bằng Ed25519: token ký, kiểm tra offline
Thiết kế GOHA License Server: FastAPI, token Ed25519 tách rời, key gắn HWID, verify offline, cùng các đánh đổi về revocation, chuyển máy và webhook thanh toán.

Bán phần mềm desktop nghĩa là phần mềm chạy trên máy người khác, nơi bạn không kiểm soát gì. Bài này kể cách mình thiết kế hệ thống license cho các app GOHA: những lựa chọn, vì sao chọn vậy, và những đánh đổi mình chấp nhận.
Kiến trúc tổng quan

GOHA License Server dùng FastAPI và SQLite. Nó cấp token ký Ed25519 dạng detached. License key được gắn với HWID (định danh máy) và có các tier.
API gọn:
POST /v1/activatenhận{key, hwid, app_version?}, trả{token, tier, exp, lexp}.POST /v1/refreshnhận{token, hwid}.GET /healthz.- Các endpoint admin: tạo key (chỉ hiện đúng một lần), liệt kê, thu hồi. Được bảo vệ bằng một admin token trong header.
Server chạy trên Railway. Feed cập nhật của app cũng được gate theo license.
Vì sao chữ ký bất đối xứng
Nếu client giữ một shared secret để verify token, thì bất kỳ ai tháo được client đều có thể tự ký token. Với phần mềm phân phối, "client là kẻ thù tiềm tàng" là giả định đúng.
Với Ed25519:
- Seed ký riêng tư chỉ nằm trong biến môi trường của server.
- Public key nhúng vào cấu hình client.
- Client verify token offline; nhưng dù có public key trong tay, người tháo client vẫn không ký giả được.
verify(token):
if not ed25519_verify(PUBLIC_KEY, payload, signature): reject
if token.hwid != current_machine_hwid: reject
if now > token.exp: try_refresh()
(Đây là pseudo-code minh họa, không phải mã thật.)
Đánh đổi: verify offline và độ trễ thu hồi
Verify offline nghĩa là app chạy được khi không có mạng, và không phụ thuộc server mỗi lần mở. Cái giá: khi bạn thu hồi một key, máy đã có token hợp lệ vẫn chạy cho tới khi token hết hạn.
Mình giảm vấn đề này bằng hai mốc thời gian trong phản hồi: exp là hạn của token ngắn hơn, lexp là hạn của license. Token ngắn hạn buộc client phải refresh định kỳ; mỗi lần refresh là một cơ hội để server nói "key này đã bị thu hồi". Độ trễ thu hồi tối đa bằng thời hạn token. Chỉnh ngắn thì an toàn hơn nhưng bắt người dùng online thường xuyên hơn. Đây là núm vặn, không có giá trị đúng tuyệt đối.
Một máy một key, và chuyển máy tự phục vụ
Key gắn với một HWID. Điều này ngăn chia sẻ key bừa bãi, nhưng cũng làm người dùng thật gặp rắc rối khi đổi máy. Vì vậy có tính năng chuyển máy tự phục vụ: người dùng tự giải phóng máy cũ thay vì phải nhắn cho mình. Mình muốn hệ thống chặn gian lận mà không biến người mua thật thành người đi xin phép.
Repo riêng cho server
Server nằm ở một repo private tách biệt. Lý do thực tế: khi mình bán hoặc phân phối source của app, seed ký và admin token không bao giờ nằm trong thứ được giao đi. Tách repo là cách bảo vệ rẻ nhất mà mình biết.
Phía website: thanh toán và tài khoản
Website dùng Next.js và Supabase, deploy trên Vercel. Nó lo tài khoản khách, gia hạn, và khu admin. Một số quyết định đáng nhắc:
- Gia hạn theo license id, có rate limiting.
- Khu admin yêu cầu 2FA (AAL2).
- Webhook thanh toán xác thực bằng so sánh API key theo thời gian hằng số (constant-time), và idempotent theo transaction id để webhook gửi lại không cộng hai lần.
- Quan trọng nhất: không tin body của webhook. Sau khi nhận, server truy vấn lại cổng thanh toán để xác nhận giao dịch. Body có thể bị giả; câu trả lời từ cổng mới là nguồn sự thật.
Những thứ không làm
Hệ thống này không chống được người đủ quyết tâm vá binary để bỏ qua kiểm tra. Không có license nào chống được điều đó hoàn toàn. Mục tiêu của mình thực tế hơn: làm cho việc dùng trái phép khó hơn việc mua, và đảm bảo không có lỗ hổng ở phía server hay secret bị lộ.
Tóm lại
- Dùng chữ ký bất đối xứng khi client không đáng tin; public key trong client, seed chỉ ở server.
- Verify offline đổi lấy độ trễ thu hồi; điều chỉnh bằng thời hạn token và refresh.
- Một máy một key kèm chuyển máy tự phục vụ để không làm khó người mua thật.
- Tách repo server để phân phối source không lộ khóa.
- Với thanh toán: constant-time, idempotency, và luôn xác nhận lại với cổng thay vì tin webhook.