feat(huya): 整合 App 协议登录获取 Cookie 全链路并与 Web 登录解耦
- core/huya: 新增 app_login, wup_encoder, nonce_forge, cert_forge, envelope_forge, device_profile, udb_aes - core/huya/__init__.py: 导出 login_huya_app_password 与 HuyaAppPasswordLogin - web/backend: 新增 /accounts/app-password-login 与 /accounts/app-password-login/selected 路由及 Schema,与原 Web 密码登录独立分开 - web/frontend: 增加 App 登录 API 与前端界面“App 登录选中”操作,弹窗结果明确区分 - tests: 新增 test_huya_app_login.py 单元测试覆盖全链路
This commit is contained in:
@@ -12,11 +12,15 @@ __all__ = [
|
||||
"HuyaLoginError",
|
||||
"HuyaLoginResult",
|
||||
"HuyaPasswordLogin",
|
||||
"HuyaAppLoginError",
|
||||
"HuyaAppQrAuthRequiredError",
|
||||
"HuyaAppPasswordLogin",
|
||||
"HuyaSmsCodeResult",
|
||||
"HuyaSmsLogin",
|
||||
"HuyaVerificationError",
|
||||
"HuyaVerificationSolver",
|
||||
"login_huya_password",
|
||||
"login_huya_app_password",
|
||||
"login_huya_sms",
|
||||
"send_huya_sms_code",
|
||||
"solve_huya_verification",
|
||||
@@ -57,6 +61,27 @@ def __getattr__(name: str):
|
||||
}
|
||||
globals().update(values)
|
||||
return values[name]
|
||||
if name in {
|
||||
"HuyaAppLoginError",
|
||||
"HuyaAppQrAuthRequiredError",
|
||||
"HuyaAppPasswordLogin",
|
||||
"login_huya_app_password",
|
||||
}:
|
||||
from .app_login import (
|
||||
HuyaAppLoginError,
|
||||
HuyaAppQrAuthRequiredError,
|
||||
HuyaAppPasswordLogin,
|
||||
login_huya_app_password,
|
||||
)
|
||||
|
||||
values = {
|
||||
"HuyaAppLoginError": HuyaAppLoginError,
|
||||
"HuyaAppQrAuthRequiredError": HuyaAppQrAuthRequiredError,
|
||||
"HuyaAppPasswordLogin": HuyaAppPasswordLogin,
|
||||
"login_huya_app_password": login_huya_app_password,
|
||||
}
|
||||
globals().update(values)
|
||||
return values[name]
|
||||
if name in {
|
||||
"HuyaSmsCodeResult",
|
||||
"HuyaSmsLogin",
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"""虎牙 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
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from typing 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 get_huya_sdid
|
||||
from .device_profile import get_profile
|
||||
from .envelope_forge import Envelope
|
||||
from .login import HuyaCredentialError, 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",
|
||||
"safedeviceid": (
|
||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class HuyaAppLoginError(HuyaLoginError):
|
||||
"""虎牙 App 登录失败。"""
|
||||
|
||||
|
||||
class HuyaAppQrAuthRequiredError(HuyaAppLoginError):
|
||||
"""风控要求扫码验证 (qr_auth)。"""
|
||||
|
||||
|
||||
def _golden_session_assets() -> tuple[dict, str, str]:
|
||||
mj = {
|
||||
"associationId": 8193,
|
||||
"funcName": "hypasswordLogin",
|
||||
"group": 1,
|
||||
"id": 4097,
|
||||
"session": 3251699,
|
||||
"step": 1,
|
||||
"stillLogin": False,
|
||||
"traceId": "0b8f098ff64a5bdc-23473-94783833787595172451",
|
||||
"type": 3,
|
||||
"uid": 0,
|
||||
"userContext": "",
|
||||
}
|
||||
ua = (
|
||||
'{"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"}]}'
|
||||
)
|
||||
sd = (
|
||||
"PQwemAN9NHkZKoMqVTFUZBIypqMTaQEOrmXr37xQVhQZqrL/gUKEQ11xvE0ju48V8O/"
|
||||
"t9UBGSp27m4+6bP4IiAEnpaR5Rj1kHEfN2SPLPqYZW9vroxUSoAvjJn6ezTP9jWGxxlRDCbt"
|
||||
"Py4Rd6MencYT/pNImVIWK+YbNKZt1O05bHUFhqHf3"
|
||||
)
|
||||
return mj, ua, sd
|
||||
|
||||
|
||||
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 密码登录,返回原始响应字节。"""
|
||||
uid_str = account[3:] if account.startswith("hy_") else account
|
||||
mj, ua, sd = _golden_session_assets()
|
||||
dev = device_info or DEFAULT_GOLDEN_DEV
|
||||
pkt = build_password_login_wup(
|
||||
uid_str,
|
||||
hashlib.sha1(password.encode()).hexdigest(),
|
||||
safedeviceid or dev.get("safedeviceid") or sd,
|
||||
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": "Dalvik/2.1.0 (Linux; U; Android 11)",
|
||||
"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) -> 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=APP_UA_MOBILE,
|
||||
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 滑块。"""
|
||||
for rnd in range(max_rounds):
|
||||
resp = wup_password_login_raw(account, password, device_info=device_info, 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)
|
||||
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):
|
||||
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 APP_UA_MOBILE,
|
||||
"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 流程。"""
|
||||
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)
|
||||
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_obj = get_huya_sdid(allow_fallback=True)
|
||||
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)
|
||||
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()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""虎牙 udb 登录证书 (biz_token) 铸造与解析工具。
|
||||
|
||||
证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] )
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import struct
|
||||
|
||||
from .udb_aes import udb_decrypt, udb_encrypt
|
||||
|
||||
KEY_TABLE_RAW = [
|
||||
"4VYcPdvKKqjBHZtCmbroRXHk",
|
||||
"xXEDWqiKLGwEZ6HubEiswCqK",
|
||||
"3FMHubdKosFrhmXNLHTNHZwe",
|
||||
]
|
||||
DEFAULT_KEY16 = KEY_TABLE_RAW[0][:16].encode()
|
||||
|
||||
|
||||
def build_p1(
|
||||
app_id: bytes,
|
||||
fingerprint: bytes,
|
||||
cred: bytes,
|
||||
rnd: bytes | None = None,
|
||||
) -> bytes:
|
||||
"""按样本结构组装 P1 明文。
|
||||
|
||||
- app_id: b'5008' 等 4 字节
|
||||
- fingerprint: 40 字节 ASCII hex
|
||||
- cred: 114 字节 hyCred
|
||||
- rnd: 20 字节 nonce,缺省用 os.urandom(20)
|
||||
"""
|
||||
assert len(fingerprint) == 40, f"指纹应为40B, 实际为 {len(fingerprint)}"
|
||||
assert len(cred) == 114, f"cred应为114B, 实际为 {len(cred)}"
|
||||
r = os.urandom(20) if rnd is None else rnd
|
||||
p1 = b"\x01\x04\x00" + app_id
|
||||
p1 += struct.pack("<H", len(r)) + r
|
||||
p1 += struct.pack("<H", len(fingerprint)) + fingerprint
|
||||
p1 += struct.pack("<H", len(cred)) + cred
|
||||
assert len(p1) == 187, f"P1长度应为187,实际为 {len(p1)}"
|
||||
return p1
|
||||
|
||||
|
||||
def parse_p1(data: bytes) -> dict:
|
||||
"""解析 P1 结构。"""
|
||||
o = 0
|
||||
|
||||
def tk(n: int) -> bytes:
|
||||
nonlocal o
|
||||
b = data[o:o + n]
|
||||
o += n
|
||||
return b
|
||||
|
||||
out = {
|
||||
"head": tk(3).hex(),
|
||||
"app_id": tk(4),
|
||||
}
|
||||
n = struct.unpack("<H", tk(2))[0]
|
||||
out["rnd"] = tk(n)
|
||||
n = struct.unpack("<H", tk(2))[0]
|
||||
out["fingerprint"] = tk(n)
|
||||
n = struct.unpack("<H", tk(2))[0]
|
||||
out["cred"] = tk(n)
|
||||
out["trailing"] = data[o:]
|
||||
return out
|
||||
|
||||
|
||||
def forge_cert(
|
||||
p1: bytes,
|
||||
key16: bytes = DEFAULT_KEY16,
|
||||
key_idx: int = 0x20,
|
||||
type_byte: int = 0x0C,
|
||||
) -> bytes:
|
||||
"""P1 -> 二进制证书 [type][key_idx][192B AES 密文]。"""
|
||||
ct = udb_encrypt(key16, p1)
|
||||
assert len(ct) == 192, len(ct)
|
||||
return bytes([type_byte, key_idx]) + ct
|
||||
|
||||
|
||||
def parse_cert(blob: bytes) -> tuple[int, int, bytes]:
|
||||
"""[0x0c][idx][192B] -> (type, idx, ct)。"""
|
||||
assert blob[0] == 0x0C and len(blob) >= 194, "非证书结构"
|
||||
return blob[0], blob[1], blob[2:194]
|
||||
|
||||
|
||||
def decrypt_cert(blob: bytes, key16: bytes = DEFAULT_KEY16) -> bytes:
|
||||
"""解密二进制证书为 P1 明文。"""
|
||||
_, _, ct = parse_cert(blob)
|
||||
return udb_decrypt(key16, ct)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""虎牙多账号设备画像生成与管理。
|
||||
|
||||
为每个账号生成并持久化独立的设备身份画像(机型、屏幕、指纹、设备ID等)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DATA_DIR = ROOT / "data"
|
||||
PRIMARY_PROFILE_DB = DATA_DIR / "huya_device_profiles.json"
|
||||
FALLBACK_PROFILE_DB = ROOT / "evidence" / "device_profiles.json"
|
||||
|
||||
REAL_MODELS = [
|
||||
("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)),
|
||||
("vivo", "V2370A", "V2370A,34,13", (1080, 2412)),
|
||||
("oppo", "PFFM20", "PFFM20,34,13", (1080, 2412)),
|
||||
("honor", "SDY-AN00", "SDY-AN00,31,12", (1080, 2400)),
|
||||
("samsung", "SM-G9910", "SM-G9910,31,12", (1440, 3200)),
|
||||
("oneplus", "PGZ110", "PGZ110,34,13", (1080, 2412)),
|
||||
("redmi", "23049PCD8G", "23049PCD8G,34,13", (1080, 2400)),
|
||||
("realme", "RMX3366", "RMX3366,34,13", (1080, 2412)),
|
||||
("iqoo", "V2183A", "V2183A,34,13", (1080, 2400)),
|
||||
("nubia", "NX729J", "NX729J,33,13", (1080, 2400)),
|
||||
("motorola", "XT2301-5", "XT2301-5,33,13", (1080, 2400)),
|
||||
("gionee", "GN9013", "GN9013,29,10", (720, 1560)),
|
||||
]
|
||||
|
||||
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:
|
||||
return hashlib.sha1(os.urandom(20)).hexdigest()
|
||||
|
||||
|
||||
def generate_profile(model_pick=None) -> dict:
|
||||
"""生成一套随机设备画像。"""
|
||||
vendor, model, screen, (w, h) = (
|
||||
random.choice(REAL_MODELS) if model_pick is None else model_pick
|
||||
)
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"sdk_version": SDK_VERSION,
|
||||
"vendor": vendor,
|
||||
"model": model,
|
||||
"os": "android",
|
||||
"ip": "127.0.0.1",
|
||||
"fingerprint": _rand_sha1_hex(),
|
||||
"screen": screen,
|
||||
"width": str(w),
|
||||
"height": str(h),
|
||||
"device_id": _rand_sha1_hex(),
|
||||
"hdid": HDID,
|
||||
"safedeviceid": SAFEDEVICEID_DEFAULT,
|
||||
}
|
||||
|
||||
|
||||
def _load_db() -> dict:
|
||||
if PRIMARY_PROFILE_DB.exists():
|
||||
try:
|
||||
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
if FALLBACK_PROFILE_DB.exists():
|
||||
try:
|
||||
return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_db(db: dict) -> None:
|
||||
try:
|
||||
PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
PRIMARY_PROFILE_DB.write_text(json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_profile(account: str, force_new: bool = False) -> dict:
|
||||
"""按账号获取或创建画像(幂等:同账号复用同一套)。"""
|
||||
db = _load_db()
|
||||
if not force_new and account in db:
|
||||
return db[account]
|
||||
p = generate_profile()
|
||||
db[account] = p
|
||||
_save_db(db)
|
||||
return p
|
||||
@@ -0,0 +1,260 @@
|
||||
"""wupData 信封构造与补丁工具。
|
||||
|
||||
解析与改写 WUP 信封中的 cert、uid、session 等字段。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||
STRING1, STRING4 = 0x06, 0x07
|
||||
MAP, LIST = 0x08, 0x09
|
||||
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||
|
||||
DEFAULT_QURL_B64 = (
|
||||
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
|
||||
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
|
||||
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
|
||||
"ZGMtMjY2OTItODI2MzkzOTQ3ODc2NjM1NTEzNjUiLCJ0eXBlIjoyLCJ1aWQiOjAsInVzZXJDb250ZXh0IjoiIn02BDUw"
|
||||
"MDhAA1a0UFF3ZW1BTjlOSGtaS29NcVdNUW5WUkl5cHFNVGFRRU9ybVhyMzd4UVZoUVpxclA1aVVLRVExMXh2RTB2cE1n"
|
||||
"a2xlajIzbEpGYmFHVW5LUEFhYnhWaUF0TnZyTkxBbXhBQzJyRGp6Qy9JSU0vSWFQeTdTcytvQXZqSmltR2pBS2RnVmpr"
|
||||
"bUhWV2Q2bEw4cUZScU4zWkJMamt4c2xUQjczaXNvallWcjlrSFhWdUh3SkxoM3YzZgB2AIYAlgALGgYgZWQwZGI4MzM0"
|
||||
"Y2FkZDIzNmMwMGNhZGY3ZTExYWI1YTUWBzEzLjQuMjImCTEuMC44MDEzODYARgkxMjcuMC4wLjFWBnhpYW9taWYACyoA"
|
||||
"ARYJTTIxMDJKMlNDJigwMmRmMzk4Nzk3NDMyZWFkZWZjYzEyNzY3MTE5YWQ1ZTgwOTk5Mzg5NgdhbmRyb2lkRg9NMjEw"
|
||||
"MkoyU0MsMzAsMTFmBDEwODB2BDIxMjCGKDdjNTM4N2UwNTM5YzAyM2MzMWM0ZmYwZTgwN2U3MjU2MTE3Mzg1ZWULMwAA"
|
||||
"ARdRuGVvRwAAAQREQ0JHY0QyN0liSWEvZnZ0UHhNT2xZdGJUY0M3bWRaUlJ3YzIyc2NnQkFyRTJ5eTZwSUdIQjNMK0tr"
|
||||
"MzVxVS9iaWc1Qk1TVVVSd3gzeS9wWVpWajRjd20rWEg1dnNrakR2SzNhUlhudEJGcURDQUUvaUVIbGs4ZXJ3VUJZdmJM"
|
||||
"aXlIb0YrSytQam5GTGJRMmlzSHVhcUtqTHAvWmRETDlxSit3VEVSb3h0ZjFuUzlTZ0l2N3lCaVIyMjd4N3F3RjllUTVu"
|
||||
"ckNaRitnRnczelVZb2N6Uk9jbHE1aXZDclhRZTVSZ3hOYkp5aWQ3ZkZqTVhYYlNQdHBIZ2p0TVJtdWp6RVRvPVYAZgAL"
|
||||
"BhB3dXB1ZGJyZXF1ZXN0X3YwHQAABQIAWkEFjJgMqAw="
|
||||
)
|
||||
|
||||
|
||||
def _read_len_int(d: bytes, p: int) -> tuple[int, int]:
|
||||
dt = d[p] & 0x0F
|
||||
if dt == ZERO:
|
||||
return 0, p + 1
|
||||
if dt == INT8:
|
||||
return struct.unpack_from(">b", d, p + 1)[0], p + 2
|
||||
if dt == INT16:
|
||||
return struct.unpack_from(">h", d, p + 1)[0], p + 3
|
||||
if dt == INT32:
|
||||
return struct.unpack_from(">i", d, p + 1)[0], p + 5
|
||||
raise ValueError(f"长度int类型异常 {dt:#x}@{p}")
|
||||
|
||||
|
||||
def _skip_value(d: bytes, p: int, dt: int) -> int:
|
||||
if dt == ZERO:
|
||||
return p
|
||||
if dt == INT8:
|
||||
return p + 1
|
||||
if dt == INT16:
|
||||
return p + 2
|
||||
if dt == INT32:
|
||||
return p + 4
|
||||
if dt == INT64:
|
||||
return p + 8
|
||||
if dt == STRING1:
|
||||
return p + 1 + d[p]
|
||||
if dt == STRING4:
|
||||
return p + 4 + struct.unpack_from(">i", d, p)[0]
|
||||
if dt == SIMPLE_LIST:
|
||||
p += 1
|
||||
n, p = _read_len_int(d, p)
|
||||
return p + n
|
||||
if dt == MAP:
|
||||
n, p = _read_len_int(d, p)
|
||||
for _ in range(n):
|
||||
h = d[p]
|
||||
p += 1
|
||||
kd = h & 0x0F
|
||||
if kd == STRING1:
|
||||
p += 1 + d[p]
|
||||
elif kd == STRING4:
|
||||
p += 4 + struct.unpack_from(">i", d, p)[0]
|
||||
else:
|
||||
raise ValueError(f"map key 类型 {kd:#x}")
|
||||
vh = d[p]
|
||||
p += 1
|
||||
p = _skip_value(d, p, vh & 0x0F)
|
||||
return p
|
||||
if dt == LIST:
|
||||
n, p = _read_len_int(d, p)
|
||||
eh = d[p]
|
||||
p += 1
|
||||
edt = eh & 0x0F
|
||||
for _ in range(n):
|
||||
p = _skip_value(d, p, edt)
|
||||
return p
|
||||
if dt == STRUCT_BEGIN:
|
||||
while True:
|
||||
h = d[p]
|
||||
p += 1
|
||||
sdt = h & 0x0F
|
||||
if sdt == STRUCT_END:
|
||||
break
|
||||
p = _skip_value(d, p, sdt)
|
||||
return p
|
||||
raise ValueError(f"未知类型 {dt:#x}@{p}")
|
||||
|
||||
|
||||
class Envelope:
|
||||
"""WUP 请求信封结构解析器与补丁器。"""
|
||||
|
||||
def __init__(self, raw_bytes: bytes):
|
||||
self.raw = bytearray(raw_bytes)
|
||||
self.tag4_span: tuple[int, int] | None = None
|
||||
self.meta_json_span: tuple[int, int] | None = None
|
||||
self.uid_off: int | None = None
|
||||
self.cert_off: int | None = None
|
||||
self.cert_len: int = 260
|
||||
self._parse()
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path | None = None) -> "Envelope":
|
||||
"""加载信封模板,支持从文件加载或使用内嵌金样本。"""
|
||||
if path:
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
return cls._load_from_path(p)
|
||||
# 尝试查找 evidence/cert_keycap.json
|
||||
candidate = Path(__file__).resolve().parent.parent.parent / "evidence" / "cert_keycap.json"
|
||||
if candidate.exists():
|
||||
try:
|
||||
return cls._load_from_path(candidate)
|
||||
except Exception:
|
||||
pass
|
||||
return cls(base64.b64decode(DEFAULT_QURL_B64))
|
||||
|
||||
@classmethod
|
||||
def _load_from_path(cls, p: Path) -> "Envelope":
|
||||
if p.suffix == ".json":
|
||||
j = json.loads(p.read_text("utf-8"))
|
||||
q = next(e["data"] for e in j if e.get("type") == "qurl_done" and e.get("data"))
|
||||
return cls(base64.b64decode(q))
|
||||
return cls(p.read_bytes())
|
||||
|
||||
def _parse(self) -> None:
|
||||
d = self.raw
|
||||
p = 4
|
||||
# WUP header
|
||||
while p < len(d):
|
||||
h = d[p]
|
||||
p += 1
|
||||
tag, dt = (h >> 4) & 0x0F, h & 0x0F
|
||||
if tag == 4 and dt == INT32:
|
||||
self.tag4_span = (p, p + 4)
|
||||
p += 4
|
||||
elif tag == 7 and dt == SIMPLE_LIST:
|
||||
p += 1 # 元素类型头
|
||||
n, p = _read_len_int(d, p)
|
||||
self._parse_sbuffer(p, n)
|
||||
break
|
||||
else:
|
||||
p = _skip_value(d, p, dt)
|
||||
|
||||
def _parse_sbuffer(self, start: int, ln: int) -> None:
|
||||
d = self.raw
|
||||
p = start
|
||||
h = d[p]
|
||||
p += 1
|
||||
assert (h & 0x0F) == MAP
|
||||
cnt, p = _read_len_int(d, p)
|
||||
for _ in range(cnt):
|
||||
kh = d[p]
|
||||
p += 1
|
||||
kln = d[p]
|
||||
p += 1
|
||||
k = bytes(d[p:p + kln])
|
||||
p += kln
|
||||
vh = d[p]
|
||||
p += 1
|
||||
if k == b"_wup_data":
|
||||
assert (vh & 0x0F) == SIMPLE_LIST
|
||||
p += 1
|
||||
wup_data_len, p = _read_len_int(d, p)
|
||||
self._parse_wup_data_struct(p, wup_data_len)
|
||||
p += wup_data_len
|
||||
else:
|
||||
p = _skip_value(d, p, vh & 0x0F)
|
||||
|
||||
def _parse_wup_data_struct(self, start: int, ln: int) -> None:
|
||||
d = self.raw
|
||||
q = start
|
||||
assert (d[q] & 0x0F) == STRUCT_BEGIN
|
||||
q += 1
|
||||
while q < start + ln:
|
||||
hh = d[q]
|
||||
q += 1
|
||||
tag, dt = (hh >> 4) & 0x0F, hh & 0x0F
|
||||
if tag == 15:
|
||||
tag = d[q]
|
||||
q += 1
|
||||
if dt == STRUCT_END:
|
||||
break
|
||||
if tag == 0 and dt == STRUCT_BEGIN:
|
||||
while True:
|
||||
h2 = d[q]
|
||||
q += 1
|
||||
tg2, dt2 = (h2 >> 4) & 0x0F, h2 & 0x0F
|
||||
if tg2 == 15:
|
||||
tg2 = d[q]
|
||||
q += 1
|
||||
if dt2 == STRUCT_END:
|
||||
break
|
||||
vs = q
|
||||
q = _skip_value(d, q, dt2)
|
||||
if tg2 == 2 and dt2 in (STRING1, STRING4):
|
||||
off = vs + (4 if dt2 == STRING4 else 1)
|
||||
self.meta_json_span = (off, q)
|
||||
elif tag == 3 and dt == INT64:
|
||||
self.uid_off = q
|
||||
q += 8
|
||||
elif tag == 4 and dt == STRING4:
|
||||
ln_c = struct.unpack_from(">i", d, q)[0]
|
||||
self.cert_off = q + 4
|
||||
self.cert_len = ln_c
|
||||
q = self.cert_off + ln_c
|
||||
else:
|
||||
q = _skip_value(d, q, dt)
|
||||
if self.uid_off is None or self.cert_off is None:
|
||||
raise ValueError("未在信封中定位到 t3(uid) 或 t4(cert)")
|
||||
|
||||
@property
|
||||
def uid(self) -> int:
|
||||
return struct.unpack_from(">Q", self.raw, self.uid_off)[0]
|
||||
|
||||
def patch_uid(self, uid: int) -> "Envelope":
|
||||
struct.pack_into(">Q", self.raw, self.uid_off, uid)
|
||||
return self
|
||||
|
||||
@property
|
||||
def cert_b64(self) -> bytes:
|
||||
return bytes(self.raw[self.cert_off:self.cert_off + self.cert_len])
|
||||
|
||||
def patch_cert(self, cert: bytes) -> "Envelope":
|
||||
b64 = base64.b64encode(cert)
|
||||
if len(b64) != self.cert_len:
|
||||
raise ValueError(
|
||||
f"证书b64长度不符: {len(b64)} != 模板 {self.cert_len} (cert {len(cert)}B)"
|
||||
)
|
||||
self.raw[self.cert_off:self.cert_off + self.cert_len] = b64
|
||||
return self
|
||||
|
||||
def patch_session(self, session: int) -> "Envelope":
|
||||
if self.tag4_span:
|
||||
struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF)
|
||||
d = self.raw
|
||||
i = d.find(b"wupudbrequest_v0")
|
||||
if i >= 0:
|
||||
j = i + len(b"wupudbrequest_v0") + 4
|
||||
if j + 4 <= len(d):
|
||||
struct.pack_into(">I", d, j, session & 0xFFFFFFFF)
|
||||
return self
|
||||
|
||||
def wup_b64(self) -> str:
|
||||
return base64.b64encode(bytes(self.raw)).decode()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""虎牙证书 nonce(rnd) 本地生成器。
|
||||
|
||||
基于 XXTEA 算法与 uid + k1 派生密钥。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
import time
|
||||
|
||||
K1_DEFAULT = "865a4924a40897ac1fcfe6b4c2cbb0e3"
|
||||
|
||||
|
||||
def _xxtea_encrypt_words(v: list[int], k: list[int]) -> list[int]:
|
||||
"""标准 XXTEA (delta=0x9e3779b9, rounds=6+52/n)。"""
|
||||
n = len(v)
|
||||
if n < 2:
|
||||
return v
|
||||
z = v[n - 1]
|
||||
y = v[0]
|
||||
s = 0
|
||||
delta = 0x9E3779B9
|
||||
q = 6 + 52 // n
|
||||
while q > 0:
|
||||
s = (s + delta) & 0xFFFFFFFF
|
||||
e = (s >> 2) & 3
|
||||
p = 0
|
||||
while p < n - 1:
|
||||
y = v[p + 1]
|
||||
z = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
|
||||
v[p] = z
|
||||
p += 1
|
||||
y = v[0]
|
||||
z = (v[n - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z)))) & 0xFFFFFFFF
|
||||
v[n - 1] = z
|
||||
q -= 1
|
||||
return v
|
||||
|
||||
|
||||
def xxtea_encrypt(data: bytes, key16: bytes) -> bytes:
|
||||
"""虎牙专用 XXTEA: 输出 words = ceil(len/4)+1, 末尾 word 存原始长度。"""
|
||||
n = len(data)
|
||||
nwords = (n // 4) + 1
|
||||
v = [0] * nwords
|
||||
for i in range(n // 4):
|
||||
v[i] = struct.unpack("<I", data[i * 4:i * 4 + 4])[0]
|
||||
v[nwords - 1] = n
|
||||
k = [struct.unpack("<I", key16[i * 4:i * 4 + 4])[0] for i in range(4)]
|
||||
_xxtea_encrypt_words(v, k)
|
||||
return b"".join(struct.pack("<I", w & 0xFFFFFFFF) for w in v)
|
||||
|
||||
|
||||
def pack_u64(v: int) -> bytes:
|
||||
"""8字节小端裸值。"""
|
||||
return struct.pack("<Q", v & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
|
||||
def gen_nonce(
|
||||
uid: int,
|
||||
k1: str = K1_DEFAULT,
|
||||
service_time_ms: int | None = None,
|
||||
counter: int = 0,
|
||||
) -> bytes:
|
||||
"""按设备算法生成 20B nonce(rnd)。
|
||||
|
||||
- uid: 虎牙 uid 数字(如 1199666914671)
|
||||
- k1: 设备常量 32hex
|
||||
- service_time_ms: 缺省为当前毫秒时间戳
|
||||
- counter: 计数器(通常为 0)
|
||||
"""
|
||||
st = service_time_ms if service_time_ms is not None else int(time.time() * 1000)
|
||||
st &= 0x7FFFFFFFFFFF
|
||||
nonce_val = (counter & 0xFFFF) | (st << 16)
|
||||
data = pack_u64(st) + pack_u64(nonce_val)
|
||||
key = hashlib.md5((str(uid) + k1).encode()).hexdigest()[:16].encode()
|
||||
return xxtea_encrypt(data, key)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""虎牙 Udb AES 加解密工具。
|
||||
|
||||
基于 AES-128-ECB 与 0 填充。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
MASTER_KEY_STR = b"HuyaUdb1928374650qwertyuiop"
|
||||
|
||||
|
||||
def encrypt_block(key16: bytes, block16: bytes) -> bytes:
|
||||
return AES.new(key16, AES.MODE_ECB).encrypt(block16)
|
||||
|
||||
|
||||
def decrypt_block(key16: bytes, block16: bytes) -> bytes:
|
||||
return AES.new(key16, AES.MODE_ECB).decrypt(block16)
|
||||
|
||||
|
||||
def zero_pad(data: bytes) -> bytes:
|
||||
"""UdbAESUtil::encrypt 填充:补 '\\0' 到 16 的倍数。"""
|
||||
if len(data) % 16 == 0:
|
||||
return data
|
||||
return data + b"\x00" * (16 - len(data) % 16)
|
||||
|
||||
|
||||
def udb_encrypt(key16: bytes, plain: bytes) -> bytes:
|
||||
"""AES-128-ECB 加密,带零填充。"""
|
||||
p = zero_pad(plain)
|
||||
return AES.new(key16, AES.MODE_ECB).encrypt(p)
|
||||
|
||||
|
||||
def udb_decrypt(key16: bytes, ct: bytes) -> bytes:
|
||||
"""AES-128-ECB 解密,去除尾部零填充。"""
|
||||
return AES.new(key16, AES.MODE_ECB).decrypt(ct).rstrip(b"\x00")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""虎牙 App 密码登录 WUP(TAF) 请求帧编码器。
|
||||
|
||||
纯 Python 构造符合 TAF/WUP 协议的请求二进制帧。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import struct
|
||||
import time as _time
|
||||
from typing import Any, Dict
|
||||
|
||||
# TAF 类型标签
|
||||
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||
STRING1, STRING4 = 0x06, 0x07
|
||||
MAP, LIST = 0x08, 0x09
|
||||
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||
|
||||
|
||||
class _Writer:
|
||||
"""TAF 输出流。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buf = bytearray()
|
||||
|
||||
def get(self) -> bytes:
|
||||
return bytes(self.buf)
|
||||
|
||||
def head(self, tag: int, dtype: int) -> None:
|
||||
if tag < 15:
|
||||
self.buf.append((tag << 4) | dtype)
|
||||
else:
|
||||
self.buf.append(0xF0 | dtype)
|
||||
self.buf.append(tag)
|
||||
|
||||
def int8(self, tag: int, v: int) -> None:
|
||||
if v == 0:
|
||||
self.head(tag, ZERO)
|
||||
else:
|
||||
self.head(tag, INT8)
|
||||
self.buf += struct.pack("b", v)
|
||||
|
||||
def int16(self, tag: int, v: int) -> None:
|
||||
if -128 <= v <= 127:
|
||||
self.int8(tag, v)
|
||||
else:
|
||||
self.head(tag, INT16)
|
||||
self.buf += struct.pack(">h", v)
|
||||
|
||||
def int32(self, tag: int, v: int) -> None:
|
||||
if -32768 <= v <= 32767:
|
||||
self.int16(tag, v)
|
||||
else:
|
||||
self.head(tag, INT32)
|
||||
self.buf += struct.pack(">i", v)
|
||||
|
||||
def int64(self, tag: int, v: int) -> None:
|
||||
if -2147483648 <= v <= 2147483647:
|
||||
self.int32(tag, v)
|
||||
else:
|
||||
self.head(tag, INT64)
|
||||
self.buf += struct.pack(">q", v)
|
||||
|
||||
def string(self, tag: int, s: str) -> None:
|
||||
b = s.encode("utf-8")
|
||||
if len(b) > 255:
|
||||
self.head(tag, STRING4)
|
||||
self.buf += struct.pack(">I", len(b))
|
||||
else:
|
||||
self.head(tag, STRING1)
|
||||
self.buf += struct.pack("B", len(b))
|
||||
self.buf += b
|
||||
|
||||
def bytes(self, tag: int, b: bytes) -> None:
|
||||
"""SIMPLE_LIST (byte[])。"""
|
||||
self.head(tag, SIMPLE_LIST)
|
||||
self.head(0, INT8)
|
||||
self.int32(0, len(b))
|
||||
self.buf += b
|
||||
|
||||
def struct_begin(self, tag: int) -> None:
|
||||
self.head(tag, STRUCT_BEGIN)
|
||||
|
||||
def struct_end(self) -> None:
|
||||
self.head(0, STRUCT_END)
|
||||
|
||||
def list_begin(self, tag: int, n: int) -> None:
|
||||
self.head(tag, LIST)
|
||||
self.int32(0, n)
|
||||
|
||||
def map_begin(self, tag: int, n: int) -> None:
|
||||
self.head(tag, MAP)
|
||||
self.int32(0, n)
|
||||
|
||||
|
||||
def _build_meta_json(session: int, trace_id: str) -> str:
|
||||
"""构造 _wup_data.t0.t2 元数据 JSON。"""
|
||||
meta: Dict[str, Any] = {
|
||||
"associationId": 8193,
|
||||
"funcName": "hypasswordLogin",
|
||||
"group": 1,
|
||||
"id": 4097,
|
||||
"session": session,
|
||||
"step": 1,
|
||||
"stillLogin": False,
|
||||
"traceId": trace_id,
|
||||
"type": 3,
|
||||
"uid": 0,
|
||||
"userContext": "",
|
||||
}
|
||||
return json.dumps(meta, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _make_name(uid_str: str) -> str:
|
||||
"""登录名 = "hy_" + 虎牙号。"""
|
||||
if uid_str.startswith("hy_"):
|
||||
return uid_str
|
||||
return "hy_" + uid_str
|
||||
|
||||
|
||||
def make_user_action(now_ms: int | None = None) -> str:
|
||||
"""生成一条随机的 userAction 风控行为 JSON。"""
|
||||
if now_ms is None:
|
||||
now_ms = int(_time.time() * 1000)
|
||||
t1 = now_ms
|
||||
t2 = now_ms + random.randint(200, 900)
|
||||
return json.dumps(
|
||||
{
|
||||
"curl": "登录页",
|
||||
"furl": "我的",
|
||||
"latitude": "-1.0",
|
||||
"longitude": "-1.0",
|
||||
"ssid": "",
|
||||
"user_action": [
|
||||
{"id": "24", "time": str(t1),
|
||||
"x": str(random.randint(150, 900)),
|
||||
"y": str(random.randint(800, 1600))},
|
||||
{"id": "11", "time": str(t2),
|
||||
"x": str(random.randint(150, 900)),
|
||||
"y": str(random.randint(800, 1600))},
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def make_trace_id(pid: int = 0) -> str:
|
||||
"""生成 traceId,格式 `<hex16>-<pid>-<毫秒时间戳>`。"""
|
||||
return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}"
|
||||
|
||||
|
||||
def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
|
||||
safedeviceid: str, hdid: str, session: int,
|
||||
trace_id: str, user_action_json: str,
|
||||
device_info: Dict[str, str]) -> None:
|
||||
"""编码 _wup_data struct。"""
|
||||
meta_json = _build_meta_json(session, trace_id)
|
||||
name = _make_name(uid_str)
|
||||
|
||||
w.struct_begin(0)
|
||||
|
||||
# -- t0: 请求头 struct --
|
||||
w.struct_begin(0)
|
||||
w.int8(0, 0)
|
||||
w.string(1, "1.0")
|
||||
w.string(2, meta_json)
|
||||
w.string(3, "5008")
|
||||
w.int8(4, 3)
|
||||
w.string(5, safedeviceid)
|
||||
w.string(6, "")
|
||||
w.string(7, "")
|
||||
w.string(8, user_action_json)
|
||||
w.string(9, "")
|
||||
w.struct_end()
|
||||
|
||||
# -- t1: 设备信息 struct --
|
||||
di = device_info
|
||||
w.struct_begin(1)
|
||||
w.string(0, hdid)
|
||||
w.string(1, di.get("app_version", "13.4.22"))
|
||||
w.string(2, di.get("sdk_version", "1.0.80138"))
|
||||
w.string(3, "")
|
||||
w.string(4, di.get("ip", "127.0.0.1"))
|
||||
w.string(5, di.get("vendor", "xiaomi"))
|
||||
w.string(6, "")
|
||||
w.struct_end()
|
||||
|
||||
# -- t2: 屏幕/设备特征 struct --
|
||||
w.struct_begin(2)
|
||||
w.int8(0, 1)
|
||||
w.string(1, di.get("model", "M2102J2SC"))
|
||||
w.string(2, di.get("fingerprint", ""))
|
||||
w.string(3, di.get("os", "android"))
|
||||
w.string(4, di.get("screen", "M2102J2SC,30,11"))
|
||||
w.string(6, str(di.get("width", "1080")))
|
||||
w.string(7, str(di.get("height", "2120")))
|
||||
w.string(8, di.get("device_id", ""))
|
||||
w.struct_end()
|
||||
|
||||
# -- 登录字段 --
|
||||
w.string(3, name)
|
||||
w.string(4, sha1_password)
|
||||
w.list_begin(5, 1)
|
||||
w.string(0, "5008")
|
||||
w.int8(6, 1)
|
||||
w.map_begin(7, 0)
|
||||
w.bytes(8, b"")
|
||||
|
||||
w.struct_end()
|
||||
|
||||
|
||||
def build_password_login_wup(
|
||||
uid_str: str,
|
||||
sha1_password: str,
|
||||
safedeviceid: str,
|
||||
hdid: str,
|
||||
session: int,
|
||||
trace_id: str,
|
||||
user_action_json: str,
|
||||
device_info: Dict[str, str],
|
||||
) -> bytes:
|
||||
"""构造密码登录的 WUP TAF 请求体。"""
|
||||
wd = _Writer()
|
||||
_build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid,
|
||||
session, trace_id, user_action_json, device_info)
|
||||
wup_data = wd.get()
|
||||
|
||||
req = _Writer()
|
||||
req.int32(0, session)
|
||||
wupdbreq_v0 = req.get()
|
||||
|
||||
sb = _Writer()
|
||||
sb.map_begin(0, 2)
|
||||
sb.string(0, "_wup_data")
|
||||
sb.bytes(1, wup_data)
|
||||
sb.string(0, "wupudbrequest_v0")
|
||||
sb.bytes(1, wupdbreq_v0)
|
||||
s_buffer = sb.get()
|
||||
|
||||
w = _Writer()
|
||||
w.int16(1, 3)
|
||||
w.int8(2, 0)
|
||||
w.int8(3, 0)
|
||||
w.int32(4, session)
|
||||
w.string(5, "huyaudbwebui")
|
||||
w.string(6, "hypasswordLogin")
|
||||
w.bytes(7, s_buffer)
|
||||
w.int32(8, 0)
|
||||
w.map_begin(9, 0)
|
||||
w.map_begin(10, 0)
|
||||
wup_body = w.get()
|
||||
|
||||
return struct.pack(">I", 4 + len(wup_body)) + wup_body
|
||||
Reference in New Issue
Block a user