feat: initial release of wx-cli v0.7.2

WeChat macOS database decryption and query tool, supporting key extraction,
message querying, full-text search, conversation export, real-time monitoring,
media handling, and HTTP server mode with REST API and SSE.
This commit is contained in:
pandorafuture
2026-06-04 22:50:00 +08:00
commit 96ccbbdc3d
186 changed files with 46712 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "wx-decrypt"
version.workspace = true
edition.workspace = true
[dependencies]
aes = "0.8"
cbc = "0.1"
pbkdf2 = { version = "0.12", features = ["sha2"] }
sha2 = "0.10"
hmac = "0.12"
thiserror = "2"
[dev-dependencies]
tempfile = "3"
+486
View File
@@ -0,0 +1,486 @@
use std::fs::{self, File};
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::Path;
use crate::error::DecryptError;
use crate::kdf::{derive_enc_key, derive_mac_key};
use crate::page::decrypt_page;
use crate::params::CryptoParams;
const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0";
/// Decrypt an entire WeChat database file.
///
/// Reads from `input`, writes the decrypted SQLite database to `output`.
/// The `raw_key` is the 32-byte key extracted from WeChat's memory.
pub fn decrypt_db(
input: &Path,
output: &Path,
raw_key: &[u8; 32],
params: &CryptoParams,
) -> Result<(), DecryptError> {
let (mut reader, first_page, file_len) = open_and_read_first_page(input, params)?;
let salt = extract_salt(&first_page, params);
let enc_key = validate_key(&first_page, raw_key, params).ok_or(DecryptError::IncorrectKey)?;
let mac_key = derive_mac_key(&enc_key, &salt, params);
let mut writer = create_output(output)?;
decrypt_all_pages(
&mut reader,
&mut writer,
&enc_key,
&mac_key,
&first_page,
file_len,
params,
)
}
/// Decrypt a database using a pre-derived encryption key and its associated salt.
///
/// Skips the 256K-iteration PBKDF2 key derivation — only the 2-iteration MAC
/// key derivation is performed. Returns `SaltMismatch` if the DB's header salt
/// does not match the provided `salt`.
pub fn decrypt_db_direct(
input: &Path,
output: &Path,
enc_key: &[u8; 32],
salt: &[u8; 16],
params: &CryptoParams,
) -> Result<(), DecryptError> {
let (mut reader, first_page, file_len) = open_and_read_first_page(input, params)?;
let db_salt = extract_salt(&first_page, params);
if db_salt != *salt {
return Err(DecryptError::SaltMismatch);
}
let mac_key = derive_mac_key(enc_key, salt, params);
// Validate HMAC on first page.
if !verify_first_page_hmac(&first_page, enc_key, &mac_key, params) {
return Err(DecryptError::IncorrectKey);
}
let mut writer = create_output(output)?;
decrypt_all_pages(
&mut reader,
&mut writer,
enc_key,
&mac_key,
&first_page,
file_len,
params,
)
}
/// Validate whether `raw_key` can decrypt the given first page.
///
/// On success (HMAC passes), returns `Some(enc_key)` — the derived encryption key.
/// On failure, returns `None`.
pub fn validate_key(
first_page: &[u8],
raw_key: &[u8; 32],
params: &CryptoParams,
) -> Option<[u8; 32]> {
if first_page.len() < params.page_size {
return None;
}
let salt = extract_salt(first_page, params);
let enc_key = derive_enc_key(raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
if verify_first_page_hmac(first_page, &enc_key, &mac_key, params) {
Some(enc_key)
} else {
None
}
}
/// Validate whether a pre-derived `enc_key` matches a DB's first page.
///
/// Extracts the salt from `first_page`, verifies it matches `salt`,
/// derives only the MAC key (2 iterations), and checks the HMAC.
pub fn validate_enc_key(
first_page: &[u8],
enc_key: &[u8; 32],
salt: &[u8; 16],
params: &CryptoParams,
) -> bool {
if first_page.len() < params.page_size {
return false;
}
let db_salt = extract_salt(first_page, params);
if db_salt != *salt {
return false;
}
let mac_key = derive_mac_key(enc_key, &db_salt, params);
verify_first_page_hmac(first_page, enc_key, &mac_key, params)
}
/// Read the 16-byte salt from the first page of an encrypted database.
pub fn read_db_salt(db_path: &Path) -> Result<[u8; 16], DecryptError> {
let mut f = File::open(db_path)?;
let mut salt = [0u8; 16];
f.read_exact(&mut salt)?;
if &salt[..] == b"SQLite format 3\0" {
return Err(DecryptError::AlreadyDecrypted);
}
Ok(salt)
}
/// Read the 16-byte salt from the main DB file for a given path.
///
/// If `path` ends with `-wal`, the corresponding main DB path is derived
/// using `wal_path_to_db_path`. Otherwise, `path` is used directly.
pub fn read_main_db_salt_for_path(path: &Path) -> Result<[u8; 16], DecryptError> {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.ends_with("-wal") {
let db_path = crate::wal::wal_path_to_db_path(path)?;
read_db_salt(&db_path)
} else {
read_db_salt(path)
}
}
// --- Internal helpers ---
/// Open an encrypted DB, read the first page, and return (reader, first_page, file_len).
fn open_and_read_first_page(
input: &Path,
params: &CryptoParams,
) -> Result<(BufReader<File>, Vec<u8>, usize), DecryptError> {
let file_len = fs::metadata(input)?.len() as usize;
if file_len < params.page_size {
return Err(DecryptError::FileTooSmall {
expected: params.page_size,
actual: file_len,
});
}
let mut reader = BufReader::new(File::open(input)?);
let mut first_page = vec![0u8; params.page_size];
reader.read_exact(&mut first_page)?;
if first_page[..SQLITE_HEADER.len()] == SQLITE_HEADER[..] {
return Err(DecryptError::AlreadyDecrypted);
}
Ok((reader, first_page, file_len))
}
/// Extract the 16-byte salt from the first page.
fn extract_salt(first_page: &[u8], params: &CryptoParams) -> [u8; 16] {
let mut salt = [0u8; 16];
salt.copy_from_slice(&first_page[..params.salt_size]);
salt
}
/// Create the output file with buffered writer, ensuring parent dirs exist.
fn create_output(output: &Path) -> Result<BufWriter<File>, DecryptError> {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
Ok(BufWriter::new(File::create(output)?))
}
/// Verify the HMAC on the first page using pre-derived keys.
fn verify_first_page_hmac(
first_page: &[u8],
_enc_key: &[u8; 32],
mac_key: &[u8; 32],
params: &CryptoParams,
) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha512;
type HmacSha512 = Hmac<Sha512>;
let offset = params.salt_size; // page 0
let hmac_data_end = params.page_size - params.reserve + params.iv_size;
let mut mac = HmacSha512::new_from_slice(mac_key).expect("HMAC key length is always valid");
mac.update(&first_page[offset..hmac_data_end]);
mac.update(&1u32.to_le_bytes()); // page 1 (1-indexed)
let calculated = mac.finalize().into_bytes();
let stored_hmac = &first_page[hmac_data_end..hmac_data_end + params.hmac_size];
calculated[..params.hmac_size] == *stored_hmac
}
/// Decrypt all pages (starting from the already-read first page) and write to output.
fn decrypt_all_pages(
reader: &mut impl Read,
writer: &mut impl Write,
enc_key: &[u8; 32],
mac_key: &[u8; 32],
first_page: &[u8],
file_len: usize,
params: &CryptoParams,
) -> Result<(), DecryptError> {
// Write SQLite header.
writer.write_all(SQLITE_HEADER)?;
// Decrypt page 0 (salt area replaced by SQLite header above).
let page0_decrypted = decrypt_page(first_page, enc_key, mac_key, 0, params)?;
writer.write_all(&page0_decrypted)?;
// Process remaining pages.
let total_pages = file_len.div_ceil(params.page_size);
let mut page_buf = vec![0u8; params.page_size];
for page_num in 1..total_pages as u32 {
let n = read_full_or_eof(reader, &mut page_buf)?;
if n == 0 {
break;
}
if n < params.page_size {
// Partial trailing page — write as-is.
writer.write_all(&page_buf[..n])?;
break;
}
// Skip all-zero pages (write as-is).
if page_buf.iter().all(|&b| b == 0) {
writer.write_all(&page_buf)?;
continue;
}
let decrypted = decrypt_page(&page_buf, enc_key, mac_key, page_num, params)?;
writer.write_all(&decrypted)?;
}
writer.flush()?;
Ok(())
}
/// Read exactly `buf.len()` bytes, returning the count read (may be less at EOF).
fn read_full_or_eof(reader: &mut impl Read, buf: &mut [u8]) -> Result<usize, std::io::Error> {
let mut total = 0;
while total < buf.len() {
match reader.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kdf::{derive_enc_key, derive_mac_key};
use crate::params::MACOS_4_1_7_31;
/// Build a fake encrypted page (reverse of decrypt_page).
fn build_encrypted_page(
content: &[u8],
enc_key: &[u8; 32],
mac_key: &[u8; 32],
page_num: u32,
params: &CryptoParams,
salt: Option<&[u8; 16]>,
) -> Vec<u8> {
use aes::cipher::{block_padding::NoPadding, BlockEncryptMut, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
type HmacSha512 = Hmac<Sha512>;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
let iv = [0x42u8; 16];
let offset = if page_num == 0 { params.salt_size } else { 0 };
let data_len = params.page_size - params.reserve - offset;
let mut plaintext = vec![0u8; data_len];
let copy_len = content.len().min(data_len);
plaintext[..copy_len].copy_from_slice(&content[..copy_len]);
let mut ciphertext = plaintext.clone();
Aes256CbcEnc::new(enc_key.into(), (&iv).into())
.encrypt_padded_mut::<NoPadding>(&mut ciphertext, data_len)
.expect("encryption should not fail");
let mut page = vec![0u8; params.page_size];
if page_num == 0 {
page[..params.salt_size].copy_from_slice(salt.unwrap());
page[offset..offset + data_len].copy_from_slice(&ciphertext);
} else {
page[..data_len].copy_from_slice(&ciphertext);
}
let iv_start = params.page_size - params.reserve;
page[iv_start..iv_start + params.iv_size].copy_from_slice(&iv);
let hmac_data_end = params.page_size - params.reserve + params.iv_size;
let mut mac = HmacSha512::new_from_slice(mac_key).unwrap();
mac.update(&page[offset..hmac_data_end]);
mac.update(&(page_num + 1).to_le_bytes());
let hmac_result = mac.finalize().into_bytes();
page[hmac_data_end..hmac_data_end + params.hmac_size]
.copy_from_slice(&hmac_result[..params.hmac_size]);
page
}
fn setup_encrypted_db(dir: &std::path::Path) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 16]) {
let params = &MACOS_4_1_7_31;
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let page0 =
build_encrypted_page(b"hello-page0", &enc_key, &mac_key, 0, params, Some(&salt));
let page1 = build_encrypted_page(b"hello-page1", &enc_key, &mac_key, 1, params, None);
std::fs::write(dir.join("test.db"), [page0, page1].concat()).unwrap();
(raw_key, enc_key, mac_key, salt)
}
#[test]
fn test_decrypt_db_direct_produces_identical_output() {
let params = &MACOS_4_1_7_31;
let dir = tempfile::tempdir().unwrap();
let (raw_key, enc_key, _mac_key, salt) = setup_encrypted_db(dir.path());
let out_raw = dir.path().join("out_raw.db");
let out_direct = dir.path().join("out_direct.db");
let input = dir.path().join("test.db");
decrypt_db(&input, &out_raw, &raw_key, params).unwrap();
decrypt_db_direct(&input, &out_direct, &enc_key, &salt, params).unwrap();
assert_eq!(
std::fs::read(&out_raw).unwrap(),
std::fs::read(&out_direct).unwrap(),
"raw and direct decrypt should produce identical output"
);
}
#[test]
fn test_decrypt_db_direct_wrong_salt_returns_salt_mismatch() {
let params = &MACOS_4_1_7_31;
let dir = tempfile::tempdir().unwrap();
let (_raw_key, enc_key, _mac_key, _salt) = setup_encrypted_db(dir.path());
let wrong_salt = [0x99u8; 16];
let input = dir.path().join("test.db");
let output = dir.path().join("out.db");
let err = decrypt_db_direct(&input, &output, &enc_key, &wrong_salt, params).unwrap_err();
assert!(matches!(err, DecryptError::SaltMismatch));
}
#[test]
fn test_validate_enc_key_correct() {
let params = &MACOS_4_1_7_31;
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let first_page = build_encrypted_page(b"test", &enc_key, &mac_key, 0, params, Some(&salt));
assert!(validate_enc_key(&first_page, &enc_key, &salt, params));
}
#[test]
fn test_validate_enc_key_wrong_salt() {
let params = &MACOS_4_1_7_31;
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let first_page = build_encrypted_page(b"test", &enc_key, &mac_key, 0, params, Some(&salt));
let wrong_salt = [0x99u8; 16];
assert!(!validate_enc_key(
&first_page,
&enc_key,
&wrong_salt,
params
));
}
#[test]
fn test_validate_enc_key_wrong_key() {
let params = &MACOS_4_1_7_31;
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let first_page = build_encrypted_page(b"test", &enc_key, &mac_key, 0, params, Some(&salt));
let wrong_key = [0xCDu8; 32];
assert!(!validate_enc_key(&first_page, &wrong_key, &salt, params));
}
#[test]
fn test_read_db_salt() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let mut data = vec![0u8; 4096];
data[..16].copy_from_slice(&[0x01u8; 16]);
std::fs::write(&db_path, &data).unwrap();
let salt = read_db_salt(&db_path).unwrap();
assert_eq!(salt, [0x01u8; 16]);
}
#[test]
fn test_read_db_salt_already_decrypted() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let mut data = vec![0u8; 4096];
data[..16].copy_from_slice(b"SQLite format 3\0");
std::fs::write(&db_path, &data).unwrap();
let err = read_db_salt(&db_path).unwrap_err();
assert!(matches!(err, DecryptError::AlreadyDecrypted));
}
#[test]
fn test_read_main_db_salt_for_db_path() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("session.db");
let mut data = vec![0u8; 4096];
data[..16].copy_from_slice(&[0x42u8; 16]);
std::fs::write(&db_path, &data).unwrap();
let salt = read_main_db_salt_for_path(&db_path).unwrap();
assert_eq!(salt, [0x42u8; 16]);
}
#[test]
fn test_read_main_db_salt_for_wal_path() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("session.db");
let mut data = vec![0u8; 4096];
data[..16].copy_from_slice(&[0x55u8; 16]);
std::fs::write(&db_path, data).unwrap();
let wal_path = dir.path().join("session.db-wal");
std::fs::write(&wal_path, [0u8; 32]).unwrap(); // WAL file exists
let salt = read_main_db_salt_for_path(&wal_path).unwrap();
assert_eq!(salt, [0x55u8; 16]);
}
#[test]
fn test_read_main_db_salt_for_missing_db_returns_error() {
let dir = tempfile::tempdir().unwrap();
let wal_path = dir.path().join("missing.db-wal");
std::fs::write(&wal_path, [0u8; 32]).unwrap();
let err = read_main_db_salt_for_path(&wal_path).unwrap_err();
assert!(matches!(err, DecryptError::Io(_)));
}
}
+282
View File
@@ -0,0 +1,282 @@
//! KeyMaterial dispatch helpers.
//!
//! Centralises the three-arm `KeyMaterial` match that selects between
//! `RawKey` → full KDF path, `EncKey` → direct path, and `EncKeys` →
//! salt-lookup + direct path.
use std::path::Path;
use crate::error::DecryptError;
use crate::key_material::KeyMaterial;
use crate::params::CryptoParams;
/// Decrypt a database file, dispatching on the [`KeyMaterial`] variant.
pub fn dispatch_decrypt_db(
src: &Path,
dst: &Path,
km: &KeyMaterial,
params: &CryptoParams,
) -> Result<(), DecryptError> {
match km {
KeyMaterial::RawKey(key) => crate::db::decrypt_db(src, dst, key, params),
KeyMaterial::EncKey { key, salt } => {
crate::db::decrypt_db_direct(src, dst, key, salt, params)
}
KeyMaterial::EncKeys(pairs) => {
let db_salt = crate::db::read_main_db_salt_for_path(src)?;
let pair = pairs
.iter()
.find(|p| p.salt == db_salt)
.ok_or(DecryptError::NoMatchingEncKey)?;
crate::db::decrypt_db_direct(src, dst, &pair.key, &pair.salt, params)
}
}
}
/// Decrypt a WAL file and patch it into the decrypted database,
/// dispatching on the [`KeyMaterial`] variant.
pub fn dispatch_decrypt_wal(
wal: &Path,
dst: &Path,
km: &KeyMaterial,
params: &CryptoParams,
) -> Result<usize, DecryptError> {
match km {
KeyMaterial::RawKey(key) => crate::wal::decrypt_wal(wal, dst, key, params),
KeyMaterial::EncKey { key, salt } => {
crate::wal::decrypt_wal_direct(wal, dst, key, salt, params)
}
KeyMaterial::EncKeys(pairs) => {
let db_salt = crate::db::read_main_db_salt_for_path(wal)?;
let pair = pairs
.iter()
.find(|p| p.salt == db_salt)
.ok_or(DecryptError::NoMatchingEncKey)?;
crate::wal::decrypt_wal_direct(wal, dst, &pair.key, &pair.salt, params)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key_material::EncKeyPair;
use crate::params::MACOS_4_1_7_31;
use tempfile::TempDir;
// ---- helpers ----
fn derive_enc_key(raw_key: &[u8; 32], salt: &[u8; 16], params: &CryptoParams) -> [u8; 32] {
let mut key = [0u8; 32];
pbkdf2::pbkdf2_hmac::<sha2::Sha512>(raw_key, salt, params.kdf_iter, &mut key);
key
}
/// Build a single-page encrypted DB file that `decrypt_db` can process.
fn build_encrypted_db(path: &Path, raw_key: &[u8; 32], salt: &[u8; 16], params: &CryptoParams) {
use aes::cipher::{BlockEncryptMut, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
let enc_key = derive_enc_key(raw_key, salt, params);
let mut mac_salt = [0u8; 16];
for (i, b) in salt.iter().enumerate() {
mac_salt[i] = b ^ 0x3a;
}
let mut mac_key = [0u8; 32];
pbkdf2::pbkdf2_hmac::<sha2::Sha512>(&enc_key, &mac_salt, 2, &mut mac_key);
let iv = [0x42u8; 16];
let data_size = params.page_size - params.reserve - params.salt_size;
let plaintext = vec![0u8; data_size];
let mut ciphertext = plaintext;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
Aes256CbcEnc::new((&enc_key).into(), (&iv).into())
.encrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut ciphertext, data_size)
.unwrap();
let mut page = Vec::with_capacity(params.page_size);
page.extend_from_slice(salt);
page.extend_from_slice(&ciphertext);
page.extend_from_slice(&iv);
page.resize(params.page_size, 0);
let hmac_data_end = params.page_size - params.reserve + params.iv_size;
let mut mac = <Hmac<Sha512> as Mac>::new_from_slice(&mac_key).unwrap();
mac.update(&page[params.salt_size..hmac_data_end]);
mac.update(&1u32.to_le_bytes());
let hmac_result = mac.finalize().into_bytes();
let hmac_start = params.page_size - params.reserve + params.iv_size;
page[hmac_start..hmac_start + params.hmac_size]
.copy_from_slice(&hmac_result[..params.hmac_size]);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, &page).unwrap();
}
// ---- dispatch_decrypt_db tests ----
#[test]
fn dispatch_db_raw_key() {
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let params = &MACOS_4_1_7_31;
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("test.db");
let dst = tmp.path().join("out.db");
build_encrypted_db(&src, &raw_key, &salt, params);
let km = KeyMaterial::RawKey(raw_key);
dispatch_decrypt_db(&src, &dst, &km, params).unwrap();
let data = std::fs::read(&dst).unwrap();
assert_eq!(&data[..16], b"SQLite format 3\0");
}
#[test]
fn dispatch_db_enc_key() {
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let params = &MACOS_4_1_7_31;
let enc_key = derive_enc_key(&raw_key, &salt, params);
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("test.db");
let dst = tmp.path().join("out.db");
build_encrypted_db(&src, &raw_key, &salt, params);
let km = KeyMaterial::EncKey { key: enc_key, salt };
dispatch_decrypt_db(&src, &dst, &km, params).unwrap();
let data = std::fs::read(&dst).unwrap();
assert_eq!(&data[..16], b"SQLite format 3\0");
}
#[test]
fn dispatch_db_enc_keys() {
let raw_key = [0xABu8; 32];
let salt1 = [0x01u8; 16];
let salt2 = [0x02u8; 16];
let params = &MACOS_4_1_7_31;
let enc_key1 = derive_enc_key(&raw_key, &salt1, params);
let enc_key2 = derive_enc_key(&raw_key, &salt2, params);
let tmp = TempDir::new().unwrap();
// DB with salt1
let src = tmp.path().join("test.db");
let dst = tmp.path().join("out.db");
build_encrypted_db(&src, &raw_key, &salt1, params);
let km = KeyMaterial::EncKeys(vec![
EncKeyPair {
key: enc_key2,
salt: salt2,
},
EncKeyPair {
key: enc_key1,
salt: salt1,
},
]);
dispatch_decrypt_db(&src, &dst, &km, params).unwrap();
let data = std::fs::read(&dst).unwrap();
assert_eq!(&data[..16], b"SQLite format 3\0");
}
#[test]
fn dispatch_db_enc_keys_no_match() {
let raw_key = [0xABu8; 32];
let salt_db = [0x01u8; 16];
let salt_wrong = [0x99u8; 16];
let params = &MACOS_4_1_7_31;
let enc_key_wrong = derive_enc_key(&raw_key, &salt_wrong, params);
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("test.db");
let dst = tmp.path().join("out.db");
build_encrypted_db(&src, &raw_key, &salt_db, params);
let km = KeyMaterial::EncKeys(vec![EncKeyPair {
key: enc_key_wrong,
salt: salt_wrong,
}]);
let err = dispatch_decrypt_db(&src, &dst, &km, params).unwrap_err();
assert!(matches!(err, DecryptError::NoMatchingEncKey));
}
// ---- dispatch_decrypt_wal tests ----
#[test]
fn dispatch_wal_raw_key_no_frames() {
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let params = &MACOS_4_1_7_31;
let tmp = TempDir::new().unwrap();
// Build a decrypted DB first
let enc_db = tmp.path().join("enc.db");
let dec_db = tmp.path().join("dec.db");
build_encrypted_db(&enc_db, &raw_key, &salt, params);
crate::db::decrypt_db(&enc_db, &dec_db, &raw_key, params).unwrap();
// Create a minimal WAL with header only (no frames)
let wal = tmp.path().join("enc.db-wal");
let mut wal_header = [0u8; 32];
wal_header[0..4].copy_from_slice(&0x377f_0682u32.to_be_bytes());
std::fs::write(&wal, wal_header).unwrap();
let km = KeyMaterial::RawKey(raw_key);
let n = dispatch_decrypt_wal(&wal, &dec_db, &km, params).unwrap();
assert_eq!(n, 0);
}
#[test]
fn dispatch_wal_enc_key_no_frames() {
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let params = &MACOS_4_1_7_31;
let enc_key = derive_enc_key(&raw_key, &salt, params);
let tmp = TempDir::new().unwrap();
let enc_db = tmp.path().join("enc.db");
let dec_db = tmp.path().join("dec.db");
build_encrypted_db(&enc_db, &raw_key, &salt, params);
crate::db::decrypt_db(&enc_db, &dec_db, &raw_key, params).unwrap();
let wal = tmp.path().join("enc.db-wal");
let mut wal_header = [0u8; 32];
wal_header[0..4].copy_from_slice(&0x377f_0682u32.to_be_bytes());
std::fs::write(&wal, wal_header).unwrap();
let km = KeyMaterial::EncKey { key: enc_key, salt };
let n = dispatch_decrypt_wal(&wal, &dec_db, &km, params).unwrap();
assert_eq!(n, 0);
}
#[test]
fn dispatch_wal_enc_keys_no_frames() {
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let params = &MACOS_4_1_7_31;
let enc_key = derive_enc_key(&raw_key, &salt, params);
let tmp = TempDir::new().unwrap();
let enc_db = tmp.path().join("enc.db");
let dec_db = tmp.path().join("dec.db");
build_encrypted_db(&enc_db, &raw_key, &salt, params);
crate::db::decrypt_db(&enc_db, &dec_db, &raw_key, params).unwrap();
let wal = tmp.path().join("enc.db-wal");
let mut wal_header = [0u8; 32];
wal_header[0..4].copy_from_slice(&0x377f_0682u32.to_be_bytes());
std::fs::write(&wal, wal_header).unwrap();
let km = KeyMaterial::EncKeys(vec![EncKeyPair { key: enc_key, salt }]);
let n = dispatch_decrypt_wal(&wal, &dec_db, &km, params).unwrap();
assert_eq!(n, 0);
}
}
+31
View File
@@ -0,0 +1,31 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DecryptError {
#[error("file too small: expected at least {expected} bytes, got {actual}")]
FileTooSmall { expected: usize, actual: usize },
#[error("database is already decrypted (SQLite header detected)")]
AlreadyDecrypted,
#[error("incorrect key: HMAC verification failed on first page")]
IncorrectKey,
#[error("salt mismatch: DB salt does not match provided salt")]
SaltMismatch,
#[error("no matching enc_key found for this DB's salt")]
NoMatchingEncKey,
#[error("HMAC verification failed on page {page_num}")]
HmacVerificationFailed { page_num: u32 },
#[error("AES decryption failed on page {page_num}: {reason}")]
AesDecryptFailed { page_num: u32, reason: String },
#[error("invalid WAL header: {reason}")]
InvalidWalHeader { reason: String },
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
+61
View File
@@ -0,0 +1,61 @@
use pbkdf2::pbkdf2_hmac;
use sha2::Sha512;
use crate::params::CryptoParams;
/// Derive the AES-256 encryption key from the raw key and salt.
///
/// Uses PBKDF2-HMAC-SHA512 with `params.kdf_iter` iterations.
pub fn derive_enc_key(raw_key: &[u8; 32], salt: &[u8; 16], params: &CryptoParams) -> [u8; 32] {
let mut enc_key = [0u8; 32];
pbkdf2_hmac::<Sha512>(raw_key, salt, params.kdf_iter, &mut enc_key);
enc_key
}
/// Derive the HMAC key from the encryption key and salt.
///
/// Uses PBKDF2-HMAC-SHA512 with 2 iterations. The MAC salt is `salt XOR 0x3a`.
pub fn derive_mac_key(enc_key: &[u8; 32], salt: &[u8; 16], _params: &CryptoParams) -> [u8; 32] {
let mac_salt: [u8; 16] = std::array::from_fn(|i| salt[i] ^ 0x3a);
let mut mac_key = [0u8; 32];
pbkdf2_hmac::<Sha512>(enc_key, &mac_salt, 2, &mut mac_key);
mac_key
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::MACOS_4_1_7_31;
#[test]
fn test_derive_keys_deterministic() {
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc1 = derive_enc_key(&raw_key, &salt, &MACOS_4_1_7_31);
let enc2 = derive_enc_key(&raw_key, &salt, &MACOS_4_1_7_31);
assert_eq!(enc1, enc2);
let mac1 = derive_mac_key(&enc1, &salt, &MACOS_4_1_7_31);
let mac2 = derive_mac_key(&enc2, &salt, &MACOS_4_1_7_31);
assert_eq!(mac1, mac2);
}
#[test]
fn test_derive_keys_different_salt() {
let raw_key = [0xABu8; 32];
let salt_a = [0x01u8; 16];
let salt_b = [0x02u8; 16];
let enc_a = derive_enc_key(&raw_key, &salt_a, &MACOS_4_1_7_31);
let enc_b = derive_enc_key(&raw_key, &salt_b, &MACOS_4_1_7_31);
assert_ne!(enc_a, enc_b);
}
#[test]
fn test_mac_salt_xor() {
let salt = [0x00u8; 16];
let mac_salt: [u8; 16] = std::array::from_fn(|i| salt[i] ^ 0x3a);
assert!(mac_salt.iter().all(|&b| b == 0x3a));
}
}
+19
View File
@@ -0,0 +1,19 @@
/// A pre-derived encryption key paired with its DB salt.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EncKeyPair {
pub key: [u8; 32],
pub salt: [u8; 16],
}
/// Represents either a raw LLDB-extracted key or a pre-derived encryption key
/// found via Mach VM memory scanning.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyMaterial {
/// 32-byte raw key from LLDB capture; requires full PBKDF2 derivation.
RawKey([u8; 32]),
/// Pre-derived encryption key + DB salt; skips the 256K-iteration PBKDF2.
EncKey { key: [u8; 32], salt: [u8; 16] },
/// Multiple pre-derived enc_keys, each paired with a different DB salt.
/// Used when `key scan` finds keys for multiple DBs within the same account.
EncKeys(Vec<EncKeyPair>),
}
+18
View File
@@ -0,0 +1,18 @@
pub mod db;
pub mod dispatch;
pub mod error;
pub mod kdf;
pub mod key_material;
pub mod page;
pub mod params;
pub mod wal;
pub use db::{
decrypt_db, decrypt_db_direct, read_db_salt, read_main_db_salt_for_path, validate_enc_key,
validate_key,
};
pub use dispatch::{dispatch_decrypt_db, dispatch_decrypt_wal};
pub use error::DecryptError;
pub use key_material::{EncKeyPair, KeyMaterial};
pub use params::{CryptoParams, MACOS_4_1_7_31};
pub use wal::{decrypt_wal, decrypt_wal_direct};
+83
View File
@@ -0,0 +1,83 @@
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
use crate::error::DecryptError;
use crate::params::CryptoParams;
type HmacSha512 = Hmac<Sha512>;
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
/// Verify HMAC for a page and decrypt it.
///
/// `page_num` is 0-indexed. The HMAC includes the 1-indexed page number as LE u32.
///
/// For page 0, the first `salt_size` bytes (salt) are skipped in both HMAC and
/// decryption — the caller is responsible for replacing them with the SQLite header.
pub fn decrypt_page(
page_buf: &[u8],
enc_key: &[u8; 32],
mac_key: &[u8; 32],
page_num: u32,
params: &CryptoParams,
) -> Result<Vec<u8>, DecryptError> {
let offset = if page_num == 0 { params.salt_size } else { 0 };
// --- HMAC verification ---
let hmac_data_end = params.page_size - params.reserve + params.iv_size;
let mut mac = HmacSha512::new_from_slice(mac_key).expect("HMAC key length is always valid");
mac.update(&page_buf[offset..hmac_data_end]);
// Page number in HMAC is 1-indexed, little-endian u32.
mac.update(&(page_num + 1).to_le_bytes());
let calculated = mac.finalize().into_bytes();
let stored_hmac = &page_buf[hmac_data_end..hmac_data_end + params.hmac_size];
if calculated[..params.hmac_size] != *stored_hmac {
return Err(DecryptError::HmacVerificationFailed { page_num });
}
// --- Extract IV from reserve area ---
let iv_start = params.page_size - params.reserve;
let iv = &page_buf[iv_start..iv_start + params.iv_size];
// --- AES-256-CBC decrypt ---
let encrypted = &page_buf[offset..params.page_size - params.reserve];
let mut buf = encrypted.to_vec();
Aes256CbcDec::new(enc_key.into(), iv.into())
.decrypt_padded_mut::<NoPadding>(&mut buf)
.map_err(|e| DecryptError::AesDecryptFailed {
page_num,
reason: e.to_string(),
})?;
// Append the reserve area (IV + HMAC) unchanged.
buf.extend_from_slice(&page_buf[params.page_size - params.reserve..params.page_size]);
Ok(buf)
}
#[cfg(test)]
mod tests {
use crate::params::MACOS_4_1_7_31;
#[test]
fn test_page_size_arithmetic() {
let p = &MACOS_4_1_7_31;
// For a non-zero page: decrypted data + reserve = page_size
let data_len = p.page_size - p.reserve; // 4016
let total = data_len + p.reserve; // 4096
assert_eq!(total, p.page_size);
}
#[test]
fn test_page0_size_arithmetic() {
let p = &MACOS_4_1_7_31;
// For page 0: (page_size - reserve - salt_size) + reserve = page_size - salt_size
let data_len = p.page_size - p.reserve - p.salt_size; // 4000
let total = data_len + p.reserve; // 4080
// Caller prepends SQLite header (16 bytes) to reach 4096
assert_eq!(total + p.salt_size, p.page_size);
}
}
+21
View File
@@ -0,0 +1,21 @@
/// Encryption parameters for a specific WeChat version.
pub struct CryptoParams {
pub page_size: usize,
pub kdf_iter: u32,
pub hmac_size: usize,
pub reserve: usize,
pub key_size: usize,
pub salt_size: usize,
pub iv_size: usize,
}
/// macOS WeChat 4.1.7.31: Apple SEE with PBKDF2-HMAC-SHA512.
pub const MACOS_4_1_7_31: CryptoParams = CryptoParams {
page_size: 4096,
kdf_iter: 256_000,
hmac_size: 64, // SHA-512 output
reserve: 80, // IV(16) + HMAC(64)
key_size: 32,
salt_size: 16,
iv_size: 16,
};
+833
View File
@@ -0,0 +1,833 @@
//! WAL (Write-Ahead Log) decryption for encrypted SQLite databases.
//!
//! SQLite WAL files consist of a 32-byte header followed by N frames.
//! Each frame has a 24-byte frame header and a full page of data.
//! In encrypted WeChat databases, the page data within each frame is
//! encrypted using the same parameters as the main database.
use std::fs::File;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use crate::db::read_db_salt;
use crate::error::DecryptError;
use crate::kdf::{derive_enc_key, derive_mac_key};
use crate::page::decrypt_page;
use crate::params::CryptoParams;
/// WAL file header size in bytes.
const WAL_HEADER_SIZE: usize = 32;
/// WAL frame header size in bytes.
const WAL_FRAME_HEADER_SIZE: usize = 24;
/// SQLite WAL magic numbers (big-endian and little-endian checksum variants).
const WAL_MAGIC_BE: u32 = 0x377f_0682;
const WAL_MAGIC_LE: u32 = 0x377f_0683;
/// Maximum valid page number (sanity check).
const MAX_PAGE_NUMBER: u32 = 1_000_000;
/// Parsed WAL file header.
#[derive(Debug, Clone)]
struct WalHeader {
salt1: u32,
salt2: u32,
}
/// Parsed WAL frame header.
#[derive(Debug, Clone)]
struct WalFrameHeader {
page_number: u32,
commit_size: u32,
salt1: u32,
salt2: u32,
}
impl WalHeader {
fn parse(buf: &[u8; WAL_HEADER_SIZE]) -> Result<Self, DecryptError> {
let magic = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != WAL_MAGIC_BE && magic != WAL_MAGIC_LE {
return Err(DecryptError::InvalidWalHeader {
reason: format!("bad magic: 0x{magic:08x}"),
});
}
let salt1 = u32::from_be_bytes([buf[16], buf[17], buf[18], buf[19]]);
let salt2 = u32::from_be_bytes([buf[20], buf[21], buf[22], buf[23]]);
Ok(WalHeader { salt1, salt2 })
}
}
impl WalFrameHeader {
fn parse(buf: &[u8; WAL_FRAME_HEADER_SIZE]) -> Self {
WalFrameHeader {
page_number: u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]),
commit_size: u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
salt1: u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]),
salt2: u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]),
}
}
fn is_valid(&self, wal_header: &WalHeader) -> bool {
self.page_number > 0
&& self.page_number <= MAX_PAGE_NUMBER
&& self.salt1 == wal_header.salt1
&& self.salt2 == wal_header.salt2
}
}
/// Decrypt valid WAL frames and patch them into a decrypted database file.
///
/// Reads the encrypted WAL file at `wal_path`, decrypts each valid frame's page
/// data, and writes it to the corresponding offset in `decrypted_db_path`.
///
/// Returns the number of frames successfully patched.
pub fn decrypt_wal(
wal_path: &Path,
decrypted_db_path: &Path,
raw_key: &[u8; 32],
params: &CryptoParams,
) -> Result<usize, DecryptError> {
let (mut wal_file, wal_header, wal_len) = open_wal(wal_path)?;
if wal_len < WAL_HEADER_SIZE {
return Ok(0);
}
let encrypted_db_path = wal_path_to_db_path(wal_path)?;
let salt = read_db_salt(&encrypted_db_path)?;
let enc_key = derive_enc_key(raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let mut db_file = std::fs::OpenOptions::new()
.write(true)
.read(true)
.open(decrypted_db_path)?;
decrypt_wal_frames(
&mut wal_file,
&mut db_file,
&enc_key,
&mac_key,
&wal_header,
wal_len,
params,
)
}
/// Decrypt WAL frames using a pre-derived encryption key and salt.
///
/// Skips the 256K-iteration PBKDF2 key derivation.
pub fn decrypt_wal_direct(
wal_path: &Path,
decrypted_db_path: &Path,
enc_key: &[u8; 32],
salt: &[u8; 16],
params: &CryptoParams,
) -> Result<usize, DecryptError> {
let (mut wal_file, wal_header, wal_len) = open_wal(wal_path)?;
if wal_len < WAL_HEADER_SIZE {
return Ok(0);
}
let mac_key = derive_mac_key(enc_key, salt, params);
let mut db_file = std::fs::OpenOptions::new()
.write(true)
.read(true)
.open(decrypted_db_path)?;
decrypt_wal_frames(
&mut wal_file,
&mut db_file,
enc_key,
&mac_key,
&wal_header,
wal_len,
params,
)
}
// --- Internal helpers ---
/// Open the WAL file, parse its header, and return (file, header, len).
/// Returns Ok with wal_len=0 for empty/too-small WAL files.
fn open_wal(wal_path: &Path) -> Result<(File, WalHeader, usize), DecryptError> {
let wal_len = std::fs::metadata(wal_path)?.len() as usize;
if wal_len < WAL_HEADER_SIZE {
// Return a dummy header — caller checks wal_len and returns Ok(0).
return Ok((
File::open(wal_path)?,
WalHeader { salt1: 0, salt2: 0 },
wal_len,
));
}
let mut wal_file = File::open(wal_path)?;
let mut wal_hdr_buf = [0u8; WAL_HEADER_SIZE];
wal_file.read_exact(&mut wal_hdr_buf)?;
let wal_header = WalHeader::parse(&wal_hdr_buf)?;
Ok((wal_file, wal_header, wal_len))
}
/// Process WAL frames: decrypt each valid frame up to the last commit boundary
/// and patch it into the DB file.
///
/// Uses a two-phase approach to match SQLite WAL reader semantics:
/// - Phase 1: Scan all frame headers to find the last frame with `commit_size > 0`.
/// - Phase 2: Apply frames 0..=last_commit_idx, skipping the rest.
///
/// Returns `Ok(0)` if there are no committed transactions in the WAL.
fn decrypt_wal_frames(
wal_file: &mut File,
db_file: &mut File,
enc_key: &[u8; 32],
mac_key: &[u8; 32],
wal_header: &WalHeader,
wal_len: usize,
params: &CryptoParams,
) -> Result<usize, DecryptError> {
let frame_size = WAL_FRAME_HEADER_SIZE + params.page_size;
let mut frame_hdr_buf = [0u8; WAL_FRAME_HEADER_SIZE];
// Phase 1: Find the last committed frame (commit_size > 0).
let total_frames = wal_len.saturating_sub(WAL_HEADER_SIZE) / frame_size;
let mut last_commit_idx: Option<usize> = None;
for i in 0..total_frames {
let hdr_offset = (WAL_HEADER_SIZE + i * frame_size) as u64;
wal_file.seek(SeekFrom::Start(hdr_offset))?;
wal_file.read_exact(&mut frame_hdr_buf)?;
let fh = WalFrameHeader::parse(&frame_hdr_buf);
if fh.is_valid(wal_header) && fh.commit_size > 0 {
last_commit_idx = Some(i);
}
}
let Some(last_commit) = last_commit_idx else {
// No committed transaction found; nothing to apply.
return Ok(0);
};
// Phase 2: Apply frames 0..=last_commit sequentially.
let mut page_buf = vec![0u8; params.page_size];
let mut patched: usize = 0;
wal_file.seek(SeekFrom::Start(WAL_HEADER_SIZE as u64))?;
for _ in 0..=last_commit {
wal_file.read_exact(&mut frame_hdr_buf)?;
let frame_hdr = WalFrameHeader::parse(&frame_hdr_buf);
wal_file.read_exact(&mut page_buf)?;
if !frame_hdr.is_valid(wal_header) {
continue;
}
if page_buf.iter().all(|&b| b == 0) {
continue;
}
// WAL page_number is 1-indexed; decrypt_page expects 0-indexed.
let page_num_0 = frame_hdr.page_number - 1;
let decrypted = decrypt_page(&page_buf, enc_key, mac_key, page_num_0, params)?;
// For page 0, decrypt_page returns 4080 bytes (skips salt area).
// Prepend the SQLite header to restore full page size.
let write_data = if page_num_0 == 0 {
let mut full = Vec::with_capacity(params.page_size);
full.extend_from_slice(b"SQLite format 3\0");
full.extend_from_slice(&decrypted);
full
} else {
decrypted
};
let offset = (page_num_0 as u64) * (params.page_size as u64);
db_file.seek(SeekFrom::Start(offset))?;
db_file.write_all(&write_data)?;
patched += 1;
}
db_file.flush()?;
Ok(patched)
}
/// Derive the encrypted DB path from a WAL path by stripping the "-wal" suffix.
pub(crate) fn wal_path_to_db_path(wal_path: &Path) -> Result<std::path::PathBuf, DecryptError> {
let wal_name = wal_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| DecryptError::InvalidWalHeader {
reason: "cannot determine WAL filename".to_string(),
})?;
if !wal_name.ends_with("-wal") {
return Err(DecryptError::InvalidWalHeader {
reason: format!("WAL path does not end with -wal: {wal_name}"),
});
}
let db_name = &wal_name[..wal_name.len() - 4];
Ok(wal_path.with_file_name(db_name))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_wal_header(salt1: u32, salt2: u32) -> [u8; WAL_HEADER_SIZE] {
let mut buf = [0u8; WAL_HEADER_SIZE];
buf[0..4].copy_from_slice(&WAL_MAGIC_BE.to_be_bytes());
buf[4..8].copy_from_slice(&3007000u32.to_be_bytes());
buf[8..12].copy_from_slice(&4096u32.to_be_bytes());
buf[12..16].copy_from_slice(&1u32.to_be_bytes());
buf[16..20].copy_from_slice(&salt1.to_be_bytes());
buf[20..24].copy_from_slice(&salt2.to_be_bytes());
buf
}
fn make_frame_header(
pgno: u32,
commit_size: u32,
salt1: u32,
salt2: u32,
) -> [u8; WAL_FRAME_HEADER_SIZE] {
let mut buf = [0u8; WAL_FRAME_HEADER_SIZE];
buf[0..4].copy_from_slice(&pgno.to_be_bytes());
buf[4..8].copy_from_slice(&commit_size.to_be_bytes());
buf[8..12].copy_from_slice(&salt1.to_be_bytes());
buf[12..16].copy_from_slice(&salt2.to_be_bytes());
buf
}
#[test]
fn test_wal_header_parse_valid() {
let buf = make_wal_header(0xAABBCCDD, 0x11223344);
let hdr = WalHeader::parse(&buf).unwrap();
assert_eq!(hdr.salt1, 0xAABBCCDD);
assert_eq!(hdr.salt2, 0x11223344);
}
#[test]
fn test_wal_header_parse_le_magic() {
let mut buf = make_wal_header(1, 2);
buf[0..4].copy_from_slice(&WAL_MAGIC_LE.to_be_bytes());
let hdr = WalHeader::parse(&buf).unwrap();
assert_eq!(hdr.salt1, 1);
}
#[test]
fn test_wal_header_parse_bad_magic() {
let mut buf = [0u8; WAL_HEADER_SIZE];
buf[0..4].copy_from_slice(&0xDEADBEEFu32.to_be_bytes());
let err = WalHeader::parse(&buf).unwrap_err();
assert!(err.to_string().contains("bad magic"));
}
#[test]
fn test_frame_header_valid() {
let wal_hdr = WalHeader {
salt1: 100,
salt2: 200,
};
let fh = WalFrameHeader::parse(&make_frame_header(5, 0, 100, 200));
assert!(fh.is_valid(&wal_hdr));
assert_eq!(fh.page_number, 5);
}
#[test]
fn test_frame_header_stale_salt() {
let wal_hdr = WalHeader {
salt1: 100,
salt2: 200,
};
let fh = WalFrameHeader::parse(&make_frame_header(5, 0, 99, 200));
assert!(!fh.is_valid(&wal_hdr));
}
#[test]
fn test_frame_header_zero_pgno() {
let wal_hdr = WalHeader {
salt1: 100,
salt2: 200,
};
let fh = WalFrameHeader::parse(&make_frame_header(0, 0, 100, 200));
assert!(!fh.is_valid(&wal_hdr));
}
#[test]
fn test_frame_header_pgno_too_large() {
let wal_hdr = WalHeader {
salt1: 100,
salt2: 200,
};
let fh = WalFrameHeader::parse(&make_frame_header(MAX_PAGE_NUMBER + 1, 0, 100, 200));
assert!(!fh.is_valid(&wal_hdr));
}
use crate::kdf::{derive_enc_key, derive_mac_key};
use crate::params::MACOS_4_1_7_31;
/// Build a fake encrypted page (reverse of decrypt_page).
fn build_encrypted_page(
content: &[u8],
enc_key: &[u8; 32],
mac_key: &[u8; 32],
page_num: u32,
params: &CryptoParams,
salt: Option<&[u8; 16]>,
) -> Vec<u8> {
use aes::cipher::{block_padding::NoPadding, BlockEncryptMut, KeyIvInit};
use hmac::{Hmac, Mac};
use sha2::Sha512;
type HmacSha512 = Hmac<Sha512>;
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
let iv = [0x42u8; 16]; // deterministic IV for testing
let offset = if page_num == 0 { params.salt_size } else { 0 };
let data_len = params.page_size - params.reserve - offset;
let mut plaintext = vec![0u8; data_len];
let copy_len = content.len().min(data_len);
plaintext[..copy_len].copy_from_slice(&content[..copy_len]);
let mut ciphertext = plaintext.clone();
Aes256CbcEnc::new(enc_key.into(), (&iv).into())
.encrypt_padded_mut::<NoPadding>(&mut ciphertext, data_len)
.expect("encryption should not fail");
let mut page = vec![0u8; params.page_size];
if page_num == 0 {
page[..params.salt_size].copy_from_slice(salt.unwrap());
page[offset..offset + data_len].copy_from_slice(&ciphertext);
} else {
page[..data_len].copy_from_slice(&ciphertext);
}
// Place IV in reserve area
let iv_start = params.page_size - params.reserve;
page[iv_start..iv_start + params.iv_size].copy_from_slice(&iv);
// Compute HMAC
let hmac_data_end = params.page_size - params.reserve + params.iv_size;
let mut mac = HmacSha512::new_from_slice(mac_key).unwrap();
mac.update(&page[offset..hmac_data_end]);
mac.update(&(page_num + 1).to_le_bytes());
let hmac_result = mac.finalize().into_bytes();
page[hmac_data_end..hmac_data_end + params.hmac_size]
.copy_from_slice(&hmac_result[..params.hmac_size]);
page
}
/// Build a WAL file in memory: header + frames.
fn build_wal_file(salt1: u32, salt2: u32, frames: &[(u32, u32, u32, u32, &[u8])]) -> Vec<u8> {
let mut wal = make_wal_header(salt1, salt2).to_vec();
for &(pgno, commit_size, fsalt1, fsalt2, page_data) in frames {
let fh = make_frame_header(pgno, commit_size, fsalt1, fsalt2);
wal.extend_from_slice(&fh);
wal.extend_from_slice(page_data);
}
wal
}
#[test]
fn test_decrypt_wal_patches_valid_frames() {
let params = &MACOS_4_1_7_31;
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let dir = tempfile::tempdir().unwrap();
// Create fake encrypted DB (2 pages)
let enc_db_path = dir.path().join("test.db");
let page0 = build_encrypted_page(
b"page0-original",
&enc_key,
&mac_key,
0,
params,
Some(&salt),
);
let page1 = build_encrypted_page(b"page1-original", &enc_key, &mac_key, 1, params, None);
std::fs::write(&enc_db_path, [page0, page1].concat()).unwrap();
// Create decrypted DB placeholder (2 pages of zeros)
let dec_db_path = dir.path().join("test_decrypted.db");
std::fs::write(&dec_db_path, vec![0u8; params.page_size * 2]).unwrap();
// WAL with 1 valid frame (page 2, 1-indexed) + 1 stale frame
let new_page1 =
build_encrypted_page(b"page1-from-wal", &enc_key, &mac_key, 1, params, None);
let stale_page = build_encrypted_page(b"stale-data", &enc_key, &mac_key, 1, params, None);
let wal_salt1: u32 = 0xAAAA;
let wal_salt2: u32 = 0xBBBB;
let wal_data = build_wal_file(
wal_salt1,
wal_salt2,
&[
(2, 1, wal_salt1, wal_salt2, &new_page1),
(2, 0, 0xDEAD, 0xBEEF, &stale_page),
],
);
let wal_path = dir.path().join("test.db-wal");
std::fs::write(&wal_path, &wal_data).unwrap();
let patched = decrypt_wal(&wal_path, &dec_db_path, &raw_key, params).unwrap();
assert_eq!(patched, 1, "should patch exactly 1 valid frame");
// Verify patched page contains the expected decrypted content.
let result = std::fs::read(&dec_db_path).unwrap();
let patched_page = &result[params.page_size..params.page_size * 2];
assert_eq!(
&patched_page[..14],
b"page1-from-wal",
"patched page content should match WAL frame plaintext"
);
assert!(
patched_page[14..params.page_size - params.reserve]
.iter()
.all(|&b| b == 0),
"rest of data area should be zero-padded"
);
}
#[test]
fn test_decrypt_wal_patches_page0_with_sqlite_header() {
let params = &MACOS_4_1_7_31;
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let dir = tempfile::tempdir().unwrap();
// Create fake encrypted DB (1 page)
let enc_db_path = dir.path().join("test.db");
let page0 = build_encrypted_page(
b"page0-original",
&enc_key,
&mac_key,
0,
params,
Some(&salt),
);
std::fs::write(&enc_db_path, &page0).unwrap();
// Create decrypted DB placeholder (1 page of zeros)
let dec_db_path = dir.path().join("test_decrypted.db");
std::fs::write(&dec_db_path, vec![0u8; params.page_size]).unwrap();
// WAL with 1 valid frame for page 1 (1-indexed = page 0, 0-indexed)
let new_page0 = build_encrypted_page(
b"page0-from-wal",
&enc_key,
&mac_key,
0,
params,
Some(&salt),
);
let wal_salt1: u32 = 0x1111;
let wal_salt2: u32 = 0x2222;
let wal_data = build_wal_file(
wal_salt1,
wal_salt2,
&[(1, 1, wal_salt1, wal_salt2, &new_page0)],
);
let wal_path = dir.path().join("test.db-wal");
std::fs::write(&wal_path, &wal_data).unwrap();
let patched = decrypt_wal(&wal_path, &dec_db_path, &raw_key, params).unwrap();
assert_eq!(patched, 1);
let result = std::fs::read(&dec_db_path).unwrap();
assert_eq!(
&result[..16],
b"SQLite format 3\0",
"page 0 should have SQLite header prepended"
);
assert_eq!(
&result[16..16 + 14],
b"page0-from-wal",
"page 0 decrypted content should match WAL frame plaintext"
);
}
#[test]
fn test_decrypt_wal_header_only_returns_zero() {
let params = &MACOS_4_1_7_31;
let dir = tempfile::tempdir().unwrap();
let enc_db_path = dir.path().join("test.db");
std::fs::write(&enc_db_path, vec![0xAAu8; params.page_size]).unwrap();
let dec_db_path = dir.path().join("test_dec.db");
std::fs::write(&dec_db_path, vec![0u8; params.page_size]).unwrap();
let wal_data = make_wal_header(1, 2).to_vec();
let wal_path = dir.path().join("test.db-wal");
std::fs::write(&wal_path, &wal_data).unwrap();
let result = decrypt_wal(&wal_path, &dec_db_path, &[0u8; 32], params).unwrap();
assert_eq!(result, 0, "header-only WAL should return Ok(0)");
}
#[test]
fn test_decrypt_wal_too_small_returns_zero() {
let params = &MACOS_4_1_7_31;
let dir = tempfile::tempdir().unwrap();
let wal_path = dir.path().join("tiny.db-wal");
std::fs::write(&wal_path, [0u8; 16]).unwrap();
let result = decrypt_wal(&wal_path, Path::new("/nonexistent"), &[0u8; 32], params);
assert_eq!(
result.unwrap(),
0,
"WAL smaller than header should return Ok(0)"
);
}
#[test]
fn test_decrypt_wal_zero_bytes_returns_zero() {
let params = &MACOS_4_1_7_31;
let dir = tempfile::tempdir().unwrap();
let wal_path = dir.path().join("empty.db-wal");
std::fs::write(&wal_path, []).unwrap();
let result = decrypt_wal(&wal_path, Path::new("/nonexistent"), &[0u8; 32], params);
assert_eq!(result.unwrap(), 0, "0-byte WAL should return Ok(0)");
}
#[test]
fn test_wal_path_to_db_path() {
let wal = Path::new("/data/session/session.db-wal");
let db = wal_path_to_db_path(wal).unwrap();
assert_eq!(db, Path::new("/data/session/session.db"));
}
#[test]
fn test_wal_path_to_db_path_invalid() {
let bad = Path::new("/data/session/session.db");
assert!(wal_path_to_db_path(bad).is_err());
}
// --- Commit boundary tests ---
/// Helper: build a minimal WAL + decrypted DB pair for commit boundary tests.
///
/// Returns `(wal_path, dec_db_path, tempdir)`.
fn setup_commit_boundary_test(
frames: &[(u32, u32, &[u8])], // (pgno, commit_size, page_content)
) -> (std::path::PathBuf, std::path::PathBuf, tempfile::TempDir) {
let params = &MACOS_4_1_7_31;
let raw_key = [0xCDu8; 32];
let salt = [0x02u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let wal_salt1: u32 = 0x1234;
let wal_salt2: u32 = 0x5678;
let dir = tempfile::tempdir().unwrap();
// Build encrypted pages for all unique page numbers.
let max_pgno = frames.iter().map(|f| f.0).max().unwrap_or(1);
// Create a fake encrypted DB large enough for all pages.
let enc_db_path = dir.path().join("cbt.db");
let mut enc_db = vec![0xFFu8; params.page_size * max_pgno as usize];
// Page 0 (1-indexed page 1) needs a salt prefix.
enc_db[..16].copy_from_slice(&salt);
std::fs::write(&enc_db_path, &enc_db).unwrap();
// Create blank decrypted DB.
let dec_db_path = dir.path().join("cbt_dec.db");
std::fs::write(
&dec_db_path,
vec![0u8; params.page_size * max_pgno as usize],
)
.unwrap();
// Build WAL frames.
let frame_data: Vec<(u32, u32, u32, u32, Vec<u8>)> = frames
.iter()
.map(|&(pgno, commit_size, content)| {
let page_num_0 = pgno - 1;
let page = build_encrypted_page(
content,
&enc_key,
&mac_key,
page_num_0,
params,
if page_num_0 == 0 { Some(&salt) } else { None },
);
(pgno, commit_size, wal_salt1, wal_salt2, page)
})
.collect();
let frame_refs: Vec<(u32, u32, u32, u32, &[u8])> = frame_data
.iter()
.map(|(pg, cs, s1, s2, data)| (*pg, *cs, *s1, *s2, data.as_slice()))
.collect();
let wal_bytes = build_wal_file(wal_salt1, wal_salt2, &frame_refs);
let wal_path = dir.path().join("cbt.db-wal");
std::fs::write(&wal_path, &wal_bytes).unwrap();
(wal_path, dec_db_path, dir)
}
/// Content-unique page data for testing (avoids all-zero page skip).
fn page_content(tag: u8) -> Vec<u8> {
vec![tag; 32]
}
#[test]
fn test_commit_boundary_partial_transaction_skipped() {
// Frame A: pgno=1, commit_size=0 (non-commit frame of a transaction)
// Frame B: pgno=2, commit_size=5 (commit frame — this is the boundary)
// Frame C: pgno=3, commit_size=0 (new uncommitted transaction — must be skipped)
let pa = page_content(0xA1);
let pb = page_content(0xA2);
let pc = page_content(0xA3);
let frames: &[(u32, u32, &[u8])] = &[(1, 0, &pa), (2, 5, &pb), (3, 0, &pc)];
let (wal_path, dec_db_path, _dir) = setup_commit_boundary_test(frames);
let params = &MACOS_4_1_7_31;
let raw_key = [0xCDu8; 32];
let patched = decrypt_wal(&wal_path, &dec_db_path, &raw_key, params).unwrap();
// Frames A and B applied; frame C skipped.
assert_eq!(patched, 2, "frames A and B should be patched; C skipped");
}
#[test]
fn test_no_commit_frame_returns_zero() {
// All frames have commit_size=0 — no committed transaction.
let pa = page_content(0xB1);
let pb = page_content(0xB2);
let frames: &[(u32, u32, &[u8])] = &[(1, 0, &pa), (2, 0, &pb)];
let (wal_path, dec_db_path, _dir) = setup_commit_boundary_test(frames);
let params = &MACOS_4_1_7_31;
let raw_key = [0xCDu8; 32];
let patched = decrypt_wal(&wal_path, &dec_db_path, &raw_key, params).unwrap();
assert_eq!(
patched, 0,
"no commit frame means nothing should be applied"
);
}
#[test]
fn test_all_committed_frames_applied() {
// Both frames are commit frames — all should be applied.
let pa = page_content(0xC1);
let pb = page_content(0xC2);
let frames: &[(u32, u32, &[u8])] = &[(1, 3, &pa), (2, 7, &pb)];
let (wal_path, dec_db_path, _dir) = setup_commit_boundary_test(frames);
let params = &MACOS_4_1_7_31;
let raw_key = [0xCDu8; 32];
let patched = decrypt_wal(&wal_path, &dec_db_path, &raw_key, params).unwrap();
assert_eq!(patched, 2, "both committed frames should be applied");
}
#[test]
fn test_commit_boundary_multi_transaction_last_wins() {
// 5 frames, two complete transactions + one incomplete:
// Frame 0 (pgno=2, commit_size=0): non-commit, part of tx1
// Frame 1 (pgno=3, commit_size=3): commit, ends tx1
// Frame 2 (pgno=4, commit_size=0): non-commit, part of tx2
// Frame 3 (pgno=5, commit_size=6): commit, ends tx2
// Frame 4 (pgno=6, commit_size=0): incomplete tx3, must be SKIPPED
let p0 = page_content(0xD0);
let p1 = page_content(0xD1);
let p2 = page_content(0xD2);
let p3 = page_content(0xD3);
let p4 = page_content(0xD4);
let frames: &[(u32, u32, &[u8])] = &[
(2, 0, &p0),
(3, 3, &p1),
(4, 0, &p2),
(5, 6, &p3),
(6, 0, &p4),
];
let (wal_path, dec_db_path, _dir) = setup_commit_boundary_test(frames);
let params = &MACOS_4_1_7_31;
let raw_key = [0xCDu8; 32];
let patched = decrypt_wal(&wal_path, &dec_db_path, &raw_key, params).unwrap();
// Frames 0-3 applied (4 frames); frame 4 skipped.
assert_eq!(patched, 4, "frames 0-3 should be patched; frame 4 skipped");
// Verify frame 4 (pgno=6, 0-indexed=5) was NOT written to the DB.
let db_contents = std::fs::read(&dec_db_path).unwrap();
let page5_offset = 5 * params.page_size;
let page5 = &db_contents[page5_offset..page5_offset + params.page_size];
assert!(
page5.iter().all(|&b| b == 0),
"page 5 (frame 4, pgno=6) should remain zero — not patched"
);
}
#[test]
fn test_decrypt_wal_direct_produces_same_result() {
let params = &MACOS_4_1_7_31;
let raw_key = [0xABu8; 32];
let salt = [0x01u8; 16];
let enc_key = derive_enc_key(&raw_key, &salt, params);
let mac_key = derive_mac_key(&enc_key, &salt, params);
let dir = tempfile::tempdir().unwrap();
// Create fake encrypted DB (2 pages)
let enc_db_path = dir.path().join("test.db");
let page0 = build_encrypted_page(
b"wal-direct-test",
&enc_key,
&mac_key,
0,
params,
Some(&salt),
);
let page1 = build_encrypted_page(b"page1-data", &enc_key, &mac_key, 1, params, None);
std::fs::write(&enc_db_path, [page0, page1].concat()).unwrap();
let wal_salt1: u32 = 0x5555;
let wal_salt2: u32 = 0x6666;
let new_page1 = build_encrypted_page(b"page1-updated", &enc_key, &mac_key, 1, params, None);
let wal_data = build_wal_file(
wal_salt1,
wal_salt2,
&[(2, 1, wal_salt1, wal_salt2, &new_page1)],
);
let wal_path = dir.path().join("test.db-wal");
std::fs::write(&wal_path, &wal_data).unwrap();
// Decrypt with raw key
let dec_raw = dir.path().join("dec_raw.db");
std::fs::write(&dec_raw, vec![0u8; params.page_size * 2]).unwrap();
let patched_raw = decrypt_wal(&wal_path, &dec_raw, &raw_key, params).unwrap();
// Decrypt with direct enc_key
let dec_direct = dir.path().join("dec_direct.db");
std::fs::write(&dec_direct, vec![0u8; params.page_size * 2]).unwrap();
let patched_direct =
decrypt_wal_direct(&wal_path, &dec_direct, &enc_key, &salt, params).unwrap();
assert_eq!(patched_raw, patched_direct);
assert_eq!(
std::fs::read(&dec_raw).unwrap(),
std::fs::read(&dec_direct).unwrap(),
);
}
}