统一虎牙 App 登录设备画像
This commit is contained in:
+44
-26
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
⚠ 风险与边界(详见 ``docs/HUYA_APP_OVERVIEW.md``):
|
⚠ 风险与边界(详见 ``docs/HUYA_APP_OVERVIEW.md``):
|
||||||
- hdid 全账号固定共享 -> 服务端可设备维度关联账号;
|
- hdid 全账号固定共享 -> 服务端可设备维度关联账号;
|
||||||
- 滑块 UA / session / traceId 用金样本常量 -> 与随机机型画像不自洽;
|
- 滑块 UA / session / traceId / userAction 均按当前画像和本次请求动态生成;
|
||||||
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
|
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ import re
|
|||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Mapping
|
from collections.abc import Mapping
|
||||||
from urllib.parse import parse_qs, quote, urlparse
|
from urllib.parse import parse_qs, quote, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -38,10 +38,10 @@ from loguru import logger
|
|||||||
from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
|
from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
|
||||||
from .cookie_utils import normalize_huya_cookie
|
from .cookie_utils import normalize_huya_cookie
|
||||||
from .device_fingerprint import account_state_dir, get_huya_sdid
|
from .device_fingerprint import account_state_dir, get_huya_sdid
|
||||||
from .device_profile import get_profile
|
from .device_profile import get_profile, mobile_user_agent
|
||||||
from .dfp_register import DfpRegistrationError, register_device
|
from .dfp_register import DfpRegistrationError, register_device
|
||||||
from .envelope_forge import Envelope
|
from .envelope_forge import Envelope
|
||||||
from .login import HuyaCredentialError, HuyaLoginError, HuyaLoginResult
|
from .login import HuyaLoginError, HuyaLoginResult
|
||||||
from .nonce_forge import K1_DEFAULT, gen_nonce
|
from .nonce_forge import K1_DEFAULT, gen_nonce
|
||||||
from .wup_encoder import build_password_login_wup
|
from .wup_encoder import build_password_login_wup
|
||||||
|
|
||||||
@@ -91,30 +91,28 @@ class HuyaAppQrAuthRequiredError(HuyaAppLoginError):
|
|||||||
|
|
||||||
|
|
||||||
def _golden_session_assets() -> tuple[dict, str, str]:
|
def _golden_session_assets() -> tuple[dict, str, str]:
|
||||||
|
"""生成本次登录的会话元数据和行为轨迹。"""
|
||||||
|
from .wup_encoder import make_trace_id, make_user_action
|
||||||
|
|
||||||
mj = {
|
mj = {
|
||||||
"associationId": 8193,
|
"associationId": 8193,
|
||||||
"funcName": "hypasswordLogin",
|
"funcName": "hypasswordLogin",
|
||||||
"group": 1,
|
"group": 1,
|
||||||
"id": 4097,
|
"id": 4097,
|
||||||
"session": 3251699,
|
"session": random.randint(1_000_000, 9_999_999),
|
||||||
"step": 1,
|
"step": 1,
|
||||||
"stillLogin": False,
|
"stillLogin": False,
|
||||||
"traceId": "0b8f098ff64a5bdc-23473-94783833787595172451",
|
"traceId": make_trace_id(),
|
||||||
"type": 3,
|
"type": 3,
|
||||||
"uid": 0,
|
"uid": 0,
|
||||||
"userContext": "",
|
"userContext": "",
|
||||||
}
|
}
|
||||||
ua = (
|
return mj, make_user_action(), ""
|
||||||
'{"curl":"登录页","furl":"我的","latitude":"-1.0","longitude":"-1.0","ssid":"",'
|
|
||||||
'"user_action":[{"id":"24","time":"1787595171950","x":"277","y":"1057"},'
|
|
||||||
'{"id":"11","time":"1787595172444","x":"296","y":"952"}]}'
|
def _android_version(device_info: Mapping[str, object]) -> str:
|
||||||
)
|
parts = str(device_info.get("screen") or "").split(",")
|
||||||
sd = (
|
return parts[2] if len(parts) >= 3 and parts[2] else "11"
|
||||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
|
||||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
|
||||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
|
||||||
)
|
|
||||||
return mj, ua, sd
|
|
||||||
|
|
||||||
|
|
||||||
def wup_password_login_raw(
|
def wup_password_login_raw(
|
||||||
@@ -133,14 +131,15 @@ def wup_password_login_raw(
|
|||||||
注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。
|
注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。
|
||||||
"""
|
"""
|
||||||
uid_str = account[3:] if account.startswith("hy_") else account
|
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()
|
mj, ua, _old_sd = _golden_session_assets()
|
||||||
dev = dict(device_info or DEFAULT_GOLDEN_DEV)
|
|
||||||
if not safedeviceid:
|
if not safedeviceid:
|
||||||
try:
|
try:
|
||||||
_t1, safedeviceid, registered_device_id = register_device(
|
_t1, safedeviceid, registered_device_id = register_device(
|
||||||
fingerprint=dev.get("fingerprint"),
|
fingerprint=dev.get("fingerprint"),
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
device_info=dev,
|
||||||
)
|
)
|
||||||
except DfpRegistrationError as exc:
|
except DfpRegistrationError as exc:
|
||||||
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
||||||
@@ -160,7 +159,7 @@ def wup_password_login_raw(
|
|||||||
data=pkt,
|
data=pkt,
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/multipart-formdata; charset=UTF-8",
|
"Content-Type": "application/multipart-formdata; charset=UTF-8",
|
||||||
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 11)",
|
"User-Agent": f"Dalvik/2.1.0 (Linux; U; Android {_android_version(dev)})",
|
||||||
"Accept-Encoding": "gzip",
|
"Accept-Encoding": "gzip",
|
||||||
},
|
},
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
@@ -203,7 +202,12 @@ def parse_risk_url(resp: bytes) -> str | None:
|
|||||||
return (pt or urls)[0]
|
return (pt or urls)[0]
|
||||||
|
|
||||||
|
|
||||||
def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict:
|
def solve_safe_auth(
|
||||||
|
risk_url: str,
|
||||||
|
proxies=None,
|
||||||
|
max_retry: int = 3,
|
||||||
|
device_info: Mapping[str, object] | None = None,
|
||||||
|
) -> dict:
|
||||||
"""解 safe_auth 风控滑块。"""
|
"""解 safe_auth 风控滑块。"""
|
||||||
from .verification.solver import (
|
from .verification.solver import (
|
||||||
HuyaQrAuthRequiredError,
|
HuyaQrAuthRequiredError,
|
||||||
@@ -218,7 +222,7 @@ def solve_safe_auth(risk_url: str, proxies=None, max_retry: int = 3) -> dict:
|
|||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
for attempt in range(max_retry):
|
for attempt in range(max_retry):
|
||||||
solver = HuyaVerificationSolver(
|
solver = HuyaVerificationSolver(
|
||||||
ua=APP_UA_MOBILE,
|
ua=mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
page_url=risk_url,
|
page_url=risk_url,
|
||||||
@@ -268,11 +272,12 @@ def login_cred_with_flow(
|
|||||||
|
|
||||||
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
|
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
|
||||||
"""
|
"""
|
||||||
dev = dict(device_info or DEFAULT_GOLDEN_DEV)
|
dev = dict(device_info) if device_info is not None else get_profile(account)
|
||||||
try:
|
try:
|
||||||
_t1, safedeviceid, registered_device_id = register_device(
|
_t1, safedeviceid, registered_device_id = register_device(
|
||||||
fingerprint=dev.get("fingerprint"),
|
fingerprint=dev.get("fingerprint"),
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
|
device_info=dev,
|
||||||
)
|
)
|
||||||
except DfpRegistrationError as exc:
|
except DfpRegistrationError as exc:
|
||||||
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
||||||
@@ -301,7 +306,7 @@ def login_cred_with_flow(
|
|||||||
raise HuyaAppQrAuthRequiredError(
|
raise HuyaAppQrAuthRequiredError(
|
||||||
f"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
|
f"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
|
||||||
)
|
)
|
||||||
solve_safe_auth(risk_url, proxies=proxies)
|
solve_safe_auth(risk_url, proxies=proxies, device_info=dev)
|
||||||
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
|
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
|
||||||
continue
|
continue
|
||||||
raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)")
|
raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)")
|
||||||
@@ -311,7 +316,13 @@ def login_cred_with_flow(
|
|||||||
class QrRole:
|
class QrRole:
|
||||||
"""udblgn.huya.com/qrLgn/* 的 JSON 协议封装。"""
|
"""udblgn.huya.com/qrLgn/* 的 JSON 协议封装。"""
|
||||||
|
|
||||||
def __init__(self, pc: bool, sdid: str, proxies: dict | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
pc: bool,
|
||||||
|
sdid: str,
|
||||||
|
proxies: dict | None = None,
|
||||||
|
device_info: Mapping[str, object] | None = None,
|
||||||
|
):
|
||||||
self.pc = pc
|
self.pc = pc
|
||||||
self.sdid = sdid
|
self.sdid = sdid
|
||||||
self.s = requests.Session()
|
self.s = requests.Session()
|
||||||
@@ -327,7 +338,9 @@ class QrRole:
|
|||||||
self.req_counter = random.randint(40_000_000, 41_000_000)
|
self.req_counter = random.randint(40_000_000, 41_000_000)
|
||||||
self.s.headers.update(
|
self.s.headers.update(
|
||||||
{
|
{
|
||||||
"User-Agent": UA_PC if pc else APP_UA_MOBILE,
|
"User-Agent": UA_PC
|
||||||
|
if pc
|
||||||
|
else mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
|
||||||
"Origin": UDB_BASE,
|
"Origin": UDB_BASE,
|
||||||
"content-type": "application/json;charset=UTF-8",
|
"content-type": "application/json;charset=UTF-8",
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
@@ -483,7 +496,12 @@ class HuyaAppPasswordLogin:
|
|||||||
)
|
)
|
||||||
sdid = sdid_obj.sdid if sdid_obj else ""
|
sdid = sdid_obj.sdid if sdid_obj else ""
|
||||||
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
||||||
ph = QrRole(pc=False, sdid=sdid, proxies=self.proxies)
|
ph = QrRole(
|
||||||
|
pc=False,
|
||||||
|
sdid=sdid,
|
||||||
|
proxies=self.proxies,
|
||||||
|
device_info=self.device_info,
|
||||||
|
)
|
||||||
beh, page = web_behavior()
|
beh, page = web_behavior()
|
||||||
|
|
||||||
# 4.1 获取 qrId
|
# 4.1 获取 qrId
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import json
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,6 +17,8 @@ from pathlib import Path
|
|||||||
import requests
|
import requests
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from .device_profile import mobile_user_agent
|
||||||
|
|
||||||
FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
|
FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
|
||||||
RUNNER_JS = FINGERPRINT_DIR / "runner.js"
|
RUNNER_JS = FINGERPRINT_DIR / "runner.js"
|
||||||
SDID_PREFIX = "__SDID__"
|
SDID_PREFIX = "__SDID__"
|
||||||
@@ -75,6 +77,7 @@ def _run_node_runner(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=total_timeout,
|
timeout=total_timeout,
|
||||||
|
check=False,
|
||||||
cwd=str(FINGERPRINT_DIR),
|
cwd=str(FINGERPRINT_DIR),
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
@@ -123,12 +126,7 @@ def write_device_hint(state_dir: str | Path, device_info: Mapping) -> Path:
|
|||||||
screen = str(device_info.get("screen", ""))
|
screen = str(device_info.get("screen", ""))
|
||||||
parts = screen.split(",")
|
parts = screen.split(",")
|
||||||
android_ver = parts[2] if len(parts) >= 3 else "11"
|
android_ver = parts[2] if len(parts) >= 3 else "11"
|
||||||
ua = (
|
ua = mobile_user_agent(device_info)
|
||||||
f"Mozilla/5.0 (Linux; Android {android_ver}; {device_info.get('model', '')} "
|
|
||||||
"Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Version/4.0 Chrome/149.0.7827.159 Mobile Safari/537.36 "
|
|
||||||
f"huya adr/13.4.22/{device_info.get('vendor', 'android')}/30"
|
|
||||||
)
|
|
||||||
hint = {
|
hint = {
|
||||||
"model": device_info.get("model", ""),
|
"model": device_info.get("model", ""),
|
||||||
"vendor": device_info.get("vendor", "android"),
|
"vendor": device_info.get("vendor", "android"),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
@@ -48,6 +49,23 @@ APP_VERSION = "13.4.22"
|
|||||||
SDK_VERSION = "1.0.80138"
|
SDK_VERSION = "1.0.80138"
|
||||||
|
|
||||||
|
|
||||||
|
def mobile_user_agent(device_info: Mapping[str, object]) -> str:
|
||||||
|
"""根据统一设备画像生成 App WebView UA。"""
|
||||||
|
screen = str(device_info.get("screen") or "")
|
||||||
|
parts = screen.split(",")
|
||||||
|
android_ver = parts[2] if len(parts) >= 3 and parts[2] else "11"
|
||||||
|
api_level = parts[1] if len(parts) >= 2 and parts[1] else "30"
|
||||||
|
model = str(device_info.get("model") or "M2102J2SC")
|
||||||
|
vendor = str(device_info.get("vendor") or "android")
|
||||||
|
app_version = str(device_info.get("app_version") or APP_VERSION)
|
||||||
|
return (
|
||||||
|
f"Mozilla/5.0 (Linux; Android {android_ver}; {model} "
|
||||||
|
"Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
f"Version/4.0 Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/"
|
||||||
|
f"{app_version}/{vendor}/{api_level}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rand_sha1_hex() -> str:
|
def _rand_sha1_hex() -> str:
|
||||||
return hashlib.sha1(os.urandom(20)).hexdigest()
|
return hashlib.sha1(os.urandom(20)).hexdigest()
|
||||||
|
|
||||||
|
|||||||
+135
-42
@@ -7,10 +7,10 @@
|
|||||||
就是 WUP 登录帧的设备字段签发源;
|
就是 WUP 登录帧的设备字段签发源;
|
||||||
* 请求模板只承载 TAF/WUP 协议形状,旧的 dfpReport 密文不会被使用。
|
* 请求模板只承载 TAF/WUP 协议形状,旧的 dfpReport 密文不会被使用。
|
||||||
|
|
||||||
请求模板来自 ``evidence/dfp_chain_golden.json``,与现有项目的证书/信封样本
|
请求按已验证的 TAF/WUP 字段布局动态编码;不依赖抓包证据文件。selectOperator
|
||||||
一样作为协议模板使用。模板中的设备字段会在 selectOperator 步骤按当前画像更新。
|
中的 RSA 字段使用协议固定格式的密文种子,设备画像字段在每次请求时写入。
|
||||||
|
|
||||||
错误语义: 链上任何一步失败(模板缺失/损坏、HTTP 异常、响应缺字段)都会抛
|
错误语义: 链上任何一步失败(请求编码、HTTP 异常、响应缺字段)都会抛
|
||||||
``DfpRegistrationError``。调用方(core/huya/app_login)应把注册失败当作明确失败
|
``DfpRegistrationError``。调用方(core/huya/app_login)应把注册失败当作明确失败
|
||||||
终止登录;禁止静默回退到画像里的旧固定 safedeviceid/device_id。
|
终止登录;禁止静默回退到画像里的旧固定 safedeviceid/device_id。
|
||||||
|
|
||||||
@@ -26,23 +26,25 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import ssl
|
|
||||||
import struct
|
import struct
|
||||||
from pathlib import Path
|
import time
|
||||||
from typing import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from .taf_protocol import TafOutputStream
|
||||||
|
from .wup_protocol import WupRequest
|
||||||
|
|
||||||
WSAPI = "https://wsapi.huya.com"
|
WSAPI = "https://wsapi.huya.com"
|
||||||
UA = "okhttp/3.14.9"
|
UA = "okhttp/3.14.9"
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
SELECT_OPERATOR_SDID = (
|
||||||
CHAIN_FILE = ROOT / "evidence" / "dfp_chain_golden.json"
|
"PQwemAN9NHkZKoMqVeJmXhIypqMTaQEOrmXr37xQVhQZqrPxjEKEQ11xvE02qawcys/"
|
||||||
|
"iwl5gSIa6nYz0L5tNl0VdpZFyNjNHCTnQtzCwD5RHKKOL+DYohiPvAFPblxznlGyB63NAY74u"
|
||||||
|
"2q9Niej6bLDarc9xAKbAuLzRb7hHIGxCTBp5uGf3"
|
||||||
|
)
|
||||||
|
|
||||||
TAF_HEAD = bytes.fromhex(
|
TAF_HEAD = bytes.fromhex(
|
||||||
"10032c3c4c56"
|
"10032c3c4c56"
|
||||||
@@ -69,20 +71,86 @@ class DfpRegistrationError(RuntimeError):
|
|||||||
"""新设备注册链失败。"""
|
"""新设备注册链失败。"""
|
||||||
|
|
||||||
|
|
||||||
def _load_chain() -> dict[str, tuple[bytes, bytes]]:
|
def _build_get_dfp_config_request() -> bytes:
|
||||||
if not CHAIN_FILE.exists():
|
"""编码 getDfpConfig 请求(67 字节 TAF/WUP)。"""
|
||||||
raise DfpRegistrationError(f"注册链模板不存在: {CHAIN_FILE}")
|
wup = WupRequest()
|
||||||
try:
|
wup.setServant("huyaudbwebui")
|
||||||
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
|
wup.setFunc("getDfpConfig")
|
||||||
return {
|
wup.iTimeout = 0
|
||||||
name: (
|
wup.writeStruct("tReq", {"app_id": "5008"})
|
||||||
base64.b64decode(item["req_b64"]),
|
return wup.encode()
|
||||||
base64.b64decode(item["resp_b64"]),
|
|
||||||
)
|
|
||||||
for name, item in data.items()
|
def _build_select_operator_request(
|
||||||
}
|
fingerprint: str | None = None,
|
||||||
except (OSError, ValueError, KeyError) as exc:
|
device_info: Mapping[str, str] | None = None,
|
||||||
raise DfpRegistrationError(f"注册链模板读取失败: {exc}") from exc
|
) -> 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(
|
def _post(
|
||||||
@@ -118,26 +186,21 @@ def _random_triple() -> tuple[str, str, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_random_dfp_body() -> bytes:
|
def _build_random_dfp_body(
|
||||||
|
device_info: Mapping[str, str] | None = None,
|
||||||
|
) -> bytes:
|
||||||
"""构造服务端接受的随机 dfpReport 请求体。
|
"""构造服务端接受的随机 dfpReport 请求体。
|
||||||
|
|
||||||
cw 中 JSON 段明文仅承载真实请求的段长度与三元组形态,实证(2026-08-27)
|
cw 中 JSON 段明文仅承载真实请求的段长度与三元组形态,实证(2026-08-27)
|
||||||
服务端不校验 cw 内容,随机 cw 照样 200 + 新签发 t2/t5,因此三元组
|
服务端不校验 cw 内容,随机 cw 照样 200 + 新签发 t2/t5,因此三元组
|
||||||
(hdid/device_id/appkey,各 64hex sha256)只为凑齐模板形态,属诊断用途。
|
(hdid/device_id/appkey,各 64hex sha256)只为凑齐模板形态,属诊断用途;
|
||||||
|
device_id、hdid 在画像存在时分别使用画像 device_id、guid32。
|
||||||
"""
|
"""
|
||||||
hdid, device_id, appkey = _random_triple()
|
json_plain = _build_dfp_json_plain(device_info)
|
||||||
json_plain = (
|
seed = json_plain
|
||||||
'{"appId":"5008","appVer":"13.4.22","appkey":"%s",'
|
if len(seed) > CW_JSON_LEN:
|
||||||
'"channel":"xiaomi","deviceId":"%s",'
|
# 保持已验证的固定 cw 段长度,同时让完整画像参与本次随机报文。
|
||||||
'"deviceName":"M2102J2SC","hdid":"%s",'
|
seed = hashlib.sha256(seed).digest()
|
||||||
'"heightPixels":"2120","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",'
|
|
||||||
'"systemInfo":"android","systemVer":"M2102J2SC,30,11",'
|
|
||||||
'"terminalType":1,"testEnv":0,"widthPixels":"1080"}'
|
|
||||||
) % (appkey, device_id, hdid)
|
|
||||||
seed = json_plain.encode("utf-8")
|
|
||||||
json_sec = os.urandom(CW_JSON_LEN)
|
json_sec = os.urandom(CW_JSON_LEN)
|
||||||
if len(seed) <= CW_JSON_LEN:
|
if len(seed) <= CW_JSON_LEN:
|
||||||
mask = os.urandom(len(seed))
|
mask = os.urandom(len(seed))
|
||||||
@@ -156,6 +219,33 @@ def _build_random_dfp_body() -> bytes:
|
|||||||
return 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:
|
def _select_operator_request(template: bytes, fingerprint: str | None) -> bytes:
|
||||||
if fingerprint and len(fingerprint) == 40:
|
if fingerprint and len(fingerprint) == 40:
|
||||||
old = b"02df398797432eadefcc12767119ad5e80999389"
|
old = b"02df398797432eadefcc12767119ad5e80999389"
|
||||||
@@ -188,11 +278,14 @@ def register_device(
|
|||||||
fingerprint: str | None = None,
|
fingerprint: str | None = None,
|
||||||
proxies: Mapping[str, str] | None = None,
|
proxies: Mapping[str, str] | None = None,
|
||||||
timeout: float = 20,
|
timeout: float = 20,
|
||||||
|
device_info: Mapping[str, str] | None = None,
|
||||||
) -> tuple[str, str, str]:
|
) -> tuple[str, str, str]:
|
||||||
"""执行新注册链,返回 ``(t1, safedeviceid, device_id)``。"""
|
"""执行新注册链,返回 ``(t1, safedeviceid, device_id)``。"""
|
||||||
chain = _load_chain()
|
chain = _load_chain(fingerprint=fingerprint, device_info=device_info)
|
||||||
_post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies)
|
_post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies)
|
||||||
select_request = _select_operator_request(chain["selectOperator"][0], fingerprint)
|
select_request = _select_operator_request(chain["selectOperator"][0], fingerprint)
|
||||||
_post(select_request, "application/x-wup", timeout, proxies)
|
_post(select_request, "application/x-wup", timeout, proxies)
|
||||||
response = _post(_build_random_dfp_body(), timeout=timeout, proxies=proxies)
|
response = _post(
|
||||||
|
_build_random_dfp_body(device_info), timeout=timeout, proxies=proxies
|
||||||
|
)
|
||||||
return _parse_response(response)
|
return _parse_response(response)
|
||||||
|
|||||||
+15
-12
@@ -21,20 +21,24 @@ Web UI 通过 `web/backend/routers/huya.py` 调用它。
|
|||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ 每次 WUP 登录前 │
|
│ 每次 WUP 登录前 │
|
||||||
│ ① 新设备注册链 core/huya/dfp_register.register_device() │
|
│ ① 读取/创建账号设备画像 core/huya/device_profile.get_profile() │
|
||||||
|
│ 同一账号复用 vendor/model/screen/width/height/fingerprint/guid32 │
|
||||||
|
│ │
|
||||||
|
│ ② 新设备注册链 core/huya/dfp_register.register_device() │
|
||||||
│ getDfpConfig → selectOperator → dfpReport(随机cw) │
|
│ getDfpConfig → selectOperator → dfpReport(随机cw) │
|
||||||
│ 返回 (t1, safedeviceid, device_id) ← 全新,非画像旧值 │
|
│ 三步请求的设备字段均来自①,返回 (t1, safedeviceid, device_id) │
|
||||||
|
│ 返回字段为本次登录专用的新令牌,非画像旧值 │
|
||||||
│ 失败 → 抛 DfpRegistrationError → HuyaAppLoginError,终止,不回退 │
|
│ 失败 → 抛 DfpRegistrationError → HuyaAppLoginError,终止,不回退 │
|
||||||
│ │
|
│ │
|
||||||
│ ② WUP 密码登录 wup_password_login_raw() │
|
│ ③ WUP 密码登录 wup_password_login_raw() │
|
||||||
│ POST wup.huya.com hypasswordLogin(safedeviceid/device_id 用①的)│
|
│ POST wup.huya.com hypasswordLogin(safedeviceid/device_id 用①的)│
|
||||||
│ ├─ 正常 → 解出 114B cred + 真实 uid │
|
│ ├─ 正常 → 解出 114B cred + 真实 uid │
|
||||||
│ └─ pt_auth 滑块 → solve_safe_auth() 自动过验 → 重发登录(≤3轮) │
|
│ └─ pt_auth 滑块 → solve_safe_auth() 自动过验 → 重发登录(≤3轮) │
|
||||||
│ │
|
│ │
|
||||||
│ ③ 本地铸证:nonce_forge(uid+k1) → cert_forge 铸证书 → envelope_forge │
|
│ ④ 本地铸证:nonce_forge(uid+k1) → cert_forge 铸证书 → envelope_forge │
|
||||||
│ 补丁 WUP 信封(证书 + UID) │
|
│ 补丁 WUP 信封(证书 + UID) │
|
||||||
│ ④ 模拟扫码绑定四步流 QrRole(getQrId→scan→bind→tryQrLogin)→ biztoken│
|
│ ⑤ 模拟扫码绑定四步流 QrRole(getQrId→scan→bind→tryQrLogin)→ biztoken│
|
||||||
│ ⑤ POST /web/cookie/verify 兑换 → 全套网页 Cookie │
|
│ ⑥ POST /web/cookie/verify 兑换 → 全套网页 Cookie │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,8 +95,8 @@ Web UI 通过 `web/backend/routers/huya.py` 调用它。
|
|||||||
| fingerprint | **随机**(每账号) | `generate_profile` sha1 | 低 |
|
| fingerprint | **随机**(每账号) | `generate_profile` sha1 | 低 |
|
||||||
| 机型 / 屏幕 / 宽高 | **随机**(每账号从 `REAL_MODELS` 选) | `generate_profile` | 低 |
|
| 机型 / 屏幕 / 宽高 | **随机**(每账号从 `REAL_MODELS` 选) | `generate_profile` | 低 |
|
||||||
| app_version / sdk_version | **固定** | 常量 `13.4.22` / `1.0.80138` | 低(需与 hdid 配套) |
|
| app_version / sdk_version | **固定** | 常量 `13.4.22` / `1.0.80138` | 低(需与 hdid 配套) |
|
||||||
| 滑块 UA | **固定** 小米 M2102J2SC | `app_login.APP_UA_MOBILE` | ⚠ 与随机机型画像不一致 |
|
| 滑块/扫码 WebView UA | **按画像派生** | `device_profile.mobile_user_agent()` | 与机型/厂商/系统版本一致 |
|
||||||
| session / traceId / 登录行为 JSON | **固定金样本** | `_golden_session_assets()` | ⚠ 多账号相同,异常画像 |
|
| session / traceId / 登录行为 JSON | **每次请求动态** | `wup_encoder.make_*` + `_golden_session_assets()` | 仅协议字段固定结构 |
|
||||||
| QrRole context / reqid / page_id | **随机** | `QrRole`(uuid/random) | 低 |
|
| QrRole context / reqid / page_id | **随机** | `QrRole`(uuid/random) | 低 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -100,10 +104,9 @@ Web UI 通过 `web/backend/routers/huya.py` 调用它。
|
|||||||
## 五、风险与未解决项(诚实清单)
|
## 五、风险与未解决项(诚实清单)
|
||||||
|
|
||||||
1. **hdid 固定共享** → 服务端可在设备维度把多个账号关联起来(见 §2 #1)。纯代码无法铸造新 hdid;换掉即 `APP_SIGN_NOT_MATCH`。
|
1. **hdid 固定共享** → 服务端可在设备维度把多个账号关联起来(见 §2 #1)。纯代码无法铸造新 hdid;换掉即 `APP_SIGN_NOT_MATCH`。
|
||||||
2. **滑块 UA / session / traceId 固定** → 与随机机型画像不自洽,长期易形成异常画像。
|
2. **qr_auth / dx_auth 无自动闭环** → 碰上直接失败(`QR_AUTH_REQUIRED`)。
|
||||||
3. **qr_auth / dx_auth 无自动闭环** → 碰上直接失败(`QR_AUTH_REQUIRED`)。
|
3. **无稳定性数据** → 没有 20/50/100 次连续成功率、多账号/IP/时段矩阵。
|
||||||
4. **无稳定性数据** → 没有 20/50/100 次连续成功率、多账号/IP/时段矩阵。
|
4. **Frida attach 主进程必被 msaoaidsec 杀**(真机/模拟器均实测)→ 见 §六。
|
||||||
5. **Frida attach 主进程必被 msaoaidsec 杀**(真机/模拟器均实测)→ 见 §六。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,24 @@
|
|||||||
* 失败语义: 模板缺失/HTTP 异常 -> DfpRegistrationError (不静默、不发登录帧)。
|
* 失败语义: 模板缺失/HTTP 异常 -> DfpRegistrationError (不静默、不发登录帧)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import hashlib
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.huya.dfp_register import (
|
from core.huya.dfp_register import (
|
||||||
CHAIN_FILE,
|
|
||||||
CW_LEN,
|
CW_LEN,
|
||||||
|
SELECT_OPERATOR_SDID,
|
||||||
DfpRegistrationError,
|
DfpRegistrationError,
|
||||||
|
_build_dfp_json_plain,
|
||||||
|
_build_get_dfp_config_request,
|
||||||
_build_random_dfp_body,
|
_build_random_dfp_body,
|
||||||
|
_build_select_operator_request,
|
||||||
_parse_response,
|
_parse_response,
|
||||||
register_device,
|
register_device,
|
||||||
)
|
)
|
||||||
|
from core.huya.wup_protocol import WupResponse
|
||||||
|
|
||||||
TAIL = bytes.fromhex("3600400c0b8c980ca80c")
|
TAIL = bytes.fromhex("3600400c0b8c980ca80c")
|
||||||
|
|
||||||
@@ -59,6 +60,40 @@ class TestRandomDfpBody:
|
|||||||
b2 = _build_random_dfp_body()
|
b2 = _build_random_dfp_body()
|
||||||
assert b1 != b2, "每次注册应生成不同的随机 cw"
|
assert b1 != b2, "每次注册应生成不同的随机 cw"
|
||||||
|
|
||||||
|
def test_body_uses_device_profile_fields(self):
|
||||||
|
profile = {
|
||||||
|
"app_version": "13.4.22",
|
||||||
|
"vendor": "vivo",
|
||||||
|
"model": "V2370A",
|
||||||
|
"screen": "V2370A,34,13",
|
||||||
|
"width": "1080",
|
||||||
|
"height": "2412",
|
||||||
|
"device_id": "d" * 64,
|
||||||
|
"guid32": "g" * 64,
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"core.huya.dfp_register._random_triple",
|
||||||
|
return_value=("h" * 64, "r" * 64, "k" * 64),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
plaintext = _build_dfp_json_plain(profile)
|
||||||
|
assert b'"channel":"vivo"' in plaintext
|
||||||
|
assert b'"deviceName":"V2370A"' in plaintext
|
||||||
|
assert b'"deviceId":"' + b"d" * 64 in plaintext
|
||||||
|
assert b'"systemVer":"V2370A,34,13"' in plaintext
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"core.huya.dfp_register._random_triple",
|
||||||
|
return_value=("h" * 64, "r" * 64, "k" * 64),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"core.huya.dfp_register.os.urandom", side_effect=lambda n: b"\0" * n
|
||||||
|
),
|
||||||
|
):
|
||||||
|
body = _build_random_dfp_body(profile)
|
||||||
|
assert hashlib.sha256(plaintext).digest() in body[-CW_LEN + 2 :]
|
||||||
|
|
||||||
|
|
||||||
class TestParseResponse:
|
class TestParseResponse:
|
||||||
def test_parse_ok(self):
|
def test_parse_ok(self):
|
||||||
@@ -67,17 +102,6 @@ class TestParseResponse:
|
|||||||
assert t2 == "A" * 180
|
assert t2 == "A" * 180
|
||||||
assert t5 == "b" * 40
|
assert t5 == "b" * 40
|
||||||
|
|
||||||
def test_parse_golden_evidence(self):
|
|
||||||
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
|
|
||||||
if not CHAIN_FILE.exists():
|
|
||||||
pytest.skip("缺失 golden 注册链模板")
|
|
||||||
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
|
|
||||||
resp = base64.b64decode(data["dfpReport"]["resp_b64"])
|
|
||||||
t1, t2, t5 = _parse_response(resp)
|
|
||||||
assert len(t1) == 32
|
|
||||||
assert len(t2) == 180
|
|
||||||
assert len(t5) == 40
|
|
||||||
|
|
||||||
def test_parse_missing_fields_raises(self):
|
def test_parse_missing_fields_raises(self):
|
||||||
with pytest.raises(DfpRegistrationError):
|
with pytest.raises(DfpRegistrationError):
|
||||||
_parse_response(b"\x16\x20" + b"a" * 32) # 缺 t2/t5
|
_parse_response(b"\x16\x20" + b"a" * 32) # 缺 t2/t5
|
||||||
@@ -88,6 +112,27 @@ class TestParseResponse:
|
|||||||
|
|
||||||
|
|
||||||
class TestRegisterDevice:
|
class TestRegisterDevice:
|
||||||
|
def test_get_dfp_config_request_is_generated(self):
|
||||||
|
req = _build_get_dfp_config_request()
|
||||||
|
assert len(req) == 67
|
||||||
|
wup = WupResponse()
|
||||||
|
wup.decode(req)
|
||||||
|
assert wup.sServantName == "huyaudbwebui"
|
||||||
|
assert wup.sFuncName == "getDfpConfig"
|
||||||
|
assert wup.readStruct("tReq") == bytes.fromhex("0a0604353030380b")
|
||||||
|
|
||||||
|
def test_select_operator_request_is_generated(self):
|
||||||
|
fp = "0" * 40
|
||||||
|
req = _build_select_operator_request(fp)
|
||||||
|
assert len(req) == 419
|
||||||
|
assert fp.encode() in req
|
||||||
|
assert SELECT_OPERATOR_SDID.encode() in req
|
||||||
|
wup = WupResponse()
|
||||||
|
wup.decode(req)
|
||||||
|
assert wup.sServantName == "huyaudbwebui"
|
||||||
|
assert wup.sFuncName == "selectOperator"
|
||||||
|
assert "_wup_data" in wup.newdata
|
||||||
|
|
||||||
def test_flow_three_steps_and_returns_triple(self):
|
def test_flow_three_steps_and_returns_triple(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
@@ -134,19 +179,6 @@ class TestRegisterDevice:
|
|||||||
assert len(dfp_body) == 4226
|
assert len(dfp_body) == 4226
|
||||||
assert dfp_ct == "application/octet-stream"
|
assert dfp_ct == "application/octet-stream"
|
||||||
|
|
||||||
def test_chain_template_missing_raises(self):
|
|
||||||
"""模板缺失 -> 注册链直接报错, 不应发起任何网络请求。"""
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"core.huya.dfp_register.CHAIN_FILE",
|
|
||||||
Path("/tmp/definitely_missing_chain.json"),
|
|
||||||
),
|
|
||||||
patch("core.huya.dfp_register._post") as m_post,
|
|
||||||
):
|
|
||||||
with pytest.raises(DfpRegistrationError):
|
|
||||||
register_device()
|
|
||||||
m_post.assert_not_called()
|
|
||||||
|
|
||||||
def test_http_failure_raises(self):
|
def test_http_failure_raises(self):
|
||||||
"""HTTP 层异常由 _post 转换为 DfpRegistrationError 后向上传播 (不静默)。"""
|
"""HTTP 层异常由 _post 转换为 DfpRegistrationError 后向上传播 (不静默)。"""
|
||||||
with (
|
with (
|
||||||
@@ -155,6 +187,6 @@ class TestRegisterDevice:
|
|||||||
"core.huya.dfp_register.requests.post",
|
"core.huya.dfp_register.requests.post",
|
||||||
side_effect=requests.RequestException("connection reset"),
|
side_effect=requests.RequestException("connection reset"),
|
||||||
),
|
),
|
||||||
|
pytest.raises(DfpRegistrationError),
|
||||||
):
|
):
|
||||||
with pytest.raises(DfpRegistrationError):
|
register_device()
|
||||||
register_device()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user