feat(密钥): 纯 Python 静态提取 XTEA 密钥 —— 去掉最后一个 Node 依赖 (6/6 构建与神谕逐字一致)

做了什么
- 新增 tcaptcha/tdc_opcodes.py: 按构建从解释器源码推导 opcode 表。
  实测 opcode 编号**每份构建整体重排**(同一源码 pc0 的 SET_STACK_LEN 在 6 份构建里
  = 53/19/32/57/55/1, JUMP = 60/17/42/58/21/2); 旧固化表只对 core_js 有效 ——
  用它反汇编其它构建会在 pc0 撞上 RET_TRUE, 得到"空反汇编"的假象。
  做法: 定位主循环 X[Y[Z++]]() 的 handler 数组 -> JS 感知切分 -> handler 源码规范化
  (去空白 + 局部变量按首次出现重命名) 取指纹 -> 与内置 58 条 core_js 参考指纹匹配;
  数组下标即该构建的 opcode, argc 由语义名取 (与 handler 体内 x[y++] 次数实测一致)。
- 新增 tcaptcha/tdc_key.py: 纯 Python XTEA 密钥静态还原器 (零 Node / 零 pc 硬编码)。
  反汇编 -> 定位含 delta 的 XTEA 闭包 -> 用"两层捕获链"选出真密钥数组:
  字闭包捕获的 B 帧寄存器, 经"定义 B 的那个闭包"的 caps 映射到 G 帧后须 == XTEA 闭包
  捕获的寄存器; 借此排除脚本里并行计算的第二套诱饵字 -> 读 4 个 (源串, 偏移, 下标)
  -> 折入轮加盐。
- tools/tdc_disasm.py: disassemble_cfg/_decode_one 支持注入按构建推导的 opcode 表
  (names/arity/dynamic), 默认仍回落 core_js 表; 控制流 opcode 改为按语义名定位。
- tools/tdc_key_pure.py: CLI (-v 打印推导过程 / --opcodes 打印 opcode 表 / --json)。
- tcaptcha/crypto.py: extract_xtea_key 改为"纯 Python 主路径 + Node 插桩兜底"。
- 新增 tcaptcha/tests/test_tdc_key.py 并接入 selftest;
  新增入库 fixture tcaptcha/tests/fixtures/tdc_fresh_2026-10-11.js (现场新抓的 tdc.js)。

为什么
- AGENTS.md §2.4 分层交付 / §1.1: 去掉最后一个 Node 依赖, 提高并发与抗发版能力;
- §5.7 负结果也是资产: 显式撤回"opcode 数值跨构建固定"与"密钥只能靠 Node 插桩拿"两条旧结论。

怎么验证
- 6 份构建纯 Python 结果 == Node 插桩 extract_xtea_key, **逐字一致**:
  core_js / root / 1ms tdc.js / tdc_live / tdc_latest / 2026-10-11 现场新抓 tdc_fresh。
- opcode 表 6/6 构建 58/58 handler 精确匹配, 0 未识别。
- .venv/bin/python -m tcaptcha.selftest 全绿 (含既有逐字节对拍 test_xtea_key)。
- 把 node_bin 指向不存在的程序时 generate_telemetry 仍产出 collect (证明已零 Node)。
This commit is contained in:
yml2213
2026-10-11 22:49:23 +08:00
parent 9286f77661
commit 2e2bbc876a
11 changed files with 1058 additions and 30 deletions
File diff suppressed because one or more lines are too long
+108
View File
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""
纯 Python XTEA 密钥提取 回归测试 (AGENTS.md §5.9)
固化的性质:
1. **跨构建通用**: opcode 编号每份构建整体重排, 但仍能按构建推导出正确语义表;
2. **纯 Python == Node 神谕**: 6 份构建(含 1 份新抓的实时 tdc.js)的期望密钥
全部由 Node `extract_xtea_key` 逐字对照确认过, 此处固化为断言 (不再依赖 Node 跑测试);
3. **定位不写死 pc**: 断言覆盖 core_js / root / 1ms(旧+两份实时) / 实时新抓 共 6 份
不同构建, 任一份 opcode 重排或寄存器分配变化都会立刻暴露。
运行:
.venv/bin/python -m tcaptcha.tests.test_tdc_key
"""
from __future__ import annotations
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))
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
from tcaptcha.tdc_opcodes import derive_opcode_table # noqa: E402
# (相对路径, 期望有效密钥, 是否必须存在)
# 期望值来自 Node 插桩 extract_xtea_key, 逐字一致。
# 入库样本 (core_js / root / 实时新抓 fixture) 为必需门禁; har/** 被 .gitignore 忽略,
# 本地有这些样本时会一并回归, 缺少时自动跳过。
FIXTURES = [
("tcaptcha/core_js/tdc_sample.js", [1818510928, 1381395306, 1245924197, 1768384072], True),
("tcaptcha/tdc_sample.js", [1448563793, 1850559083, 1733513813, 1364613714], True),
# 2026-10-11 现场从 turing.captcha.qcloud.com 下发的 tdc.js (入库固化, 防"仅本地可验")
("tcaptcha/tests/fixtures/tdc_fresh_2026-10-11.js",
[1397244740, 1799507557, 1399740484, 1516463189], True),
("har/1ms_dianxuan/tdc.js", [1448106085, 1766670948, 1819173482, 1298358636], False),
("har/1ms_dianxuan/live/tdc_live.js", [1314475846, 1500397933, 1751410275, 1716217423], False),
("har/1ms_dianxuan/live/tdc_latest.js", [1800883800, 1850828643, 1197042522, 1800823125], False),
]
_fails: list = []
def check(name: str, cond: bool, detail: str = "") -> None:
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
if not cond:
_fails.append(name)
def main() -> int:
print("1) 按构建推导 opcode 表 (58 个 handler, 无未识别)")
tables = {}
skipped = []
for rel, _expect, required in FIXTURES:
p = ROOT / rel
if not p.exists():
if required:
check(f"fixture {rel}", False, "缺失")
else:
skipped.append(rel)
continue
names, arity, dynamic, diag = derive_opcode_table(p.read_text(encoding="utf-8", errors="replace"))
tables[rel] = names
check(f"opcode 表 {Path(rel).name}",
diag["handler_count"] == 58 and not diag["unknown_slots"],
f"handlers={diag['handler_count']} unknown={diag['unknown_slots']}")
if skipped:
print(f" (跳过本地未提供的样本 {len(skipped)} 份: {[Path(s).name for s in skipped]})")
print("\n2) opcode 编号确实每份构建都不同 (证明没有依赖写死的数值)")
jump_ops = {rel: next(o for o, n in tabs.items() if n == "JUMP") for rel, tabs in tables.items()}
if len(jump_ops) >= 2:
check("JUMP 的 opcode 跨构建不统一", len(set(jump_ops.values())) >= 2, str(jump_ops))
print("\n3) 纯 Python 提取 == Node 神谕 (逐字)")
for rel, expect, required in FIXTURES:
p = ROOT / rel
if not p.exists():
continue
js = p.read_text(encoding="utf-8", errors="replace")
try:
rep = extract_xtea_key_pure(js)
check(Path(rel).name, rep["key"] == expect, f"{rep['key']}")
except Exception as exc: # noqa: BLE001
check(Path(rel).name, False, f"{type(exc).__name__}: {exc}")
print("\n4) 密钥构造自洽: base + 折盐 == key")
for rel, expect, required in FIXTURES:
p = ROOT / rel
if not p.exists():
continue
rep = extract_xtea_key_pure(p.read_text(encoding="utf-8", errors="replace"))
folded = list(rep["base"])
for idx, salt in rep["salts"]:
folded[idx] = (folded[idx] + salt) & 0xFFFFFFFF
check(f"折盐 {Path(rel).name}", folded == rep["key"] == expect)
print()
if _fails:
print(f"❌ test_tdc_key 失败 {len(_fails)} 项: {_fails}")
return 1
print("✅ test_tdc_key 全部通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())