#!/usr/bin/env python3 # -*- coding: utf-8 -*- """本地许可门控 + 在线授权台账客户端(一码一机)。 授权码 = 【短码】WX-XXXX-XXXX(用户手输)。真正的 RSA 签名存在老李的授权台账 服务器里,不在码本身 —— 这样码可以很短,又不会被伪造/被群发盗用: - 签发:signing.issue_short() 用【私钥】签 `token|tier`,(token, 签名, 等级) 入库。 - 授权:本模块把 短码 + 本机 hwid 发到台账服务 /v1/activate;服务端验签后 把 短码↔hwid 绑定(用户无感),返回等级;同码异机拒绝。 - 本地仅持【公钥】用于服务端验签,私钥永远不进技能包。 tier 等级(当前两区模型:绿区一键清理 + 迁移): 0 = 免费扫描版(只读报告,无清理) 1 = 基础版(解锁:绿区一键清理,仅此一项) 2 = 高级版(解锁:绿区一键清理 + 迁移预览 / 执行,全部功能) feature -> 所需最低 tier(minTier): scan = 0 永远允许(只读) green_clean = 1 绿区直接删除(基础版) migration_preview= 2 迁移预览(高级版) migration_exec = 2 高级版迁移执行 (以下为历史遗留 feature,仅供旧版客户端兼容,当前报告已无黄区: yellow_open = 2 在文件管理器打开;trash_move = 2 移废纸篓) server.py 的 /action mode -> feature: rm -> green_clean migrate -> migration_exec (历史:trash -> trash_move / open -> yellow_open,旧版兼容保留) """ import atexit import base64 import hashlib import json import os import re import shutil import socket import subprocess import sys import threading import time try: # 在线授权需要联网(WorkBuddy 本身联网运行,无碍) import urllib.request import urllib.error import ssl _HAVE_URLLIB = True except Exception: # pragma: no cover - 极老环境 _HAVE_URLLIB = False # --------------------------------------------------------------------------- # 内置公钥(仅公钥!私钥只在 tools/private_key.pem,不进技能包) # 由 cryptography 生成 RSA-2048,这里只存 n / e,避免 PEM 解析依赖。 # --------------------------------------------------------------------------- _PUB_N_HEX = ("96ddfaceeeb13a7de0e3978a10099bdaae727600749a7792d5e0caab0db0ae66" "bd55b186ff76ce4316e449f6aeb7339d8b78457e51b0c15b2a133fb4d6327f3e2" "d9ae3bd9e024c88fcdfe4b7af5866899f89de1c9920c630f97d044804ca32e603" "fb42e14e40e4bc6323d411888bddc06d72110f9784fcf5845bd8f6ee06c1085ca" "ecad5ba11f1fe5cbfe9bc4711737e95e0824b79174b6ca4f9bbef0ca396bc273" "60ebcf3385a985986e4730e74e557fd951bf4c661cf33c75dfcb1208fb1e6408" "e8e847208ff9a678ffc3849c5c24c19a6ef9ee61ffb670316279708783018d8" "dbd79c964c19899eada04cf4ce2a78f5aae5136a00ff4e2bcafae061dcef25") _PUB_E_HEX = "10001" PUB_N = int(_PUB_N_HEX, 16) PUB_E = int(_PUB_E_HEX, 16) KEY_BYTES = (PUB_N.bit_length() + 7) // 8 # 2048 -> 256 # --------------------------------------------------------------------------- # 等级常量 # --------------------------------------------------------------------------- FREE_TIER = 0 STANDARD_TIER = 1 PRO_TIER = 2 TIER_NAME = {0: "免费版", 1: "基础版", 2: "高级版"} # feature -> minTier FEATURE_MIN_TIER = { "scan": FREE_TIER, "green_clean": STANDARD_TIER, "yellow_open": PRO_TIER, "trash_move": PRO_TIER, "migration_preview": PRO_TIER, "migration_exec": PRO_TIER, } # server.py /action mode -> feature MODE_FEATURE = { "rm": "green_clean", "trash": "trash_move", "open": "yellow_open", "migrate": "migration_exec", } # 自有版本信标(双保险之一;市场推送是另一保险)。 # 拉取失败 / 超时不阻断任何功能,仅静默跳过。 VERSION_BEACON_URL = "https://api.aipcbutler.com/v1/skill-version" # 远端版本比较用的“当前版本号”,由 build_report / server 注入, # 这里给一个兜底解析值(语义化版本转整数,仅用于 <= 比较)。 LOCAL_VERSION = "v1.7" # 在线授权/履约服务地址(HTTPS;客户端内嵌该服务器的 CA 证书做证书固定 cert pinning, # 连接经系统默认 TLS 校验链 + 固定 CA 双重校验,仅信任我们自己的授权服务器)。 # 如需切换地址,可设置环境变量 AIPC_AUTH_URL 覆盖。 AUTH_URL = (os.environ.get("AIPC_AUTH_URL") or "https://47.82.100.165").rstrip("/") # 授权服务使用 HTTPS:客户端内嵌服务器 CA 证书,构建 SSL 上下文时追加该 CA, # 使系统默认校验链能信任它(仅追加,不覆盖系统根证书)。 AUTH_CA_PEM = """-----BEGIN CERTIFICATE----- MIIDajCCAlKgAwIBAgIUbCihud+vd/pOdkCtl5ajyfAFAMkwDQYJKoZIhvcNAQEL BQAwPDELMAkGA1UEBhMCQ04xFTATBgNVBAoMDEFJX1BDX0J1dGxlcjEWMBQGA1UE AwwNNDcuODIuMTAwLjE2NTAeFw0yNjA3MjUxMTE0MDlaFw0yNzA3MjUxMTE0MDla MDwxCzAJBgNVBAYTAkNOMRUwEwYDVQQKDAxBSV9QQ19CdXRsZXIxFjAUBgNVBAMM DTQ3LjgyLjEwMC4xNjUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/ jfhe7IQxhC4NbMKsPrenvu/9nn9LO3fEVV0VOqVVX3UG7ZadRFzXIKsO/FVjqRpU B3NB5v106mDpSXky9y7aMtriuflMpIhJ68eIofU3/V79MaAmw/Y7xSWOUCzGiTqJ KkOg7KipXwhRS+267ijRC+DAzY4aq5RvMPved8zNT+KKVwZBvnhXhSe7wy1MPDs0 Enmz8IsuPT2AMy5UX7/bP2EGYdFH4Y738zZ6pYSdUwlgdwdCWy0/gz7mYohDyeCg IXJrscPjPC1z27fUJRlAXtFoP0Md9C18NQXkKnl5s6YnO52n/cKWetIC2CpqIxJp v+rW+kWhnpgSUxYti1N9AgMBAAGjZDBiMB0GA1UdDgQWBBT5+xaBuiKl4Yfwq5mR +ZzVi/aPfzAfBgNVHSMEGDAWgBT5+xaBuiKl4Yfwq5mR+ZzVi/aPfzAPBgNVHRMB Af8EBTADAQH/MA8GA1UdEQQIMAaHBC9SZKUwDQYJKoZIhvcNAQELBQADggEBAFVp JA9Qlp7+xcwnEtDfny16nchDpfMceHjY16neqdmu6KzoJZ88rf00FF/sWCzXF5G8 iyO1iIeC/1xXuYrHcWhkMNEic9zofY+QyLaUk+U4HZp4kAKhRboYFeFzX6SmVzi/ BDGDScPK24Ze9PJpT/Lu7h6mrhNy47aIbVchCmliDHih4o5XBrfofVLYvQWFqrUq 5P5HkClQYLEBbgeK/IxJOgRlkqhNO9wLMuXV6S2Oe18mqv07tjNSxqqiqXx7ZWEy jEKOjvg75OWBQAjde5Ckv5qGwhJdgsQfDc0qrIzTg1Fn5Hf5VPO4Djg8UeE+5hz4 RAYfs3or/CVXHZVkQQ4= -----END CERTIFICATE----- """ _HERE = os.path.dirname(os.path.abspath(__file__)) CA_CERT_PATH = os.path.join(_HERE, "auth_ca.pem") # =========================================================================== # 纯 Python RSA 验签(PKCS#1 v1.5 + SHA-256),仅依赖 hashlib / int 幂运算 # =========================================================================== def _b64u_decode(s): """URL-safe base64 解码,自动补 padding。""" s = s.strip() s += "=" * ((-len(s)) % 4) return base64.urlsafe_b64decode(s) def _rsa_pkcs1v15_sha256_verify(msg, sig): """验签 msg 上的 PKCS#1 v1.5 SHA-256 签名 sig。 返回 True/False。不做任何网络 / 文件 IO。 """ if not isinstance(sig, (bytes, bytearray)): return False if len(sig) != KEY_BYTES: return False try: c = int.from_bytes(bytes(sig), "big") m = pow(c, PUB_E, PUB_N) em = m.to_bytes(KEY_BYTES, "big") except Exception: return False h = hashlib.sha256(msg).digest() # SHA-256 的 DigestInfo DER 前缀(19 字节) digest_info = (b"\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02" b"\x01\x05\x00\x04\x20") T = digest_info + h ps_len = KEY_BYTES - 3 - len(T) if ps_len < 8: # PKCS#1 要求 PS 至少 8 字节 return False em_expected = b"\x00\x01" + (b"\xff" * ps_len) + b"\x00" + T # 定长比较,避免时序侧信道 if len(em) != len(em_expected): return False diff = 0 for a, b in zip(em, em_expected): diff |= a ^ b return diff == 0 # =========================================================================== # 机器指纹(hwid) # =========================================================================== _HWID_CACHE = [None] # 进程内缓存,避免每次调用都跑 wmic 子进程(热路径性能) # v1.7.64(安全合规):设备指纹(MachineGuid / 卷序列号)上报开关。 # 默认 True(激活需要稳定机器标识做一机一码绑定),但必须由前端隐私开关显式控制。 # 用户关闭后,激活改用本地匿名安装 ID(随机生成、仅存本机),不再上报真实硬件指纹。 _HWID_SEND_ENABLED = [True] def set_hwid_send_enabled(enabled): """由前端隐私开关调用:是否允许上报真实设备指纹。 False 时激活改用匿名安装 ID,不收集/上报 MachineGuid 与卷序列号。""" _HWID_SEND_ENABLED[0] = bool(enabled) def _anon_install_id(): """匿名安装 ID:仅在用户拒绝上报真实指纹时使用。随机生成、落盘本机, 同一安装保持稳定(保证同机可重复激活),但不可追溯到真实硬件。""" import uuid _p = os.path.join(_HERE, ".anon_install_id") try: if os.path.isfile(_p): with open(_p, "r", encoding="utf-8") as f: v = f.read().strip() if v: return "anon:" + v except Exception: pass v = uuid.uuid4().hex try: with open(_p, "w", encoding="utf-8") as f: f.write(v) except Exception: pass return "anon:" + v def get_hwid(): """返回稳定的本机指纹字符串。失败时用 hostname 兜底,保证永远有值。 v1.7.40(审查修复·性能):进程内缓存。原实现每次调用都跑 wmic 子进程(可数百 ms), 而 v1.7.40 的 hwid 绑定/降级复核会在热路径多次调用它,必须缓存避免性能倒退。 hwid 在本机进程生命周期内恒定(MachineGuid + 卷序列号运行期不变),缓存安全。 """ if _HWID_CACHE[0] is not None: return _HWID_CACHE[0] # v1.7.64(安全合规):用户关闭设备指纹上报时,返回匿名安装 ID, # 不读取 MachineGuid / 卷序列号,不向服务器上报真实硬件指纹。 if not _HWID_SEND_ENABLED[0]: _HWID_CACHE[0] = _anon_install_id() return _HWID_CACHE[0] parts = [] try: if sys.platform == "win32" or sys.platform.startswith("win"): import winreg try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography") as k: guid, _ = winreg.QueryValueEx(k, "MachineGuid") parts.append("mg:" + str(guid)) except Exception: pass # 系统盘卷序列号(稳定且难伪造) try: import subprocess out = subprocess.run( ["wmic", "path", "Win32_LogicalDisk", "where", "DeviceID='C:'", "get", "VolumeSerialNumber"], capture_output=True, text=True, timeout=5, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform.startswith("win") else 0) m = re.search(r"([0-9A-Fa-f]{4,})", out.stdout or "") if m: parts.append("vsn:" + m.group(1).upper()) except Exception: pass # AIPC-HWID-VSN:wmic 不可用(新版 Windows 已弃用 / 安全策略拦截)时 # 会只剩 mg: 段,服务端将判成"另一台电脑"返回 409 —— 付费用户被误拒。 # 这里复用**同一台机器**历史上成功上报过的 vsn 段补回,保证机器码稳定。 if not any(p.startswith("vsn:") for p in parts): _mgp = "" for _p in parts: if _p.startswith("mg:"): _mgp = _p[3:] break _rv = _recorded_hwid_vsn(_mgp) if _rv: parts.append("vsn:" + _rv) elif sys.platform == "darwin": try: import subprocess out = subprocess.run( ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], capture_output=True, text=True, timeout=5) m = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', out.stdout or "") if m: parts.append("uuid:" + m.group(1)) except Exception: pass else: mid = "/etc/machine-id" try: if os.path.exists(mid): with open(mid, "r", encoding="utf-8") as f: parts.append("mid:" + f.read().strip()) except Exception: pass except Exception: pass if not parts: # 兜底:hostname + 固定 salt 的稳定哈希 import socket h = hashlib.sha256( ("aipcbutler|" + (socket.gethostname() or "unknown")).encode("utf-8") ).hexdigest()[:16] parts.append("host:" + h) _HWID_CACHE[0] = "|".join(parts) return _HWID_CACHE[0] def _recorded_hwid_vsn(mg_prefix): """AIPC-HWID-VSN(v1.7.66):取本机历史上已成功上报过的 vsn 段。 只在「同一台机器」前提下复用:要求状态文件里记录的 hwid 与本机 mg: 主干一致。 换一台电脑 mg: 不同 → 直接返回 "",不生效,不构成授权搬运。 安全边界:直接读文件,不调用 read_license(),避免与 read_license 相互递归。 """ if not mg_prefix: return "" try: for _d in _candidate_data_dirs(): for _n in ("license.json", _HWM_NAME): _p = os.path.join(_d, _n) if not os.path.isfile(_p): continue try: with open(_p, "r", encoding="utf-8") as _f: _o = json.load(_f) except Exception: continue if not isinstance(_o, dict): continue _h = _o.get("hwid") or "" if "mg:" not in _h or "vsn:" not in _h: continue _mg = _h.split("mg:", 1)[1].split("|", 1)[0].strip().lower() if _mg != mg_prefix.lower(): continue _v = _h.split("vsn:", 1)[1].split("|", 1)[0].strip() if _v: return _v.upper() except Exception: pass return "" # =========================================================================== # 授权码解析 + 校验 # =========================================================================== def parse_code(code): """把授权码拆成 (payload_bytes, sig_bytes);格式错误返回 (None, None)。""" if not code or not isinstance(code, str): return None, None code = code.strip() if "." not in code: return None, None left, _, right = code.partition(".") try: payload = _b64u_decode(left) sig = _b64u_decode(right) except Exception: return None, None if not payload or len(sig) != KEY_BYTES: return None, None return payload, sig def verify_code(code): """纯本地校验授权码。 返回 dict:{ok, tier, hwid, exp, error} ok : bool —— 是否通过全部校验(签名 + hwid + 未过期) tier : int —— 通过时该码授予的等级(失败为 0) hwid : str —— 码内绑定的机器("*" = 通用) exp : int —— 过期时间戳(0 = 永不过期) error : str —— 失败原因(大白话,可展示给用户) """ out = {"ok": False, "tier": FREE_TIER, "hwid": "", "exp": 0, "error": ""} payload, sig = parse_code(code) if payload is None: out["error"] = "授权码格式不对,请检查是否完整复制(含中间的点)。" return out if not _rsa_pkcs1v15_sha256_verify(payload, sig): out["error"] = "授权码签名校验失败(可能是伪造或被改过)。" return out try: data = json.loads(payload.decode("utf-8")) except Exception: out["error"] = "授权码内容无法解析。" return out tier = int(data.get("t", FREE_TIER) or FREE_TIER) hwid = data.get("h", "*") or "*" exp = int(data.get("e", 0) or 0) out["hwid"] = hwid out["exp"] = exp out["tier"] = tier # hwid 比对本机(通用码 "*" 跳过) if hwid and hwid != "*": local = get_hwid() if hwid != local: out["error"] = "这张授权码绑定了其他电脑,无法在本机使用。" return out # 过期检查 if exp and int(time.time()) > exp: out["error"] = "这张授权码已过期,请联系我们续期。" return out if tier < STANDARD_TIER: out["error"] = "这张授权码等级不足,无法解锁清理功能。" return out out["ok"] = True out["error"] = "" return out def verify_short_signature(token, tier, sig_b64): """服务端用:验短码签名。payload = token|tier(与 signing.issue_short 配套)。""" if not isinstance(sig_b64, str): return False try: sig = _b64u_decode(sig_b64) except Exception: return False msg = ("%s|%d" % (token, int(tier))).encode("utf-8") return _rsa_pkcs1v15_sha256_verify(msg, sig) # =========================================================================== # license.json 读写(写入【用户数据目录】,不进技能包 —— 重装 / 更新不丢) # =========================================================================== # AIPC-DATADIR-FIX-LIC (2026-09-10 P0) # # 【根因】原实现 = `os.environ.get("APPDATA") or expanduser("~")`,依赖 APPDATA # 环境变量。一旦运行环境缺少 APPDATA(WorkBuddy 子进程环境被裁剪、系统/迁移工具 # 改动环境变量等),数据目录会**静默漂移**到 `~/AI_PC_Butler`: # 激活记录写在 \AI_PC_Butler\license.json # 本次运行却读 ~\AI_PC_Butler\license.json(不存在) # → read_license() 返回 tier=0 → 报告页按"未授权"渲染 → 用户被要求再次付费。 # 迷惑性极强:用户没换电脑、订单正常、服务端也没有任何 409(客户端压根没读到本地 # 许可,直接按未授权渲染)。这就是"激活过却又要付费"的真实根因。 # # 【修复原则】激活一次,永久可用: # ① 目录解析不依赖环境变量:注册表 Shell Folders → SHGetFolderPathW → APPDATA → USERPROFILE; # ② 多候选目录中【优先选已有 license.json 的那个】——已激活用户的激活态永不丢; # ③ 自愈合并:把其它候选目录里已有的状态文件补进选定目录,消除"许可在这、订单在那"分裂脑。 # --------------------------------------------------------------------------- _DATA_DIR_CACHE = [None] # 跨候选目录自愈合并的稳定状态文件(缺哪个补哪个,绝不覆盖已有文件) _STATE_FILES = ( "license.json", "orders.json", "clean_state.json", "privacy.json", "analysis_migrated.json", "recycle_pending.json", "last_report_url.txt", # AIPC-LIC-HWM:授权水位,纳入自愈白名单,跟着状态文件一起合并 "license_hwm.json", ) def _roaming_base(): """权威 Roaming AppData 目录(不依赖 APPDATA 环境变量);取不到返回 ""。""" if sys.platform == "darwin": return os.path.expanduser("~/Library/Application Support") if not (sys.platform == "win32" or sys.platform.startswith("win")): return "" # ① 注册表 Shell Folders(系统权威值,环境变量丢失也不受影响) try: import winreg with winreg.OpenKey( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders") as _k: _v, _t = winreg.QueryValueEx(_k, "AppData") if _v and os.path.isdir(_v): return _v except Exception: pass # ② SHGetFolderPathW(CSIDL_APPDATA = 0x001A) try: import ctypes _buf = ctypes.create_unicode_buffer(1024) if ctypes.windll.shell32.SHGetFolderPathW(None, 0x001A, None, 0, _buf) == 0: _v = _buf.value if _v and os.path.isdir(_v): return _v except Exception: pass # ③ 环境变量 _v = os.environ.get("APPDATA") if _v and os.path.isdir(_v): return _v # ④ USERPROFILE 兜底(比裸 expanduser("~") 更贴近真实用户目录) _up = os.environ.get("USERPROFILE") or os.path.expanduser("~") return os.path.join(_up, "AppData", "Roaming") def _candidate_data_dirs(): """本项目历史上可能用过的所有数据目录(按优先级排序),去重且保序。""" # AIPC-DATA-DIR-OVERRIDE(v1.7.79):显式指定数据目录时,**只认它**。 # 支持多个(用系统路径分隔符隔开,Windows 上是分号),按顺序当候选目录用。 # 两个用途,都是真实需求: # ① 自动化 / 发布门禁在隔离目录里跑,绝不能读写真实用户目录 # (否则门禁结果会被本机真实许可污染,测出来的绿是假的); # ② 运维兜底:用户数据目录出现异常时,一个环境变量就能把目录钉到指定位置, # 不必改代码重发版;给两个目录时,第一个写不进会自动落到第二个。 # 未设置时行为与历史完全一致(注册表 → 环境变量 → 兜底)。 _ov = (os.environ.get("AIPC_DATA_DIR") or "").strip() if _ov: _out = [] for _p in _ov.split(os.pathsep): _p = _p.strip() if _p: _out.append(os.path.abspath(os.path.expanduser(_p))) if _out: return _out if sys.platform == "darwin": _raw = [os.path.expanduser("~/Library/Application Support/AI_PC_Butler")] elif not (sys.platform == "win32" or sys.platform.startswith("win")): _raw = [os.path.expanduser("~/.config/AI_PC_Butler"), os.path.expanduser("~/.ai_pc_butler")] else: _up = os.environ.get("USERPROFILE") or os.path.expanduser("~") _raw = [] _rb = _roaming_base() if _rb: _raw.append(os.path.join(_rb, "AI_PC_Butler")) # 历史 bug 会落到这里(APPDATA 缺失时 expanduser("~") 的兜底目录) _raw.append(os.path.join(os.path.expanduser("~"), "AI_PC_Butler")) _raw.append(os.path.join(_up, "AI_PC_Butler")) # migrate.py 曾把回收站兜底清单写到 Local _la = os.environ.get("LOCALAPPDATA") or os.path.join(_up, "AppData", "Local") _raw.append(os.path.join(_la, "AI_PC_Butler")) _out, _seen = [], set() for _d in _raw: if not _d: continue _k = os.path.normcase(os.path.abspath(_d)) if _k not in _seen: _seen.add(_k) _out.append(_d) return _out def _consolidate_state(chosen, cands): """自愈:其它候选目录里已有的状态文件,补进选定目录(只补缺,绝不覆盖)。""" try: for _f in _STATE_FILES: _dst = os.path.join(chosen, _f) if os.path.exists(_dst): continue for _d in cands: if os.path.abspath(_d) == os.path.abspath(chosen): continue _src = os.path.join(_d, _f) if os.path.isfile(_src): try: shutil.copy2(_src, _dst) except Exception: pass break except Exception: pass def get_data_dir(): """跨平台用户数据目录(单一真理源);不存在则创建。 关键:多候选目录中优先选【已有 license.json】的那个(取 mtime 最新), 保证"激活一次、永久可用",彻底消除 APPDATA 环境变量导致的目录漂移。 结果进程内缓存,避免重复探测注册表 / 磁盘。 """ if _DATA_DIR_CACHE[0]: return _DATA_DIR_CACHE[0] cands = _candidate_data_dirs() chosen = cands[0] if cands else os.path.expanduser("~/AI_PC_Butler") _best, _best_mt = None, -1.0 for _d in cands: _lp = os.path.join(_d, "license.json") if os.path.isfile(_lp): try: _mt = os.path.getmtime(_lp) except Exception: _mt = 0.0 if _mt > _best_mt: _best, _best_mt = _d, _mt if _best: chosen = _best try: os.makedirs(chosen, exist_ok=True) except Exception: chosen = os.path.expanduser("~/.ai_pc_butler") try: os.makedirs(chosen, exist_ok=True) except Exception: pass _consolidate_state(chosen, cands) _DATA_DIR_CACHE[0] = chosen return chosen def get_license_path(): return os.path.join(get_data_dir(), "license.json") def _recover_license_from_candidates(): """AIPC-DATADIR-FIX-RECOVER(v1.7.65):横向回收散落的有效授权。 纵深防御:即便将来数据目录解析又发生变化,只要本机任一候选目录里还留着一份 有效(tier>=1)的 license.json,也不让付费用户被误判未授权。 策略:只读 + 补齐(把找到的那份补回当前选定目录),绝不删除任何文件。 返回回收到的 dict;回收不到返回 None。 """ try: cands = _candidate_data_dirs() cur = os.path.abspath(get_data_dir()) best, best_mt = None, -1.0 for _d in cands: _p = os.path.join(_d, "license.json") if not os.path.isfile(_p): continue try: with open(_p, "r", encoding="utf-8") as _f: _obj = json.load(_f) except Exception: continue if not isinstance(_obj, dict): continue try: if int(_obj.get("tier", 0) or 0) < STANDARD_TIER: continue except Exception: continue try: _mt = os.path.getmtime(_p) except Exception: _mt = 0.0 if _mt > best_mt: best, best_mt = _obj, _mt if not best: return None # 补回当前选定目录(不覆盖已有文件;原子写) try: _dst = os.path.join(get_data_dir(), "license.json") if os.path.abspath(_dst) != cur or not os.path.exists(_dst): if not os.path.exists(_dst): _tmp = _dst + ".tmp" with open(_tmp, "w", encoding="utf-8") as _f: json.dump(best, _f, ensure_ascii=False, indent=2) os.replace(_tmp, _dst) except Exception: pass return best except Exception: return None # --------------------------------------------------------------------------- # AIPC-LIC-HWM(v1.7.66):授权水位 —— 「服务端最后一次确认过的等级」 # # 为什么需要它:read_license() 的失败模式是「读不到 → tier=0」。路径解析错误、 # 文件被安全软件隔离、磁盘瞬时错误、写坏……任何一种都会让已付费用户在报告页 # 看到"再付一次钱"。路径修好只堵住了一种入口,这里堵的是**这一类故障模式**。 # # 为什么它不会变成绕过吊销的后门: # 水位只在两种"服务端亲口确认"的场合写入 —— # ① activate_online() 拿到 ok 响应(首次激活/换机重绑) # ② _revalidate_license_once() 拿到 ok 响应(机会式复核) # 而 ② 拿到的权威值若比本地低(退款/吊销),会把水位一起下调。 # 因此水位永远 ≤ 服务端权威值,恢复出来的等级不可能高于服务端允许的等级。 # # 机器边界:复位要求 MachineGuid 主干(mg: 前缀)一致,防止把 A 机授权搬到 B 机。 # 只比主干、不比 vsn(卷序列号依赖 wmic,易变),避免"能算出来的机器码变化"又锁死用户。 # --------------------------------------------------------------------------- _HWM_NAME = "license_hwm.json" def _hwm_path(): return os.path.join(get_data_dir(), _HWM_NAME) def _write_json_atomic(p, obj): """原子写 JSON(同目录临时文件 + os.replace),写完【读回校验】。成功返回 True。 AIPC-WRITE-NEVER-FAIL(v1.7.79):这是许可落盘的唯一写入原语。 os.replace 在 Windows 上可能被杀软实时扫描 / 搜索索引器瞬时占用而抛 WinError 5(拒绝访问)—— 这类失败是【瞬时】的,必须由调用方重试, 所以本函数只负责"一次干净尝试 + 确认真的落盘"。 """ try: _d = os.path.dirname(p) if _d: os.makedirs(_d, exist_ok=True) # 目标路径本身是个目录(或父路径被文件占住)时,重试永远不会成功 —— 直接放弃。 # 不做这一步的话,重试会一轮轮留下 .tmp 垃圾(实测会在受管/安全环境里攒出 # 大量临时文件,触发"批量删除需确认",把整个进程拖死、连接被掐断)。 if os.path.isdir(p): return False # 目标已存在且不可写(只读属性 / 被占用)时也提前放弃:省掉注定失败的 # 「建临时文件 → 替换失败 → 删临时文件」整套动作,同样不留 .tmp 垃圾。 # 注意:这只跳过【确定】写不进的场合;杀软瞬时占用(属性仍可写)照常走重试。 try: if os.path.exists(p) and not os.access(p, os.W_OK): return False except Exception: pass tmp = p + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(obj, f, ensure_ascii=False, indent=2) try: f.flush() os.fsync(f.fileno()) except Exception: pass os.replace(tmp, p) # 读回校验:确认落盘内容真的能解析、且 tier 一致(防"写成功但内容坏") try: with open(p, "r", encoding="utf-8") as f: _chk = json.load(f) if not isinstance(_chk, dict): return False if int(_chk.get("tier", 0) or 0) != int((obj or {}).get("tier", 0) or 0): return False except Exception: return False return True except Exception: try: _t = p + ".tmp" if os.path.exists(_t): os.remove(_t) except Exception: pass return False def _write_dirs(): """可写候选目录,按优先级:当前选中目录 → 其余历史候选目录。去重保序。""" _raw = [] try: _raw.append(get_data_dir()) except Exception: pass try: _raw.extend(_candidate_data_dirs()) except Exception: pass out, seen = [], set() for _d in _raw: if not _d: continue try: _k = os.path.normcase(os.path.abspath(_d)) except Exception: continue if _k not in seen: seen.add(_k) out.append(_d) return out def _write_state_file(name, obj): """把状态文件写到【第一个写得进去】的候选目录。返回落盘路径;全失败返回 ""。 AIPC-WRITE-NEVER-FAIL(v1.7.79):主目录写不进就换目录、并整轮重试一次。 只要本机还有任何一个可写目录,就不允许"落盘失败"把付费用户判成失败。 落盘成功的目录会被设为权威目录(见 write_license),避免"写到了 B、却仍读 A"。 """ _dirs = _write_dirs() for _delay in (0.0, 0.25): if _delay: time.sleep(_delay) for _d in _dirs: _p = os.path.join(_d, name) if _write_json_atomic(_p, obj): return _p return "" def _write_hwm(tier, code, hwid): """AIPC-LIC-HWM:记录服务端确认的授权等级(原子写,失败静默)。 AIPC-WRITE-NEVER-FAIL(v1.7.79):水位是 read_license() 的最后一道兜底, 它若也写不进主目录,等于把兜底一起拆了 —— 故同样走多目录落盘。 """ try: obj = {"tier": int(tier or 0), "code": code or "", "hwid": hwid or "", "ts": int(time.time())} return bool(_write_state_file(_HWM_NAME, obj)) except Exception: return False def _hwm_files(): """各候选目录里的水位文件,按修改时间新→旧。""" out = [] try: for _d in _candidate_data_dirs(): _p = os.path.join(_d, _HWM_NAME) if os.path.isfile(_p): try: out.append((os.path.getmtime(_p), _p)) except Exception: out.append((0.0, _p)) except Exception: return [] out.sort(key=lambda x: x[0], reverse=True) return [p for _m, p in out] def _mg_compatible(a, b): """只比机器码主干 mg:;任一侧取不到就放行(宁可不锁死付费用户)。""" def _mg(s): s = s or "" if "mg:" not in s: return "" return s.split("mg:", 1)[1].split("|", 1)[0].strip().lower() ma, mb = _mg(a), _mg(b) if not ma or not mb: return True return ma == mb def _hwm_restore(): """AIPC-LIC-HWM:本地授权读不到时,用服务端确认过的水位复位。 返回复位后的 license dict;无可用水位 / 机器不符 返回 None(宁可不复位,也不误放行)。 """ try: hw_now = get_hwid() except Exception: hw_now = "" for p in _hwm_files(): try: with open(p, "r", encoding="utf-8") as f: obj = json.load(f) except Exception: continue if not isinstance(obj, dict): continue try: t = int(obj.get("tier", 0) or 0) except Exception: continue if t < STANDARD_TIER: continue if not _mg_compatible(obj.get("hwid", ""), hw_now): continue lic = {"tier": t, "active": True, "code": obj.get("code", ""), "hwid": hw_now, "exp": 0, "issued_at": int(time.time()), "version": LOCAL_VERSION, "online": True} try: write_license(lic) # 状态收敛:把复位结果写回权威目录 except Exception: pass return lic return None def read_license(): """读取本地 license.json;缺失 / 损坏一律视为未授权(tier=0)。""" default = {"tier": FREE_TIER, "active": False, "code": "", "hwid": "", "exp": 0, "issued_at": 0, "version": ""} p = get_license_path() try: with open(p, "r", encoding="utf-8") as f: d = json.load(f) if not isinstance(d, dict): return default d.setdefault("tier", FREE_TIER) # v1.7.36(审查修复·健壮性):tier 必须 int 强转。license.json 若被手工篡改为 # "tier":"2"(字符串),current_tier()/mode_allowed 里 int 比较会抛 TypeError, # 导致清理/迁移操作崩溃。容错强转:数字/数字字符串正常解析,其他一律视为 0。 try: d["tier"] = int(d.get("tier", FREE_TIER)) except Exception: d["tier"] = FREE_TIER d.setdefault("active", d.get("tier", FREE_TIER) >= STANDARD_TIER) d.setdefault("code", "") d.setdefault("hwid", "") # v1.7.40(审查修复·P0-① 自审修正):【不做本地 hwid 归零】。 # 对抗式自审发现:本机 hwid 若因卷序列号等变化(罕见但可能),本地归零会把 # 既有付费用户锁死,而 1.7.38 靠「本地不校验 hwid + 不联网」能幸免—— # 这是新增的锁死回归,违反「不能倒退」。防复制改由机会式降级复核(服务端 # 权威裁决)承担:仅当云端确认 code 无效时才降级;云端不可达/409 一律不锁死。 # AIPC-DATADIR-FIX-RECOVER:本目录没有有效授权时,横向回收一次 # (例如文件被清、或历史 APPDATA 缺失时期授权落在别处)。 if int(d.get("tier", FREE_TIER) or FREE_TIER) < STANDARD_TIER: _rec = _recover_license_from_candidates() if _rec is not None: return _rec # AIPC-LIC-HWM:横向回收也找不到,用服务端确认过的水位复位; # 仍拿不到才判未授权(此时确实是"本机从未成功激活过")。 _hwm = _hwm_restore() if _hwm is not None: return _hwm return d except Exception: # AIPC-DATADIR-FIX-RECOVER:读不到就尝试横向回收,回收不到才判未授权。 _rec = _recover_license_from_candidates() if _rec is not None: return _rec # AIPC-LIC-HWM:最后一道兜底,绝不因"文件读不到"把付费用户判成未付费。 _hwm = _hwm_restore() if _hwm is not None: return _hwm return default def write_license(obj): """把授权结果写入用户数据目录。返回是否成功。 v1.7.40(审查修复·健壮性):改为【原子写】——先写同目录临时文件再 os.replace, 避免后台降级复核线程在写文件时,主线程 read_license 读到截断/半写 JSON 造成瞬时 tier=0 抖动(read_license 遇异常会返回未授权,虽自愈,但应杜绝这种瞬时闪断)。 AIPC-WRITE-NEVER-FAIL(v1.7.79):原实现「一次失败即返回 False」。这是"付了钱、 本机激活失败"里最不该出现的一条路径 —— 云端已经 200 确认了码和等级,只因本机一次 os.replace 撞上杀软/索引器瞬时占用(WinError 5),就把这次授权判成失败。治法: ① 重试(0 / 0.12 / 0.35 / 0.8 秒)——覆盖瞬时占用; ② 主目录写不进就换候选目录,先把授权落盘保住可用性; ③ 每次写完读回校验,确认 tier 真的落盘。 只要本机还有一个可写目录,写盘就不该成为失败源。 """ obj = obj if isinstance(obj, dict) else {} _dirs = _write_dirs() for _delay in (0.0, 0.12, 0.35, 0.8): if _delay: time.sleep(_delay) for _d in _dirs: if _write_json_atomic(os.path.join(_d, "license.json"), obj): # 落盘成功的目录就是权威目录:把进程内缓存指过去, # 避免"写到了 B 目录、却仍去读 A 目录"的分裂脑。 try: _DATA_DIR_CACHE[0] = _d except Exception: pass return True return False # --------------------------------------------------------------------------- # 机会式降级复核(v1.7.40 · 审查修复 P0-① 之二:堵死「本地改 license.json 抬级」) # # 原理:license.json 里存的是纯文本 tier,本机用户可直接改成 2 免费解锁高级版。 # 客户端无法在本地验证 tier 真伪(短码是服务端签发的,私钥不在客户端),唯一 # 权威来源是云端台账。做法:拿存储的短码去 /v1/activate 复核权威 tier,仅当 # 云端权威值【更低】时才降级本地文件;云端不可达 / 报错一律保持现状。 # # 安全护栏(保证"不影响支付/激活正常运行、永不锁死"): # - 非阻塞:在后台守护线程执行,绝不阻塞 current_tier / mode_allowed 热路径。 # - 只降不升:永不用云端值抬高本地等级。 # - 永不锁死:云端不可达 / 报错 / 无码 → 直接返回,本地值原样保留。 # - 结果缓存:同进程内复核一次后 6 小时有效,不重复打网络。 # - 合法用户零影响:合法短码在服务端 DB 里的 tier 恒定,云端确认结果必然 ≥ # 本地,不触发任何降级;只有被抬级的伪造文件才会被降回权威值。 # --------------------------------------------------------------------------- _reval_lock = threading.Lock() _reval_state = {"confirmed_tier": None, "ts": 0.0, "running": False} _REVAL_TTL = 6 * 3600 # 复核结果缓存 6 小时 def _get_stored_code(): """读取 license.json 里存的短码;无 / 损坏返回 ""。""" try: p = get_license_path() if not os.path.isfile(p): return "" with open(p, "r", encoding="utf-8") as f: d = json.load(f) return (d.get("code") or "").strip() if isinstance(d, dict) else "" except Exception: return "" def _revalidate_license_once(): """后台线程体:复核存储短码的权威 tier,仅当更低时降级本地文件。""" global _reval_state try: code = _get_stored_code() if not code: return hwid = get_hwid() resp, _err = _do_post_activate(AUTH_URL, code, hwid, timeout=6) if not resp.get("ok"): # 云端不可达 / 无效 → 保持现状,绝不锁死 return server_tier = int(resp.get("tier", 0) or 0) lic = read_license() cur = int(lic.get("tier", FREE_TIER) or FREE_TIER) if server_tier < cur: # 只降不升:云端权威更低 → 降级本地文件(堵住抬级篡改) lic["tier"] = server_tier lic["active"] = server_tier >= STANDARD_TIER write_license(lic) with _reval_lock: _reval_state["confirmed_tier"] = server_tier _reval_state["ts"] = time.time() # AIPC-LIC-HWM:权威值同步进水位(可升可降)。退款/吊销时这里会一起降到 0, # 所以水位不构成绕过吊销的后门。 _write_hwm(server_tier, code, hwid) except Exception: pass def _maybe_revalidate_async(): """非阻塞触发复核:同进程只跑一次,结果缓存 _REVAL_TTL;立即返回不等待。""" global _reval_state with _reval_lock: if _reval_state["running"]: return if _reval_state["confirmed_tier"] is not None and \ (time.time() - _reval_state["ts"]) < _REVAL_TTL: return _reval_state["running"] = True def _worker(): try: _revalidate_license_once() finally: with _reval_lock: _reval_state["running"] = False try: threading.Thread(target=_worker, daemon=True).start() except Exception: with _reval_lock: _reval_state["running"] = False # --------------------------------------------------------------------------- # 本地授权服务自动兜底(预上线测试 / 人工发码阶段使用) # 当默认授权地址不可达或云端没有该码时,若 skill 目录内自带 ledger.db, # 自动在后台起一个本地 auth_server,让用户"粘贴即授权",无需手动起服务。 # --------------------------------------------------------------------------- _local_auth_lock = threading.Lock() _local_auth_info = {"process": None, "url": None, "started": False, "stage": None} _auth_cleanup_registered = False def _find_free_port(): """找一个系统空闲端口。""" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return port def _wait_for_server(url, timeout=5.0): """轮询等待本地服务就绪。""" deadline = time.time() + timeout while time.time() < deadline: try: req = urllib.request.Request(url + "/health", method="GET") with urllib.request.urlopen(req, timeout=0.8) as r: if r.status == 200: return True except Exception: pass time.sleep(0.1) return False def ensure_local_auth_server(): """确保本地授权服务已启动。返回本地 URL 或 None。 【卸载健壮性 · 历史反复出现的根因】 旧实现直接从技能目录(_HERE)拉起 auth_server.py、并让台账 ledger.db 也落在技能 目录内。该后台进程持有的文件句柄会锁住技能目录,导致 WorkBuddy 卸载技能时删除 失败(WinError 32 "文件正在使用")——这是"安装后经常卸载失败"的元凶。 修复:把 auth_server.py 及其依赖(license.py / signing.py)和台账 ledger.db 整体 复制到【技能目录之外】的临时目录(%APPDATA%/AI_PC_Butler/auth_srv_/),再从 临时副本拉起,进程全程不触碰技能目录 → 技能目录随时可卸载。 """ with _local_auth_lock: if _local_auth_info["started"] and _local_auth_info["url"]: proc = _local_auth_info["process"] if proc and proc.poll() is None: return _local_auth_info["url"] _local_auth_info["started"] = False _local_auth_info["url"] = None _local_auth_info["process"] = None ledger_path = os.path.join(_HERE, "ledger.db") if not os.path.isfile(ledger_path): return None # auth_server.py 在模块加载时 `from license import ...` 且 `import signing`, # 必须把 license.py / signing.py 一并复制到临时目录,否则子进程 import 失败。 deps = { "auth_server.py": os.path.join(_HERE, "auth_server.py"), "license.py": os.path.join(_HERE, "license.py"), "signing.py": os.path.join(_HERE, "signing.py"), } if not all(os.path.isfile(p) for p in deps.values()): return None # AIPC-DATADIR-FIX-LICSTAGE (2026-09-10 P0):暂存目录统一到数据目录, # 不再用裸 expanduser("~") 兜底,避免 APPDATA 缺失时状态分裂到另一处。 stage = os.path.join(get_data_dir(), "auth_srv_" + str(int(time.time() * 1000))) try: os.makedirs(stage, exist_ok=True) except Exception: return None # 复制依赖 + 台账到临时目录(容错,任何一步失败即放弃兜底) ok = True for name, src in deps.items(): try: shutil.copy2(src, os.path.join(stage, name)) except Exception: ok = False break ledger_copy = os.path.join(stage, "ledger.db") try: shutil.copy2(ledger_path, ledger_copy) except Exception: ok = False if not ok: try: shutil.rmtree(stage, ignore_errors=True) except Exception: pass return None port = _find_free_port() py_exe = sys.executable args = [ py_exe, os.path.join(stage, "auth_server.py"), "--port", str(port), "--host", "127.0.0.1", "--db", ledger_copy, ] try: # 隐藏窗口,后台运行;Windows 管道默认 GBK,加 PYTHONIOENCODING 避免中文/特殊符号崩溃 env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" # 关键:把 __pycache__ 也放到技能目录外,避免 .pyc 锁住原目录 env["PYTHONPYCACHEPREFIX"] = stage startupinfo = None if sys.platform == "win32": startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = 0 # SW_HIDE proc = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, startupinfo=startupinfo, cwd=stage, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0) local_url = "http://127.0.0.1:%d" % port if _wait_for_server(local_url, timeout=5.0): _local_auth_info["process"] = proc _local_auth_info["url"] = local_url _local_auth_info["started"] = True _local_auth_info["stage"] = stage # 父进程退出时终止子进程并清理临时目录,避免孤儿进程 / 垃圾残留 global _auth_cleanup_registered if not _auth_cleanup_registered: atexit.register(_cleanup_local_auth_server) _auth_cleanup_registered = True return local_url else: proc.terminate() try: shutil.rmtree(stage, ignore_errors=True) except Exception: pass return None except Exception: return None def _cleanup_local_auth_server(): """父进程退出时终止本地授权子进程并清理临时目录(仅清 auth_srv_ 临时副本)。 临时副本位于 %APPDATA%/AI_PC_Butler,删除它受 WorkBuddy 托管 Python 的 安全删除 shim 约束(可能抛 SystemExit / 走回收站)。本处理器必须【绝不向上抛异常】, 否则会污染父进程(server.py)的正常退出。故 rmtree 用 BaseException 兜底吞掉。 """ with _local_auth_lock: info = _local_auth_info proc = info.get("process") stage = info.get("stage") try: if proc and proc.poll() is None: proc.terminate() except Exception: pass if stage and os.path.isdir(stage): try: shutil.rmtree(stage, ignore_errors=True) except BaseException: # 吞掉安全删除 shim 的 SystemExit 等,避免污染父进程退出 pass info["process"] = None info["url"] = None info["started"] = False info["stage"] = None def _do_post_activate(url, code, hwid, timeout=10): """向指定授权地址发 POST /v1/activate。返回 (resp_dict, http_error_obj_or_None)。""" body = json.dumps({"token": code.strip(), "code": code.strip(), "hwid": hwid}).encode("utf-8") req = urllib.request.Request( url + "/v1/activate", data=body, headers={"Content-Type": "application/json"}, method="POST") try: # 系统默认信任链 + 追加内嵌的授权服务 CA 证书(证书固定 / cert pinning):连接经 TLS 校验,仅信任我们自己的授权服务器。 ctx = ssl.create_default_context() if AUTH_CA_PEM: ctx.load_verify_locations(cadata=AUTH_CA_PEM) with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r: return json.loads(r.read().decode("utf-8")), None except urllib.error.HTTPError as e: try: return json.loads(e.read().decode("utf-8")), e except Exception: return {"ok": False, "error": "授权服务返回错误 %d" % e.code}, e except Exception as e: return {"ok": False, "tier": 0, "error": "无法连接授权服务,请检查网络后重试", "network_error": True}, e def activate_online(code, auth_url=None, version=LOCAL_VERSION): """走在线授权台账(一码一机)。 返回 (result_dict, network_error:bool)。 result_dict 结构同 verify_code + saved。 失败原因由服务端给出(无效 / 已绑定他机 / 已作废 / 过期)。 2026-07-27 新增:若默认云端不可达或云端没有该码,且 skill 目录自带 ledger.db, 则自动在后台启动本地 auth_server 重试,让用户零配置粘贴授权。 """ url = (auth_url or AUTH_URL) if not url: return {"ok": False, "tier": 0, "error": "未配置在线授权地址"}, False if not _HAVE_URLLIB: return {"ok": False, "tier": 0, "error": "运行环境缺少网络库"}, False hwid = get_hwid() resp, _err = _do_post_activate(url, code, hwid) network_error = resp.get("network_error", False) # 云端失败时,若本地有台账,自动起本地服务兜底 if not resp.get("ok"): ledger_path = os.path.join(_HERE, "ledger.db") if os.path.isfile(ledger_path) and (network_error or resp.get("error", "").startswith("授权码无效")): local_url = ensure_local_auth_server() if local_url: resp2, _err2 = _do_post_activate(local_url, code, hwid, timeout=8) if resp2.get("ok"): resp = resp2 network_error = False if resp.get("ok"): lic = { "tier": int(resp.get("tier", 0)), "active": True, "code": code.strip(), "hwid": hwid, "exp": 0, "issued_at": int(time.time()), "version": version, "online": True, } # AIPC-INSTANT-UNLOCK(v1.7.79):把服务端验签过的签名一并存下来 —— # 之后本机可离线自证"这张码是真的、等级是 N",解锁不再依赖任何网络往返。 if resp.get("signature"): lic["sig"] = resp.get("signature") lic["src"] = "cloud-activate" saved = write_license(lic) resp["saved"] = saved # AIPC-LIC-HWM:仅在服务端确认成功时抬高水位(写盘失败也要写水位 —— # 水位是多目录落盘的,它正是 read_license() 的兜底) _write_hwm(int(lic.get("tier", 0) or 0), lic.get("code", ""), hwid) if not saved: # AIPC-WRITE-NEVER-FAIL(v1.7.79):原来这里把 ok 翻回 False,是本末倒置 —— # 云端已经 200 确认"这张码就是你的、等级是 N",事实是【已授权】,只是本机 # 落盘没成功。翻成 False 会让上层把订单打成 paid_unactivated(付了钱本机 # 激活失败),把一次磁盘抖动说成"付款失败"。改为:ok 保持 True、如实带出 # saved=False;等级已进水位,read_license() 的回收链会在任何可写目录里 # 把授权捞回来。 resp["pending_write"] = True resp["error"] = "" return resp, False resp.setdefault("saved", False) return resp, False def unlock_local(code, tier=None, signature=None, hwid=None, bound_hwid=None, version=LOCAL_VERSION, tier_candidates=()): """AIPC-INSTANT-UNLOCK(v1.7.79):用【服务端已签发的码 + 签名】在本机直接解锁。 【为什么要有它】 付费解锁的唯一真相是两件事:① 这张码是真的(服务端私钥签过 token|tier) ② 这张码绑定的是本机。这两件事**都能离线判定** —— ① 靠内嵌公钥验签 (verify_short_signature),② 靠 hwid 主干比对(_mg_compatible)。 因此"付款成功 → 解锁"不该依赖任何一次网络往返:只要云端把 tier 和 signature 一起给到客户端,本机就能零延迟、零失败地解开。网络抖动 / 限流 / 服务重启 再也不会把付费用户卡在"付了钱没解锁"。 【安全性:为什么本地解锁不会变成后门】 本函数【必须有签名且验签通过】才会解锁 —— 没有签名一律拒绝(交回云端确认)。 因此"本地改 license.json 抬级"这条老路依然走不通:抬高 tier 会让签名对不上 (签名覆盖 token|tier),验签必失败。tier_candidates 只是"在几个候选档位里 找出那个签名能对上的真实档位",不是放行条件。 【真拒绝只有两种】① 签名验不过(假码 / 档案被篡改)② 码绑定了别的机器。 其余任何情况都不判失败(宁可放行,也不锁死付费用户)。 返回 {ok, tier, error, saved, local:True}。 """ code = (code or "").strip() out = {"ok": False, "tier": FREE_TIER, "error": "", "saved": False, "local": True, "code": code} if not code: out["error"] = "授权码不能为空" return out if not signature: # 没有签名就无法在本机自证真伪 —— 不猜、不放行,交回云端激活确认。 out["error"] = "缺少服务端签名,需云端确认" return out # 候选档位:服务端给的 tier 优先,其次调用方给的候选,最后是已知档位。 _cands = [] for _t in list([tier] + list(tier_candidates or [])) + [PRO_TIER, STANDARD_TIER]: try: _ti = int(_t or 0) except Exception: continue if _ti >= STANDARD_TIER and _ti not in _cands: _cands.append(_ti) real_tier = None for _ti in _cands: try: if verify_short_signature(code, _ti, signature): real_tier = _ti break except Exception: continue if real_tier is None: out["error"] = "授权码签名无效" return out # 归属校验(防盗用):只在云端明确给出绑定机器时才比;取不到就放行(不锁死付费用户) hw = hwid or get_hwid() if bound_hwid and not _mg_compatible(bound_hwid, hw): out["error"] = "这张授权码绑定了其他电脑,无法在本机使用。" return out lic = {"tier": real_tier, "active": True, "code": code, "hwid": hw, "exp": 0, "issued_at": int(time.time()), "version": version, "online": True, "sig": signature, "src": "local-unlock"} saved = write_license(lic) out["saved"] = saved out["tier"] = real_tier if saved: _write_hwm(real_tier, code, hw) else: # 主目录写不进也别判失败:水位走多目录落盘,read_license() 会把它捞回来。 _write_hwm(real_tier, code, hw) out["ok"] = True return out def activate(code, version=LOCAL_VERSION): """授权入口:强制短码在线授权台账(一码一机,必须联网)。 短码格式:WX-XXXX-XXXX(4 段大写字母数字,不含点号)。 长码(含点号)被明确拒绝:产品要求 WorkBuddy 必须联网运行,授权必须走 在线台账实现一机一码,禁止本地离线放行。 返回 dict:{ok, tier, error, saved, network_error, ...} """ code = (code or "").strip() if not code: return {"ok": False, "tier": 0, "error": "授权码不能为空", "saved": False} # 强制短码:含点号的是开发/测试用长码,正式产品不接受 if "." in code: return {"ok": False, "tier": 0, "error": "请使用扫码工具/闲鱼收到的短授权码(如 WX-XXXX-XXXX)", "saved": False} if not AUTH_URL: return {"ok": False, "tier": 0, "error": "未配置在线授权地址,无法授权", "saved": False} res, _neterr = activate_online(code, version=version) return res def current_tier(): """当前有效等级。未授权返回 0。 v1.7.40(审查修复·P0-①):机会式降级复核。非阻塞触发后台复核(不等待、不锁死), 若已有云端确认结果则取 min(本地, 云端)——只降不升;云端不可达则保持本地值。 """ _maybe_revalidate_async() t = int(read_license().get("tier", FREE_TIER) or FREE_TIER) with _reval_lock: confirmed = _reval_state["confirmed_tier"] if confirmed is not None: t = min(t, confirmed) return t def can(feature): """当前许可是否覆盖某 feature(等级 >= minTier)。""" need = FEATURE_MIN_TIER.get(feature, STANDARD_TIER) return current_tier() >= need def mode_allowed(mode): """server.py /action 的 mode 是否允许(按 feature 门控)。""" feat = MODE_FEATURE.get(mode) if not feat: return False, FREE_TIER, "未知操作" need = FEATURE_MIN_TIER[feat] t = current_tier() if t >= need: return True, t, "" return False, t, "需要 %s 授权码才能使用此功能" % TIER_NAME.get(need, "更高") # =========================================================================== # 版本信标(静默;失败不影响任何功能) # =========================================================================== def parse_version(v): """语义化版本 -> 整数(仅用于 <= 比较)。v1.6 / 1.6.2 都解析。""" if not v: return 0 v = str(v).lstrip("vV") nums = re.findall(r"\d+", v) out = 0 for i, n in enumerate((nums + ["0", "0", "0"])[:3]): out += int(n) * (1000 ** (2 - i)) return out def fetch_latest_version(timeout=3.0): """拉取远端最新版本号;失败 / 超时返回 None(调用方静默忽略)。 默认关闭外网信标(契合「报告零网络上报」定位)。仅当显式设置环境变量 AIPC_CHECK_UPDATE=1 时才发起请求,避免任何静默外联。 """ if os.environ.get("AIPC_CHECK_UPDATE") != "1": return None try: import urllib.request req = urllib.request.Request( VERSION_BEACON_URL, headers={"User-Agent": "AI-PC-Butler-Beacon/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as r: data = json.loads(r.read().decode("utf-8")) return str(data.get("version", "")) except Exception: return None if __name__ == "__main__": # 命令行快速自检 lic = read_license() print("当前许可:tier=%d (%s) active=%s" % ( lic.get("tier", 0), TIER_NAME.get(lic.get("tier", 0), "?"), lic.get("active", False))) print("本机 hwid:", get_hwid())