# license_utils.py """RSA 验签许可证模块 —— 嵌入 exe,验签 + 每日巡检 + 防改系统时间。 用法(在 main.py 或主窗口 __init__ 中调用一次即可): from license_utils import check_license, start_license_watchdog check_license() # 启动时验签(失败则抛异常退出) start_license_watchdog(interval_minutes=1440) # 后台每天巡检 """ import json import base64 import os import sys import threading import time as _time_module import datetime from pathlib import Path import requests from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives import hashes, serialization from cryptography.exceptions import InvalidSignature # ============================================================ # 公钥(编译进 exe,可公开)—— 与 ControlPanel 使用的签名私钥配对 # 公钥更新由受控的发布流程完成,客户端不包含任何签发能力。 # ============================================================ # {{LICENSE_PUBLIC_KEY_START}} PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY----- MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArQ4l2ePnl+p9yuUWcuOK CQ4bzixyJKh8tPSVMwqI7u02v9qdrIPu5j3T0tl+qCNTLMP6WHd6s09M7bWuVTnU 220ljrdBb8opIzNGxTTri1k3JqMI95ljLwdEB6vp+ISyrdFKG4o+B+hDDvxkbyGH 2vKBG71Wrws4ujOEI1H8MDWDRMyGrHFQZZk1Sz6WkgWT+yjoZ8L0K3o0afIYw8F9 J50aaRQ9fvNW4EB+Pa5Yy5DQrNIs1smMVDpegdYt5uUMwEnfoS6Y6l98Gz7ljZ5n 6/WVaFb55XquAwsF/zq6oDfKBrAOBqzT2YYZklr8swlKKIJ3ExA1sd/dxhfZhidi pqCvye6+cYa5GTRu9knzBsVPdzzhQC5AqKUuPglVJV8dQPfH7Nb7EP5wvNSgzpLT 9wsIoZXm9GXD0hApHvobSiZnpqY5g9InV7fQZyold2zFhHWpDieNjX0844gQafnH Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE= -----END PUBLIC KEY-----""" # {{LICENSE_PUBLIC_KEY_END}} # 许可证文件相对路径 LICENSE_FILE = "license.lic" # 巡检间隔(分钟) DEFAULT_CHECK_INTERVAL = 5 ONLINE_CHECK_TIMEOUT_SECONDS = 5 DEFAULT_OFFLINE_HOURS = 100 # 过期后宽限期(小时),给用户保存工作的时间 GRACE_PERIOD_HOURS = 2 # ============================================================ # 内部状态 # ============================================================ _startup_monotonic = _time_module.monotonic() # 软件启动时刻(不受系统时间影响) _last_check_result = None _verified_license = None _verified_license_lock = threading.RLock() _watchdog_started = False _watchdog_lock = threading.Lock() _last_online_success_monotonic = None _on_expired_callback = None # 过期回调,可由外部设置 _on_grace_callback = None # 缓冲期回调 _on_log_callback = None # 日志回调,供 UI 状态栏显示 _warning_shown_states = { "expiring_today": False, # 到期当天警告是否已弹窗 "expiring_soon": False, # 即将到期(30天内)警告是否已弹窗 "expired_grace": False, # 过期缓冲期警告是否已弹窗 } # ============================================================ # GUI 线程安全工具 # ============================================================ def _invoke_on_qt_thread(func): """在 Qt 主线程中安全执行 func。 使用 QTimer.singleShot 将回调排队到主线程事件循环。 关键:必须传入 QApplication 作为 context(receiver),否则 QTimer 会被调度到 当前线程(watchdog 是 daemon 线程,没有 Qt event loop),导致静默永不触发。 """ try: from PySide6.QtCore import QTimer from PySide6.QtWidgets import QApplication app = QApplication.instance() if app is not None: QTimer.singleShot(0, app, func) return True except Exception: pass # 兜底:没有 QApplication 时直接调用(GUI 还未初始化) try: func() except Exception: pass return False def _show_message_box(icon, title, message): """线程安全地显示 QMessageBox。 Args: icon: 'critical', 'warning', 'information' title: 弹窗标题 message: 弹窗内容 """ def _show(): try: from PySide6.QtWidgets import QApplication, QMessageBox app = QApplication.instance() if app is None: return if icon == 'critical': QMessageBox.critical(None, title, message) elif icon == 'warning': QMessageBox.warning(None, title, message) else: QMessageBox.information(None, title, message) except Exception: pass _invoke_on_qt_thread(_show) # ============================================================ # 验签核心 # ============================================================ def _load_public_key(): """从内嵌的 PEM 加载公钥""" return serialization.load_pem_public_key(PUBLIC_KEY_PEM.encode()) def _validate_device_id(device_id): """确保新许可证中的目录键只能是 ``company/line``。""" if not isinstance(device_id, str): raise ValueError("许可证 device_id 必须是字符串") segments = device_id.split("/") if len(segments) != 2 or any( not segment or segment in (".", "..") or "\\" in segment for segment in segments): raise ValueError("许可证 device_id 必须是 company/production-line 格式") def _validate_payload(payload): if not isinstance(payload, dict): raise ValueError("许可证内容必须是对象") new_fields = ("license_id", "company_id", "production_line_id", "device_id") has_new_format = any(field in payload for field in new_fields) if has_new_format: missing = [field for field in new_fields if not isinstance(payload.get(field), str) or not payload[field].strip()] if missing: raise ValueError(f"新许可证缺少标识字段: {', '.join(missing)}") _validate_device_id(payload["device_id"]) else: _log("旧许可证不支持在线撤销") if not isinstance(payload.get("customer"), str) or not payload["customer"].strip(): raise ValueError("许可证缺少客户信息") return has_new_format def verify_license(lic_path=None): """验证许可证签名 + 有效期。 Args: lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic Returns: dict: 许可证 payload(customer, expiry, issued 等) Raises: FileNotFoundError: 许可证文件不存在 InvalidSignature: 签名不匹配(伪造/篡改) RuntimeError: 许可证已过期 ValueError: 许可证格式错误 """ if lic_path is None: # PyInstaller 打包后 sys.executable 是 exe 路径 exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd() lic_path = exe_dir / LICENSE_FILE if not os.path.exists(lic_path): raise FileNotFoundError(f"许可证文件不存在: {lic_path}") with open(lic_path, "r", encoding="utf-8") as f: raw = f.read().strip() # 解析: payload_base64 | signature_base64 if "|" not in raw: raise ValueError("许可证格式错误") payload_b64, signature_b64 = raw.split("|", 1) signature = base64.b64decode(signature_b64) # RSA-PSS SHA256 验签 try: pub_key = _load_public_key() pub_key.verify( signature, payload_b64.encode(), # 签名的是 base64 字符串本身 padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH, ), hashes.SHA256(), ) except InvalidSignature: raise InvalidSignature("许可证签名验证失败:文件可能被篡改") # 解析载荷 try: payload = json.loads(base64.b64decode(payload_b64)) except Exception: raise ValueError("许可证内容解析失败") _validate_payload(payload) # 有效期检查(精确到小时) expiry_str = payload.get("expiry") if not expiry_str: raise ValueError("许可证缺少过期时间") # 兼容旧格式 YYYY-MM-DD(视为当天 23:59) if len(expiry_str) == 10: expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M") else: expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M") trusted_now = _get_trusted_now() if trusted_now > expiry: gap_days = (trusted_now - expiry).days uptime_days = _get_uptime_days() # 反时钟篡改:需同时满足两个条件才怀疑用户拨慢系统时间 # ① 软件运行不到 1 天(刚启动) # ② 过期时间差超过 7 天(差距巨大 → 文件 mtime 暴露了真实时间) # 缺失任一条件 → 真过期,直接报错: # - gap 小(几分钟~几小时)→ 刚过期,正常报错 # - 软件跑了很久 → 正常使用中过期,正常报错 if uptime_days < 1 and gap_days > 7: issued_str = payload.get("issued", expiry_str) if len(issued_str) == 10: issued = datetime.datetime.strptime(issued_str + " 00:00", "%Y-%m-%d %H:%M") else: issued = datetime.datetime.strptime(issued_str, "%Y-%m-%d %H:%M") estimated_now = issued + datetime.timedelta(days=uptime_days) if estimated_now <= expiry: return payload raise ExpiredError( f"许可证已过期 (到期: {expiry_str})", expiry=expiry, ) return payload def get_verified_license(): """返回本进程已通过本地验证的许可证载荷,尚未验证时返回 ``None``。""" with _verified_license_lock: return dict(_verified_license) if _verified_license is not None else None def _is_new_license(payload): return all(payload.get(field) for field in ( "license_id", "company_id", "production_line_id", "device_id")) def _api_url(): base_url = os.environ.get( "REINLOOP_SERVER_URL", "https://ReinLoop.dominatedconvergence.com" ).rstrip("/") return os.environ.get("REINLOOP_API_URL", base_url) def _offline_limit_seconds(): try: hours = float(os.environ.get("REINLOOP_LICENSE_OFFLINE_HOURS", DEFAULT_OFFLINE_HOURS)) except ValueError: hours = DEFAULT_OFFLINE_HOURS return max(0, hours) * 3600 def validate_license_online(payload): """验证可撤销许可证的在线状态。 网络故障只在超过离线时限后失效;服务端明确拒绝则立即返回 ExpiredError。 """ global _last_online_success_monotonic if not _is_new_license(payload): return try: response = requests.post(_api_url(), json={ "type": "validateLicense", "licenseId": payload["license_id"], "deviceId": payload["device_id"], }, timeout=ONLINE_CHECK_TIMEOUT_SECONDS) response.raise_for_status() result = response.json() except (requests.RequestException, ValueError) as exc: now = _time_module.monotonic() if _last_online_success_monotonic is None: _last_online_success_monotonic = _startup_monotonic if now - _last_online_success_monotonic > _offline_limit_seconds(): raise ExpiredError("许可证在线校验超过离线宽限期") from exc _log(f"许可证在线校验暂不可用: {exc}") return status = result.get("status") if not result.get("success") or not result.get("valid") or status != "active": reason = status or result.get("errMsg") or "invalid" raise ExpiredError(f"许可证在线状态无效: {reason}") if result.get("licenseId") not in (None, payload["license_id"]): raise ExpiredError("许可证在线校验返回了不匹配的许可证") _last_online_success_monotonic = _time_module.monotonic() # ============================================================ # 时间可信度 # ============================================================ def _get_uptime_days(): """软件已连续运行的天数(基于 monotonic,不受系统时间影响)""" return (_time_module.monotonic() - _startup_monotonic) / 86400 def _get_trusted_now(): """多源交叉校验获取可信日期时间(精确到小时)。 取系统时间和文件时间的最大值,防止用户回拨系统时间。 """ candidates = [] # 1. 系统时间 candidates.append(datetime.datetime.now()) # 2. 软件启动时记录的"最早可能时间" # monotonic 计时推导出的启动时间 startup_guess = datetime.datetime.now() - datetime.timedelta( days=_get_uptime_days() ) candidates.append(startup_guess) # 3. 系统文件的修改时间(不易被用户修改) system_files = [] if sys.platform == "darwin": system_files = [ "/System/Library/CoreServices/SystemVersion.plist", "/usr/bin/python3", ] elif sys.platform == "win32": system_files = [ r"C:\Windows\System32\ntoskrnl.exe", r"C:\Windows\explorer.exe", ] for sf in system_files: if os.path.exists(sf): try: mtime = os.path.getmtime(sf) candidates.append(datetime.datetime.fromtimestamp(mtime)) except OSError: pass # 取最大值(真时间 ≥ 所有候选值。用户可能回拨,但不能让其他文件"变新") return max(candidates) # ============================================================ # 后台巡检 # ============================================================ def start_license_watchdog(interval_minutes=DEFAULT_CHECK_INTERVAL): """启动后台许可证巡检线程。 Args: interval_minutes: 检查间隔(分钟),默认 1440(24小时) """ global _watchdog_started with _watchdog_lock: if _watchdog_started: return _watchdog_started = True t = threading.Thread( target=_watchdog_loop, args=(max(5, interval_minutes),), daemon=True, name="license-watchdog", ) t.start() def _watchdog_loop(interval_minutes): while True: _time_module.sleep(interval_minutes * 60) try: payload = verify_license() with _verified_license_lock: global _verified_license _verified_license = dict(payload) validate_license_online(payload) expiry_str = payload["expiry"] # 解析到期时间(兼容旧格式) if len(expiry_str) == 10: expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M") else: expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M") trusted_now = _get_trusted_now() hours_left = (expiry - trusted_now).total_seconds() / 3600 days_left = int(hours_left / 24) if hours_left < 0: # 已过期(不应走到这里,verify_license 会抛 ExpiredError) _log(f"⚠ 许可证已过期 ({expiry_str})") _show_message_box('critical', "许可证已过期", f"您的许可证已于 {expiry_str} 到期!\n请尽快联系厂商续期。") elif hours_left <= 24: if not _warning_shown_states["expiring_today"]: _warning_shown_states["expiring_today"] = True _log(f"⚠ 许可证将在今天到期 ({expiry_str})") _show_message_box('warning', "许可证即将到期", f"您的许可证将于今天 {expiry_str} 到期!\n" f"剩余约 {int(hours_left)} 小时,请及时联系厂商续期。") elif days_left <= 30: if not _warning_shown_states["expiring_soon"]: _warning_shown_states["expiring_soon"] = True _log(f"⚠ 许可证将在 {days_left} 天后到期 ({expiry_str})") _show_message_box('warning', "许可证即将到期", f"您的许可证将在 {days_left} 天后({expiry_str})到期\n请提前联系厂商续期。") except ExpiredError as e: _handle_expired(e) except Exception as e: _log(f"许可证巡检异常: {e}") def _handle_expired(error): """处理许可证过期""" _log(f"❌ {error}") # 弹窗:许可证已过期 _show_message_box('critical', "许可证已过期", f"{error}\n\n软件将在 {GRACE_PERIOD_HOURS} 小时缓冲期后自动退出,\n请及时保存工作并联系厂商续期。") if _on_expired_callback: _on_expired_callback(str(error)) # 宽限期:给用户时间保存工作 grace_start = _time_module.monotonic() grace_seconds = GRACE_PERIOD_HOURS * 3600 if not _warning_shown_states["expired_grace"]: _warning_shown_states["expired_grace"] = True if _on_grace_callback: _on_grace_callback(GRACE_PERIOD_HOURS) # 半小时后提醒一次 warned_half = False while _time_module.monotonic() - grace_start < grace_seconds: elapsed = _time_module.monotonic() - grace_start if not warned_half and elapsed > grace_seconds / 2: warned_half = True _show_message_box('warning', "许可证已过期", f"缓冲期剩余约 {GRACE_PERIOD_HOURS // 2} 小时,\n请尽快保存工作!") _time_module.sleep(60) # 每分钟检查一次 # 宽限期过,强制退出 _log("宽限期已过,软件即将退出") _show_message_box('critical', "许可证已过期", "缓冲期已结束,软件即将退出。\n请联系厂商续期后重新启动。") # 给 5 秒做最后的清理 _time_module.sleep(5) os._exit(1) def set_on_expired(callback): """设置过期回调: callback(message: str)""" global _on_expired_callback _on_expired_callback = callback def set_on_grace(callback): """设置宽限期回调: callback(hours: int)""" global _on_grace_callback _on_grace_callback = callback def set_on_log(callback): """设置日志回调: callback(message: str) 所有许可证关键日志会同时输出到此回调,供 UI 状态栏显示。 """ global _on_log_callback _on_log_callback = callback # ============================================================ # 便捷入口 # ============================================================ class ExpiredError(RuntimeError): """许可证过期异常""" def __init__(self, message, expiry=None): super().__init__(message) self.expiry = expiry def _log(msg): """终端日志 + UI 回调(不依赖任何 UI 层)""" timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") formatted = f"[License {timestamp}] {msg}" print(formatted) # 同步推送到 UI 状态栏(如果已注册回调) if _on_log_callback: try: _on_log_callback(str(msg)) except Exception: pass def check_license(lic_path=None): """启动时调用:验证许可证,通过则返回 payload。 在 main.py 的 main() 函数开头调用一次即可。 内部会自动启动后台巡检线程。 Returns: dict: 许可证载荷 Raises: SystemExit: 验签失败或已过期(启动阶段直接退出) """ try: payload = verify_license(lic_path) environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip() if (_is_new_license(payload) and environment_device_id and environment_device_id != payload["device_id"]): raise ValueError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致") validate_license_online(payload) with _verified_license_lock: global _verified_license _verified_license = dict(payload) start_license_watchdog() expiry = payload.get("expiry", "未知") customer = payload.get("customer", "未知") _log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}") return payload except FileNotFoundError as e: _log(f"❌ {e}") _show_error_and_exit( "未找到许可证文件", "请将 license.lic 放到软件根目录,然后重新启动程序。\n\n" "如有疑问,请联系厂商获取有效的许可证文件。" ) except InvalidSignature as e: _log(f"❌ {e}") _show_error_and_exit( "许可证验证失败", "许可证签名校验不通过,文件可能已被篡改。\n\n" "请使用原始签发的 license.lic 文件,\n" "或联系厂商重新签发。" ) except ExpiredError as e: _log(f"❌ {e}") _show_error_and_exit( "许可证已过期", f"您的许可证已于 {e.expiry} 到期。\n\n" "请联系厂商续期,获取新的许可证文件后重新启动。" ) except Exception as e: _log(f"❌ 许可证检查异常: {e}") _show_error_and_exit(f"许可证校验失败: {e}", str(e)) def _show_error_and_exit(title, detail=""): """显示错误弹窗并退出(兼容 GUI 和无 GUI 模式)。 Args: title: 弹窗标题(简短概要) detail: 弹窗正文(详细说明和操作建议) """ message = f"{title}\n\n{detail}" if detail else title # 尝试 GUI 弹窗 try: from PySide6.QtWidgets import QApplication, QMessageBox app = QApplication.instance() if app is not None: QMessageBox.critical(None, title, detail or title) else: # 无 QApplication 实例时,尝试创建一个临时的 try: app = QApplication(sys.argv[:1]) QMessageBox.critical(None, title, detail or title) except Exception: pass except Exception: pass print(f"\n{'='*50}") print(message) print(f"{'='*50}\n") sys.exit(1) # ============================================================ # 辅助:获取许可证信息(供 UI 显示) # ============================================================ def get_license_info(lic_path=None): """读取许可证信息(不做过期检查),供 UI 显示。 Returns: dict | None: 许可证信息,文件不存在则返回 None """ try: if lic_path is None: exe_dir = ( Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd() ) lic_path = exe_dir / LICENSE_FILE if not os.path.exists(lic_path): return None with open(lic_path, "r", encoding="utf-8") as f: raw = f.read().strip() payload_b64 = raw.split("|")[0] payload = json.loads(base64.b64decode(payload_b64)) # 先验签保证内容可信 verify_license(lic_path) return { "customer": payload.get("customer", "未知"), "expiry": payload.get("expiry", "未知"), "issued": payload.get("issued", "未知"), "features": payload.get("features", "*"), } except Exception: return None