feat(huya): 零设备 dfpReport 注册链迁入 core/huya 并接入生产登录
- 新增 core/huya/dfp_register: 随机 4146B cw 生成 + 注册响应解析 (t1/t2/t5), 修复 JSON 模板 %s 数不匹配的 TypeError bug - app_login 每次 WUP 登录前执行注册链取新 safedeviceid/device_id, 不再读取画像旧固定值; 注册失败抛 HuyaAppLoginError 终止, 不静默回退旧链 - device_profile 移除固定 SAFEDEVICEID_DEFAULT, 画像只承载 soft 字段 - tools/huya_device_register 同步零设备生成链路 (--gen/--gen-login) - 新增 tests/test_huya_dfp_register (9 项), 扩展 test_huya_app_login 注册接线 (4 项)
This commit is contained in:
+51
-16
@@ -1,11 +1,17 @@
|
||||
"""虎牙 App 渠道协议登录模块。
|
||||
|
||||
流程:
|
||||
1. 账号+密码 -> 独立设备画像 -> WUP 密码登录 (POST wup.huya.com)
|
||||
2. safe_auth 滑块自动过验 -> 提取 fresh cred 与 真实 uid
|
||||
3. 本地 XXTEA 算 nonce -> 铸造登录证书 (cert_forge) -> 补丁 WUP 信封 (envelope_forge)
|
||||
4. 模拟扫码绑定四步流 (getQrId -> scanQrPicNotify -> bindQrLoginUser -> tryQrLogin) 获取 biztoken
|
||||
5. POST /web/cookie/verify 兑换获取全套网页 Cookie
|
||||
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``),
|
||||
不读取画像里的旧固定值,也不静默回退旧链。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,6 +33,7 @@ from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
|
||||
from .cookie_utils import normalize_huya_cookie
|
||||
from .device_fingerprint import get_huya_sdid
|
||||
from .device_profile import get_profile
|
||||
from .dfp_register import DfpRegistrationError, register_device
|
||||
from .envelope_forge import Envelope
|
||||
from .login import HuyaCredentialError, HuyaLoginError, HuyaLoginResult
|
||||
from .nonce_forge import K1_DEFAULT, gen_nonce
|
||||
@@ -66,11 +73,6 @@ DEFAULT_GOLDEN_DEV = {
|
||||
"height": "2120",
|
||||
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
|
||||
"hdid": "ed0db8334cadd236c00cadf7e11ab5a5",
|
||||
"safedeviceid": (
|
||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -118,14 +120,29 @@ def wup_password_login_raw(
|
||||
hdid: str | None = None,
|
||||
proxies: dict | None = None,
|
||||
) -> bytes:
|
||||
"""发送 WUP 密码登录,返回原始响应字节。"""
|
||||
"""发送 WUP 密码登录,返回原始响应字节。
|
||||
|
||||
未显式传入 ``safedeviceid`` 时会先执行新设备注册链,不再回退旧的
|
||||
固定 action/device_id。风控重试调用方应显式复用同一注册结果。
|
||||
注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。
|
||||
"""
|
||||
uid_str = account[3:] if account.startswith("hy_") else account
|
||||
mj, ua, sd = _golden_session_assets()
|
||||
dev = device_info or DEFAULT_GOLDEN_DEV
|
||||
mj, ua, _old_sd = _golden_session_assets()
|
||||
dev = dict(device_info or DEFAULT_GOLDEN_DEV)
|
||||
if not safedeviceid:
|
||||
try:
|
||||
_t1, safedeviceid, registered_device_id = register_device(
|
||||
fingerprint=dev.get("fingerprint"),
|
||||
proxies=proxies,
|
||||
timeout=timeout,
|
||||
)
|
||||
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 or dev.get("safedeviceid") or sd,
|
||||
safedeviceid,
|
||||
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5",
|
||||
mj["session"],
|
||||
mj["traceId"],
|
||||
@@ -238,9 +255,27 @@ def login_cred_with_flow(
|
||||
device_info: dict | None = None,
|
||||
proxies: dict | None = None,
|
||||
) -> tuple[bytes, int]:
|
||||
"""账号密码 -> (新鲜cred, 真实uid)。自动过 safe_auth 滑块。"""
|
||||
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
|
||||
|
||||
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
|
||||
"""
|
||||
dev = dict(device_info or DEFAULT_GOLDEN_DEV)
|
||||
try:
|
||||
_t1, safedeviceid, registered_device_id = register_device(
|
||||
fingerprint=dev.get("fingerprint"),
|
||||
proxies=proxies,
|
||||
)
|
||||
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=device_info, proxies=proxies)
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""虎牙多账号设备画像生成与管理。
|
||||
|
||||
为每个账号生成并持久化独立的设备身份画像(机型、屏幕、指纹、设备ID等)。
|
||||
|
||||
注意:画像只承载 *soft* 设备字段(机型/屏幕/随机指纹/随机 device_id)。
|
||||
``safedeviceid``(RSA action 令牌)与登录帧 ``device_id`` 由
|
||||
:mod:`core.huya.dfp_register` 在每次 WUP 登录前经新设备注册链实时签发,
|
||||
不再写入画像,也不再允许任何固定金样本令牌被多账号复用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -33,11 +38,6 @@ REAL_MODELS = [
|
||||
HDID = "ed0db8334cadd236c00cadf7e11ab5a5"
|
||||
APP_VERSION = "13.4.22"
|
||||
SDK_VERSION = "1.0.80138"
|
||||
SAFEDEVICEID_DEFAULT = (
|
||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
||||
)
|
||||
|
||||
|
||||
def _rand_sha1_hex() -> str:
|
||||
@@ -62,7 +62,8 @@ def generate_profile(model_pick=None) -> dict:
|
||||
"height": str(h),
|
||||
"device_id": _rand_sha1_hex(),
|
||||
"hdid": HDID,
|
||||
"safedeviceid": SAFEDEVICEID_DEFAULT,
|
||||
# 注: 不含 safedeviceid —— 每次登录前由 dfp_register 注册链签发,
|
||||
# 画像不再持久化固定令牌 (见模块注释)。
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""虎牙 App 新设备注册链(零设备 dfpReport 生成 + 注册响应解析)。
|
||||
|
||||
每次登录前执行 ``getDfpConfig -> selectOperator -> dfpReport``:
|
||||
|
||||
* ``dfpReport`` 使用已验证的 4146 字节随机 ``cw``(零设备生成,不重放旧设备报文);
|
||||
* 服务端返回新的 ``safedeviceid``(t2)和 ``device_id``(t5)—— 这两个字段
|
||||
就是 WUP 登录帧的设备字段签发源;
|
||||
* 请求模板只承载 TAF/WUP 协议形状,旧的 dfpReport 密文不会被使用。
|
||||
|
||||
请求模板来自 ``evidence/dfp_chain_golden.json``,与现有项目的证书/信封样本
|
||||
一样作为协议模板使用。模板中的设备字段会在 selectOperator 步骤按当前画像更新。
|
||||
|
||||
错误语义: 链上任何一步失败(模板缺失/损坏、HTTP 异常、响应缺字段)都会抛
|
||||
``DfpRegistrationError``。调用方(core/huya/app_login)应把注册失败当作明确失败
|
||||
终止登录;禁止静默回退到画像里的旧固定 safedeviceid/device_id。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
import requests
|
||||
|
||||
WSAPI = "https://wsapi.huya.com"
|
||||
UA = "okhttp/3.14.9"
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CHAIN_FILE = ROOT / "evidence" / "dfp_chain_golden.json"
|
||||
|
||||
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 _load_chain() -> dict[str, tuple[bytes, bytes]]:
|
||||
if not CHAIN_FILE.exists():
|
||||
raise DfpRegistrationError(f"注册链模板不存在: {CHAIN_FILE}")
|
||||
try:
|
||||
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
|
||||
return {
|
||||
name: (base64.b64decode(item["req_b64"]), base64.b64decode(item["resp_b64"]))
|
||||
for name, item in data.items()
|
||||
}
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
raise DfpRegistrationError(f"注册链模板读取失败: {exc}") from exc
|
||||
|
||||
|
||||
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=proxies,
|
||||
)
|
||||
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() -> bytes:
|
||||
"""构造服务端接受的随机 dfpReport 请求体。
|
||||
|
||||
cw 中 JSON 段明文仅承载真实请求的段长度与三元组形态,实证(2026-08-27)
|
||||
服务端不校验 cw 内容,随机 cw 照样 200 + 新签发 t2/t5,因此三元组
|
||||
(hdid/device_id/appkey,各 64hex sha256)只为凑齐模板形态,属诊断用途。
|
||||
"""
|
||||
hdid, device_id, appkey = _random_triple()
|
||||
json_plain = (
|
||||
'{"appId":"5008","appVer":"13.4.22","appkey":"%s",'
|
||||
'"channel":"xiaomi","deviceId":"%s",'
|
||||
'"deviceName":"M2102J2SC","hdid":"%s",'
|
||||
'"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)
|
||||
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 _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 = re.search(rb"\x16\x20([0-9a-f]{32})", data).group(1).decode()
|
||||
t2 = re.search(rb"\x26\xb4([A-Za-z0-9+/=]{180})", data).group(1).decode("latin1")
|
||||
t5 = re.search(rb"\x56\x28([0-9a-f]{40})", data).group(1).decode()
|
||||
except AttributeError 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) -> tuple[str, str, str]:
|
||||
"""执行新注册链,返回 ``(t1, safedeviceid, device_id)``。"""
|
||||
chain = _load_chain()
|
||||
_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(), timeout=timeout, proxies=proxies)
|
||||
return _parse_response(response)
|
||||
|
||||
Reference in New Issue
Block a user