modify: 授权

This commit is contained in:
2026-09-21 14:03:47 +08:00
parent 66e82c02e7
commit 8a3dc8036c
19 changed files with 2826 additions and 30 deletions
+858
View File
@@ -0,0 +1,858 @@
# 机器码授权指南
> 机器码授权按客户设备签发 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 两套状态判定漂移。