# 机器码授权指南 > 机器码授权按客户设备签发 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 厂商
(私钥) participant D as 设备
(公钥) participant C as 客户 Note over V: ① keygen 生成密钥对(一次性)
公钥注入构建产物 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--<校验和>`)—— 发给厂商签发 - **硬件指纹来源明细** —— 哪个来源采集成功、哪个缺失,装机时用来核对 > **为什么用申请码而不是只报机器码**:机器码是单向哈希,**反推不出各来源指纹**, > 而 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": "" } ``` **签名覆盖 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 两套状态判定漂移。