WechatOnCloud/bridge/decryptor.py
Kris a2201d8be3 feat: 新增woc-bridge服务及配套能力
新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能:
1. 新增bridge核心代码与s6服务配置
2. 新增ptrace权限初始化脚本与docker配置
3. 新增M2M鉴权API代理与环境变量配置
4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块
5. 完善docker构建与实例部署配置
2026-07-07 19:04:13 +08:00

346 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.

"""纯 Python 微信 4.x Linux SQLCipher/WCDB 解密器。
参考 wechat-cli-main 项目实现,针对微信 4.x Linux 加密数据库:
- 页面大小 4096 字节
- 第 1 页前 16 字节为 salt
- 保留区 80 字节IV(16) + HMAC-SHA512(64)
- AES-256-CBC 解密
- HMAC-SHA512 页校验
- mac_salt = salt XOR 0x3APBKDF2-HMAC-SHA512 2 轮派生 mac_key
"""
from __future__ import annotations
import hashlib
import hmac
import os
import shutil
import struct
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
# SQLite 文件头
SQLITE_HEADER = b"SQLite format 3\x00"
PAGE_SIZE = 4096
KEY_SIZE = 32
SALT_SIZE = 16
IV_SIZE = 16
HMAC_SIZE = 64 # SHA512 HMAC
RESERVE_SIZE = 80 # IV(16) + HMAC(64)
ROUND_COUNT = 256000 # PBKDF2 迭代轮数(微信 4.x Linux 使用 SHA512
MAC_SALT_XOR = 0x3A # mac_salt = salt ^ 0x3A
# WAL 文件常量
WAL_HEADER_SIZE = 32
WAL_FRAME_HEADER_SIZE = 24
def _derive_enc_key(key_material: bytes, salt: bytes) -> bytes:
"""从 32 字节 key material 派生 AES 加密密钥PBKDF2-SHA512 256000 轮)。"""
return hashlib.pbkdf2_hmac("sha512", key_material, salt, ROUND_COUNT, dklen=KEY_SIZE)
def _derive_mac_key(enc_key: bytes, salt: bytes) -> bytes:
"""派生微信 4.x 页 HMAC 密钥mac_salt = salt XOR 0x3APBKDF2-SHA512 2 轮)。"""
mac_salt = bytes(b ^ MAC_SALT_XOR for b in salt)
return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SIZE)
def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes:
"""计算页面 HMAC-SHA512。
微信 4.x Linux 页 HMAC 计算范围(含 IV
- 第 1 页salt 之后到 HMAC 之前的数据(即 offset=16 到 data_end=4096-80+16=4032
- 其他页:从头开始到 HMAC 之前的数据offset=0 到 data_end=4032
最后追加 4 字节小端页号。
"""
offset = SALT_SIZE if page_num == 1 else 0
data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
mac.update(page[offset:data_end])
mac.update(page_num.to_bytes(4, "little"))
return mac.digest()
def _resolve_page1_key_material(
key_material: bytes, page1: bytes
) -> tuple[bytes, bytes, str] | None:
"""检测输入密钥是否匹配第 1 页 HMAC。
先尝试把输入当作已派生的 enc_key 直接验证;失败则当作 raw key_material
派生 enc_key 后再验证。返回 (enc_key, mac_key, mode) 或 None。
"""
if len(page1) < PAGE_SIZE:
return None
salt = page1[:SALT_SIZE]
stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE]
# 尝试 1输入就是 enc_key
mac_key = _derive_mac_key(key_material, salt)
if hmac.compare_digest(stored_page1_hmac, _compute_page_hmac(mac_key, page1, 1)):
return key_material, mac_key, "enc_key"
# 尝试 2输入是 raw key_material需派生 enc_key
enc_key = _derive_enc_key(key_material, salt)
mac_key = _derive_mac_key(enc_key, salt)
if hmac.compare_digest(stored_page1_hmac, _compute_page_hmac(mac_key, page1, 1)):
return enc_key, mac_key, "key_material"
return None
def _decrypt_page(enc_key: bytes, page: bytes, page_num: int) -> bytes:
"""使用 AES-256-CBC 解密单页。
明文 SQLite 页不携带加密保留区,通过零填充保留页大小不变。
"""
iv = page[PAGE_SIZE - RESERVE_SIZE: PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
offset = SALT_SIZE if page_num == 1 else 0
encrypted_page = page[offset: PAGE_SIZE - RESERVE_SIZE]
cipher = Cipher(
algorithms.AES(enc_key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
if page_num == 1:
return SQLITE_HEADER + decrypted_page + (b"\x00" * RESERVE_SIZE)
return decrypted_page + (b"\x00" * RESERVE_SIZE)
def decrypt_wal(enc_key: bytes, wal_path: str, output_db_path: str) -> int:
"""把加密的 WAL 文件合并到已解密的明文 DB 中。
微信 4.x Linux 使用 SQLCipher WAL 格式,每帧结构为:
- frame header (24 bytes): pgno(4) + commit_size(4) + salt1(4) + salt2(4) + checksum(8)
- frame page (4096 bytes): 加密页数据
只合并与 WAL header 中 salt 匹配的帧,避免应用不完整数据。
Args:
enc_key: 32 字节 AES 加密密钥
wal_path: 加密 WAL 文件路径
output_db_path: 已解密的明文 DB 路径(会被原地 patch
Returns:
int: 成功合并的帧数
"""
if not os.path.exists(wal_path):
return 0
wal_size = os.path.getsize(wal_path)
if wal_size <= WAL_HEADER_SIZE:
return 0
patched = 0
with open(wal_path, "rb") as wf, open(output_db_path, "r+b") as df:
wal_header = wf.read(WAL_HEADER_SIZE)
if len(wal_header) < WAL_HEADER_SIZE:
return 0
wal_salt1 = struct.unpack(">I", wal_header[16:20])[0]
wal_salt2 = struct.unpack(">I", wal_header[20:24])[0]
frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE
while wf.tell() + frame_size <= wal_size:
frame_header = wf.read(WAL_FRAME_HEADER_SIZE)
if len(frame_header) < WAL_FRAME_HEADER_SIZE:
break
pgno = struct.unpack(">I", frame_header[0:4])[0]
frame_salt1 = struct.unpack(">I", frame_header[8:12])[0]
frame_salt2 = struct.unpack(">I", frame_header[12:16])[0]
encrypted_page = wf.read(PAGE_SIZE)
if len(encrypted_page) < PAGE_SIZE:
break
# 过滤异常帧
if pgno == 0 or pgno > 10000000:
continue
# salt 不匹配说明 WAL 已轮换或数据不完整,跳过
if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2:
continue
decrypted_page = _decrypt_page(enc_key, encrypted_page, pgno)
df.seek((pgno - 1) * PAGE_SIZE)
df.write(decrypted_page)
patched += 1
return patched
class Decryptor:
"""微信 4.x Linux SQLCipher/WCDB 数据库解密器。"""
def __init__(self, key_hex: str):
"""初始化解密器key_hex 为 64 位十六进制密钥。
Args:
key_hex: 64 位十六进制字符串32 字节 enc_key 或 raw key_material
Raises:
ValueError: 密钥长度不为 64 或非有效十六进制
"""
if len(key_hex) != 64:
raise ValueError("密钥必须是 64 位十六进制字符串")
try:
self.key_bytes = bytes.fromhex(key_hex)
except ValueError:
raise ValueError("密钥必须是有效的十六进制字符串")
self._key_hex = key_hex
self._enc_key: bytes | None = None
self._mac_key: bytes | None = None
self._key_mode: str = ""
def _resolve(self, page1: bytes) -> bool:
"""用第 1 页解析出 enc_key / mac_key / mode成功返回 True。"""
resolved = _resolve_page1_key_material(self.key_bytes, page1)
if resolved is None:
return False
self._enc_key, self._mac_key, self._key_mode = resolved
return True
def decrypt_db(self, enc_db_path: str, output_path: str) -> dict:
"""解密微信 4.x 加密数据库到明文 SQLite 文件。
采用流式分页读写:
- 仅把当前页保留在内存,不一次性读入整库。
- 明文 SQLite 直接流式复制。
Args:
enc_db_path: 加密 DB 文件路径
output_path: 解密后明文 SQLite 文件路径
Returns:
dict: 解密结果统计信息
"""
result: dict = {
"success": False,
"key_mode": "",
"total_pages": 0,
"successful_pages": 0,
"failed_pages": 0,
"copied_as_sqlite": False,
"error": "",
}
try:
file_size = os.path.getsize(enc_db_path)
except Exception as e:
result["error"] = f"stat_error: {e}"
return result
if file_size < PAGE_SIZE:
result["error"] = "file_too_small"
return result
try:
with open(enc_db_path, "rb") as f:
page1 = f.read(PAGE_SIZE)
except Exception as e:
result["error"] = f"read_error: {e}"
return result
if page1.startswith(SQLITE_HEADER):
try:
with open(enc_db_path, "rb") as src, open(output_path, "wb") as dst:
shutil.copyfileobj(src, dst)
except Exception as e:
result["error"] = f"write_error: {e}"
return result
result["success"] = True
result["copied_as_sqlite"] = True
return result
if not self._resolve(page1):
result["error"] = "key_mismatch"
return result
result["key_mode"] = self._key_mode
total_pages = (file_size + PAGE_SIZE - 1) // PAGE_SIZE
result["total_pages"] = total_pages
successful_pages = 0
failed_pages = 0
try:
with open(enc_db_path, "rb") as src, open(output_path, "wb") as dst:
for page_num in range(1, total_pages + 1):
page = src.read(PAGE_SIZE)
if not page:
break
if len(page) < PAGE_SIZE:
page = page + (b"\x00" * (PAGE_SIZE - len(page)))
# 先验 HMAC不匹配说明密钥错误或该页损坏
stored_hmac = page[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE]
expected_hmac = _compute_page_hmac(self._mac_key, page, page_num)
if not hmac.compare_digest(stored_hmac, expected_hmac):
# 第 1 页 HMAC 失败说明密钥错误,直接返回失败
if page_num == 1:
result["error"] = "page1_hmac_mismatch"
return result
# 其他页失败:写零页跳过,避免污染下游 SQLite
failed_pages += 1
dst.write(b"\x00" * PAGE_SIZE)
continue
try:
dst.write(_decrypt_page(self._enc_key, page, page_num))
successful_pages += 1
except Exception:
failed_pages += 1
dst.write(b"\x00" * PAGE_SIZE)
except Exception as e:
result["error"] = f"write_error: {e}"
return result
result["successful_pages"] = successful_pages
result["failed_pages"] = failed_pages
# 阈值判断:失败率 > 10% 或全部失败,认为整体失败
# 避免极端情况下整库解密全失败仍认为成功,缓存被复用
if total_pages > 0:
failure_ratio = failed_pages / total_pages
if failed_pages == total_pages or failure_ratio > 0.1:
result["success"] = False
result["error"] = (
f"too_many_failed_pages: {failed_pages}/{total_pages} "
f"(ratio={failure_ratio:.2%})"
)
return result
result["success"] = True
return result
def verify_key(self, enc_db_path: str) -> bool:
"""便捷方法:验证 key 是否匹配指定 DB 文件。"""
try:
with open(enc_db_path, "rb") as f:
data = f.read(PAGE_SIZE)
except Exception:
return False
if len(data) < PAGE_SIZE:
return False
if data.startswith(SQLITE_HEADER):
return True
return self._resolve(data)
def decrypt_wal(self, wal_path: str, output_db_path: str) -> int:
"""使用已解析的 enc_key 合并 WAL 到明文 DB。
必须先调用 decrypt_db 或 verify_key 解析出 enc_key。
"""
if self._enc_key is None:
raise RuntimeError("enc_key 尚未解析,请先调用 decrypt_db 或 verify_key")
return decrypt_wal(self._enc_key, wal_path, output_db_path)