License Manager / 开发者中心 ← 返回管理后台

开发者中心

这里提供把你开发的软件接入授权服务的完整指引。无论你的软件运行在 能联网的客户机 还是 完全离线的内网机器, 无论你用 C# / WPF / WinForms / .NET 还是 Java 开发, 都可以按本文档在半小时内完成接入。

系统在做什么

授权服务只做两件事:保管授权码数据 和 签发不可伪造的凭证。 验证逻辑全部放在你的软件(客户端)里,服务端只负责回答「这个授权码现在是否有效」。 这样即使客户卸载了你的软件、格式化了硬盘,也拿不到任何可以绕过验证的东西—— 因为私钥只在服务端,客户端只内置 公钥。

两种接入方式

在线授权离线授权
适用场景 客户机能访问互联网 内网机、工地机、涉密机、长期断网
客户要提供 只有授权码 授权码 + 机器码(硬件指纹)
激活方式 软件自动请求 /api/check 你在后台签一段激活码发给客户,软件本地验签
是否依赖网络 每次启动都要(可配本地缓存 + 宽限期) 永久不依赖,只需联网一次同步吊销名单
可控性 停用、改期、换绑即时生效 常规手段收不回,用「吊销名单」锁
防篡改 服务端判定,客户端无从改起 ECDSA 签名覆盖到期时间/版本/功能位
工作量 最小(一个 HTTP 请求) 略大(验签约 60 行)
怎么选
拿不准就两个都接:在线失败时回退到离线激活码。 代码结构上只是「先试在线、失败了让客户贴激活码」。

名词对照

名词含义
授权码(Key)你卖给客户的一串字符,如 LIC-3ES3-PNAH-VPH8,在后台创建
机器码(HWID)客户机器的硬件指纹,用于把授权绑定到具体电脑
激活码离线授权凭证,格式 LAV1|授权码|机器码|生效|过期|版本|套餐|功能|签名
版本(tier)如 基础版 / 专业版 / 旗舰版,签进激活码,可定制不同功能
功能位(features)逗号分隔的功能开关,如 export,multi_open,客户端按位启用
吊销名单服务端维护的机器码黑名单,客户端联网时同步,离线也能锁

接入方式选择

按你的实际情况对号入座,不确定时选「在线 + 离线双通道」。

你的业务推荐接入方式
激活后定期检查是否到期在线激活 + 本地缓存
试用期,之后输入授权码激活在线试用
客户机器完全不能联网离线激活
工厂 / 政府内网部署离线激活 + 定期同步吊销名单
想同时覆盖两类客户在线优先、离线兜底(推荐)

接入步骤总览

  1. 在管理后台「授权管理」创建一条授权码,设好到期时间、可绑定机器数、版本、功能位
  2. 把本文档对应语言的代码粘进你的工程
  3. 把授权码填进软件激活窗口,验证通过
  4. (离线授权)客户把机器码发给你 → 你在「离线授权」页签激活码 → 客户粘贴激活

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 即可。

适用环境
.NET Framework 4.5+ / .NET 6+ / WPF / WinForms 通用。 机器码生成用了 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 };
    }
}

粘到工程里

不想自己动手?
用 「一键接入」生成一段给 AI Agent 的提示词, 你的 AI 会按本文档的代码把授权窗口自动装好(含统一授权窗口与防回拨)。
  1. 右键工程 → 添加 → 类 → 命名 LicenseGuard.cs → 粘贴上面全部代码
  2. 把 BASE 常量改成你的授权服务地址
  3. .NET Framework:引用里确认有 System.Management; .NET 6+:装 System.Management NuGet 包
  4. 需要 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);
    }
}

接入说明

不想自己动手?
用 「一键接入」生成 Java 版给 AI Agent 的提示词, AI 会把核心类和授权窗口一起装好。
  1. 依赖 Gson:Maven 加 com.google.code.gson:gson:2.10.1,或换成你项目里的 JSON 库
  2. machineId() 在 Windows 用 wmic 取硬件序列号; Linux 把命令换成 dmidecode -s system-uuid 等
  3. JavaFX 启动处:Status st = LicenseGuard.check(key); if (!st.ok) showActivationDialog(st.message);
跨平台机器码
如果客户同时有 Windows 和 Linux 版本,建议机器码统一取 「系统盘卷标 + 主板序列号 + 主机名」再 SHA-1,两端算法保持一致即可 (同一台机器两端算出同一个值最佳,但不强制)。

防系统时间回拨(必须加)

与 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
  }
}

响应(失败)

HTTPmessage含义与客户端处理
400Missing key or hwid参数缺失,检查拼写
403License has expired已过期 → 引导续期,不要用缓存兜底
403License is disabled被停用 → 提示联系卖家
403Hwid mismatch该码单独绑定了其它机器 → 换机需在后台解绑
403Device limit reached超出绑定机器数 → 去后台解绑旧机器
403This device has been revoked机器被吊销 → 联系卖家重新签发
404License not found授权码不存在(输错了)
429Too many requests限流,稍后重试
错误处理原则
服务端能连通时的拒绝是权威的,必须直接退出/禁用功能, 不能用本地缓存绕过。只有网络层异常(连接失败、超时)才允许走缓存与宽限期。

吊销名单同步(离线客户端)

GET /api/revoked   →   { "status": "success", "keys": ["机器码1", "机器码2"] }

离线激活的客户端应在每次启动时(失败静默)拉取一次,若本机机器码在名单里就锁定功能。 这就是「离线码也能收回」的机制。

离线激活码格式说明

激活码由服务端私钥签名,客户端用内置公钥验签。改任何一段都会验签失败。

格式(v2,9 段)

LAV1|授权码Key|机器码或*|生效unix秒|过期unix秒|版本号|套餐|功能列表|签名(base64url)
------
前 8 段明文    |    64 字节 ECDSA P-256 签名
段内容说明
1LAV1固定前缀
2授权码 Key对应后台创建的那条
3机器码 / *精确到机器;* 表示不绑机器(一般不签发)
4生效 unix 秒通常为签发时刻减 1 天(容忍时钟偏差)
5过期 unix 秒与授权码到期时间一致
62格式版本号
7版本 tier如 专业版,无则 -
8功能 features如 export,multi_open,无则 -
9签名覆盖前 8 段的 SHA-256 + ECDSA

为什么改不动

  • 把「过期 unix 秒」改大 → 前 8 段变了 → 签名对不上
  • 把「专业版」改成「旗舰版」→ 同上
  • 换一台机器粘贴 → 第 3 段机器码不匹配
  • 私钥只在服务端 KV 里,客户端只有公钥——没有私钥就造不出合法签名

你作为卖家要做的

  1. 客户把机器码(激活窗口里的「复制」按钮)发给你
  2. 后台 离线授权 → 选授权码 + 填机器码 → 生成激活码
  3. 把整串(含签名)发给客户,他粘贴到激活窗口「离线激活」即可
  4. 要作废:在「已签发清单」里吊销该机器码,客户机器联网拉一次名单即锁定
公钥从哪来
后台「离线授权」页 → 导出公钥 → 得到 SPKI Base64。 客户端代码里的 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
Javawmic 或 dmidecode 子进程取回后 SHA-1
Pythonplatform + wmic 子进程
C++WMI 查询同上,或 GetVolumeInformation

客户换电脑怎么办

  1. 客户在新机器上打开激活窗口,把新机器码发给你
  2. 在线授权:去后台「授权管理」把该码的在线绑定解绑(或提高机器数)
  3. 离线授权:去「离线授权」页对旧机器码点「解绑」,再给新机器签一枚激活码
  4. 名额会即时释放
虚拟机客户
如果客户用 VMware / Hyper-V,主板序列号可能是固定值。 他们的机器码可能装多台都一样——这是正常的,靠「机器数上限」控制总数即可。

常见问题

接入与运营过程中最常被问到的问题。

客户说没改任何东西,突然就不能用了?

按概率排查:① 到期了(最常见);② 后台把码停用了/删了; ③ 客户换了硬件导致机器码变化;④ 机器被吊销了。 后台「审计日志」能看到操作记录,「已签发清单」能看到每台机器的绑定时间。

授权码发出去多久生效?到期准确吗?

即时生效。到期判断同时发生在服务端(在线)和客户端(离线 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 天)。 超过宽限期未联网就会拒绝启动。注意两点:① 服务端明确拒绝(过期 / 停用 / 设备满) 不会走缓存兜底,只有网络层异常才兜底;② 从未成功激活过的首启不能离线用。