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

859 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 机器码授权指南
> 机器码授权按客户设备签发 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 全流程
```mermaid
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 生成密钥对(一次性)
```bash
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`
```bash
# 方式 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):
```bash
uv run chongming-license show --request-code # 只输出申请码
uv run chongming-license show --sources # 各来源明细表
uv run chongming-license show --json # 全量 JSON(可存文件传给厂商)
```
### 3.2 厂商侧:签发
```bash
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` — 查看内容(可选验签)
```bash
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` — 按指定环境完整校验
```bash
# 在本机校验(含指纹比对与时钟防线)
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` 指定。
```bash
# 检索(默认按到期日升序,最急的排最前)
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. 配置参考
```toml
[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)上:
```bash
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"`
都能被客户一键改掉。因此"是否执行拒绝"不以配置为准,而以**打包形态**为准:
```python
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` 关键字段:
```jsonc
{
"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 文件格式
```jsonc
{
"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 |
```bash
# 三个测试都支持独立运行(无需 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 安装与运行
```bash
cd utils/license-gui
uv sync
uv run chongming-license-gui
```
> **独立环境,不污染网关**:本包有自己的 `pyproject.toml` 与 `.venv`,
> **不是**根仓 workspace 成员,因此根仓 `uv sync` 不会拉入 PySide6(约 167MB)。
> 主仓 venv 里不会出现 PySide6。
无图形环境(SSH/CI)下可做冒烟检查:
```bash
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` 给出新机器的指纹:
```bash
uv run chongming-license renew --lic-id L20260921-584426 --days 365 \
--request-code "CM1-新机器的申请码..." -k license_private.pem
```
### 13.6 导出与归档
```bash
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 两套状态判定漂移。