modify: 授权
This commit is contained in:
@@ -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