清理虎牙登录旧数据依赖并统一动态环境

This commit is contained in:
yml2213
2026-08-31 17:27:18 +08:00
parent c7db66f18c
commit 05b1325a04
11 changed files with 1414 additions and 59 deletions
+890
View File
@@ -0,0 +1,890 @@
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_<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:
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"(?<![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(
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):
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""
+303
View 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()
Executable
+7
View File
@@ -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"
+33
View File
@@ -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
+9 -6
View File
@@ -39,6 +39,7 @@ from .app_login import HuyaAppPasswordLogin
from .device_profile import ( from .device_profile import (
_load_db, _load_db,
_save_db, _save_db,
canonical_account_key,
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json) get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
) )
@@ -68,9 +69,10 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
(R36 解密真实结构), 每账号独立生成终身复用 → 一号一设备的一致性来源; (R36 解密真实结构), 每账号独立生成终身复用 → 一号一设备的一致性来源;
当前注册链服务端不校验 cw 内容, 此字段为后续真实载荷构建预留 当前注册链服务端不校验 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() db = _load_db()
record = dict(db.get(account) or env) record = dict(db.get(key) or env)
changed = False changed = False
# guid32 / hebe: 一号一致字段, 首次生成后终身不变 # guid32 / hebe: 一号一致字段, 首次生成后终身不变
if "guid32" not in record: if "guid32" not in record:
@@ -82,7 +84,7 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
} }
changed = True changed = True
if changed: if changed:
db[account] = record db[key] = record
_save_db(db) _save_db(db)
return record return record
@@ -102,7 +104,8 @@ def bind_and_login(
就是本环境的 40hex → 签发的 t2/t5 与环境绑定); safe_auth 滑块过验后的重发 就是本环境的 40hex → 签发的 t2/t5 与环境绑定); safe_auth 滑块过验后的重发
沿用同一组设备字段 (app_login.login_cred_with_flow)。 沿用同一组设备字段 (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( print(
f"[env] {account}{env.get('vendor')}/{env.get('model')} " f"[env] {account}{env.get('vendor')}/{env.get('model')} "
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... " f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
@@ -124,14 +127,14 @@ def bind_and_login(
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ---- # ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
db = _load_db() 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.setdefault("bound_at", int(time.time())) # 首次绑定时间
record["last_login"] = { record["last_login"] = {
"ok": result.success, "ok": result.success,
"msg": result.message[:120], "msg": result.message[:120],
"at": int(time.time()), "at": int(time.time()),
} }
db[account] = record db[key] = record
_save_db(db) _save_db(db)
return { return {
+28 -23
View File
@@ -68,21 +68,6 @@ _URL_TAIL_KEEP = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%" "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): class HuyaAppLoginError(HuyaLoginError):
"""虎牙 App 登录失败。""" """虎牙 App 登录失败。"""
@@ -147,11 +132,14 @@ def wup_password_login_raw(
except DfpRegistrationError as exc: except DfpRegistrationError as exc:
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
dev["device_id"] = registered_device_id 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( pkt = build_password_login_wup(
uid_str, uid_str,
hashlib.sha1(password.encode()).hexdigest(), hashlib.sha1(password.encode()).hexdigest(),
safedeviceid, safedeviceid,
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5", str(hdid_value),
mj["session"], mj["session"],
mj["traceId"], mj["traceId"],
ua, ua,
@@ -227,6 +215,8 @@ def solve_safe_auth(
HuyaVerificationSolver, HuyaVerificationSolver,
) )
if device_info is None:
raise HuyaAppLoginError("safe_auth 缺少当前账号设备画像")
q = { q = {
k: v[0] k: v[0]
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items() 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 last_err: Exception | None = None
for attempt in range(max_retry): for attempt in range(max_retry):
solver = HuyaVerificationSolver( solver = HuyaVerificationSolver(
ua=mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV), ua=mobile_user_agent(device_info),
proxies=proxies, proxies=proxies,
app_id=app_id, app_id=app_id,
page_url=risk_url, page_url=risk_url,
@@ -357,6 +347,8 @@ class QrRole:
): ):
self.pc = pc self.pc = pc
self.sdid = sdid self.sdid = sdid
if not pc and device_info is None:
raise HuyaAppLoginError("移动端二维码角色缺少当前账号设备画像")
self.s = requests.Session() self.s = requests.Session()
self.s.trust_env = False self.s.trust_env = False
if proxies: if proxies:
@@ -370,9 +362,7 @@ class QrRole:
self.req_counter = random.randint(40_000_000, 41_000_000) self.req_counter = random.randint(40_000_000, 41_000_000)
self.s.headers.update( self.s.headers.update(
{ {
"User-Agent": UA_PC "User-Agent": UA_PC if pc else mobile_user_agent(device_info),
if pc
else mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
"Origin": UDB_BASE, "Origin": UDB_BASE,
"content-type": "application/json;charset=UTF-8", "content-type": "application/json;charset=UTF-8",
"Accept": "*/*", "Accept": "*/*",
@@ -511,7 +501,15 @@ class HuyaAppPasswordLogin:
raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii") raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii")
if env.uid != uid: if env.uid != uid:
struct.pack_into(">Q", raw, env.uid_off, 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 外部接口与任务边界需要保留宽泛异常兜底 except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return HuyaLoginResult( return HuyaLoginResult(
success=False, success=False,
@@ -524,11 +522,18 @@ class HuyaAppPasswordLogin:
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享 # 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复) # 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
sdid_obj = get_huya_sdid( sdid_obj = get_huya_sdid(
allow_fallback=True, # App 主链只接受当前账号的 hydevice 高信任结果;旧版
# token+collect 降级没有账号画像,不再静默放行。
allow_fallback=False,
state_dir=account_state_dir(self.username), state_dir=account_state_dir(self.username),
device_hint=self.device_info, 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 已获取,开始二维码绑定流程") logger.info("[huya-app] cred 已获取,开始二维码绑定流程")
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies) pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
ph = QrRole( ph = QrRole(
+17 -2
View File
@@ -17,7 +17,7 @@ from pathlib import Path
import requests import requests
from loguru import logger 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" FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
RUNNER_JS = FINGERPRINT_DIR / "runner.js" 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: def account_state_dir(account: str) -> Path:
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。""" """账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
raw = str(account or "anon").strip()
account = canonical_account_key(raw)
safe = "".join( safe = "".join(
c if c.isalnum() or c in "-_." else "_" for c in (account or "anon") c if c.isalnum() or c in "-_." else "_" for c in (account or "anon")
)[:64] )[: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: def reset_account_state(account: str) -> None:
+34 -7
View File
@@ -19,6 +19,7 @@ import hashlib
import json import json
import os import os
import random import random
import re
from collections.abc import Mapping from collections.abc import Mapping
from pathlib import Path from pathlib import Path
@@ -28,6 +29,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = ROOT / "data" DATA_DIR = ROOT / "data"
PRIMARY_PROFILE_DB = DATA_DIR / "huya_device_profiles.json" PRIMARY_PROFILE_DB = DATA_DIR / "huya_device_profiles.json"
FALLBACK_PROFILE_DB = ROOT / "evidence" / "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 = [ REAL_MODELS = [
("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)), ("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)),
@@ -51,6 +53,19 @@ APP_VERSION = "13.4.22"
SDK_VERSION = "1.0.80138" 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: def mobile_user_agent(device_info: Mapping[str, object]) -> str:
"""根据统一设备画像生成 App WebView UA。""" """根据统一设备画像生成 App WebView UA。"""
screen = str(device_info.get("screen") or "") 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 import time as _time
key = canonical_account_key(account)
db = _load_db() 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: if rec is None:
return # 尚无环境的账号 (纯 Cookie 导入) 不生成记录 return # 尚无环境的账号 (纯 Cookie 导入) 不生成记录
now = int(_time.time()) now = int(_time.time())
@@ -123,11 +144,11 @@ def _load_db() -> dict:
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8")) return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug(f"读取主设备画像库失败: {exc}") logger.debug(f"读取主设备画像库失败: {exc}")
if FALLBACK_PROFILE_DB.exists(): if ALLOW_LEGACY_PROFILE_IMPORT and FALLBACK_PROFILE_DB.exists():
try: try:
return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8")) return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8"))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug(f"读取备用设备画像库失败: {exc}") logger.debug(f"读取显式迁移画像库失败: {exc}")
return {} return {}
@@ -166,14 +187,20 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]:
def get_profile(account: str, force_new: bool = False) -> dict: def get_profile(account: str, force_new: bool = False) -> dict:
"""按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。""" """按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。"""
key = canonical_account_key(account)
db = _load_db() db = _load_db()
if not force_new and account in db: # Migrate the old prefixed key lazily without discarding its environment.
enriched, changed = _enrich_profile(db[account]) 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: if changed:
db[account] = enriched db[key] = enriched
_save_db(db) _save_db(db)
return enriched return enriched
p, _ = _enrich_profile(generate_profile()) p, _ = _enrich_profile(generate_profile())
db[account] = p db[key] = p
_save_db(db) _save_db(db)
return p return p
+19 -11
View File
@@ -90,7 +90,7 @@ def _build_select_operator_request(
# 仅供无画像的协议级独立调用兜底;生产登录始终传入账号画像。 # 仅供无画像的协议级独立调用兜底;生产登录始终传入账号画像。
"app_version": "13.4.22", "app_version": "13.4.22",
"model": "M2102J2SC", "model": "M2102J2SC",
"fingerprint": fingerprint or "02df398797432eadefcc12767119ad5e80999389", "fingerprint": fingerprint or hashlib.sha1(os.urandom(20)).hexdigest(),
"screen": "M2102J2SC,30,11", "screen": "M2102J2SC,30,11",
} }
if device_info: 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: def _select_operator_request(template: bytes, fingerprint: str | None) -> bytes:
if fingerprint and len(fingerprint) == 40: """Inject a current 40-hex fingerprint into a legacy request shape.
old = b"02df398797432eadefcc12767119ad5e80999389"
index = template.find(old) This compatibility path only operates on the shape supplied by a caller;
if index >= 0: it does not contain or search for a particular captured device value.
return ( """
template[:index] if not fingerprint or len(fingerprint) != 40:
+ fingerprint.encode("ascii") return template
+ template[index + len(old) :] match = re.search(rb"(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])", template)
) if match is None:
return template return template
return (
template[: match.start()]
+ fingerprint.encode("ascii")
+ template[match.end() :]
)
def _parse_response(data: bytes) -> tuple[str, str, str]: def _parse_response(data: bytes) -> tuple[str, str, str]:
@@ -284,6 +289,9 @@ def register_device(
"""执行新注册链,返回 ``(t1, safedeviceid, device_id)``。""" """执行新注册链,返回 ``(t1, safedeviceid, device_id)``。"""
chain = _load_chain(fingerprint=fingerprint, device_info=device_info) chain = _load_chain(fingerprint=fingerprint, device_info=device_info)
_post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies) _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) select_request = _select_operator_request(chain["selectOperator"][0], fingerprint)
_post(select_request, "application/x-wup", timeout, proxies) _post(select_request, "application/x-wup", timeout, proxies)
response = _post( response = _post(
+31 -2
View File
@@ -18,7 +18,7 @@ MAP, LIST = 0x08, 0x09
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
ZERO, SIMPLE_LIST = 0x0C, 0x0D ZERO, SIMPLE_LIST = 0x0C, 0x0D
DEFAULT_QURL_B64 = ( PROTOCOL_QURL_TEMPLATE_B64 = (
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm" "AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz" "ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi" "ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
@@ -135,7 +135,7 @@ class Envelope:
return cls._load_from_path(candidate) return cls._load_from_path(candidate)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}") logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}")
return cls(base64.b64decode(DEFAULT_QURL_B64)) return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
@classmethod @classmethod
def _load_from_path(cls, p: Path) -> Envelope: def _load_from_path(cls, p: Path) -> Envelope:
@@ -258,6 +258,7 @@ class Envelope:
return self return self
def patch_session(self, session: int) -> Envelope: def patch_session(self, session: int) -> Envelope:
"""Patch the outer WUP session and its request copy."""
if self.tag4_span: if self.tag4_span:
struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF) struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF)
d = self.raw d = self.raw
@@ -268,5 +269,33 @@ class Envelope:
struct.pack_into(">I", d, j, session & 0xFFFFFFFF) struct.pack_into(">I", d, j, session & 0xFFFFFFFF)
return self 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: def wup_b64(self) -> str:
return base64.b64encode(bytes(self.raw)).decode() return base64.b64encode(bytes(self.raw)).decode()
+43 -8
View File
@@ -1,5 +1,6 @@
"""虎牙 App 密码登录及相关组件测试。""" """虎牙 App 密码登录及相关组件测试。"""
import json
import os import os
import struct import struct
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -11,14 +12,14 @@ from sqlalchemy.orm import sessionmaker
from core.huya import ( from core.huya import (
HuyaAppLoginError, HuyaAppLoginError,
) )
from core.huya.app_login import ( from core.huya.app_login import login_cred_with_flow, wup_password_login_raw
DEFAULT_GOLDEN_DEV,
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.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_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.dfp_register import DfpRegistrationError
from core.huya.envelope_forge import Envelope from core.huya.envelope_forge import Envelope
from core.huya.login import HuyaLoginResult from core.huya.login import HuyaLoginResult
@@ -91,6 +92,16 @@ class TestHuyaAppLogin:
wup_b64 = env.wup_b64() wup_b64 = env.wup_b64()
assert len(wup_b64) > 0 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): def test_device_profile_generation(self):
p1 = generate_profile() p1 = generate_profile()
assert p1["os"] == "android" assert p1["os"] == "android"
@@ -99,12 +110,34 @@ class TestHuyaAppLogin:
assert p1["hdid"] == "ed0db8334cadd236c00cadf7e11ab5a5" assert p1["hdid"] == "ed0db8334cadd236c00cadf7e11ab5a5"
# 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发 # 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发
assert "safedeviceid" not in p1 assert "safedeviceid" not in p1
assert "safedeviceid" not in DEFAULT_GOLDEN_DEV
p2 = get_profile("test_user_account_123") p2 = get_profile("test_user_account_123")
p3 = get_profile("test_user_account_123") p3 = get_profile("test_user_account_123")
assert p2["fingerprint"] == p3["fingerprint"] 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): def test_force_new_device_clears_hydevice_state(self, tmp_path, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"core.huya.device_fingerprint.FP_STATE_ROOT", tmp_path / "fp" "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"][2] == new_action
assert captured["args"][7]["device_id"] == new_device_id assert captured["args"][7]["device_id"] == new_device_id
# 画像默认值里的旧 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): def test_wup_login_registration_failure_is_explicit(self):
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。""" """注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""