41 KiB
机器码授权指南
机器码授权按客户设备签发 license:一份 license 绑定一台机器 + 一批插件 + 有效期。 未授权的插件不加载、不注册路由、管理接口禁止操作。 纯离线 —— 设备用内置厂商公钥验签,运行期零联网,与现有 air-gapped 部署形态 (Zenoh 仅 localhost、Caddy
tls internal、无 phone-home)完全一致。本文档覆盖 密钥生成 → 设备导出申请码 → 厂商签发 → 设备导入 → 验证 → 故障排查 全流程。
1. 原理与角色
1.1 为什么这么设计
| 决策 | 原因 |
|---|---|
| 非对称签名(Ed25519) | 设备只需内置公钥即可验签;公钥可明文放置(换公钥只会验签失败,无法据以伪造)。对称方案不可行 —— 见 §9.1 |
| 纯离线 | 现场设备常无外网,且现有部署形态本就 air-gapped;联网激活需要新建厂商侧服务,与本项目架构冲突 |
| N-of-M 指纹容错 | 把所有硬件标识哈希成一个值的话,换一块网卡或换一颗 eMMC 就失效。改为记录逐来源指纹、按命中数判定 |
| 打包态决定是否强制 | 部署目录的 config.toml 是明文可写的,若以配置开关为准,客户改一行就绕过了 |
| 未授权 = 不加载 | 代码不下发到现场,比"加载后返回 403"更彻底 |
1.2 三方角色
| 角色 | 持有 | 做什么 | 用什么工具 |
|---|---|---|---|
| 厂商 | 签发私钥 | 生成密钥对、签发 license | 可视化工具(推荐)/ uv run chongming-license(CLI) |
| 设备 | 内置公钥 | 导出申请码、导入 license、验签放行 | 浏览器 /static/license(网页) |
| 客户 | —— | 把申请码转给厂商、把 license 文件放回设备 | —— |
设备上没有签发命令:
chongming-cli是开发依赖,不随打包产物发布。这是刻意的 —— 签发能力只存在于厂商机器上,设备端不携带。设备侧一律走网页。厂商侧有两种等价的工具,共用同一份授权实现(
chongming-license库): 单台/少量、需要人工核对信息时用可视化工具;批量签发(几十上百台)用 CLI 脚本化。 详见 §4 与 §12。
1.3 全流程
sequenceDiagram
participant V as 厂商<br/>(私钥)
participant D as 设备<br/>(公钥)
participant C as 客户
Note over V: ① keygen 生成密钥对(一次性)<br/>公钥注入构建产物
D->>D: ② 采集硬件指纹
D->>C: ③ 申请码 CM1-xxxx-yyyy
C->>V: 转交申请码
V->>V: ④ 私钥签发 license.lic
V->>C: 交付 license.lic
C->>D: ⑤ 网页上传 / scp 到工作目录
D->>D: ⑥ 重启网关,按白名单加载插件
2. 密钥管理(厂商侧)
2.1 生成密钥对(一次性)
cd <项目根>
uv run chongming-license keygen -o license_private.pem
输出:
✓ 私钥已写入 license_private.pem(权限 0600)
厂商公钥(填入部署产物的 [license] 段):
public_key = "yGHeVrFjajV4XgtBkiE9X9KZ4uy0IzF56GkQ0wwqHG0="
公钥指纹:7a9a7c9d427ee864
- 私钥文件权限自动置
0600;已存在时需显式--force覆盖(覆盖后旧 license 仍可用旧公钥校验, 但无法再用旧私钥签新授权)。 - 记下 公钥指纹(前 16 位十六进制):设备端、日志、
/license/info都会显示它, 用于确认"设备内置的是哪把公钥"。
2.2 私钥保管要求
⚠️ 私钥是授权的唯一信任根。
- 丢失 → 再也无法签发新授权(已签发的仍有效,因为校验只需公钥)。
- 泄漏 → 任何人都能签发授权,整套机器码绑定形同虚设。
请离线保管、做好备份,绝不提交仓库或打进部署产物。
已内置的防护:
.gitignore忽略license_private*.pem/license_public.key/license.licuv run build的 授权私钥守卫阶段按内容扫描build/(跨 1MB 块边界也能检出), 发现私钥立即终止构建keygen每次都会打印保管警示
2.3 公钥注入构建
构建时按以下顺序解析公钥,注入产物 config.toml 的 [license] public_key:
- 环境变量
CHONGMING_LICENSE_PUBKEY - 环境变量
CHONGMING_LICENSE_PUBKEY_FILE指定的文件 - 项目根的
license_public.key
# 方式 A:环境变量(CI 推荐)
export CHONGMING_LICENSE_PUBKEY="yGHeVrFjajV4XgtBkiE9X9KZ4uy0IzF56GkQ0wwqHG0="
# 方式 B:文件
echo "yGHeVrFjajV4XgtBkiE9X9KZ4uy0IzF56GkQ0wwqHG0=" > license_public.key
# 方式 C:直接编辑 public/config.toml 的 [license] 段
打包时缺少公钥会直接失败 —— 这是刻意设计。缺公钥的产物会拒绝加载全部插件 (谁都无法证明被授权),等于把设备变成砖头,而这种故障往往到客户现场才暴露, 代价远大于构建失败。仅内部演示可用
CHONGMING_LICENSE_ALLOW_NO_KEY=1跳过(会打印警告)。
2.4 密钥轮换
公钥指纹进入判定,因此轮换是显式操作:
keygen -o new_private.pem生成新密钥对- 用新公钥重新构建并分发产物
- 已发出去的 license 需用新私钥重签(旧公钥不再被内置)
3. 激活流程
3.1 设备侧:导出申请码
浏览器打开 http://<设备IP>:8000/static/license,用 admin 登录(默认账号已在设备上 seed),
点「复制申请码」。
页面上同时可见:
- 机器码(短哈希,如
BC60-D20E-F863-7236-68E2-6A24)—— 适合口头/日志核对 - 申请码(单行文本,
CM1-<base64>-<校验和>)—— 发给厂商签发 - 硬件指纹来源明细 —— 哪个来源采集成功、哪个缺失,装机时用来核对
为什么用申请码而不是只报机器码:机器码是单向哈希,反推不出各来源指纹, 而 N-of-M 容错匹配需要逐来源原始值。只报机器码也能签(
issue --machine-code ...), 但那样的授权是精确匹配 —— 换一块网卡就失效。
命令行等价物(供调试;设备上通常没有 CLI):
uv run chongming-license show --request-code # 只输出申请码
uv run chongming-license show --sources # 各来源明细表
uv run chongming-license show --json # 全量 JSON(可存文件传给厂商)
3.2 厂商侧:签发
uv run chongming-license issue \
--request-code "CM1-eyJzb3VyY2VzIjp7..." \
--plugins aibox_sophon_adapter,ip_sound_column \
--customer "某某科技" --days 365 \
-k license_private.pem -o license.lic
输出:
✓ 已签发 license.lic
授权编号 L20260921-334B33
客户 某某科技
机器码 BC60-D20E-F863-7236-68E2-6A24
指纹来源 mac(不参与判定:machine_id);容错阈值 3
授权插件 ['aibox_sophon_adapter', 'ip_sound_column']
有效期 立即生效 → 2027-09-21
公钥指纹 3d956a50db56b53c
要签发给客户的三种入参(优先级从高到低):
| 入参 | 来源 | 容错 |
|---|---|---|
--request-code CM1-... |
设备网页「复制申请码」 | ✅ 支持换硬件容错 |
--sources-json <文件> |
show --json 的导出文件(批量/邮件附件场景) |
✅ |
--machine-code XXXX-... |
客户只读了短码 | ❌ 精确匹配,换网卡即失效 |
3.3 设备侧:导入 license
在同一个「授权」页点「选择 license 文件」→「导入并校验」。
- 文件落盘位置由
[license] path决定,默认网关工作目录下的license.lic(systemd 部署即/data/chongming/license.lic)。 - 校验不过不会覆盖现有文件 —— 现场误传坏文件不至于把已经能用的授权弄丢。
- 若改用 scp 手工放置,记得在页面上点「从磁盘重读」刷新状态。
3.4 重启生效
新授权的插件需重启网关才会加载(插件发现只在启动期执行)。导入成功后页面会提示
需要重启、以及哪几个插件待加载;接口响应里的 restart_required / pending_plugins 同理。
重启后日志:
[license] ✓ 授权有效(本机机器码 BC60-D20E-F863-7236-68E2-6A24)
[license] 编号 L20260921-334B33,客户 某某科技,到期 2027-09-21,授权插件 ['aibox_sophon_adapter', 'ip_sound_column']
[license] 厂商公钥指纹 3d956a50db56b53c
未授权插件会明确记日志:
[license] 插件 aibox_sophon_adapter 未获授权:该插件不在授权白名单内
授权门禁已拒绝 1 个插件(不加载、不注册路由、管理接口禁止操作): ['aibox_sophon_adapter']
4. 命令行参考
命令注册在 chongming-cli(根仓 dev 依赖),入口 uv run chongming-license。
退出码:0 成功/校验通过,1 失败(便于脚本判断)。
不习惯命令行的同学可用可视化工具,功能对等 —— 见 §12。
4.1 keygen — 生成密钥对(厂商侧,一次性)
| 参数 | 默认 | 说明 |
|---|---|---|
-o, --output |
license_private.pem |
私钥输出路径 |
--kid |
—— | 建议的密钥标识,仅提示用途 |
--force |
—— | 覆盖已存在的私钥文件 |
4.2 show — 导出机器码/申请码(设备侧)
| 参数 | 说明 |
|---|---|
--request-code |
只输出申请码(单行,供客户粘贴回传) |
--sources |
打印各硬件来源明细表(现场核对用) |
--json |
以 JSON 输出全部内容 |
4.3 issue — 签发(厂商侧)
| 参数 | 必填 | 说明 |
|---|---|---|
--request-code / --sources-json / --machine-code |
三选一 | 签发对象(见 §3.2 表) |
--plugins |
✅ | 授权插件 type,逗号分隔 |
-k, --key |
私钥路径,默认 license_private.pem |
|
-o, --output |
输出路径,默认 license.lic |
|
--customer |
客户名称(仅展示/审计) | |
--lic-id |
授权编号,默认 L<日期>-<随机> |
|
--kid |
密钥标识(写入文件,仅提示) | |
--not-before |
生效日期 ISO8601,默认立即生效 | |
--expires |
到期日期 ISO8601,含当天 | |
--days |
有效期天数(与 --expires 二选一) |
|
--note |
备注(合同号等) | |
--min-match |
容错阈值(默认 3,仅影响提示文字) |
日期一律按 UTC 计算,与校验端保持一致 —— 否则时区跨日时会差一天 (出现"刚签发即过期"或"到期后仍可用一天")。
4.4 inspect — 查看内容(可选验签)
uv run chongming-license inspect license.lic # 只看内容,不验签
uv run chongming-license inspect license.lic --key <公钥> # 顺带验签
uv run chongming-license inspect license.lic --public-key-file pub.key
公钥解析顺序:--key > --public-key-file > 根 config.toml 的 [license] public_key。
三者都没有时按"只看内容、不验签"处理(打印原始 JSON + 警告),不会报错退出。
4.5 verify — 按指定环境完整校验
# 在本机校验(含指纹比对与时钟防线)
uv run chongming-license verify license.lic --key <公钥>
# 顺带判定某插件是否在授权白名单内
uv run chongming-license verify license.lic --key <公钥> --plugin doubao
# 模拟另一台机器(厂商侧发货前自检:确认这份 license 只能在目标机器上用)
uv run chongming-license verify license.lic --key <公钥> \
--request-code "CM1-目标机器的申请码..."
--machine-code / --sources-json / --request-code 都是"用指定的机器指纹代替现场采集",
用于模拟目标设备。
4.6 台账命令(list / renew / export)
每次 issue 都会自动把授权记入台账(连同机器指纹),这是后续续期与检索的基础。
用 --no-registry 可跳过。台账库默认在 ~/.chongming/licenses.db,
可用 --db 或环境变量 CHONGMING_LICENSE_DB 指定。
# 检索(默认按到期日升序,最急的排最前)
uv run chongming-license list # 全部 + 概览计数
uv run chongming-license list --status expiring # 只看即将到期
uv run chongming-license list --customer 某某科技 # 按客户
uv run chongming-license list --machine-code BC60 # 按机器码
uv run chongming-license list --plugin aibox_sophon_adapter
uv run chongming-license list --json # 供脚本处理
# 续期:**复用台账里的机器指纹,无需再向客户索取申请码**
uv run chongming-license renew --lic-id L20260921-584426 --days 365 -k license_private.pem
# 换机器续期(新机器要给出新指纹)
uv run chongming-license renew --lic-id L20260921-584426 --days 365 \
--request-code "CM1-新机器的申请码..." -k license_private.pem
# 导出归档:.lic 文件(可按客户分目录)+ CSV 清单
uv run chongming-license export --out ./archive --csv ./台账.csv --by-customer
uv run chongming-license export --out ./expired --status expired
list 输出示例:
台账库: /home/user/.chongming/licenses.db
总计 3 条 —— 有效 1、即将到期 1、已过期 1、永久 0、未生效 0
符合条件 3 条:
授权编号 客户 机器码 状态 到期 插件
------------------------------------------------------------------------------------------------------------
L20260921-8FAA47 异机客户 AAAA-BBBB-CCCC-DDDD-EEEE-FFFF 已过期(-263d) 2026-01-01 doubao
L20260921-584426 某某科技 BC60-D20E-F863-7236-68E2-6A24 即将到期(20d) 2026-10-11 aibox_sophon_adapter,ip_sound_column
L20260921-76E11D 另一客户 BC60-D20E-F863-7236-68E2-6A24 有效(400d) 2027-10-26 ip_sound_column
5. 配置参考
[license]
path = "license.lic" # 授权文件路径(相对网关工作目录)
public_key = "" # 厂商公钥(base64 裸 32 字节,或 PEM),构建时注入
min_match = 3 # 指纹命中阈值 N-of-M
# enabled = false # 仅非打包态生效;打包产物恒为强制校验
| 键 | 默认 | 说明 |
|---|---|---|
path |
license.lic |
相对网关工作目录(systemd 部署即 /data/chongming/license.lic) |
public_key |
"" |
base64 裸 32 字节或 PEM。为空时所有插件都会被拒(no_public_key) |
min_match |
3 |
见 §6.3 |
enabled |
false |
仅非打包态读取。打包产物恒为强制校验(见 §7.2) |
相关环境变量:
| 变量 | 作用 |
|---|---|
CHONGMING_LICENSE_MODE |
强制开启/关闭校验(on/off 等)。仅非打包态生效 |
CHONGMING_LICENSE_PUBKEY |
构建期注入的公钥 |
CHONGMING_LICENSE_PUBKEY_FILE |
构建期从文件读取公钥 |
CHONGMING_LICENSE_ALLOW_NO_KEY |
设为 1 允许无公钥构建(仅内部演示) |
6. 机器指纹与容错判定
6.1 采集来源
按可信度排序(utils/license/src/chongming_license/machine.py 的 SOURCES):
| 来源 | 位置 | 说明 |
|---|---|---|
board_serial |
/proc/device-tree/serial-number、DMI board_serial |
逐机唯一,最稳定 |
emmc_cid |
/sys/block/mmcblk*/device/cid |
芯片出厂唯一,不随系统重装变化 |
cpu_serial |
/proc/cpuinfo 的 Serial |
ARM 常见;部分型号全 0(会被剔除) |
mac |
/sys/class/net/*/address |
取字典序最小的物理网卡,见下 |
product_uuid |
/sys/class/dmi/id/product_uuid |
x86 有效,ARM 通常缺失 |
machine_id |
/etc/machine-id |
⚠ weak,见 §6.2 |
取值规则:
- 归一化为小写、仅保留
[0-9a-z](AA:BB:CC与aa-bb-cc归一一致) - 剔除占位/无效值:空、
unknown/none/to be filled by oem等、全0、全f、长度 < 4 mac取字典序最小而非"第一块网卡":这样插入/移除其它网卡(USB 网卡、调试接口) 以及接口重命名(eth0↔enp1s0)都不影响指纹,只有最小值那块网卡本身被换才会变- 回环及其别名(
lo、loopback0)无条件排除 ——loopback0带device符号链接, 会骗过"物理网卡"判定(WSL 上实测到)
IP 地址不参与指纹(会变)。
6.2 weak 来源:为什么不参与判定
/etc/machine-id 在同镜像批量刷机场景下,若未在首次启动重新生成,
所有机器的该值完全相同。若让它计票,等于给"把整盘镜像拷到另一台机器"
白送一票 —— 阈值越高越危险。
因此它被标记为 weak:
- 仍会记录进 license(便于诊断、也便于售后核对"这台机器确实是我签的那台")
- 不计入判定阈值
- 前端/CLI 展示时带
*标注
仅当双方都只采集到 weak 来源时才降级纳入,并在结果里标记 degraded: true
(此时安全性等同"只靠 machine_id 绑定",调用方应告警)。
6.3 N-of-M 判定
只比对双方都有的来源(license 签发时某来源缺失、或本机现在读不到该来源, 都不计入分母),命中数达到阈值即视为同一台机器:
阈值 = max(1, min(min_match, 可比较来源数))
在真实硬件(4 个可信来源 + 阈值 3)下的实测行为:
| 场景 | 命中 | 结果 |
|---|---|---|
| 同一台机器 | 4/4 | ✅ 通过 |
| 换一块网卡 | 3/4 | ✅ 通过 |
| 换一颗 eMMC | 3/4 | ✅ 通过 |
| 同时换网卡 + eMMC | 2/4 | ❌ 拒绝 |
| 整盘拷贝到另一台机器 | 0/4 | ❌ 拒绝(weak 来源不计票) |
6.4 上机验证(部署前必做)
这是唯一无法在开发机上确认的环节。在真实硬件(如 Sophon BM1684)上:
uv run chongming-license show --sources
要确认两件事:
- 两台机器的机器码必须不同 —— 若相同,说明所有可信来源都重复了
(典型原因:同镜像刷机且
machine-id是唯一来源),需要重新评估来源清单 - 同一台机器重启后机器码必须一致 —— 若变化,说明某个来源不稳定,要从清单里换掉
同时检查来源明细:可信来源越多,容错越好。若只有 1 个可信来源或大量"缺失", 说明该硬件的序列号不可读,此时换网卡就会失授权 —— 应在上线前解决。
调整来源只需改 utils/license/src/chongming_license/machine.py 的 SOURCES 元组与对应 reader。
7. 强制执行点
7.1 门禁位置
| 位置 | 作用 | 为什么必须在这里 |
|---|---|---|
lifespan_setup._discover_plugins() |
唯一收口:未授权插件从插件列表剔除,load_workers / validate_contracts / register_routes / MQTT 主题汇总随之全部一致 |
契约校验会把孤儿 TOML 路由当硬性错误并终止启动。若实现成"跳过 import 但保留在列表里",该插件的 TOML 路由就无人注册 → 设备起不来 |
RouteRegistry.set_denied_types() |
忽略未授权类型的路由描述符(与 load_mode 无关) |
否则把插件 config.toml 的 load_mode 改成 external,网关就不加载它、set_inline_types 也不再抑制它,其独立进程推来的描述符会被正常挂载 = 绕过授权 |
worker_admin._ensure_licensed() |
未授权插件的扩缩容/重启/读改配置一律 403(7 个端点全部覆盖) | 否则可借扩缩容把未授权插件重新拉起,或改其配置 |
/api/v1/license/* |
管理接口,仅超管;导入先校验后落盘 | 误传坏文件不得覆盖现有可用授权 |
| 打包态检测 | 决定是否执行拒绝 | 见 §7.2 |
7.2 为什么改配置绕不过
部署目录的 config.toml 是明文可写的,任何 enabled = false / mode = "off"
都能被客户一键改掉。因此"是否执行拒绝"不以配置为准,而以打包形态为准:
is_packaged() # sys.frozen(PyInstaller)或 __compiled__(Nuitka)
- 打包产物 → 恒为强制校验。此时若读到"关闭授权"的配置/环境变量,会留下告警日志 (陷阱日志)但不予采纳
- 非打包态(
uv run serve/ 测试)→ 默认不执行拒绝,以免影响开发流程; 需显式[license] enabled = true或CHONGMING_LICENSE_MODE=on才生效
⚠️ 该检测假设按
deploy/chongming.service用编译产物部署。 若现场改用uv run启动,则检测不生效 —— 这也意味着不能用"未打包"的方式投产。
8. 管理接口与前端
8.1 API(均需超级管理员 Bearer token)
| 端点 | 说明 |
|---|---|
GET /api/v1/license/info |
机器码、申请码、授权状态、插件授权全景、各来源明细、告警 |
POST /api/v1/license/reload |
从磁盘重读授权(手工替换文件后无需重启刷新状态) |
POST /api/v1/license/file |
上传导入 license(multipart;先校验后落盘,上限 64KB) |
/license/info 关键字段:
{
"enforce": true, // 是否执行拒绝
"enforce_forced_by_packaging": true, // 是否由打包态强制
"license_path": "license.lic",
"min_match": 3,
"request_code": "CM1-...", // 直接复制发给厂商
"status": {
"ok": true, "code": "ok", "message": "授权有效",
"machine_code": "BC60-D20E-...",
"public_key_fingerprint": "3d956a50db56b53c",
"match": { "matched": 4, "total": 4, "degraded": false, "reason": "..." },
"payload": { "lic_id": "...", "customer": "...", "plugins": [...] },
"licensed_plugins": ["aibox_sophon_adapter", "ip_sound_column"]
},
"plugins": {
"discovered": [...], // 盘扫到的
"licensed": [...], // 已授权
"denied": { "doubao": { "code": "plugin_not_licensed", "message": "..." } },
"loaded": [...] // 当前已加载
},
"sources_detail": [ { "name": "mac", "present": true, "weak": false, "value": "..." } ],
"warnings": [...]
}
8.2 前端「授权」页
地址:http://<设备IP>:8000/static/license(前端 base = /static/,顶部导航有「授权」入口)
- 机器码大字号展示,一键复制
- 申请码一键复制(附输入框便于手动选中)
- 授权信息:授权文件路径、是否有校验、授权编号/客户/签发时间/有效期、指纹比对结论、公钥指纹、备注
- 插件授权表:每个插件的 授权 / 已加载 / 说明(未授权原因)
- 硬件指纹来源表:来源 / 说明 / 采集状态 / 值 / 细节,weak 来源带
* - 导入授权文件:选择文件 + 导入并校验 + 从磁盘重读
登录复用「管理端 token 单例」(cm_admin_token),在 Worker 管理页登录过则无需重复登录。
9. 安全边界与已知缺口
9.1 诚实的边界说明
离线 + root 的天花板是"阻止随手拷贝",不是"阻止逆向"。
有 root 权限的人可以改二进制跳过校验,或用 LD_PRELOAD 伪造指纹文件读取。这是所有
纯离线硬件绑定方案的共同边界,不应对外承诺更强。
时间防线同理:clock 模块用共享缓存里的高水位标记检测系统时间回拨
(默认容差 1 天;水位超前当前时间 90 天以上判为 RTC 异常并重新起算,避免把设备永久卡死)。
删掉缓存文件即可失效 —— 它只是抬高"随手改系统时间续期"的门槛。
真正难以绕过的是签名:伪造 license 需要厂商私钥。
9.2 .mbank 加密不能当授权信任根
根仓 resources/*.key 那套 .mbank 验签用的是 HMAC 对称算法
(sqlite_vfs/core.py 的 sign → hmac.new),且密钥与银行同目录发布。
它只能算防篡改/混淆,不能作为厂商信任根 —— 授权必须用非对称。
test/test_license_build.py::test_mbank_signing_is_not_a_trust_root 固化了这一认知:
若将来 .mbank 改为非对称,该测试会提醒更新文档。
9.3 已知缺口(本轮范围外)
| 缺口 | 影响 | 彻底封堵需要 |
|---|---|---|
| external/进程插件 | 网关侧只拦住了 HTTP 暴露;直接拉起 chongming-worker --type X 进程仍会运行并可能操作 MQTT/定时任务 |
在 chongming-worker SDK 的 process.py / plugin.py 加启动自检(需发布新 wheel,外部插件容器升级后生效) |
| Rust 进程插件 | 同上,且若未覆盖会成为绕过点 | 同步改 utils/worker-rs |
| 动态加载新授权插件 | 导入新 license 后需重启才生效(插件发现只在启动期执行) | 实现运行期插件加载,是独立功能 |
| 打包态检测 | 依赖"用编译产物部署"这一前提 | 无(部署方式约束) |
10. 故障排查
10.1 按现象定位
| 现象 | 原因码 | 处理 |
|---|---|---|
| 所有插件都不加载 | license_missing |
license 文件不在 [license] path 指定位置;网页导入或放好文件后重启 |
| 所有插件都不加载 | no_public_key |
产物未注入公钥(构建漏了)。用 CHONGMING_LICENSE_PUBKEY 重新构建 |
| 所有插件都不加载 | signature_invalid |
文件被篡改,或不是本厂商公钥对应私钥签的。核对 public_key_fingerprint |
| 所有插件都不加载 | machine_mismatch |
授权不是为本机签发的,或换了硬件。看 /license/info 的 status.match.reason |
| 所有插件都不加载 | expired |
授权过期,需续签 |
| 所有插件都不加载 | not_yet_valid |
授权尚未生效(not_before 晚于当前日期) |
| 所有插件都不加载 | clock_rollback |
系统时间被回拨。检查 RTC/时区/NTP |
| 所有插件都不加载 | fingerprint_unavailable |
本机读不到任何硬件指纹。用 show --sources 排查 |
| 所有插件都不加载 | license_malformed |
JSON 损坏或载荷版本高于程序(提示升级网关) |
| 某个插件不加载 | plugin_not_licensed |
该插件不在白名单。用 --plugins 加上后重签 |
| 某个插件不加载 | 其他原因码 | 整份 license 无效时所有插件都被拒,逐个看日志 |
| 管理接口 403 | —— | 该插件未授权,或当前用户不是超管 |
| 配置了但完全没反应 | disabled |
非打包态默认不执行拒绝。把 [license] enabled 改成 true(或设 CHONGMING_LICENSE_MODE=on) |
| 打包时报"缺少厂商授权公钥" | —— | 见 §2.3,按三种方式之一提供公钥 |
| 打包时报"发现私钥" | —— | 产物里混入了私钥(.pem 或被改名的私钥内容)。移出 build/ 与 resources/ 后重建 |
10.2 原因码全表
| 代码 | 含义 |
|---|---|
ok |
授权有效 |
disabled |
校验未启用(开发/测试模式) |
license_missing |
未找到授权文件 |
license_malformed |
格式错误或内容损坏 |
signature_invalid |
签名校验失败(被篡改或非本厂商签发) |
unsupported_alg |
不支持的签名算法(仅支持 ed25519) |
no_public_key |
未配置厂商公钥 |
machine_mismatch |
机器码不匹配(非本机授权) |
not_yet_valid |
授权尚未生效 |
expired |
授权已过期 |
clock_rollback |
检测到系统时间被回拨 |
plugin_not_licensed |
该插件不在授权白名单内 |
fingerprint_unavailable |
本机无法采集硬件指纹 |
10.3 常见困惑
"为什么我配了 license 却完全没反应?"
非打包态默认 enabled = false(不干扰日常开发)。打包产物恒为强制校验,改配置关不掉。
开发时想验证门禁,必须手动把 [license] enabled 改成 true。
"我机器上只认出 1 个指纹来源,是不是坏了?"
虚拟机(WSL/容器)没有设备树、eMMC、DMI,所以只剩 mac(虚拟 MAC)与 machine_id(weak,不计票)。
真实硬件上 board_serial / emmc_cid / cpu_serial 都会出现。见 §6.4 上机验证。
"我只报机器码行不行?" 行,但授权是精确匹配,换网卡即失效。建议一律用申请码。
"导入新 license 后为什么还要重启?" 插件发现只在启动期执行。页面会提示需要重启及待加载的插件。
"能不能签一份授权给多台机器用?"
当前不支持 —— 判定必然走"逐来源指纹容错匹配"或"机器码精确匹配"其中之一,两者都绑定单机。
批量场景的处理方式是:每台设备各导出申请码,逐台签发(issue 可脚本化,
--lic-id 可指定编号便于对账)。若确需"一授权多机",需要扩展载荷格式
(例如加入机器码数组),属新功能。
11. 相关文件与测试
11.1 文件索引
| 路径 | 说明 |
|---|---|
utils/license/(chongming-license) |
库:machine.py(指纹/机器码/申请码)、models.py(载荷格式)、issue.py(载荷拼装/签名/写盘)、verify.py(规范化/验签/判定)、clock.py(时钟防线)、registry.py(已签发台账)、__init__.py(LicenseManager 门面) |
utils/cli/src/chongming_cli/license.py |
厂商侧 CLI(chongming-license,属 dev 依赖) |
src/chongming/app/core/licensing.py |
网关侧适配:配置读取、打包态判定、门禁单例、启动横幅 |
src/chongming/app/core/lifespan_setup.py |
_scan_plugins()(盘扫,不过滤)/ _discover_plugins()(门禁收口)/ denied_plugin_types() / log_license_banner() |
src/chongming/app/core/route_registry.py |
set_denied_types() + _handle_sample 中的拒绝分支 |
src/chongming/app/routes/licensing.py |
/license/* 管理端点 |
src/chongming/app/routes/worker_admin.py |
_ensure_licensed() 及 7 处调用 |
src/chongming-web/src/views/LicenseView.vue |
前端「授权」页 |
src/chongming-web/src/api/license.ts |
前端 API 封装 |
utils/cli/src/chongming_cli/build.py |
公钥注入 _set_license_pubkey() + 私钥守卫 _stage_license_guard() |
utils/license-gui/(chongming-license-gui) |
可视化工具(厂商侧):签发 / 台账 / 密钥 / 查看校验四页,独立 venv 不污染网关环境 |
config.toml / public/config.toml |
[license] 配置段(public/ 为产物模板) |
config.toml → [pyinstaller_build_import] |
PyInstaller 白名单含 chongming_license / cryptography |
11.2 license 文件格式
{
"payload": {
"v": 1,
"lic_id": "L20260921-334B33",
"customer": "某某科技",
"machine_code": "BC60-D20E-F863-7236-68E2-6A24",
"sources": { "board_serial": "...", "emmc_cid": "...", "mac": "..." },
"plugins": ["aibox_sophon_adapter", "ip_sound_column"],
"not_before": null, // 含当天;null = 立即生效
"expires_at": "2027-09-21", // 含当天;null = 永久
"issued_at": "2026-09-21T01:30:59Z",
"note": "合同 HT-2026-0912"
},
"alg": "ed25519",
"kid": "vendor-2026",
"sig": "<base64,覆盖 payload 的规范化 JSON>"
}
签名覆盖 payload 原始字节的规范化形式(canonical_json:键排序、无空白),
因此签发与校验必须共用同一个函数(chongming_license.verify.canonical_json),
且载荷内不允许浮点数(float 的文本表示存在多种合法写法,会破坏字节级一致性)。
校验顺序是先验签、后解析模型 —— 反过来会让默认值填充/字段顺序变化改变字节导致验签失败。
11.3 测试
| 文件 | 覆盖 | 断言数 |
|---|---|---|
test/test_license_verify.py |
库层:归一化、机器码、容错匹配(含 weak 不计票)、验签/篡改/换公钥/字段重排、有效期边界、机器码-only 回退、申请码、时钟防线 | 52 |
test/test_license_gate.py |
网关门禁:剔除未授权插件、契约校验不硬失败、完全未授权、过期、管理接口 403、路由描述符拦截、HTTP 端点与导入安全性 | 28 |
test/test_license_build.py |
公钥注入(段内替换、注释保留)、解析优先级、私钥守卫(含跨块边界、证书不误判)、缺公钥构建失败、.mbank 非信任根 |
22 |
test/test_license_registry.py |
台账:记录派生、五种状态(含到期当天边界)、检索与排序、续期复用指纹、备注不被重导入覆盖、导出(.lic/CSV/中文 BOM)、结构版本、并发打开 | 53 |
# 三个测试都支持独立运行(无需 pytest)
python test/test_license_verify.py
python test/test_license_gate.py
python test/test_license_build.py
python test/test_license_registry.py
# 或
uv run pytest test/test_license_*.py
回归关注点:test_route_registry.py(确认 set_denied_types 默认为空集时不影响既有行为)、
test_worker_isolation.py(确认未破坏进程插件隔离)。
12. 可视化工具(PySide6,厂商侧)
供商务、售后等不习惯命令行的同学使用,功能与 §4 的 CLI 完全对等。
所有授权逻辑调用同一个 chongming-license 库 —— 界面不重复实现密码学、载荷格式或规范化
(一旦有两处实现,就会出现"自己签的自己验不过")。
12.1 安装与运行
cd utils/license-gui
uv sync
uv run chongming-license-gui
独立环境,不污染网关:本包有自己的
pyproject.toml与.venv, 不是根仓 workspace 成员,因此根仓uv sync不会拉入 PySide6(约 167MB)。 主仓 venv 里不会出现 PySide6。
无图形环境(SSH/CI)下可做冒烟检查:
cd utils/license-gui
QT_QPA_PLATFORM=offscreen uv run python smoke_test.py # 25 项断言
12.2 三个页面
| 页面 | 内容 |
|---|---|
| 签发(默认) | 签名私钥状态与公钥指纹 → 签发依据(申请码 / 来源 JSON / 仅机器码)→ 勾选授权插件 → 客户/编号/备注/有效期 → 载荷预览 → 签发并保存(默认自动记入台账) |
| 台账 | 检索已签发授权(客户/机器码/插件/状态/关键字/即将到期)→ 续期、编辑备注、查看详情、删除 → 导出 .lic(可按客户分目录)与 CSV 清单。见 §13 |
| 密钥 | 生成密钥对(私钥 0600,公钥自动进剪贴板);加载现有私钥并显示对应公钥与指纹 |
| 查看 / 校验 | 打开 .lic 看全部字段;填公钥验签;指纹比对基准可选"本机"或"指定机器(填申请码)" |
设计上刻意做的几件事:
- 所见即所签:预览用的是与签名同一个载荷对象(
build_payload), 不是另拼一份 —— 按下签发前就能看清"给谁、哪台机器、哪些插件、多久"。 - 窗口标题带公钥指纹:同时开多个实例或截图沟通时,能一眼确认"当前用的是哪把密钥"。
- 签发前强制核对指纹:加载私钥后立即显示公钥指纹,与设备端显示比对; 拿错密钥的代价是重签并追回已发出的文件,必须在这一步拦住。
- "指定机器"校验:厂商机器自己的指纹当然对不上客户设备。不指定目标机器时 只能验出"签名有效",验不出"机器码是否真的匹配" —— 这是发货前自检的关键。
- weak 来源如实标注:解析申请码后明确列出"可信来源 N 个 / 不参与判定(weak):…", 避免操作者误以为来源越多越保险。
- 仅机器码模式带警示:界面直说"换一块网卡就会失授权,建议改用申请码"。
12.3 与 CLI 的对应
| GUI 操作 | CLI |
|---|---|
| 密钥 → 生成密钥对 | chongming-license keygen |
| 密钥 → 加载私钥 | chongming-license inspect --key / keygen 的输出 |
| 签发 → 签发并保存 | chongming-license issue |
| 查看 / 校验 → 仅查看内容 | chongming-license inspect |
| 查看 / 校验 → 指定机器 | chongming-license verify --request-code |
批量签发(一次几十台)建议直接用 CLI 脚本化(--lic-id 可指定编号便于对账);
GUI 面向单台/少量、需要人工核对信息的场景。
12.4 使用注意
- GUI 会在
QSettings里记住私钥路径、客户名、备注、输出目录、有效期天数 (便于连续签发),不会记住私钥内容本身。 - 界面里的信息框/确认框是模态的(会阻塞等待点击)。因此 GUI 的自动化测试
(
smoke_test.py)必须先替换这些弹窗,否则会挂住 —— 这是该测试唯一需要 monkeypatch 的地方。
13. 已签发台账
13.1 为什么需要台账
签出去的 license 会散落在邮件、聊天记录、下载目录里。等到要续期时,常见困境是:
- 不知道当初给哪台机器签过、签的哪些插件
- 拿不到那台机器的硬件指纹 —— 而续期需要它;现场还可能已经联系不上客户
- 到期了没人知道,客户突然反馈"插件不能用了"才发现
台账把每次签发连同机器指纹一起存下来,于是:按客户/机器码/插件/有效期检索、 到期前主动列出该续期的清单、续期直接复用台账指纹(最大价值)、原样导出当初的 文件(审计/补发)、识别"哪份是密钥轮换前的旧公钥签的"。
13.2 位置与安全
| 项 | 说明 |
|---|---|
| 默认路径 | ~/.chongming/licenses.db |
| 覆盖方式 | --db <路径> 或环境变量 CHONGMING_LICENSE_DB;GUI 在「台账」页选择并记住 |
| 存储 | 单文件 SQLite(纯标准库,无服务、可直接进备份),WAL 模式支持多窗口并发 |
| 版本 | 库内 PRAGMA user_version;不匹配时明确报错而不是让查询诡异失败 |
| 敏感度 | 含客户设备的硬件指纹与授权信息,属厂商内部数据,不宜外发 |
刻意放在用户主目录而非项目内:台账必须能跨项目、跨工具长期留存,放在仓库里容易被 清理脚本删掉或换机器时随项目一起丢掉。
13.3 自动入库
issue 与 GUI 的「签发并保存」默认把授权写入台账(GUI 上是一个默认勾选的复选框):
✓ 已签发 license.lic
授权编号 L20260921-584426
...
已入台账 /home/user/.chongming/licenses.db
续期:`chongming-license renew --lic-id L20260921-584426 --days 365`
(复用台账指纹,无需客户再报申请码)
入库失败不会把"已签发"变成失败 —— 文件已经生成好了,只是台账没记上, 此时会明确告警(避免误以为需要重签)。
13.4 状态与字段
状态按优先级判定:not_yet_valid(未到生效日)> expired > expiring(窗口内)
forever(无到期日)/active。判定复用同一个status()方法, 因此各状态互斥、计数之和等于总数(用独立的 SQL 条件会因重叠而算错)。
关键字段:授权编号、客户、机器码、授权插件、各来源指纹、生效/到期日、签发时间、 密钥标识、签发时的公钥指纹、原始 license 文件内容、备注。
备注是本台账唯一允许人工编辑的字段("已电话确认续期"、"设备已返修"…),
重新导入同一份 license 不会覆盖它 —— 载荷里的 note 是签名内容的一部分,
台账的 note 是厂商自己的批注,二者同名但不同用途。
13.5 续期(本功能的核心)
台账 → 选中一条 → 「续期选中…」→ 只改要变的部分(有效期/插件/客户)→ 续期并保存
- 指纹复用自台账,无需再向客户索取申请码 —— 这是台账最大的价值
- 默认生成新授权编号,新旧都留在台账里,可追溯;也可选"沿用原授权编号"
- 旧记录会被自动加上
已续期 → <新编号>的批注,从任一编号都能串起来 - 原授权若当初是"仅机器码精确匹配"签的(没有来源指纹),续期会明确拒绝并提示 重新索取申请码 —— 而不是悄悄签出一份会在设备上因指纹不符被拒的授权
换机器续期时用 --request-code 给出新机器的指纹:
uv run chongming-license renew --lic-id L20260921-584426 --days 365 \
--request-code "CM1-新机器的申请码..." -k license_private.pem
13.6 导出与归档
uv run chongming-license export --out ./archive --csv ./台账.csv --by-customer
.lic文件名形如<授权编号>_<机器码>.lic,内容原样(可直接补发给客户)--by-customer按客户名分子目录,便于打包发给对应客户 (客户名里的路径非法字符会被安全替换)- CSV 以 UTF-8 BOM 写入:否则 Excel 在中文 Windows 下按 GBK 解码会乱码; 插件列表和备注含逗号时按 CSV 规范加引号转义
13.7 使用注意
- 台账只删记录,不会吊销已发出的 license —— 客户手里的文件仍然有效, 因为离线方案里没有吊销通道(见 §9.3)。删除前请确认这一点。
- 台账记录了签发时的公钥指纹,密钥轮换后可以用它筛出"需要重签"的历史授权。
- 台账规模(几百到几千条)完全在 SQLite 的舒适区;筛选逻辑放在 Python 侧以复用
status(),避免 SQL 与 Python 两套状态判定漂移。