Files
miband-bot/mi-fitness-python/src/mi_fitness/crypto.py
T
2026-05-24 21:53:04 +03:00

290 lines
8.0 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.
"""小米云服务加密模块。"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import struct
import time
from typing import Any
def _rc4_crypt(key: bytes, data: bytes, *, skip: int = 1024) -> bytes:
"""RC4 加密/解密(带前 N 字节跳过,防止密钥流弱点)。
Args:
key: RC4 密钥。
data: 待加密/解密的数据。
skip: 跳过前 N 字节的密钥流(默认 1024)。
Returns:
加密/解密后的字节数据。
"""
s = list(range(256))
j = 0
# KSA (Key-Scheduling Algorithm)
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
# PRGA (Pseudo-Random Generation Algorithm)
i = 0
j = 0
# 跳过前 skip 字节密钥流
for _ in range(skip):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
# 加密/解密
result = bytearray(len(data))
for idx in range(len(data)):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
result[idx] = data[idx] ^ s[(s[i] + s[j]) & 0xFF]
return bytes(result)
def generate_nonce() -> str:
"""生成请求 nonce。
格式: base64(random_8_bytes + minutes_since_epoch_4bytes_BE)
Returns:
Base64 编码的 nonce 字符串。
"""
random_part = os.urandom(8)
minutes = int(time.time() / 60)
time_part = struct.pack(">I", minutes)
return base64.b64encode(random_part + time_part).decode()
def compute_signed_nonce(ssecurity: str, nonce: str) -> str:
"""计算签名 nonce(用于密钥派生)。
signed_nonce = base64(SHA256(b64decode(ssecurity) + b64decode(nonce)))
Args:
ssecurity: 登录时获取的 ssecuritybase64 编码)。
nonce: 请求 nonce。
Returns:
Base64 编码的 signed_nonce(同时作为 RC4 和 HMAC 的密钥)。
"""
hash_val = hashlib.sha256(base64.b64decode(ssecurity) + base64.b64decode(nonce)).digest()
return base64.b64encode(hash_val).decode()
# endregion
# region 签名生成
def _sha1_b64(message: str) -> str:
"""纯 SHA1 哈希 → Base64 编码。
App 使用 MessageDigest("SHA1") 而非 HMAC 来生成签名。
Args:
message: 待哈希的 UTF-8 字符串。
Returns:
Base64 编码的 SHA1 摘要(28 字符)。
"""
digest = hashlib.sha1(message.encode("utf-8")).digest()
return base64.b64encode(digest).decode()
def _build_sig_message(
method: str,
url_path: str,
params: dict[str, str],
signed_nonce: str,
) -> str:
"""构建签名消息字符串(z94.b 格式)。
格式: METHOD&/path&k1=v1&k2=v2&...&signedNonce_b64
- method 大写
- path 带前导 /
- params 按 key 字典序(TreeMap)排序
- 最后追加 signedNonce 的 base64 字符串
Args:
method: HTTP 方法。
url_path: URL 路径(须含前导 /)。
params: 参数字典(已排除空 key/value)。
signed_nonce: Base64 编码的 signed_nonce。
Returns:
用 & 连接的签名消息。
"""
parts: list[str] = [method.upper()]
if not url_path.startswith("/"):
url_path = "/" + url_path
parts.append(url_path)
for k in sorted(params.keys()):
parts.append(f"{k}={params[k]}")
parts.append(signed_nonce)
return "&".join(parts)
def _rc4_stream_encrypt_values(
key_bytes: bytes,
sorted_entries: list[tuple[str, str]],
) -> dict[str, str]:
"""用连续 RC4 流加密多个值。
模拟 App 中 d8k 的行为:构造时 drop 1024 字节,
然后对 TreeMap 中每个 entry 的 value 按排序顺序
依次加密,共用同一个 RC4 密钥流。
Args:
key_bytes: RC4 密钥(signed_nonce 的原始字节)。
sorted_entries: 按 key 排序的 (key, value) 对列表。
Returns:
{key: base64(encrypted_value)} 字典。
"""
# 将所有 value 拼接,一次性过 RC4 流
all_bytes = b"".join(v.encode("utf-8") for _, v in sorted_entries)
encrypted_all = _rc4_crypt(key_bytes, all_bytes, skip=1024)
result: dict[str, str] = {}
pos = 0
for k, v in sorted_entries:
vlen = len(v.encode("utf-8"))
result[k] = base64.b64encode(encrypted_all[pos : pos + vlen]).decode()
pos += vlen
return result
# endregion
# region 数据加密/解密
def encrypt_data(signed_nonce: str, plaintext: str) -> str:
"""用 RC4 加密数据。
Args:
signed_nonce: 密钥(base64 编码的 SHA256 哈希)。
plaintext: 明文 JSON 字符串。
Returns:
Base64 编码的密文。
"""
key = base64.b64decode(signed_nonce)
encrypted = _rc4_crypt(key, plaintext.encode("utf-8"))
return base64.b64encode(encrypted).decode()
def decrypt_data(signed_nonce: str, ciphertext_b64: str) -> str:
"""用 RC4 解密数据。
Args:
signed_nonce: 密钥(base64 编码的 SHA256 哈希)。
ciphertext_b64: Base64 编码的密文。
Returns:
解密后的明文字符串。
"""
key = base64.b64decode(signed_nonce)
decrypted = _rc4_crypt(key, base64.b64decode(ciphertext_b64))
return decrypted.decode("utf-8")
# endregion
# region 封装:构建加密请求参数
def build_encrypted_params(
method: str,
url_path: str,
ssecurity: str,
params: dict[str, Any] | None = None,
) -> dict[str, str]:
"""构建完整的加密请求参数。
流程(对应 App 中 ua4.c 方法):
1. 计算 signed_nonce = base64(SHA256(ssecurity + nonce))
2. 构建原始参数 TreeMap(排除空 key/value
3. rc4_hash__ = SHA1(METHOD&/path&k=v&...&signedNonce) → base64
4. 将 rc4_hash__ 加入 TreeMap
5. 用连续 RC4 流加密所有 TreeMap 值(按 key 排序,drop 1024
6. signature = SHA1(METHOD&/path&k=enc_v&...&signedNonce) → base64
7. 返回 {加密后各参数, signature, _nonce}
Args:
method: HTTP 方法。
url_path: API 路径(如 /app/v1/relatives/get_relative_list)。
ssecurity: 登录时获取的 ssecurity。
params: 要发送的参数字典(将被 JSON 序列化后加密)。
Returns:
包含 data, signature, rc4_hash__, _nonce 的参数字典。
"""
nonce = generate_nonce()
snonce = compute_signed_nonce(ssecurity, nonce)
snonce_bytes = base64.b64decode(snonce)
# Step 1: 构建原始参数 TreeMap(排除空 key/value
raw_tree: dict[str, str] = {}
if params:
plaintext = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
raw_tree["data"] = plaintext
# Step 2: 计算 rc4_hash__(基于原始参数)
rc4_msg = _build_sig_message(method, url_path, raw_tree, snonce)
rc4_hash_raw = _sha1_b64(rc4_msg)
# Step 3: 将 rc4_hash__ 插入 TreeMap
raw_tree["rc4_hash__"] = rc4_hash_raw
# Step 4: 用连续 RC4 流加密所有值
sorted_entries = sorted(raw_tree.items())
encrypted_values = _rc4_stream_encrypt_values(snonce_bytes, sorted_entries)
# Step 5: 构建加密后参数 TreeMap,计算 signature
sig_msg = _build_sig_message(method, url_path, encrypted_values, snonce)
signature = _sha1_b64(sig_msg)
# Step 6: 组装最终结果
result: dict[str, str] = {}
for k, v in encrypted_values.items():
result[k] = v
result["signature"] = signature
result["_nonce"] = nonce
return result
def decrypt_response(
ssecurity: str,
nonce: str,
ciphertext_b64: str,
) -> Any:
"""解密 API 响应。
Args:
ssecurity: 登录时获取的 ssecurity。
nonce: 请求时使用的 nonce。
ciphertext_b64: Base64 编码的响应密文。
Returns:
解密后的 JSON 对象。
"""
snonce = compute_signed_nonce(ssecurity, nonce)
plaintext = decrypt_data(snonce, ciphertext_b64)
try:
return json.loads(plaintext)
except json.JSONDecodeError:
# 可能不是 JSON,返回原始字符串
return plaintext
# endregion