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):