简介:一套基于C#语言与.NET 6.0框架的软件授权(License)应用示例,面向需要为客户端软件加入正版授权与使用期限控制的开发者。示例围绕授权管理完整链路展开,演示了如何读取电脑硬件信息、通过非对称加密完成签名与验证、并实现授权文件的生成和读取,可帮助开发者快速搭建可复用的授权验证模块。压缩包共172个文件,以C#源码、DLL依赖库和EXE可执行程序为主,另含JSON配置、工程文件及说明文档,整体仅1.04MB,结构紧凑便于查阅与移植。目前已有626人学习下载,适合具备一定C#基础、希望借助成熟案例落地软件授权机制的开发人员参考。通过学习该工程,还能深入理解非对称加密在离线授权场景中的实际应用,以及授权文件从生成、分发到校验的完整生命周期。
1. C# 软件 Licence 实例到底在解决什么问题
很多 C# 开发者在交付软件时才碰 License:客户要求按年付费、按模块收费,或者限制只能在某几台机器上运行。常见做法是写一个机器码读取、比对注册表里的注册状态,弹个框提示“授权已过期”,但这套方案最大問題不是代码量,而是它把授权状态放在了用户可控的位置。C# 程序集可以反编译,注册表项可以被修改,系统时间可以被回拨,甚至换个盘符就能绕过路径判断。真正可交付的 C# Licence 系统,核心不在加密而在“签名”:用私钥签发授权内容,用公钥在客户端验证,授权文件只承载数据,程序只做签名校验。这篇按 C# 上位机、行业软件和内部工具三个常见场景,拆出一套离线、可复现、不依赖商业加密锁的 Licence 实现,从协议设计到签发端和校验端代码,最后给出验证手法和常见坑位。
2. 先定协议:RSA 签名、机器码和许可证文件格式
2.1 为什么用 RSA 签名而不是把密钥写死在程序里
做 License 第一个要决定的事,是“授权信息如何防伪造”。把序列号做成对称加密字符串,然后程序里放着解密密钥,这种方案等于把保险柜密码写在保险柜门上。C# 程序用 dnSpy 之类工具可以直接看到字符串常量和 IL 代码,对称密钥藏不住。
正确方向是采用非对称签名:签发端持有 RSA 私钥,负责为授权信息签名;客户端只嵌入 RSA 公钥,验证签名即可。公钥无法反推私钥,伪造 License 文件的成本从“读懂代码”变成“破解 RSA”,这个量级差异就是整个方案的底气。RSA 密钥长度建议 4096 位,虽然比 2048 位慢几十毫秒,但签发和校验都不是高频操作,用这点性能换更长余量值得。签名方案用 SHA256 与 PKCS1 v1.5 填充,兼容性比 PSS 更好,后续跨语言对接(比如用 Python 或 Go 写签发工具)也省事。
注意:客户端只放公钥,千万不要图省事把私钥也嵌进安装包。私钥一旦泄露,所有历史 License 全部失效。
2.2 机器码:取什么、怎么组合、格式怎么归一化
机器码的作用是绑定授权到指定电脑。最怕的不是取不到硬件信息,而是取到的信息在不同机器上重复,或者同一台机器重装系统后机器码变化,导致客户骂娘。
我在实际项目里用过几类信息来源,各有取舍:
| 机器码来源 | 稳定性 | 唯一性 | 采集成本 | 重装系统影响 |
|---|---|---|---|---|
| MachineGuid(注册表) | 高 | 中 | 低,读取注册表单项即可 | 不变,同一系统镜像下相同 |
| ProcessorId(WMI) | 高 | 中高,虚拟机下可能为空 | 中,需 System.Management | 不变 |
| 主板序列号(WMI) | 高 | 高,但部分品牌机为空 | 中 | 不变 |
| DiskSerialNumber(WMI) | 中 | 中,改动分区表可能变化 | 中 | 可能变化 |
常见做法是选两个来源组合后做 SHA256 哈希,再转为大写十六进制字符串。比如 MachineGuid 加 ProcessorId,前者保证重装系统后不漂移,后者补足唯一性。组合后 32 个字符,展示和复制都比一长串数字友好。特别注意字符串归一化:全部转大写、去除中划线、去掉换行和首尾空格,避免因为格式差异让同一台机器验出两个机器码。
2.3 .lic 文件结构约定与大小限制
License 文件本身是纯文本,结构定成三段拼接,中间用竖线分隔。第一段是 Base64 编码的 JSON 载荷,包含授权对象;第二段是 Base64 编码的 RSA 签名。这样做的好处是用记事本打开能看到一小段疑似乱码,不会诱导客户手动改内容,同时程序解析又很简单。
ewo... (Base64 JSON) | qV3f... (Base64 RSA Signature)JSON 载荷字段这样设计,后续扩展时不破坏旧文件格式:
{ "App": "PlcMonitor", "Customer": "某自动化公司", "MachineCode": "7F2A...", "ExpireDate": "2026-12-31", "Modules": ["Motion", "Vision"], "IssueDate": "2025-01-01" }RSA-4096 签名长度固定为 512 字节,Base64 编码后约 684 字符,加上 JSON 载荷,一个 .lic 文件通常不到 2KB。请求体太小无所谓,关键是解析时不要把整个文件读进内存后无脑 Split,要校验竖线数量,数量不是 2 就直接判失败,避免畸形文件触发异常。
3. 签发端:用 C# 命令行工具生成 License 文件
3.1 环境与密钥准备
签发端工具建议做成独立命令行程序,不集成在正式软件里。目标框架选 net6.0-windows 或 net8.0-windows,方便用 WMI 和注册表 API。项目添加 System.Management 包(.NET Framework 时代是直接引用),读取硬件信息。密钥准备分两步:第一步用 OpenSSL 生成 RSA 私钥和公钥,或直接用 C# 代码生成 PEM。我习惯把私钥 PEM 放在独立目录,签发工具通过环境变量或启动参数指定路径。
生成并导出 PEM 的 C# 代码:
using System.Security.Cryptography; // 生成 4096 位 RSA 密钥对 using var rsa = RSA.Create(4096); var privateKey = rsa.ExportRSAPrivateKeyPem(); // .NET 5+ 内置 var publicKey = rsa.ExportRSAPublicKeyPem(); File.WriteAllText("private_key.pem", privateKey); File.WriteAllText("public_key.pem", publicKey); Console.WriteLine("public key:\n" + publicKey);逻辑说明:ExportRSAPrivateKeyPem输出 PKCS#1 格式的 PEM,ImportFromPem可原样导回。公钥文件要复制到客户端工程里,作为嵌入资源或常量字符串。签发端机器不需要公钥,客户端机器不需要私钥,两个文件分开存放。
3.2 读取机器码并生成签名的核心代码
签发工具需要客户提供的机器码,但实战里经常遇到客户不会复制机器码的场景,所以签发端最好也内置读取机器码的逻辑,远程指导时直接让客户运行一条命令输出。下面这段是签发端的核心入口:
using System.Security.Cryptography; using System.Text; using System.Text.Json; var privateKeyPem = File.ReadAllText(args[0]); // 第一个参数:私钥路径 var machineCode = args[1]; // 第二个参数:目标机器码 var expireDays = int.Parse(args[2]); // 第三个参数:授权天数 // 1. 构造授权载荷 var licenseInfo = new LicenseInfo { App = "PlcMonitor", Customer = args.Length > 3 ? args[3] : "Default", MachineCode = machineCode.ToUpperInvariant(), ExpireDate = DateTime.Now.AddDays(expireDays).ToString("yyyy-MM-dd"), Modules = args.Skip(4).ToArray() }; string payloadJson = JsonSerializer.Serialize(licenseInfo); string payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson)); // 2. 对 Base64 载荷做签名 using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); byte[] payloadBytes = Encoding.UTF8.GetBytes(payload); byte[] signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); // 3. 写出 .lic 文件 File.WriteAllText("license.lic", payload + "|" + Convert.ToBase64String(signature)); Console.WriteLine("License 已生成: license.lic"); class LicenseInfo { public string App { get; set; } public string Customer { get; set; } public string MachineCode { get; set; } public string ExpireDate { get; set; } public string[] Modules { get; set; } }参数说明:expireDays用相对天数而不是绝对日期,签发时自动计算,减少人工写错日期的概率;MachineCode转大写再写进 JSON,保证和客户端校验时归一化结果一致;Modules是可选项,按功能代号传入,例如输入Motion Vision就让该 License 同时启用两个模块。签名时是对 Base64 后的字符串签名,不是对原始 JSON 签名,客户端验证时同样先 Base64 解码再核对,两侧顺序必须一致。
3.3 签发参数表:有效期、功能位、备注
签发工具的命令行参数需要一套约定,避免同一个功能每次使用都要翻文档。我固定用这套参数次序,简单场景只填前三个:
| 参数位置 | 参数名 | 必填 | 示例 | 说明 |
|---|---|---|---|---|
| args[0] | 私钥路径 | 是 | private_key.pem | 建议运行时输入,不推荐写死在程序里 |
| args[1] | 机器码 | 是 | 7F2A9C... | 客户机器输出的 32 位大写字符串 |
| args[2] | 授权天数 | 是 | 365 | 0 表示永久授权,内部存 9999-12-31 |
| args[3] | 客户名称 | 否 | 某自动化公司 | 写入 JSON,便于溯源 |
| args[4..] | 功能模块 | 否 | Motion Vision | 不传则所有模块可用 |
签发时多一个心眼:把客户名称、签发日期、机器码记录到一个本地脚本里,方便日后排查“哪个客户的 License 是哪一批发出去的”。很多 C# 软件做软件著作权登记时也要提供授权和版本说明,这份签发记录就是现成的证明材料。
4. 校验端:在 C# 软件启动流程里做一次完整验证
4.1 校验函数的输入输出设计
客户端校验最忌讳把验证逻辑散落在窗体加载事件里,然后到处引用一个bool _isLicensed标志。校验要收敛成一个独立静态类LicenseGuard,输入是 .lic 文件路径和公钥 PEM,输出是一个结果对象,包含是否通过、错误码和载荷信息。
错误码设计成我自己的这套,排查问题时省时间:
| 错误码 | 含义 | 出现场景 |
|---|---|---|
| Success | 校验通过 | 正常情况 |
| ErrorFileNotFound | 找不到 .lic 文件 | 首次运行未激活 |
| ErrorFormat | 文件格式错误 | .lic 文件被文本编辑器破坏 |
| ErrorSignature | 签名不匹配 | 文件被篡改或公钥不匹配 |
| ErrorMachine | 机器码不匹配 | License 拷到另一台机器 |
| ErrorExpired | 授权过期 | 当前日期超过有效日期 |
4.2 ValidateLicense 完整代码与参数说明
下面这段是可直接搬到上位机工程里的校验实现,目标框架 .NET 6+,注意ImportFromPem在 .NET 5 之前不可用,老工程需改用Rsa.FromXmlString或引入 BouncyCastle:
public static class LicenseGuard { public static (LicenseStatus Status, LicenseInfo Info, string Message) Validate( string licPath, string publicKeyPem, string currentMachineCode, bool enableClockSkewCheck = true) { if (!File.Exists(licPath)) return (LicenseStatus.ErrorFileNotFound, null, "缺少 License 文件"); // 1. 拆分载荷与签名 string[] parts = File.ReadAllText(licPath).Split('|'); if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) return (LicenseStatus.ErrorFormat, null, "License 文件格式错误"); string payloadBase64 = parts[0]; string signatureBase64 = parts[1]; // 2. 验签:签名通过,内容才可信 using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); byte[] payloadBytes = Encoding.UTF8.GetBytes(payloadBase64); byte[] signatureBytes = Convert.FromBase64String(signatureBase64); bool valid = rsa.VerifyData( payloadBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); if (!valid) return (LicenseStatus.ErrorSignature, null, "签名验证失败"); // 3. 反序列化并逐项检查 LicenseInfo info; try { string json = Encoding.UTF8.GetString(Convert.FromBase64String(payloadBase64)); info = JsonSerializer.Deserialize<LicenseInfo>(json); } catch { return (LicenseStatus.ErrorFormat, null, "授权内容解析失败"); } // 机器码归一化后比较 if (!string.Equals(info.MachineCode.Trim().ToUpperInvariant(), currentMachineCode.Trim().ToUpperInvariant(), StringComparison.Ordinal)) return (LicenseStatus.ErrorMachine, null, "授权未绑定当前设备"); // 有效期解析,使用不变区域设置避免本机日期格式干扰 if (!DateTime.TryParseExact(info.ExpireDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime expire)) return (LicenseStatus.ErrorFormat, null, "授权日期格式错误"); if (expire < DateTime.Now.Date) return (LicenseStatus.ErrorExpired, null, "授权已过期"); return (LicenseStatus.Success, info, "验证通过"); } } public enum LicenseStatus { Success, ErrorFileNotFound, ErrorFormat, ErrorSignature, ErrorMachine, ErrorExpired }逻辑说明:验签在反序列化之前,签名不过直接返回,后面的所有步骤都不执行,这是安全底线。机器码比较放在有效期之前,因为客户最常犯的错误是把 A 机器的 License 放到 B 机器上,先报机器码错误更友好。日期比较统一用DateTime.Now.Date,忽略时分秒,避免授权最后一天晚上 23:59 被误判。
enableClockSkewCheck参数在基础版里先预留,默认不开启,用来做时间回拨防护,后面第 5 章展开讲。
4.3 集成到上位机 / 桌面应用的三个位置
校验逻辑写好后,集成位置和方式决定整个方案是否好用。我一般在三个层面同时做:
第一层是启动入口。WinForms 在Program.Main里启动Application.Run前校验,校验失败直接弹提示并退出,或者进入降级模式。WPF 放在App.InitializeComponent之后。
第二层是核心业务模块的定时校验。上位机场景常见做法是程序运行期间每隔几十分钟重新读取一次 .lic 文件并校验,防止客户打开软件后再手动替换授权文件。WPF 可以用DispatcherTimer,WinForms 用System.Windows.Forms.Timer,间隔不要太频繁,30 到 60 分钟一次足够。
第三层是 C# 特性打标。给核心程序集加一个自定义 Attribute,例如[LicensedModule("Motion")],启动时扫描程序集特性,对照 LicenseInfo.Modules 判断功能是否启用。这样新加一个功能模块时只要打特性,不需要改动校验代码。
后台定时校验的代码框架:
var timer = new System.Threading.Timer(_ => { var result = LicenseGuard.Validate(licensePath, pubKey, machineCode); if (result.Status != LicenseStatus.Success) { // 不能在这里直接弹窗,UI 线程之外操作会抛异常 Application.Current.Dispatcher.Invoke(() => { MessageBox.Show("授权已失效,软件即将退出"); Application.Current.Shutdown(); }); } }, null, TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(30));注意在上位机里集成时,Application.Current.Dispatcher只适用于 WPF,WinForms 要改成Control.CheckForIllegalCrossThreadCalls = true;或用BeginInvoke切回 UI 线程。C# 里跨线程操作 UI 控件卡顿是高频问题,这里如果直接在新线程里MessageBox.Show会行为异常,必须显式切线程。
5. 验证手法与进阶:给自己做一轮“破解测试”
5.1 五分钟模拟篡改测试
License 代码写完,第一件事不是交给客户,而是先自己尝试各种破坏手段,确保每个分支都真正生效。我自己的验收流程如下:
打开 .lic 文件改一个字符,保存后再启动软件,预期是 ErrorSignature;把同一份 .lic 放到另一台机器,预期是 ErrorMachine;把系统日期手动改到一年后,预期是 ErrorExpired。前三条都能正确拦截,才算达到基本可用。
除了手改文件,还要测试异常文件格式:空文件、只有一行的文件、超过 10MB 的垃圾文件。常见坑是File.ReadAllText一次性读取超大文件导致内存暴涨,所以在读取前先检查文件大小,超过 1MB 直接返回格式错误:
var fileInfo = new FileInfo(licPath); if (fileInfo.Length > 1024 * 1024) return (LicenseStatus.ErrorFormat, null, "License 文件异常");5.2 时钟回拨的消耗策略
时间回拨是离线 License 最头疼的绕过手段,客户把系统日期调回授权期内就能无限续期。基础校验加上一段“上次运行时间记录”就能防住大部分情况。做法是软件正常运行时,定期把当前日期写入本地隐藏文件,校验时读取该文件里的日期,如果当前日期比记录日期提前超过一天,判定为时间回拨。
string lastRunPath = Path.Combine(AppContext.BaseDirectory, ".lastrun"); DateTime lastDate = DateTime.MinValue; if (File.Exists(lastRunPath) && DateTime.TryParseExact( File.ReadAllText(lastRunPath).Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) { lastDate = parsed; } if (DateTime.Now.Date < lastDate.AddDays(-1)) return (LicenseStatus.ErrorExpired, null, "检测到系统时间异常"); File.WriteAllText(lastRunPath, DateTime.Now.ToString("yyyy-MM-dd"));注意这个记录文件不能只存一个日期,否则删除文件就能绕过。我一般会把文件属性设为隐藏,再叠加一个简单的 HMAC,密钥从公钥派生。但要说清楚,这种防护上限不高,懂技术的用户仍能定位代码绕过,它的价值在于拦住 90% 愿意改时间但不愿分析程序的普通使用者。对高价值软件,最终防线是硬件加密锁或联网验证,超出本文范围。
5.3 常见失败现象对照表
我把这几年客户反馈最多的现象整理成一张排查表,开发时对照自查能省下大量来回沟通的时间:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 刚签发的 License 报签名不匹配 | 签发端使用的密钥与客户端公钥不是同一对 | 比对签发端导出公钥与客户端常量是否一致 |
| 同一台机器重装系统后机器码变化 | 机器码来源包含 MachineGuid 时,部分系统镜像会变 | 换成 ProcessorId + 主板序列号组合 |
| License 在笔记本上时而有效时而失效 | 笔记本睡眠唤醒后 WMI 读取部分硬件信息为空 | 机器码生成时加空值兜底,某字段为空就跳过 |
| 授权未到期但提示过期 | 日期解析使用了本地区域设置,客户电脑日期格式为 yyyy/MM/dd | 统一用 TryParseExact + yyyy-MM-dd + InvariantCulture |
| 程序启动后一段时间才弹授权错误 | 定时校验线程未切回 UI 线程 | 检查 Dispatcher.Invoke 或 BaeginInvoke |
最后的提醒是:License 只是商业化的第一道门,它决定“谁能在哪台机器上用到什么时候”,决定不了软件是否被反编译修改。C# 程序发布前至少加一层混淆,配合签名验证把能挡的路径都挡上,剩下的事情交给服务质量和法律条款。高级一点的玩法是把 License 文件内容与软件主程序集哈希绑定:校验时计算主程序集 SHA256,与 License 里存的程序集指纹比对,这样客户想替换主程序文件绕过校验也会失效。这个方法实现成本不高,对防止自带破解补丁的“热心网友”有实际效果。
本文还有配套的精品资源,点击获取