"""虎牙 App 新设备注册链(零设备 dfpReport 生成 + 注册响应解析)。 每次登录前执行 ``getDfpConfig -> selectOperator -> dfpReport``: * ``dfpReport`` 使用已验证的 4146 字节随机 ``cw``(零设备生成,不重放旧设备报文); * 服务端返回新的 ``safedeviceid``(t2)和 ``device_id``(t5)—— 这两个字段 就是 WUP 登录帧的设备字段签发源; * 请求模板只承载 TAF/WUP 协议形状,旧的 dfpReport 密文不会被使用。 请求按已验证的 TAF/WUP 字段布局动态编码;不依赖抓包证据文件。selectOperator 中的 RSA 字段使用协议固定格式的密文种子,设备画像字段在每次请求时写入。 错误语义: 链上任何一步失败(请求编码、HTTP 异常、响应缺字段)都会抛 ``DfpRegistrationError``。调用方(core/huya/app_login)应把注册失败当作明确失败 终止登录;禁止静默回退到画像里的旧固定 safedeviceid/device_id。 ⚠ 名称混淆提示(详见 ``docs/HUYA_APP_OVERVIEW.md`` §二 / R15, R39 定案): - 这里签发的 t2 = 登录帧 safedeviceid、t5 = 登录帧 device_id(40hex); - 登录帧 t1.t0 (32hex appSign) **已破解且与设备无关**: md5("5008_13.4.22_" + k1) = ed0db8334cadd236c00cadf7e11ab5a5 (k1 为 datadiv 内嵌常量, 同版本所有真机同值), 由 core/huya/app_login.py 直接使用; 本模块响应里的 t1 (服务端回显) 在 app_login 中被丢弃 (_t1), 注册链结果不依赖它; - ``_random_triple`` 的 64hex 占位只是 cw JSON 模板形态 — 真实 dfpReport 指纹 JSON (R36 解密 12 样本) 无 hdid 字段 (顶层 Athena/deviceinfo/terminal/version)。 """ from __future__ import annotations import hashlib import os import re import struct import time from collections.abc import Mapping import requests from .taf_protocol import TafOutputStream from .wup_protocol import WupRequest WSAPI = "https://wsapi.huya.com" UA = "okhttp/3.14.9" SELECT_OPERATOR_SDID = ( "PQwemAN9NHkZKoMqVeJmXhIypqMTaQEOrmXr37xQVhQZqrPxjEKEQ11xvE02qawcys/" "iwl5gSIa6nYz0L5tNl0VdpZFyNjNHCTnQtzCwD5RHKKOL+DYohiPvAFPblxznlGyB63NAY74u" "2q9Niej6bLDarc9xAKbAuLzRb7hHIGxCTBp5uGf3" ) TAF_HEAD = bytes.fromhex( "10032c3c4c56" "0c687579617564627765627569" "66" "096466705265706f7274" "7d00011056" "0800010604" "74526571" "1d00011048" "0a060016" "07616e64726f6964" "2d0001" "1032" ) MAGIC = bytes.fromhex("571882cf664bb39401ee") CW_TAIL = bytes.fromhex("3600400c0b8c980ca80c") CW_JSON_LEN = 586 CW_COLL_LEN = 3548 CW_LEN = 2 + CW_JSON_LEN + CW_COLL_LEN + len(CW_TAIL) class DfpRegistrationError(RuntimeError): """新设备注册链失败。""" def _build_get_dfp_config_request() -> bytes: """编码 getDfpConfig 请求(67 字节 TAF/WUP)。""" wup = WupRequest() wup.setServant("huyaudbwebui") wup.setFunc("getDfpConfig") wup.iTimeout = 0 wup.writeStruct("tReq", {"app_id": "5008"}) return wup.encode() def _build_select_operator_request( fingerprint: str | None = None, device_info: Mapping[str, str] | None = None, ) -> bytes: """按 selectOperator 的四段结构动态编码请求。""" dev = { "app_version": "13.4.22", "model": "M2102J2SC", "fingerprint": fingerprint or "02df398797432eadefcc12767119ad5e80999389", "screen": "M2102J2SC,30,11", } if device_info: for key in dev: if device_info.get(key): dev[key] = str(device_info[key]) body = TafOutputStream() body.write_struct_begin(0) body.write_struct_begin(0) body.write_int8(0, 0) body.write_string(1, "1.0") body.write_string(2, f"hyudb_{int(time.time() * 1000)}") body.write_string(3, "5008") body.write_int8(4, 0) body.write_string(5, SELECT_OPERATOR_SDID) for tag in range(6, 10): body.write_string(tag, "") body.write_struct_end() body.write_struct_begin(1) body.write_string(0, "") body.write_string(1, dev["app_version"]) body.write_string(2, "2.0.8") for tag in range(3, 7): body.write_string(tag, "") body.write_struct_end() body.write_struct_begin(2) body.write_int8(0, 1) body.write_string(1, dev["model"]) body.write_string(2, dev["fingerprint"]) body.write_string(3, "android") body.write_string(4, dev["screen"]) body.write_string(6, "") body.write_string(7, "") body.write_struct_end() body.write_list(3, [0, 1, 3]) body.write_struct_end() wup = WupRequest() wup.setServant("huyaudbwebui") wup.setFunc("selectOperator") wup.iTimeout = 0 wup.newdata["_wup_data"] = body.get_bytes() return wup.encode() def _load_chain( fingerprint: str | None = None, device_info: Mapping[str, str] | None = None, ) -> dict[str, tuple[bytes, bytes]]: """生成注册链请求,保留 tuple 形状兼容旧调用方和测试。""" return { "getDfpConfig": (_build_get_dfp_config_request(), b""), "selectOperator": ( _build_select_operator_request(fingerprint, device_info), b"", ), "dfpReport": (b"", b""), } def _post( body: bytes, content_type: str = "application/octet-stream", timeout: float = 20, proxies: Mapping[str, str] | None = None, ) -> bytes: try: response = requests.post( WSAPI, data=body, headers={ "Content-Type": content_type, "User-Agent": UA, "Accept-Encoding": "gzip", }, timeout=timeout, proxies=dict(proxies) if proxies else None, ) response.raise_for_status() return response.content except requests.RequestException as exc: raise DfpRegistrationError(f"注册链 HTTP 请求失败: {exc}") from exc def _random_triple() -> tuple[str, str, str]: """生成 dfp JSON 中的 40hex/40hex/64hex 三元组。""" return ( hashlib.sha256(os.urandom(32) + b"hdid").hexdigest(), hashlib.sha256(os.urandom(32) + b"devid").hexdigest(), hashlib.sha256(os.urandom(32) + b"appkey").hexdigest(), ) def _build_random_dfp_body( device_info: Mapping[str, str] | None = None, ) -> bytes: """构造服务端接受的随机 dfpReport 请求体。 cw 中 JSON 段明文仅承载真实请求的段长度与三元组形态,实证(2026-08-27) 服务端不校验 cw 内容,随机 cw 照样 200 + 新签发 t2/t5,因此三元组 (hdid/device_id/appkey,各 64hex sha256)只为凑齐模板形态,属诊断用途; device_id、hdid 在画像存在时分别使用画像 device_id、guid32。 """ json_plain = _build_dfp_json_plain(device_info) seed = json_plain if len(seed) > CW_JSON_LEN: # 保持已验证的固定 cw 段长度,同时让完整画像参与本次随机报文。 seed = hashlib.sha256(seed).digest() json_sec = os.urandom(CW_JSON_LEN) if len(seed) <= CW_JSON_LEN: mask = os.urandom(len(seed)) json_sec = bytes(a ^ b for a, b in zip(seed, mask)) + json_sec[len(seed) :] cw = os.urandom(2) + json_sec + os.urandom(CW_COLL_LEN) + CW_TAIL if len(cw) != CW_LEN: raise DfpRegistrationError(f"dfpReport cw 长度异常: {len(cw)}") body = ( struct.pack(">I", len(TAF_HEAD) + len(MAGIC) + len(cw) + 4) + TAF_HEAD + MAGIC + cw ) if len(body) != 4226: raise DfpRegistrationError(f"dfpReport body 长度异常: {len(body)}") return body def _build_dfp_json_plain(device_info: Mapping[str, str] | None = None) -> bytes: """生成 dfpReport 加密段使用的画像 JSON 明文。""" hdid, device_id, appkey = _random_triple() dev = device_info or {} app_version = str(dev.get("app_version") or "13.4.22") vendor = str(dev.get("vendor") or "xiaomi") model = str(dev.get("model") or "M2102J2SC") screen = str(dev.get("screen") or "M2102J2SC,30,11") width = str(dev.get("width") or "1080") height = str(dev.get("height") or "2120") # 注册前的 deviceId/guid 使用账号画像;缺失时才生成一次性形态值。 device_id = str(dev.get("device_id") or device_id) hdid = str(dev.get("guid32") or hdid) json_plain = ( f'{{"appId":"5008","appVer":"{app_version}","appkey":"{appkey}",' f'"channel":"{vendor}","deviceId":"{device_id}",' f'"deviceName":"{model}","hdid":"{hdid}",' f'"heightPixels":"{height}","isCloud":0,"isForbidLog":1,"isHome":0,' '"isPre":0,"openAppId":"","savePath":"/data/user/0/com.duowan.kiwi/files/huyaudb",' '"sdkVer":"1.0.80138","servantName":"huyaudbwebui",' '"shareAppDataPath":"/data/user/0/com.duowan.kiwi/files/huyaudb",' f'"systemInfo":"android","systemVer":"{screen}",' f'"terminalType":1,"testEnv":0,"widthPixels":"{width}"}}' ) return json_plain.encode("utf-8") def _select_operator_request(template: bytes, fingerprint: str | None) -> bytes: if fingerprint and len(fingerprint) == 40: old = b"02df398797432eadefcc12767119ad5e80999389" index = template.find(old) if index >= 0: return ( template[:index] + fingerprint.encode("ascii") + template[index + len(old) :] ) return template def _parse_response(data: bytes) -> tuple[str, str, str]: try: t1_match = re.search(rb"\x16\x20([0-9a-f]{32})", data) t2_match = re.search(rb"\x26\xb4([A-Za-z0-9+/=]{180})", data) t5_match = re.search(rb"\x56\x28([0-9a-f]{40})", data) if not t1_match or not t2_match or not t5_match: raise ValueError("missing response fields") t1 = t1_match.group(1).decode() t2 = t2_match.group(1).decode("latin1") t5 = t5_match.group(1).decode() except (AttributeError, ValueError) as exc: raise DfpRegistrationError("dfpReport 响应缺少 t1/t2/t5") from exc return t1, t2, t5 def register_device( fingerprint: str | None = None, proxies: Mapping[str, str] | None = None, timeout: float = 20, device_info: Mapping[str, str] | None = None, ) -> tuple[str, str, str]: """执行新注册链,返回 ``(t1, safedeviceid, device_id)``。""" chain = _load_chain(fingerprint=fingerprint, device_info=device_info) _post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies) select_request = _select_operator_request(chain["selectOperator"][0], fingerprint) _post(select_request, "application/x-wup", timeout, proxies) response = _post( _build_random_dfp_body(device_info), timeout=timeout, proxies=proxies ) return _parse_response(response)