Compare commits
4
Commits
09ab80e062
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d391e2ce3e | ||
|
|
05b1325a04 | ||
|
|
c7db66f18c | ||
|
|
76b14b69b7 |
@@ -0,0 +1,293 @@
|
||||
diff --git a/core/huya/app_login.py b/core/huya/app_login.py
|
||||
index afa33a4..86e18a9 100644
|
||||
--- a/core/huya/app_login.py
|
||||
+++ b/core/huya/app_login.py
|
||||
@@ -9,7 +9,8 @@
|
||||
6. POST /web/cookie/verify 兑换获取全套网页 Cookie
|
||||
注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚,
|
||||
-当前继续使用已注册样本;新注册链动态更新的是 safedeviceid 和 device_id。
|
||||
+登录帧中的 HDID32 是 app 版本级协议常量;二维码信封的 ACTION、设备字段、
|
||||
+UID、证书和会话元数据均在本次登录中动态生成或由注册链签发。
|
||||
注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``),
|
||||
不读取画像里的旧固定值,也不静默回退旧链。
|
||||
@@ -35,7 +36,7 @@ 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 .cert_forge import build_p1, forge_cert
|
||||
from .cookie_utils import normalize_huya_cookie
|
||||
from .device_fingerprint import account_state_dir, get_huya_sdid, reset_account_state
|
||||
from .device_profile import get_profile, mobile_user_agent
|
||||
@@ -270,7 +271,8 @@ def login_cred_with_flow(
|
||||
max_rounds: int = 3,
|
||||
device_info: dict | None = None,
|
||||
proxies: dict | None = None,
|
||||
-) -> tuple[bytes, int]:
|
||||
+ include_device_token: bool = False,
|
||||
+) -> tuple[bytes, int] | tuple[bytes, int, str]:
|
||||
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
|
||||
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
|
||||
@@ -305,6 +307,8 @@ def login_cred_with_flow(
|
||||
)
|
||||
if cred:
|
||||
uid = parse_real_uid(resp)
|
||||
+ if include_device_token:
|
||||
+ return cred, uid, safedeviceid
|
||||
return cred, uid
|
||||
if risk_url:
|
||||
kind = (
|
||||
@@ -462,11 +466,12 @@ class HuyaAppPasswordLogin:
|
||||
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
|
||||
try:
|
||||
- cred, uid = login_cred_with_flow(
|
||||
+ cred, uid, safedeviceid = login_cred_with_flow(
|
||||
acct,
|
||||
self.password,
|
||||
device_info=self.device_info,
|
||||
proxies=self.proxies,
|
||||
+ include_device_token=True,
|
||||
)
|
||||
except HuyaAppQrAuthRequiredError as exc:
|
||||
return HuyaLoginResult(
|
||||
@@ -485,30 +490,27 @@ class HuyaAppPasswordLogin:
|
||||
# 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致)
|
||||
try:
|
||||
- env = Envelope.load()
|
||||
- orig = base64.b64decode(env.cert_b64)
|
||||
- f = parse_p1(decrypt_cert(orig))
|
||||
st = int(time.time() * 1000)
|
||||
rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0)
|
||||
fp_bytes = self.device_info["fingerprint"].encode("ascii")
|
||||
- p1 = build_p1(f["app_id"], fp_bytes, cred, rnd=rnd)
|
||||
- cert = base64.b64encode(forge_cert(p1, key_idx=orig[1])).decode()
|
||||
-
|
||||
- # 3) 信封补丁
|
||||
- raw = bytearray(env.raw)
|
||||
- if env.cert_off is None or env.uid_off is None:
|
||||
- raise ValueError("信封缺少证书或 uid 偏移")
|
||||
- raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii")
|
||||
- if env.uid != uid:
|
||||
- struct.pack_into(">Q", raw, env.uid_off, uid)
|
||||
- # QR 信封只保留协议结构;不要重放抓包里的旧会话值。
|
||||
+ p1 = build_p1(b"5008", fp_bytes, cred, rnd=rnd)
|
||||
+ # 证书头和 app_id 是协议版本常量,不从抓包信封读取。
|
||||
+ cert = base64.b64encode(forge_cert(p1, key_idx=0x20)).decode()
|
||||
+
|
||||
+ # 3) 按当前账号画像和本次注册令牌重编码二维码信封。
|
||||
qr_session = random.randint(1_000_000, 9_999_999)
|
||||
qr_trace = (
|
||||
f"{uuid.uuid4().hex[:16]}-{random.randint(10000, 99999)}-"
|
||||
f"{time.time_ns():020d}"
|
||||
)
|
||||
- env.raw = raw
|
||||
- env.patch_session(qr_session).patch_meta(qr_session, qr_trace)
|
||||
+ env = Envelope.build_qr(
|
||||
+ uid=uid,
|
||||
+ cert_b64=cert,
|
||||
+ safedeviceid=safedeviceid,
|
||||
+ session=qr_session,
|
||||
+ trace_id=qr_trace,
|
||||
+ device_info=self.device_info,
|
||||
+ )
|
||||
wup = base64.b64encode(bytes(env.raw)).decode("ascii")
|
||||
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
return HuyaLoginResult(
|
||||
diff --git a/core/huya/envelope_forge.py b/core/huya/envelope_forge.py
|
||||
index c5d66ab..0d2e8b1 100644
|
||||
--- a/core/huya/envelope_forge.py
|
||||
+++ b/core/huya/envelope_forge.py
|
||||
@@ -1,6 +1,7 @@
|
||||
"""wupData 信封构造与补丁工具。
|
||||
-解析与改写 WUP 信封中的 cert、uid、session 等字段。
|
||||
+生产二维码信封由 :meth:`Envelope.build_qr` 按当前账号状态编码;
|
||||
+:meth:`Envelope.load` 仅保留协议结构模板兼容测试和离线分析。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -10,7 +11,8 @@ import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
-from loguru import logger
|
||||
+from .taf_protocol import TafOutputStream
|
||||
+from .wup_protocol import WupRequest
|
||||
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||
STRING1, STRING4 = 0x06, 0x07
|
||||
@@ -119,24 +121,106 @@ class Envelope:
|
||||
@classmethod
|
||||
def load(cls, path: str | Path | None = None) -> Envelope:
|
||||
- """加载信封模板,支持从文件加载或使用内嵌金样本。"""
|
||||
+ """加载显式模板,或使用内嵌协议结构模板。
|
||||
+
|
||||
+ 不自动扫描 ``evidence/``,避免历史抓包成为隐式生产输入。
|
||||
+ """
|
||||
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 as exc: # noqa: BLE001
|
||||
- logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}")
|
||||
return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
|
||||
+ @classmethod
|
||||
+ def build_qr(
|
||||
+ cls,
|
||||
+ *,
|
||||
+ uid: int,
|
||||
+ cert_b64: str,
|
||||
+ safedeviceid: str,
|
||||
+ session: int,
|
||||
+ trace_id: str,
|
||||
+ device_info: dict[str, str],
|
||||
+ ) -> Envelope:
|
||||
+ """按当前账号状态编码二维码绑定信封。
|
||||
+
|
||||
+ 生产二维码请求不应从抓包信封复制字段。这里仅复用已知的 TAF/WUP
|
||||
+ 字段布局;证书、ACTION、UID、会话和全部设备画像都由本次登录提供。
|
||||
+ """
|
||||
+ if len(cert_b64) != 260:
|
||||
+ raise ValueError(f"证书 base64 长度异常: {len(cert_b64)} != 260")
|
||||
+ if len(safedeviceid) != 180:
|
||||
+ raise ValueError(f"safedeviceid 长度异常: {len(safedeviceid)} != 180")
|
||||
+ dev = {k: str(v) for k, v in (device_info or {}).items()}
|
||||
+ meta = json.dumps(
|
||||
+ {
|
||||
+ "associationId": 184549392,
|
||||
+ "funcName": "",
|
||||
+ "group": 0,
|
||||
+ "id": 184549392,
|
||||
+ "session": int(session),
|
||||
+ "step": 0,
|
||||
+ "stillLogin": False,
|
||||
+ "traceId": str(trace_id),
|
||||
+ "type": 2,
|
||||
+ "uid": 0,
|
||||
+ "userContext": "",
|
||||
+ },
|
||||
+ ensure_ascii=False,
|
||||
+ separators=(",", ":"),
|
||||
+ )
|
||||
+ 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, meta)
|
||||
+ body.write_string(3, "5008")
|
||||
+ body.write_int8(4, 3)
|
||||
+ body.write_string(5, safedeviceid)
|
||||
+ body.write_string(6, "")
|
||||
+ body.write_string(7, "")
|
||||
+ body.write_string(8, "")
|
||||
+ body.write_string(9, "")
|
||||
+ body.write_struct_end()
|
||||
+
|
||||
+ body.write_struct_begin(1)
|
||||
+ body.write_string(0, dev.get("hdid", ""))
|
||||
+ body.write_string(1, dev.get("app_version", "13.4.22"))
|
||||
+ body.write_string(2, dev.get("sdk_version", "1.0.80138"))
|
||||
+ body.write_string(3, "")
|
||||
+ body.write_string(4, dev.get("ip", "127.0.0.1"))
|
||||
+ body.write_string(5, dev.get("vendor", "android"))
|
||||
+ body.write_string(6, "")
|
||||
+ body.write_struct_end()
|
||||
+
|
||||
+ body.write_struct_begin(2)
|
||||
+ body.write_int8(0, 1)
|
||||
+ body.write_string(1, dev.get("model", ""))
|
||||
+ body.write_string(2, dev.get("fingerprint", ""))
|
||||
+ body.write_string(3, dev.get("os", "android"))
|
||||
+ body.write_string(4, dev.get("screen", ""))
|
||||
+ body.write_string(6, dev.get("width", "1080"))
|
||||
+ body.write_string(7, dev.get("height", "2120"))
|
||||
+ body.write_string(8, dev.get("device_id", ""))
|
||||
+ body.write_struct_end()
|
||||
+ body.write_uint64(3, int(uid))
|
||||
+ body.write_string(4, cert_b64)
|
||||
+ body.write_string(5, "")
|
||||
+ body.write_string(6, "")
|
||||
+ body.write_struct_end()
|
||||
+
|
||||
+ req = TafOutputStream()
|
||||
+ req.write_int32(0, int(session))
|
||||
+ wup = WupRequest()
|
||||
+ wup.iTimeout = 0
|
||||
+ wup.setRequestId(int(session))
|
||||
+ wup.setServant("huyaudbwebui")
|
||||
+ wup.setFunc("default")
|
||||
+ wup.newdata["_wup_data"] = body.get_bytes()
|
||||
+ wup.newdata["wupudbrequest_v0"] = req.get_bytes()
|
||||
+ return cls(wup.encode())
|
||||
+
|
||||
@classmethod
|
||||
def _load_from_path(cls, p: Path) -> Envelope:
|
||||
if p.suffix == ".json":
|
||||
diff --git a/tests/test_huya_app_login.py b/tests/test_huya_app_login.py
|
||||
index 3512a14..0c897bb 100644
|
||||
--- a/tests/test_huya_app_login.py
|
||||
+++ b/tests/test_huya_app_login.py
|
||||
@@ -26,6 +26,7 @@ from core.huya.login import HuyaLoginResult
|
||||
from core.huya.nonce_forge import K1_DEFAULT, gen_nonce
|
||||
from core.huya.udb_aes import udb_decrypt, udb_encrypt
|
||||
from core.huya.wup_encoder import _make_name, build_password_login_wup
|
||||
+from core.huya.wup_protocol import WupResponse
|
||||
from web.backend.database import Base
|
||||
from web.backend.models import HuyaAccount, User
|
||||
from web.backend.routers.huya import (
|
||||
@@ -102,6 +103,38 @@ class TestHuyaAppLogin:
|
||||
assert b'"session":7654321' in current
|
||||
assert (b'"traceId":"' + new_trace.encode() + b'"') in current
|
||||
+ def test_qr_envelope_is_fully_dynamic(self):
|
||||
+ profile = {
|
||||
+ "hdid": "h" * 32,
|
||||
+ "app_version": "13.4.22",
|
||||
+ "sdk_version": "1.0.80138",
|
||||
+ "ip": "10.0.0.8",
|
||||
+ "vendor": "vivo",
|
||||
+ "model": "V2370A",
|
||||
+ "fingerprint": "f" * 40,
|
||||
+ "os": "android",
|
||||
+ "screen": "V2370A,34,13",
|
||||
+ "width": "1080",
|
||||
+ "height": "2412",
|
||||
+ "device_id": "d" * 40,
|
||||
+ }
|
||||
+ env = Envelope.build_qr(
|
||||
+ uid=1199664135026,
|
||||
+ cert_b64="A" * 260,
|
||||
+ safedeviceid="B" * 180,
|
||||
+ session=7654321,
|
||||
+ trace_id="t" * 16 + "-12345-" + "9" * 20,
|
||||
+ device_info=profile,
|
||||
+ )
|
||||
+ wup = WupResponse()
|
||||
+ wup.decode(bytes(env.raw))
|
||||
+ body = wup.newdata["_wup_data"]
|
||||
+ for value in (b"B" * 180, b"h" * 32, b"f" * 40, b"V2370A", b"d" * 40):
|
||||
+ assert value in body
|
||||
+ assert b"PQwemAN9NHkZKoMq" not in body
|
||||
+ assert b"02df398797432eadefcc12767119ad5e80999389" not in body
|
||||
+ assert b"7c5387e0539c023c31c4ff0e807e7256117385ee" not in body
|
||||
+
|
||||
def test_device_profile_generation(self):
|
||||
p1 = generate_profile()
|
||||
assert p1["os"] == "android"
|
||||
@@ -271,10 +304,13 @@ class TestHuyaAppLogin:
|
||||
patch("core.huya.app_login.solve_safe_auth", return_value={"authId": "id"}),
|
||||
patch("core.huya.app_login.parse_real_uid", return_value=1199666914671),
|
||||
):
|
||||
- cred, uid = login_cred_with_flow("300023887", "pw")
|
||||
+ cred, uid, safedeviceid = login_cred_with_flow(
|
||||
+ "300023887", "pw", include_device_token=True
|
||||
+ )
|
||||
assert cred == b"c" * 114
|
||||
assert uid == 1199666914671
|
||||
+ assert safedeviceid == "A" * 180
|
||||
assert calls == [assets, assets]
|
||||
def test_login_cred_flow_maps_invalid_password_response(self):
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
"""wupData 信封构造与补丁工具。
|
||||
|
||||
生产二维码信封由 :meth:`Envelope.build_qr` 按当前账号状态编码;
|
||||
:meth:`Envelope.load` 仅保留协议结构模板兼容测试和离线分析。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from .taf_protocol import TafOutputStream
|
||||
from .wup_protocol import WupRequest
|
||||
|
||||
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
|
||||
|
||||
PROTOCOL_QURL_TEMPLATE_B64 = (
|
||||
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
|
||||
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
|
||||
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
|
||||
"ZGMtMjY2OTItODI2MzkzOTQ3ODc2NjM1NTEzNjUiLCJ0eXBlIjoyLCJ1aWQiOjAsInVzZXJDb250ZXh0IjoiIn02BDUw"
|
||||
"MDhAA1a0UFF3ZW1BTjlOSGtaS29NcVdNUW5WUkl5cHFNVGFRRU9ybVhyMzd4UVZoUVpxclA1aVVLRVExMXh2RTB2cE1n"
|
||||
"a2xlajIzbEpGYmFHVW5LUEFhYnhWaUF0TnZyTkxBbXhBQzJyRGp6Qy9JSU0vSWFQeTdTcytvQXZqSmltR2pBS2RnVmpr"
|
||||
"bUhWV2Q2bEw4cUZScU4zWkJMamt4c2xUQjczaXNvallWcjlrSFhWdUh3SkxoM3YzZgB2AIYAlgALGgYgZWQwZGI4MzM0"
|
||||
"Y2FkZDIzNmMwMGNhZGY3ZTExYWI1YTUWBzEzLjQuMjImCTEuMC44MDEzODYARgkxMjcuMC4wLjFWBnhpYW9taWYACyoA"
|
||||
"ARYJTTIxMDJKMlNDJigwMmRmMzk4Nzk3NDMyZWFkZWZjYzEyNzY3MTE5YWQ1ZTgwOTk5Mzg5NgdhbmRyb2lkRg9NMjEw"
|
||||
"MkoyU0MsMzAsMTFmBDEwODB2BDIxMjCGKDdjNTM4N2UwNTM5YzAyM2MzMWM0ZmYwZTgwN2U3MjU2MTE3Mzg1ZWULMwAA"
|
||||
"ARdRuGVvRwAAAQREQ0JHY0QyN0liSWEvZnZ0UHhNT2xZdGJUY0M3bWRaUlJ3YzIyc2NnQkFyRTJ5eTZwSUdIQjNMK0tr"
|
||||
"MzVxVS9iaWc1Qk1TVVVSd3gzeS9wWVpWajRjd20rWEg1dnNrakR2SzNhUlhudEJGcURDQUUvaUVIbGs4ZXJ3VUJZdmJM"
|
||||
"aXlIb0YrSytQam5GTGJRMmlzSHVhcUtqTHAvWmRETDlxSit3VEVSb3h0ZjFuUzlTZ0l2N3lCaVIyMjd4N3F3RjllUTVu"
|
||||
"ckNaRitnRnczelVZb2N6Uk9jbHE1aXZDclhRZTVSZ3hOYkp5aWQ3ZkZqTVhYYlNQdHBIZ2p0TVJtdWp6RVRvPVYAZgAL"
|
||||
"BhB3dXB1ZGJyZXF1ZXN0X3YwHQAABQIAWkEFjJgMqAw="
|
||||
)
|
||||
|
||||
|
||||
def _read_len_int(d: bytes | bytearray, 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 | bytearray, 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:
|
||||
"""加载显式模板,或使用内嵌协议结构模板。
|
||||
|
||||
不自动扫描 ``evidence/``,避免历史抓包成为隐式生产输入。
|
||||
"""
|
||||
if path:
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
return cls._load_from_path(p)
|
||||
return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
|
||||
|
||||
@classmethod
|
||||
def build_qr(
|
||||
cls,
|
||||
*,
|
||||
uid: int,
|
||||
cert_b64: str,
|
||||
safedeviceid: str,
|
||||
session: int,
|
||||
trace_id: str,
|
||||
device_info: dict[str, str],
|
||||
) -> Envelope:
|
||||
"""按当前账号状态编码二维码绑定信封。
|
||||
|
||||
生产二维码请求不应从抓包信封复制字段。这里仅复用已知的 TAF/WUP
|
||||
字段布局;证书、ACTION、UID、会话和全部设备画像都由本次登录提供。
|
||||
"""
|
||||
if len(cert_b64) != 260:
|
||||
raise ValueError(f"证书 base64 长度异常: {len(cert_b64)} != 260")
|
||||
if len(safedeviceid) != 180:
|
||||
raise ValueError(f"safedeviceid 长度异常: {len(safedeviceid)} != 180")
|
||||
dev = {k: str(v) for k, v in (device_info or {}).items()}
|
||||
meta = json.dumps(
|
||||
{
|
||||
"associationId": 184549392,
|
||||
"funcName": "",
|
||||
"group": 0,
|
||||
"id": 184549392,
|
||||
"session": int(session),
|
||||
"step": 0,
|
||||
"stillLogin": False,
|
||||
"traceId": str(trace_id),
|
||||
"type": 2,
|
||||
"uid": 0,
|
||||
"userContext": "",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
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, meta)
|
||||
body.write_string(3, "5008")
|
||||
body.write_int8(4, 3)
|
||||
body.write_string(5, safedeviceid)
|
||||
body.write_string(6, "")
|
||||
body.write_string(7, "")
|
||||
body.write_string(8, "")
|
||||
body.write_string(9, "")
|
||||
body.write_struct_end()
|
||||
|
||||
body.write_struct_begin(1)
|
||||
body.write_string(0, dev.get("hdid", ""))
|
||||
body.write_string(1, dev.get("app_version", "13.4.22"))
|
||||
body.write_string(2, dev.get("sdk_version", "1.0.80138"))
|
||||
body.write_string(3, "")
|
||||
body.write_string(4, dev.get("ip", "127.0.0.1"))
|
||||
body.write_string(5, dev.get("vendor", "android"))
|
||||
body.write_string(6, "")
|
||||
body.write_struct_end()
|
||||
|
||||
body.write_struct_begin(2)
|
||||
body.write_int8(0, 1)
|
||||
body.write_string(1, dev.get("model", ""))
|
||||
body.write_string(2, dev.get("fingerprint", ""))
|
||||
body.write_string(3, dev.get("os", "android"))
|
||||
body.write_string(4, dev.get("screen", ""))
|
||||
body.write_string(6, dev.get("width", "1080"))
|
||||
body.write_string(7, dev.get("height", "2120"))
|
||||
body.write_string(8, dev.get("device_id", ""))
|
||||
body.write_struct_end()
|
||||
body.write_uint64(3, int(uid))
|
||||
body.write_string(4, cert_b64)
|
||||
body.write_string(5, "")
|
||||
body.write_string(6, "")
|
||||
body.write_struct_end()
|
||||
|
||||
req = TafOutputStream()
|
||||
req.write_int32(0, int(session))
|
||||
wup = WupRequest()
|
||||
wup.iTimeout = 0
|
||||
wup.setRequestId(int(session))
|
||||
wup.setServant("huyaudbwebui")
|
||||
wup.setFunc("default")
|
||||
wup.newdata["_wup_data"] = body.get_bytes()
|
||||
wup.newdata["wupudbrequest_v0"] = req.get_bytes()
|
||||
return cls(wup.encode())
|
||||
|
||||
@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):
|
||||
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:
|
||||
assert self.uid_off is not None
|
||||
return struct.unpack_from(">Q", self.raw, self.uid_off)[0]
|
||||
|
||||
def patch_uid(self, uid: int) -> Envelope:
|
||||
assert self.uid_off is not None
|
||||
struct.pack_into(">Q", self.raw, self.uid_off, uid)
|
||||
return self
|
||||
|
||||
@property
|
||||
def cert_b64(self) -> bytes:
|
||||
assert self.cert_off is not None
|
||||
return bytes(self.raw[self.cert_off : self.cert_off + self.cert_len])
|
||||
|
||||
def patch_cert(self, cert: bytes) -> Envelope:
|
||||
assert self.cert_off is not None
|
||||
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:
|
||||
"""Patch the outer WUP session and its request copy."""
|
||||
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 patch_meta(self, session: int, trace_id: str) -> Envelope:
|
||||
"""Replace QR metadata values without carrying the capture's old state.
|
||||
|
||||
The captured envelope keeps a fixed-size JSON string. Keeping the
|
||||
replacement the same size lets us update only the value bytes and
|
||||
preserve all TAF length prefixes and offsets.
|
||||
"""
|
||||
if self.meta_json_span is None:
|
||||
raise ValueError("信封缺少元数据 JSON")
|
||||
start, end = self.meta_json_span
|
||||
current = bytes(self.raw[start:end])
|
||||
try:
|
||||
meta = json.loads(current.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("信封元数据 JSON 无法解析") from exc
|
||||
meta["session"] = int(session)
|
||||
meta["traceId"] = str(trace_id)
|
||||
updated = json.dumps(meta, ensure_ascii=False, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
if len(updated) != len(current):
|
||||
raise ValueError(
|
||||
f"信封元数据长度变化: {len(updated)} != {len(current)}; "
|
||||
"请使用固定长度 session/traceId"
|
||||
)
|
||||
self.raw[start:end] = updated
|
||||
return self
|
||||
|
||||
def wup_b64(self) -> str:
|
||||
return base64.b64encode(bytes(self.raw)).decode()
|
||||
@@ -13,7 +13,7 @@
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
douyu_login_py/
|
||||
live-hub-py/
|
||||
├── core/ # 核心业务逻辑
|
||||
│ ├── models.py # Account, ProxyConfig 数据类
|
||||
│ ├── douyu/ # 斗鱼登录模块
|
||||
@@ -53,7 +53,7 @@ douyu_login_py/
|
||||
应用宝充值 Worker 已合并到 Web 应用容器中,内部仅监听 `127.0.0.1:8810`,不发布额外端口。任务数据与应用数据一起保存在 `data/yyb-worker-jobs/`。
|
||||
|
||||
```bash
|
||||
cd /opt/douyu_login_py
|
||||
cd /opt/live-hub-py
|
||||
python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
```
|
||||
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE="${1:?source path required}"
|
||||
TARGET="${2:?target path required}"
|
||||
cp "$SOURCE" "$TARGET"
|
||||
printf 'restored %s from %s\n' "$TARGET" "$SOURCE"
|
||||
@@ -0,0 +1,35 @@
|
||||
changed branch/field: Huya App login QR envelope and device-token propagation
|
||||
|
||||
MODIFIED_FILE: /Users/yml/codes/live-hub-py/MODIFIED_FILE
|
||||
DIFF_FILE: /Users/yml/codes/live-hub-py/DIFF_FILE
|
||||
VERIFICATION.txt: /Users/yml/codes/live-hub-py/VERIFICATION.txt
|
||||
ROLLBACK.sh: /Users/yml/codes/live-hub-py/ROLLBACK.sh
|
||||
MODIFIED_FILE original SHA-256 before fixture update: 08bc9d7ca6425fab15af2c6218d2574f7f0180e929fa6ccb78c8b37e6ae8493a
|
||||
MODIFIED_FILE changed SHA-256: e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1
|
||||
|
||||
BASELINE exact command:
|
||||
set -e; BASE=/tmp/live-hub-py-baseline.PJsjpJ; git archive HEAD | tar -x -C "$BASE"; .venv/bin/pytest -q "$BASE/tests/test_huya_app_login.py" "$BASE/tests/test_huya_dfp_register.py"
|
||||
BASELINE literal output/result:
|
||||
27 passed, 1 warning in 0.62s
|
||||
BASELINE exit status: 0
|
||||
|
||||
MODIFIED exact command:
|
||||
.venv/bin/pytest -q
|
||||
MODIFIED literal output/result:
|
||||
113 passed, 1 warning in 1.13s
|
||||
MODIFIED exit status: 0
|
||||
|
||||
ROLLBACK exact command:
|
||||
set -e; SRC=/tmp/rollback-source.mKNKoP; TGT=/tmp/rollback-target.hHBqnQ; cp MODIFIED_FILE "$SRC"; printf '\ncorrupt\n' >> "$TGT"; ./ROLLBACK.sh "$SRC" "$TGT"; cmp -s "$SRC" "$TGT"
|
||||
ROLLBACK literal output/result:
|
||||
restored /tmp/rollback-target.hHBqnQ from /tmp/rollback-source.mKNKoP
|
||||
rollback_status=restored
|
||||
source_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1
|
||||
target_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1
|
||||
ROLLBACK exit status: 0
|
||||
restored behavior/status: target byte-for-byte equals source; MODIFIED_FILE remains changed.
|
||||
|
||||
Additional checks:
|
||||
.venv/bin/python -m ruff check core/huya tests/test_huya_app_login.py -> All checks passed, exit 0
|
||||
git diff --check -> exit 0
|
||||
bash -n dev.sh deploy.sh -> exit 0
|
||||
@@ -39,6 +39,7 @@ from .app_login import HuyaAppPasswordLogin
|
||||
from .device_profile import (
|
||||
_load_db,
|
||||
_save_db,
|
||||
canonical_account_key,
|
||||
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
|
||||
)
|
||||
|
||||
@@ -68,9 +69,10 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
|
||||
(R36 解密真实结构), 每账号独立生成终身复用 → 一号一设备的一致性来源;
|
||||
当前注册链服务端不校验 cw 内容, 此字段为后续真实载荷构建预留
|
||||
"""
|
||||
env = get_profile(account, force_new=force_new)
|
||||
key = canonical_account_key(account)
|
||||
env = get_profile(key, force_new=force_new)
|
||||
db = _load_db()
|
||||
record = dict(db.get(account) or env)
|
||||
record = dict(db.get(key) or env)
|
||||
changed = False
|
||||
# guid32 / hebe: 一号一致字段, 首次生成后终身不变
|
||||
if "guid32" not in record:
|
||||
@@ -82,7 +84,7 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
|
||||
}
|
||||
changed = True
|
||||
if changed:
|
||||
db[account] = record
|
||||
db[key] = record
|
||||
_save_db(db)
|
||||
return record
|
||||
|
||||
@@ -102,7 +104,8 @@ def bind_and_login(
|
||||
就是本环境的 40hex → 签发的 t2/t5 与环境绑定); safe_auth 滑块过验后的重发
|
||||
沿用同一组设备字段 (app_login.login_cred_with_flow)。
|
||||
"""
|
||||
env = get_or_create_env(account, force_new=force_new_device)
|
||||
key = canonical_account_key(account)
|
||||
env = get_or_create_env(key, force_new=force_new_device)
|
||||
print(
|
||||
f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} "
|
||||
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
|
||||
@@ -124,14 +127,14 @@ def bind_and_login(
|
||||
|
||||
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
|
||||
db = _load_db()
|
||||
record = dict(db.get(account) or env)
|
||||
record = dict(db.get(key) or env)
|
||||
record.setdefault("bound_at", int(time.time())) # 首次绑定时间
|
||||
record["last_login"] = {
|
||||
"ok": result.success,
|
||||
"msg": result.message[:120],
|
||||
"at": int(time.time()),
|
||||
}
|
||||
db[account] = record
|
||||
db[key] = record
|
||||
_save_db(db)
|
||||
|
||||
return {
|
||||
|
||||
+93
-44
@@ -9,7 +9,8 @@
|
||||
6. POST /web/cookie/verify 兑换获取全套网页 Cookie
|
||||
|
||||
注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚,
|
||||
当前继续使用已注册样本;新注册链动态更新的是 safedeviceid 和 device_id。
|
||||
登录帧中的 HDID32 是 app 版本级协议常量;二维码信封的 ACTION、设备字段、
|
||||
UID、证书和会话元数据均在本次登录中动态生成或由注册链签发。
|
||||
注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``),
|
||||
不读取画像里的旧固定值,也不静默回退旧链。
|
||||
|
||||
@@ -35,9 +36,9 @@ 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 .cert_forge import build_p1, forge_cert
|
||||
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, reset_account_state
|
||||
from .device_profile import get_profile, mobile_user_agent
|
||||
from .dfp_register import DfpRegistrationError, register_device
|
||||
from .envelope_forge import Envelope
|
||||
@@ -61,26 +62,13 @@ APP_UA_MOBILE = (
|
||||
"Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30"
|
||||
)
|
||||
|
||||
SessionAssets = tuple[dict, str, str]
|
||||
|
||||
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
|
||||
_URL_TAIL_KEEP = set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
|
||||
)
|
||||
|
||||
DEFAULT_GOLDEN_DEV = {
|
||||
"app_version": "13.4.22",
|
||||
"sdk_version": "1.0.80138",
|
||||
"vendor": "xiaomi",
|
||||
"model": "M2102J2SC",
|
||||
"os": "android",
|
||||
"ip": "127.0.0.1",
|
||||
"fingerprint": "02df398797432eadefcc12767119ad5e80999389",
|
||||
"screen": "M2102J2SC,30,11",
|
||||
"width": "1080",
|
||||
"height": "2120",
|
||||
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
|
||||
"hdid": "ed0db8334cadd236c00cadf7e11ab5a5", # HDID32 登录t1.t0 (勿与GUID32混)
|
||||
}
|
||||
|
||||
|
||||
class HuyaAppLoginError(HuyaLoginError):
|
||||
"""虎牙 App 登录失败。"""
|
||||
@@ -123,6 +111,7 @@ def wup_password_login_raw(
|
||||
safedeviceid: str | None = None,
|
||||
hdid: str | None = None,
|
||||
proxies: dict | None = None,
|
||||
session_assets: SessionAssets | None = None,
|
||||
) -> bytes:
|
||||
"""发送 WUP 密码登录,返回原始响应字节。
|
||||
|
||||
@@ -132,7 +121,7 @@ def wup_password_login_raw(
|
||||
"""
|
||||
uid_str = account.removeprefix("hy_")
|
||||
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 = session_assets or _golden_session_assets()
|
||||
if not safedeviceid:
|
||||
try:
|
||||
_t1, safedeviceid, registered_device_id = register_device(
|
||||
@@ -144,11 +133,14 @@ def wup_password_login_raw(
|
||||
except DfpRegistrationError as exc:
|
||||
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
||||
dev["device_id"] = registered_device_id
|
||||
hdid_value = hdid or dev.get("hdid")
|
||||
if not hdid_value:
|
||||
raise HuyaAppLoginError("账号设备画像缺少 HDID32,拒绝使用固定金样本")
|
||||
pkt = build_password_login_wup(
|
||||
uid_str,
|
||||
hashlib.sha1(password.encode()).hexdigest(),
|
||||
safedeviceid,
|
||||
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5",
|
||||
str(hdid_value),
|
||||
mj["session"],
|
||||
mj["traceId"],
|
||||
ua,
|
||||
@@ -202,6 +194,16 @@ def parse_risk_url(resp: bytes) -> str | None:
|
||||
return (pt or urls)[0]
|
||||
|
||||
|
||||
def _response_markers(resp: bytes) -> str:
|
||||
"""提取 WUP 错误响应中的短 ASCII 字段,便于区分签名和凭据失败。"""
|
||||
markers = []
|
||||
for raw in re.findall(rb"[ -~]{4,}", resp):
|
||||
value = raw.decode("ascii", "ignore")
|
||||
if value not in markers and len(value) <= 160:
|
||||
markers.append(value)
|
||||
return ", ".join(markers[:8])
|
||||
|
||||
|
||||
def solve_safe_auth(
|
||||
risk_url: str,
|
||||
proxies=None,
|
||||
@@ -214,6 +216,8 @@ def solve_safe_auth(
|
||||
HuyaVerificationSolver,
|
||||
)
|
||||
|
||||
if device_info is None:
|
||||
raise HuyaAppLoginError("safe_auth 缺少当前账号设备画像")
|
||||
q = {
|
||||
k: v[0]
|
||||
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
|
||||
@@ -222,7 +226,7 @@ def solve_safe_auth(
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(max_retry):
|
||||
solver = HuyaVerificationSolver(
|
||||
ua=mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
|
||||
ua=mobile_user_agent(device_info),
|
||||
proxies=proxies,
|
||||
app_id=app_id,
|
||||
page_url=risk_url,
|
||||
@@ -267,7 +271,8 @@ def login_cred_with_flow(
|
||||
max_rounds: int = 3,
|
||||
device_info: dict | None = None,
|
||||
proxies: dict | None = None,
|
||||
) -> tuple[bytes, int]:
|
||||
include_device_token: bool = False,
|
||||
) -> tuple[bytes, int] | tuple[bytes, int, str]:
|
||||
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
|
||||
|
||||
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
|
||||
@@ -282,19 +287,29 @@ def login_cred_with_flow(
|
||||
except DfpRegistrationError as exc:
|
||||
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
||||
dev["device_id"] = registered_device_id
|
||||
# 风控验证绑定首次 WUP 请求的 session/traceId;所有重发必须复用它们。
|
||||
session_assets = _golden_session_assets()
|
||||
for rnd in range(max_rounds):
|
||||
logger.debug(f"[huya-app] WUP 登录请求第 {rnd + 1} 轮...")
|
||||
resp = wup_password_login_raw(
|
||||
account,
|
||||
password,
|
||||
device_info=dev,
|
||||
safedeviceid=safedeviceid,
|
||||
proxies=proxies,
|
||||
session_assets=session_assets,
|
||||
)
|
||||
cred = parse_cred(resp)
|
||||
risk_url = parse_risk_url(resp) if not cred else None
|
||||
logger.debug(
|
||||
f"[huya-app] WUP 第 {rnd + 1} 轮响应: {len(resp)}B, "
|
||||
f"cred={bool(cred)}, risk={bool(risk_url)}"
|
||||
)
|
||||
if cred:
|
||||
uid = parse_real_uid(resp)
|
||||
if include_device_token:
|
||||
return cred, uid, safedeviceid
|
||||
return cred, uid
|
||||
risk_url = parse_risk_url(resp)
|
||||
if risk_url:
|
||||
kind = (
|
||||
"pt_auth(滑块)"
|
||||
@@ -309,7 +324,18 @@ def login_cred_with_flow(
|
||||
solve_safe_auth(risk_url, proxies=proxies, device_info=dev)
|
||||
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
|
||||
continue
|
||||
raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)")
|
||||
markers = _response_markers(resp)
|
||||
detail = f",服务端字段: {markers}" if markers else ""
|
||||
logger.warning(f"[huya-app] WUP 响应未包含 cred/风控: {len(resp)}B{detail}")
|
||||
if "LGN_INFO_INVALID_USER_OR_PASSWORD" in markers:
|
||||
raise HuyaAppLoginError(
|
||||
"虎牙账号或密码错误(服务端: LGN_INFO_INVALID_USER_OR_PASSWORD)"
|
||||
)
|
||||
if "APP_SIGN_NOT_MATCH" in markers:
|
||||
raise HuyaAppLoginError("虎牙设备签名不匹配(服务端: APP_SIGN_NOT_MATCH)")
|
||||
raise HuyaAppLoginError(
|
||||
f"登录未返回凭据也无风控URL(响应 {len(resp)}B{detail})"
|
||||
)
|
||||
raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据")
|
||||
|
||||
|
||||
@@ -325,6 +351,8 @@ class QrRole:
|
||||
):
|
||||
self.pc = pc
|
||||
self.sdid = sdid
|
||||
if not pc and device_info is None:
|
||||
raise HuyaAppLoginError("移动端二维码角色缺少当前账号设备画像")
|
||||
self.s = requests.Session()
|
||||
self.s.trust_env = False
|
||||
if proxies:
|
||||
@@ -338,9 +366,7 @@ class QrRole:
|
||||
self.req_counter = random.randint(40_000_000, 41_000_000)
|
||||
self.s.headers.update(
|
||||
{
|
||||
"User-Agent": UA_PC
|
||||
if pc
|
||||
else mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
|
||||
"User-Agent": UA_PC if pc else mobile_user_agent(device_info),
|
||||
"Origin": UDB_BASE,
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
@@ -414,6 +440,8 @@ class HuyaAppPasswordLogin:
|
||||
self.proxies = dict(proxies) if proxies else None
|
||||
self.timeout = timeout or (10.0, 25.0)
|
||||
self.force_new_device = force_new_device
|
||||
if force_new_device:
|
||||
reset_account_state(self.username)
|
||||
self.device_info = device_info or get_profile(
|
||||
self.username, force_new=force_new_device
|
||||
)
|
||||
@@ -438,11 +466,12 @@ class HuyaAppPasswordLogin:
|
||||
|
||||
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
|
||||
try:
|
||||
cred, uid = login_cred_with_flow(
|
||||
cred, uid, safedeviceid = login_cred_with_flow(
|
||||
acct,
|
||||
self.password,
|
||||
device_info=self.device_info,
|
||||
proxies=self.proxies,
|
||||
include_device_token=True,
|
||||
)
|
||||
except HuyaAppQrAuthRequiredError as exc:
|
||||
return HuyaLoginResult(
|
||||
@@ -461,23 +490,28 @@ class HuyaAppPasswordLogin:
|
||||
|
||||
# 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()
|
||||
p1 = build_p1(b"5008", fp_bytes, cred, rnd=rnd)
|
||||
# 证书头和 app_id 是协议版本常量,不从抓包信封读取。
|
||||
cert = base64.b64encode(forge_cert(p1, key_idx=0x20)).decode()
|
||||
|
||||
# 3) 信封补丁
|
||||
raw = bytearray(env.raw)
|
||||
if env.cert_off is None or env.uid_off is None:
|
||||
raise ValueError("信封缺少证书或 uid 偏移")
|
||||
raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii")
|
||||
if env.uid != uid:
|
||||
struct.pack_into(">Q", raw, env.uid_off, uid)
|
||||
wup = base64.b64encode(bytes(raw)).decode("ascii")
|
||||
# 3) 按当前账号画像和本次注册令牌重编码二维码信封。
|
||||
qr_session = random.randint(1_000_000, 9_999_999)
|
||||
qr_trace = (
|
||||
f"{uuid.uuid4().hex[:16]}-{random.randint(10000, 99999)}-"
|
||||
f"{time.time_ns():020d}"
|
||||
)
|
||||
env = Envelope.build_qr(
|
||||
uid=uid,
|
||||
cert_b64=cert,
|
||||
safedeviceid=safedeviceid,
|
||||
session=qr_session,
|
||||
trace_id=qr_trace,
|
||||
device_info=self.device_info,
|
||||
)
|
||||
wup = base64.b64encode(bytes(env.raw)).decode("ascii")
|
||||
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
@@ -490,11 +524,19 @@ class HuyaAppPasswordLogin:
|
||||
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
|
||||
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
|
||||
sdid_obj = get_huya_sdid(
|
||||
allow_fallback=True,
|
||||
# App 主链只接受当前账号的 hydevice 高信任结果;旧版
|
||||
# token+collect 降级没有账号画像,不再静默放行。
|
||||
allow_fallback=False,
|
||||
state_dir=account_state_dir(self.username),
|
||||
device_hint=self.device_info,
|
||||
)
|
||||
sdid = sdid_obj.sdid if sdid_obj else ""
|
||||
if not sdid_obj or not sdid_obj.sdid or sdid_obj.source != "fingerprint":
|
||||
detail = sdid_obj.message if sdid_obj else "未返回结果"
|
||||
raise HuyaAppLoginError(
|
||||
f"账号设备指纹获取失败(必须为 hydevice): {detail}"
|
||||
)
|
||||
sdid = sdid_obj.sdid
|
||||
logger.info("[huya-app] cred 已获取,开始二维码绑定流程")
|
||||
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
||||
ph = QrRole(
|
||||
pc=False,
|
||||
@@ -511,6 +553,7 @@ class HuyaAppPasswordLogin:
|
||||
{"behavior": beh, "type": "", "domainList": "", "page": page},
|
||||
)
|
||||
qrid = (resp.get("data") or {}).get("qrId")
|
||||
logger.debug(f"[huya-app] getQrId 响应: qrid={bool(qrid)}")
|
||||
if not qrid:
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
@@ -540,6 +583,9 @@ class HuyaAppPasswordLogin:
|
||||
"page": quote(cp, safe=""),
|
||||
},
|
||||
)
|
||||
logger.debug(
|
||||
f"[huya-app] bindQrLoginUser 响应: returnCode={r2.get('returnCode')}"
|
||||
)
|
||||
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')}"
|
||||
@@ -547,7 +593,7 @@ class HuyaAppPasswordLogin:
|
||||
|
||||
# 4.3 轮询 tryQrLogin
|
||||
biztoken = None
|
||||
for _ in range(12):
|
||||
for index in range(12):
|
||||
rt = pc.call(
|
||||
"/qrLgn/tryQrLogin",
|
||||
"70003",
|
||||
@@ -560,6 +606,9 @@ class HuyaAppPasswordLogin:
|
||||
},
|
||||
)
|
||||
dt = rt.get("data") or {}
|
||||
logger.debug(
|
||||
f"[huya-app] tryQrLogin 第 {index + 1}/12 轮: stage={dt.get('stage')}"
|
||||
)
|
||||
if dt.get("stage") == 2:
|
||||
biztoken = dt.get("biztoken")
|
||||
break
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from .device_profile import mobile_user_agent
|
||||
from .device_profile import canonical_account_key, mobile_user_agent
|
||||
|
||||
FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
|
||||
RUNNER_JS = FINGERPRINT_DIR / "runner.js"
|
||||
@@ -31,10 +31,36 @@ FP_STATE_ROOT = Path(__file__).resolve().parents[2] / "data" / "huya_fp_states"
|
||||
|
||||
def account_state_dir(account: str) -> Path:
|
||||
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
|
||||
raw = str(account or "anon").strip()
|
||||
account = canonical_account_key(raw)
|
||||
safe = "".join(
|
||||
c if c.isalnum() or c in "-_." else "_" for c in (account or "anon")
|
||||
)[:64]
|
||||
return FP_STATE_ROOT / safe
|
||||
target = FP_STATE_ROOT / safe
|
||||
# Move a pre-rename ``hy_<numeric>`` directory on first access when the
|
||||
# canonical directory does not exist. If both exist, keep the canonical
|
||||
# state and leave the legacy directory untouched for manual cleanup.
|
||||
if raw != account:
|
||||
legacy_safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in raw)[:64]
|
||||
legacy = FP_STATE_ROOT / legacy_safe
|
||||
if not target.exists() and legacy.exists():
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy.rename(target)
|
||||
except OSError as exc:
|
||||
logger.debug("迁移旧虎牙设备状态失败: {}", exc)
|
||||
return target
|
||||
|
||||
|
||||
def reset_account_state(account: str) -> None:
|
||||
"""清理账号的 hydevice 持久化状态,供真正的全新设备登录使用。"""
|
||||
state_dir = account_state_dir(account)
|
||||
for name in ("localstorage.json", "device.json"):
|
||||
path = state_dir / name
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT = (8, 40)
|
||||
|
||||
@@ -19,6 +19,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
@@ -28,6 +29,7 @@ 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"
|
||||
ALLOW_LEGACY_PROFILE_IMPORT = os.getenv("HUYA_ALLOW_LEGACY_PROFILE_IMPORT") == "1"
|
||||
|
||||
REAL_MODELS = [
|
||||
("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)),
|
||||
@@ -51,6 +53,19 @@ APP_VERSION = "13.4.22"
|
||||
SDK_VERSION = "1.0.80138"
|
||||
|
||||
|
||||
def canonical_account_key(account: str) -> str:
|
||||
"""Return one stable environment key for equivalent Huya account forms.
|
||||
|
||||
The WUP protocol keeps the ``hy_`` prefix for Huya IDs, but the device
|
||||
environment must not split ``300023887`` and ``hy_300023887`` into two
|
||||
records. Phone numbers and other usernames remain unchanged.
|
||||
"""
|
||||
value = str(account or "").strip()
|
||||
if re.fullmatch(r"hy_\d+", value):
|
||||
return value[3:]
|
||||
return value
|
||||
|
||||
|
||||
def mobile_user_agent(device_info: Mapping[str, object]) -> str:
|
||||
"""根据统一设备画像生成 App WebView UA。"""
|
||||
screen = str(device_info.get("screen") or "")
|
||||
@@ -107,8 +122,14 @@ def record_login(account: str, ok: bool, message: str = "") -> None:
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
key = canonical_account_key(account)
|
||||
db = _load_db()
|
||||
rec = db.get(account)
|
||||
rec = db.get(key)
|
||||
if rec is None and key != account:
|
||||
rec = db.get(account)
|
||||
if rec is not None:
|
||||
db[key] = rec
|
||||
del db[account]
|
||||
if rec is None:
|
||||
return # 尚无环境的账号 (纯 Cookie 导入) 不生成记录
|
||||
now = int(_time.time())
|
||||
@@ -123,11 +144,11 @@ def _load_db() -> dict:
|
||||
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(f"读取主设备画像库失败: {exc}")
|
||||
if FALLBACK_PROFILE_DB.exists():
|
||||
if ALLOW_LEGACY_PROFILE_IMPORT and FALLBACK_PROFILE_DB.exists():
|
||||
try:
|
||||
return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(f"读取备用设备画像库失败: {exc}")
|
||||
logger.debug(f"读取显式迁移画像库失败: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
@@ -166,14 +187,20 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]:
|
||||
|
||||
def get_profile(account: str, force_new: bool = False) -> dict:
|
||||
"""按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。"""
|
||||
key = canonical_account_key(account)
|
||||
db = _load_db()
|
||||
if not force_new and account in db:
|
||||
enriched, changed = _enrich_profile(db[account])
|
||||
# Migrate the old prefixed key lazily without discarding its environment.
|
||||
legacy_key = str(account or "").strip()
|
||||
if key not in db and legacy_key != key and legacy_key in db:
|
||||
db[key] = db.pop(legacy_key)
|
||||
_save_db(db)
|
||||
if not force_new and key in db:
|
||||
enriched, changed = _enrich_profile(db[key])
|
||||
if changed:
|
||||
db[account] = enriched
|
||||
db[key] = enriched
|
||||
_save_db(db)
|
||||
return enriched
|
||||
p, _ = _enrich_profile(generate_profile())
|
||||
db[account] = p
|
||||
db[key] = p
|
||||
_save_db(db)
|
||||
return p
|
||||
|
||||
+19
-11
@@ -90,7 +90,7 @@ def _build_select_operator_request(
|
||||
# 仅供无画像的协议级独立调用兜底;生产登录始终传入账号画像。
|
||||
"app_version": "13.4.22",
|
||||
"model": "M2102J2SC",
|
||||
"fingerprint": fingerprint or "02df398797432eadefcc12767119ad5e80999389",
|
||||
"fingerprint": fingerprint or hashlib.sha1(os.urandom(20)).hexdigest(),
|
||||
"screen": "M2102J2SC,30,11",
|
||||
}
|
||||
if device_info:
|
||||
@@ -248,16 +248,21 @@ def _build_dfp_json_plain(device_info: Mapping[str, str] | None = None) -> bytes
|
||||
|
||||
|
||||
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
|
||||
"""Inject a current 40-hex fingerprint into a legacy request shape.
|
||||
|
||||
This compatibility path only operates on the shape supplied by a caller;
|
||||
it does not contain or search for a particular captured device value.
|
||||
"""
|
||||
if not fingerprint or len(fingerprint) != 40:
|
||||
return template
|
||||
match = re.search(rb"(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])", template)
|
||||
if match is None:
|
||||
return template
|
||||
return (
|
||||
template[: match.start()]
|
||||
+ fingerprint.encode("ascii")
|
||||
+ template[match.end() :]
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(data: bytes) -> tuple[str, str, str]:
|
||||
@@ -284,6 +289,9 @@ def register_device(
|
||||
"""执行新注册链,返回 ``(t1, safedeviceid, device_id)``。"""
|
||||
chain = _load_chain(fingerprint=fingerprint, device_info=device_info)
|
||||
_post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies)
|
||||
# The generated request already contains the current profile fingerprint.
|
||||
# Keep a generic shape-only compatibility patch for injected test/custom
|
||||
# templates; no captured device value is embedded in this module.
|
||||
select_request = _select_operator_request(chain["selectOperator"][0], fingerprint)
|
||||
_post(select_request, "application/x-wup", timeout, proxies)
|
||||
response = _post(
|
||||
|
||||
+128
-15
@@ -1,6 +1,7 @@
|
||||
"""wupData 信封构造与补丁工具。
|
||||
|
||||
解析与改写 WUP 信封中的 cert、uid、session 等字段。
|
||||
生产二维码信封由 :meth:`Envelope.build_qr` 按当前账号状态编码;
|
||||
:meth:`Envelope.load` 仅保留协议结构模板兼容测试和离线分析。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,7 +11,8 @@ import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
from .taf_protocol import TafOutputStream
|
||||
from .wup_protocol import WupRequest
|
||||
|
||||
INT8, INT16, INT32, INT64 = 0x00, 0x01, 0x02, 0x03
|
||||
STRING1, STRING4 = 0x06, 0x07
|
||||
@@ -18,7 +20,7 @@ MAP, LIST = 0x08, 0x09
|
||||
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||
|
||||
DEFAULT_QURL_B64 = (
|
||||
PROTOCOL_QURL_TEMPLATE_B64 = (
|
||||
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
|
||||
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
|
||||
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
|
||||
@@ -119,23 +121,105 @@ class Envelope:
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path | None = None) -> Envelope:
|
||||
"""加载信封模板,支持从文件加载或使用内嵌金样本。"""
|
||||
"""加载显式模板,或使用内嵌协议结构模板。
|
||||
|
||||
不自动扫描 ``evidence/``,避免历史抓包成为隐式生产输入。
|
||||
"""
|
||||
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"
|
||||
return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
|
||||
|
||||
@classmethod
|
||||
def build_qr(
|
||||
cls,
|
||||
*,
|
||||
uid: int,
|
||||
cert_b64: str,
|
||||
safedeviceid: str,
|
||||
session: int,
|
||||
trace_id: str,
|
||||
device_info: dict[str, str],
|
||||
) -> Envelope:
|
||||
"""按当前账号状态编码二维码绑定信封。
|
||||
|
||||
生产二维码请求不应从抓包信封复制字段。这里仅复用已知的 TAF/WUP
|
||||
字段布局;证书、ACTION、UID、会话和全部设备画像都由本次登录提供。
|
||||
"""
|
||||
if len(cert_b64) != 260:
|
||||
raise ValueError(f"证书 base64 长度异常: {len(cert_b64)} != 260")
|
||||
if len(safedeviceid) != 180:
|
||||
raise ValueError(f"safedeviceid 长度异常: {len(safedeviceid)} != 180")
|
||||
dev = {k: str(v) for k, v in (device_info or {}).items()}
|
||||
meta = json.dumps(
|
||||
{
|
||||
"associationId": 184549392,
|
||||
"funcName": "",
|
||||
"group": 0,
|
||||
"id": 184549392,
|
||||
"session": int(session),
|
||||
"step": 0,
|
||||
"stillLogin": False,
|
||||
"traceId": str(trace_id),
|
||||
"type": 2,
|
||||
"uid": 0,
|
||||
"userContext": "",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
if candidate.exists():
|
||||
try:
|
||||
return cls._load_from_path(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}")
|
||||
return cls(base64.b64decode(DEFAULT_QURL_B64))
|
||||
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, meta)
|
||||
body.write_string(3, "5008")
|
||||
body.write_int8(4, 3)
|
||||
body.write_string(5, safedeviceid)
|
||||
body.write_string(6, "")
|
||||
body.write_string(7, "")
|
||||
body.write_string(8, "")
|
||||
body.write_string(9, "")
|
||||
body.write_struct_end()
|
||||
|
||||
body.write_struct_begin(1)
|
||||
body.write_string(0, dev.get("hdid", ""))
|
||||
body.write_string(1, dev.get("app_version", "13.4.22"))
|
||||
body.write_string(2, dev.get("sdk_version", "1.0.80138"))
|
||||
body.write_string(3, "")
|
||||
body.write_string(4, dev.get("ip", "127.0.0.1"))
|
||||
body.write_string(5, dev.get("vendor", "android"))
|
||||
body.write_string(6, "")
|
||||
body.write_struct_end()
|
||||
|
||||
body.write_struct_begin(2)
|
||||
body.write_int8(0, 1)
|
||||
body.write_string(1, dev.get("model", ""))
|
||||
body.write_string(2, dev.get("fingerprint", ""))
|
||||
body.write_string(3, dev.get("os", "android"))
|
||||
body.write_string(4, dev.get("screen", ""))
|
||||
body.write_string(6, dev.get("width", "1080"))
|
||||
body.write_string(7, dev.get("height", "2120"))
|
||||
body.write_string(8, dev.get("device_id", ""))
|
||||
body.write_struct_end()
|
||||
body.write_uint64(3, int(uid))
|
||||
body.write_string(4, cert_b64)
|
||||
body.write_string(5, "")
|
||||
body.write_string(6, "")
|
||||
body.write_struct_end()
|
||||
|
||||
req = TafOutputStream()
|
||||
req.write_int32(0, int(session))
|
||||
wup = WupRequest()
|
||||
wup.iTimeout = 0
|
||||
wup.setRequestId(int(session))
|
||||
wup.setServant("huyaudbwebui")
|
||||
wup.setFunc("default")
|
||||
wup.newdata["_wup_data"] = body.get_bytes()
|
||||
wup.newdata["wupudbrequest_v0"] = req.get_bytes()
|
||||
return cls(wup.encode())
|
||||
|
||||
@classmethod
|
||||
def _load_from_path(cls, p: Path) -> Envelope:
|
||||
@@ -258,6 +342,7 @@ class Envelope:
|
||||
return self
|
||||
|
||||
def patch_session(self, session: int) -> Envelope:
|
||||
"""Patch the outer WUP session and its request copy."""
|
||||
if self.tag4_span:
|
||||
struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF)
|
||||
d = self.raw
|
||||
@@ -268,5 +353,33 @@ class Envelope:
|
||||
struct.pack_into(">I", d, j, session & 0xFFFFFFFF)
|
||||
return self
|
||||
|
||||
def patch_meta(self, session: int, trace_id: str) -> Envelope:
|
||||
"""Replace QR metadata values without carrying the capture's old state.
|
||||
|
||||
The captured envelope keeps a fixed-size JSON string. Keeping the
|
||||
replacement the same size lets us update only the value bytes and
|
||||
preserve all TAF length prefixes and offsets.
|
||||
"""
|
||||
if self.meta_json_span is None:
|
||||
raise ValueError("信封缺少元数据 JSON")
|
||||
start, end = self.meta_json_span
|
||||
current = bytes(self.raw[start:end])
|
||||
try:
|
||||
meta = json.loads(current.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("信封元数据 JSON 无法解析") from exc
|
||||
meta["session"] = int(session)
|
||||
meta["traceId"] = str(trace_id)
|
||||
updated = json.dumps(meta, ensure_ascii=False, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
if len(updated) != len(current):
|
||||
raise ValueError(
|
||||
f"信封元数据长度变化: {len(updated)} != {len(current)}; "
|
||||
"请使用固定长度 session/traceId"
|
||||
)
|
||||
self.raw[start:end] = updated
|
||||
return self
|
||||
|
||||
def wup_b64(self) -> str:
|
||||
return base64.b64encode(bytes(self.raw)).decode()
|
||||
|
||||
@@ -13,6 +13,10 @@ function makeEnv(overrides) {
|
||||
const VW = ov.screenWidth ? Math.round(ov.screenWidth / 2.75) : 393;
|
||||
const VH = ov.screenHeight ? Math.round(ov.screenHeight / 2.75) : 851;
|
||||
const W = globalThis;
|
||||
// Node 18/20 do not expose a browser navigator; create one before defining
|
||||
// the properties consumed by hydevice. Newer Node versions already expose
|
||||
// a Navigator instance, which is retained.
|
||||
W.navigator = W.navigator || {};
|
||||
W.screen = {width:VW, height:VH, availWidth:VW, availHeight:VH,
|
||||
colorDepth:24, pixelDepth:24, availLeft:0, availTop:0, orientation:{type:'portrait-primary', angle:0}};
|
||||
W.devicePixelRatio = 2.75;
|
||||
|
||||
@@ -6,12 +6,18 @@ const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
globalThis.window = globalThis;
|
||||
const stateDir = process.argv[2] || '.';
|
||||
let _deviceOverrides = null;
|
||||
try { _deviceOverrides = JSON.parse(fs.readFileSync(process.argv[3] || '', 'utf8')); } catch (e) {}
|
||||
// Prefer an explicitly supplied JSON path; otherwise use the device hint that
|
||||
// get_huya_sdid writes into the per-account state directory.
|
||||
try {
|
||||
const hintPath = process.argv[3] && process.argv[3].endsWith('.json')
|
||||
? process.argv[3] : path.join(stateDir, 'device.json');
|
||||
_deviceOverrides = JSON.parse(fs.readFileSync(hintPath, 'utf8'));
|
||||
} catch (e) {}
|
||||
require(path.join(__dirname, 'env.js')).makeEnv(_deviceOverrides);
|
||||
|
||||
// ---- localStorage 持久化(设备稳定性) ----
|
||||
const stateDir = process.argv[2] || '.';
|
||||
try { fs.mkdirSync(stateDir, {recursive: true}); } catch (e) {}
|
||||
const lsFile = path.join(stateDir, 'localstorage.json');
|
||||
let _ls = {};
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import struct
|
||||
import time as _time
|
||||
from typing import Any
|
||||
@@ -114,7 +115,11 @@ def _build_meta_json(session: int, trace_id: str) -> str:
|
||||
|
||||
|
||||
def _make_name(uid_str: str) -> str:
|
||||
"""登录名 = "hy_" + 虎牙号。"""
|
||||
"""构造 App 登录名:手机号原样提交,虎牙号使用 ``hy_`` 前缀。"""
|
||||
# App 协议对手机号和虎牙号使用不同的账号命名空间。手机号登录时
|
||||
# name 就是 11 位手机号;只有数字虎牙号才需要 hy_ 前缀。
|
||||
if re.fullmatch(r"1\d{10}", uid_str):
|
||||
return uid_str
|
||||
if uid_str.startswith("hy_"):
|
||||
return uid_str
|
||||
return "hy_" + uid_str
|
||||
|
||||
@@ -13,6 +13,21 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 使用新的项目标识;可通过环境变量覆盖。
|
||||
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-live-hub-py}"
|
||||
export COMPOSE_PROJECT_NAME
|
||||
|
||||
# 迁移期间优先复用旧项目卷,避免目录改名导致创建空数据库。
|
||||
MYSQL_VOLUME_NAME="${MYSQL_VOLUME_NAME:-}"
|
||||
MYSQL_VOLUME_EXTERNAL="${MYSQL_VOLUME_EXTERNAL:-false}"
|
||||
if [ -z "$MYSQL_VOLUME_NAME" ] && command -v docker >/dev/null 2>&1 \
|
||||
&& docker volume inspect douyu_login_py_mysql-data >/dev/null 2>&1; then
|
||||
MYSQL_VOLUME_NAME="douyu_login_py_mysql-data"
|
||||
MYSQL_VOLUME_EXTERNAL="true"
|
||||
fi
|
||||
export MYSQL_VOLUME_NAME
|
||||
export MYSQL_VOLUME_EXTERNAL
|
||||
|
||||
APP_PORT="${APP_PORT:-8000}"
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────
|
||||
@@ -27,6 +42,20 @@ detect_compose() {
|
||||
fi
|
||||
}
|
||||
|
||||
stop_legacy_containers() {
|
||||
local name project
|
||||
for name in douyu-login-db douyu-login; do
|
||||
if ! docker inspect "$name" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
project="$(docker inspect "$name" --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || true)"
|
||||
if [ -n "$project" ] && [ "$project" != "$COMPOSE_PROJECT_NAME" ]; then
|
||||
echo "停止旧 Compose 项目容器: $name (项目 $project)"
|
||||
docker stop "$name" >/dev/null
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ── 子命令 ──────────────────────────────────────────────
|
||||
|
||||
cmd_deploy() {
|
||||
@@ -65,6 +94,7 @@ cmd_deploy() {
|
||||
|
||||
# 构建并启动
|
||||
echo "正在构建应用镜像(首次构建约3-5分钟)..."
|
||||
stop_legacy_containers
|
||||
# 生产部署不构建含大型开发依赖的 test 镜像;测试镜像仅在本地按需构建。
|
||||
$COMPOSE build douyu-login
|
||||
|
||||
@@ -190,6 +220,7 @@ cmd_migrate_mysql() {
|
||||
fi
|
||||
|
||||
echo "正在启动 MySQL..."
|
||||
stop_legacy_containers
|
||||
$COMPOSE up -d mysql
|
||||
|
||||
echo "等待 MySQL 就绪..."
|
||||
@@ -222,6 +253,7 @@ cmd_migrate() {
|
||||
fi
|
||||
|
||||
echo "正在启动 MySQL..."
|
||||
stop_legacy_containers
|
||||
$COMPOSE up -d mysql
|
||||
|
||||
echo "等待 MySQL 就绪..."
|
||||
@@ -261,6 +293,7 @@ cmd_restart() {
|
||||
echo "❌ 未检测到 docker compose"
|
||||
exit 1
|
||||
fi
|
||||
stop_legacy_containers
|
||||
# 重建镜像和应用容器,使新迁移文件与入口迁移逻辑都能生效。
|
||||
$COMPOSE up -d --build --force-recreate douyu-login
|
||||
echo "✅ 服务已重建并重启(已自动检查数据库迁移)"
|
||||
|
||||
@@ -6,6 +6,15 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# 目录改名后,旧 shell 可能仍携带旧项目的 VIRTUAL_ENV。
|
||||
# 开发脚本始终使用当前仓库的环境,不要求每次手工指定或重新激活环境。
|
||||
unset VIRTUAL_ENV
|
||||
export UV_PROJECT_ENVIRONMENT="$ROOT_DIR/.venv"
|
||||
|
||||
# 使用新的项目标识;可通过环境变量覆盖。
|
||||
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-live-hub-py}"
|
||||
export COMPOSE_PROJECT_NAME
|
||||
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
# 调试模式复用 Docker 部署的环境变量,尤其是 JWT 和敏感字段加密密钥。
|
||||
@@ -14,6 +23,17 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 迁移期间优先复用旧项目卷,避免目录改名导致创建空数据库。
|
||||
MYSQL_VOLUME_NAME="${MYSQL_VOLUME_NAME:-}"
|
||||
MYSQL_VOLUME_EXTERNAL="${MYSQL_VOLUME_EXTERNAL:-false}"
|
||||
if [ -z "$MYSQL_VOLUME_NAME" ] && command -v docker >/dev/null 2>&1 \
|
||||
&& docker volume inspect douyu_login_py_mysql-data >/dev/null 2>&1; then
|
||||
MYSQL_VOLUME_NAME="douyu_login_py_mysql-data"
|
||||
MYSQL_VOLUME_EXTERNAL="true"
|
||||
fi
|
||||
export MYSQL_VOLUME_NAME
|
||||
export MYSQL_VOLUME_EXTERNAL
|
||||
|
||||
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
|
||||
# 8800 是项目默认开发端口并由脚本独占;其他显式端口发生冲突时只报错。
|
||||
BACKEND_PORT_EXPLICIT=false
|
||||
@@ -58,22 +78,22 @@ fi
|
||||
# --group dev 确保 pytest 等 dev 依赖已安装。
|
||||
if [ "${1:-}" = "test" ]; then
|
||||
shift
|
||||
exec uv run --group dev pytest "$@"
|
||||
exec uv run --group dev python -m pytest "$@"
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "format" ]; then
|
||||
shift
|
||||
exec uv run --group dev ruff format "$@"
|
||||
exec uv run --group dev python -m ruff format "$@"
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "format-check" ]; then
|
||||
shift
|
||||
exec uv run --group dev ruff format --check "$@"
|
||||
exec uv run --group dev python -m ruff format --check "$@"
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "type-check" ]; then
|
||||
shift
|
||||
exec uv run --group dev pyright "$@"
|
||||
exec uv run --group dev python -m pyright "$@"
|
||||
fi
|
||||
|
||||
BACKEND_PID=""
|
||||
@@ -121,6 +141,20 @@ detect_compose() {
|
||||
fi
|
||||
}
|
||||
|
||||
stop_legacy_containers() {
|
||||
local name project
|
||||
for name in douyu-login-db douyu-login; do
|
||||
if ! docker inspect "$name" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
project="$(docker inspect "$name" --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || true)"
|
||||
if [ -n "$project" ] && [ "$project" != "$COMPOSE_PROJECT_NAME" ]; then
|
||||
echo "停止旧 Compose 项目容器: $name (项目 $project)"
|
||||
docker stop "$name" >/dev/null
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
detect_backend_proxy_host() {
|
||||
if [ "$BACKEND_HOST" != "0.0.0.0" ] && [ "$BACKEND_HOST" != "::" ]; then
|
||||
echo "$BACKEND_HOST"
|
||||
@@ -263,6 +297,7 @@ start_yyb_worker() {
|
||||
}
|
||||
|
||||
echo "正在启动本地 MySQL..."
|
||||
stop_legacy_containers
|
||||
MYSQL_IMAGE="$MYSQL_IMAGE" \
|
||||
MYSQL_BIND_HOST="$MYSQL_BIND_HOST" \
|
||||
MYSQL_HOST_PORT="$MYSQL_HOST_PORT" \
|
||||
@@ -313,7 +348,7 @@ echo ""
|
||||
export DB_POOL_SIZE DB_MAX_OVERFLOW DB_POOL_TIMEOUT DB_POOL_RECYCLE
|
||||
export YYB_WORKER_KEY LOG_LEVEL LOG_DIR
|
||||
export YYB_WORKER_URL="$DEV_YYB_WORKER_URL"
|
||||
uv run uvicorn web.backend.main:app \
|
||||
uv run python -m uvicorn web.backend.main:app \
|
||||
--host "$BACKEND_HOST" \
|
||||
--port "$BACKEND_PORT" \
|
||||
--reload \
|
||||
|
||||
+3
-3
@@ -7,7 +7,6 @@ services:
|
||||
args:
|
||||
# 服务器默认走阿里云镜像;本地测试设为 false。
|
||||
USE_CHINA_MIRRORS: ${USE_CHINA_MIRRORS:-true}
|
||||
container_name: douyu-login
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 保持现有服务端口;可通过 APP_PORT 覆盖。
|
||||
@@ -86,12 +85,10 @@ services:
|
||||
args:
|
||||
USE_CHINA_MIRRORS: ${USE_CHINA_MIRRORS:-true}
|
||||
profiles: ["test"]
|
||||
container_name: douyu-login-test
|
||||
entrypoint: ["python", "-m", "pytest"]
|
||||
|
||||
mysql:
|
||||
image: ${MYSQL_IMAGE:-docker.m.daocloud.io/library/mysql:8.4}
|
||||
container_name: douyu-login-db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 仅绑定宿主机本地地址,供 ./dev.sh 的本地后端连接,不对外网开放。
|
||||
@@ -121,6 +118,9 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
# 目录/项目改名时优先复用旧项目的数据卷;新环境默认使用新项目名卷。
|
||||
name: ${MYSQL_VOLUME_NAME:-live-hub-py_mysql-data}
|
||||
external: ${MYSQL_VOLUME_EXTERNAL:-false}
|
||||
|
||||
networks:
|
||||
order-site-net:
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| **msgType** | 4097 (0x1001) = `MsgLogin.mMsgId` |
|
||||
| **请求大小** | 1356 字节 |
|
||||
| **密码** | SHA1(明文) hex,无盐(`772ed992b0e161276f44ec63671e60155c506294`) |
|
||||
| **name** | `hy_300023887`(`hy_` + 虎牙号) |
|
||||
| **name** | 虎牙号为 `hy_<虎牙号>`;手机号登录时为 11 位手机号原值 |
|
||||
|
||||
---
|
||||
|
||||
@@ -92,7 +92,7 @@ _wup_data = STRUCT {
|
||||
tag8 = "7c5387e0539c023c31c4ff0e807e7256117385ee" # 另一设备ID(40 hex)
|
||||
}
|
||||
field3 = STRUCT { # 登录凭证
|
||||
tag3 = "hy_300023887" # name (hy_虎牙号)
|
||||
tag3 = "hy_300023887" # name (虎牙号示例;手机号登录时直接放手机号)
|
||||
tag4 = "772ed992b0e161276f44ec63671e60155c506294" # password = SHA1(明文)
|
||||
tag5 = LIST["5008"] # appid列表
|
||||
tag6 = 1 (INT8)
|
||||
@@ -151,7 +151,7 @@ userAction JSON:
|
||||
7c5387e0539c023c31c4ff0e807e7256117385ee ← 另一个设备ID
|
||||
|
||||
登录凭证:
|
||||
hy_300023887 ← name
|
||||
hy_300023887 ← name (虎牙号示例;手机号登录时直接放手机号)
|
||||
772ed992b0e161276f44ec63671e60155c506294 ← password (SHA1)
|
||||
|
||||
结尾:
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[project]
|
||||
name = "douyu-login-py"
|
||||
name = "live-hub-py"
|
||||
version = "0.2.1"
|
||||
description = "直播账号运营 Web 后台"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { JSDOM, ResourceLoader } from 'jsdom';
|
||||
import { patchEnvironment } from './scripts/jsdom-patch-env.mjs';
|
||||
|
||||
const ROOT = '/Users/yml/codes/douyu_login_py';
|
||||
// Resolve the repository from this file so the probe works after a checkout
|
||||
// is renamed or moved to another host.
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const taskId = process.argv[2] || '142265ef3d874ce6';
|
||||
const out = `${ROOT}/data/yyb-worker-dev/${taskId}/jsdom-order`;
|
||||
const goodsUrl = JSON.parse(fs.readFileSync(`${out}/device-fp.json`, 'utf8')).goods_url;
|
||||
const pageInfoPath = process.argv[3] || `${out}/web-page-info-response.json`;
|
||||
const out = path.join(ROOT, 'data', 'yyb-worker-dev', taskId, 'jsdom-order');
|
||||
const goodsUrl = JSON.parse(fs.readFileSync(path.join(out, 'device-fp.json'), 'utf8')).goods_url;
|
||||
const pageInfoPath = process.argv[3] || path.join(out, 'web-page-info-response.json');
|
||||
const pageInfo = fs.existsSync(pageInfoPath)
|
||||
? fs.readFileSync(pageInfoPath, 'utf8')
|
||||
: '{"ret":0,"msg":"","info":{}}';
|
||||
@@ -14,7 +18,7 @@ const pageInfo = fs.existsSync(pageInfoPath)
|
||||
const captured = [];
|
||||
const requests = [];
|
||||
const pageErrors = [];
|
||||
const dom = new JSDOM(fs.readFileSync(`${out}/goods.html`, 'utf8'), {
|
||||
const dom = new JSDOM(fs.readFileSync(path.join(out, 'goods.html'), 'utf8'), {
|
||||
url: goodsUrl,
|
||||
referrer: 'https://z.iwan.yyb.qq.com/',
|
||||
pretendToBeVisual: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""虎牙 App 密码登录及相关组件测试。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -11,19 +12,21 @@ from sqlalchemy.orm import sessionmaker
|
||||
from core.huya import (
|
||||
HuyaAppLoginError,
|
||||
)
|
||||
from core.huya.app_login import (
|
||||
DEFAULT_GOLDEN_DEV,
|
||||
login_cred_with_flow,
|
||||
wup_password_login_raw,
|
||||
)
|
||||
from core.huya.app_login import login_cred_with_flow, wup_password_login_raw
|
||||
from core.huya.cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
|
||||
from core.huya.device_profile import generate_profile, get_profile
|
||||
from core.huya.device_fingerprint import account_state_dir, reset_account_state
|
||||
from core.huya.device_profile import (
|
||||
canonical_account_key,
|
||||
generate_profile,
|
||||
get_profile,
|
||||
)
|
||||
from core.huya.dfp_register import DfpRegistrationError
|
||||
from core.huya.envelope_forge import Envelope
|
||||
from core.huya.login import HuyaLoginResult
|
||||
from core.huya.nonce_forge import K1_DEFAULT, gen_nonce
|
||||
from core.huya.udb_aes import udb_decrypt, udb_encrypt
|
||||
from core.huya.wup_encoder import build_password_login_wup
|
||||
from core.huya.wup_encoder import _make_name, build_password_login_wup
|
||||
from core.huya.wup_protocol import WupResponse
|
||||
from web.backend.database import Base
|
||||
from web.backend.models import HuyaAccount, User
|
||||
from web.backend.routers.huya import (
|
||||
@@ -90,6 +93,48 @@ class TestHuyaAppLogin:
|
||||
wup_b64 = env.wup_b64()
|
||||
assert len(wup_b64) > 0
|
||||
|
||||
def test_envelope_metadata_is_fresh(self):
|
||||
env = Envelope.load()
|
||||
old = bytes(env.raw[env.meta_json_span[0] : env.meta_json_span[1]])
|
||||
new_trace = "a" * 16 + "-12345-" + "9" * 20
|
||||
env.patch_session(7654321).patch_meta(7654321, new_trace)
|
||||
current = bytes(env.raw[env.meta_json_span[0] : env.meta_json_span[1]])
|
||||
assert current != old
|
||||
assert b'"session":7654321' in current
|
||||
assert (b'"traceId":"' + new_trace.encode() + b'"') in current
|
||||
|
||||
def test_qr_envelope_is_fully_dynamic(self):
|
||||
profile = {
|
||||
"hdid": "h" * 32,
|
||||
"app_version": "13.4.22",
|
||||
"sdk_version": "1.0.80138",
|
||||
"ip": "10.0.0.8",
|
||||
"vendor": "vivo",
|
||||
"model": "V2370A",
|
||||
"fingerprint": "f" * 40,
|
||||
"os": "android",
|
||||
"screen": "V2370A,34,13",
|
||||
"width": "1080",
|
||||
"height": "2412",
|
||||
"device_id": "d" * 40,
|
||||
}
|
||||
env = Envelope.build_qr(
|
||||
uid=1199664135026,
|
||||
cert_b64="A" * 260,
|
||||
safedeviceid="B" * 180,
|
||||
session=7654321,
|
||||
trace_id="t" * 16 + "-12345-" + "9" * 20,
|
||||
device_info=profile,
|
||||
)
|
||||
wup = WupResponse()
|
||||
wup.decode(bytes(env.raw))
|
||||
body = wup.newdata["_wup_data"]
|
||||
for value in (b"B" * 180, b"h" * 32, b"f" * 40, b"V2370A", b"d" * 40):
|
||||
assert value in body
|
||||
assert b"PQwemAN9NHkZKoMq" not in body
|
||||
assert b"02df398797432eadefcc12767119ad5e80999389" not in body
|
||||
assert b"7c5387e0539c023c31c4ff0e807e7256117385ee" not in body
|
||||
|
||||
def test_device_profile_generation(self):
|
||||
p1 = generate_profile()
|
||||
assert p1["os"] == "android"
|
||||
@@ -98,12 +143,46 @@ class TestHuyaAppLogin:
|
||||
assert p1["hdid"] == "ed0db8334cadd236c00cadf7e11ab5a5"
|
||||
# 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发
|
||||
assert "safedeviceid" not in p1
|
||||
assert "safedeviceid" not in DEFAULT_GOLDEN_DEV
|
||||
|
||||
p2 = get_profile("test_user_account_123")
|
||||
p3 = get_profile("test_user_account_123")
|
||||
assert p2["fingerprint"] == p3["fingerprint"]
|
||||
|
||||
def test_huya_id_aliases_share_one_profile_key(self, tmp_path, monkeypatch):
|
||||
profile_db = tmp_path / "profiles.json"
|
||||
monkeypatch.setattr("core.huya.device_profile.PRIMARY_PROFILE_DB", profile_db)
|
||||
monkeypatch.setattr(
|
||||
"core.huya.device_profile.FALLBACK_PROFILE_DB", tmp_path / "missing.json"
|
||||
)
|
||||
assert canonical_account_key(" hy_300023887 ") == "300023887"
|
||||
first = get_profile("300023887")
|
||||
second = get_profile("hy_300023887")
|
||||
assert first["fingerprint"] == second["fingerprint"]
|
||||
assert list(json.loads(profile_db.read_text())) == ["300023887"]
|
||||
|
||||
def test_legacy_evidence_profile_is_not_loaded_by_default(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
primary = tmp_path / "profiles.json"
|
||||
legacy = tmp_path / "legacy.json"
|
||||
legacy.write_text(json.dumps({"old": {"fingerprint": "f" * 40}}))
|
||||
monkeypatch.setattr("core.huya.device_profile.PRIMARY_PROFILE_DB", primary)
|
||||
monkeypatch.setattr("core.huya.device_profile.FALLBACK_PROFILE_DB", legacy)
|
||||
profile = get_profile("old")
|
||||
assert profile["fingerprint"] != "f" * 40
|
||||
|
||||
def test_force_new_device_clears_hydevice_state(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"core.huya.device_fingerprint.FP_STATE_ROOT", tmp_path / "fp"
|
||||
)
|
||||
state = account_state_dir("hy_test")
|
||||
state.mkdir(parents=True)
|
||||
(state / "localstorage.json").write_text("{}", encoding="utf-8")
|
||||
(state / "device.json").write_text("{}", encoding="utf-8")
|
||||
reset_account_state("hy_test")
|
||||
assert not (state / "localstorage.json").exists()
|
||||
assert not (state / "device.json").exists()
|
||||
|
||||
def test_wup_encoder_output(self):
|
||||
dev = generate_profile()
|
||||
pkt = build_password_login_wup(
|
||||
@@ -121,6 +200,12 @@ class TestHuyaAppLogin:
|
||||
total_len = struct.unpack(">I", pkt[:4])[0]
|
||||
assert total_len == len(pkt)
|
||||
|
||||
def test_app_login_name_keeps_mobile_number(self):
|
||||
"""App 协议手机号登录名不能套用虎牙号的 hy_ 前缀。"""
|
||||
assert _make_name("15197635967") == "15197635967"
|
||||
assert _make_name("300023887") == "hy_300023887"
|
||||
assert _make_name("hy_300023887") == "hy_300023887"
|
||||
|
||||
# ---- 新设备注册链 (core/huya/dfp_register) 生产接入测试 ----
|
||||
|
||||
def test_wup_login_skips_registration_when_safedeviceid_given(self):
|
||||
@@ -165,7 +250,9 @@ class TestHuyaAppLogin:
|
||||
assert captured["args"][2] == new_action
|
||||
assert captured["args"][7]["device_id"] == new_device_id
|
||||
# 画像默认值里的旧 device_id 被注册结果覆盖,而非沿用
|
||||
assert captured["args"][7]["device_id"] != DEFAULT_GOLDEN_DEV["device_id"]
|
||||
assert captured["args"][7]["device_id"] != (
|
||||
"7c5387e0539c023c31c4ff0e807e7256117385ee"
|
||||
)
|
||||
|
||||
def test_wup_login_registration_failure_is_explicit(self):
|
||||
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""
|
||||
@@ -192,6 +279,57 @@ class TestHuyaAppLogin:
|
||||
):
|
||||
login_cred_with_flow("300023887", "pw")
|
||||
|
||||
def test_login_cred_flow_reuses_wup_session_after_slider(self):
|
||||
"""滑块通过后的 WUP 重发必须沿用首次请求的会话上下文。"""
|
||||
assets = ({"session": 123}, "ua", "")
|
||||
responses = [b"risk", b"success"]
|
||||
calls = []
|
||||
|
||||
def fake_wup(*args, **kwargs):
|
||||
calls.append(kwargs["session_assets"])
|
||||
return responses.pop(0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"core.huya.app_login.register_device",
|
||||
return_value=("a" * 32, "A" * 180, "c" * 40),
|
||||
),
|
||||
patch("core.huya.app_login._golden_session_assets", return_value=assets),
|
||||
patch("core.huya.app_login.wup_password_login_raw", side_effect=fake_wup),
|
||||
patch("core.huya.app_login.parse_cred", side_effect=[None, b"c" * 114]),
|
||||
patch(
|
||||
"core.huya.app_login.parse_risk_url",
|
||||
return_value="https://aq.huya.com/p/safe_auth/pt_auth.html?param=x",
|
||||
),
|
||||
patch("core.huya.app_login.solve_safe_auth", return_value={"authId": "id"}),
|
||||
patch("core.huya.app_login.parse_real_uid", return_value=1199666914671),
|
||||
):
|
||||
cred, uid, safedeviceid = login_cred_with_flow(
|
||||
"300023887", "pw", include_device_token=True
|
||||
)
|
||||
|
||||
assert cred == b"c" * 114
|
||||
assert uid == 1199666914671
|
||||
assert safedeviceid == "A" * 180
|
||||
assert calls == [assets, assets]
|
||||
|
||||
def test_login_cred_flow_maps_invalid_password_response(self):
|
||||
"""服务端明确返回账号密码错误时,不再显示泛化的无凭据提示。"""
|
||||
with (
|
||||
patch(
|
||||
"core.huya.app_login.register_device",
|
||||
return_value=("a" * 32, "A" * 180, "c" * 40),
|
||||
),
|
||||
patch(
|
||||
"core.huya.app_login.wup_password_login_raw",
|
||||
return_value=b"F!LGN_INFO_INVALID_USER_OR_PASSWORDV",
|
||||
),
|
||||
patch("core.huya.app_login.parse_cred", return_value=None),
|
||||
patch("core.huya.app_login.parse_risk_url", return_value=None),
|
||||
pytest.raises(HuyaAppLoginError, match="账号或密码错误"),
|
||||
):
|
||||
login_cred_with_flow("300023887", "pw")
|
||||
|
||||
def test_router_functions(self):
|
||||
mock_res = HuyaLoginResult(
|
||||
success=True,
|
||||
|
||||
@@ -224,73 +224,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ea/81cf3858b256494b31a554cf76bbd345def3ea7e7a1a592cc515633b4e28/curl_cffi-0.16.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:06b1c7e07af8ff7c4c5ce4086ea89cc582ebff9adff4a37cfffa5f5de5d5b943", size = 8603463, upload-time = "2026-08-01T13:44:55.003Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "douyu-login-py"
|
||||
version = "0.2.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "loguru" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyexecjs" },
|
||||
{ name = "pymysql" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "requests", extra = ["socks"] },
|
||||
{ name = "scipy" },
|
||||
{ name = "segno" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.18.4" },
|
||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||
{ name = "curl-cffi", specifier = ">=0.13" },
|
||||
{ name = "fastapi", specifier = ">=0.110.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.0" },
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "onnxruntime", specifier = ">=1.18.0" },
|
||||
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
|
||||
{ name = "pillow", specifier = ">=10.0.0" },
|
||||
{ name = "pycryptodome", specifier = ">=3.19.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pyexecjs", specifier = ">=1.5.1" },
|
||||
{ name = "pymysql", specifier = ">=1.1,<2" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||
{ name = "requests", extras = ["socks"], specifier = ">=2.31.0" },
|
||||
{ name = "scipy", specifier = ">=1.13.0" },
|
||||
{ name = "segno", specifier = ">=1.6" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
|
||||
{ name = "websockets", specifier = ">=16.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyright", specifier = ">=1.1.411" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "ruff", specifier = ">=0.12.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.2"
|
||||
@@ -385,6 +318,73 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "live-hub-py"
|
||||
version = "0.2.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "loguru" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyexecjs" },
|
||||
{ name = "pymysql" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "requests", extra = ["socks"] },
|
||||
{ name = "scipy" },
|
||||
{ name = "segno" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.18.4" },
|
||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||
{ name = "curl-cffi", specifier = ">=0.13" },
|
||||
{ name = "fastapi", specifier = ">=0.110.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.0" },
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "onnxruntime", specifier = ">=1.18.0" },
|
||||
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
|
||||
{ name = "pillow", specifier = ">=10.0.0" },
|
||||
{ name = "pycryptodome", specifier = ">=3.19.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pyexecjs", specifier = ">=1.5.1" },
|
||||
{ name = "pymysql", specifier = ">=1.1,<2" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||
{ name = "requests", extras = ["socks"], specifier = ">=2.31.0" },
|
||||
{ name = "scipy", specifier = ">=1.13.0" },
|
||||
{ name = "segno", specifier = ">=1.6" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
|
||||
{ name = "websockets", specifier = ">=16.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyright", specifier = ">=1.1.411" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "ruff", specifier = ">=0.12.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
|
||||
@@ -19,6 +19,6 @@ def get_app_version() -> str:
|
||||
return version
|
||||
|
||||
try:
|
||||
return package_version("douyu-login-py")
|
||||
return package_version("live-hub-py")
|
||||
except PackageNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
Reference in New Issue
Block a user