392 lines
12 KiB
Python
392 lines
12 KiB
Python
"""PrivacyKit / WeaponHI 风控票据相关工具。
|
||
|
||
已确认静态链路:
|
||
|
||
``WeaponHI.dd(21)``
|
||
-> 读取内存缓存或 SharedPreferences ``wcfg["a_y_q_z"]``
|
||
|
||
``WeaponHI.b(str)``
|
||
-> GZIP(str.getBytes())
|
||
-> ``MXSec.atlasEncrypt("privacykit", UUID, 0, gzipBytes)``
|
||
-> native ``10400``
|
||
-> Base64.NO_WRAP
|
||
|
||
注意:``weaponhi_vimg_upload`` 复现的是 ``WeaponHI.b`` 的另一条上传链,
|
||
不等同于登录票据。登录请求里的 ``VIMG_<base64>$AI_<32hex>`` 由
|
||
``Engine.pr(99999, 0, ...)`` 在本地生成,纯 Python 实现在 ``weapon_vimg``。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import gzip
|
||
import hashlib
|
||
from dataclasses import asdict, dataclass
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from typing import Any, Mapping
|
||
|
||
from .enc_data import (
|
||
KWSG_10400_DEFAULT_CFG9,
|
||
ZT_OUTER_CONFIGS,
|
||
kwsg_10400_raw,
|
||
load_kwsg_10400_tables,
|
||
parse_inner_zt_header,
|
||
zt_outer_unwrap,
|
||
)
|
||
from .weapon_vimg import (
|
||
decode_passport_account_image_payload,
|
||
generate_passport_account_image,
|
||
)
|
||
|
||
|
||
PRIVACYKIT_PRODUCT = "privacykit"
|
||
PRIVACYKIT_SDK_ID = "7e46b28a-8c93-4940-8238-4c60e64e3c81"
|
||
PASSPORT_ACCOUNT_IMAGE_PREFIX = "VIMG_"
|
||
PASSPORT_ACCOUNT_IMAGE_AI_MARKER = "$AI_"
|
||
PASSPORT_WCFG_KEY = "a_y_q_z"
|
||
WEAPONHI_IMG_INITIAL = "R_I_N_I"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PassportImageInfo:
|
||
"""``passport_account_image`` 的可诊断摘要。"""
|
||
|
||
ok: bool
|
||
has_vimg_prefix: bool
|
||
has_ai_hash: bool
|
||
base64_len: int
|
||
raw_len: int
|
||
raw_head_hex: str
|
||
format_kind: str
|
||
sdk_id: str = ""
|
||
ai_hash: str = ""
|
||
ai_matches_md5_raw: bool = False
|
||
ai_matches_md5_base64: bool = False
|
||
ai_matches_local: bool = False
|
||
inner_magic_hex: str = ""
|
||
inner_header_size: int = 0
|
||
inner_payload_len: int = 0
|
||
error: str = ""
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return asdict(self)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class WeaponHiDd21Result:
|
||
"""``WeaponHI.dd(21)`` 的纯 Python 状态机结果。
|
||
|
||
静态 smali 对齐:
|
||
|
||
- ``WeaponHI.img`` 初值是 ``R_I_N_I``。
|
||
- 若 ``img.startsWith("VIMG_")``,``dd`` 直接返回内存缓存。
|
||
- 否则读取 ``wcfg["a_y_q_z"]``,默认值为旧 ``img``,并写回 ``img``。
|
||
|
||
``wcfg`` 只承担本地持久化;票据内容可由 ``weapon_mf`` 和
|
||
``weapon_vimg`` 现场纯算,不依赖服务端回写。
|
||
"""
|
||
|
||
value: str
|
||
source: str
|
||
mtype: int
|
||
cache_before: str
|
||
cache_after: str
|
||
wcfg_key: str
|
||
has_wcfg_value: bool
|
||
is_final_ticket: bool
|
||
is_upload_vimg: bool
|
||
diagnosis: dict[str, Any]
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
return bool(self.value and self.value.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX))
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
out = asdict(self)
|
||
out["ok"] = self.ok
|
||
return out
|
||
|
||
|
||
def java_gzip(data: bytes) -> bytes:
|
||
"""生成接近 Java ``GZIPOutputStream`` 的 gzip bytes。
|
||
|
||
Java 样本头固定为 ``1f8b08000000000000ff``:
|
||
- mtime = 0
|
||
- XFL = 0(默认压缩级别)
|
||
- OS = 255
|
||
"""
|
||
|
||
buf = BytesIO()
|
||
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=6, mtime=0) as gz:
|
||
gz.write(bytes(data))
|
||
out = buf.getvalue()
|
||
if len(out) >= 10 and out[:3] == b"\x1f\x8b\x08":
|
||
out = out[:8] + b"\x00\xff" + out[10:]
|
||
return out
|
||
|
||
|
||
def _tables_or_load(
|
||
t1: bytes | None,
|
||
t2: bytes | None,
|
||
*,
|
||
t1_path: str | Path = "bin/kwsg_10400_T1.bin",
|
||
t2_path: str | Path = "bin/kwsg_10400_T2.bin",
|
||
) -> tuple[bytes, bytes]:
|
||
if t1 is None and t2 is None:
|
||
return load_kwsg_10400_tables(t1_path, t2_path)
|
||
if t1 is None or t2 is None:
|
||
raise ValueError("t1 and t2 must be provided together")
|
||
return t1, t2
|
||
|
||
|
||
def privacykit_atlas_encrypt_raw(
|
||
payload: bytes,
|
||
*,
|
||
sdk_id: str = PRIVACYKIT_SDK_ID,
|
||
epoch_seconds: int | None = None,
|
||
cfg9: bytes = KWSG_10400_DEFAULT_CFG9,
|
||
t1: bytes | None = None,
|
||
t2: bytes | None = None,
|
||
) -> bytes:
|
||
"""复现 ``atlasEncrypt("privacykit", sdk_id, 0, payload)`` 的 10400 raw。"""
|
||
|
||
if not isinstance(payload, (bytes, bytearray)):
|
||
raise TypeError("payload must be bytes")
|
||
if sdk_id not in ZT_OUTER_CONFIGS:
|
||
raise ValueError(f"unknown privacykit sdk_id: {sdk_id}")
|
||
if len(cfg9) != 9:
|
||
raise ValueError("cfg9 must be exactly 9 bytes")
|
||
|
||
table1, table2 = _tables_or_load(t1, t2)
|
||
return kwsg_10400_raw(
|
||
bytes(payload),
|
||
sdk_id,
|
||
table1,
|
||
table2,
|
||
epoch_seconds=epoch_seconds,
|
||
cfg9=cfg9,
|
||
)
|
||
|
||
|
||
def weaponhi_b(
|
||
value: str,
|
||
*,
|
||
sdk_id: str = PRIVACYKIT_SDK_ID,
|
||
epoch_seconds: int | None = None,
|
||
cfg9: bytes = KWSG_10400_DEFAULT_CFG9,
|
||
t1: bytes | None = None,
|
||
t2: bytes | None = None,
|
||
) -> str:
|
||
"""复现 ``WeaponHI.b(str)``:gzip -> privacykit 10400 -> Base64.NO_WRAP。"""
|
||
|
||
payload = java_gzip(str(value).encode("utf-8"))
|
||
raw = privacykit_atlas_encrypt_raw(
|
||
payload,
|
||
sdk_id=sdk_id,
|
||
epoch_seconds=epoch_seconds,
|
||
cfg9=cfg9,
|
||
t1=t1,
|
||
t2=t2,
|
||
)
|
||
return base64.b64encode(raw).decode("ascii")
|
||
|
||
|
||
def weaponhi_vimg_upload(value: str, **kwargs: Any) -> str:
|
||
"""构造上传态 ``VIMG_`` 值。
|
||
|
||
该值用于对齐 ``WeaponHI.b`` 上游加密积木,不等同于登录请求中的
|
||
``Engine.pr`` 票据。
|
||
"""
|
||
|
||
return PASSPORT_ACCOUNT_IMAGE_PREFIX + weaponhi_b(value, **kwargs)
|
||
|
||
|
||
def _mapping_get(mapping: Mapping[str, Any] | dict[str, Any] | None, key: str, default: str) -> str:
|
||
if mapping is None:
|
||
return default
|
||
try:
|
||
value = mapping.get(key, default) # type: ignore[attr-defined]
|
||
except AttributeError:
|
||
return default
|
||
if value is None:
|
||
return default
|
||
return str(value)
|
||
|
||
|
||
def weaponhi_dd21_from_wcfg(
|
||
wcfg: Mapping[str, Any] | dict[str, Any] | None,
|
||
*,
|
||
img_cache: str = WEAPONHI_IMG_INITIAL,
|
||
mtype: int = 21,
|
||
) -> WeaponHiDd21Result:
|
||
"""按 smali 复现 ``WeaponHI.dd(21)`` 的读取/缓存语义。
|
||
|
||
参数 ``wcfg`` 是已解析的 SharedPreferences/wcfg 字典;只读取
|
||
``a_y_q_z``。如果当前 ``img_cache`` 已经是 ``VIMG_``,则完全复用缓存,
|
||
不再读取 wcfg。
|
||
"""
|
||
|
||
cache_before = str(img_cache or "")
|
||
if cache_before.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX):
|
||
value = cache_before
|
||
source = "img_cache"
|
||
has_wcfg_value = bool(_mapping_get(wcfg, PASSPORT_WCFG_KEY, ""))
|
||
else:
|
||
wcfg_value = _mapping_get(wcfg, PASSPORT_WCFG_KEY, cache_before)
|
||
value = wcfg_value
|
||
source = f"wcfg.{PASSPORT_WCFG_KEY}" if wcfg_value != cache_before else "default_img_cache"
|
||
has_wcfg_value = wcfg_value != cache_before
|
||
|
||
diagnosis = diagnose_passport_account_image(value) if value else {}
|
||
return WeaponHiDd21Result(
|
||
value=value,
|
||
source=source,
|
||
mtype=int(mtype),
|
||
cache_before=cache_before,
|
||
cache_after=value,
|
||
wcfg_key=PASSPORT_WCFG_KEY,
|
||
has_wcfg_value=has_wcfg_value,
|
||
is_final_ticket=bool(
|
||
value.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX)
|
||
and PASSPORT_ACCOUNT_IMAGE_AI_MARKER in value
|
||
and diagnosis.get("ok")
|
||
),
|
||
is_upload_vimg=bool(
|
||
value.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX)
|
||
and PASSPORT_ACCOUNT_IMAGE_AI_MARKER not in value
|
||
and diagnosis.get("format_kind") == "zt_outer"
|
||
),
|
||
diagnosis=diagnosis,
|
||
)
|
||
|
||
|
||
def _split_passport_value(value: str) -> tuple[str, str]:
|
||
text = str(value or "").strip()
|
||
if text.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX):
|
||
text = text[len(PASSPORT_ACCOUNT_IMAGE_PREFIX):]
|
||
if PASSPORT_ACCOUNT_IMAGE_AI_MARKER in text:
|
||
body, ai_hash = text.split(PASSPORT_ACCOUNT_IMAGE_AI_MARKER, 1)
|
||
return body, ai_hash
|
||
return text, ""
|
||
|
||
|
||
def _known_outer_sdk_id(raw: bytes) -> str:
|
||
for sdk_id, cfg in ZT_OUTER_CONFIGS.items():
|
||
if raw.startswith(cfg["head8"]):
|
||
return sdk_id
|
||
return ""
|
||
|
||
|
||
def parse_passport_account_image(value: str) -> PassportImageInfo:
|
||
"""解析 ``passport_account_image``,返回形态和哈希诊断。
|
||
|
||
已知两类形态:
|
||
- ``zt_outer``:本地 ``WeaponHI.b`` 生成的 ``5a54...`` 外层 ZT 包。
|
||
- ``weapon_pr``:本地 ``Engine.pr`` 生成的 ``VIMG_...$AI_...`` 票据。
|
||
"""
|
||
|
||
original = str(value or "").strip()
|
||
if not original:
|
||
raise ValueError("passport_account_image is empty")
|
||
|
||
has_vimg = original.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX)
|
||
body_b64, ai_hash = _split_passport_value(original)
|
||
raw = base64.b64decode(body_b64, validate=True)
|
||
raw_head_hex = raw[:16].hex()
|
||
md5_raw = hashlib.md5(raw).hexdigest()
|
||
md5_b64 = hashlib.md5(body_b64.encode("ascii")).hexdigest()
|
||
|
||
sdk_id = _known_outer_sdk_id(raw)
|
||
format_kind = "unknown"
|
||
inner_magic_hex = ""
|
||
inner_header_size = 0
|
||
inner_payload_len = 0
|
||
ai_matches_local = False
|
||
|
||
if has_vimg and ai_hash:
|
||
format_kind = "weapon_pr"
|
||
try:
|
||
payload = decode_passport_account_image_payload(original)
|
||
inner_magic_hex = "2d3d00007d01"
|
||
inner_header_size = 8
|
||
inner_payload_len = len(payload.encode("utf-8"))
|
||
ai_matches_local = generate_passport_account_image(payload) == original
|
||
except Exception:
|
||
pass
|
||
elif sdk_id:
|
||
format_kind = "zt_outer"
|
||
try:
|
||
inner = zt_outer_unwrap(raw, ZT_OUTER_CONFIGS[sdk_id]["xor_key"])
|
||
parsed = parse_inner_zt_header(inner)
|
||
inner_magic_hex = parsed["magic"].hex()
|
||
inner_header_size = int(parsed["header_size"])
|
||
inner_payload_len = int(parsed["payload_len"])
|
||
except Exception:
|
||
# 保留 zt_outer 识别结果;inner 解析失败时诊断字段留空。
|
||
pass
|
||
elif raw.startswith(bytes.fromhex("dec0adde")):
|
||
format_kind = "inner_zt"
|
||
try:
|
||
parsed = parse_inner_zt_header(raw)
|
||
inner_magic_hex = parsed["magic"].hex()
|
||
inner_header_size = int(parsed["header_size"])
|
||
inner_payload_len = int(parsed["payload_len"])
|
||
except Exception:
|
||
pass
|
||
|
||
return PassportImageInfo(
|
||
ok=True,
|
||
has_vimg_prefix=has_vimg,
|
||
has_ai_hash=bool(ai_hash),
|
||
base64_len=len(body_b64),
|
||
raw_len=len(raw),
|
||
raw_head_hex=raw_head_hex,
|
||
format_kind=format_kind,
|
||
sdk_id=sdk_id,
|
||
ai_hash=ai_hash,
|
||
ai_matches_md5_raw=bool(ai_hash) and ai_hash.lower() == md5_raw,
|
||
ai_matches_md5_base64=bool(ai_hash) and ai_hash.lower() == md5_b64,
|
||
ai_matches_local=ai_matches_local,
|
||
inner_magic_hex=inner_magic_hex,
|
||
inner_header_size=inner_header_size,
|
||
inner_payload_len=inner_payload_len,
|
||
)
|
||
|
||
|
||
def diagnose_passport_account_image(value: str) -> dict[str, Any]:
|
||
"""JSON-friendly 诊断;解析失败也返回结构化错误。"""
|
||
|
||
try:
|
||
return parse_passport_account_image(value).to_dict()
|
||
except Exception as exc: # noqa: BLE001
|
||
return PassportImageInfo(
|
||
ok=False,
|
||
has_vimg_prefix=str(value or "").startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX),
|
||
has_ai_hash=PASSPORT_ACCOUNT_IMAGE_AI_MARKER in str(value or ""),
|
||
base64_len=0,
|
||
raw_len=0,
|
||
raw_head_hex="",
|
||
format_kind="invalid",
|
||
error=f"{exc.__class__.__name__}: {exc}",
|
||
).to_dict()
|
||
|
||
|
||
__all__ = [
|
||
"PASSPORT_ACCOUNT_IMAGE_AI_MARKER",
|
||
"PASSPORT_ACCOUNT_IMAGE_PREFIX",
|
||
"PASSPORT_WCFG_KEY",
|
||
"PRIVACYKIT_PRODUCT",
|
||
"PRIVACYKIT_SDK_ID",
|
||
"PassportImageInfo",
|
||
"WEAPONHI_IMG_INITIAL",
|
||
"WeaponHiDd21Result",
|
||
"diagnose_passport_account_image",
|
||
"java_gzip",
|
||
"parse_passport_account_image",
|
||
"privacykit_atlas_encrypt_raw",
|
||
"weaponhi_dd21_from_wcfg",
|
||
"weaponhi_b",
|
||
"weaponhi_vimg_upload",
|
||
]
|