技术折腾 2026-09-23 2 次阅读 # 授权系统# HMAC# 安卓# 独立开发

一码一机:我的离线授权系统设计

个人开发者怎么做软件授权?我给手势遥控器设计了一套"服务器只管试用、激活全靠离线验签"的方案:HMAC-SHA256、一码一机、72 小时离线兜底。附完整的防白嫖推演。

做《手势遥控器》商业化的时候,授权系统是第一个要过的坎。作为一个人开发、没有后台团队的产品,我的需求非常明确:

最后落地的方案是:服务器只负责试用计时,激活码完全离线验签。分享给有同样需求的独立开发者。

总体架构

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 端验签只做两件事:

  1. 重新计算 HMAC,签名对得上 → 这确实是我签发的码;
  2. 码内 device_hash 与本机设备码一致 → 这码是发给这台机器的。

两步都过,写入"已激活",从此不再依赖网络。验签失败提示"激活码无效",指纹不符提示"激活码不属于本设备"——两种提示分开,售后时用户一句话就能说清楚问题在哪。

试用:服务器绝对时间,堵死时钟回拨

试用不靠本地时钟,流程是:

防白嫖推演

把能想到的绕过路径全部列出来过了一遍:

白嫖路径 结果 原因
重装 App 重置试用 ✗ 设备码不变,服务器记录在
清数据重置试用 ✗ 同上
飞行模式重装 ✗ 首启必须联网,无缓存直接锁
回拨系统时间 ✗ 以 server_now 校正后的服务器时间为准
改本地缓存截止时间 ✗ 冷启动联网必校正
把激活码发给别人 ✗ 指纹不符,异机验签失败

诚实的安全边界:HMAC 是对称验签,密钥必须存在 App 内,专业逆向者理论上可以提取密钥自建发码机。这一点我没法根除,我的态度写在设计文档里:防的是传播(一码一机让分享失效)和普通用户绕过(绝大多数流失发生在这里),不防专业逆向工作室。真到破解版流通的那天,再上密钥混淆、Native 验签或服务器激活制——现在不过度设计,也是对开发时间的基本尊重。

写在最后

这套系统上线后跑得很稳,服务器端就是一个 Flask + SQLite 的单文件服务,跟这个博客跑在同一台机器上。个人开发者做授权,思路就八个字:信任最小,复杂度最低——服务器不需要知道用户是谁,只需要知道"这台机器什么时候开始试用";激活不需要联网,因为离线可用本身就是卖给用户的承诺。

← MediaPipe 手势识别踩坑记 为什么定价 19.9,以及为什么是买断制 →

这篇文章对你有帮助?来留言板聊聊 · 回到博客