first commit
This commit is contained in:
+72
@@ -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
|
||||||
@@ -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 需要厂商私钥,而公钥可明文放置
|
||||||
|
(换公钥只会验签失败,无法据以伪造)。
|
||||||
@@ -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/"
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 ""),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user