""" 腾讯 TDC 遥测 (collect) 纯算法还原 —— 唯一路径: **按本次会话构建纯离线生成** + 教科书 XTEA。 设计原则: * **纯离线**: 37 个语义名的取值由 `telemetry/values.py` 生成(静态画像 / 会话量 / 每次采集量三层),collect 的明文组装 / 轨迹 / 时间戳 / XTEA 加密 / 编码全部在 Python 内完成。零 Node、零真机明文。 * **密钥也纯 Python**: 从 tdc.js 静态反汇编还原 4 个密钥字(见 `telemetry/key.py`)。 * **必须用本次会话那一份构建**(实测,账本 #58/#59): 同一 aid 下, "按本次构建生成" -> errorCode=0;"拿别处模板回填" -> 恒 12。 所以历史上有过的两条老路(成功样本模板回填 / Node 插桩兜底)已整体删除。 本模块只负责: XTEA 本体 + collect 的四段组装/切分 + 遥测入口。 """ import base64 import itertools import json import logging import re import struct import urllib.parse from typing import Dict, List, Optional, Tuple M32 = 0xFFFFFFFF logger = logging.getLogger("tcaptcha_crypto") # 样本构建的 XTEA 密钥字 (未加盐)。真实样本的等价密钥 (含两个轮加盐) 见 DEFAULT_KEY = [1448563793, 1847730015, 1733184827, 1364613714] DEFAULT_USER_AGENT = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36" ) def encrypt_string(plaintext: str, key: List[int] = DEFAULT_KEY) -> str: """把 ASCII/Latin1 明文用教科书 XTEA 加密后转 Base64 (不足 8 字节用 \\x00 补齐)。 本模块只有一份 xtea_encrypt_block (教科书实现), 此函数即使用它。 """ data = plaintext.encode("latin1") pad_len = (8 - (len(data) % 8)) % 8 data += b"\x00" * pad_len out = bytearray() for i in range(0, len(data), 8): v0, v1 = struct.unpack(" str: """从 tdc.js 中提取会话密钥信封 eks (一个较长的 Base64 字符串)。 注意: tdc.js 每次构建都会重命名全局变量 (见 AGENTS.md 附录 A#6), 因此**不能**写死变量名; 这里匹配任意 `window.<随机名> = ''`, 并取长度最长的候选。 """ cands = re.findall(r"window\.[A-Za-z_$][\w$]*\s*=\s*'([A-Za-z0-9+/=]{100,})'", tdc_js_code) if not cands: return "" return max(cands, key=len) # --------------------------------------------------------------------------- # # 标准 (教科书) XTEA: 32 轮, delta=0x9E3779B9, sum 取 mod 2^32。 # 这是本模块**唯一**的 XTEA 实现; 需要从每份 tdc.js 里拿到的是那 4 个密钥字。 # --------------------------------------------------------------------------- # XTEA_DELTA = 0x9E3779B9 def xtea_encrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]: """教科书 XTEA 加密 (32 轮), 全 32 位无符号运算""" v0 &= M32 v1 &= M32 total = 0 for _ in range(32): v0 = (v0 + (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32 ^ ((total + key[total & 3]) & M32))) & M32 total = (total + XTEA_DELTA) & M32 v1 = (v1 + (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32 ^ ((total + key[(total >> 11) & 3]) & M32))) & M32 return v0, v1 def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]: """教科书 XTEA 解密 (32 轮), 用于校验提取到的密钥""" v0 &= M32 v1 &= M32 total = (XTEA_DELTA * 32) & M32 for _ in range(32): v1 = (v1 - (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32 ^ ((total + key[(total >> 11) & 3]) & M32))) & M32 total = (total - XTEA_DELTA) & M32 v0 = (v0 - (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32 ^ ((total + key[total & 3]) & M32))) & M32 return v0, v1 def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes: """XTEA 加密一段字节;不足 8 字节用 \\x00 补齐(XTEA 块对齐所需)。""" data = data + b"\x00" * ((8 - len(data) % 8) % 8) out = bytearray() for i in range(0, len(data), 8): v0, v1 = struct.unpack(" pad 1)。 # 所以**不能**用 ` {2,}` 去找 pad,那个正则会把 `{"cd":[...,0,0,0, [[1,1,12]]...` 里的 # 单空格 pad 整段漏掉,切段结果直接崩。判据必须用长度模 24 的算术。 _SpaceRun = re.compile(rb" +") def xtea_encrypt_segment(text: str, key: List[int], align: int = SEG_ALIGN, pad: bytes = SEG_PAD) -> bytes: """加密 collect 的一个**数据段**:空格 pad 到 align 字节对齐后 XTEA + Base64。 为什么要 pad 到 24(而不是 8):24 字节 -> 32 个 Base64 字符,且**不会产生 `=` 填充**。 4 段密文的 Base64 直接拼接后看起来就是一个完整的 Base64 串,没有分隔符。 实测 pad 长度落在 **1..24**(真机样本里见过 15/8/23 与 1/14/4)—— 注意 **1 个空格的 pad 是真实存在的**,别用 ` {2,}` 去找它(见 split_collect_segments)。 """ data = text.encode("latin1") # 注意:即使 len%align==0 也要补一整个 align(真机 chunk2 内容 288 字节、 # 288%24==0,却补了 24 个空格 -> pad 长度恒为 align - len%align,落在 1..align) data += pad * (align - len(data) % align) return base64.b64encode(xtea_encrypt_bytes(data, key)) def xtea_encrypt_segment_raw(text: str, key: List[int]) -> bytes: """加密 **sd 段**:不 pad 到 24,只由 XTEA 的 8 字节块补齐(真机实测如此)。""" data = text.encode("latin1") return base64.b64encode(xtea_encrypt_bytes(data, key)) def build_collect(chunk1: str, trajectory: str, chunk2: str, sd: str, key: List[int], url_encode: bool = False) -> str: """按案例 §5.1 组装 collect —— **4 段密文直接拼接,无分隔符**。 collect = b64(xtea(chunk1 + 空格pad到24)) + b64(xtea(traj + 空格pad到24)) + b64(xtea(chunk2 + 空格pad到24)) + b64(xtea(sd)) # sd 不 pad 段的内容边界(案例 §5.5,与真机明文逐字吻合): chunk1 = '{"cd":[' + 轨迹之前的指纹值 + ',' trajectory = '[[x,y,t],[dx,dy,dt],...,[1,1,12]]' chunk2 = ',' + 轨迹之后的指纹值 + '],' sd = '"sd":{"od":"C","ft":"..."}}' """ parts = [ xtea_encrypt_segment(chunk1, key), xtea_encrypt_segment(trajectory, key), xtea_encrypt_segment(chunk2, key), xtea_encrypt_segment_raw(sd, key), ] b64 = b"".join(parts).decode("ascii") if url_encode: return urllib.parse.quote(b64, safe="") return b64 def _inside_json_string(data: bytes, pos: int) -> bool: """判断字节偏移 pos 是否落在 JSON 字符串字面量内部(数未转义的双引号奇偶)。""" n = 0 i = 0 while i < pos: c = data[i] if c == 0x5C: # 反斜杠:跳过被转义的下一个字节 i += 2 continue if c == 0x22: # 双引号 n += 1 i += 1 return n % 2 == 1 def _candidate_pads(data: bytes) -> List[Tuple[int, int]]: """候选 pad 位置:空格串 [a,b) 且满足 ① b % SEG_ALIGN == 0 ② (b-a) == SEG_ALIGN - a % SEG_ALIGN ③ 不落在 JSON 字符串内部 为什么要 ③:① ② 单独用**会误报** —— 实测真机 UA 串 "Mozilla/5.0 (Macintosh…" 里偏移 47 的那个空格恰好满足 (47 % 24 == 23 -> 期望 pad 1,48 % 24 == 0),而它只是字符串内的普通空格。 段一定切在 JSON 记号边界上,所以真 pad 处引号计数必为偶数;用它把误报滤掉。 """ out: List[Tuple[int, int]] = [] for m in _SpaceRun.finditer(data): a, b = m.span() if b % SEG_ALIGN or (b - a) != SEG_ALIGN - a % SEG_ALIGN: continue if _inside_json_string(data, a): continue out.append((a, b)) return out def split_collect_segments(plain: str) -> Tuple[str, str, str, str]: """把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去掉 pad)。 结构(3 份真机样本逐字节核对,2026-10-12): chunk1 = '{"cd":[' + 轨迹之前的槽 + ',' traj = '<轨迹数组>' chunk2 = ',' + 轨迹之后的槽 + '],' sd = '"sd":{...}}' 前三段各用空格 pad 到 24 字节整数倍,sd 不 pad。 ⚠️ 轨迹落在数组两端时,相邻那段的逗号按 **JSON.stringify 语义** 消失: k == 0 -> chunk1 = '{"cd":[' (真机成功包实测) k == n-1 -> chunk2 = '],' (2026-10-12 实测构建) 做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验 (首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。 校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。 """ data = plain.encode("latin1") cands = _candidate_pads(data) n_struct = n_json = 0 for (a1, b1), (a2, b2), (a3, b3) in itertools.combinations(cands, 3): if not (b1 <= a2 and b2 <= a3): continue segs = [data[0:a1], data[b1:a2], data[b2:a3], data[b3:]] # 段边界一定落在 JSON 记号边界上: # chunk1 = '{"cd":[' + 若干槽 + [',' 或 ''] —— 轨迹正好是第 0 槽时**没有那个逗号** # (真机成功包实测:chunk1 就是 '{"cd":[',随后 17 个空格 pad) # traj = **一个**完整 JSON 值 # chunk2 = [',' + 若干槽] + '],' —— 轨迹正好是最后 1 槽时**没有那个前导逗号** if not (segs[0].startswith(b'{"cd":[') and (segs[0].endswith(b',') or segs[0] == b'{"cd":[') and segs[2].endswith(b'],') and (segs[2].startswith(b',') or segs[2] == b'],') and segs[3].startswith(b'"sd"')): n_struct += 1 continue try: json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值 except Exception: # noqa: BLE001 n_struct += 1 continue try: obj = json.loads(b"".join(segs).decode("latin1")) except Exception: # noqa: BLE001 n_json += 1 continue if "cd" not in obj or "sd" not in obj: n_json += 1 continue return (segs[0].decode("latin1"), segs[1].decode("latin1"), segs[2].decode("latin1"), segs[3].decode("latin1")) raise ValueError( f"未能切出「四段加密」结构(候选 pad {len(cands)} 个," f"边界结构不符 {n_struct} 组,拼回 JSON 校验失败 {n_json} 组);" f"这份明文可能不是 4 段格式,或生成侧拼出了非法 JSON") def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes: out = bytearray() for i in range(0, len(data) - len(data) % 8, 8): v0, v1 = struct.unpack(" List[int]: """从服务端下发的 tdc.js 里提取本次构建的 XTEA 密钥(4 个 32 位字)。 **纯 Python 静态还原**(`tcaptcha/telemetry/key.py`): 反汇编字节码 -> 定位 XTEA 闭包 -> 回溯它捕获的密钥数组 -> 读出 4 个密钥字 -> 折入轮加盐。零 Node、零 JS 运行时、零 pc 硬编码 (opcode 表按构建推导,见 opcodes.py)。 密钥按构建固定(同一份脚本多次运行首块完全一致,已实测),故可缓存。 """ if use_cache: cached = _KEY_CACHE.get(tdc_js_code) if cached: return list(cached) from .key import extract_xtea_key as _pure_extract key = [int(k) & M32 for k in _pure_extract(tdc_js_code)] if len(key) != 4: raise RuntimeError(f"纯 Python 静态还原出来的密钥字不是 4 个: {key!r}") if use_cache: _KEY_CACHE[tdc_js_code] = list(key) return list(key) # --------------------------------------------------------------------------- # # 遥测生成入口(纯离线) # --------------------------------------------------------------------------- # def generate_telemetry( tdc_js_code: str, click_points: Optional[List[Tuple[int, int]]] = None, aid: str = "", user_agent: str = DEFAULT_USER_AGENT, referrer: str = "https://1ms.run/console/personal/benefits/check-in", mode: str = "python_values", node_bin: str = "node", trajectory: Optional[List[List[int]]] = None, ) -> Dict[str, object]: """统一遥测生成入口 —— **纯离线**(唯一模式 `python_values`)。 流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段 XTEA 拼接。 零 Node、零真机明文;密钥也由纯 Python 静态提取。实现见 `telemetry/values.py`。 唯一约束(实测,见 har/1ms_dianxuan/PROGRESS.md 已知 #58/#59): collect **必须用本次会话那一份构建**生成 —— 同一 aid 下, "按本次构建生成" -> errorCode=0;"拿别处模板回填" -> 恒 12。 返回 {"collect", "tlg", "eks", "source", "key"}。 """ if mode != "python_values": raise ValueError( f"只支持 mode='python_values'(纯离线);收到 {mode!r}。" "模板回填 / Node 兜底两条老路已删除 —— 它们产生的 collect 与本次会话构建不同源," "在真实 aid 上必然 errorCode=12。") from .values import generate_collect out = generate_collect(tdc_js_code, click_points, referrer=referrer, trajectory=trajectory) return {"collect": out["collect"], "tlg": out["tlg"], "eks": out["eks"], "source": out["source"], "key": out["key"]} def selfcheck() -> None: """纯算法自检: XTEA 往返 + encrypt_string 一致性。""" for chunk in (b"hello!!", bytes(range(32)), b"x" * 24): dec = xtea_decrypt_bytes(xtea_encrypt_bytes(chunk, DEFAULT_KEY), DEFAULT_KEY) assert dec[:len(chunk)] == chunk, f"XTEA 往返失败: {chunk!r}" ref = base64.b64encode( xtea_encrypt_bytes(b"abc\x00\x00\x00\x00\x00", DEFAULT_KEY)).decode("ascii") assert encrypt_string("abc") == ref, "encrypt_string 与 xtea_encrypt_bytes 不一致" if __name__ == "__main__": selfcheck() print("crypto 自检通过: XTEA 往返 + encrypt_string 一致性")