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:
+17
-11
@@ -148,26 +148,32 @@ class TencentCaptchaClient:
|
||||
min_posterior: float = 0.95,
|
||||
pow_timeout: int = 4000000,
|
||||
inter_attempt_delay: float = 0.25,
|
||||
telemetry_mode: str = "python_keyed",
|
||||
telemetry_mode: str = "python_values",
|
||||
proxy: Optional[str] = None,
|
||||
cookies: Optional[dict] = None,
|
||||
transport: str = "httpx",
|
||||
impersonate: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry), 只走纯算:
|
||||
telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry):
|
||||
|
||||
* "python_keyed" (默认): Node 只从本次构建的 tdc.js 里提取 XTEA 密钥字;
|
||||
明文组装 / 轨迹 / 时间戳 / 加密 / 编码全部在 Python 完成。
|
||||
* "python": 完全不调用 Node, 直接用内置 DEFAULT_KEY
|
||||
* "python_values" (默认, 2026-10-12 起): **纯离线**按**本次会话的** tdc.js 生成整颗
|
||||
collect —— 37 个语义名 -> 38 槽取值 -> 4 段明文 -> 4 段加密,
|
||||
零 Node、零真机明文。"必须用本次会话的构建"这一点是实测出来的
|
||||
分水岭: 同一 aid 下它 3/3 拿 0, 而模板回填 2/2 恒 12
|
||||
(har/1ms_dianxuan/PROGRESS.md 已知 #58/#59)。
|
||||
* "python_keyed": 明文来自仓库里的**成功样本模板回填**
|
||||
(generate_collect_success): 槽序来自**另一份构建**、
|
||||
取值来自**另一台设备** -> 1ms aid 上实测恒 12。
|
||||
* "python": 完全不调用 Node, 用内置 DEFAULT_KEY + 同样的模板回填
|
||||
(仅对不校验遥测的宽松 aid 有效)。
|
||||
|
||||
关于 errorCode=12 (实测 2026-10-11, 见 har/1ms_dianxuan/ANALYSIS.md §7):
|
||||
1ms.run aid=195672344 在答案正确时返回 12, 且与遥测后端无关 ——
|
||||
python / python_keyed / node / browser 四种后端、4 个不同省份出口 IP、
|
||||
9 分钟静默后单发, **全部都是 12**。所以 12 不是遥测/环境/IP 问题,
|
||||
不要再走"补环境"或"换 IP"的路; 真正的变量 (登录态 / 真实验证码页面上下文)
|
||||
尚未被单变量实验区分。
|
||||
关于 errorCode=12 (**2026-10-12 已收口, 旧结论已推翻**):
|
||||
真因不是"环境/IP/请求方", 而是 **collect 没有用本次会话的那份构建生成**。
|
||||
同 aid / 同客户端 / 同求解器、答案都正确, 只换 collect 构造方式:
|
||||
python_values -> 0 (3/3), python_keyed (旧模板回填) -> 12 (2/2)。
|
||||
见 har/1ms_dianxuan/PROGRESS.md 已知 #58/#59。
|
||||
附带一条方法学: **答错一律 50/9**, 所以任何用"答错"做的对照都没有区分度。
|
||||
|
||||
cookies: 可选, 逐条写入 captcha 域 (turing.captcha.qcloud.com) 的 Cookie 头。
|
||||
成功样本 (har/签到成功-1.har) 的 captcha 域请求带着腾讯云登录 cookie
|
||||
|
||||
+101
-57
@@ -13,6 +13,7 @@
|
||||
"""
|
||||
|
||||
import base64
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -123,7 +124,11 @@ def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
|
||||
SEG_ALIGN = 24
|
||||
SEG_PAD = b" "
|
||||
|
||||
_collect_seg_re = re.compile(rb" {2,}")
|
||||
# 候选 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],
|
||||
@@ -132,7 +137,8 @@ def xtea_encrypt_segment(text: str, key: List[int],
|
||||
|
||||
为什么要 pad 到 24(而不是 8):24 字节 -> 32 个 Base64 字符,且**不会产生 `=` 填充**。
|
||||
4 段密文的 Base64 直接拼接后看起来就是一个完整的 Base64 串,没有分隔符。
|
||||
(真机明文里能直接看到这些空格:chunk1 +5、trajectory +10、chunk2 +24。)
|
||||
实测 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 字节、
|
||||
@@ -174,27 +180,81 @@ def build_collect(chunk1: str, trajectory: str, chunk2: str, sd: str,
|
||||
return b64
|
||||
|
||||
|
||||
def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
|
||||
"""把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去 pad)。
|
||||
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
|
||||
|
||||
用于从真机样本反解出 chunk1/trajectory/chunk2/sd,做对拍或标定。
|
||||
|
||||
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")
|
||||
bounds = [0]
|
||||
for m in _collect_seg_re.finditer(data):
|
||||
bounds.append(m.end())
|
||||
bounds.append(len(data))
|
||||
if len(bounds) != 5:
|
||||
raise ValueError(
|
||||
f"期望 3 个空格填充串(4 段结构),实际切出 {len(bounds) - 1} 段;"
|
||||
f"这份明文可能不是「四段加密」格式")
|
||||
out = []
|
||||
for i in range(4):
|
||||
seg = data[bounds[i]:bounds[i + 1]]
|
||||
if i < 3:
|
||||
seg = seg.rstrip(SEG_PAD) # 前三段去掉空格 pad
|
||||
out.append(seg.decode("latin1"))
|
||||
return out[0], out[1], out[2], out[3]
|
||||
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:
|
||||
@@ -331,38 +391,13 @@ _SUCCESS_TPL_PATH = Path(__file__).resolve().parent / "models" / "success_collec
|
||||
|
||||
def _success_style_trajectory(click_points: List[Tuple[int, int]],
|
||||
img_w: int = 672) -> str:
|
||||
"""
|
||||
按「成功签到样本」实测统计生成轨迹 (61 点, dt 均值 30.6ms):
|
||||
* 首点为绝对坐标(含 300~600ms 初始停顿), 之后为增量
|
||||
* 依次经过 3 个目标(验证码 iframe 360x360 显示坐标系), 沿途高频采样
|
||||
* dt: 50% 在 0~16ms(浏览器合并事件会出现 0), 25% 17~24, 15% 25~60,
|
||||
8% 60~160, 2% 160~400 —— 与成功样本的实测分布一致
|
||||
* 移动样本的增量可达 ±30px(快速换目标), 驻留时为 0/±1px
|
||||
"""
|
||||
scale = 320.0 / img_w
|
||||
targets = [(int(round(20 + x * scale)), int(round(60 + y * scale)))
|
||||
for x, y in click_points]
|
||||
cur = [random.randint(140, 220), random.randint(300, 340)]
|
||||
events = [[cur[0], cur[1], random.randint(300, 600)]]
|
||||
"""轨迹生成已统一到 `tcaptcha/tdc_values.trajectory_points()`(保留本函数兼容旧调用点)。
|
||||
|
||||
def dt():
|
||||
r = random.random()
|
||||
if r < 0.50: return random.randint(0, 16)
|
||||
if r < 0.75: return random.randint(17, 24)
|
||||
if r < 0.90: return random.randint(25, 60)
|
||||
if r < 0.98: return random.randint(61, 160)
|
||||
return random.randint(161, 400)
|
||||
|
||||
for tx, ty in targets:
|
||||
seg = random.randint(14, 20)
|
||||
for i in range(1, seg + 1):
|
||||
nx = cur[0] + (tx - cur[0]) * i / seg + random.randint(-1, 1)
|
||||
ny = cur[1] + (ty - cur[1]) * i / seg + random.randint(-1, 1)
|
||||
events.append([int(nx) - cur[0], int(ny) - cur[1], dt()])
|
||||
cur = [int(nx), int(ny)]
|
||||
for _ in range(random.randint(2, 5)): # 驻留
|
||||
events.append([random.randint(-1, 1), random.randint(-1, 1), dt()])
|
||||
return json.dumps(events, separators=(",", ":"))
|
||||
那里按真机样本把形状定死了:首点绝对坐标 + 增量点 + **固定收尾 `[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(
|
||||
@@ -435,10 +470,12 @@ def generate_telemetry(
|
||||
) -> Dict[str, str]:
|
||||
"""统一遥测生成入口 —— **只走纯算**, 不再有任何伪造环境的执行后端。
|
||||
|
||||
mode="python_keyed" (默认): Node 只做一件事 —— 从当前 tdc.js 提取本次构建的
|
||||
XTEA 密钥字 (密钥由 VM 运行时算出, 不是字面量); 明文组装 / 轨迹 / 时间戳 /
|
||||
加密 / 编码全部在 Python 内完成。Node 不可用时自动退回 DEFAULT_KEY。
|
||||
mode="python": 完全不调用 Node, 直接用内置 DEFAULT_KEY。
|
||||
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"]}。
|
||||
|
||||
@@ -446,8 +483,15 @@ def generate_telemetry(
|
||||
换 IP / 加长静默同样无效), 见 har/1ms_dianxuan/ANALYSIS.md §7。**不要再用"换环境"解释它**,
|
||||
把精力放在账本 §7.5 的单变量实验 (登录态/真页面上下文) 上。
|
||||
"""
|
||||
if mode not in ("python_keyed", "python"):
|
||||
raise ValueError(f"未知 telemetry mode: {mode!r} (只支持 'python_keyed' / 'python')")
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tcaptcha/tdc_values.py —— 为每个采集器**语义名**提供取值生成器(纯离线)。
|
||||
|
||||
背景(账本 §五 第 0 项,har/1ms_dianxuan/PROGRESS.md):
|
||||
「纯离线生成 collect」的唯一阻塞点 = 不知道每个语义名该填什么值。
|
||||
本文件把 2026-10-12 实测到的**同会话真值**固化成生成规则。
|
||||
|
||||
真值来源(不是猜的):
|
||||
真 Chrome + 真 1ms 页面 -> Debugger 取**同会话** tdc.js(77,954 字节,
|
||||
含 __TENCENT_CHAOS_VM)-> iframe 内 TDC.getData(true) -> 同会话 XTEA 密钥解密
|
||||
-> 按「require 序 == 槽位序」对齐,38/38 槽全覆盖。
|
||||
脚本: scripts/verify/align_session.py、scripts/verify/audit_names.py
|
||||
|
||||
会话 vs 每次采集(连续 4 次 getData 实测,这一条决定了生成器怎么分层):
|
||||
|
||||
| 语义名 | 第1次 | 第2次 | 第3次 | 第4次 | 结论 |
|
||||
|---------------------------|-------------|-------------|-------------|-------------|---------------|
|
||||
| itoken | 641547489 | 641547489 | 641547489 | 641547489 | **会话内固定** |
|
||||
| struct_40_9 (#.._rand) | 1591912482 | 1591912482 | 1591912482 | 1591912482 | **会话内固定** |
|
||||
| window_time (#e48 委派) | 1791745224 | 1791745224 | 1791745224 | 1791745224 | **会话内固定** |
|
||||
| window_time (#e40) | 1791745331 | 1791745548 | 1791745813 | 1791745814 | 每次刷新 |
|
||||
| struct_46_9 | 1 | 2 | 3 | 4 | **每次 +1 的计数器** |
|
||||
| trajectory 点数 | 1 | 61 | 1 | 1 | 取完即清空 |
|
||||
|
||||
三类取值:
|
||||
* 静态画像 DEVICE_PROFILE —— UA / 屏幕 / WebGL / Canvas / 语言 / 时区 / 核数 …
|
||||
(已用浏览器真值交叉校验: screen.width/height/availHeight/colorDepth、
|
||||
hardwareConcurrency、timezoneOffset、languages 全部一一吻合)
|
||||
* 会话量 Session —— itoken / 会话随机盐 / 页面加载时刻
|
||||
* 每次采集 Session.next_call() —— 当前时刻、采集计数器、轨迹
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 设备画像:与真机逐项核对过(见模块 docstring 的交叉校验说明) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
DEVICE_PROFILE: Dict[str, Any] = {
|
||||
"name": "Mac / Chrome 155 / 1440x2560",
|
||||
"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"),
|
||||
"platform": "MacIntel",
|
||||
"languages": ["zh-CN", "zh"],
|
||||
"timezone_offset": "+08", # 真机 Date.getTimezoneOffset() == -480
|
||||
"charset": "UTF-8",
|
||||
"hardware_concurrency": 10, # 真机 navigator.hardwareConcurrency
|
||||
# ⚠️ 方向别弄反:真机 screen.width == 1440、screen.height == 2560
|
||||
# (1440x2560 竖屏;浏览器实测 screen.availHeight == 2530)
|
||||
"screen_width": 1440,
|
||||
"screen_height": 2560,
|
||||
"screen_color_depth": 24,
|
||||
# 真机 screen_fingerprint 串;字段序 = 宽-高-可用高-色深-*-*-|-*
|
||||
"screen_fingerprint": "1440-2560-2530-24-*-*-|-*",
|
||||
"webgl_vendor": "Google Inc. (Apple)",
|
||||
"webgl_renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)",
|
||||
"canvas_fingerprint": ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1"
|
||||
"PQinPL7Bi3TG0qE2CogAAAABJRU5ErkJggg=="),
|
||||
"viewport_size": [360, 360], # 验证码 iframe 的可视区
|
||||
"cookie_enabled": 1,
|
||||
"touch_detect": 2, # 真机实测值(该机 maxTouchPoints == 0)
|
||||
"iframe_detect": "iframe",
|
||||
"webrtc_ip": "", # 未取到候选时为空串
|
||||
"dom_compliance": 1023,
|
||||
"constant_98k": "98k",
|
||||
"template_url": ("https://turing.captcha.gtimg.com/1/template/"
|
||||
"drag_ele.html?rand=1519713624347"),
|
||||
}
|
||||
|
||||
# 真机里恒为这些常量的槽(连续 4 次抽样一致)
|
||||
CONSTANTS: Dict[str, Any] = {
|
||||
"time_since_load": 0,
|
||||
"webdriver_detect": 0,
|
||||
"console_detect": 0,
|
||||
"fp_salt": 0,
|
||||
"struct_48_9_call": 0,
|
||||
"location_href": "",
|
||||
"struct_40_9#empty": "",
|
||||
}
|
||||
|
||||
# 真机里是空数组的槽(与轨迹区分:轨迹至少含一个点)
|
||||
EMPTY_LIST_NAME = "struct_76_33"
|
||||
|
||||
DEFAULT_REFERRER = "https://1ms.run/console/personal/benefits/check-in"
|
||||
|
||||
|
||||
def _rand_int32() -> int:
|
||||
"""[0, 2^31) 的随机整数 —— 真机观测值 1591912482 / 699129805 / 641547489 都落在这里。"""
|
||||
return random.randint(0, (1 << 31) - 1)
|
||||
|
||||
|
||||
def default_trajectory() -> List[List[int]]:
|
||||
"""无鼠标输入时的轨迹(真机实测就是 [[1,1,12]],看着像个"无位移"哨兵点)。"""
|
||||
return [[1, 1, 12]]
|
||||
|
||||
|
||||
def trajectory_points(click_points: Sequence[Sequence[int]],
|
||||
img_w: int = 672) -> List[List[int]]:
|
||||
"""按点击点生成鼠标轨迹:首点绝对坐标 + 增量点 + 固定收尾 `[1,1,12]`。
|
||||
|
||||
形状与真机一致(3 份样本逐字核对):
|
||||
* 首点是**绝对坐标**(含 300~600ms 初始停顿)
|
||||
* 之后全是**增量** `[dx,dy,dt]`
|
||||
* **最后一点固定是 `[1,1,12]`** —— 真机成功样本与"没动鼠标"样本都这么收尾
|
||||
(没动鼠标时整条轨迹就是 `[[1,1,12]]`)
|
||||
dt 分布按成功样本实测拟合(旧 crypto._success_style_trajectory 的统计)。
|
||||
"""
|
||||
scale = 320.0 / img_w
|
||||
targets = [(int(round(20 + x * scale)), int(round(60 + y * scale)))
|
||||
for x, y in click_points]
|
||||
cur = [random.randint(140, 220), random.randint(300, 340)]
|
||||
events: List[List[int]] = [[cur[0], cur[1], random.randint(300, 600)]]
|
||||
|
||||
def dt() -> int:
|
||||
r = random.random()
|
||||
if r < 0.50:
|
||||
return random.randint(0, 16)
|
||||
if r < 0.75:
|
||||
return random.randint(17, 24)
|
||||
if r < 0.90:
|
||||
return random.randint(25, 60)
|
||||
if r < 0.98:
|
||||
return random.randint(61, 160)
|
||||
return random.randint(161, 400)
|
||||
|
||||
for tx, ty in targets:
|
||||
seg = random.randint(14, 20)
|
||||
for i in range(1, seg + 1):
|
||||
nx = cur[0] + (tx - cur[0]) * i / seg + random.randint(-1, 1)
|
||||
ny = cur[1] + (ty - cur[1]) * i / seg + random.randint(-1, 1)
|
||||
events.append([int(nx) - cur[0], int(ny) - cur[1], dt()])
|
||||
cur = [int(nx), int(ny)]
|
||||
for _ in range(random.randint(2, 5)): # 命中后短暂驻留
|
||||
events.append([random.randint(-1, 1), random.randint(-1, 1), dt()])
|
||||
events.append([1, 1, 12]) # 固定收尾(真机如此)
|
||||
return events
|
||||
|
||||
|
||||
def rows_from_source(js_text: str) -> List[Dict]:
|
||||
"""分析 tdc.js **源码**(tdc_modules 的入口收的是路径,这里落临时文件再分析)。"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".js", prefix="tdc_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(js_text)
|
||||
return _analyze(path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _analyze(path: str) -> List[Dict]:
|
||||
"""惰性导入 tools/tdc_modules(它不在包里,且只做静态分析)。"""
|
||||
import sys
|
||||
from pathlib import Path as _P
|
||||
|
||||
tools = str(_P(__file__).resolve().parents[1] / "tools")
|
||||
if tools not in sys.path:
|
||||
sys.path.insert(0, tools)
|
||||
import tdc_modules as tm
|
||||
return tm.analyze(path)
|
||||
|
||||
|
||||
def generate_collect(tdc_js_code: str,
|
||||
click_points: Optional[Sequence[Sequence[int]]] = None,
|
||||
*,
|
||||
key: Optional[Sequence[int]] = None,
|
||||
sd: Optional[Dict] = None,
|
||||
session: Optional[Session] = None,
|
||||
img_w: int = 672,
|
||||
referrer: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""**纯离线**生成一颗 collect:只吃 tdc.js 源码,不需要任何真机明文。
|
||||
|
||||
流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密拼接。
|
||||
密钥默认用纯 Python 静态提取(`tcaptcha.tdc_key`),零 Node。
|
||||
"""
|
||||
from tcaptcha.crypto import build_collect, extract_eks # 局部导入避免环形依赖
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure
|
||||
|
||||
rows = rows_from_source(tdc_js_code)
|
||||
if key is None:
|
||||
key = extract_xtea_key_pure(tdc_js_code)["key"]
|
||||
sess = session or Session(referrer=referrer or DEFAULT_REFERRER)
|
||||
if click_points:
|
||||
sess.set_trajectory(trajectory_points(click_points, img_w=img_w))
|
||||
|
||||
cd = generate_cd(rows, sess)
|
||||
plain = build_plain(cd, rows, sd)
|
||||
collect = build_collect(plain["chunk1"], plain["trajectory"],
|
||||
plain["chunk2"], plain["sd"], list(key))
|
||||
return {
|
||||
"collect": collect,
|
||||
"tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code),
|
||||
"key": list(key),
|
||||
"source": "python_values",
|
||||
"cd": cd,
|
||||
"segments": {k: len(v) for k, v in plain.items()},
|
||||
}
|
||||
|
||||
|
||||
class Session:
|
||||
"""一次验证码会话的状态。
|
||||
|
||||
会话内固定的量(itoken / 随机盐 / 页面加载时刻)在 __init__ 里定下来,
|
||||
每次采集会变的量(当前时刻 / 计数器 / 轨迹)由 next_call() 推进。
|
||||
"""
|
||||
|
||||
def __init__(self, now: Optional[float] = None, entry_age: int = 60,
|
||||
referrer: str = DEFAULT_REFERRER, profile: Optional[Dict] = None):
|
||||
self.t0 = int(now if now is not None else time.time())
|
||||
# 页面加载时刻先于会话建立(真机两次采样差 ~107 秒)
|
||||
self.loaded_at = self.t0 - entry_age
|
||||
self.itoken = [_rand_int32(), self.loaded_at] # 会话内固定,实测 4 次完全一致
|
||||
self.session_rand = _rand_int32() # struct_40_9 的"随机"槽
|
||||
self.referrer = referrer
|
||||
self.profile = dict(profile or DEVICE_PROFILE)
|
||||
self.calls = 0
|
||||
self.now = self.t0
|
||||
self.trajectory: List[List[int]] = default_trajectory()
|
||||
|
||||
def next_call(self, now: Optional[float] = None) -> "Session":
|
||||
"""进入下一次 TDC.getData():计数器 +1、刷新当前时刻、轨迹复位由调用方决定。"""
|
||||
self.calls += 1
|
||||
self.now = int(now if now is not None else time.time())
|
||||
return self
|
||||
|
||||
def set_trajectory(self, points: Sequence[Sequence[int]]) -> "Session":
|
||||
self.trajectory = [list(p) for p in points]
|
||||
return self
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 语义名 -> 取值生成器 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def value_for(base: str, row: Dict, sess: Session) -> Any:
|
||||
"""按**基础语义名**(去掉 #结构后缀)产出一个槽的值。
|
||||
|
||||
同一基础名在一个构建里可能出现多次(user_agent×2 / window_time×2 /
|
||||
struct_40_9×2),这时用行上的**结构证据**分流,而不是靠出现次序 ——
|
||||
次序会随构建变,结构不会(见 tdc_modules.STRUCT_RULES)。
|
||||
"""
|
||||
st = row.get("struct") or {}
|
||||
p = sess.profile
|
||||
|
||||
# ---- 同组多成员:用结构 / 委派证据分流 ----
|
||||
if base == "user_agent":
|
||||
# 直采的那个返回 UA 串;走委派链的那个返回 0(真机实测)
|
||||
return 0 if row.get("via_delegate") else p["user_agent"]
|
||||
if base == "struct_40_9":
|
||||
# 闭包最后写入 "" vs 随机 int32(账本 #47 的静态消歧依据)
|
||||
return "" if st.get("closure_kind") == "empty" else sess.session_rand
|
||||
if base == "window_time":
|
||||
# 委派的那个是页面加载时刻(会话内固定),直采的那个是当前时刻(每次变)
|
||||
return sess.loaded_at if row.get("via_delegate") else sess.now
|
||||
|
||||
# ---- 会话量 / 每次采集量 ----
|
||||
if base == "itoken":
|
||||
return None # 2 槽,见 generate_cd
|
||||
if base == "trajectory":
|
||||
return sess.trajectory
|
||||
if base == "struct_46_9":
|
||||
return sess.calls # 实测 1,2,3,4 —— 每次采集 +1
|
||||
if base == "referrer":
|
||||
return sess.referrer
|
||||
|
||||
# ---- 常量 / 画像 ----
|
||||
if base in CONSTANTS:
|
||||
return CONSTANTS[base]
|
||||
if base == EMPTY_LIST_NAME:
|
||||
return []
|
||||
if base in p:
|
||||
return p[base]
|
||||
raise KeyError("没有 %r 的取值规则(新语义名? 需要补规则)" % base)
|
||||
|
||||
|
||||
def generate_cd(rows: Sequence[Dict], sess: Optional[Session] = None,
|
||||
now: Optional[float] = None) -> List[Any]:
|
||||
"""按**清单顺序**生成 cd 数组(37 项 -> 38 槽,itoken 占 2 槽)。
|
||||
|
||||
rows 来自 tools.tdc_modules.analyze()(每项含 name / struct / via_delegate)。
|
||||
每次调用会推进 sess.next_call()(模拟一次 TDC.getData)。
|
||||
"""
|
||||
s = sess or Session(now=now)
|
||||
if now is not None:
|
||||
s.next_call(now)
|
||||
elif s.calls == 0:
|
||||
s.next_call(s.t0)
|
||||
|
||||
out: List[Any] = []
|
||||
for r in rows:
|
||||
base = str(r["name"]).split("#")[0]
|
||||
if base == "itoken":
|
||||
out.extend(s.itoken) # 真机就是 2 个连续槽
|
||||
continue
|
||||
out.append(value_for(base, r, s))
|
||||
return out
|
||||
|
||||
|
||||
def trajectory_index(rows: Sequence[Dict]) -> int:
|
||||
"""轨迹在 cd 中的下标 —— 它就是 4 段加密里第 2 段的分界点。"""
|
||||
k = 0
|
||||
for r in rows:
|
||||
base = str(r["name"]).split("#")[0]
|
||||
if base == "trajectory":
|
||||
return k
|
||||
k += 2 if base == "itoken" else 1
|
||||
raise KeyError("清单里没有 trajectory 项")
|
||||
|
||||
|
||||
def build_plain(cd: Sequence[Any], rows: Sequence[Dict],
|
||||
sd: Optional[Dict] = None) -> Dict[str, str]:
|
||||
"""把 cd 组装成 4 段明文(chunk1 / trajectory / chunk2 / sd)。
|
||||
|
||||
与真机**逐字节一致**的分段写法(3 份样本核对,2026-10-12):
|
||||
|
||||
chunk1 = '{"cd":[' + 轨迹之前的槽(逗号连接)+ ','
|
||||
traj = '<轨迹槽的 JSON>'
|
||||
chunk2 = ',' + 轨迹之后的槽(逗号连接)+ '],'
|
||||
sd = '"sd":' + json(sd) + '}'
|
||||
|
||||
分隔符一律是**无空格的逗号**(真机 JSON.stringify 风格)。
|
||||
"""
|
||||
import json
|
||||
|
||||
def js(v: Any) -> str:
|
||||
return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
k = trajectory_index(rows)
|
||||
head = ",".join(js(v) for v in cd[:k])
|
||||
tail = ",".join(js(v) for v in cd[k + 1:])
|
||||
return {
|
||||
"chunk1": '{"cd":[' + head + ",",
|
||||
"trajectory": js(cd[k]),
|
||||
"chunk2": "," + tail + "],",
|
||||
"sd": '"sd":' + js(sd if sd is not None else {"od": "C"}) + "}",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
ap = argparse.ArgumentParser(description="按语义名生成 cd 取值(纯离线)")
|
||||
ap.add_argument("tdc_js", help="一份 tdc.js(用来取清单顺序与语义名)")
|
||||
ap.add_argument("--json", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
import tdc_modules as tm
|
||||
rows = tm.analyze(args.tdc_js)
|
||||
sess = Session()
|
||||
cd = generate_cd(rows, sess)
|
||||
print("项 语义名 取值")
|
||||
print("-" * 78)
|
||||
k = 0
|
||||
for r in rows:
|
||||
base = str(r["name"]).split("#")[0]
|
||||
n = 2 if base == "itoken" else 1
|
||||
val = cd[k:k + n] if n > 1 else cd[k]
|
||||
k += n
|
||||
print("%-3d %-36s %s" % (r["item"], r["name"],
|
||||
json.dumps(val, ensure_ascii=False)[:34]))
|
||||
print()
|
||||
print("槽数 %d / %d 项" % (len(cd), len(rows)))
|
||||
plain = build_plain(cd, rows)
|
||||
print("分段: chunk1=%d traj=%d chunk2=%d sd=%d"
|
||||
% (len(plain["chunk1"]), len(plain["trajectory"]),
|
||||
len(plain["chunk2"]), len(plain["sd"])))
|
||||
if args.json:
|
||||
Path(args.json).write_text(json.dumps(cd, ensure_ascii=False, indent=1),
|
||||
encoding="utf-8")
|
||||
print(">>> %s" % args.json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user