"""虎牙 App 渠道协议登录模块。 流程: 1. 零设备注册链生成随机 dfpReport,获取新 safedeviceid/device_id 2. 账号+密码+新注册字段 -> WUP 密码登录 (POST wup.huya.com) 3. safe_auth 滑块自动过验 -> 提取 fresh cred 与真实 uid 4. 本地 XXTEA 算 nonce -> 铸造登录证书 (cert_forge) -> 补丁 WUP 信封 (envelope_forge) 5. 模拟扫码绑定四步流 (getQrId -> scanQrPicNotify -> bindQrLoginUser -> tryQrLogin) 获取 biztoken 6. POST /web/cookie/verify 兑换获取全套网页 Cookie 注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚, 当前继续使用已注册样本;新注册链动态更新的是 safedeviceid 和 device_id。 注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``), 不读取画像里的旧固定值,也不静默回退旧链。 ⚠ 风险与边界(详见 ``docs/HUYA_APP_OVERVIEW.md``): - hdid 全账号固定共享 -> 服务端可设备维度关联账号; - 滑块 UA / session / traceId / userAction 均按当前画像和本次请求动态生成; - qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。 """ from __future__ import annotations import base64 import hashlib import json import random import re import struct import time import uuid from collections.abc import Mapping from urllib.parse import parse_qs, quote, urlparse import requests from loguru import logger from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1 from .cookie_utils import normalize_huya_cookie from .device_fingerprint import account_state_dir, get_huya_sdid from .device_profile import get_profile, mobile_user_agent from .dfp_register import DfpRegistrationError, register_device from .envelope_forge import Envelope from .login import HuyaLoginError, HuyaLoginResult from .nonce_forge import K1_DEFAULT, gen_nonce from .wup_encoder import build_password_login_wup WUP_URL = "https://wup.huya.com" UDB_BASE = "https://udblgn.huya.com" APP_SIGN_WEB = "1ce3bf682483d03f146f58232ec10635" APP_SIGN_H5 = "0ba67962ab9e12387648efeae2750777" UA_PC = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36" ) APP_UA_MOBILE = ( "Mozilla/5.0 (Linux; Android 11; M2102J2SC Build/RKQ1.200826.002; wv) " "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " "Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30" ) RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+") _URL_TAIL_KEEP = set( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%" ) DEFAULT_GOLDEN_DEV = { "app_version": "13.4.22", "sdk_version": "1.0.80138", "vendor": "xiaomi", "model": "M2102J2SC", "os": "android", "ip": "127.0.0.1", "fingerprint": "02df398797432eadefcc12767119ad5e80999389", "screen": "M2102J2SC,30,11", "width": "1080", "height": "2120", "device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee", "hdid": "ed0db8334cadd236c00cadf7e11ab5a5", # HDID32 登录t1.t0 (勿与GUID32混) } class HuyaAppLoginError(HuyaLoginError): """虎牙 App 登录失败。""" class HuyaAppQrAuthRequiredError(HuyaAppLoginError): """风控要求扫码验证 (qr_auth)。""" def _golden_session_assets() -> tuple[dict, str, str]: """生成本次登录的会话元数据和行为轨迹。""" from .wup_encoder import make_trace_id, make_user_action mj = { "associationId": 8193, "funcName": "hypasswordLogin", "group": 1, "id": 4097, "session": random.randint(1_000_000, 9_999_999), "step": 1, "stillLogin": False, "traceId": make_trace_id(), "type": 3, "uid": 0, "userContext": "", } return mj, make_user_action(), "" def _android_version(device_info: Mapping[str, object]) -> str: parts = str(device_info.get("screen") or "").split(",") return parts[2] if len(parts) >= 3 and parts[2] else "11" def wup_password_login_raw( account: str, password: str, timeout: int = 15, device_info: dict | None = None, safedeviceid: str | None = None, hdid: str | None = None, proxies: dict | None = None, ) -> bytes: """发送 WUP 密码登录,返回原始响应字节。 未显式传入 ``safedeviceid`` 时会先执行新设备注册链,不再回退旧的 固定 action/device_id。风控重试调用方应显式复用同一注册结果。 注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。 """ uid_str = account[3:] if account.startswith("hy_") else account dev = dict(device_info) if device_info is not None else get_profile(account) mj, ua, _old_sd = _golden_session_assets() if not safedeviceid: try: _t1, safedeviceid, registered_device_id = register_device( fingerprint=dev.get("fingerprint"), proxies=proxies, timeout=timeout, device_info=dev, ) except DfpRegistrationError as exc: raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc dev["device_id"] = registered_device_id pkt = build_password_login_wup( uid_str, hashlib.sha1(password.encode()).hexdigest(), safedeviceid, hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5", mj["session"], mj["traceId"], ua, dev, ) r = requests.post( WUP_URL, data=pkt, headers={ "Content-Type": "application/multipart-formdata; charset=UTF-8", "User-Agent": f"Dalvik/2.1.0 (Linux; U; Android {_android_version(dev)})", "Accept-Encoding": "gzip", }, proxies=proxies, timeout=timeout, ) if r.status_code != 200: raise HuyaAppLoginError(f"登录 HTTP 状态异常: {r.status_code}") return r.content def parse_cred(resp: bytes) -> bytes | None: """从 WUP 响应中提取 114 字节的 cred。""" s = resp.find(b"\x0a\x0a", 0x40) e = resp.find(b"_wup_header") if s < 0 or e < 0: return None d = resp[s : e - 6] m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d) if not m: return None ln = m.group(2)[0] st = m.start() + 4 cred = d[st : st + ln] if len(cred) == 114 and cred[:1] == b"\x0a": return cred return None def parse_risk_url(resp: bytes) -> str | None: """提取 safe_auth 风控 URL。""" urls = [] for m in RISK_URL_RE.finditer(resp): u = m.group().decode("utf-8", "ignore") while u and u[-1] not in _URL_TAIL_KEEP: u = u[:-1] urls.append(u) if not urls: return None pt = [u for u in urls if "pt_auth" in u] return (pt or urls)[0] def solve_safe_auth( risk_url: str, proxies=None, max_retry: int = 3, device_info: Mapping[str, object] | None = None, ) -> dict: """解 safe_auth 风控滑块。""" from .verification.solver import ( HuyaQrAuthRequiredError, HuyaVerificationSolver, ) q = { k: v[0] for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items() } app_id = str(q.get("appId") or "5002") last_err: Exception | None = None for attempt in range(max_retry): solver = HuyaVerificationSolver( ua=mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV), proxies=proxies, app_id=app_id, page_url=risk_url, use_touch_events=True, ) try: result = solver.solve(risk_url) except HuyaQrAuthRequiredError: raise except Exception as exc: last_err = exc logger.warning(f"[safe_auth] 第 {attempt + 1} 次过验异常: {exc}") time.sleep(1.0) continue auth_id = str((result or {}).get("authId") or "") if auth_id: logger.info(f"[safe_auth] 滑块通过 authId={auth_id[:24]}...") return result raise HuyaAppLoginError(f"safe_auth 滑块过验失败: {last_err or '未返回 authId'}") def parse_real_uid(resp: bytes) -> int: """从登录响应中提取 12 位真实 UID。""" s = resp.find(b"\x0a\x0a", 0x40) e = resp.find(b"_wup_header") if s >= 0 and e >= 0: chunk = resp[s:e] if len(chunk) > 243: uid = struct.unpack_from(">Q", chunk, 235)[0] if 1_100_000_000_000 < uid < 1_300_000_000_000: return uid for off in range(len(chunk) - 8): u = struct.unpack_from(">Q", chunk, off)[0] if 1_100_000_000_000 < u < 1_300_000_000_000: return u raise HuyaAppLoginError("无法从登录响应解析真实 UID") def login_cred_with_flow( account: str, password: str, max_rounds: int = 3, device_info: dict | None = None, proxies: dict | None = None, ) -> tuple[bytes, int]: """新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。 注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。 """ dev = dict(device_info) if device_info is not None else get_profile(account) try: _t1, safedeviceid, registered_device_id = register_device( fingerprint=dev.get("fingerprint"), proxies=proxies, device_info=dev, ) except DfpRegistrationError as exc: raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc dev["device_id"] = registered_device_id for rnd in range(max_rounds): resp = wup_password_login_raw( account, password, device_info=dev, safedeviceid=safedeviceid, proxies=proxies, ) cred = parse_cred(resp) if cred: uid = parse_real_uid(resp) return cred, uid risk_url = parse_risk_url(resp) if risk_url: kind = ( "pt_auth(滑块)" if "pt_auth" in risk_url else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知") ) logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}") if "qr_auth" in risk_url: raise HuyaAppQrAuthRequiredError( f"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。" ) solve_safe_auth(risk_url, proxies=proxies, device_info=dev) logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...") continue raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)") raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据") class QrRole: """udblgn.huya.com/qrLgn/* 的 JSON 协议封装。""" def __init__( self, pc: bool, sdid: str, proxies: dict | None = None, device_info: Mapping[str, object] | None = None, ): self.pc = pc self.sdid = sdid self.s = requests.Session() self.s.trust_env = False if proxies: self.s.proxies.update(proxies) ctx_hex = uuid.uuid4().hex tail = "CBC9F93DBEB000011DB11EC02A401BAD-" if pc else uuid.uuid4().hex.upper() tail = tail if tail.endswith("-") else tail + "-" prefix = "WB" if pc else "H5" self.context = f"{prefix}-{ctx_hex}-{tail}" self.page_id = random.randint(40_000_000, 41_000_000) self.req_counter = random.randint(40_000_000, 41_000_000) self.s.headers.update( { "User-Agent": UA_PC if pc else mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV), "Origin": UDB_BASE, "content-type": "application/json;charset=UTF-8", "Accept": "*/*", } ) def _headers(self, uri: str) -> dict: mid = "2.6" if self.pc else "2.5" return { "context": self.context, "uri": uri, "reqid": str(self.req_counter), "lcid": "2052", "Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}", } def call( self, path: str, uri: str, data: dict, cookies: dict | None = None ) -> dict: envelope = { "uri": uri, "version": "2.6" if self.pc else "2.5", "context": self.context, "appId": "5002" if self.pc else "5131", "appSign": APP_SIGN_WEB if self.pc else APP_SIGN_H5, "authId": "", "sdid": self.sdid, "lcid": "2052", "byPass": "3", "requestId": str(self.req_counter), "data": data, } self.req_counter += random.randint(120, 400) r = self.s.post( f"{UDB_BASE}{path}", json=envelope, headers=self._headers(uri), cookies=cookies, timeout=20, ) return r.json() def web_behavior(page: str = "https://www.huya.com/g") -> tuple[str, str]: now = int(time.time() * 1000) - random.randint(3000, 10000) acts = [] d = random.randint(800, 1800) for a in ("7", "7", "8", "8"): now += random.randint(600, 2000) d += random.randint(600, 2000) acts.append({"id": a, "d": d, "time": now}) val = {"furl": page, "curl": page, "user_action": acts} beh = quote(json.dumps(val, separators=(",", ":")), safe="~()*!.'") return beh, quote(page, safe="") class HuyaAppPasswordLogin: """虎牙 App 渠道纯协议密码登录器。""" def __init__( self, username: str, password: str, proxies: Mapping[str, str] | None = None, timeout: tuple[float, float] | None = None, force_new_device: bool = False, device_info: dict | None = None, ): self.username = username.strip() self.password = password self.proxies = dict(proxies) if proxies else None self.timeout = timeout or (10.0, 25.0) self.force_new_device = force_new_device self.device_info = device_info or get_profile( self.username, force_new=force_new_device ) def login(self) -> HuyaLoginResult: """执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。""" result = self._login_impl() try: from .device_profile import record_login record_login(self.username, result.success, result.message) except Exception: # 元数据记录失败不影响登录结果 pass return result def _login_impl(self) -> HuyaLoginResult: """登录主体 (原 login)。""" acct = self.username logger.info( f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})..." ) # 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块) try: cred, uid = login_cred_with_flow( acct, self.password, device_info=self.device_info, proxies=self.proxies, ) except HuyaAppQrAuthRequiredError as exc: return HuyaLoginResult( success=False, message=str(exc), code="QR_AUTH_REQUIRED", ) except Exception as exc: return HuyaLoginResult( success=False, message=f"App 登录凭证获取失败: {exc}", code="LOGIN_FAILED", ) logger.info(f"[huya-app] 成功获取 cred ({len(cred)}B), uid={uid}") # 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致) try: env = Envelope.load() orig = base64.b64decode(env.cert_b64) f = parse_p1(decrypt_cert(orig)) st = int(time.time() * 1000) rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0) fp_bytes = self.device_info["fingerprint"].encode("ascii") p1 = build_p1(f["app_id"], fp_bytes, cred, rnd=rnd) cert = base64.b64encode(forge_cert(p1, key_idx=orig[1])).decode() # 3) 信封补丁 raw = bytearray(env.raw) if env.cert_off is None or env.uid_off is None: raise ValueError("信封缺少证书或 uid 偏移") raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii") if env.uid != uid: struct.pack_into(">Q", raw, env.uid_off, uid) wup = base64.b64encode(bytes(raw)).decode("ascii") except Exception as exc: return HuyaLoginResult( success=False, message=f"证书铸造/信封补丁失败: {exc}", code="CERT_FORGE_FAILED", ) # 4) 模拟扫码绑定四步流获取 biztoken try: # 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享 # 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复) sdid_obj = get_huya_sdid( allow_fallback=True, state_dir=account_state_dir(self.username), device_hint=self.device_info, ) sdid = sdid_obj.sdid if sdid_obj else "" pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies) ph = QrRole( pc=False, sdid=sdid, proxies=self.proxies, device_info=self.device_info, ) beh, page = web_behavior() # 4.1 获取 qrId resp = pc.call( "/qrLgn/getQrId", "70001", {"behavior": beh, "type": "", "domainList": "", "page": page}, ) qrid = (resp.get("data") or {}).get("qrId") if not qrid: return HuyaLoginResult( success=False, message=f"获取 qrId 失败: {resp.get('message') or resp}", code="QR_ID_FAILED", ) # 4.2 扫码与绑定 cp = f"https://aq.huya.com/r/confirm.html?k={qrid}&id=5002" ph.call( "/qrLgn/scanQrPicNotify", "70005", { "qrId": qrid, "wupData": wup, "behavior": quote("[]", safe=""), "page": quote(cp, safe=""), }, ) r2 = ph.call( "/qrLgn/bindQrLoginUser", "70007", { "qrId": qrid, "wupData": wup, "behavior": quote("[]", safe=""), "page": quote(cp, safe=""), }, ) if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0: logger.warning( f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}" ) # 4.3 轮询 tryQrLogin biztoken = None for _ in range(12): rt = pc.call( "/qrLgn/tryQrLogin", "70003", { "qrId": qrid, "remember": "1", "domainList": "", "behavior": beh, "page": page, }, ) dt = rt.get("data") or {} if dt.get("stage") == 2: biztoken = dt.get("biztoken") break time.sleep(1.5) if not biztoken: return HuyaLoginResult( success=False, message="未能在轮询时间内获取到 biztoken (绑定超时或失败)", code="BIZTOKEN_TIMEOUT", ) # 5) POST /web/cookie/verify 兑换 Cookie verify_resp = pc.s.post( "https://udblgn.huya.com/web/cookie/verify", json={"appId": 5002}, timeout=15, ) if verify_resp.status_code != 200: return HuyaLoginResult( success=False, message=f"Cookie verify 兑换 HTTP 失败: {verify_resp.status_code}", code="VERIFY_FAILED", ) cookie_str = normalize_huya_cookie(pc.s.cookies) if "udb_cred" not in cookie_str and "yyuid" not in cookie_str: return HuyaLoginResult( success=False, message="Cookie 兑换完成但缺失关键凭据 (udb_cred/yyuid)", code="COOKIE_INCOMPLETE", ) logger.info( f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)" ) return HuyaLoginResult( success=True, cookie=cookie_str, message="App协议登录成功", sdid=sdid, context=pc.context, ) except Exception as exc: return HuyaLoginResult( success=False, message=f"扫码绑定兑换 Cookie 失败: {exc}", code="BIND_FLOW_FAILED", ) def login_huya_app_password( username: str, password: str, proxies: Mapping[str, str] | None = None, timeout: tuple[float, float] | None = None, force_new_device: bool = False, ) -> HuyaLoginResult: """函数式入口:使用 App 协议执行虎牙密码登录并获取全套 Cookie。""" return HuyaAppPasswordLogin( username=username, password=password, proxies=proxies, timeout=timeout, force_new_device=force_new_device, ).login()