modify: 授权
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user