267 lines
9.6 KiB
Python
267 lines
9.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
wxwork_crypto.py - 企业微信(WXWork)本地数据库解密算法核心
|
||
=============================================================
|
||
对应 wxSQLite3 "aes128cbc" cipher(SQLite3MultipleCiphers 项目 cipher_wxaes128.c / codec_algos.c)
|
||
企微 5.x 使用的加密方案:
|
||
- 算法 : AES-128-CBC(无 HMAC、无每页保留字节)
|
||
- 主密钥 : 16 字节(从 WXWork.exe 进程内存提取,或经 PDF 规范 MD5+RC4 派生)
|
||
- 页面密钥: MD5(master_key || page_le32 || "sAlT") 取 16 字节
|
||
- 每页 IV : 线性同余(页号) → 16 字节 initkey → MD5
|
||
- 页 1 布局: 0..15 加密(不解密) / 16..23 明文头 / 加密字节暂存于 8..15 / 24..end 加密
|
||
|
||
参考实现:
|
||
https://github.com/utelle/SQLite3MultipleCiphers
|
||
src/cipher_wxaes128.c (GenerateKey / EncryptPage / DecryptPage)
|
||
src/codec_algos.c (sqlite3mcAES128 / sqlite3mcGenerateInitialVector / RC4 / MD5)
|
||
|
||
依赖: pycryptodome (AES), hashlib (MD5)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import os
|
||
import sqlite3
|
||
import struct
|
||
import tempfile
|
||
|
||
from Crypto.Cipher import AES
|
||
|
||
SQLITE_FILE_HEADER = b"SQLite format 3\x00"
|
||
MAGIC_SALT = b"sAlT" # 0x73 0x41 0x6c 0x54
|
||
PAGE1_HEADER_CHECK = (0x40, 0x20, 0x20) # dbHeader[5..7] 魔数校验
|
||
|
||
|
||
def generate_iv(page: int) -> bytes:
|
||
"""每页 IV:MODMULT 线性同余 4 轮生成 16 字节 initkey,再 MD5。"""
|
||
z = page + 1
|
||
initkey = bytearray(16)
|
||
for j in range(4):
|
||
# MODMULT(52774, 40692, 3791, 2147483399, s)
|
||
q = z // 52774
|
||
z = 40692 * (z - 52774 * q) - 3791 * q
|
||
if z < 0:
|
||
z += 2147483399
|
||
initkey[4 * j + 0] = 0xFF & z
|
||
initkey[4 * j + 1] = 0xFF & (z >> 8)
|
||
initkey[4 * j + 2] = 0xFF & (z >> 16)
|
||
initkey[4 * j + 3] = 0xFF & (z >> 24)
|
||
return hashlib.md5(bytes(initkey)).digest()
|
||
|
||
|
||
def page_key(master_key: bytes, page: int) -> bytes:
|
||
"""页面密钥:MD5/SHA256(master_key || page_le32 || 'sAlT')。
|
||
|
||
- 16 字节主密钥(waAES128) → MD5 → 16 字节,AES-128-CBC
|
||
- 32 字节主密钥(wxAES256) → SHA256 → 32 字节,AES-256-CBC
|
||
"""
|
||
nkey = master_key + struct.pack("<I", page & 0xFFFFFFFF) + MAGIC_SALT
|
||
if len(master_key) == 16:
|
||
return hashlib.md5(nkey).digest()
|
||
return hashlib.sha256(nkey).digest()
|
||
|
||
|
||
def _aes_cbc_decrypt(key16: bytes, iv16: bytes, data: bytes) -> bytes:
|
||
return AES.new(key16, AES.MODE_CBC, iv16).decrypt(data)
|
||
|
||
|
||
def read_page_header(db_path: str) -> tuple | None:
|
||
"""读取 db 文件第 1 页明文头 16..23,返回 (page_size, header8) 或 None(文件太小/无 db)。"""
|
||
try:
|
||
with open(db_path, "rb") as f:
|
||
head = f.read(24)
|
||
if len(head) < 24:
|
||
return None
|
||
header8 = head[16:24]
|
||
db_page_size = (header8[0] << 8) | (header8[1] << 16)
|
||
if db_page_size < 512 or ((db_page_size - 1) & db_page_size) != 0:
|
||
return None
|
||
if header8[5] != PAGE1_HEADER_CHECK[0] or header8[6] != PAGE1_HEADER_CHECK[1] or header8[7] != PAGE1_HEADER_CHECK[2]:
|
||
return None
|
||
return db_page_size, header8
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def decrypt_page1_block(master_key: bytes, db_path: str) -> bytes | None:
|
||
"""仅解密第 1 页的 16..32 密文块,用于快速校验密钥。
|
||
|
||
加密文件里 page1 布局: 0..15 加密 / 8..15 暂存加密的版本头 /
|
||
16..23 明文头 / 24..end 加密。解密数据流需拼接 8..16 + 24..32,
|
||
即 data[16:32] = (8..16 的密文A) + (24..32 的密文B)。
|
||
返回解密后的 16 字节明文(前 8 字节应为明文头 dbHeader)或 None。
|
||
支持 16 字节(AES-128) 与 32 字节(AES-256) 主密钥。
|
||
"""
|
||
if len(master_key) not in (16, 32):
|
||
return None
|
||
info = read_page_header(db_path)
|
||
if info is None:
|
||
return None
|
||
page_size, header8 = info
|
||
with open(db_path, "rb") as f:
|
||
f.seek(8)
|
||
part_a = f.read(8) # 8..16: 加密的版本头
|
||
f.seek(24)
|
||
part_b = f.read(8) # 24..32: 加密的后续数据
|
||
if len(part_a) < 8 or len(part_b) < 8:
|
||
return None
|
||
block = part_a + part_b # 对应解密数据流 data[16:32]
|
||
key = page_key(master_key, 1)
|
||
iv = generate_iv(1)
|
||
return _aes_cbc_decrypt(key, iv, block)
|
||
|
||
|
||
def verify_key(master_key: bytes, db_path: str) -> bool:
|
||
"""校验主密钥是否能解密该 db 文件(页1明文头匹配)。支持 16/32 字节。"""
|
||
if len(master_key) not in (16, 32):
|
||
return False
|
||
info = read_page_header(db_path)
|
||
if info is None:
|
||
return False
|
||
_, header8 = info
|
||
dec = decrypt_page1_block(master_key, db_path)
|
||
if dec is None or len(dec) < 8:
|
||
return False
|
||
return dec[:8] == header8
|
||
|
||
|
||
def decrypt_page(master_key: bytes, page: int, page_data: bytes, page_size: int) -> bytes | None:
|
||
"""解密单个页面(非 legacy 模式)。
|
||
|
||
- page 1:16..23 为明文头,加密字节从 8..15 恢复;0..15 不解密,覆写为 SQLite magic。
|
||
- 其他页:整页解密。
|
||
密钥错误时返回 None(仅页 1 能自校验;其他页需由 SQLite 解析兜底)。
|
||
"""
|
||
data = bytearray(page_data)
|
||
key = page_key(master_key, page)
|
||
iv = generate_iv(page)
|
||
if page == 1:
|
||
header8 = bytes(data[16:24])
|
||
# 校验明文头魔数,避免对非本格式文件瞎解
|
||
if header8[5] != PAGE1_HEADER_CHECK[0] or header8[6] != PAGE1_HEADER_CHECK[1] or header8[7] != PAGE1_HEADER_CHECK[2]:
|
||
return None
|
||
data[16:24] = data[8:16] # 恢复加密字节
|
||
dec = _aes_cbc_decrypt(key, iv, bytes(data[16:page_size]))
|
||
data[16:page_size] = dec
|
||
if bytes(data[16:24]) != header8:
|
||
return None # 密钥错误
|
||
data[0:16] = SQLITE_FILE_HEADER
|
||
return bytes(data)
|
||
else:
|
||
dec = _aes_cbc_decrypt(key, iv, bytes(data[:page_size]))
|
||
return bytes(dec)
|
||
|
||
|
||
def decrypt_db_to_file(db_path: str, master_key: bytes, out_path: str, page_size: int = 4096,
|
||
progress_cb=None) -> bool:
|
||
"""整库解密为标准 SQLite 文件。
|
||
|
||
成功返回 True;密钥错误/文件异常返回 False。
|
||
"""
|
||
info = read_page_header(db_path)
|
||
if info is None:
|
||
return False
|
||
db_page_size, _ = info
|
||
size = os.path.getsize(db_path)
|
||
if size == 0:
|
||
return False
|
||
n_pages = (size + db_page_size - 1) // db_page_size
|
||
with open(db_path, "rb") as fin, open(out_path, "wb") as fout:
|
||
for pg in range(1, n_pages + 1):
|
||
data = fin.read(db_page_size)
|
||
if len(data) < db_page_size:
|
||
data += b"\x00" * (db_page_size - len(data))
|
||
dec = decrypt_page(master_key, pg, data, db_page_size)
|
||
if dec is None:
|
||
return False
|
||
fout.write(dec)
|
||
if progress_cb and pg % 256 == 0:
|
||
progress_cb(pg, n_pages)
|
||
return True
|
||
|
||
|
||
def decrypt_db_open(db_path: str, master_key: bytes) -> sqlite3.Connection | None:
|
||
"""解密 db 到临时文件并用 sqlite3 打开(只读)。调用方负责 close 并删除临时文件。
|
||
|
||
返回 (conn, tmp_path) 或 (None, None)。
|
||
"""
|
||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db", prefix="wxwork_dec_")
|
||
os.close(tmp_fd)
|
||
try:
|
||
ok = decrypt_db_to_file(db_path, master_key, tmp_path)
|
||
if not ok:
|
||
os.remove(tmp_path)
|
||
return None, None
|
||
conn = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True)
|
||
return conn, tmp_path
|
||
except Exception:
|
||
if os.path.exists(tmp_path):
|
||
try:
|
||
os.remove(tmp_path)
|
||
except Exception:
|
||
pass
|
||
return None, None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PDF 规范密钥派生(备用:内存中找到的是 passphrase 而非 raw key 时使用)
|
||
# ---------------------------------------------------------------------------
|
||
_PDF_PAD = b"\x28" * 32 # PDF 规范口令填充
|
||
|
||
|
||
def _pad_password(passphrase: bytes) -> bytes:
|
||
if len(passphrase) >= 32:
|
||
return passphrase[:32]
|
||
return passphrase + _PDF_PAD[: 32 - len(passphrase)]
|
||
|
||
|
||
def _rc4(key: bytes, data: bytes) -> bytes:
|
||
S = list(range(256))
|
||
j = 0
|
||
klen = len(key)
|
||
for i in range(256):
|
||
j = (j + S[i] + key[i % klen]) % 256
|
||
S[i], S[j] = S[j], S[i]
|
||
out = bytearray(len(data))
|
||
a = b = 0
|
||
for i in range(len(data)):
|
||
a = (a + 1) % 256
|
||
t = S[a]
|
||
b = (b + t) % 256
|
||
S[a], S[b] = S[b], S[a]
|
||
out[i] = data[i] ^ S[(S[a] + S[b]) % 256]
|
||
return bytes(out)
|
||
|
||
|
||
def derive_key_pdf(passphrase: bytes) -> bytes:
|
||
"""GenerateKeyAES128Cipher 的完整实现(PDF 规范 MD5+RC4),返回 16 字节 AES key。"""
|
||
user_pad = _pad_password(passphrase)
|
||
owner_pad = _pad_password(b"")
|
||
|
||
# --- owner key ---
|
||
digest = hashlib.md5(owner_pad).digest()
|
||
for _ in range(50):
|
||
digest = hashlib.md5(digest).digest()
|
||
owner_key = bytearray(user_pad)
|
||
for i in range(20):
|
||
mkey = bytes((digest[j] ^ i) & 0xFF for j in range(16))
|
||
owner_key = bytearray(_rc4(mkey, bytes(owner_key)))
|
||
|
||
# --- encryption key ---
|
||
digest = hashlib.md5(user_pad + bytes(owner_key)).digest()
|
||
for _ in range(50):
|
||
digest = hashlib.md5(digest).digest()
|
||
return digest[:16]
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 自检:算法一致性(生成IV/页面密钥长度/派生key长度)
|
||
iv1 = generate_iv(1)
|
||
iv2 = generate_iv(2)
|
||
assert len(iv1) == 16 and len(iv2) == 16 and iv1 != iv2
|
||
k = page_key(b"\x01" * 16, 1)
|
||
assert len(k) == 16
|
||
dk = derive_key_pdf(b"test")
|
||
assert len(dk) == 16
|
||
print("wxwork_crypto self-check OK")
|