Files
2026-09-21 14:03:47 +08:00

41 KiB
Raw Permalink Blame History

机器码授权指南

机器码授权按客户设备签发 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.lic
  • uv run build 的 授权私钥守卫阶段按内容扫描 build/(跨 1MB 块边界也能检出), 发现私钥立即终止构建
  • keygen 每次都会打印保管警示

2.3 公钥注入构建

构建时按以下顺序解析公钥,注入产物 config.toml 的 [license] public_key:

  1. 环境变量 CHONGMING_LICENSE_PUBKEY
  2. 环境变量 CHONGMING_LICENSE_PUBKEY_FILE 指定的文件
  3. 项目根的 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 密钥轮换

公钥指纹进入判定,因此轮换是显式操作:

  1. keygen -o new_private.pem 生成新密钥对
  2. 用新公钥重新构建并分发产物
  3. 已发出去的 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

要确认两件事:

  1. 两台机器的机器码必须不同 —— 若相同,说明所有可信来源都重复了 (典型原因:同镜像刷机且 machine-id 是唯一来源),需要重新评估来源清单
  2. 同一台机器重启后机器码必须一致 —— 若变化,说明某个来源不稳定,要从清单里换掉

同时检查来源明细:可信来源越多,容错越好。若只有 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 两套状态判定漂移。