License API · v1
授权验证接口
为你的软件接入在线激活、设备数量限制和离线校验。请求用 HMAC 签名,响应用 Ed25519 签名,客户端无需信任网络也能确认结果来自你的服务器。
入门
快速开始
四步接入:在后台拿到凭证,把公钥内置进客户端,用户输入授权码时激活,之后每次启动校验。
开启授权验证
在后台「商品 → 授权设置」打开授权验证,设置设备上限和有效天数,记下 App ID 与 App Secret。内置公钥
复制本页「获取公钥」里的公钥,写进客户端代码,用来验证每一个响应。激活
用户输入授权码后调用 activate,把当前设备绑定到授权码上。校验
每次启动和运行中定时调用 verify。断网时,SDK 在宽限期内使用上一次的签名结果。
获取凭证
每个开启授权的商品都有一对独立的凭证:App ID 公开,用来标识应用;App Secret 用来给请求签名。所有接口的地址都以你的商城域名开头:
引入 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-Idstring必填应用 ID,如 app_7kq2m9xw4hnp。X-KV-Timestampinteger必填当前 Unix 时间(秒)。X-KV-Noncestring必填每次请求新生成的随机串,16–128 位,字符集A-Z a-z 0-9 _ -。推荐 16 字节随机数的 hex。X-KV-Signaturestring必填签名,64 位小写 hex,算法见下方。Content-Typestring可选application/json。请求体最大 16 KB。
签名串
签名串由时间戳、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。并发激活在授权行锁上排队,不会超绑。
请求参数
licenseKeystring必填用户输入的授权码,首尾空白会被忽略。deviceIdstring必填设备指纹,8–128 位可见 ASCII 字符。建议用 SDK 的getDeviceId(appId)生成,见设备指纹。deviceNamestring可选设备名称,最多 100 个字符。会显示在买家订单页和后台,方便辨认要解绑哪台设备。platformstring可选操作系统与版本,如 macOS 26.1、Windows 11。appVersionstring可选客户端版本号,最多 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 是本次结果可以离线使用的截止时间。
请求参数
licenseKeystring必填用户输入的授权码,首尾空白会被忽略。deviceIdstring必填设备指纹,8–128 位可见 ASCII 字符。建议用 SDK 的getDeviceId(appId)生成,见设备指纹。appVersionstring可选客户端版本号,最多 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。
请求参数
licenseKeystring必填用户输入的授权码,首尾空白会被忽略。deviceIdstring必填设备指纹,8–128 位可见 ASCII 字符。建议用 SDK 的getDeviceId(appId)生成,见设备指纹。
请求示例
kv deactivate '{"licenseKey":"DSP-7KQ2-M9XW-4HNP-R3TA","deviceId":"3f2a9c0d8e7b6a5f4e3d2c1b0a998877"}'{
"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{
"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.appIdstring必填应用 ID,客户端应核对与自己一致。data.deviceIdstring必填请求里的设备指纹,客户端应核对与本机一致。data.keyFingerprintstring必填SHA-256(licenseKey)的 hex,客户端核对它,确认响应属于用户输入的授权码。data.status"active" | "suspended" | "revoked" | "expired"必填授权状态。激活和校验成功时一定是 active;解绑时返回当前真实状态。data.activatedboolean必填当前设备是否处于激活状态,解绑后为 false。data.activatedAtinteger | null必填首次激活时间(Unix 秒),未激活过为 null。data.expiresAtinteger | null必填授权到期时间(Unix 秒),null 表示永久授权。data.validUntilinteger | null必填离线宽限截止时间 =min(expiresAt, signedAt + 72h)。设备未激活时为 null。data.maxDevicesinteger必填该授权码允许同时激活的设备数。data.activeDevicesinteger必填当前已激活的设备数(含本机)。signedAtinteger必填服务器签名时间(Unix 秒)。signaturestring必填Ed25519 签名的 base64,签名内容见下方。
规范化 JSON
签名覆盖的是对象 { 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}验签步骤
验证签名
用内置公钥校验signature,签名内容里的 nonce 用你发出的那个。核对绑定
确认data.appId、data.deviceId与本机一致,data.keyFingerprint等于 SHA-256(授权码)。处理错误
带有效签名的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,在此之前可以凭本地缓存的签名响应离线使用。
宽限窗口
宽限期为最近一次成功校验后的 72 小时,但不会超过授权的到期时间。客户端每次联网校验成功,窗口就向后顺延。
缓存与失效
SDK 把最近一次成功的签名响应(data、signedAt、nonce、signature)写入本地缓存文件。离线时重新验签,以下任一情况都会让缓存失效:
- 文件被修改,签名不再匹配。
deviceId或授权码指纹不同,例如把缓存拷贝到另一台电脑。- 当前时间晚于
validUntil,或早于signedAt超过 5 分钟(时钟被回拨)。 - 收到带签名的吊销类错误,或调用了 deactivate。
指南
错误码
失败时返回 { "ok": false, "error": { "code", "message" } } 和对应的 HTTP 状态码。message 是给用户看的中文说明,可以直接展示;程序里请按 code 判断。
INVALID_REQUEST400400缺少请求头、JSON 不合法或字段不符合要求
按 message 修正请求,属于集成问题
APP_NOT_FOUND401401appId 不存在,或商品未开启授权验证
核对 appId,并在后台为商品开启授权
INVALID_SIGNATURE401401HMAC 不匹配,常见于发送的 body 与签名的不是同一份
检查 appSecret 和签名串的拼接
TIMESTAMP_SKEW401401X-KV-Timestamp 与服务器时间相差超过 300 秒
提示用户校准系统时间
NONCE_REUSED40940910 分钟内重复使用了同一个 nonce
每次请求重新生成随机 nonce
RATE_LIMITED429429同一 IP 每分钟超过 120 次,或同一授权码超过 30 次
按 Retry-After 退避;可先用离线缓存
LICENSE_NOT_FOUND404404授权码不存在,或属于其他应用
提示检查授权码SDK 清除离线缓存
LICENSE_SUSPENDED403403管理员暂停了该授权码
锁定功能,引导联系客服SDK 清除离线缓存
LICENSE_REVOKED403403授权码已被吊销(退款、违规等)
锁定功能SDK 清除离线缓存
LICENSE_EXPIRED403403已超过 expiresAt
引导续费或购买新授权SDK 清除离线缓存
DEVICE_LIMIT_REACHED409409激活设备数已达 maxDevices
引导在旧设备上解绑,或在订单页自助解绑
DEVICE_NOT_ACTIVATED404404当前设备没有激活,或已被解绑
回到激活界面SDK 清除离线缓存
INTERNAL_ERROR500500服务器内部错误
稍后重试,期间使用离线缓存
指南
设备指纹
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 下载
三种语言的单文件客户端,协议完全一致,都实现了请求签名、响应验签、离线缓存和设备指纹。
资源
常见问题
App Secret 写在客户端里,会不会被提取?
为什么公钥不能在运行时从接口获取?
授权的有效期从什么时候开始算?
用户更换电脑怎么办?
deactivate(建议在软件里提供「注销授权」菜单),或让买家在商城的订单页自助解绑设备,然后在新电脑上重新激活。断网可以用多久?
check(),联网时会自动续期。收到 TIMESTAMP_SKEW 怎么办?
能在浏览器或网页里调用吗?
如何在开发环境测试?
.env 里设置 ENABLE_MOCK_PAYMENT=true,用「模拟支付」下单即可拿到授权码。项目里的 pnpm sdk:examples 会对本地服务器跑通三个 SDK 的完整流程。