diff --git a/MODIFIED_FILE b/MODIFIED_FILE new file mode 100644 index 0000000..280bd22 --- /dev/null +++ b/MODIFIED_FILE @@ -0,0 +1,303 @@ +"""wupData 信封构造与补丁工具。 + +解析与改写 WUP 信封中的 cert、uid、session 等字段。 +""" + +# Verification fixture: this copy intentionally represents the modified branch. + +from __future__ import annotations + +import base64 +import json +import struct +from pathlib import Path + +from loguru import logger + +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: + """加载信封模板,支持从文件加载或使用内嵌金样本。""" + 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 _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() diff --git a/ROLLBACK.sh b/ROLLBACK.sh new file mode 100755 index 0000000..6461970 --- /dev/null +++ b/ROLLBACK.sh @@ -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" diff --git a/VERIFICATION.txt b/VERIFICATION.txt new file mode 100644 index 0000000..d52b3d6 --- /dev/null +++ b/VERIFICATION.txt @@ -0,0 +1,33 @@ +changed branch/field: Huya App login device identity and QR envelope metadata + +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 marker: 08bc9d7ca6425fab15af2c6218d2574f7f0180e929fa6ccb78c8b37e6ae8493a +MODIFIED_FILE changed SHA-256: 9d7240baef81e135e83cb62eada00a6a5e9f20be7d5428189bb020ac9742a557 + +BASELINE command: +/Users/yml/codes/live-hub-py/.venv/bin/pytest -q /tmp/live-hub-py-baseline.OFnU09/tests/test_huya_app_login.py /tmp/live-hub-py-baseline.OFnU09/tests/test_huya_dfp_register.py +BASELINE literal output/result: +24 passed, 1 warning in 1.43s +BASELINE exit status: 0 + +MODIFIED command: +/Users/yml/codes/live-hub-py/.venv/bin/pytest -q +MODIFIED literal output/result: +112 passed, 1 warning in 1.26s +MODIFIED exit status: 0 + +ROLLBACK command: +./ROLLBACK.sh /tmp/rollback-source.TCchx3 /tmp/rollback-target.atlKUI +ROLLBACK literal output/result: +restored /tmp/rollback-target.atlKUI from /tmp/rollback-source.TCchx3 +restored behavior/status: +target SHA-256 after rollback = source SHA-256 = 08bc9d7ca6425fab15af2c6218d2574f7f0180e929fa6ccb78c8b37e6ae8493a; exit status 0 + +Additional checks: +.venv/bin/python -m ruff check core/huya tests/test_huya_app_login.py -> All checks passed, exit 0 +bash -n dev.sh deploy.sh -> exit 0 +node --check services/yyb-worker/runtime/probe-jsdom-pay.mjs -> exit 0 +find scripts -name '*.py' -print0 | xargs -0 .venv/bin/python -m py_compile -> scripts_compile:0 diff --git a/core/huya/account_env.py b/core/huya/account_env.py index 854d061..eb98687 100644 --- a/core/huya/account_env.py +++ b/core/huya/account_env.py @@ -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 { diff --git a/core/huya/app_login.py b/core/huya/app_login.py index 1b9337e..afa33a4 100644 --- a/core/huya/app_login.py +++ b/core/huya/app_login.py @@ -68,21 +68,6 @@ _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 登录失败。""" @@ -147,11 +132,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, @@ -227,6 +215,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() @@ -235,7 +225,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, @@ -357,6 +347,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: @@ -370,9 +362,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": "*/*", @@ -511,7 +501,15 @@ class HuyaAppPasswordLogin: 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") + # QR 信封只保留协议结构;不要重放抓包里的旧会话值。 + 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) + wup = base64.b64encode(bytes(env.raw)).decode("ascii") except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 return HuyaLoginResult( success=False, @@ -524,11 +522,18 @@ 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( diff --git a/core/huya/device_fingerprint.py b/core/huya/device_fingerprint.py index f5cfd72..414a898 100644 --- a/core/huya/device_fingerprint.py +++ b/core/huya/device_fingerprint.py @@ -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,25 @@ 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_`` 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: diff --git a/core/huya/device_profile.py b/core/huya/device_profile.py index 41d4b0f..fdde1cc 100644 --- a/core/huya/device_profile.py +++ b/core/huya/device_profile.py @@ -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 diff --git a/core/huya/dfp_register.py b/core/huya/dfp_register.py index 003bc4b..b00c466 100644 --- a/core/huya/dfp_register.py +++ b/core/huya/dfp_register.py @@ -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"(? 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( diff --git a/core/huya/envelope_forge.py b/core/huya/envelope_forge.py index f103a7e..c5d66ab 100644 --- a/core/huya/envelope_forge.py +++ b/core/huya/envelope_forge.py @@ -18,7 +18,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" @@ -135,7 +135,7 @@ class Envelope: return cls._load_from_path(candidate) except Exception as exc: # noqa: BLE001 logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}") - return cls(base64.b64decode(DEFAULT_QURL_B64)) + return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64)) @classmethod def _load_from_path(cls, p: Path) -> Envelope: @@ -258,6 +258,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 +269,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() diff --git a/tests/test_huya_app_login.py b/tests/test_huya_app_login.py index 476e0ed..3512a14 100644 --- a/tests/test_huya_app_login.py +++ b/tests/test_huya_app_login.py @@ -1,5 +1,6 @@ """虎牙 App 密码登录及相关组件测试。""" +import json import os import struct from unittest.mock import MagicMock, patch @@ -11,14 +12,14 @@ 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_fingerprint import account_state_dir, reset_account_state -from core.huya.device_profile import generate_profile, get_profile +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 @@ -91,6 +92,16 @@ 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_device_profile_generation(self): p1 = generate_profile() assert p1["os"] == "android" @@ -99,12 +110,34 @@ 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" @@ -184,7 +217,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): """注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""