开发者中心
这里提供把你开发的软件接入授权服务的完整指引。无论你的软件运行在 能联网的客户机 还是 完全离线的内网机器, 无论你用 C# / WPF / WinForms / .NET 还是 Java 开发, 都可以按本文档在半小时内完成接入。
系统在做什么
授权服务只做两件事:保管授权码数据 和 签发不可伪造的凭证。 验证逻辑全部放在你的软件(客户端)里,服务端只负责回答「这个授权码现在是否有效」。 这样即使客户卸载了你的软件、格式化了硬盘,也拿不到任何可以绕过验证的东西—— 因为私钥只在服务端,客户端只内置 公钥。
两种接入方式
| 在线授权 | 离线授权 | |
|---|---|---|
| 适用场景 | 客户机能访问互联网 | 内网机、工地机、涉密机、长期断网 |
| 客户要提供 | 只有授权码 | 授权码 + 机器码(硬件指纹) |
| 激活方式 | 软件自动请求 /api/check |
你在后台签一段激活码发给客户,软件本地验签 |
| 是否依赖网络 | 每次启动都要(可配本地缓存 + 宽限期) | 永久不依赖,只需联网一次同步吊销名单 |
| 可控性 | 停用、改期、换绑即时生效 | 常规手段收不回,用「吊销名单」锁 |
| 防篡改 | 服务端判定,客户端无从改起 | ECDSA 签名覆盖到期时间/版本/功能位 |
| 工作量 | 最小(一个 HTTP 请求) | 略大(验签约 60 行) |
名词对照
| 名词 | 含义 |
|---|---|
| 授权码(Key) | 你卖给客户的一串字符,如 LIC-3ES3-PNAH-VPH8,在后台创建 |
| 机器码(HWID) | 客户机器的硬件指纹,用于把授权绑定到具体电脑 |
| 激活码 | 离线授权凭证,格式 LAV1|授权码|机器码|生效|过期|版本|套餐|功能|签名 |
| 版本(tier) | 如 基础版 / 专业版 / 旗舰版,签进激活码,可定制不同功能 |
| 功能位(features) | 逗号分隔的功能开关,如 export,multi_open,客户端按位启用 |
| 吊销名单 | 服务端维护的机器码黑名单,客户端联网时同步,离线也能锁 |
接入方式选择
按你的实际情况对号入座,不确定时选「在线 + 离线双通道」。
| 你的业务 | 推荐接入方式 |
|---|---|
| 激活后定期检查是否到期 | 在线激活 + 本地缓存 |
| 试用期,之后输入授权码激活 | 在线试用 |
| 客户机器完全不能联网 | 离线激活 |
| 工厂 / 政府内网部署 | 离线激活 + 定期同步吊销名单 |
| 想同时覆盖两类客户 | 在线优先、离线兜底(推荐) |
接入步骤总览
- 在管理后台「授权管理」创建一条授权码,设好到期时间、可绑定机器数、版本、功能位
- 把本文档对应语言的代码粘进你的工程
- 把授权码填进软件激活窗口,验证通过
- (离线授权)客户把机器码发给你 → 你在「离线授权」页签激活码 → 客户粘贴激活
5 分钟快速上手
以 C# WPF 为例走一遍完整流程,Java 见后续章节,步骤完全一致。
第一步:创建授权码
登录管理后台 → 授权管理 → 填写表单:
- 授权码 Key:点「随机生成」,或填你自己的编号规则
- 过期时间:快捷选 1 年 / 10 年,或精确到分钟
- 最多可绑定机器数:1 = 一码一机;3 = 客户可装 3 台;0 = 不限
- 版本 / 功能开关:如
专业版/export,multi_open
第二步:客户端接入
新建 LicenseGuard.cs,粘贴 C# / WPF 在线接入 的完整代码。
第三步:启动时校验
在 App.xaml.cs 的 OnStartup 里调用:
// 首次启动弹出激活窗口,之后每次启动静默校验 var st = await LicenseGuard.CheckAsync(key); if (!st.Ok) { /* 弹激活窗,拿不到授权就退出 */ }
第四步:验收清单
| 测试项 | 期望结果 |
|---|---|
| 填正确授权码点激活 | 提示授权有效,显示到期时间与版本 |
| 过期时间改到昨天再启动 | 提示已过期,拒绝进入 |
| 后台停用该授权码后启动 | 提示已停用(在线方式即时生效) |
| 换一台电脑用同一个码 | 超出机器数上限时提示设备已满 |
| 断网启动 | 走本地缓存,宽限期内正常使用 |
C# / WPF 在线授权接入
最小接入只需要一个 HTTP 请求。把下面整个 LicenseGuard.cs 粘进工程,
在启动处调用 CheckAsync 即可。
System.Management(.NET Framework 自带引用;
.NET 6+ 需装 System.Management NuGet 包,或用文件末尾的降级方案)。
完整代码 LicenseGuard.cs
using System; using System.Globalization; using System.IO; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using System.Web.Script.Serialization; using System.Management; public class LicenseStatus { public bool Ok; // 是否通过校验 public string Message; // 给人看的提示 public string Tier; // 版本(基础版/专业版…) public string Features; // 功能位,逗号分隔 public string ExpireAt; // 到期时间 yyyy-MM-dd HH:mm } public static class LicenseGuard { // ↓↓↓ 改成你的授权服务地址(Cloudflare Pages 域名) const string BASE = "https://license-admin-2tz.pages.dev"; // 本地缓存:断网时按此放行;宽限期天数建议 3-7 const int GRACE_DAYS = 7; static readonly string CacheFile = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "license_cache.json"); /* ---------- 机器码:CPU + 主板 + 系统盘序列号 ---------- */ public static string MachineId() { try { var raw = Wmi("Win32_Processor", "ProcessorId") + "|" + Wmi("Win32_BaseBoard", "SerialNumber") + "|" + DriveSerial("C"); return Sha1(raw).Substring(0, 32); // 32 位十六进制 } catch { return "FALLBACK-" + Environment.MachineName.GetHashCode().ToString("X8"); } } static string Wmi(string cls, string field) { using (var s = new ManagementObjectSearcher("SELECT " + field + " FROM " + cls)) { foreach (ManagementObject o in s.Get()) return Convert.ToString(o[field]); } return ""; } static string DriveSerial(string drive) { using (var mo = new ManagementObject( "Win32_LogicalDisk='"+drive+":'")) { mo.Get(); return Convert.ToString(mo["VolumeSerialNumber"]) ?? ""; } } static string Sha1(string s) { var h = SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(s)); var sb = new StringBuilder(); foreach (var b in h) sb.Append(b.ToString("x2")); return sb.ToString(); } /* ---------- 在线校验:自动绑机 + 返回版本功能 ---------- */ public static async Task<LicenseStatus> CheckAsync(string key) { if (string.IsNullOrWhiteSpace(key)) return new LicenseStatus { Ok = false, Message = "请先输入授权码" }; var hwid = MachineId(); var url = BASE + "/api/check?key=" + Uri.EscapeDataString(key) + "&hwid=" + Uri.EscapeDataString(hwid); try { using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) }) using (var resp = await http.GetAsync(url)) { var body = await resp.Content.ReadAsStringAsync(); var j = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(body); var st = new LicenseStatus(); st.Ok = resp.IsSuccessStatusCode && (j.ContainsKey("status") && (string)j["status"] == "success"); st.Message = st.Ok ? "授权有效" : (j.ContainsKey("message") ? (string)j["message"] : "校验失败(" + (int)resp.StatusCode + ")"); // v2 接口在 data 里返回 tier/features/max_devices/bound_devices if (j.ContainsKey("data") && j["data"] is Dictionary<string, object> d) { if (d.ContainsKey("tier")) st.Tier = (string)d["tier"]; if (d.ContainsKey("features")) st.Features = (string)d["features"]; if (d.ContainsKey("expires_at")) st.ExpireAt = (string)d["expires_at"]; } if (st.Ok) SaveCache(key, st); // 成功:刷新缓存 else if (!NetworkDown(st.Message)) // 失败但服务可达:不要用缓存兜底 return st; return st; } } catch (Exception ex) // 网络异常:走本地缓存 { return FromCache(key, "网络不可用:" + ex.Message); } } // 服务明确拒绝(过期/停用/设备满)时不能用缓存放行 static bool NetworkDown(string msg) { return msg.Contains("网络") || msg.Contains("timeout") || msg.Contains("超时"); } /* ---------- 本地缓存(断网兜底) ---------- */ static void SaveCache(string key, LicenseStatus st) { try { File.WriteAllText(CacheFile, new JavaScriptSerializer().Serialize(new { key, st.Tier, st.Features, expire = st.ExpireAt, cachedAt = DateTime.UtcNow.ToString("O"), })); } catch { /* 只读目录等场景忽略 */ } } static LicenseStatus FromCache(string key, string reason) { try { if (!File.Exists(CacheFile)) return Deny(reason); var c = new JavaScriptSerializer() .Deserialize<Dictionary<string, object>>(File.ReadAllText(CacheFile)); if (!c.ContainsKey("key") || (string)c["key"] != key) return Deny(reason); var cachedAt = DateTime.Parse((string)c["cachedAt"], null, DateTimeStyles.AdjustToUniversal); if ((DateTime.UtcNow - cachedAt).TotalDays > GRACE_DAYS) return Deny("已离线超过 " + GRACE_DAYS + " 天,请联网激活"); DateTime exp; if (DateTime.TryParse(Convert.ToString(c["expire"]), out exp) && exp < DateTime.Now) return Deny("授权已过期"); return new LicenseStatus { Ok = true, Message = "离线缓存有效(剩余 " + (GRACE_DAYS - (DateTime.UtcNow - cachedAt).TotalDays).ToString("0") + " 天)", Tier = c.ContainsKey("Tier") ? Convert.ToString(c["Tier"]) : "", Features = c.ContainsKey("Features") ? Convert.ToString(c["Features"]) : "", ExpireAt = c.ContainsKey("expire") ? Convert.ToString(c["expire"]) : "", }; } catch { return Deny(reason); } } static LicenseStatus Deny(string why) { return new LicenseStatus { Ok = false, Message = why }; } }
粘到工程里
- 右键工程 → 添加 → 类 → 命名
LicenseGuard.cs→ 粘贴上面全部代码 - 把
BASE常量改成你的授权服务地址 - .NET Framework:引用里确认有
System.Management; .NET 6+:装System.ManagementNuGet 包 - 需要
System.Web.Extensions引用提供JavaScriptSerializer(.NET Framework 4.5+ 自带;.NET 6+ 改用System.Text.Json, 把JavaScriptSerializer换成JsonSerializer即可)
启动时调用
// App.xaml.cs protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); string key = Properties.Settings.Default.LicenseKey; var st = string.IsNullOrEmpty(key) ? new LicenseStatus { Ok = false, Message = "请输入授权码激活" } : await LicenseGuard.CheckAsync(key); if (!st.Ok) { var win = new ActivationWindow(key, st.Message); if (win.ShowDialog() != true) { Shutdown(); return; } Properties.Settings.Default.LicenseKey = win.ActivatedKey; Properties.Settings.Default.Save(); st = await LicenseGuard.CheckAsync(win.ActivatedKey); if (!st.Ok) { Shutdown(); return; } } MainWindow mw = new MainWindow(); mw.Title = (st.Tier ?? "") + " · 到期 " + st.ExpireAt; if ((st.Features ?? "").Contains("export")) ((MainWindow)mw).EnableExport(); // 按功能位解锁功能 mw.Show(); }
if (!st.Ok) return; 这种写法学者能一行字节码补丁绕过。
更稳的做法:用校验结果里的数据派生密钥,解密软件运行必需的配置——
校验不过解出来就是乱码,跳过判断也跑不起来。发布前用 ConfuserEx 混淆客户端。
防系统时间回拨(必须加)
默认实现以本机时钟判断到期,存在一个攻击路径:断网 → 把系统时间改回到一年前 → 宽限与到期判断全部被绕过,白用一年。防护办法是单调时钟: 每次成功校验时把当前 UTC 时间戳同时写入本地隐藏文件和注册表,取两者较大值作为 「历史最晚时刻」;之后一旦发现本机时间早于历史记录超过 1 天,直接判定回拨并拒绝。 改系统时间骗得过即时判断,骗不过上次记录的时间。
/* ============ 单调时钟:防断网 + 改系统时间 ============ */ static readonly string ClockFile = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "license_clock.dat"); const string REG_PATH = @"Software\LicenseGuard"; const long TOLERANCE_SEC = 86400; // 容忍 1 天(时钟同步误差) // 历史最晚时刻 = max(本地文件, 注册表),双锚点,改一个没用 static long ArchivedUtc() { long t = 0; try { if (File.Exists(ClockFile)) t = Math.Max(t, long.Parse(File.ReadAllText(ClockFile).Trim())); } catch { } try { using var k = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(REG_PATH); if (k != null && long.TryParse(Convert.ToString(k.GetValue("ck")), out var r)) t = Math.Max(t, r); } catch { } return t; } /// <returns>null = 正常;非 null = 拒绝原因(调用方据此 Deny)</returns> static string CheckClock() { long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); long arch = ArchivedUtc(); if (arch > 0 && now < arch - TOLERANCE_SEC) return "检测到系统时间被回拨,授权已锁定,请先更正系统时间"; if (now > arch) { try { File.WriteAllText(ClockFile, now.ToString()); File.SetAttributes(ClockFile, FileAttributes.Hidden); } catch { } try { using var rk = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(REG_PATH); rk.SetValue("ck", now.ToString()); } catch { } } return null; }
然后在两处入口最前面调用它:
// ① 在线校验入口(断网走缓存之前) var rollback = CheckClock(); if (rollback != null) return Deny(rollback); // ② 离线激活码验签入口(同样放在最前面) var rollback2 = CheckClock(); if (rollback2 != null) return r; // r.Message 已由调用方填充
Java 在线授权接入
JDK 11+ 自带 java.net.http.HttpClient,无需第三方库。
Swing / JavaFX 通用。
完整代码 LicenseGuard.java
import java.net.URI; import java.net.http.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.security.MessageDigest; import java.time.*; import java.util.*; import com.google.gson.*; public class LicenseGuard { // ↓↓↓ 改成你的授权服务地址 private static final String BASE = "https://license-admin-2tz.pages.dev"; private static final int GRACE_DAYS = 7; private static final Path CACHE = Paths.get( System.getProperty("user.home"), ".license_cache.json"); private static final HttpClient HTTP = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(8)).build(); private static final Gson GSON = new Gson(); public static class Status { public boolean ok; public String message, tier, features, expireAt; } /* ---------- 机器码:CPU 序列号 + 主板 + 系统盘 ---------- */ public static String machineId() { try { String raw = exec("wmic cpu get processorid") + exec("wmic baseboard get serialnumber") + exec("cmd /c vol C:"); return sha1(raw).substring(0, 32); } catch (Exception e) { return "FALLBACK-" + Integer.toHexString( System.getProperty("user.name").hashCode()); } } private static String exec(String cmd) throws Exception { Process p = new ProcessBuilder("cmd", "/c", cmd) .redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); p.waitFor(); return out; } private static String sha1(String s) throws Exception { MessageDigest md = MessageDigest.getInstance("SHA-1"); StringBuilder sb = new StringBuilder(); for (byte b : md.digest(s.getBytes(StandardCharsets.UTF_8))) sb.append(String.format("%02x", b)); return sb.toString(); } /* ---------- 在线校验 ---------- */ public static Status check(String key) { if (key == null || key.isBlank()) return new Status() {{ ok = false; message = "请先输入授权码"; }}; String url = BASE + "/api/check?key=" + enc(key) + "&hwid=" + enc(machineId()); try { HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); JsonObject j = JsonParser.parseString(resp.body()).getAsJsonObject(); Status st = new Status(); st.ok = resp.statusCode() == 200 && j.has("status") && "success".equals(j.get("status").getAsString()); st.message = st.ok ? "授权有效" : (j.has("message") ? j.get("message").getAsString() : "校验失败(" + resp.statusCode() + ")"); if (j.has("data")) { JsonObject d = j.getAsJsonObject("data"); if (d.has("tier")) st.tier = d.get("tier").getAsString(); if (d.has("features")) st.features = d.get("features").getAsString(); if (d.has("expires_at")) st.expireAt = d.get("expires_at").getAsString(); } if (st.ok) saveCache(key, st); else if (!isNetworkIssue(st.message)) return st; // 服务明确拒绝 return st; } catch (Exception ex) { // 网络异常 → 本地缓存 return fromCache(key, "网络不可用:" + ex.getMessage()); } } private static boolean isNetworkIssue(String msg) { return msg != null && (msg.contains("网络") || msg.contains("timeout") || msg.contains("超时")); } /* ---------- 本地缓存 ---------- */ private static void saveCache(String key, Status st) { try { Map<String, String> c = new LinkedHashMap<>(); c.put("key", key); c.put("tier", nz(st.tier)); c.put("features", nz(st.features)); c.put("expire", nz(st.expireAt)); c.put("cachedAt", Instant.now().toString()); Files.writeString(CACHE, GSON.toJson(c)); } catch (Exception ignored) {} } private static Status fromCache(String key, String reason) { try { if (!Files.exists(CACHE)) return deny(reason); JsonObject c = JsonParser.parseString( Files.readString(CACHE)).getAsJsonObject(); if (!c.has("key") || !c.get("key").getAsString().equals(key)) return deny(reason); Instant cachedAt = Instant.parse(c.get("cachedAt").getAsString()); if (Duration.between(cachedAt, Instant.now()).toDays() > GRACE_DAYS) return deny("已离线超过 " + GRACE_DAYS + " 天,请联网激活"); if (c.has("expire")) { LocalDateTime exp = LocalDateTime.parse( c.get("expire").getAsString().replace(' ', 'T')); if (exp.isBefore(LocalDateTime.now())) return deny("授权已过期"); } Status st = new Status(); st.ok = true; st.message = "离线缓存有效"; st.tier = c.has("tier") ? c.get("tier").getAsString() : ""; st.features = c.has("features") ? c.get("features").getAsString() : ""; st.expireAt = c.has("expire") ? c.get("expire").getAsString() : ""; return st; } catch (Exception e) { return deny(reason); } } private static Status deny(String why) { return new Status() {{ ok = false; message = why; }}; } private static String nz(String s) { return s == null ? "" : s; } private static String enc(String s) { return java.net.URLEncoder.encode(s, StandardCharsets.UTF_8); } }
接入说明
- 依赖 Gson:Maven 加
com.google.code.gson:gson:2.10.1,或换成你项目里的 JSON 库 machineId()在 Windows 用wmic取硬件序列号; Linux 把命令换成dmidecode -s system-uuid等- JavaFX 启动处:
Status st = LicenseGuard.check(key); if (!st.ok) showActivationDialog(st.message);
防系统时间回拨(必须加)
与 C# 同理:本机时钟可被攻击者改回过去,从而让宽限期和到期判断失效。
用单调时钟防护——每次成功校验把 UTC 时间戳写入本地文件和
Preferences(Windows 上即注册表),取较大值作为历史最晚时刻,
发现本机时间早于历史记录超过 1 天即拒绝。
/* ============ 单调时钟:防断网 + 改系统时间 ============ */ import java.util.prefs.Preferences; // 文件顶部一并导入 private static final Path CLOCK_FILE = Paths.get( System.getProperty("user.home"), ".license_guard_clock"); private static final String PREF_NODE = "/com/yourcompany/licenseguard"; private static final long TOLERANCE_SEC = 86400L; private static long archivedUtc() { long t = 0; try { if (Files.exists(CLOCK_FILE)) t = Math.max(t, Long.parseLong(Files.readString(CLOCK_FILE).trim())); } catch (Exception ignore) {} try { t = Math.max(t, Preferences.userRoot().node(PREF_NODE).getLong("ck", 0L)); } catch (Exception ignore) {} return t; } /** 返回 null = 正常;非 null = 拒绝原因 */ private static String checkClock() { long now = Instant.now().getEpochSecond(); long arch = archivedUtc(); if (arch > 0 && now < arch - TOLERANCE_SEC) return "检测到系统时间被回拨,授权已锁定,请先更正系统时间"; if (now > arch) { try { Files.writeString(CLOCK_FILE, Long.toString(now)); } catch (Exception ignore) {} try { Preferences.userRoot().node(PREF_NODE).putLong("ck", now); } catch (Exception ignore) {} } return null; }
// 在 check(key) 最前面与 verify(...) 最前面各加: String rb = checkClock(); if (rb != null) return new Status() {{ ok = false; message = rb; }};
在线校验 API 参考
客户端唯一需要调用的接口。GET 请求,无需认证,按 IP 限流。
请求
GET /api/check?key=<授权码>&hwid=<机器码>
| 参数 | 必填 | 说明 |
|---|---|---|
key | 是 | 授权码,URL 编码 |
hwid | 是 | 机器码(硬件指纹)。首台自动绑定;已绑机器刷新心跳 |
响应(成功)
{
"status": "success",
"message": "License is valid",
"data": {
"key": "LIC-3ES3-PNAH-VPH8",
"expires_at": "2027-06-30 23:59",
"tier": "专业版",
"features": "export,multi_open",
"max_devices": 1,
"bound_devices": 1
}
}
响应(失败)
| HTTP | message | 含义与客户端处理 |
|---|---|---|
| 400 | Missing key or hwid | 参数缺失,检查拼写 |
| 403 | License has expired | 已过期 → 引导续期,不要用缓存兜底 |
| 403 | License is disabled | 被停用 → 提示联系卖家 |
| 403 | Hwid mismatch | 该码单独绑定了其它机器 → 换机需在后台解绑 |
| 403 | Device limit reached | 超出绑定机器数 → 去后台解绑旧机器 |
| 403 | This device has been revoked | 机器被吊销 → 联系卖家重新签发 |
| 404 | License not found | 授权码不存在(输错了) |
| 429 | Too many requests | 限流,稍后重试 |
吊销名单同步(离线客户端)
GET /api/revoked → { "status": "success", "keys": ["机器码1", "机器码2"] }
离线激活的客户端应在每次启动时(失败静默)拉取一次,若本机机器码在名单里就锁定功能。 这就是「离线码也能收回」的机制。
离线激活码格式说明
激活码由服务端私钥签名,客户端用内置公钥验签。改任何一段都会验签失败。
格式(v2,9 段)
LAV1|授权码Key|机器码或*|生效unix秒|过期unix秒|版本号|套餐|功能列表|签名(base64url)
------
前 8 段明文 | 64 字节 ECDSA P-256 签名
| 段 | 内容 | 说明 |
|---|---|---|
| 1 | LAV1 | 固定前缀 |
| 2 | 授权码 Key | 对应后台创建的那条 |
| 3 | 机器码 / * | 精确到机器;* 表示不绑机器(一般不签发) |
| 4 | 生效 unix 秒 | 通常为签发时刻减 1 天(容忍时钟偏差) |
| 5 | 过期 unix 秒 | 与授权码到期时间一致 |
| 6 | 2 | 格式版本号 |
| 7 | 版本 tier | 如 专业版,无则 - |
| 8 | 功能 features | 如 export,multi_open,无则 - |
| 9 | 签名 | 覆盖前 8 段的 SHA-256 + ECDSA |
为什么改不动
- 把「过期 unix 秒」改大 → 前 8 段变了 → 签名对不上
- 把「专业版」改成「旗舰版」→ 同上
- 换一台机器粘贴 → 第 3 段机器码不匹配
- 私钥只在服务端 KV 里,客户端只有公钥——没有私钥就造不出合法签名
你作为卖家要做的
- 客户把机器码(激活窗口里的「复制」按钮)发给你
- 后台 离线授权 → 选授权码 + 填机器码 → 生成激活码
- 把整串(含签名)发给客户,他粘贴到激活窗口「离线激活」即可
- 要作废:在「已签发清单」里吊销该机器码,客户机器联网拉一次名单即锁定
SPKI_B64 常量就填这个值(P-256 曲线公钥的
DER 长度固定 91 字符)。
C# / WPF 离线授权接入
只用 .NET 自带类库完成 ECDSA 验签,兼容 .NET Framework 4.8 与 .NET 6+。
把下面代码追加到 LicenseGuard.cs(在线接入那份)里。
验签核心(约 60 行)
/* ================= 离线激活码验签 ================= */ // 后台「离线授权」页导出的公钥(SPKI Base64) const string SPKI_B64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE……粘这里……"; public class OfflineResult { public bool Ok; public string Message = ""; public string Tier = ""; public string Features = ""; } public static OfflineResult VerifyOffline(string code, string myHwid) { var r = new OfflineResult(); // 0) 时间回拨检查(与在线接入的 CheckClock 同一实现,见「C# / WPF 在线接入」末节) var rollback = CheckClock(); if (rollback != null) { r.Message = rollback; return r; } var p = (code ?? "").Trim().Split('|'); if (p.Length != 9 || p[0] != "LAV1" || p[5] != "2") { r.Message = "激活码格式不正确(需要 v2 九段格式)"; return r; } // 1) 验签:前 8 段拼回 payload var payload = string.Join("|", p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7]); byte[] sig; try { sig = Base64UrlDecode(p[8]); using var ecdsa = ECDsa.Create(SpkiToParameters(SPKI_B64)); if (!ecdsa.VerifyData(Encoding.UTF8.GetBytes(payload), sig, HashAlgorithmName.SHA256)) { r.Message = "激活码无效(签名校验失败,可能被篡改)"; return r; } } catch (Exception ex) { r.Message = "验签异常:" + ex.Message; return r; } // 2) 机器码匹配 if (p[2] != "*" && p[2] != myHwid) { r.Message = "该激活码绑定的是另一台机器"; return r; } // 3) 时间窗(unix 秒) long notBefore = long.Parse(p[3]); long expire = long.Parse(p[4]); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (now < notBefore) { r.Message = "激活码还未到生效时间"; return r; } if (now > expire) { r.Message = "授权已于 " + DateTimeOffset.FromUnixTimeSeconds(expire).ToOffset(TimeSpan.FromHours(8)) .ToString("yyyy-MM-dd") + " 到期"; return r; } // 4) 版本 / 功能位 r.Tier = p[6] == "-" ? "" : p[6]; r.Features = p[7] == "-" ? "" : p[7]; r.Ok = true; r.Message = "离线授权有效,到期 " + DateTimeOffset.FromUnixTimeSeconds(expire).ToOffset(TimeSpan.FromHours(8)) .ToString("yyyy-MM-dd"); return r; } /* SPKI base64 → ECParameters(不依赖 ImportSubjectPublicKeyInfo, 这样 .NET Framework 4.8 也能用) */ static ECParameters SpkiToParameters(string b64) { var der = Convert.FromBase64String(b64); int i = 27; // P-256 SPKI 固定偏移 var prm = new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = der[i..(i + 32)], Y = der[(i + 32)..(i + 64)], }, }; return prm; } /* base64url(- _ 无填充)→ 标准 base64 */ static byte[] Base64UrlDecode(string s) { s = s.Replace('-', '+').Replace('_', '/'); switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; } return Convert.FromBase64String(s); }
活动窗口里怎么用
// 激活窗口「离线激活」按钮点击事件 private void OnOfflineActivate(object sender, RoutedEventArgs e) { var r = LicenseGuard.VerifyOffline(txtCode.Text, LicenseGuard.MachineId()); if (r.Ok) { MessageBox.Show(r.Message + "\n版本:" + r.Tier + "\n功能:" + r.Features, "激活成功"); DialogResult = true; Close(); } else MessageBox.Show(r.Message, "激活失败", MessageBoxButton.OK, MessageBoxImage.Warning); }
/api/revoked,机器码在名单里就锁定;
② 把激活码和验签结果存到本地,下次启动直接复验,
不要再让客户反复粘贴。
Java 离线授权接入
JDK 自带完整 JCA 接口,验签比 C# 还简单——SPKI 公钥可直接
X509EncodedKeySpec 导入。
验签核心
import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.X509EncodedKeySpec; import java.time.*; import java.util.Base64; public class OfflineGuard { // 后台「离线授权」页导出的公钥(SPKI Base64,P-256) private static final String SPKI_B64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE……粘这里……"; public static class Result { public boolean ok; public String message = "", tier = "", features = ""; } public static Result verify(String code, String myHwid) throws Exception { Result r = new Result(); // 0) 时间回拨检查(与 Java 在线接入的 checkClock 同一实现,见「Java 在线接入」末节) String rb = checkClock(); if (rb != null) { r.message = rb; return r; } String[] p = code.trim().split("\\|", -1); if (p.length != 9 || !"LAV1".equals(p[0]) || !"2".equals(p[5])) { r.message = "激活码格式不正确(需要 v2 九段格式)"; return r; } // 1) 验签:前 8 段拼 payload,SHA256withECDSA String payload = String.join("|", p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7]); byte[] spki = Base64.getDecoder().decode(SPKI_B64); PublicKey pub = KeyFactory.getInstance("EC") .generatePublic(new X509EncodedKeySpec(spki)); Signature sig = Signature.getInstance("SHA256withECDSA"); sig.initVerify(pub); sig.update(payload.getBytes(StandardCharsets.UTF_8)); if (!sig.verify(b64urlDecode(p[8]))) { r.message = "激活码无效(签名校验失败,可能被篡改)"; return r; } // 2) 机器码匹配 if (!"*".equals(p[2]) && !p[2].equals(myHwid)) { r.message = "该激活码绑定的是另一台机器"; return r; } // 3) 时间窗(unix 秒) long notBefore = Long.parseLong(p[3]); long expire = Long.parseLong(p[4]); long now = Instant.now().getEpochSecond(); if (now < notBefore) { r.message = "激活码还未到生效时间"; return r; } if (now > expire) { r.message = "授权已于 " + fmt(expire) + " 到期"; return r; } // 4) 版本 / 功能位 r.tier = "-".equals(p[6]) ? "" : p[6]; r.features = "-".equals(p[7]) ? "" : p[7]; r.ok = true; r.message = "离线授权有效,到期 " + fmt(expire); return r; } private static String fmt(long unixSec) { return LocalDateTime.ofInstant(Instant.ofEpochSecond(unixSec), ZoneOffset.ofHours(8)).format( DateTimeFormatter.ofPattern("yyyy-MM-dd")); } /* base64url → 字节 */ private static byte[] b64urlDecode(String s) { s = s.replace('-', '+').replace('_', '/'); switch (s.length() % 4) { case 2: s += "=="; break; case 3: s += "="; break; } return Base64.getDecoder().decode(s); } }
启动时同步吊销名单
// 可选:让离线客户端联网时同步黑名单 public static boolean isRevoked(String myHwid) { try { HttpRequest req = HttpRequest.newBuilder(URI.create( "https://license-admin-2tz.pages.dev/api/revoked")).GET().build(); HttpResponse<String> resp = HttpClient.newHttpClient() .send(req, HttpResponse.BodyHandlers.ofString()); return resp.body().contains("\"" + myHwid + "\""); } catch (Exception e) { return false; // 拉不到就不管,不阻塞启动 } }
硬件指纹(机器码)策略
机器码是把授权绑到「一台电脑」的锚点。太敏感(如含网卡 MAC)客户换网卡就失绑, 太宽松(如主机名)客户克隆一下就绕过。下面是实践下来比较稳的组成。
推荐组成
| 来源 | 说明 |
|---|---|
| CPU 序列号 | 几乎不会变,换机必然变 |
| 主板序列号 | 同上,物理更换主板才会变 |
| 系统盘卷标 | 防虚拟机克隆时各盘一模一样 |
| 主机名(兜底) | 上面取不到时用,允许误判 |
拼接后取 SHA-1 前 32 位十六进制作为对外展示的机器码—— 既固定长度,又不向客户暴露具体硬件型号。
各语言取法
| 语言 | 做法 |
|---|---|
| C# | System.Management 查 Win32_Processor / Win32_BaseBoard / Win32_LogicalDisk |
| Java | wmic 或 dmidecode 子进程取回后 SHA-1 |
| Python | platform + wmic 子进程 |
| C++ | WMI 查询同上,或 GetVolumeInformation |
客户换电脑怎么办
- 客户在新机器上打开激活窗口,把新机器码发给你
- 在线授权:去后台「授权管理」把该码的在线绑定解绑(或提高机器数)
- 离线授权:去「离线授权」页对旧机器码点「解绑」,再给新机器签一枚激活码
- 名额会即时释放
常见问题
接入与运营过程中最常被问到的问题。
客户说没改任何东西,突然就不能用了?
按概率排查:① 到期了(最常见);② 后台把码停用了/删了; ③ 客户换了硬件导致机器码变化;④ 机器被吊销了。 后台「审计日志」能看到操作记录,「已签发清单」能看到每台机器的绑定时间。
授权码发出去多久生效?到期准确吗?
即时生效。到期判断同时发生在服务端(在线)和客户端(离线 cache + 激活码 unix 秒), 两边都以东八区时间为准,不会提前或延后 8 小时。
一码多机怎么控?
授权码的「最多可绑定机器数」。在线方式新机器自动绑定直到上限; 离线方式每个机器要单独签一枚激活码,同样计数。0 表示不限。
离线客户永远不联网,我能收回授权吗?
收不回。所以别给长期不联网的客户签太长有效期—— 按年签,每年让他联网同步一次续签,是业内通用做法。
版本 / 功能位改了,客户要重新激活吗?
在线授权:下次启动自动生效,无需操作。 离线授权:要重新签一枚激活码发过去(签名里含版本功能,旧的作废)。
HTTPS 证书 / 域名会变吗?
服务地址是固定的 Cloudflare Pages 域名。你的客户端代码里 BASE 常量写死它即可, 如果域名有变,发新版本软件时改常量。
接口被不停地扫,有防护吗?
有:/api/check 与 /api/query 都按 IP 限流,
连续爆破授权码会触发 429。管理后台登录自带失败锁定(5 次锁 15 分钟)。
我是纯小白,软件是 AI 写的,怎么最快接入?
用管理后台的 「一键接入」工具:选好语言(WPF 选 C#), 点「生成提示词」,把生成的一段话术原样发给你写软件的那个 AI(Cursor / Claude / ChatGPT 均可)。 话术里已包含全部源码(授权核心 + 统一授权窗口 + 启动接线)、配套修改清单和 7 条验收标准, AI 会自己把授权窗口装进你的软件,你只需要最后把授权码填进去。
客户断网后把系统时间改回一年前,能白用吗?
不能——前提是你按文档接入了「单调时钟」防护。默认的示例代码以本机时钟判断到期, 确实存在这个漏洞:断网 → 把系统时间改回到签发前 → 宽限期与到期判断全部失效。 文档在「C# / WPF 在线接入」和「Java 在线接入」末尾都给出了防护实现: 每次成功校验时把 UTC 时间戳写入本地隐藏文件 + 注册表(双锚点,取较大值), 之后发现本机时间早于历史记录超过 1 天即判定回拨并锁定。改系统时间骗得过即时判断, 骗不过上次记录的时间。请务必把这段加进客户端。
断网后还能用多久?
在线授权采用「本地缓存 + 宽限期」兜底:每次成功联网校验都会刷新本地缓存,
断网后按缓存放行,宽限期默认 7 天(代码里 GRACE_DAYS 可调,建议 3-7 天)。
超过宽限期未联网就会拒绝启动。注意两点:① 服务端明确拒绝(过期 / 停用 / 设备满)
不会走缓存兜底,只有网络层异常才兜底;② 从未成功激活过的首启不能离线用。
