License API · v1

授权验证接口

为你的软件接入在线激活、设备数量限制和离线校验。请求用 HMAC 签名,响应用 Ed25519 签名,客户端无需信任网络也能确认结果来自你的服务器。

入门

快速开始

四步接入:在后台拿到凭证,把公钥内置进客户端,用户输入授权码时激活,之后每次启动校验。

  1. 开启授权验证

    在后台「商品 → 授权设置」打开授权验证,设置设备上限和有效天数,记下 App ID 与 App Secret。
  2. 内置公钥

    复制本页「获取公钥」里的公钥,写进客户端代码,用来验证每一个响应。
  3. 激活

    用户输入授权码后调用 activate,把当前设备绑定到授权码上。
  4. 校验

    每次启动和运行中定时调用 verify。断网时,SDK 在宽限期内使用上一次的签名结果。

获取凭证

每个开启授权的商品都有一对独立的凭证:App ID 公开,用来标识应用;App Secret 用来给请求签名。所有接口的地址都以你的商城域名开头:

Base URL

引入 SDK

三种 SDK 都是单文件,下载后放进项目即可,不需要包管理器。

# 下载单文件 SDK(三选一)
curl -O https://shop.example.com/developers/sdk/keyvault.ts
curl -O https://shop.example.com/developers/sdk/keyvault.py
curl -O https://shop.example.com/developers/sdk/keyvault.go

激活与校验

推荐在客户端里使用 check():联网时等同于 verify,网络不可用、服务端 5xx 或被限流时回退到离线缓存。

import { KeyVaultClient, KeyVaultError, getDeviceId } from "./keyvault";

const APP_ID = "app_7kq2m9xw4hnp";
const kv = new KeyVaultClient({
  baseUrl: "https://shop.example.com",
  appId: APP_ID,
  appSecret: process.env.KV_APP_SECRET!,
  publicKey: "MCowBQYDK2VwAyEA…", // 下方「获取公钥」里复制,随客户端发布
});
const deviceId = getDeviceId(APP_ID);

// 1. 用户输入授权码后激活
await kv.activate({ licenseKey, deviceId, deviceName: os.hostname() });

// 2. 每次启动、以及运行中每隔几小时校验一次;断网时自动使用离线缓存
try {
  const state = await kv.check({ licenseKey, deviceId });
  unlockFeatures(state.expiresAt);
} catch (err) {
  if (err instanceof KeyVaultError) showActivationScreen(err.message);
}

入门

认证与请求签名

所有授权接口都是 POST + JSON,并在请求头里携带签名。服务器按 App ID 取出 App Secret 重新计算签名,用常量时间比较。

请求头

X-KV-App-Id

string必填

应用 ID,如 app_7kq2m9xw4hnp。
X-KV-Timestamp

integer必填

当前 Unix 时间(秒)。
X-KV-Nonce

string必填

每次请求新生成的随机串,16–128 位,字符集 A-Z a-z 0-9 _ -。推荐 16 字节随机数的 hex。
X-KV-Signature

string必填

签名,64 位小写 hex,算法见下方。
Content-Type

string可选

application/json。请求体最大 16 KB。

签名串

签名串的构造
1792641600
X-KV-Timestamp
\n
9f86d081884c7d65…
X-KV-Nonce
\n
{"licenseKey":"DSP-7KQ2-…","deviceId":"3f2a9c…"}
原始请求体(逐字节)
HMAC-SHA256,密钥为 appSecret
5d41402abc4b2a76b9719d911017c592e3b0c44298fc1c149afbf4c8996fb924
X-KV-Signature(64 位小写 hex)

签名串由时间戳、nonce 和原始请求体三部分组成,用换行符 \n 连接,再用 App Secret 做 HMAC-SHA256。请求体按实际发送的字节参与计算:先序列化成字符串,签名后原样发送,不要再次序列化。

计算签名

SDK 已经封装好签名,下面是不依赖 SDK 的实现,便于在其他语言中移植。

APP_ID="app_7kq2m9xw4hnp"
APP_SECRET="你的 App Secret"

# kv <接口名> <JSON 请求体>:签名并发送,后面的 cURL 示例都用它
kv() {
  local ts nonce sig
  ts=$(date +%s)
  nonce=$(openssl rand -hex 16)
  sig=$(printf '%s\n%s\n%s' "$ts" "$nonce" "$2" \
    | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $NF}')
  curl -s "https://shop.example.com/api/v1/licenses/$1" \
    -H "Content-Type: application/json" \
    -H "X-KV-App-Id: $APP_ID" \
    -H "X-KV-Timestamp: $ts" \
    -H "X-KV-Nonce: $nonce" \
    -H "X-KV-Signature: $sig" \
    --data-binary "$2"
}

时间戳与 Nonce

  • 服务器只接受与自身时间相差 ±300 秒以内的请求,否则返回 TIMESTAMP_SKEW。
  • 同一个 App ID 下,nonce 在 10 分钟内只能使用一次,重复使用返回 NONCE_REUSED。
  • nonce 还会被绑进响应签名:截获的旧响应无法被拿来回答新的请求。

接口

激活设备

POST /api/v1/licenses/activate

在当前设备上激活授权码。首次激活时写入 activatedAt,并按商品的有效天数计算 expiresAt;永久授权的 expiresAt 为 null。

  • 同一设备重复激活是幂等的:只刷新最后在线时间和设备信息,不占用新名额。
  • 已解绑的设备重新激活,需要重新占用一个名额。
  • 达到 maxDevices 时返回 DEVICE_LIMIT_REACHED。并发激活在授权行锁上排队,不会超绑。

请求参数

licenseKey

string必填

用户输入的授权码,首尾空白会被忽略。
deviceId

string必填

设备指纹,8–128 位可见 ASCII 字符。建议用 SDK 的 getDeviceId(appId) 生成,见设备指纹。
deviceName

string可选

设备名称,最多 100 个字符。会显示在买家订单页和后台,方便辨认要解绑哪台设备。
platform

string可选

操作系统与版本,如 macOS 26.1、Windows 11。
appVersion

string可选

客户端版本号,最多 50 个字符,后台可以据此统计版本分布。

请求示例

# kv 函数见「认证与请求签名 → 计算签名」
kv activate '{
  "licenseKey": "DSP-7KQ2-M9XW-4HNP-R3TA",
  "deviceId": "3f2a9c0d8e7b6a5f4e3d2c1b0a998877",
  "deviceName": "MacBook Pro",
  "platform": "macOS 26.1",
  "appVersion": "3.2.0"
}'

响应

{
  "ok": true,
  "data": {
    "action": "activate",
    "activated": true,
    "activatedAt": 1792641600,
    "activeDevices": 1,
    "appId": "app_7kq2m9xw4hnp",
    "deviceId": "3f2a9c0d8e7b6a5f4e3d2c1b0a998877",
    "expiresAt": 1824177600,
    "keyFingerprint": "9b74c9897bac770ffc029102a200c5de1f4f7b5c8c2e1d6f0a3b4c5d6e7f8091",
    "maxDevices": 2,
    "status": "active",
    "validUntil": 1792900800
  },
  "signedAt": 1792641600,
  "signature": "q8LxS1v0m2Yc3Gz7Jt5Wd9Rk4Hn6Bp2Ue8Af0Ci3Xo5Ls7Nq1Mv4Tz6Ry9Pw2Eb8Kj3Dh5Fg7Ua0Sc4Lz1Ow=="
}

接口

校验授权

POST /api/v1/licenses/verify

校验授权码在当前设备上是否有效,同时作为心跳刷新最后在线时间。建议在每次启动、以及运行中每 4–12 小时调用一次。返回的 validUntil 是本次结果可以离线使用的截止时间。

请求参数

licenseKey

string必填

用户输入的授权码,首尾空白会被忽略。
deviceId

string必填

设备指纹,8–128 位可见 ASCII 字符。建议用 SDK 的 getDeviceId(appId) 生成,见设备指纹。
appVersion

string可选

客户端版本号,最多 50 个字符,后台可以据此统计版本分布。

请求示例

kv verify '{"licenseKey":"DSP-7KQ2-M9XW-4HNP-R3TA","deviceId":"3f2a9c0d8e7b6a5f4e3d2c1b0a998877","appVersion":"3.2.0"}'

响应

{
  "ok": true,
  "data": {
    "action": "verify",
    "activated": true,
    "activatedAt": 1792641600,
    "activeDevices": 2,
    "appId": "app_7kq2m9xw4hnp",
    "deviceId": "3f2a9c0d8e7b6a5f4e3d2c1b0a998877",
    "expiresAt": 1824177600,
    "keyFingerprint": "9b74c9897bac770ffc029102a200c5de1f4f7b5c8c2e1d6f0a3b4c5d6e7f8091",
    "maxDevices": 2,
    "status": "active",
    "validUntil": 1792900800
  },
  "signedAt": 1792641600,
  "signature": "q8LxS1v0m2Yc3Gz7Jt5Wd9Rk4Hn6Bp2Ue8Af0Ci3Xo5Ls7Nq1Mv4Tz6Ry9Pw2Eb8Kj3Dh5Fg7Ua0Sc4Lz1Ow=="
}

接口

解绑设备

POST /api/v1/licenses/deactivate

解绑当前设备,释放一个名额。授权被暂停、吊销或过期时也可以解绑。买家也可以在商城订单页自助解绑,效果相同。成功响应里 activated 为 false,validUntil 为 null。

请求参数

licenseKey

string必填

用户输入的授权码,首尾空白会被忽略。
deviceId

string必填

设备指纹,8–128 位可见 ASCII 字符。建议用 SDK 的 getDeviceId(appId) 生成,见设备指纹。

请求示例

kv deactivate '{"licenseKey":"DSP-7KQ2-M9XW-4HNP-R3TA","deviceId":"3f2a9c0d8e7b6a5f4e3d2c1b0a998877"}'
200 成功
{
  "ok": true,
  "data": {
    "action": "deactivate",
    "activated": false,
    "activatedAt": 1792641600,
    "activeDevices": 1,
    "appId": "app_7kq2m9xw4hnp",
    "deviceId": "3f2a9c0d8e7b6a5f4e3d2c1b0a998877",
    "expiresAt": 1824177600,
    "keyFingerprint": "9b74c9897bac770ffc029102a200c5de1f4f7b5c8c2e1d6f0a3b4c5d6e7f8091",
    "maxDevices": 2,
    "status": "active",
    "validUntil": null
  },
  "signedAt": 1792641600,
  "signature": "q8LxS1v0m2Yc3Gz7Jt5Wd9Rk4Hn6Bp2Ue8Af0Ci3Xo5Ls7Nq1Mv4Tz6Ry9Pw2Eb8Kj3Dh5Fg7Ua0Sc4Lz1Ow=="
}

接口

获取公钥

GET /api/v1/public-key

公钥用于验证响应签名。发布客户端时把公钥写进代码,不要在运行时请求这个接口,否则中间人可以同时替换响应和公钥。

当前公钥

接口

无需签名,可以用来在部署后核对指纹。

请求
curl https://shop.example.com/api/v1/public-key
200 成功
{
  "ok": true,
  "data": {
    "algorithm": "Ed25519",
    "pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA…\n-----END PUBLIC KEY-----",
    "base64": "MCowBQYDK2VwAyEA…",
    "raw": "EVMGbdm+3WlkPPF/…",
    "fingerprint": "3e61a6ac40704b0b…"
  }
}

指南

响应验签

成功响应和通过认证之后的错误响应都带 Ed25519 签名。客户端用内置公钥验签,就能确认结果来自你的服务器、属于这一次请求、且没有被修改。

响应字段

data.action

"activate" | "verify" | "deactivate"必填

本次调用的接口。
data.appId

string必填

应用 ID,客户端应核对与自己一致。
data.deviceId

string必填

请求里的设备指纹,客户端应核对与本机一致。
data.keyFingerprint

string必填

SHA-256(licenseKey) 的 hex,客户端核对它,确认响应属于用户输入的授权码。
data.status

"active" | "suspended" | "revoked" | "expired"必填

授权状态。激活和校验成功时一定是 active;解绑时返回当前真实状态。
data.activated

boolean必填

当前设备是否处于激活状态,解绑后为 false。
data.activatedAt

integer | null必填

首次激活时间(Unix 秒),未激活过为 null。
data.expiresAt

integer | null必填

授权到期时间(Unix 秒),null 表示永久授权。
data.validUntil

integer | null必填

离线宽限截止时间 = min(expiresAt, signedAt + 72h)。设备未激活时为 null。
data.maxDevices

integer必填

该授权码允许同时激活的设备数。
data.activeDevices

integer必填

当前已激活的设备数(含本机)。
signedAt

integer必填

服务器签名时间(Unix 秒)。
signature

string必填

Ed25519 签名的 base64,签名内容见下方。

规范化 JSON

响应签名覆盖的内容
{"action":"verify",…}
响应里的 data
9f86d081884c7d65…
你发出的 X-KV-Nonce
1792641600
响应里的 signedAt
规范化 JSON:键按字典序、无空白
{"data":{…},"nonce":"9f86d081…","signedAt":1792641600}
待验签字符串
Ed25519 验签,公钥内置在客户端
通过:再核对 appId、deviceId、keyFingerprint
失败:视为被篡改,丢弃响应

签名覆盖的是对象 { data, nonce, signedAt } 的规范化 JSON:

  • 对象的键按字典序排列(递归到每一层),没有任何空白。
  • 字符串转义与 JavaScript 的 JSON.stringify 一致:只转义 "、\ 和控制字符,中文与 <>& 原样输出。
  • 数字都是整数,原样输出。nonce 是你这次请求自己生成的值,不在响应里。
待验签字符串示例
{"data":{"action":"verify","activated":true,"activatedAt":1792641600,"activeDevices":2,"appId":"app_7kq2m9xw4hnp","deviceId":"3f2a9c0d8e7b6a5f4e3d2c1b0a998877","expiresAt":1824177600,"keyFingerprint":"9b74c989…","maxDevices":2,"status":"active","validUntil":1792900800},"nonce":"9f86d081884c7d659a2feaa0c55ad015","signedAt":1792641600}

验签步骤

  1. 验证签名

    用内置公钥校验 signature,签名内容里的 nonce 用你发出的那个。
  2. 核对绑定

    确认 data.appId、data.deviceId 与本机一致,data.keyFingerprint 等于 SHA-256(授权码)。
  3. 处理错误

    带有效签名的 LICENSE_*、DEVICE_NOT_ACTIVATED 是服务器的明确结论,应清除离线缓存并锁定功能;没有签名的错误不能作为吊销依据。
import { createPublicKey, verify } from "node:crypto";

const publicKey = createPublicKey(PUBLIC_KEY_PEM); // 随客户端发布,不要运行时下载

function canonicalJson(v: unknown): string {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  const o = v as Record<string, unknown>;
  return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
}

const json = await res.json();
// nonce 用你这次请求自己生成的那个,而不是从响应里读
const payload = canonicalJson({ data: json.data, nonce, signedAt: json.signedAt });
const ok = verify(null, Buffer.from(payload), publicKey, Buffer.from(json.signature, "base64"));
if (!ok || json.data.deviceId !== deviceId) throw new Error("响应校验失败");

指南

离线宽限

客户端不必一直在线。每次成功校验都会得到 validUntil,在此之前可以凭本地缓存的签名响应离线使用。

宽限窗口

validUntil = min(expiresAt, signedAt + 72 小时)
到期时间还远最近一次校验后 72 小时
离线可用
signedAt
validUntil
expiresAt
72 小时内到期截止于授权到期
离线可用
signedAt
validUntil = expiresAt

宽限期为最近一次成功校验后的 72 小时,但不会超过授权的到期时间。客户端每次联网校验成功,窗口就向后顺延。

缓存与失效

SDK 把最近一次成功的签名响应(data、signedAt、nonce、signature)写入本地缓存文件。离线时重新验签,以下任一情况都会让缓存失效:

  • 文件被修改,签名不再匹配。
  • deviceId 或授权码指纹不同,例如把缓存拷贝到另一台电脑。
  • 当前时间晚于 validUntil,或早于 signedAt 超过 5 分钟(时钟被回拨)。
  • 收到带签名的吊销类错误,或调用了 deactivate。

指南

错误码

失败时返回 { "ok": false, "error": { "code", "message" } } 和对应的 HTTP 状态码。message 是给用户看的中文说明,可以直接展示;程序里请按 code 判断。

请求与认证
  • INVALID_REQUEST400

    缺少请求头、JSON 不合法或字段不符合要求

    按 message 修正请求,属于集成问题

  • APP_NOT_FOUND401

    appId 不存在,或商品未开启授权验证

    核对 appId,并在后台为商品开启授权

  • INVALID_SIGNATURE401

    HMAC 不匹配,常见于发送的 body 与签名的不是同一份

    检查 appSecret 和签名串的拼接

  • TIMESTAMP_SKEW401

    X-KV-Timestamp 与服务器时间相差超过 300 秒

    提示用户校准系统时间

  • NONCE_REUSED409

    10 分钟内重复使用了同一个 nonce

    每次请求重新生成随机 nonce

  • RATE_LIMITED429

    同一 IP 每分钟超过 120 次,或同一授权码超过 30 次

    按 Retry-After 退避;可先用离线缓存

授权状态
  • LICENSE_NOT_FOUND404

    授权码不存在,或属于其他应用

    提示检查授权码SDK 清除离线缓存

  • LICENSE_SUSPENDED403

    管理员暂停了该授权码

    锁定功能,引导联系客服SDK 清除离线缓存

  • LICENSE_REVOKED403

    授权码已被吊销(退款、违规等)

    锁定功能SDK 清除离线缓存

  • LICENSE_EXPIRED403

    已超过 expiresAt

    引导续费或购买新授权SDK 清除离线缓存

设备
  • DEVICE_LIMIT_REACHED409

    激活设备数已达 maxDevices

    引导在旧设备上解绑,或在订单页自助解绑

  • DEVICE_NOT_ACTIVATED404

    当前设备没有激活,或已被解绑

    回到激活界面SDK 清除离线缓存

服务端
  • INTERNAL_ERROR500

    服务器内部错误

    稍后重试,期间使用离线缓存

指南

设备指纹

deviceId 用来区分同一授权码下的不同设备。好的设备指纹既稳定,又不泄露用户隐私。

  • 稳定:重装软件、升级系统后保持不变。优先使用系统机器 ID,不要用 MAC 地址或 IP。
  • 不泄露隐私:上报前做 SHA-256,并以 App ID 作为盐,同一台机器在不同软件里得到不同的值。
  • 不要随机生成后只存在本地:用户清理数据后会变成「新设备」,很快占满名额。
  • 虚拟机与容器:克隆出来的虚拟机可能共用同一个 machine-id,制作镜像时应重新生成 /etc/machine-id。
import { getDeviceId } from "./keyvault";

// 读取系统机器 ID(macOS IOPlatformUUID / Windows MachineGuid / Linux machine-id),
// 与 appId 一起做 SHA-256:同一台机器在不同软件里得到不同的 ID
const deviceId = getDeviceId("app_7kq2m9xw4hnp");

资源

SDK 下载

三种语言的单文件客户端,协议完全一致,都实现了请求签名、响应验签、离线缓存和设备指纹。

  • Node.jsTypeScript · Node 18+零依赖

    node:crypto + fetch,Electron 主进程可直接使用

    keyvault.ts下载
  • PythonPython 3.8+cryptography

    HMAC 用标准库,Ed25519 验签用 cryptography

    keyvault.py下载
  • GoGo 1.21+零依赖

    只用标准库,附带规范化 JSON 实现

    keyvault.go下载

文件顶部有用法说明。项目源码的 sdk/ 目录还附带每种语言的 README 和完整示例(example.ts、example.py、example/main.go)。

资源

常见问题

App Secret 写在客户端里,会不会被提取?
会。任何随软件分发的密钥都可能被逆向提取,所以 App Secret 只用来挡住随意伪造的请求和滥用。真正的安全边界是 Ed25519 响应签名:私钥只在服务器上,破解者即使拿到 App Secret,也无法伪造「授权有效」的响应。客户端里把验签和功能解锁放在一起,并做好代码混淆。
为什么公钥不能在运行时从接口获取?
如果客户端信任从网络拿到的公钥,攻击者可以搭一个假服务器,同时返回自己的公钥和自己签名的响应。公钥必须在编译或打包时内置。
授权的有效期从什么时候开始算?
从首次激活开始。买家购买后没有激活,有效期不会流逝;管理员也可以在后台为单个授权码指定固定的到期时间,此时以指定的时间为准。
用户更换电脑怎么办?
在旧电脑上调用 deactivate(建议在软件里提供「注销授权」菜单),或让买家在商城的订单页自助解绑设备,然后在新电脑上重新激活。
断网可以用多久?
最近一次成功校验后最多 72 小时,且不超过授权到期时间。运行中每 4–12 小时调用一次 check(),联网时会自动续期。
收到 TIMESTAMP_SKEW 怎么办?
说明设备时间与服务器相差超过 5 分钟。提示用户开启系统的「自动设置时间」后重试。
能在浏览器或网页里调用吗?
不建议。接口没有开启 CORS,App Secret 也不应该出现在网页里。Electron 应用请在主进程里调用。
如何在开发环境测试?
在 .env 里设置 ENABLE_MOCK_PAYMENT=true,用「模拟支付」下单即可拿到授权码。项目里的 pnpm sdk:examples 会对本地服务器跑通三个 SDK 的完整流程。