Files
ReinLoopTest/ReinLoop/license_utils.py
T
2026-07-30 11:12:31 +08:00

657 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = 72
# 过期后宽限期(小时),给用户保存工作的时间
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 作为 contextreceiver),否则 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: 许可证 payloadcustomer, 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", "http://ReinLoop.dominatedconvergence.com"
).rstrip("/")
return os.environ.get("REINLOOP_API_URL", f"{base_url}/api")
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