做了什么:
- 新增 tcaptcha/tdc_values.py: 37 个语义名的取值生成器(静态画像 / 会话量 /
每次采集量三层)+ build_plain 四段组装 + generate_collect 纯离线出包
- crypto: 新增 generate_telemetry(mode="python_values");轨迹生成统一到
trajectory_points()(首点绝对坐标 + 增量 + 固定收尾 [1,1,12])
- crypto.split_collect_segments 重写: pad 只有 1 个空格时旧 ` {2,}` 判据会漏段,
且 UA 串里的普通空格会假阳性 —— 改为「模 24 算术 + 不落在 JSON 字符串内部 + 结构校验」
- tools/tdc_modules: trajectory 指纹原先指向只做 JSON.stringify 的模块,改为
mouseMoveEvent/touchStartEvent(真轨迹模块),37/37 不变
- client 默认 telemetry_mode 改为 python_values
- 新增 scripts/verify/{align_session,audit_names,verify_values,verify_offline,send_test}.py
与 tools/{cdp_collect,cdp_dump_scripts,tdc_profile}.py
为什么:
- 账本 #40「谁在发 HTTP 是 12 的剩余自变量」被推翻。同 aid/同客户端/同求解器、
答案都对,只换 collect 构造方式: 本次会话构建生成 -> 0 (4/4),
旧模板回填 -> 12 (2/2)。真因是 collect 必须与本次会话构建同源。
怎么验证:
- .venv/bin/python -m tcaptcha.selftest 全绿(含新增四段切分与纯离线闭环回归)
- scripts/verify/verify_values.py 4 份真机明文 152/152 逐槽全等
- scripts/verify/verify_offline.py 逐槽全等 + 分段几何一致
- scripts/verify/send_test.py --mode python_values 1ms 签到页文字点选 errorCode=0 x4
530 lines
24 KiB
Python
530 lines
24 KiB
Python
"""
|
||
腾讯 TDC 遥测 (collect) 纯算法还原 —— 唯一路径: 明文模板回填 + 教科书 XTEA。
|
||
|
||
设计原则 (AGENTS.md §2.3 / §2.4):
|
||
* **只走纯算**: collect 的明文组装 / 轨迹 / 时间戳 / XTEA 加密 / 编码全部在 Python 内完成,
|
||
不再用伪造 DOM/WebGL/Canvas 的"补环境"沙箱去跑 tdc.js。
|
||
(踩过的坑见 har/1ms_dianxuan/ANALYSIS.md §7: 换 python/node/browser 后端对 errorCode=12
|
||
毫无差异, 说明"环境"不是自变量, 补环境路线已废弃。)
|
||
* **Node 的唯一用途**: 从服务端下发的 tdc.js 里提取本次构建的 4 个 XTEA 密钥字
|
||
(密钥由 VM 运行时算出, 不是字面量, 只能靠 opcode 插桩捕获)。Node 不参与明文生成。
|
||
* 明文结构以「真实成功样本」(models/success_collect_template.b64) 为模板,
|
||
Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。
|
||
"""
|
||
|
||
import base64
|
||
import itertools
|
||
import json
|
||
import logging
|
||
import os
|
||
import random
|
||
import re
|
||
import struct
|
||
import subprocess
|
||
import time
|
||
import urllib.parse
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
M32 = 0xFFFFFFFF
|
||
|
||
TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js"
|
||
|
||
logger = logging.getLogger("tcaptcha_crypto")
|
||
|
||
# 样本构建的 XTEA 密钥字 (未加盐)。真实样本的等价密钥 (含两个轮加盐) 见
|
||
# tests/test_xtea_key.py 的 SAMPLE_KEY = [1448563793, 1850559083, 1733513813, 1364613714]。
|
||
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("<II", data[i : i + 8])
|
||
ev0, ev1 = xtea_encrypt_block(v0, v1, key)
|
||
out.extend(struct.pack("<II", ev0, ev1))
|
||
|
||
return base64.b64encode(out).decode("ascii")
|
||
|
||
|
||
def extract_eks(tdc_js_code: str) -> str:
|
||
"""从 tdc.js 中提取会话密钥信封 eks (一个较长的 Base64 字符串)。
|
||
|
||
注意: tdc.js 每次构建都会重命名全局变量 (见 AGENTS.md 附录 A#6), 因此**不能**写死变量名;
|
||
这里匹配任意 `window.<随机名> = '<base64>'`, 并取长度最长的候选。
|
||
"""
|
||
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("<II", data[i:i + 8])
|
||
e0, e1 = xtea_encrypt_block(v0, v1, key)
|
||
out += struct.pack("<II", e0, e1)
|
||
return bytes(out)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# collect 的「四段加密」结构(按 docs/tdc-案例.md §5.1,真机明文已判决) #
|
||
# --------------------------------------------------------------------------- #
|
||
SEG_ALIGN = 24
|
||
SEG_PAD = b" "
|
||
|
||
# 候选 pad = 一段空格。⚠️ **pad 可以只有 1 个空格**(段内容长 % 24 == 23 时),
|
||
# 2026-10-12 在真机上实测到这种样本(chunk1 = 287 字节 -> 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。
|
||
|
||
做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验
|
||
(首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。
|
||
校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。
|
||
"""
|
||
data = plain.encode("latin1")
|
||
cands = _candidate_pads(data)
|
||
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 记号边界上(3 份真机样本一致):
|
||
# chunk1 以 ',' 结尾;traj 是**一个**完整 JSON 值;chunk2 以 ',' 开头、以 '],' 结尾
|
||
if not (segs[0].startswith(b'{"cd":[') and segs[0].endswith(b',')
|
||
and segs[2].startswith(b',') and segs[2].endswith(b'],')
|
||
and segs[3].startswith(b'"sd"')):
|
||
continue
|
||
try:
|
||
json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
try:
|
||
obj = json.loads(b"".join(segs).decode("latin1"))
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
if "cd" not in obj or "sd" not in obj:
|
||
continue
|
||
return (segs[0].decode("latin1"), segs[1].decode("latin1"),
|
||
segs[2].decode("latin1"), segs[3].decode("latin1"))
|
||
raise ValueError(
|
||
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个);这份明文可能不是 4 段格式")
|
||
|
||
|
||
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("<II", data[i:i + 8])
|
||
d0, d1 = xtea_decrypt_block(v0, v1, key)
|
||
out += struct.pack("<II", d0, d1)
|
||
return bytes(out)
|
||
|
||
|
||
_CAPTURE_CACHE: Dict[str, Tuple[List[List], bytes]] = {}
|
||
|
||
|
||
def _capture_key_words_cached(tdc_js_code: str, node_bin: str = "node"):
|
||
cached = _CAPTURE_CACHE.get(tdc_js_code)
|
||
if cached is None:
|
||
cached = _capture_key_words(tdc_js_code, node_bin=node_bin)
|
||
_CAPTURE_CACHE[tdc_js_code] = cached
|
||
return cached
|
||
|
||
|
||
def _capture_key_words(tdc_js_code: str, node_bin: str = "node",
|
||
timeout: float = 180.0) -> Tuple[List[List], bytes]:
|
||
"""调用 Node 插桩运行 tdc.js, 返回 (密钥候选, 该脚本产出的 collect 原始字节)"""
|
||
script = TDC_WORKER_PATH
|
||
if not script.exists():
|
||
raise FileNotFoundError(f"缺少 tdc_worker.js: {script}")
|
||
cmd = [node_bin, str(script), "-", "[[0,0],[0,0],[0,0]]",
|
||
json.dumps({"aid": "1", "captureKey": True})]
|
||
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True,
|
||
timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""})
|
||
out = (proc.stdout or "").strip().splitlines()
|
||
if not out:
|
||
raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}")
|
||
payload = json.loads(out[-1])
|
||
if payload.get("error"):
|
||
raise RuntimeError(f"tdc_worker 执行失败: {payload['error'][:400]}")
|
||
words = payload.get("key_words") or []
|
||
if not words:
|
||
raise RuntimeError("tdc_worker 未返回密钥候选 (captureKey 未生效?)")
|
||
raw = base64.b64decode(urllib.parse.unquote(payload["collect"]))
|
||
return words, raw
|
||
|
||
|
||
_KEY_CACHE: Dict[str, List[int]] = {}
|
||
|
||
|
||
def extract_xtea_key(tdc_js_code: str, node_bin: str = "node",
|
||
top_n: int = 8, use_cache: bool = True) -> List[int]:
|
||
"""
|
||
从服务端下发的 tdc.js 里提取本次构建的 XTEA 密钥 (4 个 32 位字)。
|
||
|
||
**主路径 = 纯 Python 静态还原** (`tcaptcha/tdc_key.py`):
|
||
反汇编字节码 -> 定位 XTEA 闭包 -> 回溯它捕获的密钥数组 -> 读出 4 个密钥字
|
||
闭包(源串 + 偏移 + 存取下标) -> 折入轮加盐。
|
||
零 Node、零 JS 运行时、零 pc 硬编码 (opcode 表按构建推导, 见 tdc_opcodes.py)。
|
||
|
||
**兜底路径 = Node 插桩** (密钥构造算法若被改动时仍可用):
|
||
1. Node 在 VM 上下文里把 ADD opcode 换成带日志版本;
|
||
XTEA 每轮的 (sum + K[j]) 是一次 ADD, 两个操作数必有一个等于 r*DELTA,
|
||
于是另一个操作数就是密钥字 —— 按频次排序得到无歧义候选。
|
||
2. Python 取候选 Top-N, 对 4! 个排列逐个用教科书 XTEA 解密
|
||
tdc.js 自己产出的 collect 前 48 字节, 与常量 cd 前缀比对 —— 命中即真密钥。
|
||
|
||
密钥按构建固定 (同一份脚本多次运行首块完全一致, 已实测), 故可缓存。
|
||
"""
|
||
import itertools
|
||
|
||
if use_cache:
|
||
cached = _KEY_CACHE.get(tdc_js_code)
|
||
if cached:
|
||
return list(cached)
|
||
|
||
# ---- 主路径: 纯 Python 静态还原 (零 Node) ----
|
||
try:
|
||
from .tdc_key import extract_xtea_key as _pure_extract
|
||
key = [int(k) & M32 for k in _pure_extract(tdc_js_code)]
|
||
if len(key) == 4:
|
||
if use_cache:
|
||
_KEY_CACHE[tdc_js_code] = key
|
||
return list(key)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.warning("纯 Python 静态提取失败, 退回 Node 插桩: %s", exc)
|
||
|
||
words, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
|
||
candidates = [int(k) & M32 for k, *_ in words[:top_n]]
|
||
# 验证用「内容特征」而非固定明文 (AGENTS.md §5.3 / 附录 A#6):
|
||
# 实测 tdc.js 至少两代明文布局 ——
|
||
# 旧版: 4 段 base64 拼接, 首段 = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
|
||
# 新版: 单个 JSON, 首部 = '{"cd":[1,[],"Apple M1",24,...' / '{"cd":[1791712995,0,...'
|
||
# 字段序还随构建变化, 故只依赖跨版本稳定的结构特征: '{"cd":[' 开头 + 首块全可打印。
|
||
blocks = [struct.unpack("<II", raw[i:i + 8]) for i in range(0, 48, 8)]
|
||
|
||
for perm in itertools.permutations(candidates, 4):
|
||
key = list(perm)
|
||
head = b"".join(struct.pack("<II", *xtea_decrypt_block(v0, v1, key))
|
||
for v0, v1 in blocks)
|
||
if head[:7] == b'{"cd":[' and all(32 <= b < 127 for b in head[:48]):
|
||
if use_cache:
|
||
_KEY_CACHE[tdc_js_code] = key
|
||
return key
|
||
raise RuntimeError(
|
||
f"未能从 tdc.js 复原 XTEA 密钥 (候选 {len(candidates)} 个, 排列全试失败)")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# §5.8 标定式交付: 运行时取一次明文模板, 之后全部由 Python 回填
|
||
# (新版 tdc.js 的明文是单个 JSON, 且**字段序随构建变化** —— 实测两份同时期脚本
|
||
# 首部一个是 '{"cd":[1,[],"Apple M1",24,...', 另一个是 '{"cd":[1791712995,0,...',
|
||
# 所以不能硬编码模板, 只能每次标定一份。)
|
||
# --------------------------------------------------------------------------- #
|
||
_TRAJ_RE = re.compile(r"\[\[\d+,-?\d+,\d+\](?:,\[-?\d+,-?\d+,\d+\])+\]")
|
||
_EPOCH_RE = re.compile(r"(?<![\d.])(1[6-9]\d{8})(?![\d.])")
|
||
|
||
|
||
def recover_payload_template(tdc_js_code: str, node_bin: str = "node"):
|
||
"""
|
||
标定: 跑一次 tdc.js, 提取本次构建的 XTEA 密钥并解密出**明文模板**。
|
||
返回 (template_text, key)。
|
||
"""
|
||
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
|
||
_, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
|
||
plain = xtea_decrypt_bytes(raw, key).rstrip(b"\x00")
|
||
return plain.decode("latin1"), key
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 纯算主路径: 以「真实成功样本的明文」为结构模板 (服务端已认可的字段序与取值),
|
||
# Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。Node 只提供 XTEA 密钥。
|
||
# --------------------------------------------------------------------------- #
|
||
_SUCCESS_TPL_PATH = Path(__file__).resolve().parent / "models" / "success_collect_template.b64"
|
||
|
||
|
||
def _success_style_trajectory(click_points: List[Tuple[int, int]],
|
||
img_w: int = 672) -> str:
|
||
"""轨迹生成已统一到 `tcaptcha/tdc_values.trajectory_points()`(保留本函数兼容旧调用点)。
|
||
|
||
那里按真机样本把形状定死了:首点绝对坐标 + 增量点 + **固定收尾 `[1,1,12]`**。
|
||
"""
|
||
from tcaptcha.tdc_values import trajectory_points
|
||
return json.dumps(trajectory_points(click_points, img_w=img_w),
|
||
separators=(",", ":"))
|
||
|
||
|
||
def generate_collect_success(
|
||
click_points: List[Tuple[int, int]],
|
||
key: List[int],
|
||
entry_url: str = "https://1ms.run/console/personal/benefits/check-in",
|
||
raw_base64: bool = True,
|
||
) -> str:
|
||
"""成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。
|
||
|
||
raw_base64=True 返回未 URL 编码的标准 Base64 (由 httpx 表单编码时单层编码,
|
||
与真实抓包一致); False 则预先做一次 percent-encoding。
|
||
"""
|
||
if not _SUCCESS_TPL_PATH.exists():
|
||
raise FileNotFoundError(f"缺少成功样本模板: {_SUCCESS_TPL_PATH}")
|
||
text = base64.b64decode(_SUCCESS_TPL_PATH.read_text(encoding="ascii")).decode("latin1")
|
||
text = text.replace(
|
||
'"https://1ms.run/console/personal/benefits/check-in"', '"%s"' % entry_url, 1)
|
||
text = _TRAJ_RE.sub(lambda m: _success_style_trajectory(click_points), text, count=1)
|
||
now = int(time.time())
|
||
text = text.replace("1791715501", str(now), 1) # 采集时刻
|
||
text = text.replace("1791715508", str(now + random.randint(4, 12)), 1)
|
||
text = text.replace("1791706326", str(now - random.randint(6000, 12000)), 1)
|
||
text = text.replace("699129805", str(random.randint(100000000, 999999999)), 1)
|
||
data = text.encode("latin1")
|
||
data += b"\x00" * ((8 - len(data) % 8) % 8) # 成功样本用 \x00 填充
|
||
b64 = base64.b64encode(xtea_encrypt_bytes(data, key)).decode("ascii")
|
||
if raw_base64:
|
||
return b64
|
||
return urllib.parse.quote(b64, safe="")
|
||
|
||
|
||
def generate_collect_from_template(
|
||
template: str,
|
||
click_points: List[Tuple[int, int]],
|
||
key: List[int],
|
||
refresh_time: bool = True,
|
||
) -> str:
|
||
"""用标定得到的明文模板 + Python 生成的轨迹回填出 collect (纯算)。
|
||
|
||
1. 用 Python 轨迹替换模板里的轨迹数组
|
||
2. 刷新模板里的秒级时间戳
|
||
3. 空格补齐到 8 字节倍数 -> XTEA 加密 -> base64 -> URL 编码
|
||
"""
|
||
text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template
|
||
|
||
traj = _success_style_trajectory(click_points)
|
||
m = _TRAJ_RE.search(text)
|
||
if not m:
|
||
raise RuntimeError("模板里未找到轨迹数组, 无法回填")
|
||
text = text[:m.start()] + traj + text[m.end():]
|
||
|
||
if refresh_time:
|
||
text = _EPOCH_RE.sub(str(int(time.time())), text)
|
||
|
||
data = text.encode("latin1")
|
||
data += b" " * ((8 - len(data) % 8) % 8) # 与真实 tdc.js 一致: 空格补齐
|
||
return urllib.parse.quote_from_bytes(
|
||
base64.b64encode(xtea_encrypt_bytes(data, key)), safe="")
|
||
|
||
|
||
def generate_telemetry(
|
||
tdc_js_code: str,
|
||
click_points: List[Tuple[int, int]],
|
||
aid: str = "2039519451",
|
||
user_agent: str = DEFAULT_USER_AGENT,
|
||
referrer: str = "https://app.rainyun.com/",
|
||
mode: str = "python_keyed",
|
||
node_bin: str = "node",
|
||
) -> Dict[str, str]:
|
||
"""统一遥测生成入口 —— **只走纯算**, 不再有任何伪造环境的执行后端。
|
||
|
||
mode="python_values" (推荐, 2026-10-12 新增): **纯离线**生成整颗 collect ——
|
||
tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密。
|
||
零 Node、零真机明文(密钥也由纯 Python 静态提取)。见 tcaptcha/tdc_values.py。
|
||
mode="python_keyed": 明文仍来自仓库里的**成功样本模板回填**
|
||
(generate_collect_success),Node 只负责从当前 tdc.js 提取 XTEA 密钥字。
|
||
mode="python": 完全不调用 Node, 用内置 DEFAULT_KEY + 同样的模板回填。
|
||
|
||
返回 {"collect", "tlg", "eks", "source"[, "key"]}。
|
||
|
||
关于 errorCode=12: 实测与遥测后端无关 (python / python_keyed / node / browser 全部 12,
|
||
换 IP / 加长静默同样无效), 见 har/1ms_dianxuan/ANALYSIS.md §7。**不要再用"换环境"解释它**,
|
||
把精力放在账本 §7.5 的单变量实验 (登录态/真页面上下文) 上。
|
||
"""
|
||
if mode not in ("python_values", "python_keyed", "python"):
|
||
raise ValueError(f"未知 telemetry mode: {mode!r} "
|
||
"(只支持 'python_values' / 'python_keyed' / 'python')")
|
||
|
||
if mode == "python_values":
|
||
from tcaptcha.tdc_values import generate_collect
|
||
out = generate_collect(tdc_js_code, click_points, referrer=referrer)
|
||
return {"collect": out["collect"], "tlg": out["tlg"], "eks": out["eks"],
|
||
"source": out["source"], "key": out["key"]}
|
||
|
||
key: Optional[List[int]] = None
|
||
source = "python"
|
||
if mode == "python_keyed":
|
||
try:
|
||
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
|
||
source = "python_keyed"
|
||
except Exception as exc: # Node 缺失/插桩失败 -> 退回固定密钥
|
||
logger.warning("XTEA 密钥提取失败, 退回内置 DEFAULT_KEY: %s", exc)
|
||
if key is None:
|
||
key = list(DEFAULT_KEY)
|
||
|
||
collect = generate_collect_success(click_points, key, entry_url=referrer, raw_base64=True)
|
||
return {
|
||
"collect": collect,
|
||
"tlg": str(len(collect)),
|
||
"eks": extract_eks(tdc_js_code),
|
||
"source": source,
|
||
"key": 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 一致性")
|