wx-cli 0.1.1

WeChat 4.x (macOS/Linux) local data CLI — decrypt SQLCipher DBs, query chat history, watch new messages
pub mod wal;

use anyhow::{bail, Result};
use aes::Aes256;
use cbc::Decryptor;
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
use std::path::Path;

pub const PAGE_SZ: usize = 4096;
pub const SALT_SZ: usize = 16;
pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64)

/// SQLite 文件头魔数(16字节)
pub const SQLITE_HDR: &[u8] = b"SQLite format 3\x00";

type Aes256CbcDec = Decryptor<Aes256>;

/// 解密单个 SQLCipher 4 页
///
/// - `enc_key`: 32字节 AES 密钥
/// - `page_data`: 原始加密页面数据(PAGE_SZ 字节)
/// - `pgno`: 页码(从1开始)
///
/// 返回解密后的完整页面(PAGE_SZ 字节)
pub fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Result<Vec<u8>> {
    if page_data.len() < PAGE_SZ {
        bail!("页面数据不足 {} 字节", PAGE_SZ);
    }

    // IV 位于页面末尾 RESERVE_SZ 区域的前16字节
    let iv_offset = PAGE_SZ - RESERVE_SZ;
    let iv: &[u8; 16] = page_data[iv_offset..iv_offset + 16]
        .try_into()
        .expect("IV 长度固定为 16");

    let mut result = vec![0u8; PAGE_SZ];

    if pgno == 1 {
        // 第一页:跳过 salt(16字节),解密 [SALT_SZ..PAGE_SZ-RESERVE_SZ]
        let enc = &page_data[SALT_SZ..PAGE_SZ - RESERVE_SZ];
        let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
        // 写入 SQLite 文件头
        result[..16].copy_from_slice(SQLITE_HDR);
        // 写入解密数据(从第16字节开始)
        result[16..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
        // 末尾 RESERVE_SZ 字节补零
        // (已经是零,无需显式操作)
    } else {
        // 其他页:解密 [0..PAGE_SZ-RESERVE_SZ]
        let enc = &page_data[..PAGE_SZ - RESERVE_SZ];
        let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
        result[..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
        // 末尾 RESERVE_SZ 字节补零
    }

    Ok(result)
}

/// AES-256-CBC 解密(不去除 padding,SQLCipher 不使用 PKCS#7 padding)
fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result<Vec<u8>> {
    if data.is_empty() || data.len() % 16 != 0 {
        bail!("密文长度不是 AES 块大小的倍数: {}", data.len());
    }
    let mut buf = data.to_vec();
    // 使用 raw 模式不处理 padding
    Aes256CbcDec::new(key.into(), iv.into())
        .decrypt_blocks_mut(unsafe {
            std::slice::from_raw_parts_mut(
                buf.as_mut_ptr() as *mut aes::cipher::Block<Aes256>,
                buf.len() / 16,
            )
        });
    Ok(buf)
}

/// 完整解密一个 SQLCipher 数据库文件
///
/// 读取 `db_path`,按 PAGE_SZ 分页解密,写入 `out_path`
pub fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> {
    let data = std::fs::read(db_path)?;
    if data.is_empty() {
        bail!("数据库文件为空: {}", db_path.display());
    }

    if let Some(parent) = out_path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let total_pages = (data.len() + PAGE_SZ - 1) / PAGE_SZ;
    let mut out = Vec::with_capacity(data.len());

    for pgno in 1..=total_pages {
        let offset = (pgno - 1) * PAGE_SZ;
        let end = std::cmp::min(offset + PAGE_SZ, data.len());
        let mut page = data[offset..end].to_vec();
        // 不足一页则补零
        if page.len() < PAGE_SZ {
            page.resize(PAGE_SZ, 0);
        }
        let dec = decrypt_page(enc_key, &page, pgno as u32)?;
        out.extend_from_slice(&dec);
    }

    std::fs::write(out_path, &out)?;
    Ok(())
}