feat(collect): 纯离线生成打通 1ms 签到文字点选 —— errorCode=0 收口

做了什么:
- 新增 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
This commit is contained in:
yml2213
2026-10-12 03:38:18 +08:00
parent 80a122245a
commit 51dff870cf
18 changed files with 2102 additions and 91 deletions
+118 -2
View File
@@ -5,25 +5,141 @@
固化:
1. 教科书 XTEA 加解密往返 (含 8 字节对齐补齐)
2. encrypt_string 与 xtea_encrypt_bytes 结果一致 (防止两份 block 实现再次漂移)
3. **四段切分**的实测回归:真机存在「pad 只有 1 个空格」的样本,
旧的 ` {2,}` 判据会直接崩(2026-10-12 踩坑,账本 #50/#51)
4. **纯离线闭环**:tdc.js -> 语义名 -> 槽取值 -> 4 段明文 -> 加密 -> 解回
运行:
.venv/bin/python -m tcaptcha.tests.test_crypto_core
"""
from __future__ import annotations
import base64
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if str(ROOT / "tools") not in sys.path:
sys.path.insert(0, str(ROOT / "tools"))
from tcaptcha.crypto import selfcheck # noqa: E402
from tcaptcha.crypto import (build_collect, selfcheck, split_collect_segments, # noqa: E402
xtea_decrypt_bytes)
# 真机同会话 collect 的**解密明文**(2026-10-12,1ms aid=195672344 的验证码 iframe)。
# 它的价值在于:chunk1 = 287 字节,287 % 24 == 23 -> **pad 只有 1 个空格**,
# 而那个空格又恰好满足 `b%24==0 且 (b-a)==24-a%24` —— 两个坑它一次占全。
REAL_PLAIN_B64 = (
"eyJjZCI6WzI1NjAsMCw2NDE1NDc0ODksMTc5MTczMjAwMSwiTW96aWxsYS81LjAgKE1hY2ludG9z"
"aDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlr"
"ZSBHZWNrbykgQ2hyb21lLzE1NS4wLjAuMCBTYWZhcmkvNTM3LjM2IiwwLCJVVEYtOCIsWzM2MCwz"
"NjBdLDAsIk1hY0ludGVsIixbInpoLUNOIiwiemgiXSwxMCwiMTQ0MC0yNTYwLTI1MzAtMjQtKi0q"
"LXwtKiIsIiIsW10sMTc5MTc0NTU0OCwyLCIrMDgiLCJHb29nbGUgSW5jLiAoQXBwbGUpIiwwLDAs"
"MCwgW1sxNCwxMSwzMjI5NzFdLFszLDAsN10sWzQsMSw3XSxbNSwtMSw3XSxbNiwwLDddLFsyLDEs"
"N10sWzMsLTEsNl0sWzQsMCw3XSxbNSwxLDddLFs2LC0xLDddLFsyLDAsN10sWzMsMSw3XSxbNCwt"
"MSw2XSxbNSwwLDddLFs2LDEsN10sWzIsLTEsN10sWzMsMCw3XSxbNCwxLDddLFs1LC0xLDddLFs2"
"LDAsN10sWzIsMSw3XSxbMywtMSw3XSxbNCwwLDddLFs1LDEsN10sWzYsLTEsN10sWzIsMCw2XSxb"
"MywxLDddLFs0LC0xLDddLFs1LDAsN10sWzYsMSw3XSxbMiwtMSw3XSxbMywwLDddLFs0LDEsNl0s"
"WzUsLTEsN10sWzYsMCw3XSxbMiwxLDddLFszLC0xLDddLFs0LDAsN10sWzUsMSw3XSxbNiwtMSw3"
"XSxbMiwwLDddLFszLDEsN10sWzQsLTEsNl0sWzUsMCw3XSxbNiwxLDddLFsyLC0xLDddLFszLDAs"
"Nl0sWzQsMSw3XSxbNSwtMSw3XSxbNiwwLDddLFsyLDEsN10sWzMsLTEsN10sWzQsMCw3XSxbNSwx"
"LDddLFs2LC0xLDddLFsyLDAsN10sWzMsMSw2XSxbNCwtMSw4XSxbNSwwLDddLFs2LDEsN10sWzEs"
"MSwxMl1dICAgICAgICAgICAgLCIiLDEwMjMsMTU5MTkxMjQ4MiwyLCJodHRwczovLzFtcy5ydW4v"
"Y29uc29sZS9wZXJzb25hbC9iZW5lZml0cy9jaGVjay1pbiIsIkdnb0FBQUFOU1VoRVVnQUFBU3dB"
"QUFDV0NBWUFBQVRmM3Y4YmgrcnlXY2ZTQlNMa21OM04xUFFpblBMN0JpM1RHMHFFMkNvZ0FBQUFC"
"SlJVNUVya0pnZ2c9PSIsMSwiQU5HTEUgKEFwcGxlLCBBTkdMRSBNZXRhbCBSZW5kZXJlcjogQXBw"
"bGUgTTQsIFVuc3BlY2lmaWVkIFZlcnNpb24pIiwxNzkxNzQ1MjI0LCJpZnJhbWUiLDE0NDAsIiIs"
"Imh0dHBzOi8vdHVyaW5nLmNhcHRjaGEuZ3RpbWcuY29tLzEvdGVtcGxhdGUvZHJhZ19lbGUuaHRt"
"bD9yYW5kPTE1MTk3MTM2MjQzNDciLDI0LCI5OGsiXSwgICAgInNkIjp7Im9kIjoiQyJ9fQ=="
)
def test_split_single_space_pad() -> None:
"""真机明文:chunk1 的 pad 只有 1 个空格,旧判据会漏。"""
plain = base64.b64decode(REAL_PLAIN_B64).decode("latin1")
assert len(plain) == 1192
# 旧判据(` {2,}`)只看得见 2 个 pad -> 切不出 4 段(这就是被修掉的 bug)
assert len(re.findall(r" {2,}", plain)) == 2
c1, traj, c2, sd = split_collect_segments(plain)
assert (len(c1), len(traj), len(c2), len(sd)) == (287, 516, 356, 16)
assert c1.startswith('{"cd":[') and c1.endswith(",")
assert c2.startswith(",") and c2.endswith("],")
assert sd == '"sd":{"od":"C"}}'
obj = json.loads(c1 + traj + c2 + sd)
assert len(obj["cd"]) == 38
points = json.loads(traj)
assert len(points) == 61 and points[0] == [14, 11, 322971]
assert points[-1] == [1, 1, 12]
def test_split_ignores_space_inside_string() -> None:
"""UA 串里的普通空格在偏移 47 处也满足 pad 模算术,不能被当成 pad。"""
plain = base64.b64decode(REAL_PLAIN_B64).decode("latin1")
assert plain[47] == " " and 47 % 24 == 23 and 48 % 24 == 0 # 它确实满足判据
c1, _traj, _c2, _sd = split_collect_segments(plain)
assert '"Mozilla/5.0 (Macintosh' in c1 # 空格仍在字符串里,没被切掉
def test_build_collect_roundtrip_single_space_pad() -> None:
"""自己造一份 pad==1 的样本,验证 加密 -> 切分 可逆。"""
key = [0x11111111, 0x22222222, 0x33333333, 0x44444444]
chunk1 = '{"cd":[' + "1," * 20 # 7 + 40 = 47, 47 % 24 == 23 -> pad 1
traj = "[[1,1,12]]"
chunk2 = ',"x"],'
sd = '"sd":{"od":"C"}}'
assert len(chunk1) % 24 == 23
collect = build_collect(chunk1, traj, chunk2, sd, key)
plain = xtea_decrypt_bytes(base64.b64decode(collect), key).decode("latin1").rstrip("\x00")
assert plain[47:48] == " " # 第一段的 pad 就是一个空格
assert split_collect_segments(plain) == (chunk1, traj, chunk2, sd)
def test_offline_generate_and_roundtrip() -> None:
"""纯离线:tdc.js -> 37 语义名 -> 38 槽 -> 4 段明文 -> 加密 -> 解回逐槽相等。"""
import tdc_modules as tm
from tcaptcha.tdc_key import extract_xtea_key_pure
from tcaptcha.tdc_values import Session, build_plain, generate_cd, trajectory_index
build = ROOT / "tcaptcha" / "tdc_sample.js"
js = build.read_text(encoding="utf-8", errors="replace")
key = extract_xtea_key_pure(js)["key"]
rows = tm.analyze(str(build))
assert len(rows) == 37
assert not [r for r in rows if r["name"] == "unknown"]
cd = generate_cd(rows, Session())
assert len(cd) == 38 # itoken 占 2 槽
plain = build_plain(cd, rows)
k = trajectory_index(rows)
assert cd[k] == [[1, 1, 12]] # 轨迹槽
collect = build_collect(plain["chunk1"], plain["trajectory"],
plain["chunk2"], plain["sd"], key)
dec = xtea_decrypt_bytes(base64.b64decode(collect), key).decode("latin1").rstrip("\x00")
c1, traj, c2, sd = split_collect_segments(dec)
assert json.loads(c1 + traj + c2 + sd)["cd"] == cd
assert (len(c1), len(traj), len(c2), len(sd)) == \
(len(plain["chunk1"]), len(plain["trajectory"]),
len(plain["chunk2"]), len(plain["sd"]))
def main() -> int:
selfcheck()
print("✅ crypto 纯算法自检通过 (XTEA 往返 + encrypt_string 一致性)")
test_split_single_space_pad()
test_split_ignores_space_inside_string()
test_build_collect_roundtrip_single_space_pad()
test_offline_generate_and_roundtrip()
print("✅ crypto 纯算法自检通过 (XTEA 往返 + encrypt_string 一致性"
" + 四段切分回归 + 纯离线闭环)")
return 0