225 lines
8.9 KiB
Python
225 lines
8.9 KiB
Python
"""构建期授权守护测试:公钥注入 + 私钥泄漏守卫。
|
|||
|
|
|
||
|
|
对齐 `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())
|