2025-10-29 22:42:25 +08:00
|
|
|
|
"""
|
|
|
|
|
|
软件授权管理模块
|
|
|
|
|
|
提供硬件指纹生成、授权文件验证、数字签名校验等功能
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import platform
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import os
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
from typing import Dict, Any, Optional, Tuple
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
|
|
|
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
|
|
|
|
from cryptography.exceptions import InvalidSignature
|
|
|
|
|
|
import base64
|
2026-02-04 16:05:48 +08:00
|
|
|
|
import shutil
|
2025-10-29 22:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2025-10-30 14:02:39 +08:00
|
|
|
|
# 授权签名与验证参数(跨语言一致性):
|
|
|
|
|
|
# - 算法:RSA-PSS + SHA-256
|
|
|
|
|
|
# - MGF:MGF1(SHA-256)
|
|
|
|
|
|
# - 盐长度:固定 32 字节
|
|
|
|
|
|
# - 数据序列化(签名输入):
|
|
|
|
|
|
# * 去除 signature 字段
|
|
|
|
|
|
# * 按键名排序(包括嵌套对象)
|
|
|
|
|
|
# * 紧凑 JSON:等价于 json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
PSS_SALT_LENGTH_BYTES = 32
|
|
|
|
|
|
|
2025-10-29 22:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class LicenseStatus:
|
|
|
|
|
|
"""授权状态数据类"""
|
|
|
|
|
|
valid: bool = False
|
|
|
|
|
|
message: str = ""
|
|
|
|
|
|
license_type: str = ""
|
|
|
|
|
|
expires_at: Optional[datetime] = None
|
|
|
|
|
|
features: Dict[str, Any] = None
|
|
|
|
|
|
license_id: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
|
if self.features is None:
|
|
|
|
|
|
self.features = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LicenseManager:
|
|
|
|
|
|
"""授权管理器"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, config_manager=None):
|
|
|
|
|
|
self.config_manager = config_manager
|
|
|
|
|
|
self._machine_id = None
|
2026-02-04 16:05:48 +08:00
|
|
|
|
self._machine_id_candidates = None
|
2025-10-29 22:42:25 +08:00
|
|
|
|
self._license_cache = None
|
|
|
|
|
|
self._cache_timestamp = None
|
|
|
|
|
|
|
2026-02-04 16:05:48 +08:00
|
|
|
|
def _get_backend_base_dir(self) -> str:
|
|
|
|
|
|
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
|
|
|
|
|
|
|
|
def _get_persistent_license_dir(self) -> str:
|
|
|
|
|
|
system = platform.system()
|
|
|
|
|
|
if system == "Windows":
|
|
|
|
|
|
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE") or ""
|
|
|
|
|
|
if not base:
|
|
|
|
|
|
base = os.path.expanduser("~\\AppData\\Local")
|
|
|
|
|
|
else:
|
|
|
|
|
|
base = os.path.expanduser("~/.config")
|
|
|
|
|
|
return os.path.join(base, "BodyCheck", "license")
|
|
|
|
|
|
|
|
|
|
|
|
def _get_configured_license_paths(self) -> Tuple[str, str, int]:
|
|
|
|
|
|
license_path = "data/license.json"
|
|
|
|
|
|
public_key_path = "backend/license_pub.pem"
|
|
|
|
|
|
grace_days = 3
|
|
|
|
|
|
if self.config_manager:
|
|
|
|
|
|
license_path = self.config_manager.get_config_value("LICENSE", "path", license_path)
|
|
|
|
|
|
public_key_path = self.config_manager.get_config_value("LICENSE", "public_key", public_key_path)
|
|
|
|
|
|
grace_days = int(self.config_manager.get_config_value("LICENSE", "grace_days", str(grace_days)))
|
|
|
|
|
|
|
|
|
|
|
|
if not os.path.isabs(license_path):
|
|
|
|
|
|
license_path = os.path.join(self._get_backend_base_dir(), license_path)
|
|
|
|
|
|
if not os.path.isabs(public_key_path):
|
|
|
|
|
|
public_key_path = os.path.join(self._get_backend_base_dir(), public_key_path)
|
|
|
|
|
|
return license_path, public_key_path, grace_days
|
|
|
|
|
|
|
|
|
|
|
|
def _get_persistent_license_paths(self) -> Tuple[str, str]:
|
|
|
|
|
|
pdir = self._get_persistent_license_dir()
|
|
|
|
|
|
return os.path.join(pdir, "license.json"), os.path.join(pdir, "license_public_key.pem")
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_license_paths(self) -> Tuple[str, str, int]:
|
|
|
|
|
|
cfg_license_path, cfg_public_key_path, grace_days = self._get_configured_license_paths()
|
|
|
|
|
|
p_license_path, p_public_key_path = self._get_persistent_license_paths()
|
|
|
|
|
|
|
|
|
|
|
|
effective_license_path = cfg_license_path
|
|
|
|
|
|
if not os.path.exists(effective_license_path) and os.path.exists(p_license_path):
|
|
|
|
|
|
effective_license_path = p_license_path
|
|
|
|
|
|
|
|
|
|
|
|
effective_public_key_path = cfg_public_key_path
|
|
|
|
|
|
if not os.path.exists(effective_public_key_path) and os.path.exists(p_public_key_path):
|
|
|
|
|
|
effective_public_key_path = p_public_key_path
|
|
|
|
|
|
|
|
|
|
|
|
return effective_license_path, effective_public_key_path, grace_days
|
|
|
|
|
|
|
|
|
|
|
|
def _mirror_license_assets(self, license_path: Optional[str] = None, public_key_path: Optional[str] = None) -> None:
|
|
|
|
|
|
p_license_path, p_public_key_path = self._get_persistent_license_paths()
|
|
|
|
|
|
pdir = os.path.dirname(p_license_path)
|
2025-10-29 22:42:25 +08:00
|
|
|
|
try:
|
2026-02-04 16:05:48 +08:00
|
|
|
|
os.makedirs(pdir, exist_ok=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if license_path and os.path.exists(license_path):
|
2025-10-29 22:42:25 +08:00
|
|
|
|
try:
|
2026-02-04 16:05:48 +08:00
|
|
|
|
shutil.copyfile(license_path, p_license_path)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if public_key_path and os.path.exists(public_key_path):
|
2025-10-29 22:42:25 +08:00
|
|
|
|
try:
|
2026-02-04 16:05:48 +08:00
|
|
|
|
shutil.copyfile(public_key_path, p_public_key_path)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def install_license_file(self, source_license_path: str) -> Tuple[bool, str]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not os.path.exists(source_license_path):
|
|
|
|
|
|
return False, "源授权文件不存在"
|
|
|
|
|
|
|
|
|
|
|
|
cfg_license_path, cfg_public_key_path, _ = self._get_configured_license_paths()
|
|
|
|
|
|
os.makedirs(os.path.dirname(cfg_license_path), exist_ok=True)
|
|
|
|
|
|
shutil.copyfile(source_license_path, cfg_license_path)
|
|
|
|
|
|
self._mirror_license_assets(license_path=cfg_license_path, public_key_path=cfg_public_key_path)
|
|
|
|
|
|
return True, cfg_license_path
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return False, str(e)
|
|
|
|
|
|
|
|
|
|
|
|
def mirror_license_dir(self, source_dir: str) -> None:
|
|
|
|
|
|
if not source_dir or not os.path.isdir(source_dir):
|
|
|
|
|
|
return
|
|
|
|
|
|
license_candidate = os.path.join(source_dir, "license.json")
|
|
|
|
|
|
pub_candidates = [
|
|
|
|
|
|
os.path.join(source_dir, "license_public_key.pem"),
|
|
|
|
|
|
os.path.join(source_dir, "license_pub.pem"),
|
|
|
|
|
|
os.path.join(source_dir, "public_key.pem"),
|
|
|
|
|
|
]
|
|
|
|
|
|
pub_path = ""
|
|
|
|
|
|
for c in pub_candidates:
|
|
|
|
|
|
if os.path.exists(c):
|
|
|
|
|
|
pub_path = c
|
|
|
|
|
|
break
|
|
|
|
|
|
self._mirror_license_assets(license_path=license_candidate if os.path.exists(license_candidate) else None, public_key_path=pub_path or None)
|
|
|
|
|
|
|
|
|
|
|
|
def _run_powershell(self, command: str, timeout: int = 10) -> str:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"powershell",
|
|
|
|
|
|
"-NoProfile",
|
|
|
|
|
|
"-NonInteractive",
|
|
|
|
|
|
"-ExecutionPolicy",
|
|
|
|
|
|
"Bypass",
|
|
|
|
|
|
"-Command",
|
|
|
|
|
|
command,
|
|
|
|
|
|
],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
return (result.stdout or "").strip()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def _get_windows_cpu_id(self) -> str:
|
|
|
|
|
|
out = self._run_powershell(
|
|
|
|
|
|
"(Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 -ExpandProperty ProcessorId)"
|
|
|
|
|
|
)
|
|
|
|
|
|
return out.strip()
|
|
|
|
|
|
|
|
|
|
|
|
def _get_windows_baseboard_serial(self) -> str:
|
|
|
|
|
|
out = self._run_powershell(
|
|
|
|
|
|
"(Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -First 1 -ExpandProperty SerialNumber)"
|
|
|
|
|
|
)
|
|
|
|
|
|
serial = out.strip()
|
|
|
|
|
|
if serial and serial != "To be filled by O.E.M.":
|
|
|
|
|
|
return serial
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def _get_windows_disk_serials(self) -> list:
|
|
|
|
|
|
raw = self._run_powershell(
|
|
|
|
|
|
"$d=Get-CimInstance -ClassName Win32_DiskDrive | Select-Object SerialNumber,InterfaceType,PNPDeviceID,MediaType; $d | ConvertTo-Json -Compress"
|
|
|
|
|
|
)
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return []
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(raw)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return []
|
|
|
|
|
|
disks = data if isinstance(data, list) else ([data] if isinstance(data, dict) else [])
|
|
|
|
|
|
serials = []
|
|
|
|
|
|
for d in disks:
|
|
|
|
|
|
serial = str((d.get("SerialNumber") or "")).strip()
|
|
|
|
|
|
iface = str((d.get("InterfaceType") or "")).strip().upper()
|
|
|
|
|
|
pnp = str((d.get("PNPDeviceID") or "")).strip().upper()
|
|
|
|
|
|
media = str((d.get("MediaType") or "")).strip().upper()
|
|
|
|
|
|
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
|
|
|
|
|
|
serials.append(serial)
|
|
|
|
|
|
serials = sorted(set(serials))
|
|
|
|
|
|
return serials
|
|
|
|
|
|
|
|
|
|
|
|
def _get_windows_identifiers(self) -> Dict[str, Any]:
|
|
|
|
|
|
cpu_id = self._get_windows_cpu_id()
|
|
|
|
|
|
board_serial = self._get_windows_baseboard_serial()
|
|
|
|
|
|
disk_serials = self._get_windows_disk_serials()
|
|
|
|
|
|
|
|
|
|
|
|
if not cpu_id:
|
2025-10-29 22:42:25 +08:00
|
|
|
|
try:
|
2026-02-04 16:05:48 +08:00
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
["wmic", "cpu", "get", "ProcessorId", "/value"],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=10,
|
|
|
|
|
|
)
|
|
|
|
|
|
for line in (result.stdout or "").split("\n"):
|
|
|
|
|
|
if "ProcessorId=" in line:
|
|
|
|
|
|
cpu_id = line.split("=", 1)[1].strip()
|
|
|
|
|
|
if cpu_id:
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
if not board_serial:
|
2025-10-29 22:42:25 +08:00
|
|
|
|
try:
|
2026-02-04 16:05:48 +08:00
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
["wmic", "baseboard", "get", "SerialNumber", "/value"],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=10,
|
|
|
|
|
|
)
|
|
|
|
|
|
for line in (result.stdout or "").split("\n"):
|
|
|
|
|
|
if "SerialNumber=" in line:
|
|
|
|
|
|
board_serial = line.split("=", 1)[1].strip()
|
|
|
|
|
|
if board_serial and board_serial != "To be filled by O.E.M.":
|
|
|
|
|
|
break
|
|
|
|
|
|
board_serial = ""
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
if not disk_serials:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"wmic",
|
|
|
|
|
|
"path",
|
|
|
|
|
|
"Win32_DiskDrive",
|
|
|
|
|
|
"get",
|
|
|
|
|
|
"SerialNumber,InterfaceType,PNPDeviceID,MediaType",
|
|
|
|
|
|
"/value",
|
|
|
|
|
|
],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=10,
|
|
|
|
|
|
)
|
|
|
|
|
|
block = {}
|
|
|
|
|
|
serials = []
|
|
|
|
|
|
for line in (result.stdout or "").split("\n"):
|
|
|
|
|
|
line = line.strip()
|
|
|
|
|
|
if not line:
|
|
|
|
|
|
serial = (block.get("SerialNumber") or "").strip()
|
|
|
|
|
|
iface = (block.get("InterfaceType") or "").strip().upper()
|
|
|
|
|
|
pnp = (block.get("PNPDeviceID") or "").strip().upper()
|
|
|
|
|
|
media = (block.get("MediaType") or "").strip().upper()
|
|
|
|
|
|
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
|
|
|
|
|
|
serials.append(serial)
|
|
|
|
|
|
block = {}
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "=" in line:
|
|
|
|
|
|
k, v = line.split("=", 1)
|
|
|
|
|
|
block[k] = v
|
|
|
|
|
|
if block:
|
|
|
|
|
|
serial = (block.get("SerialNumber") or "").strip()
|
|
|
|
|
|
iface = (block.get("InterfaceType") or "").strip().upper()
|
|
|
|
|
|
pnp = (block.get("PNPDeviceID") or "").strip().upper()
|
|
|
|
|
|
media = (block.get("MediaType") or "").strip().upper()
|
|
|
|
|
|
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
|
|
|
|
|
|
serials.append(serial)
|
|
|
|
|
|
disk_serials = sorted(set(serials))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
mac = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
|
|
mac = ":".join(
|
|
|
|
|
|
["{:02x}".format((uuid.getnode() >> elements) & 0xFF) for elements in range(0, 2 * 6, 2)][::-1]
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
mac = ""
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"cpu_id": cpu_id.strip() if cpu_id else "",
|
|
|
|
|
|
"board_serial": board_serial.strip() if board_serial else "",
|
|
|
|
|
|
"disk_serials": disk_serials or [],
|
|
|
|
|
|
"mac": mac,
|
|
|
|
|
|
"node": platform.node(),
|
|
|
|
|
|
"processor": platform.processor(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _hash_core_info(self, core_info: list) -> str:
|
|
|
|
|
|
combined_info = "|".join(sorted(core_info))
|
|
|
|
|
|
return hashlib.sha256(combined_info.encode("utf-8")).hexdigest()[:16].upper()
|
|
|
|
|
|
|
|
|
|
|
|
def _build_machine_id_candidates(self) -> list:
|
|
|
|
|
|
system = platform.system()
|
|
|
|
|
|
info: Dict[str, Any] = {}
|
|
|
|
|
|
if system == "Windows":
|
|
|
|
|
|
info = self._get_windows_identifiers()
|
|
|
|
|
|
else:
|
|
|
|
|
|
info = {
|
|
|
|
|
|
"cpu_id": "",
|
|
|
|
|
|
"board_serial": "",
|
|
|
|
|
|
"disk_serials": [],
|
|
|
|
|
|
"mac": "",
|
|
|
|
|
|
"node": platform.node(),
|
|
|
|
|
|
"processor": platform.processor(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cpu_id = info.get("cpu_id") or ""
|
|
|
|
|
|
board_serial = info.get("board_serial") or ""
|
|
|
|
|
|
disk_serials = info.get("disk_serials") or []
|
|
|
|
|
|
mac = info.get("mac") or ""
|
|
|
|
|
|
|
|
|
|
|
|
variants = []
|
|
|
|
|
|
|
|
|
|
|
|
full_core = []
|
|
|
|
|
|
if cpu_id:
|
|
|
|
|
|
full_core.append(f"CPU:{cpu_id}")
|
|
|
|
|
|
if board_serial:
|
|
|
|
|
|
full_core.append(f"BOARD:{board_serial}")
|
|
|
|
|
|
for s in disk_serials:
|
|
|
|
|
|
full_core.append(f"DISK:{s}")
|
|
|
|
|
|
if full_core:
|
|
|
|
|
|
variants.append(full_core)
|
|
|
|
|
|
|
|
|
|
|
|
no_disk = []
|
|
|
|
|
|
if cpu_id:
|
|
|
|
|
|
no_disk.append(f"CPU:{cpu_id}")
|
|
|
|
|
|
if board_serial:
|
|
|
|
|
|
no_disk.append(f"BOARD:{board_serial}")
|
|
|
|
|
|
if no_disk:
|
|
|
|
|
|
variants.append(no_disk)
|
|
|
|
|
|
|
|
|
|
|
|
if cpu_id:
|
|
|
|
|
|
variants.append([f"CPU:{cpu_id}"])
|
|
|
|
|
|
if board_serial:
|
|
|
|
|
|
variants.append([f"BOARD:{board_serial}"])
|
|
|
|
|
|
|
|
|
|
|
|
if mac:
|
|
|
|
|
|
variants.append([f"MAC:{mac}"])
|
|
|
|
|
|
if cpu_id and mac:
|
|
|
|
|
|
variants.append([f"CPU:{cpu_id}", f"MAC:{mac}"])
|
|
|
|
|
|
|
|
|
|
|
|
fallback_core = []
|
|
|
|
|
|
node = (info.get("node") or "").strip()
|
|
|
|
|
|
proc = (info.get("processor") or "").strip()
|
|
|
|
|
|
if node:
|
|
|
|
|
|
fallback_core.append(f"NODE:{node}")
|
|
|
|
|
|
if proc:
|
|
|
|
|
|
fallback_core.append(f"PROCESSOR:{proc}")
|
|
|
|
|
|
if fallback_core:
|
|
|
|
|
|
variants.append(fallback_core)
|
|
|
|
|
|
|
|
|
|
|
|
prefix = "W10-" if system == "Windows" else "FB-"
|
|
|
|
|
|
candidates = []
|
|
|
|
|
|
for core in variants:
|
|
|
|
|
|
try:
|
|
|
|
|
|
mid = f"{prefix}{self._hash_core_info(core)}"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if mid not in candidates:
|
|
|
|
|
|
candidates.append(mid)
|
|
|
|
|
|
return candidates
|
|
|
|
|
|
|
|
|
|
|
|
def get_machine_id(self) -> str:
|
|
|
|
|
|
"""生成机器硬件指纹"""
|
|
|
|
|
|
if self._machine_id:
|
|
|
|
|
|
return self._machine_id
|
|
|
|
|
|
try:
|
|
|
|
|
|
candidates = self._build_machine_id_candidates()
|
|
|
|
|
|
if not candidates:
|
|
|
|
|
|
raise RuntimeError("无法生成机器指纹")
|
|
|
|
|
|
self._machine_id_candidates = candidates
|
|
|
|
|
|
self._machine_id = candidates[0]
|
2025-10-29 22:42:25 +08:00
|
|
|
|
logger.info(f"生成机器指纹: {self._machine_id}")
|
|
|
|
|
|
return self._machine_id
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"生成机器指纹失败: {e}")
|
|
|
|
|
|
fallback_info = f"{platform.system()}-{platform.node()}-{platform.machine()}"
|
|
|
|
|
|
fallback_id = hashlib.md5(fallback_info.encode('utf-8')).hexdigest()[:12].upper()
|
|
|
|
|
|
self._machine_id = f"FB-{fallback_id}"
|
2026-02-04 16:05:48 +08:00
|
|
|
|
self._machine_id_candidates = [self._machine_id]
|
2025-10-29 22:42:25 +08:00
|
|
|
|
return self._machine_id
|
2026-02-04 16:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
def get_machine_id_candidates(self) -> list:
|
|
|
|
|
|
if self._machine_id_candidates is None:
|
|
|
|
|
|
self.get_machine_id()
|
|
|
|
|
|
return list(self._machine_id_candidates or [])
|
2025-10-29 22:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
def load_license(self, license_path: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
"""加载授权文件"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not os.path.exists(license_path):
|
|
|
|
|
|
logger.warning(f"授权文件不存在: {license_path}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
with open(license_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
|
license_data = json.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
# 基本字段验证
|
|
|
|
|
|
required_fields = ['product', 'license_id', 'license_type', 'machine_id',
|
|
|
|
|
|
'issued_at', 'expires_at', 'signature']
|
|
|
|
|
|
for field in required_fields:
|
|
|
|
|
|
if field not in license_data:
|
|
|
|
|
|
logger.error(f"授权文件缺少必要字段: {field}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
return license_data
|
|
|
|
|
|
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
|
logger.error(f"授权文件格式错误: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"加载授权文件失败: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def verify_signature(self, license_data: Dict[str, Any], public_key_path: str) -> bool:
|
2025-10-30 14:02:39 +08:00
|
|
|
|
"""验证授权文件数字签名
|
|
|
|
|
|
|
|
|
|
|
|
规则:RSA-PSS + SHA256,MGF1(SHA256),盐长度固定为 32 字节;
|
|
|
|
|
|
待签名数据为去除 signature 后的紧凑、排序键 JSON。
|
|
|
|
|
|
"""
|
2025-10-29 22:42:25 +08:00
|
|
|
|
try:
|
|
|
|
|
|
if not os.path.exists(public_key_path):
|
|
|
|
|
|
logger.error(f"公钥文件不存在: {public_key_path}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 加载公钥
|
|
|
|
|
|
with open(public_key_path, 'rb') as f:
|
|
|
|
|
|
public_key = serialization.load_pem_public_key(f.read())
|
|
|
|
|
|
|
|
|
|
|
|
# 提取签名
|
|
|
|
|
|
signature_b64 = license_data.get('signature', '')
|
|
|
|
|
|
if not signature_b64:
|
|
|
|
|
|
logger.error("授权文件缺少签名")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
signature = base64.b64decode(signature_b64)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"签名格式错误: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 构建待验证的数据(排除signature字段)
|
|
|
|
|
|
license_copy = license_data.copy()
|
|
|
|
|
|
license_copy.pop('signature', None)
|
|
|
|
|
|
|
|
|
|
|
|
# 按键排序确保一致性
|
|
|
|
|
|
sorted_data = json.dumps(license_copy, sort_keys=True, separators=(',', ':'))
|
|
|
|
|
|
message = sorted_data.encode('utf-8')
|
|
|
|
|
|
|
2025-10-30 14:02:39 +08:00
|
|
|
|
# 验证签名(固定 PSS 盐长度为 32 字节,确保跨语言一致)
|
2025-10-29 22:42:25 +08:00
|
|
|
|
try:
|
|
|
|
|
|
public_key.verify(
|
|
|
|
|
|
signature,
|
|
|
|
|
|
message,
|
|
|
|
|
|
padding.PSS(
|
|
|
|
|
|
mgf=padding.MGF1(hashes.SHA256()),
|
2025-10-30 14:02:39 +08:00
|
|
|
|
salt_length=PSS_SALT_LENGTH_BYTES
|
2025-10-29 22:42:25 +08:00
|
|
|
|
),
|
|
|
|
|
|
hashes.SHA256()
|
|
|
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
|
|
|
except InvalidSignature:
|
|
|
|
|
|
logger.error("授权文件签名验证失败")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"签名验证过程出错: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def check_validity(self, license_data: Dict[str, Any], machine_id: str,
|
|
|
|
|
|
grace_days: int = 0) -> Tuple[bool, str]:
|
|
|
|
|
|
"""检查授权有效性"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 检查产品匹配
|
|
|
|
|
|
if license_data.get('product') != 'BodyBalanceEvaluation':
|
|
|
|
|
|
return False, "授权文件产品不匹配"
|
|
|
|
|
|
|
|
|
|
|
|
# 检查机器绑定
|
|
|
|
|
|
license_machine_id = license_data.get('machine_id', '')
|
2026-02-04 16:05:48 +08:00
|
|
|
|
candidates = []
|
|
|
|
|
|
if machine_id:
|
|
|
|
|
|
candidates.append(machine_id)
|
|
|
|
|
|
for mid in self.get_machine_id_candidates():
|
|
|
|
|
|
if mid not in candidates:
|
|
|
|
|
|
candidates.append(mid)
|
|
|
|
|
|
if license_machine_id not in candidates:
|
2025-10-29 22:42:25 +08:00
|
|
|
|
return False, f"授权文件与当前机器不匹配 (当前: {machine_id}, 授权: {license_machine_id})"
|
|
|
|
|
|
|
|
|
|
|
|
# 检查有效期
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
expires_at_str = license_data.get('expires_at', '')
|
|
|
|
|
|
expires_at = datetime.fromisoformat(expires_at_str.replace('Z', '+00:00'))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return False, f"授权过期时间格式错误: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
# 应用宽限期
|
|
|
|
|
|
if grace_days > 0:
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
expires_at += timedelta(days=grace_days)
|
|
|
|
|
|
|
|
|
|
|
|
if now > expires_at:
|
|
|
|
|
|
return False, f"授权已过期 (过期时间: {expires_at.strftime('%Y-%m-%d %H:%M:%S')})"
|
|
|
|
|
|
|
|
|
|
|
|
return True, "授权有效"
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return False, f"授权验证过程出错: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
def get_license_status(self, force_reload: bool = False) -> LicenseStatus:
|
|
|
|
|
|
"""获取当前授权状态"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 检查缓存(避免频繁文件访问)
|
|
|
|
|
|
if not force_reload and self._license_cache and self._cache_timestamp:
|
|
|
|
|
|
cache_age = datetime.now().timestamp() - self._cache_timestamp
|
|
|
|
|
|
if cache_age < 300: # 5分钟缓存
|
|
|
|
|
|
return self._license_cache
|
|
|
|
|
|
|
|
|
|
|
|
# 获取配置
|
|
|
|
|
|
if not self.config_manager:
|
|
|
|
|
|
return LicenseStatus(valid=False, message="配置管理器未初始化")
|
2026-02-04 16:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
license_path, public_key_path, grace_days = self._resolve_license_paths()
|
2025-10-29 22:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 获取机器指纹
|
|
|
|
|
|
machine_id = self.get_machine_id()
|
|
|
|
|
|
|
|
|
|
|
|
# 加载授权文件
|
|
|
|
|
|
license_data = self.load_license(license_path)
|
|
|
|
|
|
if not license_data:
|
|
|
|
|
|
status = LicenseStatus(
|
|
|
|
|
|
valid=False,
|
|
|
|
|
|
message="授权文件不存在或格式错误,当前为试用模式",
|
|
|
|
|
|
license_type="trial",
|
|
|
|
|
|
features={"recording": True, "export": False, "trial_limit_minutes": 30}
|
|
|
|
|
|
)
|
|
|
|
|
|
self._license_cache = status
|
|
|
|
|
|
self._cache_timestamp = datetime.now().timestamp()
|
|
|
|
|
|
return status
|
|
|
|
|
|
|
|
|
|
|
|
# 验证签名
|
|
|
|
|
|
if not self.verify_signature(license_data, public_key_path):
|
|
|
|
|
|
status = LicenseStatus(
|
|
|
|
|
|
valid=False,
|
|
|
|
|
|
message="授权文件签名验证失败",
|
|
|
|
|
|
license_type="invalid"
|
|
|
|
|
|
)
|
|
|
|
|
|
self._license_cache = status
|
|
|
|
|
|
self._cache_timestamp = datetime.now().timestamp()
|
|
|
|
|
|
return status
|
2026-02-04 16:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
self._mirror_license_assets(license_path=license_path, public_key_path=public_key_path)
|
2025-10-29 22:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 检查有效性
|
|
|
|
|
|
is_valid, message = self.check_validity(license_data, machine_id, grace_days)
|
|
|
|
|
|
|
|
|
|
|
|
if is_valid:
|
|
|
|
|
|
# 解析过期时间
|
|
|
|
|
|
expires_at = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
expires_at_str = license_data.get('expires_at', '')
|
|
|
|
|
|
expires_at = datetime.fromisoformat(expires_at_str.replace('Z', '+00:00'))
|
|
|
|
|
|
except:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
status = LicenseStatus(
|
|
|
|
|
|
valid=True,
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
license_type=license_data.get('license_type', 'unknown'),
|
|
|
|
|
|
expires_at=expires_at,
|
|
|
|
|
|
features=license_data.get('features', {}),
|
|
|
|
|
|
license_id=license_data.get('license_id', '')
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
status = LicenseStatus(
|
|
|
|
|
|
valid=False,
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
license_type=license_data.get('license_type', 'unknown'),
|
|
|
|
|
|
license_id=license_data.get('license_id', '')
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 缓存结果
|
|
|
|
|
|
self._license_cache = status
|
|
|
|
|
|
self._cache_timestamp = datetime.now().timestamp()
|
|
|
|
|
|
|
|
|
|
|
|
return status
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"获取授权状态失败: {e}")
|
|
|
|
|
|
status = LicenseStatus(
|
|
|
|
|
|
valid=False,
|
|
|
|
|
|
message=f"授权检查出错: {str(e)}",
|
|
|
|
|
|
license_type="error"
|
|
|
|
|
|
)
|
|
|
|
|
|
return status
|
|
|
|
|
|
|
|
|
|
|
|
def verify_license_file(self, license_path: str) -> Tuple[bool, str]:
|
|
|
|
|
|
"""验证指定授权文件的签名与有效性
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(bool, str): 验证结果与消息
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not self.config_manager:
|
|
|
|
|
|
return False, "配置管理器未初始化"
|
|
|
|
|
|
|
2026-02-04 16:05:48 +08:00
|
|
|
|
_, public_key_path, grace_days = self._resolve_license_paths()
|
2025-10-29 22:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
if not os.path.exists(license_path):
|
|
|
|
|
|
return False, f"授权文件不存在: {license_path}"
|
|
|
|
|
|
|
|
|
|
|
|
# 加载授权文件
|
|
|
|
|
|
data = self.load_license(license_path)
|
|
|
|
|
|
if not data:
|
|
|
|
|
|
return False, "授权文件格式错误或缺少必要字段"
|
|
|
|
|
|
|
|
|
|
|
|
# 验证签名
|
|
|
|
|
|
if not self.verify_signature(data, public_key_path):
|
|
|
|
|
|
return False, "授权文件签名验证失败"
|
|
|
|
|
|
|
|
|
|
|
|
# 检查有效性(机器绑定 + 过期判断)
|
|
|
|
|
|
machine_id = self.get_machine_id()
|
|
|
|
|
|
is_valid, message = self.check_validity(data, machine_id, grace_days)
|
|
|
|
|
|
return is_valid, message
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"验证授权文件失败: {e}")
|
|
|
|
|
|
return False, f"验证过程出错: {str(e)}"
|
|
|
|
|
|
|
|
|
|
|
|
def generate_activation_request(self, output_path: str = None, company_name: str = None, contact_info: str = None) -> str:
|
|
|
|
|
|
"""生成激活请求文件(离线激活用)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
machine_id = self.get_machine_id()
|
|
|
|
|
|
|
|
|
|
|
|
request_data = {
|
|
|
|
|
|
"product": "BodyBalanceEvaluation",
|
2025-12-12 17:33:14 +08:00
|
|
|
|
"version": "1.5.0",
|
2025-10-29 22:42:25 +08:00
|
|
|
|
"machine_id": machine_id,
|
|
|
|
|
|
"platform": platform.system(),
|
|
|
|
|
|
"request_time": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
"hardware_info": {
|
|
|
|
|
|
"system": platform.system(),
|
|
|
|
|
|
"machine": platform.machine(),
|
|
|
|
|
|
"processor": platform.processor(),
|
|
|
|
|
|
"node": platform.node()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if company_name:
|
|
|
|
|
|
request_data["company_name"] = company_name
|
|
|
|
|
|
if contact_info:
|
|
|
|
|
|
request_data["contact_info"] = contact_info
|
|
|
|
|
|
|
|
|
|
|
|
if not output_path:
|
|
|
|
|
|
output_path = f"activation_request_{machine_id}.json"
|
|
|
|
|
|
|
|
|
|
|
|
with open(output_path, 'w', encoding='utf-8') as f:
|
|
|
|
|
|
json.dump(request_data, f, indent=2, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"激活请求文件已生成: {output_path}")
|
|
|
|
|
|
return output_path
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"生成激活请求失败: {e}")
|
2025-12-07 20:07:22 +08:00
|
|
|
|
raise
|