一、先说结论:离线授权的三个设计约束
给工厂做软件,约束和互联网产品完全不一样:
所以流程只能长这样:
客户机器 你的电脑
1. 显示机器码
2. 导出 request.txt ──微信──► 3. 签发 license.lic
◄──微信── 4. 导入激活
二、机器码:为什么不能只哈希一个硬件序列号
最常见的写法是取主板序列号,哈希一下当机器码。上线后你会发现两个坑:
坑 1:占位值。 很多主板(尤其是工控机和虚拟机)出厂时序列号填的是 To be filled by O.E.M.、
Default string、System Serial Number 这种占位字符串。如果你不做过滤,
所有这种机器会算出同一个机器码——一份授权能在所有机器上用。
坑 2:单点依赖。 只绑硬盘,客户一换硬盘就失效;只绑网卡,客户一插拔网卡就失效。
而且虚拟机、VPN、蓝牙、Docker 都会创建虚拟网卡,取"第一个网卡"的顺序还可能变。
正确做法是多因子 + 分级 + 容错:
// 强特征:主板、BIOS、CPU、系统盘卷序列号、Windows MachineGuid
// 中特征:物理网卡 MAC(必须先排掉虚拟网卡)
// 弱特征:主机名、系统版本(只记录,不参与校验)
// 每个部件单独哈希,带命名空间避免跨类型碰撞
static string FingerprintHash(string kind, string raw)
=> Base32(SHA256("ml.component.v1", kind, raw.Trim().ToUpperInvariant()))[..12];
校验时用容错策略而不是"全等":
- 匹配数 ≥ minMatch(默认 2)
- 变化数 ≤ tolerance(默认 1)
- 本机读不到的部件算"未知",不算不匹配
结果就是:客户换了块硬盘,还有 4 项强特征匹配,照常能用;
把整套软件拷到另一台机器,5 项全部不匹配,直接用不了。
还有一点容易被忽略:机器码本身不应包含弱特征。
换网卡、改主机名之后机器码必须保持不变,否则客户会以为授权坏了。
三、不要把机器码当密码用
MachineGuid、主板序列号这些值,客户自己也能读到。
如果你把授权做成"机器码 XOR 一个固定常量",那么任何人拿到机器码就能算出授权。
正确做法是非对称签名:
- 你手里一份私钥,客户端内嵌公钥。
- 授权文件 = 载荷 + 私钥签名。
- 客户端验签通过才认。
这样即使客户完全反编译了你的程序,也拿不到私钥,无法伪造授权。
私钥只在你这台机器上,这就是真正的安全边界。
// 签发端
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
// 客户端验签(只有公钥)
bool ok = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
注意用 PSS 填充,不要用 PKCS#1 v1.5。 PSS 是可证明安全的填充方式,而且不容易踩到
"同一密钥既签名又加密"的实现坑。
四、别忘了把 keyId 放进授权文件
如果你以后换了密钥,或者客户机器上同时装了别家的软件,客户会看到一个笼统的
“签名校验失败”,然后打电话问你。而真正的原因可能是他装错了文件。
在授权文件里带 8 字节的 keyId(公钥模数的哈希前缀),客户端先比 keyId,
不一致就直接说:
授权文件不是本产品签发的(密钥标识不匹配:218bf45a… ≠ 7c31d0e2…)
一句准确的错误提示,能省掉一轮电话。
五、试用期:先说清楚它能做什么
试用期记录想做到"绝对防重置"是不可能的——客户端没有可信时间源。
所以务实的目标是"劝退",而不是"防御":
- 记录写多个位置(ProgramData / LocalAppData / Roaming / 注册表),删一个没用,读的时候取"最靠后"的那份。
- 时间只前进不后退:以"见过的最大时间"为准,回拨系统时间不会让试用期变长。
- 回拨超过容忍幅度(比如 2 小时)记一次异常,累计 3 次直接终止试用。容忍幅度是必要的,
NTP 同步、手动校时都会造成小幅漂移。 - 记录带校验标签,直接改文件内容会被拒绝解析。
已知边界要如实告诉客户:四处记录全部删掉可以重新计时。
这比吹"无法破解"、然后被客户发现漏洞要强得多。
六、一个比"试用期被重置"更隐蔽的洞:授权到期
上面说的都是试用期。但正式授权的到期判断其实有同一个洞,而且更少人注意到:
// 很多人(包括我第一版)就是这么写的
if (DateTimeOffset.UtcNow > payload.ExpiresAt) { 提示到期; return false; }
客户把系统时间调回上个月,一张已经过期的授权就复活了。
而且这个洞在测试阶段几乎不可能被发现——因为你的开发机时间是对的。
修法和试用期一样,但要用在授权校验上:维护一份"这台机器上见过的最晚时间",
校验时取 max(本机时间, 时间水位)。时间只能前进,不能后退。
不过加了这条之后会引入一个新的风险,这一点必须一起处理:
客户的主板电池没电,时间跳到 2000 年;或者手滑把年份设成 2030 年。
如果时间水位无条件接受这个值,客户的授权就被永久烧掉了 ——
永久损坏一个付费客户的授权,比少赚一单的代价大得多。
所以还要加一条"大幅向前跳要连续两次确认":
if (localNow > highWater + 30天) {
if (上一次也看到同一个时间) highWater = localNow; // 确认,接受
else { pendingJump = localNow; 用 highWater 校验; } // 第一次只忽略
}
两个阈值:
- 回拨容忍 2 小时(NTP 同步、手动校时不能误判)
- 向前跳阈值 30 天(超过就要确认两次)
写完之后建议配一条反证测试:
关掉时间守卫 → 同一个"改时间"场景确实会被绕过 → 说明守卫是必要的
这条测试的价值不在于覆盖率,而在于:如果哪天有人重构时把守卫绕过去了,
它会立刻失败。只测"守卫生效"是测不出这个的。
七、上线前一定要测的几件事
这些是我踩过的,不是在文档里看来的:
(文中这套流程的评估包我传到 CSDN 下载区了,免费,含 76 项自检和完整的"机器码 → 申请 → 签发 → 激活"演示:
https://download.csdn.net/download/jhjjbb/93466800 )
文中的代码是简化片段。完整实现我在自己的项目上跑通了一版,需要的话可以私信我。
网硕互联帮助中心




评论前必须登录!
注册