357 lines
11 KiB
Python
357 lines
11 KiB
Python
"""Pure-Python KWS/WebWeapon fallback ticket generator.
|
|
|
|
The Nebula H5 bundle initializes WebWeapon roughly as:
|
|
|
|
- product cookie: ``kwpsecproductname=kuaishou-growth``;
|
|
- fallback fingerprint: AES-CBC(``...|nonce8``, key=iv ``K8wm...``)
|
|
wrapped as ``K + b64[:4] + W + b64[4:-2] + F + b64[-2:]``;
|
|
- fallback signature token: random 64-char ``kwssectoken`` plus
|
|
AES-CBC(``...|kwssectoken[:8]``, key=iv ``H4t...``) wrapped with ``S``.
|
|
|
|
When the online Web DFP config succeeds, APP may replace these with
|
|
server-provided ``secToken`` + sign-script output. This module implements the
|
|
local fallback path so a fresh ``ksck`` does not need stale copied KWS cookies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from random import Random
|
|
from typing import Any, Mapping, Protocol
|
|
from urllib.parse import quote
|
|
|
|
from core.h5_kww_alg import kwf_aes_cbc_decrypt, kwf_aes_cbc_encrypt
|
|
|
|
|
|
KWS_PRODUCT_NAME = "kuaishou-growth"
|
|
KWS_FINGERPRINT_KEY = "K8wm5PvY9nX7qJc2"
|
|
KWS_TOKEN_KEY = "H4tL6rNd3vB9xM5k"
|
|
KWS_CONFIG_KEY = "webweaponconfigs"
|
|
KWS_CONFIG_URL = "https://gdfp.gifshow.com/s/w/c"
|
|
KWS_RANDOM_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
KWS_URL_SLICE_LEN = 80
|
|
KWS_SCRIPT_CODE_RE = re.compile(r"^[0-9a-z]{64}$")
|
|
DEFAULT_KWS_SIGN_RUNNER = Path(__file__).with_name("h5_kws_sign.mjs")
|
|
DEFAULT_KWS_SIGN_SCRIPT = Path(__file__).with_name("kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js")
|
|
|
|
|
|
class _RandLike(Protocol):
|
|
def randrange(self, stop: int) -> int: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class H5KwsTicket:
|
|
kwpsecproductname: str
|
|
kwssectoken: str
|
|
kwscode: str
|
|
kwfv1: str
|
|
kww: str
|
|
fingerprint_plain: str
|
|
token_plain: str
|
|
|
|
def cookie_fields(self, *, include_fingerprint: bool = True) -> dict[str, str]:
|
|
fields = {
|
|
"kwpsecproductname": self.kwpsecproductname,
|
|
"kwssectoken": self.kwssectoken,
|
|
"kwscode": self.kwscode,
|
|
}
|
|
if include_fingerprint:
|
|
fields["kwfv1"] = self.kwfv1
|
|
return fields
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class H5KwsConfigRequest:
|
|
product_name: str
|
|
ts: int
|
|
did: str
|
|
plain: str
|
|
data: str
|
|
url: str = KWS_CONFIG_URL
|
|
|
|
def body(self) -> dict[str, str]:
|
|
return {"data": self.data}
|
|
|
|
|
|
def _now_ms() -> int:
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
def _random_text(length: int, rng: _RandLike | None = None) -> str:
|
|
alphabet = KWS_RANDOM_ALPHABET
|
|
if rng is None:
|
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
return "".join(alphabet[rng.randrange(len(alphabet))] for _ in range(length))
|
|
|
|
|
|
def _encode_uri(value: str) -> str:
|
|
"""Match JavaScript ``encodeURI`` for the URL prefix used by WebWeapon."""
|
|
|
|
return quote(str(value), safe=";/?:@&=+$,#-_.!~*'()")
|
|
|
|
|
|
def _encrypt_webweapon_b64(plain: str, key_text: str) -> str:
|
|
key = key_text.encode("utf-8")
|
|
encrypted = kwf_aes_cbc_encrypt(plain.encode("utf-8"), key=key, iv=key)
|
|
return base64.b64encode(encrypted).decode("ascii")
|
|
|
|
|
|
def webweapon_aes_encrypt_b64(
|
|
plain: str,
|
|
key_text: str = KWS_CONFIG_KEY,
|
|
) -> str:
|
|
"""AES-CBC/PKCS7 + Base64 used by WebWeapon config payloads."""
|
|
|
|
return _encrypt_webweapon_b64(str(plain), key_text)
|
|
|
|
|
|
def webweapon_aes_decrypt_b64(
|
|
encrypted_b64: str,
|
|
key_text: str = KWS_CONFIG_KEY,
|
|
) -> str:
|
|
"""Decrypt WebWeapon AES-CBC/PKCS7 Base64 text."""
|
|
|
|
key = key_text.encode("utf-8")
|
|
try:
|
|
encrypted = base64.b64decode(encrypted_b64, validate=True)
|
|
plain = kwf_aes_cbc_decrypt(encrypted, key=key, iv=key)
|
|
except Exception as exc:
|
|
raise ValueError("invalid WebWeapon AES payload") from exc
|
|
return plain.decode("utf-8")
|
|
|
|
|
|
def _wrap_webweapon_value(encrypted_b64: str, marker: str) -> str:
|
|
return f"K{encrypted_b64[:4]}W{encrypted_b64[4:-2]}{marker}{encrypted_b64[-2:]}"
|
|
|
|
|
|
def is_h5_kws_script_code(value: str) -> bool:
|
|
"""Validate the 64-char code emitted by the KWS ``signUrl`` script."""
|
|
|
|
return bool(KWS_SCRIPT_CODE_RE.fullmatch(str(value or "")))
|
|
|
|
|
|
def _short_runner_error(stdout: str, stderr: str) -> str:
|
|
detail = (stderr or stdout or "").strip()
|
|
if len(detail) <= 500:
|
|
return detail
|
|
return detail[:240] + "...(truncated)..." + detail[-240:]
|
|
|
|
|
|
def run_h5_kws_sign_script(
|
|
*,
|
|
script_path: str | Path | None = None,
|
|
runner: str | Path = DEFAULT_KWS_SIGN_RUNNER,
|
|
node_bin: str | None = None,
|
|
timeout: int = 10,
|
|
) -> str:
|
|
"""Run the local KWS ``signUrl`` VM script and return ``kwscode``.
|
|
|
|
This is the script-equivalent path for the online WebWeapon config mode:
|
|
``/s/w/c`` gives ``secToken`` + ``signUrl``; loading that sign script calls
|
|
``window.kwscb(code)``. The Node runner supplies a deterministic browser
|
|
fixture and captures the callback result.
|
|
"""
|
|
|
|
runner_path = Path(runner)
|
|
script = Path(script_path) if script_path is not None else DEFAULT_KWS_SIGN_SCRIPT
|
|
if not runner_path.exists():
|
|
raise FileNotFoundError(f"KWS sign runner not found: {runner_path}")
|
|
if not script.exists():
|
|
raise FileNotFoundError(f"KWS sign script not found: {script}")
|
|
|
|
node = node_bin or os.environ.get("NODE_BIN") or "node"
|
|
proc = subprocess.run(
|
|
[node, str(runner_path), str(script)],
|
|
text=True,
|
|
encoding="utf-8",
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"KWS sign runner failed: {_short_runner_error(proc.stdout, proc.stderr)}")
|
|
try:
|
|
data = json.loads(proc.stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"KWS sign runner returned invalid JSON: {_short_runner_error(proc.stdout, proc.stderr)}") from exc
|
|
if not data.get("ok"):
|
|
raise RuntimeError(f"KWS sign runner failed: {data.get('error')}")
|
|
kwscode = str(data.get("kwscode") or "")
|
|
if not is_h5_kws_script_code(kwscode):
|
|
raise RuntimeError(f"KWS sign runner returned invalid kwscode length/shape: {kwscode!r}")
|
|
return kwscode
|
|
|
|
|
|
def build_h5_kws_script_ticket(
|
|
*,
|
|
sec_token: str,
|
|
kwscode: str | None = None,
|
|
product_name: str = KWS_PRODUCT_NAME,
|
|
script_path: str | Path | None = None,
|
|
runner: str | Path = DEFAULT_KWS_SIGN_RUNNER,
|
|
node_bin: str | None = None,
|
|
timeout: int = 10,
|
|
) -> dict[str, str]:
|
|
"""Build KWS cookie fields from server ``secToken`` and sign script code."""
|
|
|
|
token = str(sec_token or "")
|
|
if not token:
|
|
raise ValueError("sec_token must be non-empty")
|
|
code = kwscode
|
|
if not code:
|
|
script = Path(script_path) if script_path is not None else DEFAULT_KWS_SIGN_SCRIPT
|
|
try:
|
|
from core.h5_kws_vm import kwscode_from_known_h5_kws_script
|
|
|
|
code = kwscode_from_known_h5_kws_script(script)
|
|
except Exception:
|
|
code = None
|
|
if not code:
|
|
code = run_h5_kws_sign_script(
|
|
script_path=script_path,
|
|
runner=runner,
|
|
node_bin=node_bin,
|
|
timeout=timeout,
|
|
)
|
|
if not is_h5_kws_script_code(code):
|
|
raise ValueError("kwscode must be a 64-char lowercase alnum KWS script code")
|
|
return {
|
|
"kwpsecproductname": product_name,
|
|
"kwssectoken": token,
|
|
"kwscode": code,
|
|
}
|
|
|
|
|
|
def build_h5_kws_default_ticket(
|
|
*,
|
|
url: str,
|
|
did: str,
|
|
product_name: str = KWS_PRODUCT_NAME,
|
|
now_ms: int | None = None,
|
|
fingerprint_nonce: str | None = None,
|
|
sec_token: str | None = None,
|
|
rng: Random | None = None,
|
|
) -> H5KwsTicket:
|
|
"""Build WebWeapon ``getDefaultData(true)`` ticket fields.
|
|
|
|
``fingerprint_nonce`` corresponds to the random 8-char suffix used for the
|
|
fallback ``kwfv1``. ``sec_token`` corresponds to the 64-char
|
|
``kwssectoken``; its first 8 chars are folded into ``kwscode``.
|
|
"""
|
|
|
|
ts = _now_ms() if now_ms is None else int(now_ms)
|
|
fp_nonce = fingerprint_nonce if fingerprint_nonce is not None else _random_text(8, rng)
|
|
if len(fp_nonce) != 8:
|
|
raise ValueError("fingerprint_nonce must be 8 characters")
|
|
|
|
token = sec_token if sec_token is not None else _random_text(64, rng)
|
|
if len(token) != 64:
|
|
raise ValueError("sec_token must be 64 characters")
|
|
|
|
url_prefix = _encode_uri(str(url)[:KWS_URL_SLICE_LEN])
|
|
common = f"{url_prefix}|{did}|{product_name}|{ts}|"
|
|
fingerprint_plain = common + fp_nonce
|
|
token_plain = common + token[:8]
|
|
|
|
kwfv1 = _wrap_webweapon_value(
|
|
_encrypt_webweapon_b64(fingerprint_plain, KWS_FINGERPRINT_KEY),
|
|
"F",
|
|
)
|
|
kwscode = _wrap_webweapon_value(
|
|
_encrypt_webweapon_b64(token_plain, KWS_TOKEN_KEY),
|
|
"S",
|
|
)
|
|
return H5KwsTicket(
|
|
kwpsecproductname=product_name,
|
|
kwssectoken=token,
|
|
kwscode=kwscode,
|
|
kwfv1=kwfv1,
|
|
kww=kwfv1,
|
|
fingerprint_plain=fingerprint_plain,
|
|
token_plain=token_plain,
|
|
)
|
|
|
|
|
|
def build_h5_kws_config_request_data(
|
|
*,
|
|
did: str,
|
|
product_name: str = KWS_PRODUCT_NAME,
|
|
ts_ms: int | None = None,
|
|
) -> H5KwsConfigRequest:
|
|
"""Build the encrypted ``/s/w/c`` config POST body.
|
|
|
|
WebWeapon sends compact JSON in this exact key order:
|
|
``productName`` → ``ts`` → ``did``. The JSON string is encrypted with
|
|
AES-CBC where key and IV are both ``webweaponconfigs``.
|
|
"""
|
|
|
|
ts = _now_ms() if ts_ms is None else int(ts_ms)
|
|
payload = {
|
|
"productName": product_name,
|
|
"ts": ts,
|
|
"did": str(did),
|
|
}
|
|
plain = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
return H5KwsConfigRequest(
|
|
product_name=product_name,
|
|
ts=ts,
|
|
did=str(did),
|
|
plain=plain,
|
|
data=webweapon_aes_encrypt_b64(plain, KWS_CONFIG_KEY),
|
|
)
|
|
|
|
|
|
def decrypt_h5_kws_config_response_data_rsp(data_rsp: str) -> dict[str, Any]:
|
|
"""Decrypt the ``dataRsp`` field returned by ``/s/w/c``."""
|
|
|
|
plain = webweapon_aes_decrypt_b64(data_rsp, KWS_CONFIG_KEY)
|
|
try:
|
|
decoded = json.loads(plain)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("WebWeapon config response is not JSON") from exc
|
|
if not isinstance(decoded, dict):
|
|
raise ValueError("WebWeapon config response must be a JSON object")
|
|
return decoded
|
|
|
|
|
|
def decrypt_h5_kws_config_response(response: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Decrypt a full ``/s/w/c`` JSON response object containing ``dataRsp``."""
|
|
|
|
data_rsp = response.get("dataRsp")
|
|
if not isinstance(data_rsp, str) or not data_rsp:
|
|
raise ValueError("WebWeapon config response is missing dataRsp")
|
|
return decrypt_h5_kws_config_response_data_rsp(data_rsp)
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_KWS_SIGN_RUNNER",
|
|
"DEFAULT_KWS_SIGN_SCRIPT",
|
|
"H5KwsTicket",
|
|
"H5KwsConfigRequest",
|
|
"KWS_CONFIG_KEY",
|
|
"KWS_CONFIG_URL",
|
|
"KWS_FINGERPRINT_KEY",
|
|
"KWS_PRODUCT_NAME",
|
|
"KWS_RANDOM_ALPHABET",
|
|
"KWS_SCRIPT_CODE_RE",
|
|
"KWS_TOKEN_KEY",
|
|
"KWS_URL_SLICE_LEN",
|
|
"build_h5_kws_script_ticket",
|
|
"build_h5_kws_config_request_data",
|
|
"build_h5_kws_default_ticket",
|
|
"decrypt_h5_kws_config_response",
|
|
"decrypt_h5_kws_config_response_data_rsp",
|
|
"is_h5_kws_script_code",
|
|
"run_h5_kws_sign_script",
|
|
"webweapon_aes_decrypt_b64",
|
|
"webweapon_aes_encrypt_b64",
|
|
]
|