将服务地址、appId、协议版本与核对过的服务 ES256 签名公钥固定进正式构建。
PROTOCOL V2 · INTEGRATION GUIDE
通用客户端授权
接入文档
面向 Windows、macOS、Linux 和 Electron 客户端的完整实施指南。涵盖信任建立、加密协议、授权生命周期、错误处理与上线验收。
01 / QUICK START
新客户端快速接入
下面是一次真实接入从安装到持续使用的完整闭环。先把客户端能力准备好,再让公开密钥、挑战、激活和凭证校验按固定顺序发生;私钥与完整授权码始终不离开受保护的本机环境。
客户端先集成这四项
签名私钥只用于 ES256 请求证明;加密私钥只用于 ECDH 解密发给本机的 JWE,均存入系统凭据库。
负责生成随机数、导出公开 JWK、构造 JWE 密文及对精确方法与路径签发 ES256 proof。
集中保存已验签凭证、离线截止时间与功能开关;界面和功能都只读取这一份状态。
| 配置 | 示例 | 要求 |
|---|---|---|
| 服务地址 | https://license.example.com | 生产环境只允许 HTTPS |
| 应用编号 | my-desktop-app | 小写字母、数字和连字符,发布后稳定 |
| 协议版本 | 2 | 拒绝未知版本 |
| 服务签名公钥 | P-256 Public JWK | 构建时固定,运行时不得静默替换 |
从首次运行到换机:谁做什么
- 01客户端:固定信任并检查连通性
调用
GET /health确认服务存活;读取GET /v2/keys后只与发布时固定的服务签名公钥人工核对,不能静默覆盖信任锚。 - 02客户端:生成两把设备密钥,上传公开 JWK
首次安装生成一把 ES256 签名密钥和一把 ECDH 加密密钥;保留私钥在本机密钥库,向
POST /v2/activation/challenge提交 appId、随机数和两份公开 JWK。 - 03客户端:验证挑战,再提交激活
验证服务 ES256 签名、kid、appId、随机数与过期时间。随后把完整授权码放进 JWE 内层,对密文摘要、方法与精确路径生成设备 ES256 proof,调用
POST /v2/activation/complete。 - 04客户端:验签保存,心跳或停用
解密并验证授权凭证后,仅保存 deviceHandle、脱敏授权码、已验签凭证和离线截止时间。用
POST /v2/devices/heartbeat刷新;换机前调用POST /v2/devices/deactivate。
Electron / Node.js 接入骨架
以下代码演示客户端应该准备什么。它刻意只给出密钥生成、公开数据和状态保存的骨架;JWE、JWS 的精确编码与验签必须按后面的“安全信封”章节实现,不能用简化实现替代。
async function generateDeviceKeyPairs() {
const signing = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']
);
const encryption = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']
);
return {
signingPublicJwk: await crypto.subtle.exportKey('jwk', signing.publicKey),
encryptionPublicJwk: await crypto.subtle.exportKey('jwk', encryption.publicKey),
privateKeys: { signing: signing.privateKey, encryption: encryption.privateKey },
};
}
// privateKeys 仅交给 Windows DPAPI / Keychain / 系统密钥环保存。function buildChallengeRequest(keys) {
const clientNonce = crypto.randomUUID();
return {
appId: APP_ID,
appVersion: APP_VERSION,
platform: process.platform,
clientNonce,
signingJwk: keys.signingPublicJwk,
encryptionJwk: keys.encryptionPublicJwk,
};
}
const challengeResponse = await fetch(`${LICENSE_ORIGIN}/v2/activation/challenge`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(buildChallengeRequest(keys)),
});
// 用固定的服务 ES256 公钥验证 challengeResponse 的 JWS、nonce、appId 与 expiresAt。const payload = await encryptLicenseAsJwe({
licenseKey: userInputLicenseKey, challengeId, iat: now(), jti: crypto.randomUUID(),
}, keys.encryptionPublicJwk); // 按“安全信封”章节使用服务加密公钥实现
const proof = await signRequestProof({
method: 'POST', path: '/v2/activation/complete', bodyHash: sha256(payload),
}, keys.privateKeys.signing); // 使用本机 ES256 签名私钥实现
const grant = await decryptAndVerifyGrant({ payload, proof });
await secureStore.save('license-state', {
deviceHandle: grant.deviceHandle,
maskedLicenseKey: mask(userInputLicenseKey),
verifiedGrant: grant,
offlineUntil: grant.offlineValidUntil,
});
// 不保存完整授权码;私钥也不进入普通 JSON、日志或渲染进程。02 / REQUEST MAP
调用全景
| 方法与路径 | 调用方 | 用途 | 保护 |
|---|---|---|---|
GET /health | 运维 | 存活检查 | HTTPS |
GET /v2/keys | 客户端 / CLI | 获取公开服务密钥 | 固定签名公钥核对 |
POST /v2/activation/challenge | 客户端 | 获取一次性签名挑战 | 服务 ES256 签名 |
POST /v2/activation/complete | 客户端 | 提交授权码并绑定设备 | JWE + 设备 JWS |
POST /v2/devices/heartbeat | 客户端 | 刷新权威授权状态 | JWE + 已绑定设备 JWS |
POST /v2/devices/deactivate | 客户端 | 释放当前设备名额 | JWE + 已绑定设备 JWS |
POST /v2/admin/licenses/issue | 管理员 CLI | 签发新授权 | JWE + 管理员 JWS |
POST /v2/admin/licenses/renew | 管理员 CLI | 续期、转计划、调设备数 | JWE + 管理员 JWS |
API SANDBOX / SAFE MODE
接口调试台
用于理解接入顺序和返回结构。健康检查可对当前主机做一次真实只读检测;挑战、激活、心跳和停用全部在浏览器内模拟,模拟授权,不写入真实授权账本,也不会调用任何受保护的 V2 POST 接口。
不要在此页输入真实授权码、设备私钥或管理员密钥。模拟产生的标识仅存在于当前页面,刷新即清除。
/health真实只读检测/v2/activation/challenge浏览器内模拟/v2/activation/complete浏览器内模拟/v2/devices/heartbeat浏览器内模拟/v2/devices/deactivate浏览器内模拟{
"mode": "SIMULATED",
"message": "选择左侧接口开始调试"
}- 调试台已初始化;仅健康检查可执行真实只读请求。
03 / TRUST MODEL
密钥与信任模型
服务密钥
服务端使用独立的 P-256 签名密钥和加密密钥。客户端必须把核对后的签名公钥作为信任锚固定进发布配置;从网络获得的新签名公钥只能用于人工核对或受控轮换,不能直接覆盖已有信任锚。
设备密钥
每个安装实例首次运行生成两套不同密钥:签名私钥用于 ES256 请求证明,加密私钥用于解密只发给该设备的 JWE。两套公钥指纹必须不同。
- Windows:优先 DPAPI 或 Credential Locker。
- macOS:使用 Keychain。
- Linux:使用 Secret Service 或系统密钥环。
- 不得把完整授权码、设备私钥或解密载荷写入普通 JSON、日志或 Web Storage。
04 / SECURE ENVELOPE
JWE + JWS 安全信封
敏感请求使用 JWE Compact 加密,算法为 ECDH-ES+A256KW + A256GCM;外层 proof 使用设备或管理员的 ES256 JWS,绑定请求方法、精确路径和密文摘要。
{
"method": "POST",
"path": "/v2/devices/heartbeat",
"bodyHash": "SHA-256(payload)-base64url"
}每个加密内层请求还必须包含 Unix 秒级 iat 和唯一 jti。请求时间与服务端偏差不得超过五分钟;短时间重复的 jti 会被拒绝。
05 / ACTIVATION
首次激活
第一步:获取并验证挑战
用户点击激活后,先调用 POST /v2/activation/challenge,只提交应用信息、客户端随机数与两套设备公钥。验证返回 JWS 的签名、kid、appId、随机数、公钥指纹和五分钟有效期。
{
"appId": "my-desktop-app",
"appVersion": "1.0.0",
"platform": "win32",
"clientNonce": "NEW_RANDOM_VALUE",
"signingJwk": {"kty":"EC","crv":"P-256","x":"PUBLIC_X","y":"PUBLIC_Y"},
"encryptionJwk": {"kty":"EC","crv":"P-256","x":"PUBLIC_X2","y":"PUBLIC_Y2"}
}第二步:完成激活
挑战校验通过后调用 POST /v2/activation/complete。完整授权码仅放入 JWE 内层;proof 由本机设备签名私钥产生。成功后解密响应,并再次验证服务签发的 licenseGrant。
{
"payload": "JWE_COMPACT_VALUE",
"proof": "DEVICE_ES256_JWS"
}本地只保存 deviceHandle、脱敏授权码、已验签授权凭证、offlineValidUntil、最近可信服务器时间及经过系统保护的设备密钥。完整授权码使用后不再保存。
06 / HEARTBEAT
心跳、刷新与离线窗口
使用已绑定设备密钥调用 POST /v2/devices/heartbeat。建议在应用启动、网络恢复、用户手动刷新以及正常运行期间调用;服务返回的建议间隔为六小时,客户端加入约 ±10% 抖动。
- 成功:替换本地 licenseGrant 与 offlineValidUntil,再按新凭证刷新功能开关。
- 网络失败、超时或 5xx:不是授权失效证据;签名凭证仍在离线窗口内时可以继续使用。
- 权威失效错误:立即重置授权展示和所有受控功能。
离线窗口最长 72 小时且不超过订阅截止时间。永久计划同样需要周期心跳。客户端应同时记录可信服务器时间和单调时钟基准,防止通过系统时间回拨延长授权。
07 / DEACTIVATION
停用与设备迁移
用户确认停用本机时调用 POST /v2/devices/deactivate。只有收到服务端加密成功响应 {"deactivated":true} 后,客户端才清除设备状态和本机密钥。网络失败时保留状态并提示稍后重试。
服务端保留 90 天设备墓碑,使被停用设备再次联网时获得权威的 DEVICE_DEACTIVATED 结果,而不是被误判为普通断网。
08 / ADMIN OPERATIONS
授权签发与续期
自动化或服务器终端应使用独立管理员密钥调用加密 CLI 接口。普通客户端永远不调用这两个接口,也不携带管理员凭据。
签发:POST /v2/admin/licenses/issue
| 字段 | 取值 | 说明 |
|---|---|---|
plan | annual / monthly / perpetual | 授权计划 |
years | 正整数 | 年计划时使用,默认 1 |
months | 正整数 | 月计划时使用,默认 1 |
deviceLimit | 1–10 | 允许激活设备数 |
customerRef | 字符串 | 订单或客户备注,不下发客户端 |
续期:POST /v2/admin/licenses/renew
使用 targetPlan 选择月、年或永久,可同时调整 deviceLimit。若目标设备数小于当前激活数,返回 DEVICE_DEACTIVATION_REQUIRED,管理员先停用足够设备再重试。
09 / ERROR HANDLING
权威错误处理
| 错误 | 含义 | 客户端动作 |
|---|---|---|
LICENSE_NOT_FOUND | 授权不存在或已删除 | 重置授权状态与受控功能,返回激活页 |
LICENSE_REVOKED | 授权被撤销 | 立即停止受控功能 |
LICENSE_EXPIRED | 授权到期 | 停止受控功能并提示续期 |
DEVICE_LIMIT_REACHED | 设备已满 | 提示停用旧设备或联系管理员 |
DEVICE_DEACTIVATED | 本机被停用 | 清除授权和设备身份 |
DEVICE_KEY_INVALID | 本机密钥不匹配 | 清除损坏状态并重新激活 |
REPLAY_REJECTED | 请求被重复使用 | 生成新 jti 并检查重试逻辑 |
| HTTP 429 | 触发频率限制 | 遵循 Retry-After 并加入退避抖动 |
| 网络 / 超时 / 5xx | 无权威授权结论 | 离线窗口内保留有效授权并重试 |
10 / CLIENT STRUCTURE
桌面客户端推荐结构
密码学和密钥访问放在 Electron 主进程或原生模块,渲染进程只读取最少的授权视图状态。
Renderer UI
└─ restricted IPC: activate / refresh / deactivate / status
└─ Main Process LicenseService
├─ SecureStore: device keys + verified grant
├─ ProtocolClient: challenge + JWE/JWS transport
├─ GrantVerifier: pinned service public key
└─ EntitlementGate: one authoritative feature state所有页面显示和功能判断必须读取同一个 EntitlementGate。不能出现“弹窗显示未授权,但新建或修改功能仍然可用”的状态分裂。
11 / REVERSE ENGINEERING
客户端防逆向建议
本服务能显著提高复制授权和伪造服务响应的成本,但不能保证客户端绝对无法破解。本地程序始终运行在用户控制的设备上,正确目标是让一个补丁或一个泄露值不足以绕过完整授权链。
- 固定服务端签名公钥,严格校验 kid、算法和曲线。
- 设备密钥进入系统安全存储,渲染层永远拿不到原始私钥。
- 正式包关闭开发者工具、源码映射、调试端口和详细协议日志。
- 对关键模块做代码签名、资源哈希和运行时完整性检查。
- 授权状态机可适度混淆,但不要把混淆当作密码学。
- 高价值能力尽量由服务端做最终判断,客户端保留多处一致性校验。
12 / OPERATIONS
部署、备份与密钥轮换
生产部署使用 HTTPS 反向代理,只开放官网和 V2 协议所需端口。授权账本、审计日志、服务密钥与管理员公钥注册信息分层保存并限制文件权限。
密钥轮换顺序
- 发布同时信任旧、新服务签名公钥的过渡客户端。
- 确认过渡版本覆盖率后,切换服务签名密钥。
- 观察客户端心跳和错误率,最后淘汰旧公钥。
服务签名密钥是客户端信任锚,直接替换会使旧客户端拒绝新授权。备份必须包含授权账本、审计记录和密钥材料,并定期做恢复演练。
13 / ACCEPTANCE
上线验收清单
- 正确固定公钥可激活,替换服务签名公钥后客户端拒绝响应。
- 两套设备公钥不同,复制状态到另一台机器后无法完成签名心跳。
- 授权不存在、撤销、过期或设备被停用时,展示与受控功能同步重置。
- 达到设备上限不能继续激活;停用旧设备后新设备可以激活。
- 心跳成功会原子替换授权凭证和离线截止时间。
- 断网只在签名离线窗口内运行,时钟回拨不能延长期限。
- 完整授权码和任何私钥都不出现在日志、崩溃报告或普通本地文件。
- HTTP 429 使用退避;网络错误与服务端 5xx 不误判为撤销。
- 正式安装包完成代码签名,关闭开发配置并通过完整性检查。
- 生产备份可恢复,密钥轮换方案经过预演。