first commit

This commit is contained in:
2026-09-21 11:00:37 +08:00
commit f4a319337a
10 changed files with 2415 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
/target
# Byte-compiled / optimized / DLL files
__pycache__/
.pytest_cache/
*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
.venv/
env/
bin/
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
include/
man/
venv/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
pip-selfcheck.json
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Rope
.ropeproject
# Django stuff:
*.log
*.pot
.DS_Store
# Sphinx documentation
docs/_build/
# PyCharm
.idea/
# VSCode
.vscode/
# Pyenv
.python-version
+91
View File
@@ -0,0 +1,91 @@
# chongming-license
机器码授权共享包:硬件指纹采集、license 载荷格式、Ed25519 验签与插件白名单判定。
网关与厂商侧签发工具共用同一份实现 —— 尤其是**规范化 JSON 只有一处实现**,
避免"自己签的自己验不过"。
## 设计要点
- **纯离线**:设备用内置公钥验签,运行期零联网。与现有 air-gapped 部署形态
(Zenoh localhost、Caddy `tls internal`、无 phone-home)一致。
- **非对称**:Ed25519。注意根仓 `resources/*.key` 那套 `.mbank` 验签是
**HMAC 对称**且密钥与银行同目录发布,只能算防篡改/混淆,**不能**作为厂商
信任根 —— 授权必须用非对称,公钥可安全内置。
- **N-of-M 容错**:license 里存各来源的原始指纹,校验按"命中数达阈值"判定,
而非把所有来源哈希成一个值 —— 否则换一块网卡就直接失效。
展示给客户的仍是单个 `machine_code`。
- **weak 来源**:`/etc/machine-id` 在同镜像批量刷机场景下**所有机器相同**,
故标记 `weak` 且**不参与判定阈值**(只作诊断参考),避免它给攻击者白送一票。
## 采集来源
| 来源 | 位置 | 说明 |
| --- | --- | --- |
| `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,见上 |
## 文件格式
```json
{
"payload": {
"v": 1,
"lic_id": "L2026-0001",
"customer": "某某科技",
"machine_code": "A3F9-1B77-9E04-2C8D-6F31-0A55",
"sources": {"board_serial": "...", "emmc_cid": "...", "mac": "..."},
"plugins": ["aibox_sophon_adapter", "ip_sound_column"],
"not_before": "2026-09-01",
"expires_at": "2027-09-01",
"issued_at": "2026-09-01T10:00:00Z",
"note": ""
},
"alg": "ed25519",
"kid": "vendor-2026",
"sig": "<base64,覆盖 payload 的规范化 JSON>"
}
```
签名覆盖 payload **原始字节的规范化形式**(`canonical_json`:键排序、无空白),
因此载荷内不允许浮点数(float 文本表示存在多种合法写法)。校验顺序是
先验签、后解析模型 —— 反过来会让默认值填充/字段顺序改变字节导致验签失败。
## 用法
网关侧:
```python
from chongming_license import LicenseManager
mgr = LicenseManager(
license_path="license.lic",
public_key=config["license"]["public_key"],
clock_store=cache, # chongming_cache.Cache,存时间高水位
enforce=is_packaged(), # 打包产物强制校验;开发/测试放行
)
decision = mgr.check_plugin("aibox_sophon_adapter")
if not decision.allowed:
... # 不加载该插件
```
厂商侧(`chongming-cli`,私钥只在这边):
```bash
uv run chongming-license keygen -o license_private.pem # 一次性生成密钥对
uv run chongming-license issue --machine-code <码> --sources-json <采集导出> \
--plugins aibox_sophon_adapter,ip_sound_column \
--customer 某某科技 --expires 2027-09-01 -k license_private.pem -o license.lic
```
## 诚实的边界
离线 + root 的天花板是**阻止随手拷贝**,不是阻止逆向:有 root 者可改二进制
跳过校验,或 `LD_PRELOAD` 伪造指纹读取。`clock` 模块的时间回拨检测同理 ——
删掉缓存文件即失效,它只是抬高随手改时间的门槛。
真正难以绕过的是**签名**:伪造 license 需要厂商私钥,而公钥可明文放置
(换公钥只会验签失败,无法据以伪造)。
+25
View File
@@ -0,0 +1,25 @@
[build-system]
build-backend = "hatchling.build"
requires = ["hatchling"]
[project]
name = "chongming-license"
version = "0.1.0"
description = "chongming 机器码授权:硬件指纹采集、license 载荷与 Ed25519 验签、插件白名单判定(网关与签发工具共用)"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"cryptography",
"orjson",
"pydantic",
]
# 不在此声明 [dependency-groups] dev:本包已加入根 workspace(members),
# 其 dev group 会并入 workspace 解析,而根仓的测试统一放在 test/ 下运行。
# 若日后需要独立开发,再按 utils/auth 的写法单独加上。
[tool.hatch.build.targets.wheel]
packages = ["src/chongming_license"]
[[tool.uv.index]]
url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/"
+366
View File
@@ -0,0 +1,366 @@
"""chongming-license —— 机器码授权共享包。
纯离线授权:厂商用**私钥**签发 ``license.lic``(绑定机器指纹 + 插件白名单 +
有效期),设备用内置**公钥**验签,无需任何联网。
使用方式(网关侧)::
from chongming_license import LicenseManager
mgr = LicenseManager(
license_path="license.lic",
public_key=config["license"]["public_key"],
clock_store=cache,
enforce=is_packaged(), # 打包产物强制校验,开发/测试放行
)
decision = mgr.check_plugin("aibox_sophon_adapter")
if not decision.allowed:
... # 不加载该插件
两侧职责边界:
- 本包(网关与签发工具共用):指纹采集、载荷格式、规范化与验签、判定、时钟防线
- 签发工具(``chongming-cli license``,仅厂商侧):生成密钥对、签发 license
- 私钥绝不进入部署产物(构建脚本有守护检查)
"""
from typing import Dict
from .clock import (
CLOCK_HWM_KEY,
DEFAULT_CLOCK_TOLERANCE_SECONDS,
DEFAULT_RESEED_IF_AHEAD_DAYS,
ClockCheck,
ClockGuard,
MemoryClockStore,
)
from .issue import (
build_license,
build_payload,
make_license_id,
resolve_expiry,
utc_today,
write_license,
)
from .machine import (
DEFAULT_MIN_MATCH,
MACHINE_CODE_HEX_LEN,
REQUEST_CODE_PREFIX,
SOURCES,
FingerprintReport,
FingerprintUnavailableError,
MatchResult,
RawValue,
RequestCodeError,
SourceDetail,
SourceSpec,
collect_fingerprints,
collect_report,
decode_request_code,
encode_request_code,
machine_code,
match_fingerprints,
normalize,
)
from .models import (
LICENSE_VERSION,
MACHINE_CODE_GROUP,
SIGNATURE_ALGORITHM,
LicenseFile,
LicensePayload,
)
from .registry import (
DEFAULT_EXPIRING_DAYS,
SCHEMA_VERSION,
STATUS_LABELS,
LicenseRecord,
Registry,
RegistryError,
default_registry_path,
record_from_license,
)
from .verify import (
REASON_CLOCK_ROLLBACK,
REASON_DISABLED,
REASON_EXPIRED,
REASON_FINGERPRINT_UNAVAILABLE,
REASON_LICENSE_MALFORMED,
REASON_LICENSE_MISSING,
REASON_MACHINE_MISMATCH,
REASON_NOT_YET_VALID,
REASON_NO_PUBLIC_KEY,
REASON_OK,
REASON_PLUGIN_NOT_LICENSED,
REASON_SIGNATURE_INVALID,
REASON_TEXT,
REASON_UNSUPPORTED_ALG,
LicenseError,
LicenseMalformedError,
LicenseStatus,
PluginDecision,
PublicKeyError,
SignatureInvalidError,
canonical_json,
decision_for_plugin,
generate_keypair,
load_private_key,
load_public_key,
public_key_b64,
public_key_fingerprint,
public_key_pem,
read_license,
sign_payload,
verify_signature,
write_private_key,
)
__all__ = [
# 门面
"LicenseManager",
# 签发(厂商侧)
"build_license",
"build_payload",
"make_license_id",
"resolve_expiry",
"utc_today",
"write_license",
# 机器指纹
"SOURCES",
"DEFAULT_MIN_MATCH",
"MACHINE_CODE_HEX_LEN",
"REQUEST_CODE_PREFIX",
"FingerprintReport",
"FingerprintUnavailableError",
"MatchResult",
"RawValue",
"RequestCodeError",
"SourceDetail",
"SourceSpec",
"collect_fingerprints",
"collect_report",
"decode_request_code",
"encode_request_code",
"machine_code",
"match_fingerprints",
"normalize",
# 载荷格式
"LICENSE_VERSION",
"MACHINE_CODE_GROUP",
"SIGNATURE_ALGORITHM",
"LicenseFile",
"LicensePayload",
# 验签与判定
"LicenseError",
"LicenseMalformedError",
"LicenseStatus",
"PluginDecision",
"PublicKeyError",
"SignatureInvalidError",
"REASON_TEXT",
"REASON_OK",
"REASON_DISABLED",
"REASON_LICENSE_MISSING",
"REASON_LICENSE_MALFORMED",
"REASON_SIGNATURE_INVALID",
"REASON_UNSUPPORTED_ALG",
"REASON_NO_PUBLIC_KEY",
"REASON_MACHINE_MISMATCH",
"REASON_NOT_YET_VALID",
"REASON_EXPIRED",
"REASON_CLOCK_ROLLBACK",
"REASON_PLUGIN_NOT_LICENSED",
"REASON_FINGERPRINT_UNAVAILABLE",
"canonical_json",
"decision_for_plugin",
"generate_keypair",
"load_private_key",
"load_public_key",
"public_key_b64",
"public_key_fingerprint",
"public_key_pem",
"read_license",
"sign_payload",
"verify_signature",
"write_private_key",
# 时钟防线
"CLOCK_HWM_KEY",
"DEFAULT_CLOCK_TOLERANCE_SECONDS",
"DEFAULT_RESEED_IF_AHEAD_DAYS",
"ClockCheck",
"ClockGuard",
"MemoryClockStore",
# 已签发台账(厂商侧)
"DEFAULT_EXPIRING_DAYS",
"SCHEMA_VERSION",
"STATUS_LABELS",
"LicenseRecord",
"Registry",
"RegistryError",
"default_registry_path",
"record_from_license",
]
class LicenseManager:
"""授权门面:读一次 license,回答"某插件能否运行"。
结果缓存到 :meth:`reload` 之前,因此启动期对每个插件调用 ``check_plugin``
只付出一次文件读取与验签开销。替换 license 文件后调用 :meth:`reload` 即可
生效(管理接口导入新文件时会调用)。
Args:
license_path: license.lic 路径
public_key: 厂商公钥(base64 裸 32 字节或 PEM);为空则视为未配置
min_match: 指纹命中阈值
clock_store: 时钟水位存储(``chongming_cache.Cache`` 或等价对象)
enforce: 是否真正执行拒绝。False 时 ``check_plugin`` 一律放行,
但 :attr:`status` 仍如实反映校验结果(开发/测试模式)
collector: 指纹采集函数,None = 现场采集(测试注入用)
logger: 可选的 ``logging.Logger``,用于输出启动横幅
"""
def __init__(
self,
*,
license_path: str = "license.lic",
public_key: str = "",
min_match: int = DEFAULT_MIN_MATCH,
clock_store=None,
enforce: bool = True,
collector=None,
logger=None,
) -> None:
self.license_path = license_path
self.public_key = public_key or ""
self.min_match = max(1, int(min_match))
self.enforce = bool(enforce)
self._collector = collector
self._logger = logger
self._clock_guard = ClockGuard(clock_store)
self._status: LicenseStatus | None = None
# -- 校验与缓存 ---------------------------------------------------------
def _collect(self):
"""采集本机指纹;未注入 collector 时走现场采集。"""
if self._collector is not None:
return self._collector()
return collect_fingerprints()
def collect_sources(self) -> Dict[str, str]:
"""采集本机指纹来源(公开接口;管理接口导入 license 时复用同一份采集)。"""
return self._collect()
def reload(self) -> LicenseStatus:
"""重新读取并校验 license(丢弃缓存)。"""
# 先过时钟防线:检出回拨时不再采信有效期
clock = self._clock_guard.check()
if not clock.ok:
self._status = LicenseStatus(
ok=False,
code=REASON_CLOCK_ROLLBACK,
message=f"{REASON_TEXT[REASON_CLOCK_ROLLBACK]}:{clock.reason}",
license_path=str(self.license_path),
public_key_fingerprint=public_key_fingerprint(self.public_key),
warnings=[clock.reason],
)
return self._status
try:
sources = self._collect()
except Exception as exc:
sources = {}
self._status = LicenseStatus(
ok=False,
code=REASON_FINGERPRINT_UNAVAILABLE,
message=f"{REASON_TEXT[REASON_FINGERPRINT_UNAVAILABLE]}:{exc}",
license_path=str(self.license_path),
public_key_fingerprint=public_key_fingerprint(self.public_key),
warnings=[str(exc)],
)
return self._status
try:
report = collect_report()
warnings = list(report.warnings)
except Exception:
warnings = []
status = read_license(
self.license_path,
self.public_key,
min_match=self.min_match,
sources=sources,
)
# 采集侧告警(无效值、来源不足等)并入状态,便于管理页一次看全
status.warnings = warnings + list(status.warnings)
if clock.reason:
status.warnings.append(clock.reason)
self._status = status
return status
@property
def status(self) -> LicenseStatus:
"""当前校验结论(首次访问时自动校验)。"""
if self._status is None:
return self.reload()
return self._status
# -- 对外判定 -----------------------------------------------------------
def check_plugin(self, plugin_type: str) -> PluginDecision:
"""该插件是否获授权运行。
``enforce=False``(开发/测试)时始终放行,但仍可在 :attr:`status`
看到真实的校验结论。
"""
status = self.status
if not self.enforce:
return PluginDecision(
allowed=True,
code=REASON_DISABLED,
plugin_type=plugin_type,
message=f"{REASON_TEXT[REASON_DISABLED]}(真实校验结果:{status.code})",
)
return decision_for_plugin(status, plugin_type)
def denied_types(self, plugin_types) -> set[str]:
"""从给定插件类型中筛出**未获授权**的那些(当前不允许加载的集合)。"""
return {t for t in plugin_types if not self.check_plugin(t).allowed}
def describe(self) -> dict:
"""给管理接口/前端展示的完整信息(含每个来源的采集明细)。"""
status = self.status
try:
report = collect_report()
details = [d.as_dict() for d in report.details]
warnings = list(report.warnings)
except Exception as exc:
details, warnings = [], [str(exc)]
return {
"enforce": self.enforce,
"min_match": self.min_match,
"status": status.as_dict(),
"sources_detail": details,
"warnings": list(dict.fromkeys(warnings + status.warnings)),
}
# -- 日志 ---------------------------------------------------------------
def banner(self) -> list[str]:
"""启动日志行(由调用方按自己的日志格式输出)。"""
status = self.status
flag = "✓" if status.ok else "✗"
if not self.enforce:
lines = [
f"[license] 校验未启用(开发/测试模式),本机机器码 {status.machine_code}",
f"[license] 当前授权文件真实校验结果:{status.message}",
]
else:
lines = [f"[license] {flag} {status.message}(本机机器码 {status.machine_code})"]
if status.payload:
p = status.payload
lines.append(
f"[license] 编号 {p.lic_id}"
+ (f",客户 {p.customer}" if p.customer else "")
+ (f",到期 {p.expires_at}" if p.expires_at else ",无到期日")
+ f",授权插件 {sorted(status.licensed_plugins) or '无'}"
)
if status.public_key_fingerprint:
lines.append(f"[license] 厂商公钥指纹 {status.public_key_fingerprint}")
return lines
+159
View File
@@ -0,0 +1,159 @@
"""系统时间回拨检测(离线授权的必要配套)。
纯离线方案里"有效期"依赖本机时钟,而本机时钟对 root 是可改的:把时间调回
授权期内,过期的 license 就能一直用。这里用**高水位标记**做最基础的一道防线 ——
把观测到过的最大时间持久化,之后若时间显著早于该水位,即判定被回拨。
诚实的边界:攻击者若删掉缓存文件(或整库恢复快照),水位就没了。因此这是
"抬高随手改时间的门槛",不是不可绕过的防线;真正难绕过的是 license 有效期
本身(由厂商私钥签名,无法伪造)。所以本模块**只用于中止校验并告警**,
不参与签名与机器绑定判定。
存储接口(duck typing):``get(key, default)`` / ``set(key, value)``
—— 直接传 ``chongming_cache.Cache`` 即可;测试可传 :class:`MemoryClockStore`。
注意**不要给该键设置 TTL**:水位一旦过期被淘汰,防线即失效。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Protocol
# 高水位键名(与 cache 内其它键同库共存)
CLOCK_HWM_KEY = "license:clock_hwm"
# 容差(秒):允许的回退幅度,默认 1 天。
# 取较大值是为了容忍 NTP 校时、RTC 初始不准等**正常**回退而不误判;对 license
# 而言(有效期以月/年计)1 天的作弊窗口没有实际意义。
DEFAULT_CLOCK_TOLERANCE_SECONDS = 86400
# 水位领先当前时间超过该天数时,视为时钟本身不可信(如首次上电 RTC 混乱、
# 出厂预置了错误年份),记录告警并重新起算,避免把设备永久卡死。
DEFAULT_RESEED_IF_AHEAD_DAYS = 90
# 记录到存储里的时间格式(ISO8601,便于运维直接查看 cache.db)
_STORED_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
class ClockStore(Protocol):
"""时钟水位的最小存储接口(chongming_cache.Cache 天然满足)。"""
def get(self, key: str, default: Any = None) -> Any: ...
def set(self, key: str, value: Any) -> None: ...
class MemoryClockStore:
"""内存存储(测试用;进程重启即丢失水位)。"""
def __init__(self) -> None:
self._data: Dict[str, Any] = {}
def get(self, key: str, default: Any = None) -> Any:
return self._data.get(key, default)
def set(self, key: str, value: Any) -> None:
self._data[key] = value
@dataclass
class ClockCheck:
"""时钟检查结论。"""
ok: bool
reason: str = ""
rollback_seconds: float = 0.0
high_water_mark: datetime | None = None
def as_dict(self) -> dict:
return {
"ok": self.ok,
"reason": self.reason,
"rollback_seconds": self.rollback_seconds,
"high_water_mark": (
self.high_water_mark.strftime(_STORED_FORMAT) if self.high_water_mark else None
),
}
class ClockGuard:
"""时间回拨检测器。
Args:
store: 持久化存储(chongming_cache.Cache 或等价对象);None = 不检测
tolerance_seconds: 允许的回退幅度
reseed_if_ahead_days: 水位领先当前时间超过该天数时重新起算
"""
def __init__(
self,
store: ClockStore | None = None,
*,
tolerance_seconds: float = DEFAULT_CLOCK_TOLERANCE_SECONDS,
reseed_if_ahead_days: float = DEFAULT_RESEED_IF_AHEAD_DAYS,
) -> None:
self._store = store
self.tolerance = max(0.0, float(tolerance_seconds))
self.reseed_if_ahead_days = max(0.0, float(reseed_if_ahead_days))
def _read_hwm(self) -> datetime | None:
raw = self._store.get(CLOCK_HWM_KEY) if self._store else None
if not raw:
return None
try:
return datetime.strptime(str(raw), _STORED_FORMAT).replace(tzinfo=timezone.utc)
except ValueError:
# 存储被人工改坏:丢弃水位重新起算,不让它卡死设备
return None
def _write_hwm(self, when: datetime) -> None:
if self._store:
self._store.set(CLOCK_HWM_KEY, when.strftime(_STORED_FORMAT))
def check(self, now: datetime | None = None) -> ClockCheck:
"""检查并推进水位。
Returns:
ClockCheck:``ok=False`` 表示检出明显回拨(应中止授权校验)
"""
if self._store is None:
return ClockCheck(ok=True, reason="未配置时钟水位存储,跳过回拨检测")
current = now or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
hwm = self._read_hwm()
if hwm is None:
self._write_hwm(current)
return ClockCheck(ok=True, high_water_mark=current)
skew = (hwm - current).total_seconds()
# 时钟大幅领先:视为 RTC/时区异常而非攻击,重新起算并告警
if skew > self.reseed_if_ahead_days * 86400:
self._write_hwm(current)
return ClockCheck(
ok=True,
reason=(
f"检测到系统时间比历史水位晚 {skew / 86400:.1f} 天,"
"判为时钟异常并重新起算水位(请检查 RTC/时区/NTP)"
),
high_water_mark=current,
)
# 明显回拨:中止授权校验
if skew > self.tolerance:
return ClockCheck(
ok=False,
reason=(
f"系统时间比历史水位早 {skew / 3600:.1f} 小时"
f"(超过容差 {self.tolerance / 3600:.1f} 小时)"
),
rollback_seconds=skew,
high_water_mark=hwm,
)
# 正常或容差内的小幅回退:推进水位(不推进到 current,保持单调)
if current > hwm:
self._write_hwm(current)
return ClockCheck(ok=True, high_water_mark=max(hwm, current))
+126
View File
@@ -0,0 +1,126 @@
"""授权签发(厂商侧共用实现)。
字段拼装刻意收在这里,而不是让 CLI 与 GUI 各自拼一遍:载荷 schema 只要有两处
实现,就迟早出现"两边字段不一致 → 签出来的文件另一侧读不懂"的问题。签发端的
规范化与校验端共用 ``canonical_json``(见 :mod:`chongming_license.verify`),
所以**必须**用本模块构造载荷,不要手写 dict。
"""
from __future__ import annotations
import json
import secrets
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, Mapping
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from .models import LICENSE_VERSION, SIGNATURE_ALGORITHM
from .verify import sign_payload
def utc_today() -> date:
"""判定基准日期用 **UTC**,与校验端保持一致。
若签发端用本地日期、校验端用 UTC,在时区偏移跨日的时段会差一天
(出现"刚签发即过期"或"到期后仍可用一天")。
"""
return datetime.now(timezone.utc).date()
def make_license_id(when: date | None = None) -> str:
"""自动生成授权编号:``L<日期>-<6位随机>``(仅便于对账,不参与校验)。"""
ref = when or utc_today()
return f"L{ref.strftime('%Y%m%d')}-{secrets.token_hex(3).upper()}"
def resolve_expiry(
*,
days: int | None = None,
expires: str | date | None = None,
) -> str | None:
"""把「N 天」或「指定日期」统一成 ``YYYY-MM-DD``;两者都空 = 永久(None)。"""
if days is not None and expires:
raise ValueError("--days 与 --expires 不能同时使用")
if days is not None:
return (utc_today() + timedelta(days=int(days))).isoformat()
if not expires:
return None
text = expires.isoformat() if isinstance(expires, date) else str(expires).strip()
return date.fromisoformat(text[:10]).isoformat()
def build_payload(
*,
machine_code: str,
plugins: Iterable[str],
sources: Mapping[str, str] | None = None,
customer: str = "",
lic_id: str = "",
not_before: str | date | None = None,
expires_at: str | date | None = None,
note: str = "",
issued_at: str | None = None,
) -> Dict[str, Any]:
"""构造 license 载荷(签发前可预览)。
Args:
machine_code: 机器码。由申请码/来源指纹推导,或客户直接报来的短码
plugins: 授权插件 type 列表(去空白、去重、保持顺序)
sources: 各来源原始指纹;为空表示"仅按机器码精确匹配"(无换硬件容错)
not_before / expires_at: ISO8601 日期,含首尾当天;None = 立即生效 / 永久
"""
clean_plugins = [p.strip() for p in plugins if str(p).strip()]
if not clean_plugins:
raise ValueError("授权插件列表为空:不授权任何插件的 license 没有意义")
if not machine_code.strip():
raise ValueError("缺少机器码:无法确定授权对象")
def _date_str(v: str | date | None) -> str | None:
if v is None:
return None
return v.isoformat() if isinstance(v, date) else (str(v).strip()[:10] or None)
return {
"v": LICENSE_VERSION,
"lic_id": lic_id or make_license_id(),
"customer": customer,
"machine_code": machine_code.strip().upper(),
"sources": dict(sources or {}),
"plugins": clean_plugins,
"not_before": _date_str(not_before),
"expires_at": _date_str(expires_at),
"issued_at": issued_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"note": note,
}
def build_license(
payload_raw: Mapping[str, Any],
private_key: Ed25519PrivateKey,
*,
kid: str = "",
) -> Dict[str, Any]:
"""用私钥对载荷签名,返回完整的 license 文档(可 JSON 序列化)。
签名覆盖载荷的规范化 JSON,因此载荷内**不允许浮点数**(float 的文本表示
有多种合法写法,会破坏字节级一致性)。
"""
payload = dict(payload_raw)
return {
"payload": payload,
"alg": SIGNATURE_ALGORITHM,
"kid": kid,
"sig": sign_payload(payload, private_key),
}
def write_license(doc: Mapping[str, Any], path: str | Path) -> Path:
"""把 license 文档写成易读的 JSON 文件(保证父目录存在)。"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
json.dumps(dict(doc), ensure_ascii=False, indent=2),
encoding="utf-8",
)
return target
+496
View File
@@ -0,0 +1,496 @@
"""硬件指纹采集与机器码生成。
目标平台是无外网的嵌入式边缘盒子(如 Sophon BM1684),因此指纹必须来自
**本机可读的硬件标识**,且在重启、升级、(同机型)换机之间保持可预期。
采集来源(按可信度排序)::
board_serial 设备树 / DMI 主板序列号 —— 逐机唯一,最稳定
emmc_cid eMMC CID(芯片出厂唯一) —— 逐机唯一,不随系统重装变化
cpu_serial /proc/cpuinfo 的 Serial —— ARM 常见;部分型号全 0(会被剔除)
mac 物理网卡 MAC(取字典序最小) —— 逐机唯一;换网卡会变
product_uuid DMI product_uuid —— x86 有效,ARM 通常缺失
machine_id /etc/machine-id —— ⚠ weak:同镜像批量刷机时所有机器相同
``weak`` 标记的来源**不参与判定阈值**,只作诊断参考 —— 这正是为了避开
"/etc/machine-id 在批量刷机场景下全机相同"这个陷阱:若把 machine_id 计入
阈值,攻击者只要把镜像整体拷到另一台机器就能拿到 1 票;不计入则它毫无
帮助。参见过滤后若可用来源不足,会自动降级把 weak 来源纳入并给出告警。
**容错匹配**:license 里保存的是各来源的**原始指纹**,校验时按 N-of-M
判定(默认 3/4),而不是把所有来源哈希成一个值 —— 否则换一块网卡或换
一颗 eMMC 就直接失效。展示给客户的仍是单个 ``machine_code``。
"""
from __future__ import annotations
import base64
import hashlib
import os
import re
from dataclasses import dataclass, field
from glob import glob
from typing import Callable, Dict, List
import orjson
# 机器码位数(十六进制字符数)。128 bit 足以避免碰撞,且抄写长度可接受。
MACHINE_CODE_HEX_LEN = 24
# 默认容错阈值:命中数 >= min_match 即视为同一台机器
DEFAULT_MIN_MATCH = 3
# 来源值最小长度(过短不可能是有效身份标识,多半是占位符)
_MIN_VALUE_LEN = 4
# 常见占位/无效值(规范化后比较)
_PLACEHOLDERS = frozenset({
"unknown", "none", "null", "nil", "na", "n", "notspecified", "notavailable",
"systemserialnumber", "systemversion", "defaultstring", "tobefilledbyoem",
"oem", "oemstring", "nonesuch", "invalid", "uninitialized",
})
# 虚拟网卡名字前缀兜底(优先用 sysfs 的 device 符号链接判定物理性)
_VIRTUAL_IF_PREFIXES = (
"docker", "veth", "br-", "virbr", "tun", "tap", "bond", "dummy",
"sit", "ip6", "wg", "zt", "tailscale", "vmnet", "ham",
)
# 一律排除的接口名前缀:回环及其别名。WSL 等环境存在名为 loopback0 的接口,
# 它带有 device 符号链接、能骗过"物理网卡"判定,因此名字层面的排除必须
# **无条件生效**,不能只作为兜底。
_ALWAYS_EXCLUDED_PREFIXES = ("lo", "loopback")
_NON_ALNUM = re.compile(r"[^0-9a-z]+")
class FingerprintUnavailableError(RuntimeError):
"""本机采集不到任何可用硬件指纹(无法生成机器码)。"""
@dataclass(frozen=True)
class RawValue:
"""一个来源的原始读取结果。
value: 参与归一化与比对的值
detail: 仅用于展示的附加说明(如"来自 eth0"),不参与指纹计算
"""
value: str
detail: str = ""
@dataclass(frozen=True)
class SourceSpec:
"""一个指纹来源的声明。"""
name: str
label: str
reader: Callable[[], RawValue | None]
weak: bool = False
def normalize(value: str | None) -> str:
"""归一化来源值:小写、仅保留 [0-9a-z]。
签发端与校验端共用本函数,因此结果一致;分隔符差异
(``AA:BB:..`` / ``aa-bb-..``)不会造成误判。
无效值(空、占位符、全 0、全 f、过短)返回空字符串。
"""
if not value:
return ""
cleaned = _NON_ALNUM.sub("", value.strip().lower())
if len(cleaned) < _MIN_VALUE_LEN:
return ""
if cleaned in _PLACEHOLDERS:
return ""
# 全 0 / 全 f:未烧录或读失败的典型表现
if set(cleaned) <= {"0"} or set(cleaned) <= {"f"}:
return ""
return cleaned
def _read_text(path: str) -> str | None:
"""读文本文件;不存在/无权限/异常一律返回 None(单个来源失败不影响整体)。"""
try:
with open(path, "rb") as f:
raw = f.read()
except OSError:
return None
# 设备树属性文件常以 NUL 结尾
return raw.replace(b"\x00", b"").decode("utf-8", errors="ignore").strip()
def _read_first_line(text: str | None, *keys: str) -> str | None:
"""在 ``key: value`` 形式的文本中取首个匹配键的值(如 /proc/cpuinfo)。"""
if not text:
return None
wanted = tuple(k.lower() for k in keys)
for line in text.splitlines():
if ":" not in line:
continue
k, _, v = line.partition(":")
if k.strip().lower() in wanted and v.strip():
return v.strip()
return None
def _read_board_serial() -> RawValue | None:
"""主板序列号:优先设备树(ARM),其次 DMI(x86)。"""
for path in (
"/proc/device-tree/serial-number",
"/sys/firmware/devicetree/base/serial-number",
):
v = _read_text(path)
if v:
return RawValue(v, detail=path)
v = _read_text("/sys/class/dmi/id/board_serial")
if v:
return RawValue(v, detail="dmi/board_serial")
return None
def _read_emmc_cid() -> RawValue | None:
"""eMMC 芯片 CID / 序列号(出厂唯一,不随系统重装变化)。"""
for pattern, note in (
("/sys/block/mmcblk*/device/cid", "cid"),
("/sys/block/mmcblk*/device/serial", "serial"),
):
for path in sorted(glob(pattern)):
v = _read_text(path)
if v:
return RawValue(v, detail=f"{path} ({note})")
return None
def _read_cpu_serial() -> RawValue | None:
"""ARM 平台 /proc/cpuinfo 的 Serial 字段(部分型号输出全 0,归一化时剔除)。"""
v = _read_first_line(_read_text("/proc/cpuinfo"), "serial")
return RawValue(v, detail="/proc/cpuinfo Serial") if v else None
def _physical_interfaces() -> List[str]:
"""枚举物理网卡名。
先无条件排除回环类接口(lo / loopback0 等),再用 sysfs 的 ``device``
符号链接判定物理性(物理网卡有、虚拟网卡没有)。若一个都没识别出来
(部分 ARM 平台无该链接),退回名字前缀黑名单。
"""
base = "/sys/class/net"
try:
names = sorted(os.listdir(base))
except OSError:
return []
candidates = [
n for n in names if not n.startswith(_ALWAYS_EXCLUDED_PREFIXES)
]
physical = [
n for n in candidates
if os.path.isdir(os.path.join(base, n, "device"))
]
if physical:
return physical
return [n for n in candidates if not n.startswith(_VIRTUAL_IF_PREFIXES)]
def _read_mac() -> RawValue | None:
"""物理网卡 MAC:取字典序最小者。
取最小值而非"第一块网卡",是为了让指纹在**插入/移除其它网卡**(USB 网卡、
调试用的额外接口)以及**接口重命名**(eth0 ↔ enp1s0)时保持稳定 —— 只有
最小值那块网卡本身被更换才会变化。
"""
base = "/sys/class/net"
found: Dict[str, str] = {}
for name in _physical_interfaces():
mac = normalize(_read_text(os.path.join(base, name, "address")))
if len(mac) == 12: # MAC 定长 6 字节
found.setdefault(mac, name)
if not found:
return None
mac = min(found)
return RawValue(mac, detail=f"来自 {found[mac]}")
def _read_product_uuid() -> RawValue | None:
"""DMI product_uuid(x86 逐机唯一;ARM 通常不存在)。"""
v = _read_text("/sys/class/dmi/id/product_uuid")
return RawValue(v, detail="dmi/product_uuid") if v else None
def _read_machine_id() -> RawValue | None:
"""systemd machine-id。
⚠ weak 来源:以同一份系统镜像批量刷机时,若未在首次启动重新生成,
所有机器的该值完全相同。因此只作诊断参考,不计入判定阈值。
"""
for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
v = _read_text(path)
if v:
return RawValue(v, detail=f"{path}(可能因同镜像刷机而重复)")
return None
# 来源清单(顺序即展示顺序,也是筛选不足时的优先取舍顺序)
SOURCES: tuple[SourceSpec, ...] = (
SourceSpec("board_serial", "主板序列号(设备树/DMI)", _read_board_serial),
SourceSpec("emmc_cid", "eMMC 芯片 CID", _read_emmc_cid),
SourceSpec("cpu_serial", "CPU 序列号(/proc/cpuinfo)", _read_cpu_serial),
SourceSpec("mac", "物理网卡 MAC(字典序最小)", _read_mac),
SourceSpec("product_uuid", "DMI product_uuid", _read_product_uuid),
SourceSpec("machine_id", "systemd machine-id", _read_machine_id, weak=True),
)
_BY_NAME = {s.name: s for s in SOURCES}
@dataclass
class SourceDetail:
"""单个来源的采集明细(供 ``--sources`` / 前端诊断展示)。"""
name: str
label: str
weak: bool
present: bool
value: str = ""
raw: str = ""
detail: str = ""
def as_dict(self) -> dict:
return {
"name": self.name,
"label": self.label,
"weak": self.weak,
"present": self.present,
"value": self.value,
"raw": self.raw,
"detail": self.detail,
}
@dataclass
class FingerprintReport:
"""一次完整采集的结果。"""
sources: Dict[str, str] = field(default_factory=dict)
details: List[SourceDetail] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@property
def authoritative(self) -> Dict[str, str]:
"""可信来源(非 weak)的指纹子集,判定阈值的基数。"""
return {k: v for k, v in self.sources.items() if not _BY_NAME[k].weak}
def as_dict(self) -> dict:
return {
"sources": dict(self.sources),
"details": [d.as_dict() for d in self.details],
"warnings": list(self.warnings),
}
def collect_report() -> FingerprintReport:
"""采集全部来源并生成明细报告(含诊断告警)。"""
report = FingerprintReport()
for spec in SOURCES:
raw_value: RawValue | None = None
try:
raw_value = spec.reader()
except Exception as exc: # 单个来源异常绝不拖垮整体采集
report.warnings.append(f"来源 {spec.name} 采集异常:{exc}")
raw_value = None
normalized = normalize(raw_value.value) if raw_value else ""
detail = SourceDetail(
name=spec.name,
label=spec.label,
weak=spec.weak,
present=bool(normalized),
value=normalized,
raw=(raw_value.value if raw_value else ""),
detail=(raw_value.detail if raw_value else ""),
)
report.details.append(detail)
if normalized:
report.sources[spec.name] = normalized
elif raw_value:
# 读到了值但被判为无效(占位/全 0/过短)—— 这是最需要现场排查的情况
report.warnings.append(
f"来源 {spec.name} 读取到无效值 {raw_value.value!r},已忽略"
)
if not report.sources:
report.warnings.append(
"未采集到任何可用硬件指纹:请检查 /proc/device-tree/serial-number、"
"/sys/block/mmcblk*/device/cid、/proc/cpuinfo、/sys/class/net 是否可读"
)
elif len(report.authoritative) < DEFAULT_MIN_MATCH:
report.warnings.append(
f"可信来源仅 {len(report.authoritative)} 个(< {DEFAULT_MIN_MATCH}),"
"判定将降级纳入 machine_id 等易重复来源,建议现场确认硬件序列号是否可读"
)
if "machine_id" in report.sources:
report.warnings.append(
"已采集 machine-id:同镜像批量刷机时该值可能全机相同,故不参与判定阈值"
)
return report
def collect_fingerprints() -> Dict[str, str]:
"""采集本机硬件指纹(来源名 -> 归一化值)。"""
return collect_report().sources
def machine_code(sources: Dict[str, str]) -> str:
"""由指纹子集生成展示用机器码。
只使用**可信来源**(非 weak),并以 ``来源名=值`` 排序后拼接再哈希:
同类设备间只要可信来源有任何差异,机器码就不同。
注意机器码是**单向哈希**,无法反推出各来源指纹。而容错匹配需要逐来源的
原始值,所以申请授权时要用 :func:`encode_request_code` 导出申请码,
只报机器码则只能签出"按哈希精确匹配"的授权(失去容错,见 read_license)。
"""
usable = {k: v for k, v in sources.items() if not _BY_NAME[k].weak} if sources else {}
if not usable:
usable = dict(sources) # 降级:仅有 weak 来源时仍给出机器码并另行告警
if not usable:
raise FingerprintUnavailableError("未采集到任何硬件指纹,无法生成机器码")
payload = "\n".join(f"{k}={usable[k]}" for k in sorted(usable))
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest().upper()
code = digest[:MACHINE_CODE_HEX_LEN]
n = 4
return "-".join(code[i:i + n] for i in range(0, len(code), n))
# ---------------------------------------------------------------------------
# 申请码:把各来源指纹打包成一段可抄写的文本,供客户回传厂商签发
# ---------------------------------------------------------------------------
REQUEST_CODE_PREFIX = "CM1"
_CHECKSUM_LEN = 8
class RequestCodeError(ValueError):
"""申请码格式错误(抄写不全、被截断或校验和不符)。"""
def encode_request_code(sources: Dict[str, str] | None = None) -> str:
"""把本机指纹打包成申请码(``CM1-<base64url>-<校验和>``)。
机器码是哈希、无法反推各来源值,而容错匹配需要逐来源原始值 —— 因此首次
申请授权时客户应回传**申请码**(一段单行文本),而不是只报机器码。
申请码**不是**安全凭据:它只是公开硬件标识的传输编码,不参与验签。
客户伪造申请码无利可图 —— 签出来的授权只会绑定到他所声称的那台硬件。
"""
data = sources if sources is not None else collect_fingerprints()
if not data:
raise FingerprintUnavailableError("未采集到任何硬件指纹,无法生成申请码")
raw = orjson.dumps({"sources": dict(sorted(data.items())), "machine_code": machine_code(data)})
body = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
checksum = hashlib.sha256(body.encode("ascii")).hexdigest()[:_CHECKSUM_LEN]
return f"{REQUEST_CODE_PREFIX}-{body}-{checksum}"
def decode_request_code(text: str) -> dict:
"""解析申请码,返回 ``{"sources": {...}, "machine_code": "..."}``。
容忍空白与换行(客户从邮件/截图抄回时常被折行)。
"""
cleaned = "".join(str(text or "").split())
if not cleaned:
raise RequestCodeError("申请码为空")
parts = cleaned.split("-")
if len(parts) != 3 or parts[0] != REQUEST_CODE_PREFIX:
raise RequestCodeError(
f"申请码应以 {REQUEST_CODE_PREFIX}- 开头且由三段构成,实际:{cleaned[:24]}…"
)
_, body, checksum = parts
expected = hashlib.sha256(body.encode("ascii")).hexdigest()[:_CHECKSUM_LEN]
if checksum.lower() != expected:
raise RequestCodeError("申请码校验和不符(可能抄写不全或被截断),请重新导出")
try:
padded = body + "=" * (-len(body) % 4)
raw = base64.urlsafe_b64decode(padded)
data = orjson.loads(raw)
except Exception as exc:
raise RequestCodeError(f"申请码内容无法解析:{exc}") from exc
sources = data.get("sources")
if not isinstance(sources, dict) or not sources:
raise RequestCodeError("申请码内不含任何指纹来源")
return {
"sources": {str(k): str(v) for k, v in sources.items()},
"machine_code": str(data.get("machine_code", "")),
}
@dataclass
class MatchResult:
"""机器指纹比对结果。"""
ok: bool
matched: int
total: int
reason: str = ""
matched_sources: List[str] = field(default_factory=list)
mismatched_sources: List[str] = field(default_factory=list)
# 降级标记:可信来源不足,本次判定纳入了 weak 来源(安全性下降,需告警)
degraded: bool = False
def as_dict(self) -> dict:
return {
"ok": self.ok,
"matched": self.matched,
"total": self.total,
"reason": self.reason,
"matched_sources": list(self.matched_sources),
"mismatched_sources": list(self.mismatched_sources),
"degraded": self.degraded,
}
def match_fingerprints(
licensed: Dict[str, str],
current: Dict[str, str],
min_match: int = DEFAULT_MIN_MATCH,
) -> MatchResult:
"""按 N-of-M 容错比对 license 记录与本机采集的指纹。
只比对**双方都有**的来源(license 签发时某来源缺失、或本机现在读不到该
来源,都不计入分母),命中数达到 ``min_match`` 即判定为同一台机器。
这样换一块网卡 / 换一颗 eMMC 只会少一票,不会直接失效。
**weak 来源不参与判定**:``machine_id`` 这类同镜像刷机会重复的值若能计票,
等于给"整盘拷贝到另一台机器"白送一票。仅当双方都只采集到 weak 来源时
才降级纳入(``degraded=True``),此时安全性与"只靠 machine_id 绑定"等同,
调用方应告警。
"""
both = set(licensed) & set(current)
if not both:
return MatchResult(
ok=False, matched=0, total=0,
reason="license 记录与本机无任何共同指纹来源",
)
strong = {k for k in both if not _BY_NAME[k].weak}
degraded = not strong
common = sorted(strong or both)
matched = [k for k in common if licensed[k] == current[k]]
mismatched = [k for k in common if licensed[k] != current[k]]
# 阈值不能超过可比较的来源数,否则永远无法通过(例如 license 只记录了 2 个来源)
threshold = max(1, min(min_match, len(common)))
ok = len(matched) >= threshold
suffix = ";仅 weak 来源可用,判定已降级" if degraded else ""
reason = (
f"指纹命中 {len(matched)}/{len(common)}(阈值 {threshold}){suffix}"
if ok else
f"指纹命中不足:{len(matched)}/{len(common)},低于阈值 {threshold}"
f"(不匹配来源:{', '.join(mismatched)}){suffix}"
)
return MatchResult(
ok=ok, matched=len(matched), total=len(common), reason=reason,
matched_sources=matched, mismatched_sources=mismatched, degraded=degraded,
)
+91
View File
@@ -0,0 +1,91 @@
"""license 载荷与文件格式。
磁盘格式(``license.lic``)::
{
"payload": {
"v": 1,
"lic_id": "L2026-0001",
"customer": "某某科技",
"machine_code": "A3F9-1B77-9E04-2C8D-6F31-0A55",
"sources": {"board_serial": "...", "emmc_cid": "...", "mac": "..."},
"plugins": ["aibox_sophon_adapter", "ip_sound_column"],
"not_before": "2026-09-01",
"expires_at": "2027-09-01",
"issued_at": "2026-09-01T10:00:00Z",
"note": ""
},
"alg": "ed25519",
"kid": "vendor-2026",
"sig": "<base64 Ed25519 签名,覆盖 payload 的规范化 JSON>"
}
签名覆盖的是 **payload 原始字节的规范化形式**(``canonical_json``,键排序、
无多余空白),而不是 pydantic 重新序列化的结果 —— 否则任何字段顺序变化或
默认值填充都会改变字节导致验签失败。因此验签必须在解析模型之前完成。
"""
from __future__ import annotations
from typing import Any, Dict, List
from pydantic import BaseModel, ConfigDict, Field
# license 载荷版本。新增**可选**字段不必升版本(签名覆盖原始字节,
# 旧版本代码会忽略未知字段);字段语义变更或变为必填时才升。
LICENSE_VERSION = 1
# 签名算法。目前只支持 Ed25519(非对称,公钥可安全内置/外发)。
SIGNATURE_ALGORITHM = "ed25519"
# 机器码展示分组长度(4 字符一组,便于人工抄写/口述)
MACHINE_CODE_GROUP = 4
class LicensePayload(BaseModel):
"""license 载荷:机器绑定 + 插件白名单 + 有效期。
只允许 str/int/bool/list/dict(不含浮点),以保证 ``canonical_json``
在签发端与校验端产生完全相同的字节。
"""
model_config = ConfigDict(extra="ignore")
v: int = Field(default=LICENSE_VERSION, description="载荷版本")
lic_id: str = Field(..., description="授权编号(厂商内部唯一)")
customer: str = Field(default="", description="被授权客户名称(仅展示/审计)")
machine_code: str = Field(..., description="机器码(人工核对用,判定以 sources 为准)")
sources: Dict[str, str] = Field(
default_factory=dict,
description="签发时采集到的各硬件来源指纹,用于 N-of-M 容错匹配",
)
plugins: List[str] = Field(
default_factory=list,
description="被授权的插件 type 列表(空列表 = 不授权任何插件)",
)
not_before: str | None = Field(default=None, description="生效日期 ISO8601(含当天),None = 立即生效")
expires_at: str | None = Field(default=None, description="到期日期 ISO8601(含当天),None = 永不过期")
issued_at: str | None = Field(default=None, description="签发时间(审计用)")
note: str = Field(default="", description="备注(合同号等,仅展示)")
def plugins_normalized(self) -> set[str]:
"""归一化插件白名单:去空白、去空项。"""
return {str(p).strip() for p in self.plugins if str(p).strip()}
class LicenseFile(BaseModel):
"""``license.lic`` 的完整结构(信封 + 载荷 + 签名)。"""
model_config = ConfigDict(extra="ignore")
payload: LicensePayload
alg: str = Field(default=SIGNATURE_ALGORITHM, description="签名算法")
kid: str = Field(default="", description="签名密钥标识(轮换用,仅提示)")
sig: str = Field(default="", description="base64 签名值(覆盖 payload 的规范化 JSON)")
@staticmethod
def from_raw(raw: Dict[str, Any]) -> "LicenseFile":
"""从已解析的 JSON 字典构造(payload 交给 pydantic 校验)。"""
return LicenseFile.model_validate(raw)
+496
View File
@@ -0,0 +1,496 @@
"""已签发授权台账(厂商侧)。
解决的问题:签出去的 license 会散落在邮件、聊天记录、下载目录里。等到要续期时,
既不知道当初给哪台机器签过,也拿不到那台机器的硬件指纹 —— 只能让客户重新导出
申请码,现场还可能已经联系不上人。
台账把每次签发**连同机器指纹一起**存下来,于是:
- 按客户 / 机器码 / 插件 / 有效期检索
- 到期前能主动列出"该续期的"清单
- **续期无需再向客户索取申请码** —— 直接复用台账里的指纹重新签发(这是最大价值)
- 原样导出当初签发的文件(审计 / 补发)
- 识别"哪份授权是轮换前的旧公钥签的"(密钥轮换后需要重签)
存储是单文件 SQLite(纯标准库):无服务、可拷贝、可以直接丢进备份。
**为什么放在 chongming-license 库而不是 GUI 包**:CLI 也要能批量 `list` / `renew` /
`export`(几十上百台的续期适合脚本化),而 CLI 不能去 import 带 PySide6 的 GUI 包。
代价是网关产物里会多带这个纯标准库模块(无第三方依赖,可忽略)。
**注意**:本库文件含客户设备的硬件指纹与授权信息,属厂商内部数据,不宜外发。
"""
from __future__ import annotations
import csv
import json
import os
import sqlite3
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from threading import RLock
from typing import Any, Dict, Iterable, List, Mapping, Sequence
from .issue import utc_today
# 台账库结构版本。变更时请同时提供迁移或导出方案(本模块不做自动迁移)。
SCHEMA_VERSION = 1
# 默认"即将到期"窗口(天)
DEFAULT_EXPIRING_DAYS = 30
# 记录状态码 -> 中文说明
STATUS_LABELS = {
"forever": "永久有效",
"active": "有效",
"expiring": "即将到期",
"expired": "已过期",
"not_yet_valid": "未生效",
}
_SCHEMA = """
CREATE TABLE IF NOT EXISTS issued_licenses (
lic_id TEXT PRIMARY KEY,
machine_code TEXT NOT NULL,
customer TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
plugins_json TEXT NOT NULL,
sources_json TEXT NOT NULL,
not_before TEXT,
expires_at TEXT,
issued_at TEXT NOT NULL DEFAULT '',
kid TEXT NOT NULL DEFAULT '',
public_key_fp TEXT NOT NULL DEFAULT '',
license_json TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_lic_machine ON issued_licenses(machine_code);
CREATE INDEX IF NOT EXISTS idx_lic_customer ON issued_licenses(customer);
CREATE INDEX IF NOT EXISTS idx_lic_expires ON issued_licenses(expires_at);
"""
class RegistryError(Exception):
"""台账读写错误(含结构版本不匹配)。"""
def default_registry_path() -> Path:
"""默认台账库位置:``~/.chongming/licenses.db``。
刻意放在用户主目录而非项目内:台账含客户设备指纹,且**必须**能跨项目、
跨工具长期留存(放在仓库里容易被 .gitignore 忽略、被清理脚本删掉、
或在换机器时随项目一起丢掉)。可用 ``CHONGMING_LICENSE_DB`` 环境变量或
``--db`` 参数覆盖。
"""
env = os.environ.get("CHONGMING_LICENSE_DB", "").strip()
if env:
return Path(env).expanduser()
return Path.home() / ".chongming" / "licenses.db"
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _clip_date(value: Any) -> str | None:
"""只保留 ISO8601 的日期部分(校验端同样只读前 10 字符)。
统一在这里裁剪,避免台账里混入 "2027-09-01T00:00:00Z" 这类写法 ——
否则按日期排序/比较(SQL 里是字符串比较)会得出错误结果。
"""
if value is None:
return None
text = str(value).strip()
return text[:10] or None
@dataclass
class LicenseRecord:
"""台账中的一条签发记录。"""
lic_id: str
machine_code: str
customer: str = ""
note: str = ""
plugins: List[str] = field(default_factory=list)
sources: Dict[str, str] = field(default_factory=dict)
not_before: str | None = None
expires_at: str | None = None
issued_at: str = ""
kid: str = ""
public_key_fp: str = ""
license_json: str = ""
source: str = ""
created_at: str = ""
updated_at: str = ""
# ---- 派生信息 ----------------------------------------------------
def status(self, today: date | None = None,
expiring_days: int = DEFAULT_EXPIRING_DAYS) -> str:
"""状态:expired / not_yet_valid / expiring / active / forever。"""
ref = today or utc_today()
if self.not_before:
try:
if date.fromisoformat(self.not_before) > ref:
return "not_yet_valid"
except ValueError:
pass
if not self.expires_at:
return "forever"
try:
expires = date.fromisoformat(self.expires_at)
except ValueError:
return "active"
if expires < ref:
return "expired"
if expires <= ref + timedelta(days=expiring_days):
return "expiring"
return "active"
def status_label(self, today: date | None = None,
expiring_days: int = DEFAULT_EXPIRING_DAYS) -> str:
return STATUS_LABELS.get(self.status(today, expiring_days), "未知")
def days_left(self, today: date | None = None) -> int | None:
"""剩余天数;无到期日返回 None,已过期返回负数。"""
if not self.expires_at:
return None
try:
return (date.fromisoformat(self.expires_at) - (today or utc_today())).days
except ValueError:
return None
def license_doc(self) -> Dict[str, Any]:
"""还原完整 license 文档(即当初签发的文件内容)。"""
return json.loads(self.license_json) if self.license_json else {}
def summary(self) -> str:
"""一行摘要(日志/CSV 用)。"""
plugins = ",".join(self.plugins)
return (
f"{self.lic_id} | {self.customer or '(未填客户)'} | {self.machine_code} "
f"| {plugins} | {self.not_before or '立即'} → {self.expires_at or '永久'}"
)
def record_from_license(
license_doc: Mapping[str, Any],
*,
public_key_fp: str = "",
source: str = "",
) -> LicenseRecord:
"""由 license 文档派生台账记录(**唯一的写入口**)。
所有查询用的列都从文档派生,因此不会与 license_json 不一致;
调用方不要手工拼 LicenseRecord 去写库。
"""
payload = dict(license_doc.get("payload") or {})
lic_id = str(payload.get("lic_id") or "").strip()
if not lic_id:
raise RegistryError("license 缺少 lic_id,无法入库")
now = _now_iso()
return LicenseRecord(
lic_id=lic_id,
machine_code=str(payload.get("machine_code") or "").strip().upper(),
customer=str(payload.get("customer") or ""),
note=str(payload.get("note") or ""),
plugins=[str(p) for p in (payload.get("plugins") or [])],
sources={str(k): str(v) for k, v in (payload.get("sources") or {}).items()},
not_before=_clip_date(payload.get("not_before")),
expires_at=_clip_date(payload.get("expires_at")),
issued_at=str(payload.get("issued_at") or ""),
kid=str(license_doc.get("kid") or ""),
public_key_fp=public_key_fp,
license_json=json.dumps(dict(license_doc), ensure_ascii=False),
source=source,
created_at=now,
updated_at=now,
)
class Registry:
"""已签发授权台账(SQLite 单文件)。
与其他实例并发访问安全(WAL + busy_timeout),因此可以同时开多个 GUI 窗口。
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA busy_timeout=5000")
self._conn.executescript(_SCHEMA)
self._conn.commit()
self._check_schema_version()
# ---- 生命周期 ----------------------------------------------------
def _check_schema_version(self) -> None:
"""结构版本不匹配时明确报错,而不是让后续查询以诡异方式失败。"""
current = self._conn.execute("PRAGMA user_version").fetchone()[0]
if current == 0:
self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
self._conn.commit()
elif current != SCHEMA_VERSION:
raise RegistryError(
f"台账库结构版本为 {current},本程序支持 {SCHEMA_VERSION}:{self.path}\n"
"该库由更新/更旧版本的工具创建。请先导出台账(export)后换用匹配的版本。"
)
def close(self) -> None:
with self._lock:
self._conn.close()
def __enter__(self) -> "Registry":
return self
def __exit__(self, *exc) -> None:
"""退出 ``with`` 时**关闭连接**。
``with Registry(path) as reg:`` 是"用完即关"的语义,适合 CLI 这类
「打开 → 操作 → 结束」的短生命周期用法。
⚠️ 长生命周期场景(例如 GUI 里被多个页面共享的台账)**不要**用 ``with``
—— 它会把连接提前关掉,之后任何操作都报
``Cannot operate on a closed database``。那种场景应由持有连接的一方
(见 GUI 的 ``RegistryHolder``)显式管理生命周期。
"""
self.close()
# ---- 写 ----------------------------------------------------------
def save(self, license_doc: Mapping[str, Any], *,
public_key_fp: str = "", source: str = "",
overwrite_note: bool = False) -> LicenseRecord:
"""写入/覆盖一条记录(按 lic_id 幂等)。
**备注的处理**:载荷里的 `note` 是签发时写进**签名内容**的字段,而台账的
`note` 是厂商自己的人工批注("已电话确认续期"、"设备已返修"…)。二者同名但
不同用途,因此覆盖已有记录时**默认保留台账里的人工批注**,不被重新导入冲掉;
需要让载荷备注覆盖批注时显式传 ``overwrite_note=True``。
"""
record = record_from_license(license_doc, public_key_fp=public_key_fp, source=source)
with self._lock:
old = self.get(record.lic_id)
if old is not None:
if not overwrite_note:
record.note = old.note
record.created_at = old.created_at
record.updated_at = _now_iso()
self._conn.execute(
"""
INSERT INTO issued_licenses (
lic_id, machine_code, customer, note, plugins_json, sources_json,
not_before, expires_at, issued_at, kid, public_key_fp, license_json,
source, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(lic_id) DO UPDATE SET
machine_code=excluded.machine_code,
customer=excluded.customer,
note=excluded.note,
plugins_json=excluded.plugins_json,
sources_json=excluded.sources_json,
not_before=excluded.not_before,
expires_at=excluded.expires_at,
issued_at=excluded.issued_at,
kid=excluded.kid,
public_key_fp=excluded.public_key_fp,
license_json=excluded.license_json,
source=excluded.source,
updated_at=excluded.updated_at
""",
(
record.lic_id, record.machine_code, record.customer, record.note,
json.dumps(record.plugins, ensure_ascii=False),
json.dumps(record.sources, ensure_ascii=False),
record.not_before, record.expires_at, record.issued_at, record.kid,
record.public_key_fp, record.license_json, record.source,
record.created_at, record.updated_at,
),
)
self._conn.commit()
return record
def update_note(self, lic_id: str, note: str) -> bool:
"""只改备注(台账里唯一允许手工编辑、且不会被重新导入覆盖的字段)。"""
with self._lock:
cur = self._conn.execute(
"UPDATE issued_licenses SET note=?, updated_at=? WHERE lic_id=?",
(note, _now_iso(), lic_id),
)
self._conn.commit()
return cur.rowcount > 0
def delete(self, lic_id: str) -> bool:
with self._lock:
cur = self._conn.execute("DELETE FROM issued_licenses WHERE lic_id=?", (lic_id,))
self._conn.commit()
return cur.rowcount > 0
# ---- 读 ----------------------------------------------------------
@staticmethod
def _row_to_record(row: sqlite3.Row) -> LicenseRecord:
return LicenseRecord(
lic_id=row["lic_id"],
machine_code=row["machine_code"],
customer=row["customer"],
note=row["note"],
plugins=json.loads(row["plugins_json"] or "[]"),
sources=json.loads(row["sources_json"] or "{}"),
not_before=row["not_before"],
expires_at=row["expires_at"],
issued_at=row["issued_at"],
kid=row["kid"],
public_key_fp=row["public_key_fp"],
license_json=row["license_json"],
source=row["source"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def get(self, lic_id: str) -> LicenseRecord | None:
with self._lock:
row = self._conn.execute(
"SELECT * FROM issued_licenses WHERE lic_id=?", (lic_id,)
).fetchone()
return self._row_to_record(row) if row else None
def query(
self,
*,
customer: str | None = None,
machine_code: str | None = None,
plugin: str | None = None,
status: str | None = None,
keyword: str | None = None,
expiring_days: int = DEFAULT_EXPIRING_DAYS,
today: date | None = None,
limit: int | None = None,
) -> List[LicenseRecord]:
"""按条件检索,按到期日升序(无到期日的排最后)。
Args:
status: expired / expiring / active / forever / not_yet_valid
keyword: 模糊匹配 授权编号 / 机器码 / 客户 / 备注
状态筛选刻意放在 Python 侧(用 :meth:`LicenseRecord.status`)而不是写 SQL 条件:
状态有优先级(未生效 > 已过期 > 即将到期 > 其余),SQL 里各条件会互相重叠
(例如"未生效且到期日在 30 天内"会被 active 和 expiring 同时命中),
导致 stats() 各项之和不等于总数。复用同一个 status() 才是唯一自洽的做法,
台账规模(厂商手里几百到几千条)也完全不需要把它下推到 SQL。
"""
ref = today or utc_today()
where: List[str] = []
args: List[Any] = []
if customer:
where.append("customer LIKE ?")
args.append(f"%{customer}%")
if machine_code:
# 机器码统一大写存储,这里同样大写以便大小写不敏感
where.append("machine_code LIKE ?")
args.append(f"%{machine_code.strip().upper()}%")
if plugin:
# 插件列表是 JSON 数组文本,用带引号的子串匹配避免 "doubao" 命中 "doubao_extended"
where.append("plugins_json LIKE ?")
args.append(f'%"{plugin}"%')
if keyword:
like = f"%{keyword}%"
where.append("(lic_id LIKE ? OR machine_code LIKE ? OR customer LIKE ? OR note LIKE ?)")
args.extend([like, like, like, like])
sql = "SELECT * FROM issued_licenses"
if where:
sql += " WHERE " + " AND ".join(where)
# 到期日升序;NULL(永久)用哨兵排到最后
sql += " ORDER BY COALESCE(expires_at, '9999-12-31') ASC, lic_id ASC"
with self._lock:
rows = self._conn.execute(sql, args).fetchall()
records = [self._row_to_record(r) for r in rows]
if status:
records = [r for r in records
if r.status(today=ref, expiring_days=expiring_days) == status]
if limit:
records = records[:int(limit)]
return records
def find_by_machine_code(self, machine_code: str) -> List[LicenseRecord]:
"""某台机器的全部授权(含历史,用于续期前确认上次签了什么)。"""
return self.query(machine_code=machine_code)
def stats(self, *, expiring_days: int = DEFAULT_EXPIRING_DAYS,
today: date | None = None) -> Dict[str, int]:
"""台账概览计数。"""
return {
"total": len(self.query(today=today)),
"expired": len(self.query(status="expired", today=today)),
"expiring": len(self.query(status="expiring", expiring_days=expiring_days,
today=today)),
"active": len(self.query(status="active", expiring_days=expiring_days,
today=today)),
"forever": len(self.query(status="forever", today=today)),
"not_yet_valid": len(self.query(status="not_yet_valid", today=today)),
}
# ---- 导出 --------------------------------------------------------
def export_licenses(
self,
out_dir: str | Path,
records: Sequence[LicenseRecord] | None = None,
*,
subdir_by_customer: bool = False,
) -> List[Path]:
"""把台账里的授权原样导出为 .lic 文件(归档 / 补发)。
文件名形如 ``<lic_id>_<机器码>.lic``;可选按客户名分子目录,
便于直接打包发给对应客户。
"""
base = Path(out_dir)
base.mkdir(parents=True, exist_ok=True)
written: List[Path] = []
for rec in (records if records is not None else self.query()):
target_dir = base
if subdir_by_customer:
# 客户名可能含路径分隔符等非法字符,做一次保守替换
safe = "".join(c if c.isalnum() or c in " ._-" else "_"
for c in (rec.customer or "未填客户")).strip() or "未填客户"
target_dir = base / safe
target_dir.mkdir(parents=True, exist_ok=True)
path = target_dir / f"{rec.lic_id}_{rec.machine_code or 'nomachine'}.lic"
path.write_text(rec.license_json, encoding="utf-8")
written.append(path)
return written
def export_csv(self, path: str | Path,
records: Iterable[LicenseRecord] | None = None,
*, today: date | None = None) -> Path:
"""导出台账清单为 CSV(Excel 可直接打开,用于对账/商务统计)。
以 UTF-8 BOM 写入:否则 Excel 在中文 Windows 下会按 GBK 解码而乱码。
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
rows = list(records if records is not None else self.query())
with target.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"授权编号", "客户", "机器码", "授权插件", "状态", "剩余天数",
"生效日期", "到期日期", "签发时间", "密钥标识", "公钥指纹", "备注",
])
for rec in rows:
writer.writerow([
rec.lic_id, rec.customer, rec.machine_code,
",".join(rec.plugins), rec.status_label(today=today),
"" if rec.days_left(today=today) is None else rec.days_left(today=today),
rec.not_before or "", rec.expires_at or "", rec.issued_at,
rec.kid, rec.public_key_fp, rec.note,
])
return target
+493
View File
@@ -0,0 +1,493 @@
"""license 验签与判定。
**关键设计**:签名覆盖 payload 的规范化 JSON(``canonical_json``),而签发
(``sign_payload``)与校验(``verify_signature``)共用同一个函数 —— 否则两端
序列化差异会导致"自己签的自己验不过"。因此本模块同时包含签名与验签逻辑,
是刻意为之:规范化只有一处实现。
非对称密钥选型:Ed25519(``cryptography``),公钥可安全内置进网关配置或
随包发布。注意根仓 ``resources/*.key`` 那套 ``.mbank`` 验签是 **HMAC 对称**
且密钥与银行同目录发布,只能算防篡改/混淆,不能作为厂商信任根 —— 授权必须
用非对称。
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import os
import stat
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
import orjson
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from .machine import (
DEFAULT_MIN_MATCH,
FingerprintUnavailableError,
MatchResult,
collect_fingerprints,
machine_code,
match_fingerprints,
)
from .models import LICENSE_VERSION, SIGNATURE_ALGORITHM, LicenseFile, LicensePayload
# ---------------------------------------------------------------------------
# 判定原因码(前端/日志按码分支,文案可改)
# ---------------------------------------------------------------------------
REASON_OK = "ok"
REASON_LICENSE_MISSING = "license_missing"
REASON_LICENSE_MALFORMED = "license_malformed"
REASON_SIGNATURE_INVALID = "signature_invalid"
REASON_UNSUPPORTED_ALG = "unsupported_alg"
REASON_NO_PUBLIC_KEY = "no_public_key"
REASON_MACHINE_MISMATCH = "machine_mismatch"
REASON_NOT_YET_VALID = "not_yet_valid"
REASON_EXPIRED = "expired"
REASON_CLOCK_ROLLBACK = "clock_rollback"
REASON_PLUGIN_NOT_LICENSED = "plugin_not_licensed"
REASON_FINGERPRINT_UNAVAILABLE = "fingerprint_unavailable"
REASON_DISABLED = "disabled"
# 原因码 -> 中文说明(日志与前端共用)
REASON_TEXT = {
REASON_OK: "授权有效",
REASON_LICENSE_MISSING: "未找到授权文件",
REASON_LICENSE_MALFORMED: "授权文件格式错误或内容损坏",
REASON_SIGNATURE_INVALID: "授权文件签名校验失败(可能被篡改或非本厂商签发)",
REASON_UNSUPPORTED_ALG: "授权文件使用了不支持的签名算法",
REASON_NO_PUBLIC_KEY: "未配置厂商公钥,无法校验授权",
REASON_MACHINE_MISMATCH: "机器码不匹配(授权文件不是为本机签发的)",
REASON_NOT_YET_VALID: "授权尚未生效",
REASON_EXPIRED: "授权已过期",
REASON_CLOCK_ROLLBACK: "检测到系统时间被回拨,授权校验中止",
REASON_PLUGIN_NOT_LICENSED: "该插件不在授权白名单内",
REASON_FINGERPRINT_UNAVAILABLE: "本机无法采集硬件指纹",
REASON_DISABLED: "授权校验未启用(开发/测试模式)",
}
class LicenseError(Exception):
"""license 校验相关的基类异常。"""
class LicenseMalformedError(LicenseError):
"""文件不是合法 JSON / 缺少必要字段。"""
class SignatureInvalidError(LicenseError):
"""签名校验失败。"""
class PublicKeyError(LicenseError):
"""公钥缺失或格式无法解析。"""
# ---------------------------------------------------------------------------
# 规范化与签名
# ---------------------------------------------------------------------------
def canonical_json(payload: Any) -> bytes:
"""payload 的规范化字节:键排序、无多余空白。
签发与校验**必须**共用本函数(见模块文档)。载荷内不允许浮点数
(float 的文本表示存在多种合法写法,会破坏字节级一致性)。
"""
return orjson.dumps(payload, option=orjson.OPT_SORT_KEYS)
def load_public_key(key: str) -> Ed25519PublicKey:
"""解析公钥:支持 base64 裸 32 字节,或 PEM。
优先按 base64 裸字节解析,因为配置里放一行 base64 最便于运维;
解析不出再当作 PEM(带 ``-----BEGIN`` 头)。
"""
if not key or not key.strip():
raise PublicKeyError("未提供公钥")
text = key.strip()
if "-----BEGIN" in text:
try:
return serialization.load_pem_public_key(text.encode("utf-8")) # type: ignore[return-value]
except Exception as exc:
raise PublicKeyError(f"PEM 公钥解析失败:{exc}") from exc
try:
raw = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise PublicKeyError(f"公钥既不是合法 PEM 也不是合法 base64:{exc}") from exc
if len(raw) != 32:
raise PublicKeyError(
f"Ed25519 公钥应为 32 字节,实际 {len(raw)} 字节(base64 内容有误?)"
)
return Ed25519PublicKey.from_public_bytes(raw)
def public_key_fingerprint(key: str) -> str:
"""公钥指纹(短十六进制),用于日志确认"当前内置的是哪把公钥"。"""
try:
raw = load_public_key(key).public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
except PublicKeyError:
return ""
return hashlib.sha256(raw).hexdigest()[:16]
def verify_signature(
payload_raw: Dict[str, Any],
sig_b64: str,
public_key: str,
alg: str = SIGNATURE_ALGORITHM,
) -> None:
"""校验 payload 的签名;失败抛 :class:`SignatureInvalidError`。
``payload_raw`` 必须是**从文件中直接解析出的原始字典**,不能是 pydantic
重新序列化的结果(默认值填充/字段顺序变化都会改变字节)。
"""
if alg != SIGNATURE_ALGORITHM:
raise SignatureInvalidError(f"不支持的签名算法:{alg}(仅支持 {SIGNATURE_ALGORITHM})")
if not sig_b64:
raise SignatureInvalidError("缺少签名值")
try:
sig = base64.b64decode(sig_b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise SignatureInvalidError(f"签名值不是合法 base64:{exc}") from exc
pub = load_public_key(public_key)
try:
pub.verify(sig, canonical_json(payload_raw))
except InvalidSignature as exc:
raise SignatureInvalidError("签名校验失败") from exc
def sign_payload(
payload_raw: Dict[str, Any],
private_key: Ed25519PrivateKey,
) -> str:
"""对 payload 签名,返回 base64 签名值(厂商侧签发工具用)。"""
return base64.b64encode(private_key.sign(canonical_json(payload_raw))).decode("ascii")
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
"""从 PEM 文件读取 Ed25519 私钥(厂商侧,私钥绝不进部署产物)。"""
data = Path(path).read_bytes()
key = serialization.load_pem_private_key(data, password=None)
if not isinstance(key, Ed25519PrivateKey):
raise LicenseError(f"{path} 不是 Ed25519 私钥")
return key
def public_key_b64(private_key: Ed25519PrivateKey) -> str:
"""私钥对应的公钥,base64 裸 32 字节 —— 可直接填进 ``[license] public_key``。"""
raw = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return base64.b64encode(raw).decode("ascii")
def public_key_pem(private_key: Ed25519PrivateKey) -> str:
"""私钥对应的公钥 PEM(便于贴进工单/邮件,或改用 PEM 形式配置)。"""
return private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
def generate_keypair() -> tuple[Ed25519PrivateKey, str]:
"""生成 Ed25519 密钥对,返回 ``(私钥, 公钥 base64)``。"""
private_key = Ed25519PrivateKey.generate()
return private_key, public_key_b64(private_key)
def write_private_key(
private_key: Ed25519PrivateKey,
path: str | Path,
*,
overwrite: bool = False,
) -> Path:
"""把私钥写为**不加密**的 PKCS#8 PEM,文件权限置 ``0600``。
不加密是有意为之:这个私钥进不了制品、只存在厂商的签发机器上,加口令会让
自动化签发(批量签几百台设备)变得不可用,而防护收益有限(口令多半就写在
同一个脚本里)。真正的防线是**文件权限 + 不落地到产物 + 离线保管**。
Args:
overwrite: False 时目标已存在则抛 :class:`LicenseError`
"""
target = Path(path)
if target.exists() and not overwrite:
raise LicenseError(f"{target} 已存在(确要覆盖请显式允许)")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(
private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
# 私钥文件权限收到 0600:同机器上的其它用户不应能读到
os.chmod(target, stat.S_IRUSR | stat.S_IWUSR)
return target
# ---------------------------------------------------------------------------
# 日期
# ---------------------------------------------------------------------------
def _parse_date(value: str | None) -> date | None:
"""解析 ISO8601 日期(容忍 "2027-09-01" / "2027-09-01T00:00:00Z" 两种写法)。"""
if not value:
return None
text = str(value).strip()
if not text:
return None
try:
return date.fromisoformat(text[:10])
except ValueError as exc:
raise LicenseMalformedError(f"日期格式无法解析:{value!r}") from exc
def _today() -> date:
return datetime.now(timezone.utc).date()
# ---------------------------------------------------------------------------
# 判定结果
# ---------------------------------------------------------------------------
@dataclass
class LicenseStatus:
"""整份 license 的校验结论(与"哪个插件被授权"无关)。"""
ok: bool
code: str
message: str
payload: LicensePayload | None = None
license_path: str = ""
machine_code: str = ""
match: MatchResult | None = None
public_key_fingerprint: str = ""
sources: Dict[str, str] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)
@property
def licensed_plugins(self) -> set[str]:
return self.payload.plugins_normalized() if self.payload else set()
def as_dict(self) -> dict:
"""给 /license/info 的可序列化结构。"""
return {
"ok": self.ok,
"code": self.code,
"message": self.message,
"license_path": self.license_path,
"machine_code": self.machine_code,
"public_key_fingerprint": self.public_key_fingerprint,
"sources": dict(self.sources),
"warnings": list(self.warnings),
"match": self.match.as_dict() if self.match else None,
"payload": self.payload.model_dump() if self.payload else None,
"licensed_plugins": sorted(self.licensed_plugins),
}
@dataclass
class PluginDecision:
"""单个插件是否被授权运行的结论。"""
allowed: bool
code: str
message: str
plugin_type: str = ""
def as_dict(self) -> dict:
return {
"allowed": self.allowed,
"code": self.code,
"message": self.message,
"plugin_type": self.plugin_type,
}
# ---------------------------------------------------------------------------
# 整份 license 的读取与校验
# ---------------------------------------------------------------------------
def read_license(
license_path: str | Path,
public_key: str,
*,
min_match: int = DEFAULT_MIN_MATCH,
sources: Dict[str, str] | None = None,
today: date | None = None,
) -> LicenseStatus:
"""读取并校验 license 文件:签名 → 机器指纹 → 有效期。
调用方负责时钟回拨检查(见 :mod:`chongming_license.clock`),本函数只做
纯函数式的判定,便于测试。
Args:
license_path: license.lic 路径
public_key: 厂商公钥(base64 裸字节或 PEM)
min_match: 指纹命中阈值
sources: 本机指纹;None = 现场采集(测试可注入)
today: 判定基准日期;None = 当前 UTC 日期
Returns:
LicenseStatus:``ok`` 表示这份 license 对本机有效(与插件白名单无关)
"""
path = Path(license_path)
key_fp = public_key_fingerprint(public_key)
# 1. 本机指纹
if sources is None:
try:
sources = collect_fingerprints()
except Exception as exc:
return LicenseStatus(
ok=False, code=REASON_FINGERPRINT_UNAVAILABLE,
message=f"{REASON_TEXT[REASON_FINGERPRINT_UNAVAILABLE]}:{exc}",
license_path=str(path), public_key_fingerprint=key_fp,
)
try:
current_code = machine_code(sources)
except FingerprintUnavailableError as exc:
return LicenseStatus(
ok=False, code=REASON_FINGERPRINT_UNAVAILABLE,
message=f"{REASON_TEXT[REASON_FINGERPRINT_UNAVAILABLE]}:{exc}",
license_path=str(path), public_key_fingerprint=key_fp,
)
def _fail(code: str, extra: str = "", **kw) -> LicenseStatus:
msg = REASON_TEXT.get(code, code)
return LicenseStatus(
ok=False, code=code,
message=f"{msg}{(':' + extra) if extra else ''}",
license_path=str(path), machine_code=current_code,
public_key_fingerprint=key_fp, sources=dict(sources or {}), **kw,
)
# 2. 公钥(未配置时后续无法验签,提前给出明确原因)
if not key_fp:
return _fail(REASON_NO_PUBLIC_KEY)
# 3. 读文件
if not path.is_file():
return _fail(REASON_LICENSE_MISSING, str(path))
try:
raw = json.loads(path.read_bytes())
except (OSError, ValueError) as exc:
return _fail(REASON_LICENSE_MALFORMED, str(exc))
if not isinstance(raw, dict) or "payload" not in raw:
return _fail(REASON_LICENSE_MALFORMED, "缺少 payload 字段")
payload_raw = raw.get("payload")
if not isinstance(payload_raw, dict):
return _fail(REASON_LICENSE_MALFORMED, "payload 不是对象")
# 4. 验签(必须先于模型解析:签名覆盖的是原始字节)
alg = str(raw.get("alg", SIGNATURE_ALGORITHM))
if alg != SIGNATURE_ALGORITHM:
return _fail(REASON_UNSUPPORTED_ALG, alg)
try:
verify_signature(payload_raw, str(raw.get("sig", "")), public_key, alg)
except SignatureInvalidError as exc:
return _fail(REASON_SIGNATURE_INVALID, str(exc))
# 5. 解析载荷(签名已验证,此后的内容可信)
try:
lic = LicenseFile.from_raw(raw)
payload = lic.payload
except Exception as exc:
return _fail(REASON_LICENSE_MALFORMED, str(exc))
if payload.v > LICENSE_VERSION:
return _fail(
REASON_LICENSE_MALFORMED,
f"载荷版本 {payload.v} 高于本程序支持的 {LICENSE_VERSION},请升级网关",
)
# 6. 机器指纹比对
# license 记录了逐来源指纹 → N-of-M 容错匹配;
# 只记录了机器码(客户仅报了一个短码)→ 退化为哈希精确匹配。
if payload.sources:
match = match_fingerprints(payload.sources, sources, min_match=min_match)
else:
same = bool(payload.machine_code) and payload.machine_code == current_code
match = MatchResult(
ok=same, matched=1 if same else 0, total=1,
matched_sources=["machine_code"] if same else [],
mismatched_sources=[] if same else ["machine_code"],
reason=(
"授权按机器码精确匹配(未记录逐来源指纹,无容错)"
if same else
f"机器码不符(授权 {payload.machine_code},本机 {current_code})"
),
)
if not match.ok:
return LicenseStatus(
ok=False, code=REASON_MACHINE_MISMATCH,
message=f"{REASON_TEXT[REASON_MACHINE_MISMATCH]}:{match.reason}",
payload=payload, license_path=str(path), machine_code=current_code,
match=match, public_key_fingerprint=key_fp, sources=dict(sources),
)
# 7. 有效期(含首尾当天)
ref = today or _today()
try:
not_before = _parse_date(payload.not_before)
expires_at = _parse_date(payload.expires_at)
except LicenseMalformedError as exc:
return _fail(REASON_LICENSE_MALFORMED, str(exc), payload=payload, match=match)
if not_before and ref < not_before:
return LicenseStatus(
ok=False, code=REASON_NOT_YET_VALID,
message=f"{REASON_TEXT[REASON_NOT_YET_VALID]}(生效日期 {not_before})",
payload=payload, license_path=str(path), machine_code=current_code,
match=match, public_key_fingerprint=key_fp, sources=dict(sources),
)
if expires_at and ref > expires_at:
return LicenseStatus(
ok=False, code=REASON_EXPIRED,
message=f"{REASON_TEXT[REASON_EXPIRED]}(到期日期 {expires_at})",
payload=payload, license_path=str(path), machine_code=current_code,
match=match, public_key_fingerprint=key_fp, sources=dict(sources),
)
return LicenseStatus(
ok=True, code=REASON_OK,
message=REASON_TEXT[REASON_OK],
payload=payload, license_path=str(path), machine_code=current_code,
match=match, public_key_fingerprint=key_fp, sources=dict(sources),
)
def decision_for_plugin(status: LicenseStatus, plugin_type: str) -> PluginDecision:
"""由整份 license 的结论推导单个插件的授权结论。"""
def deny(code: str, message: str = "") -> PluginDecision:
# status.message 已含原因文案(如"未配置厂商公钥,无法校验授权"),
# 优先用它,避免把原因码文案再拼一遍造成"…:…"的重复
detail = message or REASON_TEXT.get(code, code)
return PluginDecision(
allowed=False, code=code, plugin_type=plugin_type,
message=f"插件 {plugin_type} 未获授权:{detail}",
)
if not status.ok:
# 整份 license 无效(缺失/过期/机器码不符…)→ 一律拒绝
return deny(status.code, status.message)
if plugin_type not in status.licensed_plugins:
return deny(REASON_PLUGIN_NOT_LICENSED)
return PluginDecision(
allowed=True, code=REASON_OK, plugin_type=plugin_type,
message=f"插件 {plugin_type} 已授权"
+ (f"(到期 {status.payload.expires_at})" if status.payload and status.payload.expires_at else ""),
)