ksjsb/core/sms_login.py
2026-07-30 20:25:56 +08:00

1582 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""快手极速版 短信登录(纯 Python 复刻)。
基于已还原算法 + 主 app Retrofit 接口 ``zvl/a.java`` 定位的端点:
- ``POST {host}/rest/n/user/requestMobileCode`` 发送短信验证码
- ``POST {host}/rest/n/user/login/mobileVerifyCode`` 验码登录 -> ``LoginUserResponse``
关键:响应是**明文 JSON**,直接含 ``kuaishou.api_st`` / ``kuaishou.h5_st`` /
``kuaishou.api_client_salt`` / ``userInfo``,无需 pfl/kwsg 解密(与运营商
一键登录的加密 ``dataRsp`` 不同)。
请求签名走已还原的 ``sig`` / ``__NS_sig3`` / ``__NS_xfalcon``。
静态定位2026-07-24手机号可登录时APP 发码 type=27验码提交走
``mobileVerifyCode``form 字段是 ``code`` / ``type=27`` / ``isDegraded=false``
并补账号保护字段 ``publicKey`` / ``raw`` / ``secret``。
唯一非 Python 环节:接收短信验证码(由调用方提供)。
待实测确认host / 精确参数放置 / userInfo 字段名):
- ``host``:走 aegon 网关,默认 ``apissl.ksapisrv.com``
可经 ``KS_LOGIN_HOST`` 覆盖。
- ``session_seed``native 在进程启动时由 ``srand(time)`` / ``rand()+1``
生成;同一进程的所有 10418 请求共享 seed 和递增 counter。CLI 会现场复现,
也可经 ``KS_SIG3_SESSION_SEED`` 覆盖以回放旧样本。
"""
from __future__ import annotations
import base64
import gzip
import hashlib
import json
import math
import os
import secrets
import sys
import time
import urllib.parse
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Mapping
from .device_cookie import device_profile_cookie_fields
from .device_profile import DeviceProfile
from .sig import sig as calc_sig
from .sig3 import (
KWSG_10418_DEFAULT_STATE_SOURCE,
Kwsg10418State,
kwsg_10418_digest24_unmix,
load_kwsg_10418_tables,
)
from .tokensig import tokensig as calc_tokensig
from .weapon_kas import build_weapon_signature_input
from .xfalcon import xfalcon_value_from_input_bytes
# 旧捕获样本的进程 seed直接调用单个端点时作为兼容回退。
# CLI 实际运行会生成并传入一份进程级 Kwsg10418State。
LOGIN_SIG3_SESSION_SEED = 0x5D7E742B
DEFAULT_LOGIN_HOST = "https://apissl.ksapisrv.com" # 实测确认2026-07-24路由 /rest/n/user/login
# 当前 APP 启动链路通过 apijsv6 更新匿名 regionapihb6 保留为完整 base
# 配置兼容回退,供主机未返回 region_info 时重试。
DEFAULT_REGION_HOST = "https://apijsv6.ksapisrv.com"
REGION_FULL_CONFIG_HOST = "https://apihb6.ksapisrv.com"
REGION_KEYCONFIG_PATH = "/rest/nebula/system/keyconfig"
REGION_KEYCONFIG_BASE_VERSION = 6
REQUEST_MOBILE_CODE_PATH = "/rest/n/user/requestMobileCode"
ANONYMOUS_TOKEN_PATH = "/rest/zt/pass/refresh/anonymousToken"
# APP 登录页在发码/验码前会先跑手机号预检;真实请求同样被 path 归一到
# ``/rest/nebula/user/mobile/checker`` 并带 ``passport_account_image``。
MOBILE_CHECKER_PATH = "/rest/n/user/mobile/checker"
# 初始短信登录候选端点(登出态 -> 验码 -> LoginUserResponse 会话)。
LOGIN_CODE_PATH = "/rest/n/user/login/mobileVerifyCode"
# 运营商一键登录zvl.a 方法 X返回 LoginUserResponse
QUICK_LOGIN_PATH = "/rest/n/user/login/quickLogin"
CLIENT_KEY = "2ac2a76d"
SIGNATURE_QUERY_KEYS = {"sig", "sig2", "__NS_sig3", "__NStokensig", "__NS_xfalcon"}
# requestMobileCode 的 type 不是验证码位数:
# 27 = 已存在手机号短信登录swl.q0 -> Qh(27)
# 302 = 不可登录手机号注册swl.q0 -> Qh(302) -> register/mobileV2
# 6 = 手机验证/换绑页phoneverify/c -> verify/mobile不是登录拿会话。
LOGIN_SMS_CODE_TYPE = 27
REGISTER_SMS_CODE_TYPE = 302
VERIFY_MOBILE_CODE_TYPE = 6
ACCOUNT_SECURITY_KEY_BITS = 2048
# 14.5.50.11631 匿名登录实测静态协议字段(设备字段由 DeviceProfile 覆盖)。
_STATIC_API_PARAMS: dict[str, str] = {
"kpn": "NEBULA",
"kpf": "ANDROID_PHONE",
"app": "0",
"apptype": "22",
"kcv": "1630",
"thermal": "10000",
"net": "WIFI",
"slh": "0",
"nbh": "0",
"browseType": "3",
"grant_browse_type": "AUTHORIZED",
"userRecoBit": "0",
"iuid": "",
"cdid_tag": "0",
"did_tag": "0",
"keyconfig_state": "2",
"hotfix_ver": "",
"ftt": "bd-T-T",
"earphoneMode": "1",
"android_os": "0",
"language": "zh-cn",
"ud": "0",
"bottom_navigation": "true",
"is_background": "0",
"icaver": "1",
"darkMode": "false",
}
LOGIN_HEADERS: dict[str, str] = {
"User-Agent": "kwai-android",
"Accept-Language": "zh-cn",
"Content-Type": "application/x-www-form-urlencoded",
"Accept-Encoding": "gzip",
"Connection": "keep-alive",
"x-aegon-bussiness-message": '{"is_launch_finished":true}',
"page-code": "PHONE_NUMBER_LOGIN_PAGE",
}
WeaponHeaderProvider = Callable[[str], Mapping[str, str]]
@dataclass
class LoginSession:
"""验证码/一键登录成功后的会话。"""
api_st: str = ""
h5_st: str = ""
client_salt: str = ""
user_id: str = ""
mobile: str = ""
mobile_country_code: str = ""
pass_token: str = ""
quicklogin_token: str = ""
region_ticket: str = ""
raw: dict[str, Any] = field(default_factory=dict)
@property
def ok(self) -> bool:
return bool(self.api_st)
def to_dict(self) -> dict[str, Any]:
return {
"api_st": self.api_st,
"h5_st": self.h5_st,
"client_salt": self.client_salt,
"user_id": self.user_id,
"mobile": self.mobile,
"mobile_country_code": self.mobile_country_code,
"pass_token": self.pass_token,
"quicklogin_token": self.quicklogin_token,
"region_ticket": self.region_ticket,
"raw": self.raw,
}
@dataclass
class RegionTicket:
"""keyconfig 中的区域路由身份。"""
uid: str = ""
name: str = ""
ticket: str = ""
status: int = 0
error: str = ""
@property
def ok(self) -> bool:
return bool(self.ticket)
def to_dict(self) -> dict[str, Any]:
return {
"uid": self.uid,
"name": self.name,
"ticket": self.ticket,
"status": self.status,
"error": self.error,
}
@dataclass(frozen=True)
class _AccountSecurityMaterial:
"""APP ``accountsecurity.f`` 等价的最小密钥材料。"""
n: int
d: int
e: int
key_bytes: int
public_key_der: bytes
_ACCOUNT_SECURITY_MATERIAL: _AccountSecurityMaterial | None = None
# 先做小素数试除,减少 Miller-Rabin 轮次调用成本。
_SMALL_PRIMES = (
3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47,
53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107,
109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167,
173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229,
233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283,
293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359,
367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431,
433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491,
499, 503, 509, 521, 523, 541, 547, 557, 563, 569, 571,
577, 587, 593, 599, 601, 607, 613, 617, 619, 631, 641,
643, 647, 653, 659, 661, 673, 677, 683, 691, 701, 709,
719, 727, 733, 739, 743, 751, 757, 761, 769, 773, 787,
797, 809, 811, 821, 823, 827, 829, 839, 853, 857, 859,
863, 877, 881, 883, 887, 907, 911, 919, 929, 937, 941,
947, 953, 967, 971, 977, 983, 991, 997,
)
def _der_len(length: int) -> bytes:
if length < 0x80:
return bytes([length])
raw = length.to_bytes((length.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(raw)]) + raw
def _der(tag: int, value: bytes) -> bytes:
return bytes([tag]) + _der_len(len(value)) + value
def _der_integer(value: int) -> bytes:
if value == 0:
raw = b"\x00"
else:
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return _der(0x02, raw)
def _rsa_public_key_der(n: int, e: int) -> bytes:
"""生成 Java ``PublicKey.getEncoded()`` 同形态的 X.509 SPKI DER。"""
rsa_public_key = _der(0x30, _der_integer(n) + _der_integer(e))
rsa_encryption_oid = b"\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01"
algorithm = _der(0x30, rsa_encryption_oid + b"\x05\x00")
public_bit_string = _der(0x03, b"\x00" + rsa_public_key)
return _der(0x30, algorithm + public_bit_string)
def _is_probable_prime(value: int, rounds: int = 16) -> bool:
if value < 2:
return False
if value == 2:
return True
if value % 2 == 0:
return False
for prime in _SMALL_PRIMES:
if value == prime:
return True
if value % prime == 0:
return False
d = value - 1
s = 0
while d % 2 == 0:
s += 1
d //= 2
for _ in range(rounds):
a = secrets.randbelow(value - 3) + 2
x = pow(a, d, value)
if x in (1, value - 1):
continue
for _ in range(s - 1):
x = pow(x, 2, value)
if x == value - 1:
break
else:
return False
return True
def _generate_prime(bits: int, e: int) -> int:
while True:
candidate = secrets.randbits(bits) | (1 << (bits - 1)) | 1
# 保证 e 与 phi 分量互素,避免后续求逆失败。
if math.gcd(candidate - 1, e) != 1:
continue
if _is_probable_prime(candidate):
return candidate
def _account_security_material() -> _AccountSecurityMaterial:
"""懒生成/复用账号保护 RSA 密钥。
APP 优先从 AndroidKeyStore 取 ``ks_account_protect_private``;没有时生成
RSA-2048并把 publicKey 作为 X.509 DER Base64 提交。Python 端没有
AndroidKeyStore这里按同协议生成进程级临时密钥。
"""
global _ACCOUNT_SECURITY_MATERIAL
if _ACCOUNT_SECURITY_MATERIAL is not None:
return _ACCOUNT_SECURITY_MATERIAL
e = 65537
prime_bits = ACCOUNT_SECURITY_KEY_BITS // 2
while True:
p = _generate_prime(prime_bits, e)
q = _generate_prime(prime_bits, e)
n = p * q
if q != p and n.bit_length() == ACCOUNT_SECURITY_KEY_BITS:
break
lam = math.lcm(p - 1, q - 1)
d = pow(e, -1, lam)
key_bytes = (n.bit_length() + 7) // 8
_ACCOUNT_SECURITY_MATERIAL = _AccountSecurityMaterial(
n=n,
d=d,
e=e,
key_bytes=key_bytes,
public_key_der=_rsa_public_key_der(n, e),
)
return _ACCOUNT_SECURITY_MATERIAL
def _sha256withrsa_b64(material: _AccountSecurityMaterial, text: str) -> str:
"""Java ``Signature.getInstance("SHA256withRSA")`` 的 PKCS#1 v1.5 输出。"""
digest = hashlib.sha256(text.encode("utf-8")).digest()
digest_info = bytes.fromhex("3031300d060960864801650304020105000420") + digest
padding_len = material.key_bytes - len(digest_info) - 3
if padding_len < 8:
raise ValueError("RSA key too small for SHA256withRSA")
encoded = b"\x00\x01" + (b"\xff" * padding_len) + b"\x00" + digest_info
signature = pow(int.from_bytes(encoded, "big"), material.d, material.n)
return base64.b64encode(signature.to_bytes(material.key_bytes, "big")).decode("ascii")
def build_account_security_fields(*, raw_ms: int | None = None) -> dict[str, str]:
"""构造 ``mobileVerifyCode`` 需要的账号保护字段。
对应 APP
- ``publicKey`` = ``Base64(PublicKey.getEncoded())``
- ``raw`` = ``String.valueOf(System.currentTimeMillis())``
- ``secret`` = ``SHA256withRSA(raw, privateKey)`` 后标准 Base64
"""
material = _account_security_material()
raw = str(raw_ms if raw_ms is not None else int(time.time() * 1000))
return {
"publicKey": base64.b64encode(material.public_key_der).decode("ascii"),
"raw": raw,
"secret": _sha256withrsa_b64(material, raw),
}
def login_api_params(profile: DeviceProfile, *, now_ms: int | None = None) -> dict[str, str]:
"""构造登录请求的设备 + app 参数query
与 ``main.py._api_params()`` 同源:静态协议字段 + ``device_profile_cookie_fields``
覆盖的设备字段did/oDid/rdid/egid/mod/sys/硬件 等)。
"""
if now_ms is None:
now_ms = int(time.time() * 1000)
params: dict[str, str] = dict(_STATIC_API_PARAMS)
profile_fields = device_profile_cookie_fields(profile)
# 两份当前版本 APP 最终登录日志均不在 query 发送这些通用 Cookie 字段。
# client_key/os 由 ParamsInterceptor 放入 form body不能在 query 重复。
for key in ("oaid", "countryCode", "sid", "deviceName"):
profile_fields.pop(key, None)
params.update(profile_fields)
# device_profile_cookie_fields 未覆盖但登录需要的运行期字段
params.setdefault("ver", ".".join(profile.app_version.split(".")[:2]))
params.setdefault("sys", f"ANDROID_{profile.android_release}")
params.setdefault("androidApiLevel", "36" if profile.android_release == "16" else profile.android_release)
params.setdefault("did_gt", profile.runtime_hints.did_gt or str(profile.install_time_ms))
params.setdefault("cold_launch_time_ms", str(profile.cold_launch_time_ms))
return params
def keyconfig_api_params(profile: DeviceProfile, *, now_ms: int | None = None) -> dict[str, str]:
"""构造 ``system/keyconfig`` 的 query 设备参数。
当前 APP 的 keyconfig 请求与登录请求共用大部分设备字段,但
``client_key`` 和 ``os`` 仅出现在 keyconfig query 中。
"""
params = login_api_params(profile, now_ms=now_ms)
params["client_key"] = CLIENT_KEY
params["os"] = "android"
return params
def _merge_extra_query_params(params: dict[str, str], extra_query_params: Mapping[str, str] | None) -> None:
"""合并 APP 抓到的 query 参数,同时丢弃旧签名字段。"""
if not extra_query_params:
return
params.update(_clean_extra_query_params(extra_query_params))
def _clean_extra_query_params(extra_query_params: Mapping[str, str] | None) -> dict[str, str]:
"""清理 APP 抓到的 query 参数,移除旧签名字段。"""
out: dict[str, str] = {}
if not extra_query_params:
return out
for key, value in extra_query_params.items():
key = str(key)
if key in SIGNATURE_QUERY_KEYS or key.startswith("__NS"):
continue
out[key] = "" if value is None else str(value)
return out
def signed_login_url(
path: str,
params: dict[str, str],
body_pairs: list[tuple[str, str]],
*,
state: Kwsg10418State,
base_url: str,
t1: bytes,
t2: bytes,
client_salt: str = "",
) -> str:
"""构造带 ``sig`` / ``__NS_sig3`` / ``__NS_xfalcon`` 的登录 URL。
若提供 ``client_salt``,额外带 ``__NStokensig = SHA256(sig + client_salt)``
(验码登录需要;发码 ``requestMobileCode`` 不需要)。
"""
request_path = _sig3_path(path)
signing = {k: v for k, v in params.items() if k not in SIGNATURE_QUERY_KEYS and not k.startswith("__NS")}
signing.update(dict(body_pairs))
sig_value = calc_sig(signing)
sig3_value = state.sig3_hex(request_path + sig_value, t1=t1, t2=t2)
xfalcon = _xfalcon_from_sig_pair(sig_value, sig3_value)
token_sig = calc_tokensig(sig_value, client_salt) if client_salt else ""
signed = dict(params)
signed.update({"sig": sig_value, "__NS_sig3": sig3_value, "__NS_xfalcon": xfalcon})
if token_sig:
signed["__NStokensig"] = token_sig
return f"{base_url}{request_path}?{urllib.parse.urlencode(signed)}"
def _sig3_path(path: str) -> str:
"""对齐 APP ``u7a.a.a(encodedPath)`` 的 sig3 path 归一规则。
真实请求 URL 仍使用 Retrofit 注解里的 ``/rest/n/...``;只有
``KSecurity.atlasSign(path + sig)`` 前会把部分业务 path 归一到
``/rest/nebula/...``。
"""
if (
"rest/n/sf2020" in path
or "rest/n/sf21" in path
or "/rest/n/livep2p" in path
or "rest/n/mp/" in path
):
return path
if "rest/n/" in path:
return path.replace("rest/n/", "rest/nebula/")
if "rest/system/" in path:
return path.replace("rest/system/", "rest/nebula/system/")
if "rest/user/" in path:
return path.replace("rest/user/", "rest/nebula/user/")
if "rest/photo/" in path:
return path.replace("rest/photo/", "rest/nebula/photo/")
return path
def _xfalcon_from_sig_pair(sig_value: str, sig3_value: str) -> str:
"""对齐 APP ``u0a.p.b(path, sig+sig3)`` 的 KXGS 输入。
静态实现中 path 只用于跳过列表/开关判断,真正传入 KXGS 的 byte[] 是
第二参 ``str2.getBytes()``,也就是 ``sig + __NS_sig3``。
"""
return xfalcon_value_from_input_bytes((sig_value + sig3_value).encode("utf-8"))
def _sig3_state(
session_seed: int | None = None,
shared_state: Kwsg10418State | None = None,
) -> Kwsg10418State:
if shared_state is not None:
return shared_state
seed = session_seed if session_seed is not None else _env_int("KS_SIG3_SESSION_SEED", LOGIN_SIG3_SESSION_SEED)
return Kwsg10418State(session_seed=seed, state_source=KWSG_10418_DEFAULT_STATE_SOURCE)
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name)
if not raw:
return default
return int(raw, 0)
def _base_url() -> str:
return os.environ.get("KS_LOGIN_HOST", DEFAULT_LOGIN_HOST).rstrip("/")
def _post(post_func: Callable[..., Any] | None):
if post_func is None:
import requests
return requests.post
return post_func
def _requests_timeout(timeout: Any) -> Any:
"""把 CLI 的单个秒数转成 requests(connect, read) 超时。
线上网关偶发长时间不回 status line拆成较短连接超时 + 明确读超时,
能避免发码阶段一直卡在 ssl.read()。
"""
if isinstance(timeout, tuple):
return timeout
try:
read_timeout = max(1, int(timeout))
except (TypeError, ValueError):
return timeout
connect_timeout = min(5, read_timeout)
return (connect_timeout, read_timeout)
def build_request_id(*, now_ms: int | None = None, suffix: int | None = None) -> str:
"""构造 APP 网关使用的 13 位毫秒时间戳 + 5 位随机尾号。"""
timestamp = int(time.time() * 1000) if now_ms is None else int(now_ms)
tail = secrets.randbelow(100_000) if suffix is None else int(suffix)
if tail < 0 or tail > 99_999:
raise ValueError("request id suffix must be between 0 and 99999")
return f"{timestamp}{tail:05d}"
def build_login_headers(
base_url: str,
*,
request_id: str | None = None,
weapon_headers: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""构造 APP 最终 OkHttp 登录请求头。"""
headers = dict(LOGIN_HEADERS)
headers["Host"] = urllib.parse.urlparse(base_url).hostname or ""
headers["X-REQUESTID"] = request_id or build_request_id()
if weapon_headers:
for name in ("kaw", "kas"):
value = weapon_headers.get(name)
if value:
headers[name] = str(value)
return headers
def _prepared_request_metadata(prepared: Any) -> dict[str, Any]:
"""提取实际出站请求的脱敏结构,供 705 身份绑定诊断。"""
actual_url = str(getattr(prepared, "url", "") or "")
parsed_url = urllib.parse.urlsplit(actual_url)
query_pairs = urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True)
query = dict(query_pairs)
headers = getattr(prepared, "headers", None)
try:
request_id = str(headers.get("X-REQUESTID") or "") if headers is not None else ""
cookie_header = str(headers.get("Cookie") or "") if headers is not None else ""
except Exception:
request_id = ""
cookie_header = ""
raw_body = getattr(prepared, "body", b"") or b""
if isinstance(raw_body, bytes):
body_text = raw_body.decode("utf-8", errors="replace")
elif isinstance(raw_body, str):
body_text = raw_body
else:
body_text = ""
body_pairs = urllib.parse.parse_qsl(body_text, keep_blank_values=True)
body = dict(body_pairs)
cookie_names = sorted(
{
item.partition("=")[0].strip()
for item in cookie_header.split(";")
if item.partition("=")[0].strip()
}
)
captcha_token = str(body.get("captcha_token") or "")
metadata: dict[str, Any] = {
"prepared": True,
"request_id": request_id,
"path": parsed_url.path,
"prepared_header_names": sorted(str(name) for name in headers) if headers is not None else [],
"query_keys": [key for key, _ in query_pairs],
"body_keys": [key for key, _ in body_pairs],
"cookie_names": cookie_names,
"captcha_token_length": len(captcha_token),
"captcha_token_sha256": (
hashlib.sha256(captcha_token.encode("utf-8")).hexdigest()[:12]
if captcha_token
else ""
),
}
sig3_value = str(query.get("__NS_sig3") or "")
if sig3_value:
try:
parsed_sig3 = kwsg_10418_digest24_unmix(sig3_value)
except (TypeError, ValueError):
pass
else:
metadata["sig3_session_seed"] = int(parsed_sig3["session_seed"])
metadata["sig3_counter"] = int(parsed_sig3["counter"])
return metadata
def _response_http_version(response: Any) -> str:
"""Normalize requests/urllib3 and curl_cffi HTTP version metadata."""
raw_version = getattr(getattr(response, "raw", None), "version", None)
if raw_version is not None:
return {
10: "HTTP/1.0",
11: "HTTP/1.1",
20: "HTTP/2",
30: "HTTP/3",
}.get(raw_version, str(raw_version))
curl_version = getattr(response, "http_version", None)
if curl_version is None:
return ""
version_name = str(getattr(curl_version, "name", "") or "").upper()
if "V1_0" in version_name:
return "HTTP/1.0"
if "V1_1" in version_name:
return "HTTP/1.1"
if "V2" in version_name:
return "HTTP/2"
if "V3" in version_name:
return "HTTP/3"
try:
version_number = int(curl_version)
except (TypeError, ValueError):
return str(curl_version)
return {
1: "HTTP/1.0",
2: "HTTP/1.1",
3: "HTTP/2",
4: "HTTP/2",
5: "HTTP/2",
30: "HTTP/3",
31: "HTTP/3",
}.get(version_number, str(curl_version))
def _cookie_jar_of(post_func: Any) -> Any:
"""Best-effort从 bound post 方法取底层 session 的 cookie jar。
OkHttp4Android10Session 把真实 curl jar 包在 _CurlCookieAdapter._cookies 里;
requests.Session 直接暴露 .cookies。
"""
owner = getattr(post_func, "__self__", None)
if owner is None:
return None
jar = getattr(owner, "cookies", None)
inner = getattr(jar, "_cookies", None)
return inner or jar
def _debug_print_request_cookies(post_func: Any, url: str) -> None:
"""``KS_DEBUG_COOKIES=1`` 时打印会随本次请求出站的 cookie。
curl_cffi 把 cookie 放在 libcurl 引擎里,不写进 ``request.headers["Cookie"]``
所以日志里的 ``cookie_names=-`` 不可信;这里直接从 jar 侧读,并按请求 host
标注哪些会真正被发送SEND还是被域过滤掉skip
"""
if not os.environ.get("KS_DEBUG_COOKIES"):
return
jar = _cookie_jar_of(post_func)
# curl_cffi / httpx 的 Cookies 迭代得到的是 name 字符串;真实 Cookie 对象在 .jar
jar = getattr(jar, "jar", None) or jar
host = urllib.parse.urlsplit(url).hostname or ""
rows: list[tuple[str, str, str, str]] = []
try:
for cookie in jar or []:
name = str(getattr(cookie, "name", "?") or "")
domain = str(getattr(cookie, "domain", "") or "")
path = str(getattr(cookie, "path", "") or "/")
secure = "1" if getattr(cookie, "secure", False) else "0"
rows.append((name, domain, path, secure))
except Exception as exc: # noqa: BLE001
print(f" [dbg-cookie] jar 读取失败: {exc.__class__.__name__}: {exc}", file=sys.stderr)
return
print(f" [dbg-cookie] host={host} jar_size={len(rows)}", file=sys.stderr)
for name, domain, path, secure in sorted(rows, key=lambda r: (r[1], r[0])):
dom = domain.lstrip(".")
will_send = not domain or host == dom or host.endswith("." + dom) or host.endswith(dom)
print(
f" [{'SEND' if will_send else 'skip'}] {name} "
f"domain={domain or '(none)'} path={path} secure={secure}",
file=sys.stderr,
)
def _do_post(
post_func: Callable[..., Any],
url: str,
body: bytes,
*,
base_url: str,
timeout: int,
weapon_header_provider: WeaponHeaderProvider | None = None,
) -> dict[str, Any]:
"""POST 并捕获 status / body / **cookies** / Set-Cookie。
登录会话(api_st/h5_st)可能在响应 body 或 Set-Cookie 头,故全捕获。
"""
import json
request_meta: dict[str, Any] = {
"has_kaw": False,
"has_kas": False,
"header_names": [],
}
try:
weapon_headers = weapon_header_provider(url) if weapon_header_provider else None
headers = build_login_headers(base_url, weapon_headers=weapon_headers)
request_meta = {
"has_kaw": bool(headers.get("kaw")),
"has_kas": bool(headers.get("kas")),
"header_names": sorted(headers),
}
_debug_print_request_cookies(post_func, url)
resp = post_func(url, data=body, headers=headers, timeout=_requests_timeout(timeout))
except Exception as exc: # noqa: BLE001
return {
"status": 0,
"error": f"{exc.__class__.__name__}: {exc}",
"body": None,
"text": "",
"cookies": {},
"set_cookie": [],
"request_meta": request_meta,
}
prepared = getattr(resp, "request", None)
if prepared is not None:
request_meta.update(_prepared_request_metadata(prepared))
http_version = _response_http_version(resp)
if http_version:
request_meta["http_version"] = http_version
text = getattr(resp, "text", "") or ""
status = int(getattr(resp, "status_code", 0) or 0)
body_obj: Any = None
try:
body_obj = json.loads(text)
except Exception:
pass
# okhttp4 传输:协议层手填 Accept-Encoding: gzip 头,但 curl_cffi 的
# accept_encoding=None 不会自动解压响应。大响应(如 mobileVerifyCode 登录体)
# 会被服务端 gzipresp.text 即压缩字节 -> JSON 解析失败 -> body 误判为空、
# 会话 token 被丢弃。这里补 gunzip及 b2a.h 的 XOR 0x2B兜底解码。
if body_obj is None:
content = getattr(resp, "content", None)
if isinstance(content, str):
content = content.encode("utf-8", "replace")
if content:
xor_raw = bytes(b ^ 0x2B for b in content)
candidates: list[bytes] = []
for base in (content, xor_raw):
candidates.append(base)
try:
candidates.append(gzip.decompress(base))
except (OSError, EOFError):
pass
for cand in candidates:
try:
body_obj = json.loads(cand.decode("utf-8", "replace"))
text = cand.decode("utf-8", "replace")
break
except (UnicodeDecodeError, ValueError):
continue
if body_obj is None:
ce = ""
try:
ce = resp.headers.get("Content-Encoding") if hasattr(resp.headers, "get") else ""
except Exception:
ce = ""
sys.stderr.write(
f"[_do_post] body 解析失败 status={status} "
f"content_encoding={ce!r} len={len(content)} "
f"head_hex={content[:48].hex()}\n"
)
# cookiesrequests: RequestsCookieJar兼容 dict-like / 迭代)
cookies: dict[str, str] = {}
jar = getattr(resp, "cookies", None)
if jar is not None:
try:
cookies = dict(jar)
except Exception:
try:
cookies = {c.name: c.value for c in jar} # type: ignore[attr-defined]
except Exception:
pass
# Set-Cookie 原始头
set_cookie: list[str] = []
hdrs = getattr(resp, "headers", None)
if hdrs is not None:
try:
if hasattr(hdrs, "get_list"):
set_cookie = list(hdrs.get_list("Set-Cookie"))
except Exception:
pass
if not set_cookie:
try:
v = hdrs.get("Set-Cookie") if hasattr(hdrs, "get") else None
except Exception:
v = None
if v:
set_cookie = [v]
return {
"status": status,
"error": None,
"body": body_obj,
"text": text,
"cookies": cookies,
"set_cookie": set_cookie,
"request_meta": request_meta,
}
def decode_keyconfig_payload(content: bytes | bytearray | str) -> dict[str, Any]:
"""解析 keyconfig 响应:明文 JSON 或 APP 使用的逐字节 XOR ``0x2B``。"""
raw = content.encode("utf-8") if isinstance(content, str) else bytes(content)
candidates: list[bytes] = [raw]
try:
candidates.append(gzip.decompress(raw))
except (OSError, EOFError):
pass
xor_raw = bytes(value ^ 0x2B for value in raw)
candidates.append(xor_raw)
try:
candidates.append(gzip.decompress(xor_raw))
except (OSError, EOFError):
pass
# curl_cffi okhttp4 profile 不会自动解 ``Content-Encoding: gzip``,而 APP 是
# OkHttp 透明解 gzip 后再由 b2a.h 拦截器做 XOR 0x2B。所以服务端实际发的是
# ``gzip(xor_0x2B(json))``:必须先 gunzip 再 XOR上面的候选缺少这一顺序。
try:
gunzipped = gzip.decompress(raw)
except (OSError, EOFError):
gunzipped = b""
if gunzipped:
candidates.append(gunzipped)
candidates.append(bytes(value ^ 0x2B for value in gunzipped))
for candidate in candidates:
try:
decoded = json.loads(candidate.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
continue
if isinstance(decoded, dict):
return decoded
if os.environ.get("KS_DEBUG_KEYCONFIG"):
head_hex = raw[:64].hex()
head_ascii = "".join(chr(b) if 32 <= b < 127 else "." for b in raw[:64])
sys.stderr.write(
f"[dbg-keyconfig] decode failed len={len(raw)} "
f"head_hex={head_hex} head_ascii={head_ascii!r}\n"
)
raise ValueError("keyconfig response is neither JSON nor XOR-0x2B JSON")
def _extract_region_mapping(payload: Any) -> Mapping[str, Any] | None:
if not isinstance(payload, (dict, list)):
return None
if isinstance(payload, dict):
base = payload.get("base")
if isinstance(base, dict):
schedule = base.get("schedule")
if isinstance(schedule, dict):
region_info = schedule.get("region_info")
if isinstance(region_info, dict) and isinstance(region_info.get("region"), dict):
return region_info["region"]
region = payload.get("region")
if isinstance(region, dict) and str(region.get("ticket") or "").startswith("RT_"):
return region
for value in payload.values():
found = _extract_region_mapping(value)
if found is not None:
return found
return None
for value in payload:
found = _extract_region_mapping(value)
if found is not None:
return found
return None
def extract_region_ticket(payload: Any, *, status: int = 200) -> RegionTicket:
"""从完整或差量 keyconfig JSON 中提取 ``region_info.region``。"""
region = _extract_region_mapping(payload)
if region is None:
return RegionTicket(status=status, error="keyconfig response has no region ticket")
ticket = str(region.get("ticket") or "")
if not ticket.startswith("RT_"):
return RegionTicket(status=status, error="keyconfig region ticket has invalid format")
return RegionTicket(
uid=str(region.get("uid") if region.get("uid") is not None else ""),
name=str(region.get("name") or ""),
ticket=ticket,
status=status,
)
def refresh_region_ticket(
profile: DeviceProfile,
*,
api_st: str = "",
client_salt: str = "",
user_id: str = "",
base_url: str | None = None,
keyconfig_version: int = REGION_KEYCONFIG_BASE_VERSION,
extra_query_params: Mapping[str, str] | None = None,
timeout: int = 20,
get_func: Callable[..., Any] | None = None,
) -> RegionTicket:
"""从 keyconfig 在线获取当前设备/账号对应的 ``region_ticket``。
使用完整 ``base`` 配置路径,响应按 APP ``b2a.h`` 的 XOR ``0x2B``
拦截器解码。该端点在实测请求中只带 ``sig``;登录态额外带
``__NStokensig``,不带 ``__NS_sig3``。
"""
region_base = (base_url or os.environ.get("KS_REGION_HOST") or DEFAULT_REGION_HOST).rstrip("/")
params = keyconfig_api_params(profile)
_merge_extra_query_params(params, extra_query_params)
params.update(
{
"ud": str(user_id or "0"),
"kcv": str(keyconfig_version),
"keyconfig_state": "1",
"keyConfigVersion": str(keyconfig_version),
"updatedKeyConfigKey": "base",
"diffInfo": "",
"ts": "0",
"apiInvokeTiming": "LOGIN" if api_st else "COLD_START",
"is_background": "0",
"cs": "false",
"language": "zh-cn",
}
)
for stale_auth_key in ("userId", "pUid", "kuaishou.api_st", "kuaishou.h5_st", "token"):
params.pop(stale_auth_key, None)
if api_st:
params["kuaishou.api_st"] = api_st
sig_value = calc_sig(params)
params["sig"] = sig_value
# keyconfig 在 APP 的签名跳过列表中,抓包表现为保留空 xfalcon 且无 sig3。
params["__NS_xfalcon"] = ""
if api_st and client_salt:
params["__NStokensig"] = calc_tokensig(sig_value, client_salt)
url = f"{region_base}{REGION_KEYCONFIG_PATH}?{urllib.parse.urlencode(params)}"
headers = {
"Host": urllib.parse.urlparse(region_base).hostname or "",
"User-Agent": LOGIN_HEADERS["User-Agent"],
"Accept-Language": LOGIN_HEADERS["Accept-Language"],
"Accept-Encoding": "gzip",
"Connection": "close",
}
if get_func is None:
import requests
get_func = requests.get
try:
response = get_func(url, headers=headers, timeout=_requests_timeout(timeout))
except Exception as exc: # noqa: BLE001
return RegionTicket(error=f"{exc.__class__.__name__}: {exc}")
status = int(getattr(response, "status_code", 0) or 0)
content = getattr(response, "content", None)
if content is None:
content = (getattr(response, "text", "") or "").encode("utf-8")
if status != 200:
return RegionTicket(status=status, error=f"keyconfig HTTP {status}")
try:
payload = decode_keyconfig_payload(content)
except (TypeError, ValueError) as exc:
return RegionTicket(status=status, error=str(exc))
return extract_region_ticket(payload, status=status)
def request_mobile_code(
profile: DeviceProfile,
mobile: str,
*,
mobile_country_code: str = "86",
encrypted_mobile: str = "",
passport_account_image: str = "",
code_type: int = LOGIN_SMS_CODE_TYPE,
use_voice: bool = False,
need_check: bool = True,
prefetch_phone_number: str = "",
request_source: str = "login",
captcha_token: str = "",
base_url: str | None = None,
session_seed: int | None = None,
sig3_state: Kwsg10418State | None = None,
extra_query_params: Mapping[str, str] | None = None,
exact_query_params: bool = False,
t1: bytes | None = None,
t2: bytes | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
weapon_header_provider: WeaponHeaderProvider | None = None,
) -> dict[str, Any]:
"""``POST /rest/n/user/requestMobileCode`` 发送短信验证码。
返回原始响应 JSON``RequestVerifyCodeResponse````{result, isCheck, phone[]}``)。
"""
base_url = (base_url or _base_url()).rstrip("/")
state = _sig3_state(session_seed, sig3_state)
if t1 is None or t2 is None:
t1, t2 = load_kwsg_10418_tables()
if exact_query_params and extra_query_params:
params = _clean_extra_query_params(extra_query_params)
else:
params = login_api_params(profile)
_merge_extra_query_params(params, extra_query_params)
effective_mobile = encrypted_mobile or os.environ.get("KS_ENCRYPTED_MOBILE") or mobile
passport_image = passport_account_image or os.environ.get("KS_PASSPORT_ACCOUNT_IMAGE", "")
body_pairs = [
("mobileCountryCode", mobile_country_code),
("mobile", effective_mobile),
("type", str(code_type)),
("useVoice", "true" if use_voice else "false"),
("needCheck", "true" if need_check else "false"),
("prefetchPhoneNumber", prefetch_phone_number),
("requestSource", request_source),
]
# jlm.a 在 705 重放时改写原始 FieldMap 追加 token与 mobile_checker/login_by_code 同路径。
if captcha_token:
body_pairs.append(("captcha_token", captcha_token))
if encrypted_mobile or passport_image:
body_pairs.extend(
[
("videoModelCrowdTag", os.environ.get("KS_VIDEO_MODEL_CROWD_TAG", "")),
("os", "android"),
]
)
if passport_image:
body_pairs.append(("passport_account_image", passport_image))
body_pairs.extend(
[
("cs", "false"),
("client_key", CLIENT_KEY),
("uQaTag", os.environ.get("KS_UQA_TAG", "")),
]
)
url = signed_login_url(REQUEST_MOBILE_CODE_PATH, params, body_pairs, state=state, base_url=base_url, t1=t1, t2=t2)
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
return _do_post(
_post(post_func),
url,
body,
base_url=base_url,
timeout=timeout,
weapon_header_provider=weapon_header_provider,
)
def mobile_checker(
profile: DeviceProfile,
mobile: str,
*,
mobile_country_code: str = "86",
encrypted_mobile: str = "",
passport_account_image: str = "",
captcha_token: str = "",
base_url: str | None = None,
session_seed: int | None = None,
sig3_state: Kwsg10418State | None = None,
extra_query_params: Mapping[str, str] | None = None,
exact_query_params: bool = False,
t1: bytes | None = None,
t2: bytes | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
weapon_header_provider: WeaponHeaderProvider | None = None,
) -> dict[str, Any]:
"""``POST /rest/n/user/mobile/checker`` 手机号登录前预检。
APP 在手机号输入/发码前会先走该请求。实测最终形态:
``mobileCountryCode`` / ``mobile`` /
``cs`` / ``client_key`` / ``videoModelCrowdTag`` /
``os`` / ``uQaTag`` / ``passport_account_image``
其中 ``mobile`` 是 ``LoginHelper.b(phone)`` 的密文;
``passport_account_image`` 来自 ``WeaponHI.dd(21)``。
"""
base_url = (base_url or _base_url()).rstrip("/")
state = _sig3_state(session_seed, sig3_state)
if t1 is None or t2 is None:
t1, t2 = load_kwsg_10418_tables()
if exact_query_params and extra_query_params:
params = _clean_extra_query_params(extra_query_params)
else:
params = login_api_params(profile)
_merge_extra_query_params(params, extra_query_params)
effective_mobile = encrypted_mobile or os.environ.get("KS_ENCRYPTED_MOBILE") or mobile
passport_image = passport_account_image or os.environ.get("KS_PASSPORT_ACCOUNT_IMAGE", "")
body_pairs = [
("mobileCountryCode", mobile_country_code),
("mobile", effective_mobile),
]
# jlm.a 在 Retrofit 原始表单上追加 token公共参数拦截器随后才补其余字段。
if captcha_token:
body_pairs.append(("captcha_token", captcha_token))
body_pairs.extend(
[
("cs", "false"),
("client_key", CLIENT_KEY),
("videoModelCrowdTag", os.environ.get("KS_VIDEO_MODEL_CROWD_TAG", "")),
("os", "android"),
("uQaTag", os.environ.get("KS_UQA_TAG", "")),
]
)
if passport_image:
body_pairs.append(("passport_account_image", passport_image))
url = signed_login_url(MOBILE_CHECKER_PATH, params, body_pairs, state=state, base_url=base_url, t1=t1, t2=t2)
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
return _do_post(
_post(post_func),
url,
body,
base_url=base_url,
timeout=timeout,
weapon_header_provider=weapon_header_provider,
)
def parse_login_user_response(data: Any) -> LoginSession:
"""解析登录响应。会话字段可来自 body``LoginUserResponse.data``)或 Set-Cookie。
``data`` 是 ``_do_post`` 返回的 rich dict``{status, body, text, cookies, set_cookie}``。
"""
sess = LoginSession()
if not isinstance(data, dict):
sess.raw = {"_raw": data}
return sess
sess.raw = data
body = data.get("body") if isinstance(data.get("body"), dict) else {}
cookies = data.get("cookies") or {}
payload = body.get("data") if isinstance(body.get("data"), dict) else body
# api_st / h5_st / client_salt先 body 字段,再 cookie
sess.api_st = str(payload.get("kuaishou.api_st") or cookies.get("kuaishou.api_st") or "")
sess.h5_st = str(payload.get("kuaishou.h5_st") or cookies.get("kuaishou.h5_st") or "")
sess.client_salt = str(payload.get("kuaishou.api_client_salt") or cookies.get("kuaishou.api_client_salt") or "")
sess.mobile = str(payload.get("mobile") or cookies.get("mobile") or "")
sess.mobile_country_code = str(payload.get("mobileCountryCode") or cookies.get("mobileCountryCode") or "")
sess.pass_token = str(payload.get("passToken") or cookies.get("passToken") or "")
sess.quicklogin_token = str(payload.get("quickloginToken") or "")
user_info = payload.get("userInfo") or payload.get("user") or payload.get("multiUserInfo") or {}
if isinstance(user_info, list) and user_info:
user_info = user_info[0]
if isinstance(user_info, dict):
sess.user_id = str(
user_info.get("user_id")
or user_info.get("userId")
or user_info.get("eid")
or user_info.get("uid")
or ""
)
# 没 user_id 时,从 cookie 的 userId 兜底
if not sess.user_id:
sess.user_id = str(payload.get("userId") or payload.get("user_id") or cookies.get("userId") or cookies.get("ud") or "")
response_region = extract_region_ticket(body, status=int(data.get("status") or 0))
if response_region.ok:
sess.region_ticket = response_region.ticket
return sess
def fetch_anonymous_token(
profile: DeviceProfile,
*,
base_url: str | None = None,
session_seed: int | None = None,
sig3_state: Kwsg10418State | None = None,
t1: bytes | None = None,
t2: bytes | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
) -> dict[str, str]:
"""``POST /rest/zt/pass/refresh/anonymousToken`` -> 访客 token登出态用
返回 ``{"visitor_st": ..., "ssecurity": ..., "user_id": ...}``。
该接口保留给 RE-LOGIN/实验路径;当前短信验证码登录分支不再依赖它。
"""
base_url = (base_url or _base_url()).rstrip("/")
state = _sig3_state(session_seed, sig3_state)
if t1 is None or t2 is None:
t1, t2 = load_kwsg_10418_tables()
params = login_api_params(profile)
body_pairs = [
("kuaishou.api.visitor_st", ""),
("cs", "false"),
("client_key", CLIENT_KEY),
("os", "android"),
]
url = signed_login_url(ANONYMOUS_TOKEN_PATH, params, body_pairs, state=state, base_url=base_url, t1=t1, t2=t2)
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
data = _do_post(_post(post_func), url, body, base_url=base_url, timeout=timeout)
body_obj = data.get("body") if isinstance(data, dict) else None
payload = body_obj.get("data") if isinstance(body_obj, dict) and isinstance(body_obj.get("data"), dict) else (body_obj or {})
return {
"visitor_st": str(payload.get("kuaishou.api.visitor_st") or ""),
"ssecurity": str(payload.get("ssecurity") or ""),
"user_id": str(payload.get("userId") or ""),
}
def login_by_code(
profile: DeviceProfile,
mobile: str,
code: str,
*,
mobile_country_code: str = "86",
encrypted_mobile: str = "",
passport_account_image: str = "",
captcha_token: str = "",
login_path: str | None = None,
login_type: int = LOGIN_SMS_CODE_TYPE,
is_degraded: bool = False,
device_name: str | None = None,
prefetch_phone_number: str | None = None,
account_security_fields: Mapping[str, str] | None = None,
include_prefetch_phone_number: bool = True,
visitor_token: str = "",
client_salt: str = "",
base_url: str | None = None,
session_seed: int | None = None,
sig3_state: Kwsg10418State | None = None,
extra_query_params: Mapping[str, str] | None = None,
exact_query_params: bool = False,
t1: bytes | None = None,
t2: bytes | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
weapon_header_provider: WeaponHeaderProvider | None = None,
) -> LoginSession:
"""``POST <login_path>`` 验码登录 -> ``LoginSession``。
APP 已存在手机号短信登录分支:
``code`` / ``mobile`` / ``mobileCountryCode`` / ``type=27`` /
``isDegraded=false``,再由账号保护层补
``deviceName`` / ``deviceMode`` / ``publicKey`` / ``raw`` / ``secret``。
实测 APP 登录链路里 ``mobile`` 可能已由 ``LoginHelper.b`` 加密;
纯 Python 路径可通过 ``encrypted_mobile`` 或 ``KS_ENCRYPTED_MOBILE``
传入抓到/计算出的值。风险 body 参数
``passport_account_image`` 来自 ``WeaponHI.dd(21)``,可通过同名参数
或 ``KS_PASSPORT_ACCOUNT_IMAGE`` 传入。
``visitor_token`` / ``client_salt`` 是旧探测参数,当前分支保留入参但不写
form避免偏离 APP 的 ``mobileVerifyCode`` FieldMap。
成功时 ``LoginSession.ok`` 为 True含 ``api_st`` / ``h5_st`` /
``client_salt`` / ``user_id``。
"""
base_url = (base_url or _base_url()).rstrip("/")
path = login_path or LOGIN_CODE_PATH
request_path = _sig3_path(path)
state = _sig3_state(session_seed, sig3_state)
if t1 is None or t2 is None:
t1, t2 = load_kwsg_10418_tables()
if exact_query_params and extra_query_params:
params = _clean_extra_query_params(extra_query_params)
else:
params = login_api_params(profile)
_merge_extra_query_params(params, extra_query_params)
# APP 的 mobileVerifyCode FieldMap 只在 form body 中放 deviceName/deviceMode。
# 设备画像里带的 query deviceName 会和 body 同名但取值不同,导致签名明文不一致。
params.pop("deviceName", None)
params.pop("deviceMode", None)
device_label = device_name or os.environ.get("KS_DEVICE_NAME") or f"{profile.manufacturer}({profile.model})"
effective_mobile = encrypted_mobile or os.environ.get("KS_ENCRYPTED_MOBILE") or mobile
effective_prefetch_phone_number = mobile if prefetch_phone_number is None else prefetch_phone_number
passport_image = passport_account_image or os.environ.get("KS_PASSPORT_ACCOUNT_IMAGE", "")
account_security = (
{str(k): str(v) for k, v in account_security_fields.items()}
if account_security_fields
else build_account_security_fields()
)
if not all(account_security.get(key) for key in ("raw", "publicKey", "secret")):
raise ValueError("account_security_fields must contain raw/publicKey/secret")
body_pairs = [
("isDegraded", "true" if is_degraded else "false"),
("code", code),
("mobileCountryCode", mobile_country_code),
("deviceMode", device_label),
("mobile", effective_mobile),
("raw", account_security["raw"]),
("publicKey", account_security["publicKey"]),
("secret", account_security["secret"]),
("type", str(login_type)),
("deviceName", device_label),
]
if include_prefetch_phone_number:
body_pairs.insert(5, ("prefetchPhoneNumber", effective_prefetch_phone_number))
# APP 的 705 重试先由 jlm.a 改写原始 FieldMap再经过公共参数拦截器。
if captcha_token:
body_pairs.append(("captcha_token", captcha_token))
body_pairs.extend(
[
("videoModelCrowdTag", os.environ.get("KS_VIDEO_MODEL_CROWD_TAG", "")),
("os", "android"),
]
)
if passport_image:
body_pairs.append(("passport_account_image", passport_image))
body_pairs.extend(
[
# Aegon ParamsInterceptor 会给 FormBody 追加这些公共字段后再签名。
# 一键登录/日志样例均显示它们在 body而不是只靠 query。
("cs", "false"),
("client_key", CLIENT_KEY),
("uQaTag", os.environ.get("KS_UQA_TAG", "")),
]
)
# login/* 接口在 Retrofit 中是 @FieldMap实测 mobileVerifyCode 最终形态:
# URL path 被拦截器归一为 /rest/nebula/...,业务字段留在 form body
# sig/__NS_sig3/__NS_xfalcon 由拦截器写入 URL query。
signing = {k: v for k, v in params.items() if k not in SIGNATURE_QUERY_KEYS and not k.startswith("__NS")}
signing.update(dict(body_pairs))
sig_value = calc_sig(signing)
sig3_value = state.sig3_hex(request_path + sig_value, t1=t1, t2=t2)
xfalcon = _xfalcon_from_sig_pair(sig_value, sig3_value)
signed_params = dict(params)
signed_params.update({"sig": sig_value, "__NS_xfalcon": xfalcon, "__NS_sig3": sig3_value})
if client_salt:
signed_params["__NStokensig"] = calc_tokensig(sig_value, client_salt)
url = f"{base_url}{request_path}?{urllib.parse.urlencode(signed_params)}"
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
data = _do_post(
_post(post_func),
url,
body,
base_url=base_url,
timeout=timeout,
weapon_header_provider=weapon_header_provider,
)
sess = parse_login_user_response(data)
if sess.ok:
if not sess.mobile:
sess.mobile = mobile
if not sess.mobile_country_code:
sess.mobile_country_code = mobile_country_code
return sess
# 初始短信登录候选端点(登出态 -> 验码 -> LoginUserResponse 会话)。
# 一次验证码依次试,失败(result:11/109 一般不消耗码)则换下一个。
def _encode_varint(value: int) -> bytes:
buf = bytearray()
while value > 0x7F:
buf.append((value & 0x7F) | 0x80)
value >>= 7
buf.append(value & 0x7F)
return bytes(buf)
def build_provider_token(
kpn: str = "NEBULA",
did: str = "",
user_id: int = 0,
timestamp_ms: int | None = None,
) -> str:
"""构造运营商一键登录 ``provider_token``protobuf {kpn, did, userId, ts} + base64
实测2026-07-24provider_token = protobuf{
field1(string) = kpn("NEBULA")
field2(string) = did("ANDROID_...")
field3(varint) = userId
field4(varint) = timestamp(ms)
} + base64。不含运营商 gwAuth/accessCode那些在 pre-login 步骤用,拿 userId
**已知 userId 即可纯 Python 构造,不需要运营商 SDK。**
"""
if timestamp_ms is None:
timestamp_ms = int(time.time() * 1000)
buf = b"\x0a" + bytes([len(kpn)]) + kpn.encode("utf-8")
buf += b"\x12" + bytes([len(did)]) + did.encode("utf-8")
buf += b"\x18" + _encode_varint(int(user_id))
buf += b"\x20" + _encode_varint(int(timestamp_ms))
return base64.b64encode(buf).decode("ascii")
def login_by_quick_login(
profile: DeviceProfile,
*,
user_id: int = 0,
provider_token: str = "",
provider: int = 11,
api_st: str = "",
client_salt: str = "",
base_url: str | None = None,
session_seed: int | None = None,
sig3_state: Kwsg10418State | None = None,
t1: bytes | None = None,
t2: bytes | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
) -> LoginSession:
"""``POST /rest/n/user/login/quickLogin`` 运营商一键登录 -> ``LoginSession``。
实测2026-07-24 frida 抓包签名字段sig/sig3/tokensig/xfalcon全部在
**body**(不在 querybody 还需 session_id/uQaTag/videoModelCrowdTag
token 字段名是 ``kuaishou.api_st``(不是 ``token``)。
"""
if not provider_token and user_id:
provider_token = build_provider_token("NEBULA", profile.did, user_id)
if not provider_token:
raise ValueError("需要 user_id 或 provider_token")
base_url = (base_url or _base_url()).rstrip("/")
path = QUICK_LOGIN_PATH
state = _sig3_state(session_seed, sig3_state)
if t1 is None or t2 is None:
t1, t2 = load_kwsg_10418_tables()
params = login_api_params(profile)
# body 业务字段(签名前,不含 sig/__NS*
body_pairs = [
("provider", str(provider)),
("provider_token", provider_token),
("session_id", str(uuid.uuid4())),
("cs", "false"),
("uQaTag", os.environ.get("KS_UQA_TAG", "3#33333333339999999999#DP:3hX9ONf4GgQVINru4wfjCg==#ecBl:33#ecPp:-9#cmNt:-1#cmHs:-5#cmMnsl:-0#cmAu:-3")),
("videoModelCrowdTag", "1_100"),
("os", "android"),
("client_key", CLIENT_KEY),
]
# kuaishou.api_stfresh 登录 = 空串app 清空后无 sessionRE-LOGIN = 已有 api_st
body_pairs.append(("kuaishou.api_st", api_st))
# 计算签名query 设备参数 + body 业务字段,不含 sig/__NS*
signing = {k: v for k, v in params.items() if k not in SIGNATURE_QUERY_KEYS and not k.startswith("__NS")}
signing.update(dict(body_pairs))
sig_value = calc_sig(signing)
sig3_value = state.sig3_hex(_sig3_path(path) + sig_value, t1=t1, t2=t2)
token_sig = calc_tokensig(sig_value, client_salt) if client_salt else ""
xfalcon = _xfalcon_from_sig_pair(sig_value, sig3_value)
# 签名加入 body不是 query
body_pairs.append(("sig", sig_value))
body_pairs.append(("__NS_sig3", sig3_value))
body_pairs.append(("__NStokensig", token_sig)) # fresh=SHA256(sig+""),始终带
body_pairs.append(("__NS_xfalcon", xfalcon))
# URL 只有设备参数(无签名)
url = f"{base_url}{path}?{urllib.parse.urlencode(params)}"
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
data = _do_post(_post(post_func), url, body, base_url=base_url, timeout=timeout)
return parse_login_user_response(data)
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
data = _do_post(_post(post_func), url, body, base_url=base_url, timeout=timeout)
return parse_login_user_response(data)
LOGIN_CODE_CANDIDATES = [
LOGIN_CODE_PATH, # r0APP 短信登录提交分支,返回 LoginUserResponse
]
def login_by_code_try_paths(
profile: DeviceProfile,
mobile: str,
code: str,
*,
paths: list[str] | None = None,
mobile_country_code: str = "86",
encrypted_mobile: str = "",
passport_account_image: str = "",
captcha_token: str = "",
device_name: str | None = None,
prefetch_phone_number: str | None = None,
account_security_fields: Mapping[str, str] | None = None,
include_prefetch_phone_number: bool = True,
login_type: int = LOGIN_SMS_CODE_TYPE,
base_url: str | None = None,
session_seed: int | None = None,
sig3_state: Kwsg10418State | None = None,
extra_query_params: Mapping[str, str] | None = None,
exact_query_params: bool = False,
t1: bytes | None = None,
t2: bytes | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
weapon_header_provider: WeaponHeaderProvider | None = None,
) -> tuple[LoginSession, list[tuple[str, LoginSession]]]:
"""用一个验证码试登录端点,命中(拿到会话)即停。
默认只走 APP 定位到的 ``mobileVerifyCode````paths`` 仅用于显式实验。
返回 (最终 session, [(path, session), ...])。
"""
paths = paths or LOGIN_CODE_CANDIDATES
results: list[tuple[str, LoginSession]] = []
sess = LoginSession()
for path in paths:
sess = login_by_code(
profile, mobile, code,
mobile_country_code=mobile_country_code,
encrypted_mobile=encrypted_mobile,
passport_account_image=passport_account_image,
captcha_token=captcha_token,
device_name=device_name,
prefetch_phone_number=prefetch_phone_number,
account_security_fields=account_security_fields,
include_prefetch_phone_number=include_prefetch_phone_number,
login_path=path,
login_type=login_type,
base_url=base_url,
session_seed=session_seed,
sig3_state=sig3_state,
extra_query_params=extra_query_params,
exact_query_params=exact_query_params,
t1=t1, t2=t2, timeout=timeout, post_func=post_func,
weapon_header_provider=weapon_header_provider,
)
results.append((path, sess))
if sess.ok:
return sess, results
return sess, results
__all__ = [
"CLIENT_KEY",
"ANONYMOUS_TOKEN_PATH",
"ACCOUNT_SECURITY_KEY_BITS",
"DEFAULT_REGION_HOST",
"REGION_FULL_CONFIG_HOST",
"MOBILE_CHECKER_PATH",
"QUICK_LOGIN_PATH",
"REGION_KEYCONFIG_BASE_VERSION",
"REGION_KEYCONFIG_PATH",
"LOGIN_SMS_CODE_TYPE",
"REGISTER_SMS_CODE_TYPE",
"VERIFY_MOBILE_CODE_TYPE",
"build_account_security_fields",
"build_login_headers",
"build_provider_token",
"build_request_id",
"build_weapon_signature_input",
"decode_keyconfig_payload",
"extract_region_ticket",
"fetch_anonymous_token",
"login_by_quick_login",
"LOGIN_CODE_PATH",
"LOGIN_HEADERS",
"LOGIN_SIG3_SESSION_SEED",
"LoginSession",
"REQUEST_MOBILE_CODE_PATH",
"login_api_params",
"login_by_code",
"mobile_checker",
"parse_login_user_response",
"refresh_region_ticket",
"request_mobile_code",
"signed_login_url",
]