feat: 打包时烘焙授权决策(--license 变为可选,消除砖头包风险)

prepare 阶段把"是否授权版"写进 chongming/_build_flags.py(随产物编译、部署侧
不可改):
- --license + 公钥:烘焙授权版 —— 强制校验,公钥随代码内置,现场即便把
  config.toml 里的公钥换成自己的并自签 license 也过不了验签;同时保留原有
  注入产物配置(仅作镜像/可见性)
- 不加 --license:烘焙非授权版 —— 不校验授权、插件照常加载(与开发态一致),
  即"打包不配 license"是合法选项,不再是拒绝所有插件的砖头包
- --license 缺公钥:默认仍让构建失败;ALLOW_NO_KEY=1 显式放行时降级为
  非授权版(旧行为是产出砖头包)
- 公钥在构建期校验格式,非法即终止构建;烘焙文件的常量名与网关读取端
  chongming.app.core.licensing 对齐
This commit is contained in:
2026-10-14 10:57:14 +08:00
parent fe50c70b03
commit bafca77436
+74 -15
View File
@@ -27,14 +27,16 @@
做 PyArmor 混淆。**默认关闭**(产物为明文,默认只适合内部 做 PyArmor 混淆。**默认关闭**(产物为明文,默认只适合内部
调试;对外分发必须加这个参数) 调试;对外分发必须加这个参数)
--license 打开授权:注入厂商授权公钥(缺公钥则构建失败)、跑授权私钥 --license 打开授权:注入厂商授权公钥(缺公钥则构建失败)、跑授权私钥
泄漏守卫。**默认关闭**(不注入、不守卫、缺公钥也不报错, 泄漏守卫。**默认关闭**(license 是可选构建项:不加则产物为
产物无法校验任何授权;对外分发必须加这个参数) "非授权版"——不校验授权、插件照常加载,与开发态一致;
加上则产物为"授权版"——强制校验,现场改配置/环境变量关不掉。
两者由构建期烘焙进产物,见 chongming/_build_flags.py)
示例: 示例:
uv run build --only mbank # 依赖未变时,仅重新打包 .mbank 模块文件 uv run build --only mbank # 依赖未变时,仅重新打包 .mbank 模块文件
uv run build nuitka --only mbank,prepare uv run build nuitka --only mbank,prepare
uv run build --encrypt --license # 对外分发:加密模块 + 注入授权公钥 uv run build --encrypt --license # 对外分发:加密模块 + 授权版产物
uv run build # 内部调试:明文模块、不带授权 uv run build # 内部/无授权版:明文模块、不校验授权
依赖收集(--only pack 生效): 依赖收集(--only pack 生效):
打包收集清单 = 根 config.toml [pyinstaller_build_import](网关本体依赖) 打包收集清单 = 根 config.toml [pyinstaller_build_import](网关本体依赖)
@@ -99,7 +101,7 @@ class BuildOptions:
"""构建开关(命令行可选参数,默认全部关闭 = 明文调试产物)""" """构建开关(命令行可选参数,默认全部关闭 = 明文调试产物)"""
encrypt: bool = False # --encrypt: 打开 mbank 加密与 PyArmor 混淆 encrypt: bool = False # --encrypt: 打开 mbank 加密与 PyArmor 混淆
license: bool = False # --license: 打开授权公钥注入与私钥泄漏守卫 license: bool = False # --license: 授权版产物(注入公钥 + 烘焙强制校验);默认非授权版
@dataclass @dataclass
@@ -387,33 +389,46 @@ def _stage_prepare_build_dir(config: Config, mode: str, options: BuildOptions) -
jwt_key = Encryption.generate_key() jwt_key = Encryption.generate_key()
content = content.replace("your-secret-key-change-in-production", jwt_key) content = content.replace("your-secret-key-change-in-production", jwt_key)
# 注入厂商授权公钥(机器码授权)—— 默认不做,加 --license 才注入 # 注入厂商授权公钥(机器码授权)—— 默认不做,加 --license 才注入;
# 两条路径都会把"本产物是否为授权版"烘焙进 chongming/_build_flags.py
# (编译进产物、部署侧不可改)—— 未加 --license 的产物是不校验授权、
# 插件照常加载的正常版本,而不是"拒绝所有插件"的砖头包。
if options.license: if options.license:
pubkey = _resolve_license_public_key() pubkey = _resolve_license_public_key()
if pubkey: if pubkey:
from chongming_license import public_key_fingerprint
content = _set_license_pubkey(content, pubkey) content = _set_license_pubkey(content, pubkey)
_write_build_flags(enforce=True, public_key=pubkey)
print(
f" ✓ 已注入厂商授权公钥(指纹 {public_key_fingerprint(pubkey)}):"
"产物为授权版,强制校验授权(现场改配置/环境变量关不掉)"
)
elif os.environ.get(ALLOW_NO_KEY_ENV) == "1": elif os.environ.get(ALLOW_NO_KEY_ENV) == "1":
# 公钥缺失时打包产物会拒绝**全部**插件(没人能证明被授权)= 砖头设备, # 无公钥 = 谁都证明不了被授权。此时**不再**烘焙"授权版"(那会拒绝
# 故默认让构建失败;仅内部演示允许显式放行。 # 全部插件 = 砖头设备),而是烘焙非授权版:插件照常加载、不校验授权。
_write_build_flags(enforce=False)
print( print(
f" ⚠️ 未提供授权公钥且设置了 {ALLOW_NO_KEY_ENV}=1:" f" ⚠️ 未提供授权公钥且设置了 {ALLOW_NO_KEY_ENV}=1:"
"本产物无法校验任何授权(所有插件都不会加载),仅限内部演示" "本产物为非授权版(不校验授权,插件照常加载),仅限内部演示"
) )
else: else:
raise RuntimeError( raise RuntimeError(
"缺少厂商授权公钥:打包产物会拒绝加载所有插件(设备变砖)。\n" "缺少厂商授权公钥:无法产出可校验授权的产物。\n"
f" 请用环境变量 {LICENSE_PUBKEY_ENV}=<base64 公钥> 或 " f" 请用环境变量 {LICENSE_PUBKEY_ENV}=<base64 公钥> 或 "
f"{LICENSE_PUBKEY_FILE_ENV}=<文件路径> 提供,\n" f"{LICENSE_PUBKEY_FILE_ENV}=<文件路径> 提供,\n"
f" 或把公钥写入 {DEFAULT_LICENSE_PUBKEY_FILE}" f" 或把公钥写入 {DEFAULT_LICENSE_PUBKEY_FILE}"
"(由 `uv run chongming-license keygen` 输出)。\n" "(由 `uv run chongming-license keygen` 输出)。\n"
f" 仅内部演示无授权构建可去掉 --license," " 不需要授权校验的内部/演示构建请**去掉 --license**"
f"或设 {ALLOW_NO_KEY_ENV}=1 强制放行。" f"(产物不校验、插件照常加载),或设 {ALLOW_NO_KEY_ENV}=1 强制放行。"
) )
else: else:
# 默认不注入:产物不带厂商授权信任根(模板里已写着的公钥也不动) # 非授权版:明确烘焙"不校验授权"(产物内编译,部署侧改不了)——
# 打包不含 license 因此始终是合法选项,产物与开发态一致、插件照常加载。
_write_build_flags(enforce=False)
print( print(
" ⚠️ 未注入厂商授权公钥(默认):本产物无法校验任何授权," " ⚠️ 未注入厂商授权公钥(默认):本产物为非授权版(不校验授权,"
"对外分发请加 --license" "插件照常加载),对外分发请加 --license"
) )
cfg_path.write_text(content, encoding="utf-8") cfg_path.write_text(content, encoding="utf-8")
@@ -457,6 +472,50 @@ def _resolve_license_public_key() -> str:
return "" return ""
def _write_build_flags(enforce: bool, public_key: str = "") -> Path:
"""把"本产物是否为授权版"烘焙成 ``chongming/_build_flags.py``(随代码编译)。
格式契约与网关读取端 ``chongming.app.core.licensing`` 对齐(常量名一致):
该模块在打包 venv 的网关包内生成,编译进产物后**部署侧改不了** —— 所以
"打包不配 license" 可以是合法选项(烘焙 False = 不校验、插件照常加载),
而授权版产物的强制校验也不会被现场改 config.toml / 环境变量关掉。
授权版同时把公钥烘焙进代码:现场即便把 config.toml 里的公钥换成自己的
并自签一份 license,也过不了内置公钥的验签(配置公钥只在未烘焙时兜底)。
"""
import importlib.util
from chongming_license import load_public_key, public_key_fingerprint
fingerprint = ""
if public_key:
# 失败即构建期配置错误:趁早报错,避免打出一个"谁都验不过"的授权版产物
load_public_key(public_key)
fingerprint = public_key_fingerprint(public_key)
spec = importlib.util.find_spec("chongming")
if spec is None or not spec.submodule_search_locations:
raise RuntimeError(
"未找到 chongming 网关包,无法写入构建期授权标记(_build_flags.py)。\n"
" 请确认构建 venv 已安装 chongming-gateway(uv sync)"
)
target = Path(next(iter(spec.submodule_search_locations))) / "_build_flags.py"
target.write_text(
"\n".join([
'"""构建期烘焙的授权决策 —— 由打包脚本生成,请勿手改。',
"",
"编译进产物后部署侧不可修改:部署目录的 config.toml / 环境变量都影响不了",
"这份决策。读取端:chongming.app.core.licensing。",
'"""',
f"LICENSE_ENFORCE = {enforce!r}",
f'LICENSE_PUBLIC_KEY = "{public_key}"',
f'PUBLIC_KEY_FINGERPRINT = "{fingerprint}"',
]) + "\n",
encoding="utf-8",
)
return target
def _set_license_pubkey(text: str, pubkey: str) -> str: def _set_license_pubkey(text: str, pubkey: str) -> str:
"""把公钥写进 [license] 段的 public_key,保留其余注释与排版。 """把公钥写进 [license] 段的 public_key,保留其余注释与排版。