WechatOnCloud/bridge/woc_bridge/db/decryptor.py

326 lines
11 KiB
Python
Raw Normal View History

"""纯 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 验证
# 参考 wechat-cli-main微信运行时 DB 页面可能因 WAL 并发写入
# 等原因 HMAC 不匹配,但 key 本身正确page1 验证已通过)。
# 逐页 HMAC 验证导致大量页面误判为损坏,实际 AES 解密仍可得到有效数据
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
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)