云计算百科
云计算领域专业知识百科平台

上位机软件怎么防止被拷到第二台机器?一套可落地的C# 离线授权方案

一、先说结论:离线授权的三个设计约束

给工厂做软件,约束和互联网产品完全不一样:

  • 工厂没有外网。很多车间电脑是内网,甚至有物理隔离。任何依赖"客户端联网验证"的方案,在现场直接不可用。
  • 现场只有键盘和触摸屏。客户抄不了一长串字符,机器码必须短、要能分组、要能一键复制。
  • 客户会换硬件。硬盘坏了自己换一块、重装系统、加一条内存——这些都不是"想盗版",是正常运维。方案如果扛不住这个,你会被售后电话淹没。
  • 所以流程只能长这样:

    客户机器 你的电脑
    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 天(超过就要确认两次)

    写完之后建议配一条反证测试:

    关掉时间守卫 → 同一个"改时间"场景确实会被绕过 → 说明守卫是必要的

    这条测试的价值不在于覆盖率,而在于:如果哪天有人重构时把守卫绕过去了,
    它会立刻失败。只测"守卫生效"是测不出这个的。

    七、上线前一定要测的几件事

    这些是我踩过的,不是在文档里看来的:

  • 在只有 3 项硬件特征能读到的机器上能激活吗? 用 Win32_BaseBoard 查不到的机器很多。
  • 换一块硬盘还能用吗? 拿两块硬盘对拷系统试一次。
  • 把 license.lic 拷到另一台机器会被拒吗?
  • 授权文件被邮件/微信传输截断后会怎样? 结果应该是"格式错误",不是崩溃。
  • 到期那一晚会怎样? 产线半夜停机是很严重的事故,宽限期不是可选项。
  • 在非管理员账户下能激活吗? ProgramData 可能写不进去,要有回落路径。

  • (文中这套流程的评估包我传到 CSDN 下载区了,免费,含 76 项自检和完整的"机器码 → 申请 → 签发 → 激活"演示:
    https://download.csdn.net/download/jhjjbb/93466800 )
    文中的代码是简化片段。完整实现我在自己的项目上跑通了一版,需要的话可以私信我。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 上位机软件怎么防止被拷到第二台机器?一套可落地的C# 离线授权方案
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!