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

392 lines
12 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.

"""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",
]