做《手势遥控器》商业化的时候,授权系统是第一个要过的坎。作为一个人开发、没有后台团队的产品,我的需求非常明确:
- 买断制,一次付费永久用,不搞订阅;
- 一码一机,激活码换台机器就无效,控制传播;
- 激活必须离线可用——用户激活之后,App 不能因为我的服务器挂了而失效;
- 防白嫖,但不过度设计。
最后落地的方案是:服务器只负责试用计时,激活码完全离线验签。分享给有同样需求的独立开发者。
总体架构
App ──① 冷启动联网登记/校验──> 授权服务器 (一张设备表)
│ <── 返回试用截止时间 ──
│
└──② 输入激活码 (完全离线, 本地 HMAC 验签)
激活码 = 你用发码工具生成
两个角色各司其职:
| 角色 | 职责 |
|---|---|
| 授权服务器 | 记录设备码首次启动时间,下发试用截止时间(仅此而已) |
| 发码工具 | 在我自己的电脑上跑,设备码 → 激活码,密钥只存在这里和 App 内 |
设备码:ANDROID_ID 的单向哈希
设备码是整个体系的锚点。我选了 Settings.Secure.ANDROID_ID——Android 8+ 上每设备每应用唯一,恢复出厂才变,清数据/重装不变。Android 10 之后拿不到 IMEI 和序列号,它就是合规且最稳的选择。
原始 ID 不直接暴露给用户,而是:
device_code = SHA-256(ANDROID_ID)[0:4] → "A3F9-2C71" (8 位短码)
取前 4 字节有理论上的碰撞概率,但 32 位空间对"一个售后群里的用户量"完全够用,换来的是用户能把设备码念得出来、抄得上去——这在人工发码的售后流程里比什么都重要。
激活码:HMAC 签名 + Base32
激活码的结构(9 字节载荷 + 8 字节签名):
payload (9B) = device_hash(4B) | type(1B) | issued_ts(4B)
tag (8B) = HMAC-SHA256(KEY, payload)[0:8]
code = Base32(payload + tag) → "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
type 区分两种码:永久买断码和24 小时体验码(售后补偿用,比如用户第一次没体验完整)。
App 端验签只做两件事:
- 重新计算 HMAC,签名对得上 → 这确实是我签发的码;
- 码内 device_hash 与本机设备码一致 → 这码是发给这台机器的。
两步都过,写入"已激活",从此不再依赖网络。验签失败提示"激活码无效",指纹不符提示"激活码不属于本设备"——两种提示分开,售后时用户一句话就能说清楚问题在哪。
试用:服务器绝对时间,堵死时钟回拨
试用不靠本地时钟,流程是:
- 首次启动必须联网:服务器查无此设备码 → 写入
first_seen = 服务器当前时间,返回trial_end = first_seen + 24h; - 有记录 → 幂等返回存量的
trial_end(所以重装、清数据都不会重置试用); - 响应里带
server_now,App 用它校正本地时钟偏移,回拨系统时间毫无意义; - 已登记的设备断网时有 72 小时离线缓存兜底——不是为了安全,是为了地库、电梯里断网的正常用户不被误锁。
防白嫖推演
把能想到的绕过路径全部列出来过了一遍:
| 白嫖路径 | 结果 | 原因 |
|---|---|---|
| 重装 App 重置试用 | ✗ | 设备码不变,服务器记录在 |
| 清数据重置试用 | ✗ | 同上 |
| 飞行模式重装 | ✗ | 首启必须联网,无缓存直接锁 |
| 回拨系统时间 | ✗ | 以 server_now 校正后的服务器时间为准 |
| 改本地缓存截止时间 | ✗ | 冷启动联网必校正 |
| 把激活码发给别人 | ✗ | 指纹不符,异机验签失败 |
诚实的安全边界:HMAC 是对称验签,密钥必须存在 App 内,专业逆向者理论上可以提取密钥自建发码机。这一点我没法根除,我的态度写在设计文档里:防的是传播(一码一机让分享失效)和普通用户绕过(绝大多数流失发生在这里),不防专业逆向工作室。真到破解版流通的那天,再上密钥混淆、Native 验签或服务器激活制——现在不过度设计,也是对开发时间的基本尊重。
写在最后
这套系统上线后跑得很稳,服务器端就是一个 Flask + SQLite 的单文件服务,跟这个博客跑在同一台机器上。个人开发者做授权,思路就八个字:信任最小,复杂度最低——服务器不需要知道用户是谁,只需要知道"这台机器什么时候开始试用";激活不需要联网,因为离线可用本身就是卖给用户的承诺。