#!/usr/bin/env python3 # -*- coding: utf-8 -*- """license_check.py — 授权凭证(license)的**离线校验**(纯 Python 标准库,零第三方依赖) 为什么要有它:table-clean(乱表变干净)以「Pay Skill」形态售卖时,平台的硬要求是 「源文件依赖后端服务,脱离后端无法独立使用」。落实方式 = 执行清洗前必须持有一份**我们签发的授权**。 授权由端点签发(`产品/_gate-skills-402/`),本文件负责**在买家机器上离线验签**。 设计取舍(诚实边界,别对外夸大): - **这是付费门,不是版权保护。** 离线可验 → 防"没付钱就用"; 但不防"付一次、改本地计数重复用"。清洗必须在买家机器上跑(要读买家的表格文件), 架构上与「服务端执行」不兼容。 - **验签规则只有一处定义**(端点侧 `src/_license.js` 的 `signStringOf`): 键名升序、跳过空值、`k=v` 用 `&` 连接;签名 = RSA2(SHA-256 + PKCS#1 v1.5)。 - **公钥随工具分发是设计**,不是泄漏:公钥只能验签,不能签发。 用法: python license_check.py check [--fingerprint ] # 退出码 0=通过,非 0=拒绝 python license_check.py info python license_check.py fingerprint # 本机指纹(拿它去换授权) """ from __future__ import annotations import base64 import hashlib import json import os import sys from datetime import datetime, timezone HERE = os.path.dirname(os.path.abspath(__file__)) PUBKEY_FILE = os.path.join(HERE, "..", "assets", "app-public-key.b64") PRODUCT = "humanizer-zh" SCHEMA = "pay-skill-license/v1" # 退出码:0 通过 / 2 文件或公钥问题 / 3 格式非法 / 4 签名不通过 / 5 已过期 / 6 产品不匹配 / 7 指纹不匹配 EXIT_OK, EXIT_IO, EXIT_FORMAT, EXIT_SIG, EXIT_EXPIRED, EXIT_PRODUCT, EXIT_FP = 0, 2, 3, 4, 5, 6, 7 # ---------- 最小 DER 解析(只够读 SubjectPublicKeyInfo 里的 RSA n/e) ---------- def _der_read(buf, i): """读一个 TLV,返回 (tag, value_bytes, next_index)。""" tag = buf[i] i += 1 ln = buf[i] i += 1 if ln & 0x80: n = ln & 0x7F ln = int.from_bytes(buf[i:i + n], "big") i += n return tag, buf[i:i + ln], i + ln def _der_seq(buf): """把 SEQUENCE 的内容拆成子 TLV 列表。""" out, i = [], 0 while i < len(buf): tag, val, i = _der_read(buf, i) out.append((tag, val)) return out # 公钥:**由 工具/build_table_clean_skill.py 注入**(腾讯 SkillHub 拒收 `.b64` 文件类型, # 实测报 `不允许的文件类型: assets/app-public-key.b64`)。内嵌是安全的: # 公钥只能验签、不能签发,它本来就该随工具分发。 PUBKEY_B64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlwDhsJpfT65bKrmoVOZB0WmK4d6XXpgd/41f0oi1mI2hctvNxHTIXumzo9BHPuqGbrZVCQHJqFL4knWhuXol0iSrrZwZEwBW960IC9UXmLQIzb2neme2n5KKW/+CtZTev/Ej3yQ4WwTFgsa6RG1NHX9VwcI3831KYVDydegHvYEc6DJVbEryXv2/35gXJNRtm547HuyGcBEAt8XOC2edR2mYPAULDKFav3UcI5OHnkOru/Vlv9fntW4RcWtAy/UmduHteVWK2uSmkrZomA2iafSq15Wb2Y4TNPNkaCHXuDASyC8p6BqQmrfQfGfs2oLuf5OLc0VileIdeLVPqLSGYwIDAQAB" def load_public_key(path: str | None = None): """读应用公钥(base64 SPKI)→ (n, e)。**优先用内嵌常量**,回退读文件。""" if PUBKEY_B64.strip(): b64 = "".join(PUBKEY_B64.split()) else: with open(path or PUBKEY_FILE, "r", encoding="utf-8") as f: b64 = "".join(f.read().split()) der = base64.b64decode(b64) tag, spki, _ = _der_read(der, 0) if tag != 0x30: raise ValueError("不是合法的 SubjectPublicKeyInfo(外层不是 SEQUENCE)") parts = _der_seq(spki) # [AlgorithmIdentifier, BIT STRING] if len(parts) < 2 or parts[1][0] != 0x03: raise ValueError("SPKI 结构异常(缺 BIT STRING)") bitstr = parts[1][1] inner = bitstr[1:] if bitstr and bitstr[0] == 0 else bitstr # 去掉 unused-bits 字节 tag, rsakey, _ = _der_read(inner, 0) if tag != 0x30: raise ValueError("RSAPublicKey 不是 SEQUENCE") fields = _der_seq(rsakey) n = int.from_bytes(fields[0][1], "big") e = int.from_bytes(fields[1][1], "big") return n, e # ---------- PKCS#1 v1.5 验签 ---------- _SHA256_DIGESTINFO_PREFIX = bytes.fromhex("3031300d060960864801650304020105000420") def rsa2_verify(sign_string: str, signature_b64: str, n: int, e: int) -> bool: """验 RSA2(SHA-256, PKCS#1 v1.5) 签名。任何异常都返回 False(不抛给调用方)。""" try: sig = base64.b64decode(signature_b64, validate=True) except Exception: return False k = (n.bit_length() + 7) // 8 if len(sig) != k: return False m = pow(int.from_bytes(sig, "big"), e, n) # 只做一次模幂,这就是"纯标准库"的全部秘密 em = m.to_bytes(k, "big") # 期望:0x00 || 0x01 || 0xFF…(>=8) || 0x00 || DigestInfo || H if em[0] != 0x00 or em[1] != 0x01: return False try: sep = em.index(b"\x00", 2) except ValueError: return False if sep - 2 < 8 or any(b != 0xFF for b in em[2:sep]): return False digest = hashlib.sha256(sign_string.encode("utf-8")).digest() expected = _SHA256_DIGESTINFO_PREFIX + digest return em[sep + 1:] == expected # ---------- 授权校验 ---------- def sign_string_of(payload: dict) -> str: """**与端点签名侧逐字相同的规则**:键名升序、跳过空值、k=v 用 & 连接。""" return "&".join( f"{k}={payload[k]}" for k in sorted(payload) if payload[k] is not None and payload[k] != "" ) def check(lic: dict, fingerprint: str | None = None, now: datetime | None = None) -> tuple[int, str]: """返回 (退出码, 说明)。0 = 通过。""" if not isinstance(lic, dict): return EXIT_FORMAT, "授权不是 JSON 对象" if lic.get("schema") != SCHEMA: return EXIT_FORMAT, f"schema 不匹配(期望 {SCHEMA},实际 {lic.get('schema')!r})" payload, sig = lic.get("payload"), lic.get("signature") if not isinstance(payload, dict) or not sig: return EXIT_FORMAT, "缺 payload 或 signature" n, e = load_public_key() if not rsa2_verify(sign_string_of(payload), sig, n, e): return EXIT_SIG, "签名不通过(授权被篡改,或不是本服务签发)" if payload.get("product") != PRODUCT: return EXIT_PRODUCT, f"产品不匹配(授权是 {payload.get('product')!r})" now = now or datetime.now(timezone.utc) try: exp = datetime.fromisoformat(str(payload["expires_at"]).replace("Z", "+00:00")) except Exception: return EXIT_FORMAT, f"expires_at 不可解析:{payload.get('expires_at')!r}" if exp <= now: return EXIT_EXPIRED, f"授权已过期({payload['expires_at']})" sub = payload.get("subject") if sub and fingerprint and sub != fingerprint: return EXIT_FP, "机器指纹不匹配(这份授权是给另一台机器的)" return EXIT_OK, "授权有效" def machine_fingerprint() -> str: """本机指纹:**稳定、可复现、不含个人信息**(主机名 + 平台 + 机器标识,取 sha256 前 32 位)。 为什么不让调用方随便编一个字符串:指纹的意义是"这份授权给这台机器", 随手编的字符串会让"绑定"变成摆设(换台机器用同一个串照样过)。 本函数不采集 MAC / 序列号等强标识,也不外传任何原始值——只输出哈希。 """ import platform import uuid raw = f"{platform.node()}|{platform.system()}|{platform.machine()}|{uuid.getnode()}" return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32] def main(argv: list[str]) -> int: if len(argv) >= 1 and argv[0] == "fingerprint": print(machine_fingerprint()) return EXIT_OK if len(argv) < 2 or argv[0] not in ("check", "info"): print(__doc__.strip().splitlines()[-2].strip()) return EXIT_IO cmd, path = argv[0], argv[1] fp = None if "--fingerprint" in argv: fp = argv[argv.index("--fingerprint") + 1] try: with open(path, "r", encoding="utf-8") as f: lic = json.load(f) except Exception as ex: print(f"[拒绝] 读不到或不是合法 JSON:{path}({ex})") return EXIT_IO if cmd == "info": p = lic.get("payload", {}) print(f"产品 : {p.get('product')}") print(f"资源 : {p.get('resource_id')}") print(f"订单号 : {p.get('out_trade_no')}") print(f"签发/到期 : {p.get('issued_at')} → {p.get('expires_at')}") print(f"粒度 : {p.get('grant_mode')}") print(f"绑定指纹 : {p.get('subject') or '(未绑定)'}") code, msg = check(lic, fingerprint=fp) print(f"[{'通过' if code == EXIT_OK else '拒绝'}] {msg}") return code if __name__ == "__main__": sys.exit(main(sys.argv[1:]))