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

343 lines
11 KiB
Python
Raw Permalink 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 复现 ``gdfp.gifshow.com/f/a/p`` 设备指纹上报端点。
该端点不是 ``passport_account_image`` 的生成源。登录票据由客户端
``Engine.pr`` 本地生成;本模块仅保留设备遥测协议研究和响应提取工具。
静态定论(``out/jadx/sources/com/kuaishou/weapon/ks/``
签名 ``h1.a(map)`` / ``h1.d(ctx)``
``sign = md5_hex(appkey + secretkey + timestamp)``
query = ``appkey=X&secretkey=Y&timestamp=Z&sign=S``
appkey/secretkey ``t.a(ctx)`` / ``h1.a(ctx)``
优先读 ``wcfg````z0.i()````"appkey-secretkey"`` 拆分);为空回退硬编码
默认值 ``appkey="16"``、``secretkey="62c80c436b7547a68a12774c67519836"``。
URL ``r1.a(payload, ctx)``
``{base}/f/a/p?{query}````x0.d`` = ``Base64("L2YvYS9w")`` = ``"/f/a/p"``。
Body ``r1.a`` 三参路径(注册上传,带 ``VIMG_`` 前缀)
``{"data": "VIMG_" + WeaponHI.b(payload)}``
其中 ``payload`` 是 JSON 字符串(见 ``build_default_payload``)。
响应 ``i.a`` 返回 JSON 字符串。尚无证据表明响应会签发登录使用的
``VIMG_<base64>$AI_<32hex>``;递归提取函数只用于兼容历史样本分析。
两个设备遥测字段仍待确认(代码内以 ``TODO(U1/U2)`` 标注):
U1
payload 明文结构(``build_default_payload`` 的字段集/取值),候选来自
``t.a()`` / ``h1.b()``,需对照抓到的请求 body 校验。
U2
历史响应中若存在 ``a_y_q_z``,确认其字段路径和业务用途。
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import time
import urllib.parse
from dataclasses import dataclass
from typing import Any, Callable, Mapping
from .privacykit_encrypt import (
PASSPORT_ACCOUNT_IMAGE_AI_MARKER,
PASSPORT_ACCOUNT_IMAGE_PREFIX,
weaponhi_vimg_upload,
)
# ---------------------------------------------------------------------------
# 配置(可经环境变量覆盖,与 core/sms_login.py / core/dfp_client.py 风格一致)
# ---------------------------------------------------------------------------
DEFAULT_FAP_HOST = "https://gdfp.gifshow.com"
FAP_PATH = "/f/a/p"
# h1.java:52 硬编码默认值t.a() 返回空时 APP 自身回退用)。
DEFAULT_APPKEY = "16"
DEFAULT_SECRETKEY = "62c80c436b7547a68a12774c67519836"
WEAPON_SDK_VERSION = "7.2.1"
# a_y_q_z 票据形态VIMG_<base64>$AI_<32hex>。递归匹配兜底用。
_TICKET_RE = re.compile(
re.escape(PASSPORT_ACCOUNT_IMAGE_PREFIX)
+ r"[A-Za-z0-9+/]+={0,2}"
+ re.escape(PASSPORT_ACCOUNT_IMAGE_AI_MARKER)
+ r"[0-9a-f]{32}"
)
def _fap_host() -> str:
return os.environ.get("KS_FAP_HOST", DEFAULT_FAP_HOST).rstrip("/")
def _appkey() -> str:
return os.environ.get("KS_FAP_APPKEY", DEFAULT_APPKEY)
def _secretkey() -> str:
return os.environ.get("KS_FAP_SECRETKEY", DEFAULT_SECRETKEY)
# ---------------------------------------------------------------------------
# 签名 / queryh1.java纯 MD5与 atlasSign / kws 无关)
# ---------------------------------------------------------------------------
def build_sign(appkey: str, secretkey: str, timestamp: int) -> str:
"""``md5_hex(appkey + secretkey + timestamp)``。
对照 ``v0.a(String)`` = ``MessageDigest("MD5").digest(str.getBytes())``。
"""
raw = f"{appkey}{secretkey}{timestamp}".encode("utf-8")
return hashlib.md5(raw).hexdigest()
def build_query(appkey: str, secretkey: str, timestamp: int) -> str:
"""``appkey=X&secretkey=Y&timestamp=Z&sign=S````h1.d`` 顺序)。"""
sign = build_sign(appkey, secretkey, timestamp)
# 显式保序,避免依赖 dict/HashMap 迭代顺序。
pairs = [
("appkey", appkey),
("secretkey", secretkey),
("timestamp", str(timestamp)),
("sign", sign),
]
return urllib.parse.urlencode(pairs)
def build_fap_url(timestamp: int | None = None, *, host: str | None = None) -> str:
ts = int(timestamp if timestamp is not None else time.time())
host = host or _fap_host()
return f"{host}{FAP_PATH}?{build_query(_appkey(), _secretkey(), ts)}"
# ---------------------------------------------------------------------------
# Body{"data": "VIMG_" + WeaponHI.b(payload_json)}r1.a 三参路径)
# ---------------------------------------------------------------------------
def build_fap_body(payload: Mapping[str, Any] | str) -> bytes:
"""构造 ``{"data": "VIMG_" + WeaponHI.b(payload_json)}``。
``payload`` 可传 dict自动 ``json.dumps````separators=(',', ':')`` 紧凑)
或已序列化的字符串。返回 UTF-8 JSON bytes。
"""
if isinstance(payload, str):
payload_str = payload
else:
# WeaponHI.b(str) 上游拿的是 str.getBytes()JSON 紧凑序列化对齐 Java 侧 JSONObject。
payload_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
vimg = weaponhi_vimg_upload(payload_str)
return json.dumps({"data": vimg}, separators=(",", ":")).encode("utf-8")
# ---------------------------------------------------------------------------
# payload 明文U1 候选,需抓证校验)
# ---------------------------------------------------------------------------
def build_default_payload(device_info: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""构造 /f/a/p payload 候选结构。
候选字段来自 ``t.a()``userId/platform/channel/mod/globalId/sysver/
rdid/did_tag/cdid_tag与 ``h1.b(ctx)``k/hp/hv/pver/platform/device_id/
sdkver/piv/sysver/mod的并集值从 ``device_info``(通常取自
``out/app_login_fields_latest.json`` 的 query_params 快照)映射。
# TODO(U1):抓证后用真实请求 body 校验字段集/取值/嵌套结构,回填此函数。
"""
info = device_info or {}
get = lambda key, default="": str(info.get(key, default) or default or "")
return {
# t.a() 侧
"userId": "",
"platform": get("kpf", "ANDROID_PHONE"),
"channel": get("c", get("oc", "")),
"mod": get("mod", ""),
"globalId": "",
"sysver": get("sys", ""),
"rdid": get("rdid", ""),
"did_tag": get("did_tag", ""),
"cdid_tag": get("cdid_tag", ""),
# h1.b(ctx) 侧
"k": "",
"hp": get("kpn", "com.kuaishou.nebula"),
"hv": get("appver", ""),
"pver": "0.0.0",
"platform_h1": 1,
"device_id": get("did", get("oDid", "")),
"sdkver": WEAPON_SDK_VERSION,
}
# ---------------------------------------------------------------------------
# 响应解析U2需抓证定位精确字段路径
# ---------------------------------------------------------------------------
def _iter_str_values(node: Any):
"""递归遍历解析后的 JSON产出所有字符串值。"""
if isinstance(node, str):
yield node
elif isinstance(node, Mapping):
for v in node.values():
yield from _iter_str_values(v)
elif isinstance(node, (list, tuple)):
for v in node:
yield from _iter_str_values(v)
def extract_a_y_q_z(response: Any) -> str | None:
"""从 /f/a/p 响应里提取 ``a_y_q_z`` 完整票据。
# TODO(U2):抓证后改为精确字段路径(如 ``response["a_y_q_z"]``)。
当前用递归匹配 ``VIMG_...$AI_...`` 形态兜底,能覆盖顶层/嵌套两种情况。
"""
if response is None:
return None
if isinstance(response, str):
# 响应是裸字符串时直接匹配;否则尝试 JSON 解析后递归。
m = _TICKET_RE.search(response)
if m:
return m.group(0)
try:
response = json.loads(response)
except Exception:
return None
for value in _iter_str_values(response):
m = _TICKET_RE.search(value)
if m:
return m.group(0)
return None
# ---------------------------------------------------------------------------
# HTTP参考 core/dfp_client.post_request 的 requests 范式)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FapResponse:
ok: bool
status_code: int
data: Any
text: str
error: str = ""
def _default_post() -> Callable[..., Any]:
import requests
return requests.post
def call_fap(
payload: Mapping[str, Any] | str,
*,
timestamp: int | None = None,
host: str | None = None,
headers: Mapping[str, str] | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
) -> FapResponse:
"""POST ``/f/a/p`` 并返回解析后的响应。
headers 可传 k1 的 c/d``t.b()`` 设备串 / ``t.b(ctx)`` 加密设备头)。
默认不送;若服务端校验 header 则从抓证固化后传入。
"""
post = post_func or _default_post()
ts = int(timestamp if timestamp is not None else time.time())
url = build_fap_url(ts, host=host)
body = build_fap_body(payload)
hdrs = {"Content-Type": "application/json; charset=utf-8"}
if headers:
hdrs.update(dict(headers))
try:
resp = post(url, data=body, headers=hdrs, timeout=timeout)
except Exception as exc:
return FapResponse(ok=False, status_code=0, data=None, text=str(exc), error=exc.__class__.__name__)
text = getattr(resp, "text", "")
try:
data = resp.json()
except Exception:
try:
data = json.loads(text)
except Exception:
data = text
return FapResponse(
ok=bool(getattr(resp, "ok", False)),
status_code=int(getattr(resp, "status_code", 0)),
data=data,
text=text,
)
# ---------------------------------------------------------------------------
# 顶层入口
# ---------------------------------------------------------------------------
def fetch_fresh_a_y_q_z(
device_info: Mapping[str, Any] | None = None,
*,
payload: Mapping[str, Any] | str | None = None,
host: str | None = None,
headers: Mapping[str, str] | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
) -> str | None:
"""取新鲜 ``a_y_q_z`` 完整票据(``VIMG_...$AI_...``)。
默认用 ``build_default_payload(device_info)`` 组 payload调用方可直接传
``payload``dict 或已序列化字符串)覆盖。失败返回 None由调用方决定回退。
"""
if payload is None:
payload = build_default_payload(device_info)
resp = call_fap(
payload,
host=host,
headers=headers,
timeout=timeout,
post_func=post_func,
)
if not resp.ok:
return None
return extract_a_y_q_z(resp.data)
__all__ = [
"DEFAULT_APPKEY",
"DEFAULT_FAP_HOST",
"DEFAULT_SECRETKEY",
"FAP_PATH",
"FapResponse",
"WEAPON_SDK_VERSION",
"build_default_payload",
"build_fap_body",
"build_fap_url",
"build_query",
"build_sign",
"call_fap",
"extract_a_y_q_z",
"fetch_fresh_a_y_q_z",
]