From 8a3dc8036ccebbd26074ac4c9c811f171abbfa09 Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Mon, 21 Sep 2026 14:03:47 +0800 Subject: [PATCH] =?UTF-8?q?modify:=20=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 14 +- README.md | 52 ++ config.toml | 45 +- docs/license.md | 858 +++++++++++++++++++++++ public/config.toml | 17 + public/utils/launch.py | 12 +- pyproject.toml | 1 + src/chongming/app/__init__.py | 12 +- src/chongming/app/core/licensing.py | 142 ++++ src/chongming/app/core/lifespan_setup.py | 83 ++- src/chongming/app/core/route_registry.py | 28 + src/chongming/app/routes/__init__.py | 9 +- src/chongming/app/routes/licensing.py | 208 ++++++ src/chongming/app/routes/worker_admin.py | 50 +- test/test_license_build.py | 224 ++++++ test/test_license_gate.py | 377 ++++++++++ test/test_license_registry.py | 327 +++++++++ test/test_license_verify.py | 358 ++++++++++ uv.lock | 39 +- 19 files changed, 2826 insertions(+), 30 deletions(-) create mode 100644 docs/license.md create mode 100644 src/chongming/app/core/licensing.py create mode 100644 src/chongming/app/routes/licensing.py create mode 100644 test/test_license_build.py create mode 100644 test/test_license_gate.py create mode 100644 test/test_license_registry.py create mode 100644 test/test_license_verify.py diff --git a/.gitignore b/.gitignore index 37c5793..1895ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,16 @@ config # lib lib auto.crt -auto.key \ No newline at end of file +auto.key + +# 机器码授权 +# 签发私钥是授权的唯一信任根,绝不入库(丢了无法再签发,泄漏则人人可签发); +# license.lic 是设备运行期文件,公钥由构建期从环境变量/文件注入。 +license_private*.pem +license_public.key +license.lic +# 台账库(默认在 ~/.chongming/,若显式放到项目内也不应提交) +licenses.db +licenses.db-wal +licenses.db-shm +授权台账.csv \ No newline at end of file diff --git a/README.md b/README.md index 5d13a6c..aa72b21 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ | **构建打包** | PyInstaller / Nuitka 一键打包为独立可执行文件 | | **Vue 3 前端** | Element Plus UI、Pinia 状态管理、类型安全的 API 客户端 | | **Worker 运维管理** | 前端经 Zenoh 向单个副本下发 restart/stop/改配置指令(超管),前端可视化列表 + 分页管理 | +| **机器码授权** | 离线 Ed25519 签名 license:绑定机器指纹 + 插件白名单 + 有效期,未授权插件不加载 | --- @@ -356,6 +357,56 @@ external 副本走同一 `__admin__` Queryable,路由发现、指令下发与 --- +## 机器码授权 + +按客户设备「机器码」签发授权:**一份 license 绑定一台机器 + 一批插件 + 有效期**, +未授权的插件不加载、不注册路由、管理接口禁止操作。**纯离线** —— 设备用内置厂商公钥验签, +运行期零联网(与 Zenoh localhost、Caddy `tls internal` 的 air-gapped 形态一致)。 + +> 📖 **完整指南见 [docs/license.md](docs/license.md)** —— 密钥管理、激活流程、 +> 命令行参考、可视化工具、机器指纹与容错判定、强制执行点、安全边界、 +> 故障排查(含原因码全表)。下面只保留最小上手路径。 +> +> 厂商侧有**两种等价工具**(共用同一份实现):不习惯命令行的话用可视化工具 +> —— `cd utils/license-gui && uv sync && uv run chongming-license-gui`(签发 / 密钥 / 校验三页)。 + +### 最小上手路径 + +```bash +# ① 厂商侧:生成密钥对(一次性)。私钥离线保管并备份,绝不进仓库/产物 +uv run chongming-license keygen -o license_private.pem +# → 输出公钥,按提示注入构建(CHONGMING_LICENSE_PUBKEY 环境变量 / license_public.key 文件) +# → 打包时缺少公钥会直接失败:缺公钥的产物会拒绝加载所有插件,等于砖头 + +# ② 设备侧:浏览器打开 http://<设备IP>:8000/static/license,登录后「复制申请码」 + +# ③ 厂商侧:签发 +uv run chongming-license issue --request-code "CM1-客户发来的..." \ + --plugins aibox_sophon_adapter,ip_sound_column \ + --customer "某某科技" --days 365 -k license_private.pem -o license.lic + +# ④ 设备侧:同一页面「导入并校验」→「重启网关」生效 +``` + +签发会**自动记入台账**(`~/.chongming/licenses.db`,含机器指纹)。续期时直接复用, +**无需再向客户索取申请码**: + +```bash +uv run chongming-license list # 台账检索(按到期日升序) +uv run chongming-license renew --lic-id L2026... --days 365 -k license_private.pem +uv run chongming-license export --out ./archive --csv ./台账.csv --by-customer +``` + +### 三条需要记住的约束 + +- **申请码 vs 机器码**:机器码是短哈希,只报它只能签出**精确匹配**的授权(换网卡即失效)。 + 申请码带回各来源原始值,才能签出支持换硬件的**容错**授权。 +- **开发态默认不执行拒绝**:`[license] enabled` 默认 `false`,改配置也绕不过(打包产物恒为强制校验, + 依据 `sys.frozen`/`__compiled__` 判定)。开发时想验证门禁需手动改成 `true`。 +- **设备侧没有签发命令**:`chongming-cli` 是开发依赖,不随产物发布 —— 签发能力只存在于厂商机器上。 + +--- + ## 端点类型与路由注册 ### 端点类型 @@ -859,5 +910,6 @@ uv pip install -e utils/ # 安装/更新工具包子包 - [配置说明](config/README.md) - [插件开发指南](src/plugins/README.md) - [进程插件开发指南(external 模式,从开发到部署)](docs/process-plugin.md) +- [机器码授权指南(密钥管理 / 激活流程 / 故障排查)](docs/license.md) - [前端开发指南](src/chongming-web/README.md) - [Worker 框架](utils/worker/README.md) diff --git a/config.toml b/config.toml index 373b7ae..9d782e3 100644 --- a/config.toml +++ b/config.toml @@ -11,7 +11,7 @@ upload_path = "uploads" json5_path = "./config/zenoh_config.json5" [cache] -backend = "sqlite" # "sqlite"(默认,本机共享) | "redis"(跨主机) +backend = "sqlite" # "sqlite"(默认,本机共享) | "redis"(跨主机) # backend = "redis" # path = "./cache_data/cache.db" @@ -38,6 +38,22 @@ redis_prefix = "chongming:" algorithm = "HS256" secret_key = "your-secret-key-change-in-production" +[license] +# 机器码授权(插件白名单)。未授权插件不加载、不注册路由、管理接口禁止操作。 +# +# enabled 只在**非打包态**(uv run serve / 测试)决定是否执行拒绝:打包产物 +# (Nuitka/PyInstaller)恒为强制校验 —— 部署目录的 config.toml 是明文可写的, +# 若以配置开关为准,改一行就能绕过授权。 +enabled = false +# 授权文件路径(相对网关工作目录) +path = "license.lic" +# 厂商公钥(base64 裸 32 字节,或 PEM)。公钥可明文放置:换公钥只会验签失败, +# 无法据以伪造授权(伪造需要厂商私钥)。构建时由 build.py 从 +# CHONGMING_LICENSE_PUBKEY / license_public.key 注入。 +public_key = "" +# 指纹命中阈值(N-of-M)。4 个可信来源下取 3 表示容忍单块网卡或单颗 eMMC 更换。 +min_match = 3 + [development] debug = true env = "development" @@ -102,7 +118,7 @@ config_path = "./config/workers/config.toml" [pyinstaller_build_import] base = [ - "utils", # public 骨架 + "utils", # public 骨架 "utils.launch", "utils.config", "zenoh", @@ -112,13 +128,16 @@ base = [ "chongming_worker.routes_publisher", "chongming_request", "chongming_auth", + "chongming_license", # 机器码授权(指纹采集/验签/判定) + "cryptography", # Ed25519 验签 + "cryptography.hazmat.primitives.asymmetric.ed25519", "server", - "module_bank", # 模块化打包工具 + "module_bank", # 模块化打包工具 "sqlite_vfs", "sqlite_vfs.core", "sqlite_vfs.contrib", "sqlite_vfs.contrib.fastapi_static", - "fastapi", # web 应用依赖 + "fastapi", # web 应用依赖 "fastapi.staticfiles", "fastapi.middleware.cors", "fastapi.middleware.gzip", @@ -149,16 +168,20 @@ base = [ "aiofiles", "openai", ] -linux = [ - "gunicorn.glogging", - "gunicorn.app", - "gunicorn.app.wsgiapp", -] +linux = ["gunicorn.glogging", "gunicorn.app", "gunicorn.app.wsgiapp"] windows = [] [production.module_system] path = [ - ["resources/app.mbank", "src/chongming/app", "app"], - ["resources/plugins.mbank", "src/plugins", "plugins"], + [ + "resources/app.mbank", + "src/chongming/app", + "app", + ], + [ + "resources/plugins.mbank", + "src/plugins", + "plugins", + ], ] type = "module-bank" diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..7b5bf67 --- /dev/null +++ b/docs/license.md @@ -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 厂商
(私钥) + 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 两套状态判定漂移。 diff --git a/public/config.toml b/public/config.toml index a7ac02e..1b08c92 100644 --- a/public/config.toml +++ b/public/config.toml @@ -28,6 +28,23 @@ launch_worker_count = "cache" algorithm = "HS256" secret_key = "your-secret-key-change-in-production" +[license] +# 机器码授权(插件白名单)。未授权插件不加载、不注册路由、管理接口禁止操作。 +# +# 打包产物(Nuitka/PyInstaller)**恒为强制校验**,不存在可被配置绕过的开关: +# 部署目录的 config.toml 是明文可写的,任何 enabled/mode 开关都能被一键改掉。 +# 只有非打包态(开发/测试)才会读取 enabled,默认关闭以免影响开发流程。 +# enabled = false +# 授权文件路径(相对网关工作目录) +path = "license.lic" +# 厂商公钥(base64 裸 32 字节,或 PEM)。公钥可明文放置:换公钥只会验签失败, +# 无法据以伪造授权(伪造需要厂商私钥)。 +# 构建时由 build.py 从 CHONGMING_LICENSE_PUBKEY 或 license_public.key 注入; +# 缺失时构建会失败(避免产出无法验签、所有插件都加载不了的"砖头"包)。 +public_key = "" +# 指纹命中阈值(N-of-M)。4 个可信来源下取 3 表示容忍单块网卡或单颗 eMMC 更换。 +min_match = 3 + [production] debug = false env = "production" diff --git a/public/utils/launch.py b/public/utils/launch.py index 6db5547..e0963ab 100644 --- a/public/utils/launch.py +++ b/public/utils/launch.py @@ -2,6 +2,7 @@ from .config import ConfigLoader import os from module_bank import PythonToSQLite from typing import Callable +from pathlib import Path # module_bank 依赖在每次加载 PyArmor 加密模块时都会 print 一行 # "已部署 PyArmor 运行时",启动期会刷屏(约 50 行)。依赖包不便改动, @@ -36,8 +37,15 @@ def init_module_bank(config): ] # 遍历所有打包器,安装导入器(模块清单不再打印,避免启动刷屏) for packer in packer_list: - key = packer.get_key() - packer.install_importer(key=key) + key_file_path = Path(packer.db_path).with_suffix(".key") + with open(key_file_path, "r", encoding="utf-8") as f: + key = f.read().strip() + packer.install_importer( + key=key, + signature_key="my_key", + signature_key_id="ci-2026", + allow_unsigned=False, + ) def launch(run_fun: Callable[[dict, dict], None], run_mode: str): diff --git a/pyproject.toml b/pyproject.toml index 530d0b2..ec2b608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "bytedance", "chongming-cache", "chongming-auth", + "chongming-license", ] description = "基于 modules-bank 的 fastapi 打包脚手架" name = "chongming-zenoh" diff --git a/src/chongming/app/__init__.py b/src/chongming/app/__init__.py index e2012d1..fe702a8 100644 --- a/src/chongming/app/__init__.py +++ b/src/chongming/app/__init__.py @@ -23,7 +23,13 @@ from .core.logger import get_logger # from .core.static_files import SVFSStaticFiles from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles from .core import lifespan_setup, call_stats -from .routes import storage_router, stats_router, worker_admin_router, auth_router +from .routes import ( + storage_router, + stats_router, + worker_admin_router, + auth_router, + license_router, +) config = get_config() @@ -63,6 +69,9 @@ async def lifespan(app: FastAPI): is_inline_owner = manager.acquire_inline_owner() app.state.inline_owner_locked = is_inline_owner + # 4.3 机器码授权状态横幅(需 WorkerManager 就绪:时钟水位存共享 cache) + lifespan_setup.log_license_banner(app) + # 5. 插件加载(混合模式:inline 进程内加载;external 由独立进程运行, # 其路由经 Zenoh 动态注册——进程编排交给 Docker/K8s,网关不拉起进程) worker_tasks, custom_routers, inline_types, external_types = ( @@ -315,3 +324,4 @@ app.include_router(storage_router) app.include_router(stats_router) app.include_router(worker_admin_router) app.include_router(auth_router) +app.include_router(license_router) diff --git a/src/chongming/app/core/licensing.py b/src/chongming/app/core/licensing.py new file mode 100644 index 0000000..bc84334 --- /dev/null +++ b/src/chongming/app/core/licensing.py @@ -0,0 +1,142 @@ +"""网关侧机器码授权门禁。 + +职责:把 `chongming_license` 的判定接进网关生命周期 —— 读配置、建管理器、 +在插件发现阶段拒绝未授权插件、输出启动横幅。 + +**为什么"是否执行拒绝"不读配置开关**:部署目录的 `config.toml` 是明文可写的, +任何 `enabled = false` / `mode = "off"` 都能被一键绕开。因此以**打包形态**为准 +(Nuitka/PyInstaller 产物 `sys.frozen`/`__compiled__`):打包即强制校验,且 +改配置无法伪造打包与否。开发/测试(非打包)默认放行,需显式用 +``[license] enabled = true`` 或 ``CHONGMING_LICENSE_MODE=on`` 打开才能验证门禁逻辑。 + +安全边界说明:授权信任根是**厂商私钥**(Ed25519 签名),公钥可明文放置 —— +换公钥只会验签失败,无法据以伪造。注意根仓 `resources/*.key` 那套 `.mbank` +验签是 HMAC **对称**且密钥随包发布,只能算防篡改,**不能**作为授权信任根。 +""" +from __future__ import annotations + +import os +import sys + +from chongming_cache import Cache +from chongming_config import get_config +from chongming_license import ( + DEFAULT_MIN_MATCH, + LicenseManager, + LicenseStatus, + PluginDecision, +) + +from .logger import get_logger + +logger = get_logger("license") + +# 默认授权文件路径(相对网关工作目录,与 config/、data/ 同级) +DEFAULT_LICENSE_PATH = "license.lic" + +# 强制开启/关闭的环境变量(仅非打包态生效;打包态恒为强制校验) +ENV_MODE = "CHONGMING_LICENSE_MODE" +_TRUTHY = {"on", "enforce", "enabled", "true", "1", "yes"} + +_manager: LicenseManager | None = None + + +def is_packaged() -> bool: + """当前是否运行在打包产物中(Nuitka / PyInstaller)。 + + Nuitka 在模块全局注入 ``__compiled__``,PyInstaller 设置 ``sys.frozen``。 + """ + return bool(getattr(sys, "frozen", False) or globals().get("__compiled__")) + + +def license_conf() -> dict: + """``[license]`` 配置段(缺省为空 dict)。""" + return get_config().get("license", {}) or {} + + +def should_enforce() -> bool: + """是否真正执行拒绝。 + + 打包产物 **恒为 True**(见模块文档);非打包态由配置/环境变量决定, + 默认 False 以免影响现有开发流程与测试。 + """ + conf = license_conf() + env_mode = os.environ.get(ENV_MODE, "").strip().lower() + + if is_packaged(): + if conf.get("enabled") is False or env_mode in ("off", "disable", "disabled", "0"): + # 陷阱日志:打包态却看到"关闭授权"的意图 —— 不予采纳,但要留痕 + logger.warning( + "[license] 检测到关闭授权的配置/环境变量,但当前为打包产物," + "授权校验强制开启(该配置不生效)。若确需关闭请联系研发。" + ) + return True + + if env_mode: + return env_mode in _TRUTHY + return bool(conf.get("enabled", False)) + + +def _build_manager() -> LicenseManager: + conf = license_conf() + return LicenseManager( + license_path=str(conf.get("path") or DEFAULT_LICENSE_PATH), + public_key=str(conf.get("public_key") or ""), + min_match=int(conf.get("min_match", DEFAULT_MIN_MATCH)), + # 时钟水位存共享缓存(sqlite 本机 / redis 跨主机),不加 TTL: + # 水位一旦过期被淘汰,回拨检测就失效了 + clock_store=Cache.from_config(get_config()["cache"]), + enforce=should_enforce(), + logger=logger, + ) + + +def get_manager() -> LicenseManager: + """授权管理器单例(惰性创建)。""" + global _manager + if _manager is None: + _manager = _build_manager() + return _manager + + +def reset_manager() -> None: + """丢弃单例(测试用;换配置或换 license 文件后重新判定)。""" + global _manager + _manager = None + + +def reload() -> LicenseStatus: + """重新读取 license 文件(管理接口导入新文件后调用)。""" + return get_manager().reload() + + +def check_plugin(plugin_type: str) -> PluginDecision: + """该插件是否获授权运行。""" + return get_manager().check_plugin(plugin_type) + + +def denied_types(plugin_types) -> set[str]: + """从给定插件类型里筛出未获授权的(即不允许加载的)。""" + return get_manager().denied_types(plugin_types) + + +def describe() -> dict: + """给 ``GET /license/info`` 与前端「授权」页的完整信息。""" + data = get_manager().describe() + data["enforce_forced_by_packaging"] = is_packaged() + data["license_path"] = get_manager().license_path + return data + + +def log_banner() -> None: + """启动横幅:机器码、授权状态、授权插件、公钥指纹。""" + try: + for line in get_manager().banner(): + logger.info(line) + if get_manager().enforce and not get_manager().status.ok: + logger.error( + "[license] 未授权状态下所有插件均不会加载;" + "请在管理页「授权」导入 license 文件,或联系厂商签发(需提供本机机器码)。" + ) + except Exception as exc: # 授权模块异常不应阻断启动流程 + logger.error("[license] 授权状态检查异常: %s", exc) diff --git a/src/chongming/app/core/lifespan_setup.py b/src/chongming/app/core/lifespan_setup.py index c7435e5..ec4ddcf 100644 --- a/src/chongming/app/core/lifespan_setup.py +++ b/src/chongming/app/core/lifespan_setup.py @@ -326,6 +326,16 @@ async def setup_route_registry( registry = RouteRegistry(app, manager, prefix=prefix) if inline_types: registry.set_inline_types(set(inline_types)) + # 授权门禁:未授权类型一律忽略其路由描述符。此处独立重算(不依赖 load_workers + # 的调用顺序),避免模块级状态带来的隐式时序耦合。 + from .licensing import denied_types as _denied_types, get_manager as _get_license + if _get_license().enforce: + denied = _denied_types({p["type"] for p in _scan_plugins()}) + registry.set_denied_types(denied) + if denied: + logger.warning( + "路由注册表已拒绝未授权插件描述符: %s", sorted(denied) + ) await registry.start(session) app.state.route_registry = registry logger.info( @@ -335,9 +345,9 @@ async def setup_route_registry( return registry -def _discover_plugins() -> list[dict]: +def _scan_plugins() -> list[dict]: """ - 发现所有插件配置。 + 扫描全部插件配置(**不做授权过滤**,授权门禁见 _discover_plugins)。 优先从各插件的独立配置文件 config/workers//config.toml 的 [workers] 节读取, 兼容旧的 config/workers/config.toml 全局配置。 @@ -422,6 +432,75 @@ def _discover_plugins() -> list[dict]: return plugins +# 最近一次授权门禁的拒绝明细:插件类型 -> 拒绝原因 +_denied_plugin_types: dict[str, str] = {} + + +def denied_plugin_types() -> dict[str, str]: + """最近一次插件发现中被授权门禁拒绝的类型 -> 原因(供管理接口展示)。""" + return dict(_denied_plugin_types) + + +def log_license_banner(app: FastAPI | None = None) -> None: + """启动横幅:机器码、授权状态、授权插件、公钥指纹。 + + 在 WorkerManager 就绪后调用(时钟水位写入共享 cache)。异常不阻断启动 —— + 授权模块自身出问题时仍应让网关起来并留下日志,而不是变成一个起不来的黑盒。 + """ + from .licensing import log_banner + + try: + log_banner() + except Exception as exc: + logger.error("[license] 授权状态横幅输出失败: %s", exc) + + +def _discover_plugins() -> list[dict]: + """发现插件并应用**授权门禁**,返回可加载的插件列表。 + + 这里是机器码授权的**唯一收口**:load_workers / validate_contracts / + register_routes(以及旧写法下的 MQTT 主题汇总)全部经由本函数取插件列表, + 因此在此剔除未授权插件即可让三处自动保持一致。 + + 必须收口于此而不是"跳过 import":契约校验(``validate_contracts``)会把 + 该插件的 TOML 路由视为"缺少 @rpc_method 注册"的硬性错误并**终止启动** —— + 只跳过 import 会让设备直接起不来。在此剔除后,该插件连同其 TOML 路由、 + 自定义路由、MQTT 主题、副本数一起从启动流程中消失,契约校验自然跳过。 + + external 插件的路由由 RouteRegistry 动态挂载,不经本函数,故另有 + ``RouteRegistry.set_denied_types`` 封堵(否则把 load_mode 改成 external + 即可绕过门禁)。 + """ + plugins = _scan_plugins() + + from .licensing import check_plugin, get_manager + + if not get_manager().enforce: + # 未启用(开发/测试):不剔除任何插件 + _denied_plugin_types.clear() + return plugins + + allowed: list[dict] = [] + denied: dict[str, str] = {} + for p in plugins: + decision = check_plugin(p["type"]) + if decision.allowed: + allowed.append(p) + else: + denied[p["type"]] = decision.message + + _denied_plugin_types.clear() + _denied_plugin_types.update(denied) + for plugin_type in sorted(denied): + logger.error("[license] %s", denied[plugin_type]) + if denied: + logger.warning( + "授权门禁已拒绝 %d 个插件(不加载、不注册路由、管理接口禁止操作): %s", + len(denied), sorted(denied), + ) + return allowed + + async def load_workers(app: FastAPI, session: zenoh.Session, manager: WorkerManager, is_inline_owner: bool = True): """ diff --git a/src/chongming/app/core/route_registry.py b/src/chongming/app/core/route_registry.py index 7ef3553..2e40b92 100644 --- a/src/chongming/app/core/route_registry.py +++ b/src/chongming/app/core/route_registry.py @@ -74,6 +74,10 @@ class RouteRegistry: self._mqtt_bridge = None # 网关进程内加载(inline)的插件类型:忽略其描述符,防止与盘扫注册重复挂载 self._inline_types: set = set() + # 被授权门禁拒绝的插件类型:忽略其描述符(未获授权的插件不得暴露任何 HTTP 端点) + self._denied_types: set = set() + # 已告警过的拒绝类型(避免 worker 周期性重发布描述符导致日志刷屏) + self._denied_logged: set = set() # 将动态 schema 注入 OpenAPI(provider 实时读取本注册表) _inject_schemas_to_openapi( @@ -87,6 +91,20 @@ class RouteRegistry: """设置 inline 插件类型集合(混合模式防双注册)。""" self._inline_types = set(types) + def set_denied_types(self, types: set): + """设置被授权门禁拒绝的插件类型集合,其路由描述符一律忽略。 + + 必要性:门禁在网关侧只能拦住"盘扫加载"这条路径。若把某插件 config.toml + 的 ``load_mode`` 改成 ``external``,网关就不再加载它、``set_inline_types`` + 也不再抑制它,其独立进程推来的路由描述符就会被正常挂载 —— 等于绕过了 + 授权。因此这里对未授权类型再做一道拦截,且**与 load_mode 无关**。 + + 局限(如实记录):这只拦截 HTTP 暴露,被直接拉起的 external 进程自身 + 仍会运行。彻底封堵需在 chongming-worker SDK 侧加门禁(另行规划)。 + """ + self._denied_types = set(types) + self._denied_logged &= self._denied_types + async def start(self, session: zenoh.Session): """订阅 chongming/routes/**,开始接收路由描述符。""" self._loop = asyncio.get_running_loop() @@ -151,6 +169,16 @@ class RouteRegistry: # 该插件由网关进程内加载(盘扫注册路由),忽略外部描述符防双注册 return + if wtype in self._denied_types: + # 授权门禁拒绝的插件:不下发任何 HTTP 端点(与 load_mode 无关) + if wtype not in self._denied_logged: + self._denied_logged.add(wtype) + logger.error( + "拒绝未授权插件 %s 的路由描述符(key=%s):该插件不在授权白名单内," + "其端点不会被挂载", wtype, key, + ) + return + if kind == zenoh.SampleKind.DELETE: self._remove(wtype, route_id) return diff --git a/src/chongming/app/routes/__init__.py b/src/chongming/app/routes/__init__.py index 8599271..a9b4f0b 100644 --- a/src/chongming/app/routes/__init__.py +++ b/src/chongming/app/routes/__init__.py @@ -2,5 +2,12 @@ from .storage import router as storage_router from .stats import router as stats_router from .worker_admin import router as worker_admin_router from .auth import router as auth_router +from .licensing import router as license_router -__all__ = ["storage_router", "stats_router", "worker_admin_router", "auth_router"] \ No newline at end of file +__all__ = [ + "storage_router", + "stats_router", + "worker_admin_router", + "auth_router", + "license_router", +] \ No newline at end of file diff --git a/src/chongming/app/routes/licensing.py b/src/chongming/app/routes/licensing.py new file mode 100644 index 0000000..00a26a5 --- /dev/null +++ b/src/chongming/app/routes/licensing.py @@ -0,0 +1,208 @@ +"""机器码授权管理端点(查看授权状态 / 展示机器码 / 导入 license 文件)。 + +设计取舍: + +- **仅超级管理员可用**:机器码与各硬件来源指纹属设备标识信息,且导入 license 会 + 改变整个网关的插件加载行为,与 `worker_admin` 同级风险,统一按超管门禁。 + 设备上默认已 seed admin/admin,现场装机人员登录后即可取机器码,不额外加门槛。 +- **导入先验证后落盘**:非法/验签失败/机器码不符的文件一律拒绝且不覆盖现有文件, + 避免"上传了一个坏文件导致原本可用的授权也没了"。 +- **导入不即时加载插件**:`_discover_plugins` 在启动期完成,新授权的插件要等 + 网关重启才会真正加载;响应里的 `restart_required` 如实告知,前端据此提示。 + +路径为 ``/api/v1/license/...``(跟随 ``[default] prefix``)。 +""" +from __future__ import annotations + +import json +import os +import shutil +import tempfile +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile +from chongming_config import get_config + +from ..core import licensing +from ..core.auth import jwt_auth_dependency +from ..core.lifespan_setup import _scan_plugins, denied_plugin_types +from ..core.logger import get_logger + +config = get_config() + +logger = get_logger("license") + +router = APIRouter(prefix=config["default"]["prefix"], tags=["license"]) + +# license 文件大小上限:正常文件仅数 KB,给足余量的同时避免被当作上传通道 +MAX_LICENSE_BYTES = 64 * 1024 + + +def require_superuser( + payload: dict = Depends(jwt_auth_dependency), +) -> dict: + """仅超级管理员可访问(与 worker_admin 一致的门禁口径)。""" + if not payload.get("is_superuser"): + raise HTTPException(status_code=403, detail="需要超级管理员权限 (is_superuser)") + return payload + + +def _loaded_types(request: Request) -> set[str]: + """当前已实际加载的插件类型(inline + external 观测集合)。""" + return set(getattr(request.app.state, "inline_types", None) or []) | set( + getattr(request.app.state, "external_types", None) or [] + ) + + +def _plugin_overview(request: Request) -> dict: + """插件视角的授权全景:盘扫到的 / 已授权 / 被拒 / 当前已加载。""" + discovered = [p["type"] for p in _scan_plugins()] + manager = licensing.get_manager() + enforce = manager.enforce + + licensed, denied = [], {} + for plugin_type in discovered: + decision = manager.check_plugin(plugin_type) + if decision.allowed: + licensed.append(plugin_type) + else: + denied[plugin_type] = {"code": decision.code, "message": decision.message} + + return { + "discovered": sorted(discovered), + # 未启用校验时不存在"授权与否",如实按"全部可用"展示,避免误导 + "licensed": sorted(licensed) if enforce else sorted(discovered), + "denied": denied if enforce else {}, + "loaded": sorted(_loaded_types(request)), + "denied_from_last_scan": denied_plugin_types(), + } + + +@router.get("/license/info", summary="授权状态 / 机器码 / 申请码") +async def license_info( + request: Request, + _admin: dict = Depends(require_superuser), +): + """返回本机机器码、授权状态、各插件授权情况与硬件来源明细。 + + 现场申请授权时,把响应中的 ``request_code``(或 ``machine_code``)发给厂商即可。 + """ + info = licensing.describe() + + # 申请码:把各来源指纹打包成一段可抄写文本,方便直接贴进邮件 —— + # 只报机器码只能签出"精确匹配"的授权(无换硬件容错),故优先给申请码 + request_code = "" + try: + from chongming_license import encode_request_code + + request_code = encode_request_code() + except Exception as exc: + logger.warning("生成申请码失败:%s", exc) + + overview = _plugin_overview(request) + + return { + **info, + "request_code": request_code, + "plugins": overview, + } + + +@router.post("/license/reload", summary="重新读取授权文件") +async def license_reload( + _admin: dict = Depends(require_superuser), +): + """从磁盘重新读取 license(手工替换文件后无需重启即可刷新状态)。 + + 注意:插件是否加载仍以启动期判定为准,刷新状态不会加载新授权的插件。 + """ + status = licensing.reload() + logger.info("授权文件已重新读取: %s", status.message) + return {"success": True, "status": status.as_dict()} + + +@router.post("/license/file", summary="导入 license 文件(覆盖现有授权)") +async def license_upload( + request: Request, + file: UploadFile = File(..., description="厂商签发的 license.lic"), + _admin: dict = Depends(require_superuser), +): + """上传并安装 license 文件。 + + 流程:读入 → 大小/JSON 校验 → **验签 + 机器码 + 有效期校验** → 通过才落盘 + (临时文件 + 原子替换)→ 刷新授权状态。 + + 未通过校验的文件一律拒绝,且**不会覆盖**现有可用授权 —— 现场误传坏文件不至于 + 把设备搞成未授权。校验通过但白名单含此前未授权的插件时,需**重启网关**才会 + 加载它们(`_discover_plugins` 只在启动期执行)。 + """ + raw = await file.read() + if not raw: + raise HTTPException(status_code=422, detail="上传内容为空") + if len(raw) > MAX_LICENSE_BYTES: + raise HTTPException( + status_code=413, + detail=f"license 文件过大({len(raw)} 字节,上限 {MAX_LICENSE_BYTES})", + ) + try: + json.loads(raw) + except ValueError as exc: + raise HTTPException(status_code=422, detail=f"不是合法的 JSON:{exc}") from exc + + manager = licensing.get_manager() + target = Path(manager.license_path) + + # 先落到同目录的临时文件再校验:read_license 需要真实路径, + # 且同目录保证后续 move 是原子 rename(不会读到半截文件) + tmp_dir = target.parent if str(target.parent) else Path(".") + tmp_dir.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=".license-", suffix=".tmp", dir=str(tmp_dir)) + try: + with os.fdopen(fd, "wb") as f: + f.write(raw) + + # 用与运行期完全相同的判定逻辑校验(含机器码与有效期) + from chongming_license import read_license + + status = read_license( + tmp_name, + manager.public_key, + min_match=manager.min_match, + sources=manager.collect_sources(), + ) + if not status.ok: + raise HTTPException( + status_code=422, + detail=f"授权文件校验未通过:{status.message}(原因码 {status.code})", + ) + + shutil.move(tmp_name, target) + tmp_name = "" # 已移走,finally 不再删除 + os.chmod(target, 0o644) + finally: + if tmp_name and os.path.exists(tmp_name): + os.unlink(tmp_name) + + after = licensing.reload() + logger.info( + "已导入授权文件 %s:%s(授权插件 %s)", + target, after.message, sorted(after.licensed_plugins) or "无", + ) + + # 本次授权后"已授权但尚未加载"的插件 —— 这些需要重启网关才会生效 + pending = sorted( + (set(_plugin_overview(request)["discovered"]) & after.licensed_plugins) + - _loaded_types(request) + ) + return { + "success": True, + "license_path": str(target), + "status": after.as_dict(), + "licensed_plugins": sorted(after.licensed_plugins), + "restart_required": bool(pending), + "pending_plugins": pending, + "restart_hint": ( + f"以下插件已获授权但尚未加载,重启网关后生效:{pending}" + if pending else "无新增插件需要加载,无需重启。" + ), + } diff --git a/src/chongming/app/routes/worker_admin.py b/src/chongming/app/routes/worker_admin.py index 6fb7c55..dc4a80c 100644 --- a/src/chongming/app/routes/worker_admin.py +++ b/src/chongming/app/routes/worker_admin.py @@ -19,6 +19,7 @@ from chongming_config import get_config from ..core.auth import jwt_auth_dependency from ..core.logger import get_logger +from ..core import licensing config = get_config() @@ -39,6 +40,26 @@ def require_admin( return payload +def _ensure_licensed(worker_type: str) -> None: + """授权门禁:未授权的插件禁止任何管理操作(扩缩容 / 重启 / 读改配置)。 + + 未授权插件在启动期就不加载(见 lifespan_setup._discover_plugins),因此在 + 离线设备上通常表现为"不存在";但网关可能因设备重启后 + license 到期、或被手动改过配置而留下残存副本,故此处必须独立拦截 —— + 否则可以借扩缩容把未授权插件重新拉起、或经 config 修改其行为。 + + 每个管理端点在入口显式调用(而非做成依赖),便于审计"哪些入口受门禁保护"。 + """ + if not licensing.get_manager().enforce: + return + decision = licensing.check_plugin(worker_type) + if not decision.allowed: + raise HTTPException( + status_code=403, + detail=f"{decision.message};该插件不在授权白名单内,管理操作已拒绝", + ) + + def _get_manager(request: Request): """从应用状态获取 WorkerManager;未初始化时(lifespan 外)报 503。""" manager = getattr(request.app.state, "worker_manager", None) @@ -52,9 +73,27 @@ async def list_workers( request: Request, _admin: dict = Depends(require_admin), ): - """返回全部插件实例(worker_type / worker_id / mode / alive),供前端渲染与选择。""" + """返回全部插件实例(worker_type / worker_id / mode / alive),供前端渲染与选择。 + + 每个实例附 `licensed` 标注:未授权插件即使有残存副本也不应被操作, + 前端据此禁用按钮。 + """ manager = _get_manager(request) - return {"workers": manager.list_workers(), "success": True} + workers = manager.list_workers() + enforce = licensing.get_manager().enforce + for w in workers: + w["licensed"] = ( + True if not enforce + else licensing.check_plugin(w["worker_type"]).allowed + ) + return { + "workers": workers, + "success": True, + "license_enforced": enforce, + "denied_plugin_types": ( + licensing.denied_types({w["worker_type"] for w in workers}) if enforce else set() + ), + } @router.post("/admin/workers/restart", summary="重启单个 Worker 副本") @@ -69,6 +108,7 @@ async def restart_worker( - worker_type: 插件类型(如 example) - worker_id: 副本 id(如 worker-0-1-12345),由 GET /admin/workers 提供 """ + _ensure_licensed(worker_type) manager = _get_manager(request) try: result = await manager.restart_worker(worker_type, worker_id) @@ -103,6 +143,7 @@ async def restart_all( 注意:网关进程内加载的 inline 插件副本经本接口重启,仅重建 worker 协程, 不会卸载插件;external 插件同理逐个副本重启。 """ + _ensure_licensed(worker_type) manager = _get_manager(request) candidates = [ w for w in manager.list_workers() @@ -146,6 +187,7 @@ async def scale_workers( - inline 直接调整本进程 Worker 池;external 经 Zenoh 下发 rescale 到存活副本, 由其进程内池扩缩并写回自身配置。 """ + _ensure_licensed(worker_type) manager = _get_manager(request) try: result = await manager.scale_workers(worker_type, body.count) @@ -186,6 +228,7 @@ async def list_worker_instances( 每个实例 = 一个可独立承载副本的执行单元(inline=本网关进程/host, external=独立进程/容器),含当前副本数与可选中的 worker_id。 """ + _ensure_licensed(worker_type) manager = _get_manager(request) try: instances = await manager.list_instances(worker_type) @@ -210,6 +253,7 @@ async def scale_worker_instance( 经该实例任一副本下发 rescale{scale, instance_id, absolute:true},由其进程内 WorkerPool 直接扩缩并记入逐实例覆盖表;重启后仍保持该绝对数。 """ + _ensure_licensed(worker_type) manager = _get_manager(request) try: result = await manager.scale_instance( @@ -256,6 +300,7 @@ async def read_worker_config( - worker_type: 插件类型(如 example) """ + _ensure_licensed(worker_type) manager = _get_manager(request) try: info = await manager.get_worker_config(worker_type) @@ -305,6 +350,7 @@ async def update_worker_config( - worker_type: 插件类型(如 example) - body.updates: 待更新的键值,如 {"data_dir": "./data2"} """ + _ensure_licensed(worker_type) manager = _get_manager(request) if not body.updates: raise HTTPException(status_code=422, detail="updates 不能为空") diff --git a/test/test_license_build.py b/test/test_license_build.py new file mode 100644 index 0000000..aa15bf7 --- /dev/null +++ b/test/test_license_build.py @@ -0,0 +1,224 @@ +"""构建期授权守护测试:公钥注入 + 私钥泄漏守卫。 + +对齐 `test_build_plugin.py` 对"插件包不得包含 secret_key"的同类断言 —— +**签发私钥绝不能进入部署产物**:它是授权的唯一信任根,一旦随包发布, +任何人都能签发授权,机器码绑定形同虚设。 + +运行方式:`python test/test_license_build.py`,或 `uv run pytest test/test_license_build.py`。 +""" +import os +import sys +import tempfile +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "utils" / "cli" / "src")) + +from chongming_cli.build import ( # noqa: E402 + ALLOW_NO_KEY_ENV, + LICENSE_PUBKEY_ENV, + _resolve_license_public_key, + _set_license_pubkey, + _stage_license_guard, +) + +failures: list[str] = [] + + +def check(name, cond, detail=""): + status = "PASS" if cond else "FAIL" + print(f"[{status}] {name}" + (f" — {detail}" if detail else "")) + if not cond: + failures.append(name) + + +def test_pubkey_injection_is_section_scoped(): + """公钥只写进 [license] 段,注释与其它段不受影响。""" + src = (ROOT / "public" / "config.toml").read_text(encoding="utf-8") + out = _set_license_pubkey(src, "AAAABBBB=") + + body = tomllib.loads(out) + check("注入后 TOML 可解析", body["license"]["public_key"] == "AAAABBBB=", + str(body["license"].get("public_key"))) + + # 只改了 [license] 段内那一行 + lic_start = out.index("[license]") + prod_start = out.index("[production]") + check("公钥位于 [license] 段内", + 'public_key = "AAAABBBB="' in out[lic_start:prod_start]) + check("[production] 段未被误改", "public_key" not in out[prod_start:]) + check("注释保留", "# 机器码授权" in out) + check("其它段结构完整", "[production.module_system]" in out) + check("[license] 段仍保留 path/min_match", + body["license"]["path"] == "license.lic" and body["license"]["min_match"] == 3) + + diff = [i for i, (a, b) in enumerate(zip(src.splitlines(), out.splitlines())) if a != b] + check("仅一行发生变化", len(diff) == 1, f"变化行号={diff}") + + +def test_pubkey_resolution_order(): + """公钥解析优先级:环境变量 > 文件环境变量 > 默认文件 license_public.key。 + + 必须在**空的临时目录**里跑:默认回退路径 ``license_public.key`` 是相对 CWD 的, + 而厂商在项目根放过这个文件是正常用法(也正是构建取公钥的正规途径), + 测试不能依赖它"不存在"。 + """ + tmp = Path(tempfile.mkdtemp()) + key_file = tmp / "pk.txt" + key_file.write_text("FROMFILE", encoding="utf-8") + + old_cwd = os.getcwd() + old_env = dict(os.environ) + try: + os.chdir(tmp) # 空目录:既无 license_public.key 也无其它干扰 + for var in (LICENSE_PUBKEY_ENV, "CHONGMING_LICENSE_PUBKEY_FILE"): + os.environ.pop(var, None) + + check("三者都无时返回空", _resolve_license_public_key() == "") + + # 默认文件回退(构建按此取公钥,之前漏测) + (tmp / "license_public.key").write_text("FROMDEFAULT", encoding="utf-8") + check("回退到默认文件 license_public.key", + _resolve_license_public_key() == "FROMDEFAULT") + + os.environ["CHONGMING_LICENSE_PUBKEY_FILE"] = str(key_file) + check("文件环境变量优先于默认文件", + _resolve_license_public_key() == "FROMFILE") + + os.environ[LICENSE_PUBKEY_ENV] = "FROMENV" + check("环境变量优先于文件", _resolve_license_public_key() == "FROMENV") + finally: + os.chdir(old_cwd) + os.environ.clear() + os.environ.update(old_env) + + +def test_guard_detects_private_key(): + """守护能检出私钥(按内容,与扩展名无关,含跨块边界)。""" + tmp = Path(tempfile.mkdtemp()) + build = tmp / "build" + (build / "sub").mkdir(parents=True) + + old_cwd = os.getcwd() + try: + os.chdir(tmp) + + (build / "ok.txt").write_text("正常内容\n", encoding="utf-8") + check("干净产物通过", _stage_license_guard() == "未发现私钥泄漏") + + # 证书类 .pem 是合法的构建内容,不应被误判 + (build / "cert.pem").write_text( + "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n", encoding="utf-8" + ) + check("合法证书 .pem 不误判", _stage_license_guard() == "未发现私钥泄漏") + + # 私钥改名藏进普通文件 -> 按内容仍能检出 + leak = build / "sub" / "innocent.txt" + leak.write_text("-----BEGIN PRIVATE KEY-----\nMIIEvQ\n", encoding="utf-8") + try: + _stage_license_guard() + check("按内容检出改名的私钥", False, "未检出") + except RuntimeError as exc: + check("按内容检出改名的私钥", "innocent.txt" in str(exc)) + leak.unlink() + + # 跨 1MB 块边界的私钥 + (build / "big.bin").write_bytes( + b"x" * (1 << 20) + b"-----BEGIN RSA PRIVATE KEY-----" + b"y" * (1 << 20) + ) + try: + _stage_license_guard() + check("检出跨块边界的私钥", False, "未检出") + except RuntimeError as exc: + check("检出跨块边界的私钥", "big.bin" in str(exc)) + (build / "big.bin").unlink() + + # 文件名叫 license_private* 的一律拦下 + (build / "license_private.pem").write_text("(truncated)", encoding="utf-8") + try: + _stage_license_guard() + check("按文件名拦下授权私钥文件", False, "未检出") + except RuntimeError as exc: + check("按文件名拦下授权私钥文件", "license_private" in str(exc)) + finally: + os.chdir(old_cwd) + + +def test_build_fails_without_pubkey(): + """打包时缺少公钥 -> 构建失败(避免产出拒绝所有插件的砖头包)。""" + import chongming_cli.build as build_mod + + tmp = Path(tempfile.mkdtemp()) + old_cwd = os.getcwd() + old_env = dict(os.environ) + try: + # 复刻 build/ 最小结构:_stage_prepare_build_dir 需要 public/ 与 config.toml + (tmp / "public" / "utils").mkdir(parents=True) + (tmp / "public" / "main.py").write_text("", encoding="utf-8") + (tmp / "public" / "server.py").write_text("", encoding="utf-8") + (tmp / "public" / "config.toml").write_text( + (ROOT / "public" / "config.toml").read_text(encoding="utf-8"), encoding="utf-8" + ) + os.chdir(tmp) + + for var in (LICENSE_PUBKEY_ENV, "CHONGMING_LICENSE_PUBKEY_FILE", ALLOW_NO_KEY_ENV): + os.environ.pop(var, None) + + try: + build_mod._stage_prepare_build_dir({}, "pyinstaller") # type: ignore[arg-type] + check("缺少公钥时构建失败", False, "未抛异常") + except RuntimeError as exc: + check("缺少公钥时构建失败", LICENSE_PUBKEY_ENV in str(exc)) + + # 显式放行时才允许无公钥构建 + os.environ[ALLOW_NO_KEY_ENV] = "1" + build_mod._stage_prepare_build_dir({}, "pyinstaller") # type: ignore[arg-type] + built = tomllib.loads((tmp / "build" / "config.toml").read_text(encoding="utf-8")) + check("显式放行时构建成功且公钥为空", built["license"]["public_key"] == "") + + # 提供公钥后应注入到产物配置 + os.environ[LICENSE_PUBKEY_ENV] = "INJECTEDKEY=" + build_mod._stage_prepare_build_dir({}, "pyinstaller") # type: ignore[arg-type] + built = tomllib.loads((tmp / "build" / "config.toml").read_text(encoding="utf-8")) + check("公钥被注入产物配置", built["license"]["public_key"] == "INJECTEDKEY=") + check("产物配置仍含 min_match", built["license"]["min_match"] == 3) + finally: + os.chdir(old_cwd) + os.environ.clear() + os.environ.update(old_env) + + +def test_mbank_signing_is_not_a_trust_root(): + """.mbank 验签用随包发布的对称密钥 —— 不能作为授权信任根。 + + 本测试固化这一认知:只要 sqlite_vfs 的 sign 仍是 HMAC,就说明它只能算防篡改, + 授权必须依赖非对称签名(chongming_license 的 Ed25519)。 + """ + import inspect + + from sqlite_vfs import core as vfs_core + + src = inspect.getsource(vfs_core.SQLiteVFS.sign) + check("确认 .mbank 签名为 HMAC(对称)", "hmac.new" in src, + "若已改为非对称,可更新本测试与文档说明") + check("确认验证端同样是对称 HMAC", + "hmac.new" in inspect.getsource(vfs_core.SQLiteVFS.verify_signature)) + + +def main(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in tests: + print(f"\n--- {fn.__name__} ---") + fn() + print("\n" + "=" * 56) + if failures: + print(f"❌ {len(failures)} 项未通过: {failures}") + return 1 + print("✅ 全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_license_gate.py b/test/test_license_gate.py new file mode 100644 index 0000000..12c8738 --- /dev/null +++ b/test/test_license_gate.py @@ -0,0 +1,377 @@ +"""机器码授权门禁测试(网关侧)。 + +核心验证目标 —— 未授权插件被拒绝时,**网关必须仍能正常启动**: + +1. 门禁收口在 `_discover_plugins()`:未授权插件不出现在返回列表中, + 且 `denied_plugin_types()` 能给出拒绝原因; +2. **契约校验不硬失败**:这是最容易踩的坑 —— `validate_contracts()` 会把 + "TOML 路由没有对应 @rpc_method 注册"视为硬性错误并终止启动。若把"不加载 + 未授权插件"实现成"跳过 import 但保留在插件列表里",该插件的 TOML 路由就会 + 变成孤儿 → 设备起不来。本测试确保收口位置正确。 +3. 路由描述符门禁:未授权类型的 external 路由描述符被忽略(防"改 load_mode + 成 external 绕过门禁"); +4. 管理接口门禁:未授权插件的扩缩容/重启/配置接口一律 403; +5. 未启用校验时(开发/测试)全部放行。 + +运行方式:`python test/test_license_gate.py`,或 `uv run pytest test/test_license_gate.py`。 +""" +import asyncio +import base64 +import json +import os +import sys +import tempfile +from datetime import date, timedelta +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +# 必须在导入网关模块前设置:licensing 的 enforce 在首次建管理器时读取 +os.environ["CHONGMING_LICENSE_MODE"] = "enforce" + +from chongming_config import get_config # noqa: E402 +from cryptography.hazmat.primitives import serialization # noqa: E402 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402 +from chongming_license import collect_fingerprints, machine_code, sign_payload # noqa: E402 + +from chongming.app.core import licensing # noqa: E402 +from chongming.app.core.lifespan_setup import ( # noqa: E402 + _discover_plugins, + _scan_plugins, + denied_plugin_types, +) + +failures: list[str] = [] + + +def check(name, cond, detail=""): + status = "PASS" if cond else "FAIL" + print(f"[{status}] {name}" + (f" — {detail}" if detail else "")) + if not cond: + failures.append(name) + + +# --------------------------------------------------------------------------- +# 测试夹具:临时密钥对 + license 文件 +# --------------------------------------------------------------------------- +def _make_keypair() -> tuple[str, Ed25519PrivateKey]: + priv = Ed25519PrivateKey.generate() + pub = base64.b64encode( + priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ).decode("ascii") + return pub, priv + + +def _issue(tmpdir: Path, priv: Ed25519PrivateKey, plugins: list[str], + expires_days: int = 365, sources: dict | None = None) -> str: + src = sources if sources is not None else collect_fingerprints() + payload = { + "v": 1, + "lic_id": "L-TEST-0001", + "customer": "测试客户", + "machine_code": machine_code(src), + "sources": src, + "plugins": plugins, + "not_before": None, + "expires_at": (date.today() + timedelta(days=expires_days)).isoformat(), + "issued_at": "2026-09-20T00:00:00Z", + "note": "", + } + doc = { + "payload": payload, + "alg": "ed25519", + "kid": "test", + "sig": sign_payload(payload, priv), + } + path = tmpdir / "license.lic" + path.write_text(json.dumps(doc), encoding="utf-8") + return str(path) + + +def _use_license(public_key: str, path: str, min_match: int = 3) -> None: + """把测试用的公钥/路径写进运行期配置并重建管理器。 + + 直接改 `get_config()` 返回的 dict(chongming_config 缓存同一对象)。 + """ + cfg = get_config() + cfg.setdefault("license", {}) + cfg["license"]["public_key"] = public_key + cfg["license"]["path"] = path + cfg["license"]["min_match"] = min_match + licensing.reset_manager() + + +def test_gate_denies_unlicensed_plugins(): + """未授权插件被剔除,且原因可查。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"])) + + scanned = sorted(p["type"] for p in _scan_plugins()) + allowed = sorted(p["type"] for p in _discover_plugins()) + denied = denied_plugin_types() + + check("盘扫到全部插件", scanned == ["aibox_sophon_adapter", "ip_sound_column"], + f"scanned={scanned}") + check("只放行已授权插件", allowed == ["ip_sound_column"], f"allowed={allowed}") + check("未授权插件给出拒绝原因", + "aibox_sophon_adapter" in denied + and "白名单" in denied["aibox_sophon_adapter"], + str(denied)) + + +def test_contract_validation_survives_denial(): + """关键:有插件被拒时契约校验不得抛异常(否则设备起不来)。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"])) + + from chongming.app.core.contracts import validate_contracts + + inline = [p for p in _discover_plugins() if p.get("load_mode", "inline") != "external"] + try: + validate_contracts(inline) + check("被拒插件的 TOML 路由不会导致契约校验硬失败", True) + except Exception as exc: # noqa: BLE001 + check("被拒插件的 TOML 路由不会导致契约校验硬失败", False, repr(exc)) + + +def test_all_denied_still_validates(): + """极端情况:完全未授权(无 license 文件)时启动流程仍不硬失败。""" + tmp = Path(tempfile.mkdtemp()) + pub, _priv = _make_keypair() + _use_license(pub, str(tmp / "not-exists.lic")) + + from chongming.app.core.contracts import validate_contracts + + allowed = _discover_plugins() + check("无授权文件时不放行任何插件", allowed == [], str(allowed)) + try: + validate_contracts([]) + check("完全未授权时契约校验不抛异常", True) + except Exception as exc: # noqa: BLE001 + check("完全未授权时契约校验不抛异常", False, repr(exc)) + + +def test_route_registry_denies_descriptors(): + """未授权类型的路由描述符被忽略(防改 load_mode 绕过)。""" + from fastapi import FastAPI + + from chongming.app.core.route_registry import RouteRegistry + + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"])) + + denied = licensing.denied_types({p["type"] for p in _scan_plugins()}) + check("denied_types 含未授权插件", "aibox_sophon_adapter" in denied, str(denied)) + + async def run(): + registry = RouteRegistry(FastAPI(), worker_manager=None, prefix="/api/v1") + registry.set_denied_types(denied) + descriptor = { + "schema_version": 1, "type": "aibox_sophon_adapter", + "subject": "aibox_sophon_adapter.alarm_report", "method": "POST", + "path": "/aibox_sophon_adapter/alarm", "params": {}, + } + await registry._handle_sample( + "chongming/routes/aibox_sophon_adapter/alarm_report", + __import__("zenoh").SampleKind.PUT, + json.dumps(descriptor).encode(), + ) + return registry._groups, registry.route_types + + groups, route_types = asyncio.run(run()) + check("未授权类型的描述符未进入路由表", groups == {}, f"groups={list(groups)}") + check("未授权类型不出现在 route_types 中", route_types == [], str(route_types)) + + +def test_admin_gate_403_for_denied(): + """未授权插件的管理操作一律 403。""" + from fastapi import HTTPException + + from chongming.app.routes.worker_admin import _ensure_licensed + + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"])) + + try: + _ensure_licensed("aibox_sophon_adapter") + check("未授权插件的管理操作被拒", False, "未抛 403") + except HTTPException as exc: + check("未授权插件的管理操作被拒", exc.status_code == 403, + f"status={exc.status_code}") + + try: + _ensure_licensed("ip_sound_column") + check("已授权插件的管理操作放行", True) + except HTTPException as exc: # noqa: BLE001 + check("已授权插件的管理操作放行", False, f"status={exc.status_code}") + + +def test_expired_license_denies_all(): + """过期授权 -> 全部拒绝。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column", "aibox_sophon_adapter"], + expires_days=-1)) + + allowed = _discover_plugins() + check("过期授权不放行任何插件", allowed == [], str(allowed)) + check("拒绝原因为过期", + all("过期" in m for m in denied_plugin_types().values()), + str(denied_plugin_types())) + + +def test_disabled_mode_allows_everything(): + """未启用校验时(非打包态的默认行为)全部放行。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=[])) + licensing.reset_manager() + os.environ.pop("CHONGMING_LICENSE_MODE", None) + get_config()["license"]["enabled"] = False + try: + allowed = sorted(p["type"] for p in _discover_plugins()) + check("未启用校验时全部放行", + allowed == ["aibox_sophon_adapter", "ip_sound_column"], str(allowed)) + check("未启用校验时无拒绝记录", denied_plugin_types() == {}, + str(denied_plugin_types())) + finally: + os.environ["CHONGMING_LICENSE_MODE"] = "enforce" + get_config()["license"]["enabled"] = False + licensing.reset_manager() + + +def test_http_endpoints_and_upload_safety(): + """HTTP 层:门禁、信息接口、以及"坏文件不覆盖现有授权"。 + + 这一步覆盖最容易在现场出事的行为 —— 误传一个坏 license 文件把设备搞成未授权。 + """ + import base64 as _b64 + import json as _json + import tempfile as _tf + from datetime import date as _date, timedelta as _td + + import orjson + from cryptography.hazmat.primitives import serialization as _ser + from fastapi.testclient import TestClient + + from chongming_jwt import JWT + from chongming_license import collect_fingerprints as _collect + + from chongming.app import app + + tmp = Path(_tf.mkdtemp()) + priv = Ed25519PrivateKey.generate() + pub = _b64.b64encode( + priv.public_key().public_bytes( + encoding=_ser.Encoding.Raw, format=_ser.PublicFormat.Raw + ) + ).decode("ascii") + + src = _collect() + payload = { + "v": 1, "lic_id": "L-HTTP-1", "customer": "HTTP 测试", + "machine_code": machine_code(src), "sources": src, + "plugins": ["ip_sound_column"], "not_before": None, + "expires_at": (_date.today() + _td(days=30)).isoformat(), + "issued_at": "2026-09-20T00:00:00Z", "note": "", + } + lic = tmp / "license.lic" + lic.write_text(_json.dumps({ + "payload": payload, "alg": "ed25519", "kid": "test", + "sig": sign_payload(payload, priv), + }), encoding="utf-8") + + _use_license(pub, str(lic)) + + sec = get_config()["security"] + def _token(superuser: bool) -> str: + return JWT(sec["secret_key"], sec["algorithm"]).encode( + orjson.dumps({"sub": "u1", "username": "admin", + "is_superuser": superuser}).decode(), + expires_in_seconds=3600, + ) + + client = TestClient(app) + auth = {"Authorization": f"Bearer {_token(True)}"} + + r = client.get("/api/v1/license/info") + check("无 token 时拒绝访问", r.status_code == 401, f"status={r.status_code}") + + r = client.get("/api/v1/license/info", + headers={"Authorization": f"Bearer {_token(False)}"}) + check("非超管拒绝访问", r.status_code == 403, f"status={r.status_code}") + + r = client.get("/api/v1/license/info", headers=auth) + check("超管可读取授权信息", r.status_code == 200, f"status={r.status_code}") + info = r.json() + check("/license/info 含机器码", info["status"]["machine_code"] != "", + info["status"]["machine_code"]) + check("/license/info 含申请码", info["request_code"].startswith("CM1-"), + info["request_code"][:16]) + check("未授权插件在 denied 中", "aibox_sophon_adapter" in info["plugins"]["denied"], + str(list(info["plugins"]["denied"]))) + check("来源明细含 weak 标注", + any(s["weak"] for s in info["sources_detail"]), + str([(s["name"], s["weak"]) for s in info["sources_detail"]])) + + # 签名错误的文件:必须拒绝,且不得覆盖现有授权 + tampered = dict(payload, plugins=["aibox_sophon_adapter"]) + r = client.post( + "/api/v1/license/file", headers=auth, + files={"file": ("bad.lic", _json.dumps( + {"payload": tampered, "alg": "ed25519", "kid": "test", "sig": "AAAA"} + ), "application/json")}, + ) + check("签名错误的文件被拒绝", r.status_code == 422, f"status={r.status_code}") + check("被拒文件未覆盖现有授权", + _json.loads(lic.read_text(encoding="utf-8"))["payload"]["lic_id"] == "L-HTTP-1") + + # 非 JSON 文件 + r = client.post("/api/v1/license/file", headers=auth, + files={"file": ("bad.lic", "not json at all", "application/json")}) + check("非 JSON 文件被拒绝", r.status_code == 422, f"status={r.status_code}") + + # 合法文件:安装成功,并提示需重启加载 + payload2 = dict(payload, lic_id="L-HTTP-2", + plugins=["ip_sound_column", "aibox_sophon_adapter"]) + r = client.post( + "/api/v1/license/file", headers=auth, + files={"file": ("good.lic", _json.dumps( + {"payload": payload2, "alg": "ed25519", "kid": "test", + "sig": sign_payload(payload2, priv)} + ), "application/json")}, + ) + check("合法文件导入成功", r.status_code == 200, f"status={r.status_code}") + if r.status_code == 200: + body = r.json() + check("导入后授权插件更新", + body["licensed_plugins"] == ["aibox_sophon_adapter", "ip_sound_column"], + str(body["licensed_plugins"])) + check("导入后提示需重启加载", + body["restart_required"] and "aibox_sophon_adapter" in body["pending_plugins"], + str(body["pending_plugins"])) + + +def main(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in tests: + print(f"\n--- {fn.__name__} ---") + fn() + print("\n" + "=" * 56) + if failures: + print(f"❌ {len(failures)} 项未通过: {failures}") + return 1 + print("✅ 全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_license_registry.py b/test/test_license_registry.py new file mode 100644 index 0000000..e744c4a --- /dev/null +++ b/test/test_license_registry.py @@ -0,0 +1,327 @@ +"""已签发授权台账测试(厂商侧 Registry)。 + +重点验证: +1. 由 license 文档派生记录(唯一写入口,不手工拼记录) +2. 状态判定(永久/有效/即将到期/已过期/未生效)与剩余天数 +3. 检索:客户 / 机器码 / 插件 / 关键字 / 状态 +4. **续期复用台账里的来源指纹** —— 台账最大的价值:不必再向客户索取申请码 +5. 重新导入不覆盖人工维护的备注 +6. 导出 .lic(按客户分子目录)与 CSV(UTF-8 BOM,Excel 中文不乱码) +7. 结构版本不匹配时明确报错,而不是让查询诡异失败 + +运行:`python test/test_license_registry.py`,或 `uv run pytest test/test_license_registry.py`。 +""" +import csv +import sqlite3 +import sys +import tempfile +from datetime import date, timedelta +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402 + +from chongming_license import ( # noqa: E402 + Registry, + RegistryError, + build_license, + build_payload, + machine_code, + public_key_b64, + public_key_fingerprint, + record_from_license, +) + +failures: list[str] = [] + + +def check(name, cond, detail=""): + print(f"[{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + if not cond: + failures.append(name) + + +PRIV = Ed25519PrivateKey.generate() +PUB = public_key_b64(PRIV) +FP = public_key_fingerprint(PUB) + +SRC = {"board_serial": "sn123456789", "emmc_cid": "1a2b3c4d", "mac": "aabbccddeeff"} +CODE = machine_code(SRC) + + +def make_doc(*, customer="某某科技", days=300, plugins=("aibox_sophon_adapter",), + note="", machine=None, sources=None, lic_id="", not_before=None): + payload = build_payload( + machine_code=machine or CODE, + plugins=list(plugins), + sources=SRC if sources is None else sources, + customer=customer, + note=note, + lic_id=lic_id, + not_before=not_before, + expires_at=(date.today() + timedelta(days=days)) if days is not None else None, + ) + return build_license(payload, PRIV, kid="vendor-2026") + + +def test_record_derivation(): + """记录完全由 license 文档派生,查询列与原始文档不会不一致。""" + doc = make_doc(customer="派生测试", note="合同 X", plugins=["a", "b"]) + rec = record_from_license(doc, public_key_fp=FP, source="test") + + payload = doc["payload"] + check("lic_id 取自载荷", rec.lic_id == payload["lic_id"], rec.lic_id) + check("机器码大写归一", rec.machine_code == payload["machine_code"].upper()) + check("插件列表解析", rec.plugins == ["a", "b"], str(rec.plugins)) + check("来源指纹解析", rec.sources == SRC, str(sorted(rec.sources))) + check("kid 取自信封", rec.kid == "vendor-2026") + check("公钥指纹记录在案", rec.public_key_fp == FP) + check("可还原完整文档", rec.license_doc()["sig"] == doc["sig"]) + + # 日期只保留日期部分:否则 SQL 里的字符串比较会得出错误排序 + doc2 = make_doc() + doc2["payload"]["expires_at"] = "2027-09-01T12:34:56Z" + check("日期裁剪到日粒度", + record_from_license(doc2).expires_at == "2027-09-01", + str(record_from_license(doc2).expires_at)) + + # 缺 lic_id 必须报错,而不是静默写一条垃圾记录 + try: + record_from_license({"payload": {"machine_code": "X"}}) + check("缺 lic_id 时报错", False, "未抛异常") + except RegistryError: + check("缺 lic_id 时报错", True) + + +def test_status_and_days_left(): + """五种状态的判定(含边界:到期当天仍有效)。""" + tmp = Path(tempfile.mkdtemp()) + with Registry(tmp / "r.db") as reg: + reg.save(make_doc(lic_id="L-FOREVER", days=None), public_key_fp=FP) + reg.save(make_doc(lic_id="L-ACTIVE", days=300), public_key_fp=FP) + reg.save(make_doc(lic_id="L-EXPIRING", days=10), public_key_fp=FP) + reg.save(make_doc(lic_id="L-EXPIRED", days=-1), public_key_fp=FP) + reg.save(make_doc(lic_id="L-TODAY", days=0), public_key_fp=FP) + reg.save(make_doc(lic_id="L-FUTURE", days=100, + not_before=(date.today() + timedelta(days=30)).isoformat()), + public_key_fp=FP) + + got = {r.lic_id: r.status() for r in reg.query()} + check("无到期日 → forever", got["L-FOREVER"] == "forever", got["L-FOREVER"]) + check("远期 → active", got["L-ACTIVE"] == "active", got["L-ACTIVE"]) + check("30 天内 → expiring", got["L-EXPIRING"] == "expiring", got["L-EXPIRING"]) + check("已过 → expired", got["L-EXPIRED"] == "expired", got["L-EXPIRED"]) + check("到期当天仍有效(含当天)", got["L-TODAY"] == "expiring", got["L-TODAY"]) + check("未到生效日 → not_yet_valid", + got["L-FUTURE"] == "not_yet_valid", got["L-FUTURE"]) + + check("剩余天数为负表示已过期", + reg.get("L-EXPIRED").days_left() < 0, + str(reg.get("L-EXPIRED").days_left())) + check("永久授权剩余天数为 None", + reg.get("L-FOREVER").days_left() is None) + + st = reg.stats() + check("概览计数一致", + st == {"total": 6, "expired": 1, "expiring": 2, "active": 1, + "forever": 1, "not_yet_valid": 1}, str(st)) + + +def test_query_filters(): + """检索:客户 / 机器码 / 插件 / 关键字 / 状态,以及排序。""" + tmp = Path(tempfile.mkdtemp()) + with Registry(tmp / "r.db") as reg: + reg.save(make_doc(lic_id="L-A", customer="阿尔法科技", days=300, + plugins=["doubao"], note="HT-001"), public_key_fp=FP) + reg.save(make_doc(lic_id="L-B", customer="贝塔公司", days=10, + plugins=["ip_sound_column"], note="HT-002"), public_key_fp=FP) + reg.save(make_doc(lic_id="L-C", customer="贝塔公司", days=200, + plugins=["doubao_extended"], note="HT-003"), public_key_fp=FP) + other = "AAAA-BBBB-CCCC-DDDD-EEEE-FFFF" + reg.save(make_doc(lic_id="L-D", customer="伽马", days=300, machine=other, + sources={"mac": "aabbccddeeff"}), public_key_fp=FP) + + check("按客户模糊匹配", {r.lic_id for r in reg.query(customer="贝塔")} == {"L-B", "L-C"}) + # L-C 装的是 doubao_extended,查 doubao 时不应被命中(前缀同名不算同一插件) + check("按插件匹配不误伤前缀同名", + {r.lic_id for r in reg.query(plugin="doubao")} == {"L-A"}, + str([r.lic_id for r in reg.query(plugin="doubao")])) + check("按插件匹配到前缀同名者本身", + {r.lic_id for r in reg.query(plugin="doubao_extended")} == {"L-C"}) + check("按机器码匹配", {r.lic_id for r in reg.query(machine_code=CODE[:16])} + == {"L-A", "L-B", "L-C"}) + check("按备注关键字匹配", {r.lic_id for r in reg.query(keyword="HT-002")} == {"L-B"}) + check("大小写不敏感(机器码)", + {r.lic_id for r in reg.query(machine_code=other.lower())} == {"L-D"}) + + order = [r.lic_id for r in reg.query()] + check("按到期日升序(急的在前)", order[0] == "L-B", str(order)) + + # 某台机器的全部历史授权 + check("按机器码取全部历史", + len(reg.find_by_machine_code(CODE)) == 3, + str(len(reg.find_by_machine_code(CODE)))) + + check("limit 生效", len(reg.query(limit=2)) == 2) + + +def test_renew_reuses_sources_without_request_code(): + """核心能力:续期复用台账里的指纹,无需再向客户索取申请码。""" + tmp = Path(tempfile.mkdtemp()) + with Registry(tmp / "r.db") as reg: + old = reg.save(make_doc(lic_id="L-OLD", customer="续期客户", days=5, + plugins=["aibox_sophon_adapter", "ip_sound_column"], + note="合同 HT-100"), public_key_fp=FP) + check("原授权即将到期", old.status() == "expiring", old.status()) + + # 模拟现场:客户已联系不上,只有台账记录可用 + renewed_doc = make_doc(lic_id="L-NEW", customer=old.customer, days=365, + plugins=old.plugins, note=old.note, + machine=old.machine_code, sources=old.sources) + new = reg.save(renewed_doc, public_key_fp=FP, source="renew") + + check("续期成功", new.status() == "active", new.status()) + check("指纹与原件一致(未索取新申请码)", new.sources == old.sources) + check("机器码不变", new.machine_code == old.machine_code) + check("新旧授权都在台账(可追溯)", + {r.lic_id for r in reg.find_by_machine_code(old.machine_code)} + == {"L-OLD", "L-NEW"}) + + +def test_note_preserved_on_reimport(): + """重新导入同一份 license 不应覆盖人工维护的备注。""" + tmp = Path(tempfile.mkdtemp()) + with Registry(tmp / "r.db") as reg: + doc = make_doc(lic_id="L-N", note="原始备注") + reg.save(doc, public_key_fp=FP) + check("备注已入库", reg.get("L-N").note == "原始备注") + + reg.update_note("L-N", "人工补充:已电话确认续期") + check("备注可更新", reg.get("L-N").note == "人工补充:已电话确认续期") + + created = reg.get("L-N").created_at + reg.save(doc, public_key_fp=FP) # 重新导入(载荷里还是旧备注) + after = reg.get("L-N") + check("重新导入不覆盖人工备注", + after.note == "人工补充:已电话确认续期", after.note) + check("创建时间保持不变", after.created_at == created, after.created_at) + check("更新时间被推进", after.updated_at >= created) + + +def test_export_and_csv(): + """导出 .lic(可按客户分目录)与 CSV(UTF-8 BOM,Excel 不乱码)。""" + tmp = Path(tempfile.mkdtemp()) + with Registry(tmp / "r.db") as reg: + reg.save(make_doc(lic_id="L-X1", customer="甲方/科技", days=100), public_key_fp=FP) + reg.save(make_doc(lic_id="L-X2", customer="乙方", days=-3), public_key_fp=FP) + + files = reg.export_licenses(tmp / "arc", subdir_by_customer=True) + check("导出数量正确", len(files) == 2, str([f.name for f in files])) + check("客户名含非法字符时安全替换", + any("甲方" in str(f) for f in files), str([str(f.parent.name) for f in files])) + + # 导出的文件应能被校验端直接读取(验证导出内容不是"看着像"而已) + # 注意顺序按到期日升序:files[0] 是已过期的 L-X2,files[1] 是有效的 L-X1 + from chongming_license import read_license + + valid_file = next(f for f in files if "L-X1" in f.name) + expired_file = next(f for f in files if "L-X2" in f.name) + + st = read_license(valid_file, PUB, sources=SRC) + check("导出的有效授权仍可验签通过", st.ok, st.message) + st2 = read_license(expired_file, PUB, sources=SRC) + check("导出的已过期授权被判定为过期(到期日完整保留)", + not st2.ok and st2.code == "expired", f"{st2.code} / {st2.message}") + check("导出内容可原样还原", + valid_file.read_text(encoding="utf-8").lstrip().startswith("{")) + + csv_path = reg.export_csv(tmp / "台账.csv") + with csv_path.open(encoding="utf-8-sig", newline="") as f: + rows = list(csv.reader(f)) + check("CSV 含表头 + 两条数据", len(rows) == 3, str(len(rows))) + check("CSV 表头含状态与到期日", + "状态" in rows[0] and "到期日期" in rows[0], str(rows[0])) + check("CSV 记录了状态文案", + any("已过期" in r for r in rows[1:]), str(rows[1:])) + check("CSV 以 UTF-8 BOM 写入(Excel 中文不乱码)", + csv_path.read_bytes()[:3] == b"\xef\xbb\xbf") + + +def test_schema_version_mismatch_is_explicit(): + """结构版本不匹配时明确报错,而不是让后续查询诡异失败。""" + tmp = Path(tempfile.mkdtemp()) + path = tmp / "r.db" + with Registry(path) as reg: + reg.save(make_doc(lic_id="L-V"), public_key_fp=FP) + + # 手工把版本改成未来值,模拟"被更新版本的工具写过" + conn = sqlite3.connect(str(path)) + conn.execute("PRAGMA user_version = 99") + conn.commit() + conn.close() + + try: + Registry(path) + check("版本不匹配时拒绝打开", False, "未抛异常") + except RegistryError as exc: + check("版本不匹配时拒绝打开", "99" in str(exc), str(exc).splitlines()[0]) + + # 已存在但 user_version=0 的库(旧文件)应被平滑接管 + path2 = tmp / "old.db" + conn = sqlite3.connect(str(path2)) + conn.executescript( + "CREATE TABLE issued_licenses (lic_id TEXT PRIMARY KEY, machine_code TEXT, " + "customer TEXT, note TEXT, plugins_json TEXT, sources_json TEXT, not_before TEXT, " + "expires_at TEXT, issued_at TEXT, kid TEXT, public_key_fp TEXT, license_json TEXT, " + "source TEXT, created_at TEXT, updated_at TEXT);" + ) + conn.commit() + conn.close() + with Registry(path2) as reg2: + check("user_version=0 的旧库被平滑接管", reg2.stats()["total"] == 0) + + +def test_concurrent_open(): + """两个实例同时打开同一库(多个 GUI 窗口 / CLI 并行)不应互相报错。""" + tmp = Path(tempfile.mkdtemp()) + path = tmp / "r.db" + a = Registry(path) + b = Registry(path) + try: + a.save(make_doc(lic_id="L-C1", customer="并发A"), public_key_fp=FP) + b.save(make_doc(lic_id="L-C2", customer="并发B"), public_key_fp=FP) + check("两实例各自写入后互相可见", + {r.lic_id for r in a.query()} == {"L-C1", "L-C2"}, + str([r.lic_id for r in a.query()])) + check("B 能看到 A 的记录", b.get("L-C1") is not None) + finally: + a.close() + b.close() + + +def test_delete(): + tmp = Path(tempfile.mkdtemp()) + with Registry(tmp / "r.db") as reg: + reg.save(make_doc(lic_id="L-DEL"), public_key_fp=FP) + check("删除成功", reg.delete("L-DEL") is True) + check("删除后查不到", reg.get("L-DEL") is None) + check("删除不存在的返回 False", reg.delete("L-DEL") is False) + + +def main(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in tests: + print(f"\n--- {fn.__name__} ---") + fn() + print("\n" + "=" * 56) + if failures: + print(f"❌ {len(failures)} 项未通过: {failures}") + return 1 + print("✅ 全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_license_verify.py b/test/test_license_verify.py new file mode 100644 index 0000000..a93316f --- /dev/null +++ b/test/test_license_verify.py @@ -0,0 +1,358 @@ +"""chongming-license 库层测试:指纹、机器码、验签、容错匹配、时钟防线。 + +运行方式:`python test/test_license_verify.py`,或 `uv run pytest test/test_license_verify.py`。 +""" +import base64 +import json +import sys +import tempfile +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from cryptography.hazmat.primitives import serialization # noqa: E402 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402 + +from chongming_license import ( # noqa: E402 + REASON_CLOCK_ROLLBACK, + REASON_EXPIRED, + REASON_MACHINE_MISMATCH, + REASON_NOT_YET_VALID, + REASON_OK, + REASON_PLUGIN_NOT_LICENSED, + REASON_SIGNATURE_INVALID, + ClockGuard, + LicenseManager, + MemoryClockStore, + RequestCodeError, + decode_request_code, + encode_request_code, + machine_code, + match_fingerprints, + normalize, + read_license, + sign_payload, +) + +failures: list[str] = [] + + +def check(name, cond, detail=""): + status = "PASS" if cond else "FAIL" + print(f"[{status}] {name}" + (f" — {detail}" if detail else "")) + if not cond: + failures.append(name) + + +# --------------------------------------------------------------------------- +# 夹具 +# --------------------------------------------------------------------------- +# 模拟真实盒子的指纹:4 个可信来源 + 1 个 weak(同镜像刷机会重复的 machine-id) +HW = { + "board_serial": "sn123456789", + "emmc_cid": "1a2b3c4d", + "cpu_serial": "0000deadbeef", + "mac": "aabbccddeeff", + "machine_id": "sharedbyallunits", +} + + +def _keypair(): + priv = Ed25519PrivateKey.generate() + pub = base64.b64encode( + priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ).decode("ascii") + return pub, priv + + +def _write_license(tmp: Path, priv, sources=None, plugins=None, **over) -> str: + src = HW if sources is None else sources + payload = { + "v": 1, + "lic_id": "L-TEST", + "customer": "测试客户", + # sources 为空表示"只签机器码"的授权(无逐来源容错),此时机器码必须由调用方给定 + "machine_code": over.pop("machine_code") if not src else machine_code(src), + "sources": src, + "plugins": plugins if plugins is not None else ["aibox_sophon_adapter"], + "not_before": None, + "expires_at": (date.today() + timedelta(days=365)).isoformat(), + "issued_at": "2026-09-20T00:00:00Z", + "note": "", + } + payload.update(over) + doc = {"payload": payload, "alg": "ed25519", "kid": "test", + "sig": sign_payload(payload, priv)} + path = tmp / "license.lic" + path.write_text(json.dumps(doc), encoding="utf-8") + return str(path) + + +# --------------------------------------------------------------------------- +# 指纹与机器码 +# --------------------------------------------------------------------------- +def test_normalize_rejects_placeholders(): + """占位符/全 0/全 f/过短一律视为无效。""" + for bad in ["", " ", "unknown", "None", "0000000000000000", "ffffffffffff", + "ab", "00000000-0000-0000-0000-000000000000"]: + check(f"normalize 拒绝 {bad!r}", normalize(bad) == "", f"got {normalize(bad)!r}") + check("normalize 保留合法值", normalize("SN-1234-ABCD") == "sn1234abcd", + normalize("SN-1234-ABCD")) + check("normalize 分隔符差异归一", normalize("AA:BB:CC:DD:EE:FF") == normalize("aabbccddeeff")) + + +def test_machine_code_deterministic_and_weak_excluded(): + """机器码确定,且不受 weak 来源影响。""" + a = machine_code(HW) + b = machine_code(dict(reversed(list(HW.items())))) + check("机器码与来源顺序无关", a == b) + check("机器码分组格式", len(a.split("-")) == 6 and len(a.replace("-", "")) == 24, a) + + other = dict(HW, machine_id="anothervalue") + check("weak 来源变化不影响机器码", machine_code(other) == a) + strong_changed = dict(HW, mac="112233445566") + check("可信来源变化会改变机器码", machine_code(strong_changed) != a) + + +# --------------------------------------------------------------------------- +# 容错匹配 +# --------------------------------------------------------------------------- +def test_tolerance_single_component_swap(): + """换一块网卡 / 换一颗 eMMC 仍通过;整盘拷到别的机器不通过。""" + same = match_fingerprints(HW, dict(HW)) + check("同机全命中", same.ok and same.matched == 4 and same.total == 4, same.reason) + + nic = match_fingerprints(HW, dict(HW, mac="112233445566")) + check("换网卡仍通过(3/4)", nic.ok, nic.reason) + + emmc = match_fingerprints(HW, dict(HW, emmc_cid="ffffffff")) + check("换 eMMC 仍通过(3/4)", emmc.ok, emmc.reason) + + two = match_fingerprints(HW, dict(HW, mac="112233445566", emmc_cid="ffffffff")) + check("同时换网卡与 eMMC 不通过(2/4)", not two.ok, two.reason) + + copied = match_fingerprints( + HW, + {"board_serial": "x1", "emmc_cid": "x2", "cpu_serial": "x3", "mac": "x4", + "machine_id": "sharedbyallunits"}, + ) + check("整盘拷贝到另一台机器不通过(weak 来源不得计票)", + not copied.ok and copied.matched == 0, copied.reason) + check("判定未降级", not copied.degraded, str(copied.degraded)) + + +def test_tolerance_degraded_when_only_weak(): + """仅有 weak 来源时降级判定,并显式标记。""" + r = match_fingerprints({"machine_id": "shared"}, {"machine_id": "shared"}) + check("仅 weak 来源时降级通过", r.ok and r.degraded, r.reason) + + +# --------------------------------------------------------------------------- +# 验签与判定 +# --------------------------------------------------------------------------- +def test_sign_verify_roundtrip_and_tamper(): + tmp = Path(tempfile.mkdtemp()) + pub, priv = _keypair() + path = _write_license(tmp, priv) + + st = read_license(path, pub, sources=HW) + check("正常授权校验通过", st.ok and st.code == REASON_OK, st.message) + check("解析出授权插件", st.licensed_plugins == {"aibox_sophon_adapter"}, + str(st.licensed_plugins)) + + # 篡改白名单(典型的"自己加一个插件")→ 签名失效 + doc = json.loads(Path(path).read_text()) + doc["payload"]["plugins"] = ["aibox_sophon_adapter", "doubao"] + bad = tmp / "tampered.lic" + bad.write_text(json.dumps(doc)) + st2 = read_license(str(bad), pub, sources=HW) + check("篡改白名单被识破", not st2.ok and st2.code == REASON_SIGNATURE_INVALID, + st2.message) + + # 换公钥(非本厂商签发) + other_pub, _ = _keypair() + st3 = read_license(path, other_pub, sources=HW) + check("非本厂商公钥验签失败", + not st3.ok and st3.code == REASON_SIGNATURE_INVALID, st3.message) + + # 字段重排序不影响验签(签名覆盖规范化 JSON) + doc = json.loads(Path(path).read_text()) + doc["payload"] = dict(reversed(list(doc["payload"].items()))) + reordered = tmp / "reordered.lic" + reordered.write_text(json.dumps(doc)) + st4 = read_license(str(reordered), pub, sources=HW) + check("载荷字段重排序不影响验签", st4.ok, st4.message) + + +def test_expiry_and_not_before(): + tmp = Path(tempfile.mkdtemp()) + pub, priv = _keypair() + + expired = _write_license( + tmp, priv, expires_at=(date.today() - timedelta(days=1)).isoformat() + ) + st = read_license(expired, pub, sources=HW) + check("过期授权被拒", not st.ok and st.code == REASON_EXPIRED, st.message) + + # 到期当天仍有效(含首尾当天) + today = _write_license(tmp, priv, expires_at=date.today().isoformat()) + st2 = read_license(today, pub, sources=HW) + check("到期当天仍然有效", st2.ok, st2.message) + + future = _write_license( + tmp, priv, not_before=(date.today() + timedelta(days=5)).isoformat() + ) + st3 = read_license(future, pub, sources=HW) + check("未生效授权被拒", not st3.ok and st3.code == REASON_NOT_YET_VALID, st3.message) + + never = _write_license(tmp, priv, expires_at=None) + st4 = read_license(never, pub, sources=HW) + check("无到期日视为永久有效", st4.ok, st4.message) + + +def test_machine_mismatch_and_plugin_whitelist(): + tmp = Path(tempfile.mkdtemp()) + pub, priv = _keypair() + path = _write_license(tmp, priv, plugins=["ip_sound_column"]) + + st = read_license(path, pub, sources=HW) + check("本机授权有效", st.ok, st.message) + + mgr = LicenseManager(license_path=path, public_key=pub, min_match=3, + clock_store=MemoryClockStore(), collector=lambda: HW) + check("白名单内插件放行", mgr.check_plugin("ip_sound_column").allowed) + denied = mgr.check_plugin("doubao") + check("白名单外插件拒绝", + not denied.allowed and denied.code == REASON_PLUGIN_NOT_LICENSED, denied.message) + + other_machine = dict(HW, mac="999999999999", cpu_serial="888888888888") + st2 = read_license(path, pub, sources=other_machine) + check("机器码不符拒绝", + not st2.ok and st2.code == REASON_MACHINE_MISMATCH, st2.message) + + +def test_machine_code_only_license_fallback(): + """只签机器码(无逐来源指纹)时退化为精确匹配。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _keypair() + path = _write_license(tmp, priv, sources={}, machine_code=machine_code(HW)) + + st = read_license(path, pub, sources=HW) + check("仅机器码的授权:本机通过", st.ok, st.message) + + st2 = read_license(path, pub, sources=dict(HW, mac="000000000001")) + check("仅机器码的授权:异机拒绝", not st2.ok and st2.code == REASON_MACHINE_MISMATCH, + st2.message) + + +# --------------------------------------------------------------------------- +# 申请码 +# --------------------------------------------------------------------------- +def test_request_code_roundtrip(): + code = encode_request_code(HW) + check("申请码前缀", code.startswith("CM1-"), code[:12]) + data = decode_request_code(code) + check("申请码解析出全部来源", data["sources"] == HW, str(data["sources"])) + check("申请码含机器码", data["machine_code"] == machine_code(HW)) + + wrapped = code[:30] + "\n " + code[30:] + " " + check("申请码容忍折行与空白", decode_request_code(wrapped)["sources"] == HW) + + try: + decode_request_code(code[:-1] + ("0" if code[-1] != "0" else "1")) + check("申请码校验和可检出抄写错误", False, "未抛异常") + except RequestCodeError as exc: + check("申请码校验和可检出抄写错误", True, str(exc)) + + try: + decode_request_code("not-a-code") + check("非法申请码被拒", False, "未抛异常") + except RequestCodeError: + check("非法申请码被拒", True) + + +# --------------------------------------------------------------------------- +# 时钟防线 +# --------------------------------------------------------------------------- +def test_clock_guard(): + store = MemoryClockStore() + guard = ClockGuard(store, tolerance_seconds=3600) + now = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc) + + check("首次检查通过并建立水位", guard.check(now).ok) + + later = now + timedelta(hours=5) + check("时间前进正常", guard.check(later).ok) + + # 回退 30 分钟:容差内,放行 + slight = later - timedelta(minutes=30) + check("容差内回退放行", guard.check(slight).ok) + + # 回退 10 小时:超过容差,拒绝 + rollback = later - timedelta(hours=10) + r = guard.check(rollback) + check("明显回拨被检出", not r.ok and r.rollback_seconds > 0, r.reason) + + # 水位未被回退覆盖 + check("回拨后水位未被拉低", guard.check(later).ok) + + +def test_clock_guard_reseeds_when_far_ahead(): + """水位远超当前时间(RTC 异常)时重新起算,避免把设备永久卡死。""" + store = MemoryClockStore() + guard = ClockGuard(store, tolerance_seconds=3600, reseed_if_ahead_days=90) + guard.check(datetime(2027, 1, 1, tzinfo=timezone.utc)) + r = guard.check(datetime(2026, 9, 20, tzinfo=timezone.utc)) + check("大幅领先判为时钟异常而非攻击", r.ok and "异常" in r.reason, r.reason) + + +def test_manager_blocks_on_clock_rollback(): + """时钟回拨时管理器的判定整体中止。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _keypair() + path = _write_license(tmp, priv, plugins=["ip_sound_column"]) + + store = MemoryClockStore() + far_future = datetime.now(timezone.utc) + timedelta(days=2) + ClockGuard(store, tolerance_seconds=3600).check(far_future) + + mgr = LicenseManager(license_path=path, public_key=pub, clock_store=store, + collector=lambda: HW) + st = mgr.reload() + check("时钟回拨时授权判定中止", + not st.ok and st.code == REASON_CLOCK_ROLLBACK, st.message) + check("时钟回拨时插件被拒", not mgr.check_plugin("ip_sound_column").allowed) + + +def test_manager_disabled_mode_reports_reality(): + """enforce=False(开发/测试)时放行,但状态仍如实反映真实校验结果。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _keypair() + path = _write_license(tmp, priv, plugins=[]) + + mgr = LicenseManager(license_path=path, public_key=pub, enforce=False, + collector=lambda: HW) + check("未启用时插件放行", mgr.check_plugin("doubao").allowed) + check("未启用时仍如实报告授权有效", mgr.status.ok, mgr.status.message) + + +def main(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in tests: + print(f"\n--- {fn.__name__} ---") + fn() + print("\n" + "=" * 56) + if failures: + print(f"❌ {len(failures)} 项未通过: {failures}") + return 1 + print("✅ 全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index e199c6d..b94edc5 100644 --- a/uv.lock +++ b/uv.lock @@ -461,17 +461,18 @@ wheels = [ [[package]] name = "chongming-cli" -version = "0.3.1" +version = "0.3.2" source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } dependencies = [ { name = "chongming-config" }, + { name = "chongming-license" }, { name = "module-bank" }, { name = "sqlite-vfs" }, { name = "tomli" }, ] -sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.1/chongming_cli-0.3.1.tar.gz", hash = "sha256:b5925b9b22ffe374720a48d2614a3a06ace020cba53a20e2b24d53c0e1d2305e" } +sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.2/chongming_cli-0.3.2.tar.gz", hash = "sha256:af02210a9fa124eda0533dcb7c833c985e629fc520a70c6fffeb87ee461a1659" } wheels = [ - { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.1/chongming_cli-0.3.1-py3-none-any.whl", hash = "sha256:189c9e43d0c88817844f689e32686c2f4b66f4e25e89ab23c1adb135d7a3f4e2" }, + { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.2/chongming_cli-0.3.2-py3-none-any.whl", hash = "sha256:57a286c33f364082aa43a71500ec0c918111327053def1d75d36bb3b3acc3eb6" }, ] [[package]] @@ -513,6 +514,20 @@ wheels = [ { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:d63cc24550b7084e55d4dd0cb9f99cbd0a9d77da39ad0685698fd484e4376e72" }, ] +[[package]] +name = "chongming-license" +version = "0.1.0" +source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } +dependencies = [ + { name = "cryptography" }, + { name = "orjson" }, + { name = "pydantic" }, +] +sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-license/0.1.0/chongming_license-0.1.0.tar.gz", hash = "sha256:30f3752a53ea76465c47ad324cbe1e4cfc656df175a9864f6d217d7b983d21c9" } +wheels = [ + { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-license/0.1.0/chongming_license-0.1.0-py3-none-any.whl", hash = "sha256:6369da005874c83a279d35cbaffcd606c494b49514e4896b665e6f19ba2aa36a" }, +] + [[package]] name = "chongming-mqtt" version = "0.1.0" @@ -587,6 +602,7 @@ dependencies = [ { name = "chongming-auth" }, { name = "chongming-cache" }, { name = "chongming-jwt" }, + { name = "chongming-license" }, { name = "chongming-mqtt" }, { name = "chongming-password" }, { name = "chongming-request" }, @@ -624,6 +640,7 @@ requires-dist = [ { name = "chongming-auth" }, { name = "chongming-cache" }, { name = "chongming-jwt" }, + { name = "chongming-license" }, { name = "chongming-mqtt" }, { name = "chongming-password" }, { name = "chongming-request" }, @@ -1119,14 +1136,15 @@ wheels = [ [[package]] name = "module-bank" -version = "0.3.2" +version = "0.4.2" source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } dependencies = [ { name = "cryptography" }, { name = "pyarmor" }, + { name = "sqlite-vfs" }, ] wheels = [ - { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/d3/0a7a8297ec3041e0352f313e020276a04c59bc9200138094df2a6d374f51/module_bank-0.3.2-py3-none-any.whl", hash = "sha256:524faaa86258ebbdc07df493a041566adfbdf57461487a63bf7161e6aa79a817", size = 19051, upload-time = "2026-05-04T16:21:19.827Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bc/a6/436bc5ffd88b5bf00afff5632a3f02a1aa679487fff4e0d4c581af973c6b/module_bank-0.4.2-py3-none-any.whl", hash = "sha256:a89fdd7a5c1b8b2b6a073a4208d42e17cbee7cbf60971436b19d236b97b11b88", size = 44987, upload-time = "2026-09-20T09:32:06.539Z" }, ] [[package]] @@ -1137,7 +1155,7 @@ sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c7/ce/38 [[package]] name = "openai" -version = "3.15.0" +version = "3.16.2" source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } dependencies = [ { name = "anyio" }, @@ -1147,9 +1165,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/82/6b/f086332d9fdefcc3853f2457209fcf221985d01d32b5021ab8096be7d506/openai-3.15.0.tar.gz", hash = "sha256:47bc15f8b9db62ba9ff7fa32164c72f4fd6333b22d890251292aac1c6bfe998f", size = 1664554, upload-time = "2026-09-18T00:53:05.192Z" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e2/c6/770171ef28854d8092ee2c3df68ef97d127362b28771e6b423c948894e07/openai-3.16.2.tar.gz", hash = "sha256:e129455fc6a744276cda63a9e2f0759207bc0cf24afc4cf356bf5029e631e955", size = 1696266, upload-time = "2026-09-18T21:27:23.586Z" } wheels = [ - { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fb/92/fc65194e6e92cf0202a2e5fff75e2fef62e7dbb7a6cf2b48c1239dfedb50/openai-3.15.0-py3-none-any.whl", hash = "sha256:95d6b2caf3823c7e0fc08911317b7f31b9f9ccf93b414e7a5c8d5b226af115f6", size = 2018693, upload-time = "2026-09-18T00:53:02.507Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/17/72/2b1fe3e303db76ab2ba0c2de2b57822869bcb588db82334d4ff3aea8acf2/openai-3.16.2-py3-none-any.whl", hash = "sha256:660a7f8307e6605342ae84ff4425412e7cd19e5e9b298f975502df2a1a9a5d5f", size = 2053568, upload-time = "2026-09-18T21:27:20.885Z" }, ] [[package]] @@ -1740,10 +1758,11 @@ wheels = [ [[package]] name = "sqlite-vfs" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1a/b2/d4d1dcdbf13d9a9884b30d5cab90d518d86e5f1ec3531acddfbe8ebcdd1a/sqlite_vfs-0.4.0.tar.gz", hash = "sha256:970cc30d0cd16b3bc9f02b08af37eb8fcf89523b6c17740a86cd9cc260d544a4", size = 740965, upload-time = "2026-09-20T08:13:35.884Z" } wheels = [ - { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/13/28/29f6e4ec636c8ea5a91912079044c415cf59828d8185006ed1ca0104b9ba/sqlite_vfs-0.3.0-py3-none-any.whl", hash = "sha256:21db55f73867fee539aa8ae9e58e4dd06173d7b856d5d4a29e86d9d60364cffb", size = 82968, upload-time = "2026-09-16T01:53:57.007Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/37/9f/5b32146512efc290df1a7c654f603317346d17b04684ea55dff19affe18d/sqlite_vfs-0.4.0-py3-none-any.whl", hash = "sha256:d2584e8d1cf5908f30aea6db6b95eaa01c62be426bde41e5ff7d226d40128a31", size = 94794, upload-time = "2026-09-20T08:13:34.216Z" }, ] [[package]]