做了什么
- 从「签到成功-1.har」提取该次会话的 tdc.js, 提取密钥解密出
errorCode=0 那次 collect 的完整明文 (1360 字节), 存为
tcaptcha/models/success_collect_template.b64
- 新增 generate_collect_success(): 以成功样本为结构模板, Python 只回填
轨迹/当前时间戳/随机盐, 其余环境字段 (canvas/WebGL/屏幕/语言/sd.ft) 原样保留
- 新增 _success_style_trajectory(): 按成功样本实测统计生成轨迹
(61 点, dt 均值 30.6ms, 50% 在 0~16ms, 途经 3 个目标, 驻留抖动)
—— 修正了统计正则不允许负增量的 bug (真实 61 点, 此前误数成 18)
- 发现成功样本用 \x00 填充 (不是空格), sd 多一个 ft 字段
- telemetry_mode=python_keyed 默认走该路径; Node 仅提取每构建 XTEA 密钥
- ANALYSIS.md 第七节重写为 v2 证据账本, 显式撤回 4 个无证据结论
验证
- 纯 Python 密文 == 真实 tdc.js 密文 (逐字节)
- 回归: test_xtea_key 11 项、test_glyph_solver 8/8
- 端到端 1ms 实测: 仍 errorCode=12 (诚实记录, 未解决)
结论(见 ANALYSIS.md 7.5-7.7)
- 12 不是"检测到机器人": 同 IP 有成功样本、参数逐字段一致、
真 Chrome 网络栈/真人点击/CDP 均排除
- 剩余假设: H1 需已登录业务会话(未用 auth_token 调 captcha/params 复测)
H3 需有效 qcloud.com cookie(过期 skey 复测不能定论)
- 下一步: 用登录态 auth_token 调 /api/v1/captcha/params,
对比返回是否多出鉴权字段 —— 这是唯一未做且成本低的关键实验
887 lines
36 KiB
Python
887 lines
36 KiB
Python
"""
|
|
Pure Python implementation of Tencent TDC Chaos VM (JSVMP) Cryptographic
|
|
and Telemetry generation algorithm.
|
|
|
|
Eliminates the Node.js sandbox dependency by replicating the 32-round custom
|
|
XTEA block cipher, human-like mouse movement trajectory generation, and
|
|
telemetry payload serialization.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import math
|
|
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"
|
|
TDC_BROWSER_PATH = Path(__file__).resolve().parent / "tdc_browser.js"
|
|
|
|
# TDC VM Cryptographic Constants
|
|
DELTA = 2654435769 # 0x9E3779B9
|
|
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/152.0.0.0 Safari/537.36"
|
|
)
|
|
DEFAULT_RENDERER = "Apple M1"
|
|
DEFAULT_VENDOR = "Apple"
|
|
DEFAULT_CANVAS_FP = (
|
|
"GgoAAAANSUhEUgAAAAEAAAABCAYAAAUhEUgAAAAEAAAABCAYAAAA"
|
|
"fFcSJAAAADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
def xtea_encrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
|
|
"""
|
|
Tencent custom 32-round XTEA block cipher for a 64-bit block (v0, v1).
|
|
Includes specific additive offsets (2829068 and 328986) depending on round index.
|
|
"""
|
|
v0 &= 0xFFFFFFFF
|
|
v1 &= 0xFFFFFFFF
|
|
total_sum = 0
|
|
|
|
for _ in range(32):
|
|
# Step 1: Update v0
|
|
k_idx1 = total_sum & 3
|
|
shift1 = (((v1 << 4) ^ (v1 >> 5)) + v1) & 0xFFFFFFFF
|
|
if k_idx1 == 1:
|
|
k_term1 = (total_sum + key[1] + 2829068) & 0xFFFFFFFF
|
|
elif k_idx1 == 2:
|
|
k_term1 = (total_sum + key[2] + 328986) & 0xFFFFFFFF
|
|
else:
|
|
k_term1 = (total_sum + key[k_idx1]) & 0xFFFFFFFF
|
|
v0 = (v0 + (shift1 ^ k_term1)) & 0xFFFFFFFF
|
|
|
|
# Step 2: Update total_sum (standard XTEA delta increment)
|
|
total_sum = (total_sum + DELTA) & 0xFFFFFFFFFFFFFFFF
|
|
|
|
# Step 3: Update v1
|
|
k_idx2 = (total_sum >> 11) & 3
|
|
shift2 = (((v0 << 4) ^ (v0 >> 5)) + v0) & 0xFFFFFFFF
|
|
if k_idx2 == 1:
|
|
k_term2 = (total_sum + key[1] + 2829068) & 0xFFFFFFFF
|
|
elif k_idx2 == 2:
|
|
k_term2 = (total_sum + key[2] + 328986) & 0xFFFFFFFF
|
|
else:
|
|
k_term2 = (total_sum + key[k_idx2]) & 0xFFFFFFFF
|
|
v1 = (v1 + (shift2 ^ k_term2)) & 0xFFFFFFFF
|
|
|
|
return v0, v1
|
|
|
|
|
|
def encrypt_string(plaintext: str, key: List[int] = DEFAULT_KEY) -> str:
|
|
"""
|
|
Encrypts an ASCII/Latin1 plaintext string into Base64 using the custom XTEA cipher.
|
|
Input is padded with null bytes to an 8-byte boundary if needed.
|
|
"""
|
|
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 _bezier_interp(
|
|
p0: Tuple[float, float],
|
|
p1: Tuple[float, float],
|
|
steps: int,
|
|
) -> List[Tuple[int, int]]:
|
|
"""Generates cubic Bézier interpolation curve points with natural tremor."""
|
|
dx = p1[0] - p0[0]
|
|
dy = p1[1] - p0[1]
|
|
dist = math.hypot(dx, dy) or 1.0
|
|
|
|
perp_x = -dy / dist
|
|
perp_y = dx / dist
|
|
curvature = (random.random() - 0.5) * min(50.0, dist * 0.4)
|
|
|
|
cp1 = (
|
|
p0[0] + dx * 0.3 + perp_x * curvature,
|
|
p0[1] + dy * 0.3 + perp_y * curvature,
|
|
)
|
|
cp2 = (
|
|
p0[0] + dx * 0.7 - perp_x * curvature * 0.5,
|
|
p0[1] + dy * 0.7 - perp_y * curvature * 0.5,
|
|
)
|
|
|
|
pts = []
|
|
for i in range(steps + 1):
|
|
t = i / float(steps)
|
|
ease_t = t * t * (3.0 - 2.0 * t)
|
|
inv_t = 1.0 - ease_t
|
|
|
|
bx = (
|
|
inv_t**3 * p0[0]
|
|
+ 3 * inv_t**2 * ease_t * cp1[0]
|
|
+ 3 * inv_t * ease_t**2 * cp2[0]
|
|
+ ease_t**3 * p1[0]
|
|
)
|
|
by = (
|
|
inv_t**3 * p0[1]
|
|
+ 3 * inv_t**2 * ease_t * cp1[1]
|
|
+ 3 * inv_t * ease_t**2 * cp2[1]
|
|
+ ease_t**3 * p1[1]
|
|
)
|
|
tremor = 0.0 if i in (0, steps) else (random.random() - 0.5) * 1.5
|
|
pts.append((int(round(bx + tremor)), int(round(by + tremor))))
|
|
|
|
return pts
|
|
|
|
|
|
def human_dt_ms() -> int:
|
|
"""
|
|
真实 mousemove 的采样间隔 (毫秒)。
|
|
|
|
依据: 成功签到样本 (har/签到成功-1.har) 解密出的轨迹 dt 分布为
|
|
12~601ms, 均值 63.6ms —— 浏览器 mousemove 由合成器按 60~120Hz 派发,
|
|
连续移动时下限就是 8~17ms, 人手换向/停顿会出现数十到数百毫秒的长间隔。
|
|
|
|
之前这里用 random.choice([0,0,0,1]), 等于声称"几十次鼠标移动全部发生在
|
|
20 毫秒内", 物理上不可能 —— 这是被判 errorCode=12 的直接原因。
|
|
"""
|
|
r = random.random()
|
|
if r < 0.68:
|
|
return random.randint(13, 19) # 连续采样 (60~75Hz)
|
|
if r < 0.90:
|
|
return random.randint(20, 45) # 轻微停顿
|
|
if r < 0.98:
|
|
return random.randint(46, 120) # 明显停顿 (换向/瞄准)
|
|
return random.randint(121, 400) # 长停顿
|
|
|
|
|
|
def generate_trajectory_payload(
|
|
click_points: List[Tuple[int, int]],
|
|
target_len: int = 600,
|
|
) -> str:
|
|
"""
|
|
Generates realistic human mouse movement trajectory and serializes it
|
|
into the TDC relative-displacement JSON format:
|
|
[[x0, y0, 2], [dx1, dy1, dt1], [dx2, dy2, dt2], ...]
|
|
|
|
The string is padded with trailing spaces to target_len (multiple of 8).
|
|
"""
|
|
# Scale from original 672x480 coordinate space to 360x360 captcha iframe display space
|
|
scale = 320.0 / 672.0
|
|
mapped_targets = [
|
|
(int(round(20 + p[0] * scale)), int(round(60 + p[1] * scale)))
|
|
for p in click_points
|
|
]
|
|
|
|
# Starting position near entry area
|
|
current_pt = (
|
|
int(round(150 + (random.random() - 0.5) * 60)),
|
|
int(round(320 + (random.random() - 0.5) * 20)),
|
|
)
|
|
|
|
events: List[List[int]] = []
|
|
# 首个三元组的第三位是"进入验证码区域"的初始停顿 (成功样本实测 601ms)
|
|
events.append([current_pt[0], current_pt[1], random.randint(280, 820)])
|
|
|
|
last_x, last_y = current_pt[0], current_pt[1]
|
|
|
|
for target in mapped_targets:
|
|
# 真实样本 3 次点击只记到 18 个三元组 -> 每次点击 5~8 个采样点
|
|
steps = random.randint(5, 8)
|
|
curve = _bezier_interp((last_x, last_y), target, steps)
|
|
|
|
for pt in curve[1:]:
|
|
dx = pt[0] - last_x
|
|
dy = pt[1] - last_y
|
|
events.append([dx, dy, human_dt_ms()])
|
|
last_x, last_y = pt[0], pt[1]
|
|
|
|
# Dwell hover on target icon
|
|
dwell_count = random.randint(1, 2)
|
|
for _ in range(dwell_count):
|
|
dx = random.randint(-1, 1)
|
|
dy = random.randint(-1, 1)
|
|
events.append([dx, dy, human_dt_ms()])
|
|
last_x += dx
|
|
last_y += dy
|
|
|
|
import json
|
|
raw_json = json.dumps(events, separators=(",", ":"))
|
|
|
|
# 填充到 24 字节对齐 —— 24 是 8 (XTEA 分组) 与 3 (Base64 分组) 的最小公倍数,
|
|
# 这样每个分块单独 base64 后拼接不会产生 **块内 '=' 填充**。
|
|
# 实测真实 tdc.js 的 collect 整体只有末尾一个 '==' (见 点选.har),
|
|
# 块内出现 '=' 会导致服务端解析/解密失败。
|
|
desired_len = max(len(raw_json), target_len)
|
|
pad_needed = (24 - (desired_len % 24)) % 24
|
|
total_len = desired_len + pad_needed
|
|
|
|
return raw_json.ljust(total_len, " ")
|
|
|
|
|
|
def extract_eks(tdc_js_code: str) -> str:
|
|
"""Extracts session public key (`eks`) from TDC.js script header."""
|
|
match = re.search(r"window\.KQnlRHY\w+\s*=\s*'([^']+)'", tdc_js_code)
|
|
if match:
|
|
return match.group(1)
|
|
# Fallback to any base64 string assignment
|
|
match_fallback = re.search(r"window\.\w+\s*=\s*'([A-Za-z0-9+/=]{100,})'", tdc_js_code)
|
|
if match_fallback:
|
|
return match_fallback.group(1)
|
|
return ""
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 标准 XTEA (32 轮, delta=0x9E3779B9, sum 取 mod 2^32) —— 等价于原 crypto.py 的
|
|
# 自定义形态 + 密钥折叠: K' = [k0, k1 + salt1, k2 + salt2, k3]
|
|
# 由此「算法」就是教科书 XTEA, 唯一需要从每份 tdc.js 里拿到的是那 4 个密钥字。
|
|
# --------------------------------------------------------------------------- #
|
|
XTEA_DELTA = 0x9E3779B9
|
|
CD_PRE_RAW = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
|
|
|
|
|
|
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:
|
|
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)
|
|
|
|
|
|
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 位字)。
|
|
|
|
流程 (Node 只负责"跑出密钥", 算法全在 Python):
|
|
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)
|
|
|
|
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 填值, 与加密无关)
|
|
#
|
|
# 取值来源: /Users/yml/Desktop/抓包/毫秒/签到成功-1.har 里那次 errorCode=0 的 collect,
|
|
# 用该会话自己的 tdc.js 提取密钥解密后读出的真实字段。
|
|
# 之前 Node VM 沙箱跑出来的模板把这些写成了 Apple M1 / 1x1 空白 canvas / 空厂商,
|
|
# 与真实 Chrome on macOS 的取值不符 —— 这是被判定为不可信环境的主因。
|
|
# --------------------------------------------------------------------------- #
|
|
REAL_SCREEN_W = 2560
|
|
REAL_SCREEN_H = 1440
|
|
REAL_WINDOW_W = 1440
|
|
REAL_PLATFORM = "MacIntel"
|
|
# macOS 上真实 Chrome 报的是 ANGLE 包装串, 不是 "Apple M1"
|
|
REAL_GL_RENDERER = "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)"
|
|
REAL_GL_VENDOR = "Google Inc. (Apple)"
|
|
# 真实 canvas 指纹 (成功样本原值, 368 bytes PNG)
|
|
REAL_CANVAS_FP = ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3"
|
|
"TG0qE2CogAAAABJRU5ErkJggg==")
|
|
REAL_UA = ("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")
|
|
|
|
_CANVAS_RE = re.compile(r'"[A-Za-z0-9+/]{40,}={0,2}"')
|
|
_RENDERER_RE = re.compile(r'"(?:ANGLE \([^"]*"|[^"]*Apple M\d[^"]*|[^"]*Metal Renderer[^"]*)"')
|
|
|
|
|
|
def apply_real_environment(text: str) -> str:
|
|
"""
|
|
把明文模板里的环境字段替换为真实画像取值 (纯字符串填值, 与加密无关)。
|
|
|
|
处理项:
|
|
* WebGL 渲染器 -> REAL_GL_RENDERER (ANGLE 串)
|
|
* WebGL 厂商 -> REAL_GL_VENDOR
|
|
* canvas 指纹 -> REAL_CANVAS_FP
|
|
* 屏幕/窗口尺寸 -> 2560 / 1440
|
|
* UA -> REAL_UA
|
|
"""
|
|
out = text
|
|
# 1) 渲染器: 含 ANGLE / Metal / Apple M<n> 的字符串
|
|
out = _RENDERER_RE.sub('"%s"' % REAL_GL_RENDERER, out, count=1)
|
|
# 2) 厂商: 只有 Apple / Google Inc. (Apple) 这类短串
|
|
out = re.sub(r'"(?:Apple|Google Inc\. \(Apple\)|Google Inc\.)"',
|
|
'"%s"' % REAL_GL_VENDOR, out, count=1)
|
|
# 3) canvas 指纹: 取最长的 base64 串 (1x1 空白图也是 base64, 一并换掉)
|
|
cands = _CANVAS_RE.findall(out)
|
|
if cands:
|
|
longest = max(cands, key=len)
|
|
out = out.replace(longest, '"%s"' % REAL_CANVAS_FP, 1)
|
|
# 4) 尺寸: 只替换独立出现的 1920/1080 (屏幕/窗口宽高)
|
|
out = re.sub(r'(?<![\d.])1920(?![\d.])', str(REAL_SCREEN_W), out)
|
|
out = re.sub(r'(?<![\d.])1080(?![\d.])', str(REAL_SCREEN_H), out)
|
|
out = re.sub(r'(?<![\d.])1440(?![\d.])', str(REAL_WINDOW_W), out)
|
|
# 5) UA
|
|
out = re.sub(r'"Mozilla/5\.0[^"]*"', '"%s"' % REAL_UA, out, count=1)
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 纯算主路径: 以「成功签到样本的明文」为结构模板 (服务端已认可的字段序与取值),
|
|
# Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。
|
|
# 环境字段 (canvas / WebGL / 屏幕 / 语言 / sd.ft) 原样保留真实取值。
|
|
# Node 的唯一职责 = 提取当前构建的 XTEA 密钥。
|
|
# --------------------------------------------------------------------------- #
|
|
_SUCCESS_TPL_PATH = Path(__file__).resolve().parent / "models" / "success_collect_template.b64"
|
|
_SD_TAIL_RE = re.compile(r'"sd":\{[^}]*\}')
|
|
|
|
|
|
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)]]
|
|
|
|
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=(",", ":"))
|
|
|
|
|
|
def generate_collect_success(
|
|
click_points: List[Tuple[int, int]],
|
|
key: List[int],
|
|
entry_url: str = "https://1ms.run/console/personal/benefits/check-in",
|
|
) -> str:
|
|
"""成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。"""
|
|
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 填充
|
|
return urllib.parse.quote_from_bytes(
|
|
base64.b64encode(xtea_encrypt_bytes(data, key)), 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 -> TDC URL 编码
|
|
加密与组包 100% 在 Python, Node 只提供密钥与一次性模板。
|
|
"""
|
|
text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template
|
|
|
|
text = apply_real_environment(text)
|
|
|
|
traj = generate_trajectory_payload(click_points, target_len=0).rstrip()
|
|
m = _TRAJ_RE.search(text)
|
|
if not m:
|
|
raise RuntimeError("模板里未找到轨迹数组, 无法回填")
|
|
text = text[:m.start()] + traj + text[m.end():]
|
|
|
|
if refresh_time:
|
|
now = int(time.time())
|
|
text = _EPOCH_RE.sub(str(now), 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_collect_pure(
|
|
tdc_js_code: str,
|
|
click_points: List[Tuple[int, int]],
|
|
aid: str = "2039519451",
|
|
user_agent: str = DEFAULT_USER_AGENT,
|
|
renderer: str = DEFAULT_RENDERER,
|
|
vendor: str = DEFAULT_VENDOR,
|
|
canvas_fp: str = DEFAULT_CANVAS_FP,
|
|
node_bin: str = "node",
|
|
) -> Tuple[str, List[int]]:
|
|
"""
|
|
**纯 Python 出包入口**: Node 只负责「跑出密钥 + 一次性标定明文模板」,
|
|
轨迹生成 / 组包 / XTEA 加密 / 编码 全部由 Python 完成。
|
|
|
|
两次 Node 调用均按脚本内容缓存 (同一构建只付一次成本)。
|
|
"""
|
|
try:
|
|
template, key = recover_payload_template(tdc_js_code, node_bin=node_bin)
|
|
return generate_collect_from_template(template, click_points, key), key
|
|
except Exception:
|
|
# 模板不适用 (例如两段式旧结构) -> 退回旧版结构 + 同一把密钥
|
|
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
|
|
return generate_collect_keyed(click_points, key, aid=aid, user_agent=user_agent,
|
|
renderer=renderer, vendor=vendor,
|
|
canvas_fp=canvas_fp), key
|
|
|
|
|
|
def _collect_segments(click_points: List[Tuple[int, int]], aid: str, user_agent: str,
|
|
renderer: str, vendor: str, canvas_fp: str) -> List[bytes]:
|
|
"""组装 collect 的 4 个明文分块 (与真实 tdc.js 一致)"""
|
|
now_sec = int(time.time())
|
|
rand_salt = random.randint(100000000, 999999999)
|
|
|
|
cd_pre = CD_PRE_RAW.ljust(48, " ")
|
|
|
|
traj_payload = generate_trajectory_payload(click_points, target_len=600)
|
|
|
|
cd_post_inner = (
|
|
f',"",'
|
|
f'"undefined?rand=1519713624347",'
|
|
f"1,"
|
|
f'"{canvas_fp}",'
|
|
f"{now_sec},"
|
|
f"{rand_salt},"
|
|
f"{now_sec},"
|
|
f'"",'
|
|
f"0,"
|
|
f'"{user_agent}",'
|
|
f'"top",'
|
|
f'"MacIntel",'
|
|
f'"98k",'
|
|
f"40,"
|
|
f'"",'
|
|
f'"{renderer}",'
|
|
f"0,"
|
|
f'"{vendor}",'
|
|
f"{now_sec},"
|
|
f'"+08",'
|
|
f'["zh-CN","zh","en"],'
|
|
f"1422008362,"
|
|
f"2,"
|
|
f"3,"
|
|
f'"https://turing.captcha.qcloud.com/cap_union_new_show?aid={aid}",'
|
|
f"0,"
|
|
f"24,"
|
|
f"22,"
|
|
f"1920,"
|
|
f"1,"
|
|
f"[360,360],"
|
|
f"1080],"
|
|
)
|
|
cd_post = cd_post_inner.ljust(504, " ")
|
|
|
|
sd = '"sd":{"od":"C"}}'
|
|
return [s.encode("latin1") for s in (cd_pre, traj_payload, cd_post, sd)]
|
|
|
|
|
|
def _assemble_collect(segments: List[bytes], key: Optional[List[int]] = None) -> str:
|
|
"""4 段分别加密 + base64 + 拼接 + TDC URL 编码。
|
|
|
|
每段长度都是 3 的倍数 (48 / 24k / 504), 末段 16 字节 -> 只有末尾一个 '==',
|
|
与真实样本结构一致 (块内出现 '=' 会导致服务端解析失败)。
|
|
"""
|
|
if key is None:
|
|
enc = [encrypt_string(s.decode("latin1")) for s in segments]
|
|
else:
|
|
enc = [base64.b64encode(xtea_encrypt_bytes(s, key)).decode("ascii") for s in segments]
|
|
return "".join(enc).replace("+", "%2B").replace("/", "%2F").replace("=", "%3D")
|
|
|
|
|
|
def generate_collect_keyed(
|
|
click_points: List[Tuple[int, int]],
|
|
key: List[int],
|
|
aid: str = "2039519451",
|
|
user_agent: str = DEFAULT_USER_AGENT,
|
|
renderer: str = DEFAULT_RENDERER,
|
|
vendor: str = DEFAULT_VENDOR,
|
|
canvas_fp: str = DEFAULT_CANVAS_FP,
|
|
) -> str:
|
|
"""用「从 tdc.js 提取到的本次构建密钥」纯 Python 生成 collect (标准 XTEA)"""
|
|
segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp)
|
|
return _assemble_collect(segments, key=key)
|
|
|
|
|
|
def generate_collect(
|
|
click_points: List[Tuple[int, int]],
|
|
aid: str = "2039519451",
|
|
user_agent: str = DEFAULT_USER_AGENT,
|
|
renderer: str = DEFAULT_RENDERER,
|
|
vendor: str = DEFAULT_VENDOR,
|
|
canvas_fp: str = DEFAULT_CANVAS_FP,
|
|
) -> str:
|
|
"""
|
|
用 crypto.py 内置的固定密钥 (DEFAULT_KEY + 两个历史加盐) 生成 collect。
|
|
等价于标准 XTEA + K' = [k0, k1+salt1, k2+salt2, k3]。
|
|
|
|
兼容性: 实测雨云 aid 直接接受 (errorCode=0), 说明服务端并不校验 collect 与 eks
|
|
的同源性; 需要严格场景请用 `generate_collect_keyed()` + `extract_xtea_key()`。
|
|
"""
|
|
segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp)
|
|
return _assemble_collect(segments, key=None)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 真实 tdc.js 执行路径 (推荐): 在 Node VM 沙箱中跑当前会话的 tdc.js
|
|
# --------------------------------------------------------------------------- #
|
|
def generate_telemetry_node(
|
|
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/",
|
|
frame_size: Tuple[int, int] = (360, 360),
|
|
display_scale: Optional[float] = None,
|
|
node_bin: str = "node",
|
|
timeout: float = 60.0,
|
|
) -> Dict[str, str]:
|
|
"""
|
|
在 Node 沙箱里执行真实 tdc.js, 产出与浏览器一致的 collect / tlg / eks。
|
|
|
|
为什么必须走这条路
|
|
------------------
|
|
tdc.js 每次加载都会重新混淆, 且其 XTEA 会话密钥并非固定常量 —— 用
|
|
`har/1ms_dianxuan/live/tdc_live.js` 产出的真实 collect 用 DEFAULT_KEY 解密
|
|
得到的是随机字节 (见 ANALYSIS.md 验证记录)。因此纯 Python 硬编码密钥的实现
|
|
只对当初逆向的那一份 tdc.js 有效, 换会话即失效; 而 Node 沙箱直接执行服务端
|
|
下发的 tdc.js, 拿到的是服务端自己认可的密文。
|
|
|
|
返回: {"collect": str, "tlg": str, "eks": str, "source": "node"}
|
|
"""
|
|
script = TDC_WORKER_PATH
|
|
if not script.exists():
|
|
raise FileNotFoundError(f"缺少 tdc_worker.js: {script}")
|
|
|
|
options = {
|
|
"aid": str(aid),
|
|
"referrer": referrer,
|
|
"userAgent": user_agent,
|
|
"screenWidth": 1920,
|
|
"screenHeight": 1080,
|
|
"availHeight": 1055,
|
|
"frameWidth": frame_size[0],
|
|
"frameHeight": frame_size[1],
|
|
"offsetX": 0,
|
|
"offsetY": 0,
|
|
}
|
|
if display_scale is not None:
|
|
options["displayScale"] = float(display_scale)
|
|
|
|
cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]),
|
|
json.dumps(options)]
|
|
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]}")
|
|
try:
|
|
payload = json.loads(out[-1])
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"tdc_worker 输出无法解析: {out[-1][:300]}") from exc
|
|
if payload.get("error"):
|
|
raise RuntimeError(f"tdc_worker 执行 tdc.js 失败: {payload['error'][:400]}")
|
|
collect = payload.get("collect") or ""
|
|
if not collect:
|
|
raise RuntimeError("tdc_worker 未产出 collect")
|
|
return {
|
|
"collect": collect,
|
|
"tlg": str(payload.get("tlg") or len(collect)),
|
|
"eks": payload.get("eks") or "",
|
|
"source": "node",
|
|
}
|
|
|
|
|
|
def generate_telemetry_browser(
|
|
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/",
|
|
frame_size: Tuple[int, int] = (360, 360),
|
|
display_scale: Optional[float] = None,
|
|
chrome_path: Optional[str] = None,
|
|
headless: bool = True,
|
|
node_bin: str = "node",
|
|
timeout: float = 120.0,
|
|
) -> Dict[str, str]:
|
|
"""
|
|
用 **真实 Chrome** (CDP 驱动) 执行 tdc.js, 产出环境指纹真实度最高的 collect / eks。
|
|
|
|
与 `generate_telemetry_node` 的区别: 后者在 Node VM 里伪造 DOM/BOM/Canvas,
|
|
实测答题正确后仍会被服务端判定为环境异常 (errorCode 12); 本函数把 tdc.js 放进
|
|
真实浏览器, 用 Input.dispatchMouseEvent 派发可信鼠标事件, 再读回 TDC.getData()。
|
|
"""
|
|
script = TDC_BROWSER_PATH
|
|
if not script.exists():
|
|
raise FileNotFoundError(f"缺少 tdc_browser.js: {script}")
|
|
|
|
options = {
|
|
"aid": str(aid),
|
|
"referer": referrer,
|
|
"userAgent": user_agent,
|
|
"frameWidth": frame_size[0],
|
|
"frameHeight": frame_size[1],
|
|
"offsetX": 0,
|
|
"offsetY": 0,
|
|
"headless": headless,
|
|
}
|
|
if chrome_path:
|
|
options["chromePath"] = chrome_path
|
|
if display_scale is not None:
|
|
options["displayScale"] = float(display_scale)
|
|
|
|
cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]),
|
|
json.dumps(options)]
|
|
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True, timeout=timeout)
|
|
out = (proc.stdout or "").strip().splitlines()
|
|
if not out:
|
|
raise RuntimeError(f"tdc_browser 无输出 (rc={proc.returncode}): {proc.stderr[:600]}")
|
|
try:
|
|
payload = json.loads(out[-1])
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"tdc_browser 输出无法解析: {out[-1][:300]}") from exc
|
|
if payload.get("error"):
|
|
raise RuntimeError(f"真实 Chrome 执行 tdc.js 失败: {payload['error'][:600]}")
|
|
collect = payload.get("collect") or ""
|
|
if not collect:
|
|
raise RuntimeError("真实 Chrome 未产出 collect")
|
|
return {"collect": collect, "tlg": str(payload.get("tlg") or len(collect)),
|
|
"eks": payload.get("eks") or "", "source": "browser"}
|
|
|
|
|
|
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",
|
|
) -> Dict[str, str]:
|
|
"""
|
|
统一遥测生成入口。
|
|
|
|
mode="python" : 纯 Python 算法还原 (内置固定密钥; 默认, 无 Node 依赖, ~1ms)
|
|
mode="python_keyed": **Node 仅提取本次构建的 XTEA 密钥, 加密全部在 Python**
|
|
mode="node" : Node VM 沙箱执行服务端下发的真实 tdc.js (~0.1s)
|
|
mode="browser" : 真实 Chrome + CDP 执行真实 tdc.js (~50s)
|
|
mode="auto" : python -> node -> browser 逐级回退
|
|
|
|
为什么默认仍然是纯 Python
|
|
------------------------
|
|
实测(2026-10-11)证明服务端 **并不按 tdc.js 的会话密钥解密 collect**:
|
|
雨云 aid=2039519451 + 纯 Python(硬编码 DEFAULT_KEY) 的 collect + 正确答案
|
|
→ errorCode=0 并返回真实 ticket (tr03jvqlp225MjNBxjDj_-mnM1VTS8O9)
|
|
而 `DEFAULT_KEY` 只对仓库里那份 tdc_sample.js 有效(能解出明文
|
|
`{"cd":[[],"1920-1080..."`), 对当天实时抓的同一 aid 的脚本已失效 —— 说明
|
|
tdc.js 的密码学参数(4 个密钥字 + 2829068/328986 两个轮加盐)是**每次构建随机**的,
|
|
但服务端并不校验收到的 collect 是否与 eks 同源。
|
|
|
|
因此纯 Python 方案在协议层完全够用; 需要 node/browser 的场景是目标 aid 启用了
|
|
更严格的风控(例如 1ms.run 的 aid=195672344 在答案正确时返回 errorCode=12),
|
|
详见 har/1ms_dianxuan/ANALYSIS.md。
|
|
"""
|
|
chain = {"python": ["python"], "python_keyed": ["python_keyed"],
|
|
"node": ["node"], "browser": ["browser"],
|
|
"auto": ["python", "python_keyed", "node", "browser"],
|
|
"auto_keyed": ["python_keyed", "node", "python"]}.get(mode, ["python"])
|
|
errors = []
|
|
for kind in chain:
|
|
try:
|
|
if kind == "browser":
|
|
return generate_telemetry_browser(tdc_js_code, click_points, aid=aid,
|
|
user_agent=user_agent, referrer=referrer)
|
|
if kind == "node":
|
|
return generate_telemetry_node(tdc_js_code, click_points, aid=aid,
|
|
user_agent=user_agent, referrer=referrer)
|
|
if kind == "python_keyed":
|
|
# Node 只负责提取当前构建的 XTEA 密钥; 明文/加密/组包全部纯 Python
|
|
key = extract_xtea_key(tdc_js_code)
|
|
collect = generate_collect_success(click_points, key)
|
|
return {"collect": collect, "tlg": str(len(collect)),
|
|
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
|
|
"key": key}
|
|
collect = generate_collect(click_points, aid=aid, user_agent=user_agent)
|
|
return {"collect": collect, "tlg": str(len(collect)),
|
|
"eks": extract_eks(tdc_js_code), "source": "python"}
|
|
except Exception as exc:
|
|
errors.append(f"{kind}: {exc}")
|
|
if mode != "auto":
|
|
raise
|
|
raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Self-test unit verification
|
|
test4 = '"sd":{"od":"C"}}'
|
|
exp4 = "nshM2N4iPvX6JRNXi68v5w=="
|
|
act4 = encrypt_string(test4)
|
|
assert act4 == exp4, f"XTEA unit test failed: {act4} != {exp4}"
|
|
|
|
test2 = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"", '
|
|
exp2 = "9qJOPk+13StWMa2FQroCP6yba6nGw0BmJaarCCtGtLi0Wfg7WQyVXwMoFAjWcXAj"
|
|
act2 = encrypt_string(test2)
|
|
assert act2 == exp2, f"XTEA unit test failed: {act2} != {exp2}"
|
|
|
|
sample_pts = [(626, 253), (120, 120), (438, 33)]
|
|
sample_collect = generate_collect(sample_pts)
|
|
print(f"XTEA Unit Tests: PASSED")
|
|
print(f"Sample collect length: {len(sample_collect)}")
|
|
print(f"Sample collect prefix: {sample_collect[:80]}...")
|