PUBLIC INTEGRATION DOCS / 2.1

通用接入规范

统一协议,按项目与产品接入。覆盖首次激活、日常验证和重置后的恢复购买,适用于软件、服务与多种设备。

服务 v0.5.0FastAPI · HTTPS JSON静态 HTML · Agent 可直接读取
接入前,Agent 必须主动询问开发者

请确认 API 根地址、Project Code、Product Code、身份类型与真实身份读取方式。本文不披露实际接入代码或凭据。占位符必须经开发者提供的值替换后才能计算哈希、签名或提交请求;不能根据显示名称和文档 URL 猜测。

展开协议目录

PROJECT / PRODUCT

项目与产品接入目录

先确认显示名称,阅读对应流程。每页都包含通用规范;实际代码仍须向开发者询问。新增项目、产品后重新导出文档即可增加页面。

暂无项目专属页面,按通用规范接入并向开发者确认配置。

本规范以独立 HTML 发布,适用于所有接入项目。先阅读统一规范,再阅读对应项目、产品的接入说明。接入方无需访问 Common API 源码、管理后台或数据库。所有接入代码值由开发者单独提供,公开文档不包含真实项目代码、产品代码、服务地址或凭据。

生产环境的 Command API 服务地址 为 https://auth.convgg.cn。文中的激活码、VIN、公钥和 UUID 均为演示值;Project Code、Product Code 及身份读取方式须向管理员确认。

1. Agent 必须主动向开发者询问的配置

Agent 必须先主动询问开发者:本项目的 API 根地址、Project Code、Product Code、身份类型及真实身份读取方式,以及客户端验证时机与恢复审核需求。开发者在实现对应功能时提供这些值。未得到明确配置前,只能实现与参数无关的通用协议部分,不得猜测、从项目名称推断,或将文档占位符当作真实参数。

本文所有 <PROJECT_CODE>、<PRODUCT_CODE> 等均为占位符。先由开发者替换为符合约束的真实值,再构建 B、计算哈希和签名。项目专属页面同样不披露这些代码。

配置 含义 示例
BASE_URL Command API 的 HTTPS 根地址,不含 /v1,不带查询参数和路径前缀 https://auth.convgg.cn
PROJECT_CODE 管理后台中的项目编码,使用 code,通过 JSON 字段 project_code 提交,不是项目 UUID 或显示名称 <PROJECT_CODE>
PRODUCT_CODE 该项目下的授权产品编码,不是发卡平台商品 ID <PRODUCT_CODE>
IDENTITY_TYPE 管理员为该授权产品设置的身份类型 <IDENTITY_TYPE>,由开发者确认
身份值来源 客户端能稳定获取的真实身份标识 开发者说明系统接口及读取方法
设备私钥保存位置 每台设备独立、可跨应用重启保留的密钥存储 目标平台的安全密钥存储
验证时机与频率 由各客户端与开发者确定并实现,统一管理后台不配置检查周期 启动时、按客户端定时任务或使用授权功能前验证
联调激活码 已导入本项目、本产品,状态允许激活的完整密钥 从管理员获取专用测试密钥

项目和产品编码符合 [a-z][a-z0-9_-]{1,63},区分大小写。不能从激活码推断项目、产品或身份类型;这些配置须由管理员提供。

文档不提供可用的项目代码或产品代码。即使项目和产品名称已知,Agent 也必须向开发者取得准确代码;不同项目的身份规则不能互相照搬。

接入 Agent 应按以下顺序实现:

  1. 取得上述配置,确认 Command API 可从目标设备或服务器访问。
  2. 在本机生成并持久保存一对设备 P-256 密钥,取得真实设备身份。
  3. 用户首次输入激活码时,执行本文的挑战和激活流程。
  4. 激活成功后保存 binding_id,继续使用同一身份、同一设备私钥在线验证。
  5. 按开发者确认的客户端验证时机与频率发起后续检查,按错误码处理停用、吊销、到期、换机和网络故障。
  6. 提供“恢复购买”入口:用户输入原激活码,客户端读取原稳定身份,使用当前设备私钥执行第 6.1 节的恢复流程。私钥丢失时生成并持久保存新密钥;无需旧 Binding ID、旧私钥或购买订单。

这里的“挑战”是签名协议中的服务端随机挑战,与短信、邮件验证码无关。短信、邮件验证码模块当前未实现。

2. 发卡与验证的业务边界

当前流程如下:

管理员在统一管理后台按项目生成密钥
    → 将这批密钥导入统一管理后台的指定项目和授权产品
    → 将同一批完整密钥交给发卡平台,用于商品交付
    → 用户购买并取得完整激活码
    → 用户的软件直接请求 Command API 激活
    → Command API 查询自己的 MySQL,绑定设备身份与设备公钥
    → 软件之后凭 binding_id + 身份 + 设备签名在线验证

新密钥格式为 20 位字母数字,分为四组,每组 5 位:

ABCDE-FGHJK-LMNPQ-RSTUV

横杠不计入 20 位字符。服务端查找密钥时会去除首尾空白、转大写并去除横杠;还兼容历史 16 位和 32 位密钥。不要修改字符本身,也不能用后台掩码 *****-ZC5XY 代替完整激活码。

3. HTTP 约定与设备身份

客户端使用固定 URL,项目编码通过 JSON 字段 project_code 传递,并纳入业务摘要和设备签名。相较文档 1.0,本版已移除路径中的项目编码;旧的带项目编码 URL 不再提供,已有客户端须同步更新。项目编码是路由标识,不是认证凭据,请求内容仍能由请求双方读取。

所有业务请求使用 POST、UTF-8 JSON,请求头为:

Content-Type: application/json
Accept: application/json

成功返回 HTTP 200。时间字段为带 Z 的 UTC ISO 8601 时间。请求对象拒绝未定义的额外字段。UUID 一律使用小写、带横杠的标准字符串,例如 11111111-1111-4111-8111-111111111111;服务端解析 UUID 后会规范化,签名前就应使用这一形式。

客户端接口列表:

方法 路径 用途
POST /v1/challenges 为本次激活、验证或恢复申请随机挑战
POST /v1/licenses/activate 首次激活,或同身份、同设备的激活重试
POST /v1/licenses/verify 已激活授权的在线验证
POST /v1/licenses/restore 原密钥 + 原身份恢复购买,支持自动审核或人工审核

服务健康检查:GET /health/live 返回服务状态及版本,GET /health/ready 检查数据库连接。健康检查通过不代表某枚密钥有效。

3.1 身份对象

{
  "type": "DEVICE_ID",
  "value": "device-example-0001"
}

type 必须与密钥导入时冻结的产品身份规则匹配。value 为字符串,原始字段长度为 1~128;具体规则如下:

类型 服务端匹配前的规范化 格式
VIN 去除首尾空白,转大写 17 位,[A-HJ-NPR-Z0-9]{17},不含 I、O、Q
ANDROID_ID 去除首尾空白,转小写 16 位十六进制,[0-9a-f]{16}
DEVICE_ID 去除首尾空白,保留大小写 [A-Za-z0-9_.:-]{4,128}
OEM_VEHICLE_ID 去除首尾空白,保留大小写 [A-Za-z0-9_.:-]{4,128}

身份采集由接入项目负责,Command API 不提供终端身份采集接口。车辆项目可使用真实 VIN,其他项目按产品选择 DEVICE_ID、ANDROID_ID 或厂商标识。不能在每次启动时重新随机生成身份。

建议在构建业务对象前完成身份和激活码的规范化。一旦计算 payload_hash,就不能再修改请求中的身份值、激活码、UUID 或任何业务字段。 服务端的身份匹配规范化发生在签名校验之后;签名绑定的是提交的业务对象。

3.2 设备密钥

4. 完整签名流程

激活、验证和恢复购买都按下面的顺序执行:

构建本次业务对象 B
    → SHA-256(RFC 8785 JCS(B)),得到 payload_hash
    → POST /v1/challenges,取得 challenge_id、nonce、expires_at
    → 用设备私钥签署 ES256 JWT,得到 proof
    → POST /v1/licenses/activate、/v1/licenses/verify 或 /v1/licenses/restore

4.1 业务对象 B

下例以 DEVICE_ID 演示通用结构,不代表任何真实项目。所有代码占位符必须先由开发者提供并替换,身份类型也必须按产品配置调整。

激活时,B 恰好包含以下字段:

{
  "project_code": "<PROJECT_CODE>",
  "request_id": "11111111-1111-4111-8111-111111111111",
  "product_code": "<PRODUCT_CODE>",
  "activation_code": "ABCDE-FGHJK-LMNPQ-RSTUV",
  "identity": {
    "type": "DEVICE_ID",
    "value": "device-example-0001"
  },
  "device_public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li_hp_m47n60p8D54WK84zV2sxXs7LtkBoN79R9Q"
}

验证时,B 使用 binding_id 替代 activation_code,其他字段相同。每一次新的业务操作生成新的 request_id。该 UUID 需要同时出现在 B、挑战请求和 JWT claims 中。

product_code 为前述产品编码;activation_code 请求字段长度为 16~80,另有第 2 节的字符与主体长度限制;binding_id 为激活成功返回的 UUID。

计算:

canonical_bytes = RFC8785_JCS(B) 的 UTF-8 字节
payload_hash = SHA256(canonical_bytes) 的小写十六进制字符串(64 位)

使用标准 RFC 8785 实现。普通 JSON.stringify 或仅对外层对象排序不能替代 JCS。challenge_id、proof 不参加 B 的哈希计算;project_code 必须放进 B 并参加哈希,同时写入 JWT claims。URL 使用固定路径,不包含项目编码。

为帮助跨语言实现自检,上面的完整 B 对应:

payload_hash = fc5740beffc050cd2d995f15f05d5817021424a3d81950c497c57a36663e5be9

这是包含 <PROJECT_CODE>、<PRODUCT_CODE> 字面占位符的固定离线哈希自检向量,只用于检查 JCS 与 SHA-256,不能直接提交到服务端。取得真实代码并替换后必须重新计算哈希。公钥仅供示例使用,真实设备必须生成自己的随机私钥。

4.2 申请挑战

POST {BASE_URL}/v1/challenges
{
  "project_code": "<PROJECT_CODE>",
  "request_id": "11111111-1111-4111-8111-111111111111",
  "purpose": "activate",
  "payload_hash": "fc5740beffc050cd2d995f15f05d5817021424a3d81950c497c57a36663e5be9",
  "device_public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li_hp_m47n60p8D54WK84zV2sxXs7LtkBoN79R9Q"
}

purpose 只能是 activate、verify 或 restore。payload_hash 必须匹配本次 B。成功响应示意:

{
  "challenge_id": "22222222-2222-4222-8222-222222222222",
  "nonce": "<服务端返回的随机字符串,原样使用>",
  "expires_at": "2026-10-23T04:02:00Z",
  "algorithm": "ES256",
  "proof_type": "common-api-proof+jwt"
}

挑战默认有效期 120 秒,服务器可配置为 30~600 秒。始终读取本次实际返回的 expires_at,不要硬编码示例时间或自行生成 nonce。取得挑战后应立即签名并提交。

4.3 创建设备签名 proof

proof 是用设备私钥签署的 JWS Compact JWT。受保护头:

{
  "alg": "ES256",
  "typ": "common-api-proof+jwt"
}

Claims:

{
  "project_code": "<PROJECT_CODE>",
  "purpose": "activate",
  "request_id": "11111111-1111-4111-8111-111111111111",
  "challenge_id": "22222222-2222-4222-8222-222222222222",
  "nonce": "<本次挑战返回的 nonce>",
  "method": "POST",
  "path": "/v1/licenses/activate",
  "payload_hash": "fc5740beffc050cd2d995f15f05d5817021424a3d81950c497c57a36663e5be9",
  "exp": 1792728120
}

exp 是挑战 expires_at 转换出的 Unix 秒数,取整数,不能晚于挑战到期时间,且提交时必须尚未到期。示例 exp 对应上面的示例时间;真实请求必须重新计算。

path 只含实际 API 路径,不含域名、查询参数或额外斜杠。验证操作需同时将 purpose 改为 verify、path 改为 /v1/licenses/verify,并使用验证业务对象的哈希。

ES256 的 JWT 签名是 JOSE 规定的 64 字节 R || S,不是 ASN.1 DER 签名。应使用支持 ES256 的 JWT/JWS 库处理编码,不能直接把平台返回的 DER 签名拼进 JWT。proof 字符串长度为 40~4096。

4.4 提交激活

POST {BASE_URL}/v1/licenses/activate

请求 JSON 为 原始 B 加上两个字段:

{
  "project_code": "<PROJECT_CODE>",
  "request_id": "11111111-1111-4111-8111-111111111111",
  "product_code": "<PRODUCT_CODE>",
  "activation_code": "ABCDE-FGHJK-LMNPQ-RSTUV",
  "identity": {
    "type": "DEVICE_ID",
    "value": "device-example-0001"
  },
  "device_public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li_hp_m47n60p8D54WK84zV2sxXs7LtkBoN79R9Q",
  "challenge_id": "22222222-2222-4222-8222-222222222222",
  "proof": "<本次设备私钥签署的完整 JWT>"
}

签名中的 nonce、payload_hash、purpose 等字段留在 JWT 内,不额外加到这里的请求对象中。

5. 激活与验证的成功响应

两个接口使用相同的响应结构:

{
  "status": "ACTIVE",
  "license_id": "33333333-3333-4333-8333-333333333333",
  "binding_id": "44444444-4444-4444-8444-444444444444",
  "device_epoch": 1,
  "term": "PERPETUAL",
  "expires_at": null,
  "offline_enabled": false,
  "server_time": "2026-10-23T04:00:05Z"
}
字段 客户端处理方式
status HTTP 200 且值为 ACTIVE 才表示本次授权检查成功
license_id 授权记录 UUID,可用于本地关联和管理员排查
binding_id 保存到持久存储;以后在线验证使用它,不必反复提交激活码
device_epoch 设备凭据版本;自动或人工审核通过且公钥变更时递增。可记录用于诊断,不是 JWT 或访问令牌
term PERPETUAL 为永久授权;DURATION 为按天计时授权
expires_at 永久授权为 null;计时授权为 UTC 到期时间,计时从首次成功激活开始
offline_enabled 当前实现固定为 false,不签发离线授权凭证
server_time 当前服务端 UTC 时间,可用于时间偏差诊断

客户端须保存原设备私钥、binding_id 和对应的项目、产品、身份上下文。不要将不同项目、产品或设备的绑定记录混用。

当前授权按整枚密钥判断是否有效,不划分基础、高级或其他功能等级。服务端不下发功能清单或客户端验证周期;已有接入方升级时应移除对这两类返回字段的依赖。

重复激活同一枚密钥,若身份和设备公钥一致,会返回原绑定,不会延长计时授权。同项目、同产品下,一枚密钥只能绑定一个身份,一个身份也只能绑定一枚密钥;不同设备私钥即使提交相同身份,也需要通过恢复购买流程,由项目与产品策略决定自动或人工审核。

6. 后续在线验证

构造新的 B,用保存的 binding_id 替代激活码:

{
  "project_code": "<PROJECT_CODE>",
  "request_id": "55555555-5555-4555-8555-555555555555",
  "product_code": "<PRODUCT_CODE>",
  "binding_id": "44444444-4444-4444-8444-444444444444",
  "identity": {
    "type": "DEVICE_ID",
    "value": "device-example-0001"
  },
  "device_public_key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li_hp_m47n60p8D54WK84zV2sxXs7LtkBoN79R9Q"
}

然后重新计算哈希、申请 purpose=verify 的挑战、生成验证 proof,提交到:

POST {BASE_URL}/v1/licenses/verify

最终请求仍然为 B 加 challenge_id、proof。验证请求不能包含 activation_code,也不能复用激活操作的 proof。返回结构见第 5 节。

验证时机与频率由各客户端实现,并由该项目开发者确认,例如启动时、定时检查或使用授权功能前验证。统一管理后台只在收到请求时检查当前授权状态,不下发检查周期,也不会主动调度客户端。后台吊销、删除密钥或停用项目后,客户端需要在下一次验证时才能获知变更,实际延迟取决于客户端安排。

每次验证都重新读取或确认真实身份,使用原设备密钥,不要通过不断尝试新激活码来绕过绑定冲突。当前没有“允许离线 7 天”的规则,也没有可供客户端长期缓存放行的服务端签名许可证。PERPETUAL 表示密钥没有时间到期日,仍需通过在线请求检查吊销、删除及停用状态。网络故障时的重试、提示和功能处理由客户端与开发者确认,不能将旧的成功响应视为服务端签发的离线授权。

6.1 恢复购买:设备或应用数据重置之后

普通重启使用第 6 节的验证接口。设备还原初始设置、应用重装或数据清除导致设备私钥、binding_id 丢失时,使用本节;不要把恢复实现成再次购买、再次发码或重新开始授权计时。

用户界面只需提供“恢复购买”和原激活码输入框。原稳定身份、公钥、Challenge、proof、request_id 均由客户端处理:

用户点击恢复购买并输入原激活码
  → 读取原稳定身份(如设备 ID、Android ID、车辆 VIN 或厂商 ID)
  → 私钥丢失时生成并持久保存新的 P-256 密钥对;已有私钥则复用
  → 固定恢复业务对象 B 与 request_id
  → 申请 purpose=restore 的 Challenge,以当前私钥签名
  → POST /v1/licenses/restore
      ├─ ACTIVE:保存返回的原 binding_id,继续在线验证,立即可用
      ├─ PENDING:显示等待审核,保留本地恢复上下文,稍后查询
      └─ 拒绝、过期或冲突:明确展示原因,不能放行授权功能

请求、Challenge 与 proof

POST {BASE_URL}/v1/licenses/restore

恢复 B 与激活 B 的字段完全相同,不提交旧 binding_id:

{
  "project_code": "<PROJECT_CODE>",
  "request_id": "77777777-7777-4777-8777-777777777777",
  "product_code": "<PRODUCT_CODE>",
  "activation_code": "ABCDE-FGHJK-LMNPQ-RSTUV",
  "identity": {"type": "DEVICE_ID", "value": "device-example-0001"},
  "device_public_key": "<当前设备公钥:P-256 SPKI DER 的无填充 base64url>"
}

上面的公钥占位符必须替换为程序生成的真实公钥。字段约束与第 4 节一致。客户端应在构建 B 前规范化激活码(如统一去横杠并转大写),这样下次输入相同激活码时不会因排版差异改变 B。

按第 4 节计算 payload_hash=SHA256(JCS(B)),调用 /v1/challenges,其中 project_code、request_id、payload_hash、device_public_key 对应此 B,purpose 改为 restore。JWT 头仍为 alg=ES256、typ=common-api-proof+jwt,claims 为:

{
  "project_code": "<PROJECT_CODE>",
  "purpose": "restore",
  "request_id": "77777777-7777-4777-8777-777777777777",
  "challenge_id": "<本次 Challenge UUID>",
  "nonce": "<本次 Challenge nonce>",
  "method": "POST",
  "path": "/v1/licenses/restore",
  "payload_hash": "<恢复 B 的 SHA-256 小写十六进制摘要>",
  "exp": 1792713600
}

exp 必须由本次 Challenge 的真实到期时间计算,不能照抄示例时间。使用本次公钥对应的当前私钥签名,不需要已经丢失的旧私钥。最终请求为 B 加 challenge_id 与 proof。激活或验证的 Challenge/proof 不能用于恢复。

自动审核与人工审核

后台“项目与产品”提供两层配置:项目默认 MANUAL(人工审核)或 AUTO(自动审核);产品可选 INHERIT(继承项目)、MANUAL 或 AUTO。产品的显式设置优先于项目默认设置,配置修改立即影响该产品已发放的密钥,不更改授权快照或期限。

无论哪种模式,服务端都先检查项目、产品启用,原密钥存在且可用,已经激活,以及请求身份与原绑定一致。错误密钥、不同身份、已吊销、已删除、已到期等请求不会因自动或人工审核而放行,也不会进入待审核列表。尚未首次激活的密钥返回 NOT_ACTIVATED,应改用首次激活接口。

返回结果与客户端处理

自动审核成功示例(含第 5 节的完整授权结构):

{
  "status": "ACTIVE",
  "license_id": "33333333-3333-4333-8333-333333333333",
  "binding_id": "44444444-4444-4444-8444-444444444444",
  "device_epoch": 2,
  "term": "PERPETUAL",
  "expires_at": null,
  "offline_enabled": false,
  "server_time": "2026-10-23T08:00:00Z",
  "recovery_id": "88888888-8888-4888-8888-888888888888",
  "recovery_status": "APPROVED",
  "review_method": "AUTOMATIC",
  "reason": "项目与产品策略自动通过:原密钥与绑定身份匹配",
  "request_expires_at": "2026-10-24T08:00:00Z"
}

人工审核等待示例,HTTP 同为 200,不含有效授权或 Binding ID,不可视为成功激活:

{
  "status": "PENDING",
  "recovery_id": "88888888-8888-4888-8888-888888888888",
  "recovery_status": "PENDING",
  "review_method": null,
  "reason": null,
  "request_expires_at": "2026-10-24T08:00:00Z",
  "retry_after_seconds": 30
}
顶层 status recovery_status 客户端行为
ACTIVE APPROVED 保存返回的原 binding_id 和当前设备私钥;按第 6 节立即验证并继续周期检查
PENDING PENDING 显示“等待审核”;至少间隔 retry_after_seconds 秒再查询,退出应用后保留恢复上下文
REJECTED REJECTED 显示审核 reason,停止轮询;用户修正问题后可明确发起新申请
EXPIRED EXPIRED 申请等待审核已过期,停止轮询;用户重试时使用新的 request_id
SUPERSEDED SUPERSEDED 后续恢复已改变设备公钥版本,此申请失效;停止轮询,不回滚旧设备公钥

review_method 为 AUTOMATIC、MANUAL、UNCHANGED 或 null。reason 是客户端可展示的审核说明。request_expires_at 为待审核期限,不是授权到期时间;授权期限只看 expires_at。已通过申请查询不会仅因等待审核期限已过而失效,仍需校验当前绑定和授权。

查询审核结果、网络重试与本地持久化

没有免签的公开审核查询接口。 查询时重新调用 /v1/licenses/restore,保留原 request_id、原激活码、原身份、原产品和同一公钥,即完全相同的 B;重新申请 purpose=restore 的 Challenge 并签名。recovery_id 用于显示与排查,不能代替设备签名或 B。

客户端持久保存当前设备私钥、恢复 request_id、项目/产品/身份和公钥上下文。若需后台轮询且不再询问激活码,应使用平台保护存储保存完整恢复 B,成功或终止后清理;不得记录到日志。下方 Python 示例不保存原激活码,每次执行 --restore 时重新输入,并用规范化后的摘要检查是否仍是同一申请。

管理员批准后,下次签名查询返回 ACTIVE 和原 Binding ID。重复查询同一成功请求不会再次换钥或增加版本;如果后来另一条申请已成功换钥,旧请求返回 SUPERSEDED,不能覆盖新绑定。每次返回前还会重新检查删除、吊销、停用、到期状态。

恢复成功后沿用原 license_id、binding_id、首次激活时间和到期时间,只在公钥变化时增加 device_epoch。原私钥立即不能通过服务端验证;应用在下一次在线检查时获知失效。恢复不续期,不新增授权,不访问 ConvCard 数据库。

稳定身份自动恢复的信任边界

自动审核适用于“持有原完整激活码 + 原稳定身份”的恢复。新私钥签名证明请求方掌握新私钥,不证明设备上报的身份来自不可伪造的硬件。客户端应从系统读取真实身份,不提供任意身份输入;本版没有 OEM 硬件证明或可信执行环境认证。不同身份的恢复属于迁移场景,当前接口拒绝,不通过本流程改绑另一个授权对象。

7. 错误处理、幂等与重试

应用返回的标准错误结构:

{
  "error": {
    "code": "LICENSE_REVOKED",
    "message": "授权已吊销",
    "retryable": false
  },
  "trace_id": "66666666-6666-4666-8666-666666666666"
}

应用响应还包含 X-Request-ID 跟踪头。它和客户端业务 request_id 是不同概念:前者跟踪一次 HTTP 请求,后者用于业务幂等。排查时记录 HTTP 状态、错误码、trace_id 和时间,避免记录完整激活码、VIN、私钥或 proof。

7.1 客户端接口错误码

HTTP error.code 含义与处理
422 INVALID_REQUEST 字段缺失、格式错误或带了多余字段;修正请求格式
400 INVALID_DEVICE_KEY 公钥曲线、SPKI DER 或 base64url 格式错误
400 IDENTITY_TYPE_MISMATCH 身份类型与密钥策略不一致;核对项目产品配置
400 INVALID_IDENTITY VIN、Android ID 或其他身份值格式错误
401 CHALLENGE_EXPIRED 挑战不存在、项目不匹配或已到期;重新申请挑战并签名
401 CHALLENGE_MISMATCH 挑战绑定的用途、业务 UUID 或哈希与提交内容不符;检查 B 是否被修改
401 INVALID_PROOF JWT 头、签名、公钥、claims 或到期时间校验失败
401 CHALLENGE_USED 已消费的挑战没有可复用的幂等回执;重新申请挑战,不循环提交旧 proof
404 PROJECT_NOT_FOUND 项目编码不存在
404 PRODUCT_NOT_FOUND 项目下不存在该授权产品编码
404 INVALID_LICENSE 密钥/绑定不存在、项目产品不匹配、密钥已删除,或密钥内容格式无效
403 PROJECT_DISABLED / PRODUCT_DISABLED 项目或产品停用;停止授权功能并联系管理员
403 LICENSE_REVOKED 密钥已吊销;停止授权功能,不能靠重新激活恢复
403 LICENSE_EXPIRED 计时授权已到期;按产品续期流程处理
403 LICENSE_NOT_SOLD 历史库存密钥尚未具备可激活状态;联系管理员核对登记
409 IDENTITY_NOT_CONFIGURED 管理员尚未配置真实身份来源
409 IDENTITY_CONFLICT 密钥或绑定属于其他身份;不能自动覆盖
409 IDENTITY_ALREADY_BOUND 本身份已绑定该产品下的另一枚密钥;联系管理员
409 DEVICE_RECOVERY_REQUIRED 身份匹配,但设备私钥变化;引导用户选择恢复购买,调用 /v1/licenses/restore
409 NOT_ACTIVATED 尚无可用激活绑定;先完成首次激活。未知绑定通常返回 INVALID_LICENSE
409 IDEMPOTENCY_CONFLICT 同一业务 request_id 被用于不同内容或设备;修正本地幂等记录
409 CONFLICT / INVALID_REFERENCE 数据唯一性或关联冲突;联系管理员排查,不持续盲重试
429 RECOVERY_COOLDOWN 短时间内再次自动更换设备公钥;等待响应头 Retry-After 秒后,以同一 B 申请新挑战重试。默认冷却 300 秒,可由服务端调整
429 RATE_LIMITED 请求过密;遵守 Retry-After,当前值为 60 秒
503 DATABASE_UNAVAILABLE 数据库暂时不可用;退避重试
500 INTERNAL_ERROR 服务内部错误;记录跟踪号并退避重试

RATE_LIMITED、RECOVERY_COOLDOWN、DATABASE_UNAVAILABLE、INTERNAL_ERROR 的 retryable 为 true。挑战到期等错误虽然 retryable=false,仍可按表中的步骤申请新挑战后重新构造请求;不能机械地把所有 401 都当作激活码无效。

7.2 重试规则

  1. 一次业务操作开始时生成 request_id,固定 B、身份和设备密钥。遇到超时或连接中断,无法仅凭网络错误判断服务器是否已经完成激活。
  2. 对同一操作的重试保留原 request_id 和完全相同的 B。挑战仍有效时可重发同一最终请求;挑战已到期或已消费时,重新申请挑战、重新签名,B 及其哈希保持不变。
  3. 激活和验证的成功回执默认保留约 24 小时,由服务端定时清理。恢复申请另行持久保存用于审核与防重放,默认等待审核期限为 24 小时;该期限不限制已通过申请的查询。不要将回执窗口当作长期缓存授权。服务端即使处理幂等重试,也会重新检查当前吊销、删除、停用和到期状态。
  4. 用户修改了激活码、身份、产品或设备公钥时,属于新的业务操作,使用新的 request_id。每次定期验证也使用新的 UUID。
  5. 网络/5xx 故障建议采用有上限的指数退避,例如 1、2、4、8 秒并加入随机抖动。遇到 429 先等待 Retry-After;长时间失败转为“等待在线验证”,保留本地凭据供恢复后重试。
  6. 网络不可达不能据此标记密钥已吊销,也不能直接当作验证成功。客户端应实现与开发者确认的验证失败处理及重试入口;不要自行增加 7 天离线放行。

默认一分钟限额为:同项目、来源 IP 的挑战 120 次、激活 30 次、验证 180 次、恢复 30 次;同项目同一激活码另有 30 次限额。服务器可调整。不要让多个定时器同时轮询同一授权。

8. 可独立运行的 Python 参考实现

以下代码只依赖公开 Python 包,不导入本项目的 app 模块。将代码块保存为 license_client.py,使用 Python 3.10 或以上版本。

这是单进程联调示例:首次运行询问激活码并激活,保存绑定后立即验证;再次运行直接验证。--restore 执行恢复购买,等待审核时保留 request_id 与当前私钥,下次用同一参数再次执行即可查询。示例不自动重试和不做周期调度,生产客户端按第 6、7 节实现。signed_call 接受可复用的 request_id,便于实现同一业务操作的重试。

示例将私钥保存为仅当前用户可读写的 PEM 文件。正式应用应改用目标平台的保护存储(如 Android Keystore),并在平台内完成签名;不要给所有设备打包同一个私钥。

安装依赖:

python -m venv .venv
# macOS / Linux
source .venv/bin/activate
python -m pip install 'httpx>=0.28,<1' 'cryptography>=44' 'PyJWT[crypto]>=2.10,<3' 'rfc8785>=0.1,<1'

参考程序:

import argparse
import base64
import getpass
import hashlib
import json
import os
import re
from datetime import datetime
from pathlib import Path
from urllib.parse import urlsplit
from uuid import UUID, uuid4

import httpx
import jwt
import rfc8785
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec


class LicenseAPIError(RuntimeError):
    def __init__(self, status_code, code, retryable=False, trace_id=None, retry_after=None):
        self.status_code = status_code
        self.code = code
        self.retryable = retryable
        self.trace_id = trace_id
        self.retry_after = retry_after
        super().__init__(f"HTTP {status_code} {code}; trace_id={trace_id}")


def post_json(http, path, body):
    response = http.post(path, json=body)
    try:
        data = response.json()
    except ValueError:
        raise LicenseAPIError(response.status_code, "NON_JSON_RESPONSE") from None
    if response.status_code != 200:
        detail = data.get("error", {}) if isinstance(data, dict) else {}
        raise LicenseAPIError(
            response.status_code,
            detail.get("code", "HTTP_ERROR"),
            detail.get("retryable", False),
            data.get("trace_id") if isinstance(data, dict) else None,
            response.headers.get("Retry-After"),
        )
    return data


def public_key_text(key):
    der = key.public_key().public_bytes(
        serialization.Encoding.DER,
        serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    return base64.urlsafe_b64encode(der).rstrip(b"=").decode("ascii")


def load_or_create_key(path):
    if path.exists():
        key = serialization.load_pem_private_key(path.read_bytes(), password=None)
        if not isinstance(key, ec.EllipticCurvePrivateKey) or not isinstance(
            key.curve, ec.SECP256R1
        ):
            raise RuntimeError("设备私钥必须是 P-256")
        return key
    key = ec.generate_private_key(ec.SECP256R1())
    pem = key.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.PKCS8,
        serialization.NoEncryption(),
    )
    # O_EXCL 避免覆盖已经存在的设备私钥;此示例在 macOS/Linux 上限制文件权限。
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "wb") as stream:
        stream.write(pem)
    return key


def signed_call(http, project_code, purpose, payload, key, request_id=None):
    if purpose not in ("activate", "verify", "restore"):
        raise ValueError("purpose 必须是 activate、verify 或 restore")
    # payload 仅包含 product_code、identity 和 activation_code/binding_id(恢复使用 activation_code)。
    business = {
        **payload,
        "project_code": project_code,
        "request_id": str(UUID(request_id)) if request_id else str(uuid4()),
        "device_public_key": public_key_text(key),
    }
    fingerprint = hashlib.sha256(rfc8785.dumps(business)).hexdigest()
    challenge = post_json(
        http,
        "/v1/challenges",
        {
            "project_code": project_code,
            "request_id": business["request_id"],
            "purpose": purpose,
            "payload_hash": fingerprint,
            "device_public_key": business["device_public_key"],
        },
    )
    if (challenge["algorithm"], challenge["proof_type"]) != (
        "ES256", "common-api-proof+jwt"
    ):
        raise RuntimeError("服务端返回了不支持的签名协议")
    path = f"/v1/licenses/{purpose}"
    expires_at = datetime.fromisoformat(challenge["expires_at"].replace("Z", "+00:00"))
    claims = {
        "project_code": project_code,
        "purpose": purpose,
        "request_id": business["request_id"],
        "challenge_id": challenge["challenge_id"],
        "nonce": challenge["nonce"],
        "method": "POST",
        "path": path,
        "payload_hash": fingerprint,
        "exp": int(expires_at.timestamp()),
    }
    proof = jwt.encode(
        claims, key, algorithm="ES256", headers={"typ": "common-api-proof+jwt"}
    )
    result = post_json(
        http, path,
        {**business, "challenge_id": challenge["challenge_id"], "proof": proof},
    )
    if purpose == "restore" and result.get("status") in (
        "PENDING", "REJECTED", "EXPIRED", "SUPERSEDED"
    ):
        return result  # 这些状态不能放行授权功能。
    if result.get("status") != "ACTIVE":
        raise RuntimeError("服务端没有返回 ACTIVE,不能视为授权成功")
    return result


def save_state(path, data):
    temporary = path.with_suffix(".tmp")
    fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as stream:
        json.dump(data, stream, ensure_ascii=False)
    temporary.replace(path)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--restore", action="store_true", help="用原激活码恢复购买或查询恢复结果")
    args = parser.parse_args()
    base_url = os.environ["COMMAND_API_BASE_URL"].rstrip("/")
    project = os.environ["COMMAND_API_PROJECT_CODE"]
    product = os.environ["COMMAND_API_PRODUCT_CODE"]
    identity_type = os.environ["COMMAND_API_IDENTITY_TYPE"]
    identity_value = os.environ["COMMAND_API_IDENTITY_VALUE"].strip()
    parts = urlsplit(base_url)
    local_http = parts.scheme == "http" and parts.hostname in ("127.0.0.1", "localhost", "::1")
    if (
        not parts.hostname or (parts.scheme != "https" and not local_http)
        or parts.path or parts.query or parts.fragment or parts.username or parts.password
    ):
        raise ValueError("BASE_URL 须为 HTTPS 根地址;仅本机联调允许 HTTP")
    if not all(re.fullmatch(r"[a-z][a-z0-9_-]{1,63}", value) for value in (project, product)):
        raise ValueError("项目或产品编码格式错误")
    if identity_type not in ("VIN", "ANDROID_ID", "DEVICE_ID", "OEM_VEHICLE_ID"):
        raise ValueError("未知身份类型")
    if identity_type == "VIN":
        identity_value = identity_value.upper()
    elif identity_type == "ANDROID_ID":
        identity_value = identity_value.lower()
    identity = {"type": identity_type, "value": identity_value}
    directory = Path(os.environ.get("COMMAND_API_STATE_DIR", ".command-api-client"))
    directory.mkdir(parents=True, exist_ok=True, mode=0o700)
    directory.chmod(0o700)
    key_path, state_path = directory / "device.pem", directory / "binding.json"
    restore_path = directory / "restore.json"
    if not args.restore and restore_path.exists():
        raise RuntimeError("存在恢复申请,请用 --restore 查询或完成恢复")
    if state_path.exists() and not key_path.exists() and not args.restore:
        raise RuntimeError("设备私钥丢失,请使用 --restore 和原激活码恢复购买")
    key = load_or_create_key(key_path)
    scope = {
        "base_url": base_url, "project_code": project, "product_code": product,
        "identity": identity, "device_public_key": public_key_text(key),
    }
    with httpx.Client(
        base_url=base_url,
        timeout=httpx.Timeout(20.0, connect=5.0),
        headers={"Accept": "application/json"},
        follow_redirects=False,
    ) as http:
        if args.restore:
            code = getpass.getpass("输入原完整激活码以恢复购买(不回显):").strip().upper().replace("-", "")
            if not re.fullmatch(r"[A-Z0-9]+", code) or len(code) not in (16, 20, 32):
                raise ValueError("激活码格式错误")
            code_hash = hashlib.sha256(code.encode("ascii")).hexdigest()
            if restore_path.exists():
                pending = json.loads(restore_path.read_text(encoding="utf-8"))
                if pending["scope"] != scope or pending["code_hash"] != code_hash:
                    raise RuntimeError("恢复上下文发生变化;请使用原项目、身份、设备私钥和同一激活码查询")
            else:
                pending = {"request_id": str(uuid4()), "scope": scope, "code_hash": code_hash}
                save_state(restore_path, pending)
            active = signed_call(
                http, project, "restore",
                {"product_code": product, "identity": identity, "activation_code": code},
                key, request_id=pending["request_id"],
            )
            if active["status"] != "ACTIVE":
                print(json.dumps(active, ensure_ascii=False, indent=2))
                if active["status"] != "PENDING":
                    restore_path.unlink(missing_ok=True)
                return  # 不授予功能;用户稍后重试,不在这里循环轮询。
            binding_id = active["binding_id"]
            save_state(state_path, {"scope": scope, "binding_id": binding_id})
            restore_path.unlink(missing_ok=True)
        elif state_path.exists():
            saved = json.loads(state_path.read_text(encoding="utf-8"))
            if saved["scope"] != scope:
                raise RuntimeError("本地绑定与当前项目、产品、身份或设备公钥不符")
            binding_id = saved["binding_id"]
        else:
            code = getpass.getpass("输入完整激活码(不回显):").strip().upper()
            active = signed_call(
                http, project, "activate",
                {"product_code": product, "identity": identity, "activation_code": code}, key,
            )
            binding_id = active["binding_id"]
            save_state(state_path, {"scope": scope, "binding_id": binding_id})
        verified = signed_call(
            http, project, "verify",
            {"product_code": product, "identity": identity, "binding_id": binding_id}, key,
        )
        print(json.dumps(verified, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    try:
        main()
    except LicenseAPIError as error:
        raise SystemExit(str(error)) from None
    except httpx.RequestError:
        raise SystemExit("网络请求失败,当前无法在线验证;请保留设备私钥和绑定记录后重试") from None

配置并运行(以下值均需替换;激活码由程序交互输入,避免进入命令历史):

export COMMAND_API_BASE_URL='https://auth.convgg.cn'
export COMMAND_API_PROJECT_CODE='<PROJECT_CODE>'
export COMMAND_API_PRODUCT_CODE='<PRODUCT_CODE>'
export COMMAND_API_IDENTITY_TYPE='<IDENTITY_TYPE>'
export COMMAND_API_IDENTITY_VALUE='<读取的真实身份>'
export COMMAND_API_STATE_DIR='./.command-api-client'
python license_client.py

# 设备或应用数据重置、私钥丢失后恢复;PENDING 时稍后重复执行此命令查询
python license_client.py --restore

在 Common API 所在电脑联调时,COMMAND_API_BASE_URL 可以设为 http://127.0.0.1:8000。其他电脑、云服务器和车机不能用此地址访问本机服务,须使用已部署的 HTTPS 地址。

--restore 会复用仍然存在的设备私钥,仅在私钥丢失时创建新密钥;不要为了每次查询重新生成密钥。restore.json 保存恢复 UUID、上下文和激活码摘要,不保存激活码原文。正常重启直接运行,不加 --restore。

示例状态目录含设备私钥、绑定 ID、恢复上下文及身份上下文,应留在当前授权设备,加入项目忽略规则并限制访问。生产环境不能将它打包到公共安装包或提交到代码仓库。httpx 默认校验 TLS 证书,正式接入保留该校验。

9. 其他语言实现要点

可使用 Kotlin/Java、Swift、JavaScript/TypeScript、Go 等语言实现相同协议。跨语言行为应与第 4 节的固定哈希向量和 Python 示例一致。

环节 必须一致的内容
公钥 P-256,SPKI DER,再无填充 base64url;不是 PEM/JWK/裸点
项目定位 project_code 放在请求 JSON、业务对象 B 和 JWT claims 中,不能写入 URL;三处必须一致
哈希 RFC 8785 JCS 的 UTF-8 字节,再 SHA-256,小写十六进制
UUID 小写、标准带横杠形式,参与哈希前即规范化
JWT ES256,typ=common-api-proof+jwt,JWS Compact 格式,签名为 64 字节 R || S
时间 exp 使用挑战到期时间的 Unix 秒数,不是毫秒或 ISO 字符串
请求路径 claims 中为 /v1/licenses/{purpose},不带域名和查询串
业务体 激活与恢复用 activation_code,验证用 binding_id;三者均包含身份、公钥、业务 UUID、产品编码
私钥生命周期 每台授权设备独立、持久保存,不随启动、挑战或业务请求重新生成

服务器端程序可以为其自身授权身份使用这套协议;若服务器只转发终端设备请求,应保持终端的签名和身份归属,不能用一组全局设备私钥和固定身份代表所有用户。

当前服务没有配置跨域 CORS 策略。浏览器网页跨域直接调用前,需要另行配置明确的允许来源,或通过同源服务转发;原生应用和服务器 HTTP 客户端不受浏览器 CORS 约束。

10. 联调验收清单

恢复验收:同身份自动恢复立即 ACTIVE;人工模式 PENDING → 批准 → 同 B 签名查询 ACTIVE;保留原 Binding ID、原首次激活和到期时间;旧私钥验证失败;不同身份拒绝;重复请求不重复换钥;旧申请不能覆盖后续成功恢复;拒绝/过期不放行;恢复后吊销、删除、停用仍然失效。

下列测试会产生真实绑定或改变密钥状态,应使用独立测试项目和专用测试密钥;删除、吊销测试由管理员在测试数据上执行。

场景 预期结果
指定项目、产品下导入测试密钥,用真实身份完成挑战与激活 HTTP 200、ACTIVE,后台出现绑定和首次激活时间
保存状态后重新运行客户端 使用原私钥和 binding_id 验证成功,不再要求输入激活码
同密钥、同身份、同公钥再次激活 返回同一个 binding_id,计时授权到期时间不延长
完全相同业务请求重试;或保留 B 重新申请挑战后重试 在有效条件下成功,绑定数量不增加
同一业务 UUID 改变内容,并为新内容生成有效挑战、签名 IDEMPOTENCY_CONFLICT;若直接篡改已签名请求,会先因挑战或签名不匹配被拒绝
同密钥换另一身份,重新生成匹配的挑战和签名 IDENTITY_CONFLICT
同身份、公钥改用另一枚同产品密钥 IDENTITY_ALREADY_BOUND
同身份换一对随机新设备密钥 DEVICE_RECOVERY_REQUIRED
改变签名后的身份、激活码或绑定 ID CHALLENGE_MISMATCH 或 INVALID_PROOF,不能成功
使用错误项目、产品或未导入的激活码 项目/产品不存在,或 INVALID_LICENSE
密钥吊销后,原设备再申请挑战并验证 LICENSE_REVOKED,不能靠激活重试恢复
密钥删除后,原设备再申请挑战并验证或激活 INVALID_LICENSE;重放旧请求同样不能成功
挑战到期后提交 CHALLENGE_EXPIRED;保留 B、重取挑战和签名后可继续
专用计时密钥到期、测试产品停用 对应 LICENSE_EXPIRED、PRODUCT_DISABLED
暂时无法连接服务 客户端显示等待验证,保留私钥和绑定,不错误标记为吊销或放行成功

当前本地参考实现的联调通过情况不等于目标公网部署、反向代理、TLS 和实际客户端均已验收。上线前应从实际目标运行环境走完至少一次“激活 → 重启应用 → 在线验证 → 管理员吊销 → 验证拒绝”。

11. 文档托管与生产接入说明

  1. 发布整个 HTML 文档目录,入口为 index.html,项目与产品页面位于 projects/project-N/product-N.html。目录和文件名只使用序号,不包含项目代码、产品代码。每个产品页面都包含完整通用规范,可独立交给 Agent 阅读。
  2. HTML 的正文、表格、请求例子和 Python 示例都在静态 HTML 内。无需管理后台登录、JavaScript、第三方 CDN 或浏览器执行脚本,Agent 可直接读取页面内容。
  3. 文档不包含实际 Project Code、Product Code、业务 API 根地址、真实激活码、VIN、后台密码或其他凭据。开发者须在开发对应功能时单独提供接入配置,Agent 必须主动询问。
  4. 应用 /integration 提供这套静态 HTML 的入口;生产模式关闭 /docs、/openapi.json、/guide 和 /design,保留 /integration。也可以将整套 HTML 放在独立静态站点。文档站点地址与业务 API 地址是两种配置,不得互相推断。
  5. 反向代理应保留 /v1/... 路径和 JSON 请求体,转发到应用,不重写签名中的业务路径。当前应用不信任代理转发的客户端 IP;若所有请求经同一代理进入,默认限流可能共享一个来源 IP 计数,上线时需核对限流容量。
  6. 项目专属说明是导出时的流程与策略快照。后台配置变化或新增项目、产品后,维护者须重新生成并发布 HTML;最终审核与授权状态以实际 API 响应为准。

交给其他项目 Agent 时,可以附上以下任务说明:

阅读 Command API 的 HTML 通用规范和本项目、产品的接入说明。
先主动向我询问本项目的 API 根地址、Project Code、Product Code、身份类型和身份读取方式;
这些值由我提供,不得从文档、项目名称、URL 或其他项目推断。
配置确认后实现设备私钥持久化、Challenge、RFC 8785 哈希、ES256 proof、首次激活、
Binding ID 在线验证及恢复购买。恢复使用原激活码与原稳定身份,按 API 结果处理自动通过、
等待人工审核、拒绝、过期和失效,不重置原授权期限。
按文档实现幂等重试、错误处理和周期检查,用专用测试密钥验收。
客户端不得持有后台管理员、销售渠道或数据库凭据,不得将占位符用于真实请求。