""" KeyVault License SDK for Python 单文件:HMAC 签名只用标准库,响应验签(Ed25519)依赖 `cryptography`: pip install cryptography 用法: from keyvault import KeyVaultClient, get_device_id kv = KeyVaultClient(base_url, app_id, app_secret, public_key) device_id = get_device_id(app_id) kv.activate(license_key, device_id) state = kv.check(license_key, device_id) # 联网失败时自动使用离线缓存 协议说明见商城的 /developers 页面。支持 Python 3.8+。 """ from __future__ import annotations import base64 import hashlib import hmac import json import os import platform import re import secrets import socket import subprocess import sys import tempfile import time import urllib.error import urllib.request from dataclasses import dataclass, field from typing import Any, Dict, Optional, Union from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.hazmat.primitives.serialization import load_der_public_key, load_pem_public_key SDK_VERSION = "1.0.0" # 收到这些「已签名」的错误时清除本地缓存 REVOKING_CODES = { "LICENSE_NOT_FOUND", "LICENSE_SUSPENDED", "LICENSE_REVOKED", "LICENSE_EXPIRED", "DEVICE_NOT_ACTIVATED", } class KeyVaultError(Exception): """服务端错误码(见文档「错误码」),以及 SDK 自身的: NETWORK_ERROR 联网失败 · BAD_RESPONSE 响应格式不对 · TAMPERED_RESPONSE 响应签名或绑定校验失败 """ def __init__(self, code: str, message: str, status: int = 0, signed: bool = False): super().__init__(f"{code}: {message}") self.code = code self.message = message self.status = status # 错误响应是否带有效签名(可信地来自服务端) self.signed = signed @property def transient(self) -> bool: return self.code in ("NETWORK_ERROR", "BAD_RESPONSE", "RATE_LIMITED") or self.status >= 500 @dataclass class LicenseState: """服务端签名覆盖的数据,时间字段均为 Unix 秒""" action: str app_id: str device_id: str key_fingerprint: str status: str activated: bool activated_at: Optional[int] expires_at: Optional[int] valid_until: Optional[int] max_devices: int active_devices: int signed_at: int # True 表示这次结果来自本地缓存(离线宽限期内) offline: bool = False raw: Dict[str, Any] = field(default_factory=dict, repr=False) @classmethod def from_data(cls, data: Dict[str, Any], signed_at: int, offline: bool) -> "LicenseState": return cls( action=data["action"], app_id=data["appId"], device_id=data["deviceId"], key_fingerprint=data["keyFingerprint"], status=data["status"], activated=data["activated"], activated_at=data["activatedAt"], expires_at=data["expiresAt"], valid_until=data["validUntil"], max_devices=data["maxDevices"], active_devices=data["activeDevices"], signed_at=signed_at, offline=offline, raw=data, ) def canonical_json(value: Any) -> str: """规范化 JSON:键按字典序、无空白,与服务端签名时的序列化完全一致""" return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) def fingerprint(license_key: str) -> str: """SHA-256(授权码) 的 hex,与服务端 data.keyFingerprint 对应""" return hashlib.sha256(license_key.strip().encode("utf-8")).hexdigest() def load_public_key(value: str) -> Ed25519PublicKey: """接受 PEM、SPKI DER 的 base64,或 32 字节原始公钥的 base64""" text = value.strip() if text.startswith("-----BEGIN"): key = load_pem_public_key(text.encode()) else: raw = base64.b64decode(text) key = Ed25519PublicKey.from_public_bytes(raw) if len(raw) == 32 else load_der_public_key(raw) if not isinstance(key, Ed25519PublicKey): raise ValueError("public_key 必须是 Ed25519 公钥") return key class KeyVaultClient: def __init__( self, base_url: str, app_id: str, app_secret: str, public_key: str, cache_file: Union[str, bool, None] = None, timeout: float = 10.0, clock_tolerance: int = 300, ): """ :param base_url: 商城地址,如 https://shop.example.com :param public_key: Ed25519 公钥(发布客户端时内置,不要运行时下载) :param cache_file: 离线缓存文件;默认 ~/.keyvault/.json,传 False 关闭 """ self.base_url = base_url.rstrip("/") self.app_id = app_id self.app_secret = app_secret.encode("utf-8") self.public_key = load_public_key(public_key) if cache_file is False: self.cache_file: Optional[str] = None elif cache_file: self.cache_file = str(cache_file) else: self.cache_file = os.path.join(os.path.expanduser("~"), ".keyvault", f"{app_id}.json") self.timeout = timeout self.tolerance = clock_tolerance # ---------- 公开方法 ---------- def activate( self, license_key: str, device_id: str, device_name: Optional[str] = None, platform_name: Optional[str] = None, app_version: Optional[str] = None, ) -> LicenseState: """在当前设备上激活。同一设备重复调用是幂等的""" payload = {"licenseKey": license_key, "deviceId": device_id} if device_name: payload["deviceName"] = device_name if platform_name: payload["platform"] = platform_name if app_version: payload["appVersion"] = app_version return self._call("activate", payload, license_key, device_id) def verify(self, license_key: str, device_id: str, app_version: Optional[str] = None) -> LicenseState: """联网校验,同时作为心跳。不会回退到离线缓存,需要回退请用 check()""" payload = {"licenseKey": license_key, "deviceId": device_id} if app_version: payload["appVersion"] = app_version return self._call("verify", payload, license_key, device_id) def deactivate(self, license_key: str, device_id: str) -> LicenseState: """解绑当前设备,成功后清除本地缓存""" state = self._call("deactivate", {"licenseKey": license_key, "deviceId": device_id}, license_key, device_id) self.clear_cache() return state def check(self, license_key: str, device_id: str, app_version: Optional[str] = None) -> LicenseState: """推荐在启动和定时心跳时调用:先联网校验;网络不可用、服务端 5xx 或被限流时, 回退到上一次的签名缓存(valid_until 之前有效)。授权被吊销等明确结论会直接抛错。""" try: return self.verify(license_key, device_id, app_version) except KeyVaultError as err: if err.transient: cached = self.load_offline(license_key, device_id) if cached: return cached raise def load_offline(self, license_key: str, device_id: str, now: Optional[float] = None) -> Optional[LicenseState]: """读取并校验离线缓存;无效(签名不符、换了设备或授权码、已过宽限期、时钟回拨)时返回 None""" if not self.cache_file: return None try: with open(self.cache_file, "r", encoding="utf-8") as f: entry = json.load(f) data, signed_at, nonce, signature = entry["data"], entry["signedAt"], entry["nonce"], entry["signature"] except (OSError, ValueError, KeyError, TypeError): return None if entry.get("version") != 1: return None if not self._verify({"data": data, "nonce": nonce, "signedAt": signed_at}, signature): return None now_sec = int(now if now is not None else time.time()) valid = ( data.get("appId") == self.app_id and data.get("deviceId") == device_id and data.get("keyFingerprint") == fingerprint(license_key) and data.get("activated") is True and data.get("status") == "active" and isinstance(data.get("validUntil"), int) and now_sec <= data["validUntil"] and now_sec >= signed_at - self.tolerance ) return LicenseState.from_data(data, signed_at, offline=True) if valid else None def clear_cache(self) -> None: if self.cache_file: try: os.remove(self.cache_file) except FileNotFoundError: pass # ---------- 内部实现 ---------- def _call(self, action: str, payload: Dict[str, Any], license_key: str, device_id: str) -> LicenseState: body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") timestamp = str(int(time.time())) nonce = secrets.token_hex(16) message = timestamp.encode() + b"\n" + nonce.encode() + b"\n" + body signature = hmac.new(self.app_secret, message, hashlib.sha256).hexdigest() req = urllib.request.Request( f"{self.base_url}/api/v1/licenses/{action}", data=body, method="POST", headers={ "Content-Type": "application/json", "User-Agent": f"keyvault-python/{SDK_VERSION}", "X-KV-App-Id": self.app_id, "X-KV-Timestamp": timestamp, "X-KV-Nonce": nonce, "X-KV-Signature": signature, }, ) try: with urllib.request.urlopen(req, timeout=self.timeout) as res: status, text = res.status, res.read() except urllib.error.HTTPError as err: status, text = err.code, err.read() except (urllib.error.URLError, socket.timeout, ConnectionError, OSError) as err: raise KeyVaultError("NETWORK_ERROR", f"无法连接授权服务器:{err}") from None try: res_json = json.loads(text) if not isinstance(res_json, dict): raise ValueError except ValueError: raise KeyVaultError("BAD_RESPONSE", f"授权服务器返回了无法解析的内容(HTTP {status})", status) from None if res_json.get("ok") is not True: error = res_json.get("error") or {"code": "BAD_RESPONSE", "message": "响应缺少 error 字段"} signed_at = res_json.get("signedAt") signed = ( isinstance(signed_at, int) and isinstance(res_json.get("signature"), str) and self._verify({"error": error, "nonce": nonce, "signedAt": signed_at}, res_json["signature"]) ) if signed and error.get("code") in REVOKING_CODES: self.clear_cache() raise KeyVaultError(str(error.get("code")), str(error.get("message")), status, signed) data, signed_at, sig = res_json.get("data"), res_json.get("signedAt"), res_json.get("signature") if not isinstance(data, dict) or not isinstance(signed_at, int) or not isinstance(sig, str): raise KeyVaultError("BAD_RESPONSE", "响应缺少 data / signedAt / signature", status) if not self._verify({"data": data, "nonce": nonce, "signedAt": signed_at}, sig): raise KeyVaultError("TAMPERED_RESPONSE", "响应签名校验失败", status) if ( data.get("appId") != self.app_id or data.get("deviceId") != device_id or data.get("keyFingerprint") != fingerprint(license_key) ): raise KeyVaultError("TAMPERED_RESPONSE", "响应与本次请求不匹配", status) if action != "deactivate": self._save_cache({"version": 1, "data": data, "signedAt": signed_at, "nonce": nonce, "signature": sig}) return LicenseState.from_data(data, signed_at, offline=False) def _verify(self, signed: Dict[str, Any], signature: str) -> bool: try: self.public_key.verify(base64.b64decode(signature), canonical_json(signed).encode("utf-8")) return True except (InvalidSignature, ValueError, TypeError): return False def _save_cache(self, entry: Dict[str, Any]) -> None: if not self.cache_file: return try: directory = os.path.dirname(self.cache_file) or "." os.makedirs(directory, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=directory, prefix=".keyvault-") with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(entry, f, ensure_ascii=False) os.chmod(tmp, 0o600) os.replace(tmp, self.cache_file) except OSError: pass # 缓存写入失败不影响在线结果 def _read_machine_id() -> Optional[str]: try: if sys.platform == "darwin": out = subprocess.run( ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], capture_output=True, text=True, timeout=3 ).stdout m = re.search(r'"IOPlatformUUID" = "([^"]+)"', out) return m.group(1) if m else None if sys.platform == "win32": import winreg # type: ignore[import-not-found] with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography") as k: return str(winreg.QueryValueEx(k, "MachineGuid")[0]) for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"): try: with open(path, "r", encoding="utf-8") as f: value = f.read().strip() if value: return value except OSError: continue except Exception: # noqa: BLE001 读取失败时退回主机名 pass return None def get_device_id(salt: str = "") -> str: """生成稳定的设备指纹:系统机器 ID 与 salt(建议传 app_id)一起做 SHA-256, 与 Node / Go SDK 的算法一致,同一台机器在三种 SDK 下得到相同的 deviceId。""" raw = _read_machine_id() or f"{socket.gethostname()}|{_node_platform()}|{_node_arch()}" return hashlib.sha256(f"{salt}:{raw}".encode("utf-8")).hexdigest() def _node_platform() -> str: return {"darwin": "darwin", "win32": "win32"}.get(sys.platform, "linux" if sys.platform.startswith("linux") else sys.platform) def _node_arch() -> str: m = platform.machine().lower() return {"x86_64": "x64", "amd64": "x64", "aarch64": "arm64", "arm64": "arm64"}.get(m, m)