perf: eliminate repeated SQLCipher key derivation

This commit is contained in:
pandorafuture
2026-07-10 19:09:49 +08:00
parent b31a416f20
commit fffe4c9c22
16 changed files with 515 additions and 125 deletions
Generated
+1
View File
@@ -3203,6 +3203,7 @@ dependencies = [
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"wx-decrypt",
"zstd", "zstd",
] ]
+15
View File
@@ -8,3 +8,18 @@ edition = "2021"
license = "MIT" license = "MIT"
repository = "https://github.com/pandorafuture/wx-cli" repository = "https://github.com/pandorafuture/wx-cli"
description = "WeChat macOS database decryption and query tool" description = "WeChat macOS database decryption and query tool"
# PBKDF2 intentionally runs 256k rounds. Keep the crypto crate optimized in
# dev/test builds so parallel integration tests and local debug binaries do not
# spend seconds per database deriving SQLCipher keys.
[profile.dev.package.wx-decrypt]
opt-level = 3
[profile.dev.package.pbkdf2]
opt-level = 3
[profile.dev.package.sha2]
opt-level = 3
[profile.dev.package.hmac]
opt-level = 3
+17 -18
View File
@@ -77,24 +77,23 @@ pub fn cmd_query(
if options.is_enabled() && !preserve_local_warning { if options.is_enabled() && !preserve_local_warning {
let client = ThinClient::new(options.clone()); let client = ThinClient::new(options.clone());
match client.probe_health().and_then(|_| { match client.probe_health() {
fetch_remote_query( Ok(()) => {
&client, let envelope = fetch_remote_query(
contact, &client,
since, contact,
until, since,
msg_type.clone(), until,
effective_limit, msg_type.clone(),
offset, effective_limit,
order.clone(), offset,
around_sort_seq, order.clone(),
around_server_id, around_sort_seq,
context, around_server_id,
after_sort_seq, context,
show_hidden, after_sort_seq,
) show_hidden,
}) { )?;
Ok(envelope) => {
let is_group = envelope let is_group = envelope
.items .items
.first() .first()
+5 -4
View File
@@ -1,8 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use wx_context::{ use wx_context::{register_mm_fts_tokenizer, AccountContext, ContactResolver, ResolveParams};
open_fts_connection_with_key, AccountContext, ContactResolver, ResolveParams,
};
use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions}; use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions};
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta}; use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
@@ -64,7 +62,10 @@ fn load_local_search(
// --- Native FTS search → fallback to scan --- // --- Native FTS search → fallback to scan ---
let use_fallback = match db.message_fts_path.as_deref() { let use_fallback = match db.message_fts_path.as_deref() {
Some(fts_path) => match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) { Some(fts_path) => match db.open_related_readonly(fts_path).and_then(|conn| {
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
Ok(conn)
}) {
Ok(conn) => { Ok(conn) => {
match wx_db::native_fts::search_message_fts( match wx_db::native_fts::search_message_fts(
&conn, &conn,
+13 -7
View File
@@ -19,8 +19,8 @@ use tokio::signal::unix::SignalKind;
use tokio::sync::{broadcast, mpsc, watch}; use tokio::sync::{broadcast, mpsc, watch};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use wx_context::{ use wx_context::{
open_fts_connection_with_key, register_mm_fts_tokenizer, write_shard_metadata_sidecar, register_mm_fts_tokenizer, write_shard_metadata_sidecar, AccountContext, ContactResolver,
AccountContext, ContactResolver, DecryptRequest, PersistentCache, ResolveParams, DecryptRequest, PersistentCache, ResolveParams,
}; };
use crate::util::{print_cache_stats, print_detection_note}; use crate::util::{print_cache_stats, print_detection_note};
@@ -122,7 +122,10 @@ pub async fn cmd_serve(
// 3b. Open independent FTS connection (outside WechatDb Mutex) // 3b. Open independent FTS connection (outside WechatDb Mutex)
let fts_conn = db.message_fts_path.as_deref().and_then(|fts_path| { let fts_conn = db.message_fts_path.as_deref().and_then(|fts_path| {
match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) { match db.open_related_readonly(fts_path).and_then(|conn| {
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
Ok(conn)
}) {
Ok(conn) => { Ok(conn) => {
if let Ok(mode) = if let Ok(mode) =
conn.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0)) conn.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
@@ -196,7 +199,7 @@ pub async fn cmd_serve(
// 3c. Open hardlink.db connection (pooled, outside WechatDb Mutex) // 3c. Open hardlink.db connection (pooled, outside WechatDb Mutex)
let hardlink_db_conn = if hardlink_db_path.exists() { let hardlink_db_conn = if hardlink_db_path.exists() {
match wx_db::open_readonly_connection(&hardlink_db_path, acct.raw_key.as_ref()) { match db.open_related_readonly(&hardlink_db_path) {
Ok(conn) => { Ok(conn) => {
eprintln!("server/hardlink: opened pooled connection"); eprintln!("server/hardlink: opened pooled connection");
Some(conn) Some(conn)
@@ -222,9 +225,14 @@ pub async fn cmd_serve(
} }
let watch_mode = resolve_watch_mode(poll, fsnotify); let watch_mode = resolve_watch_mode(poll, fsnotify);
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
let config = wx_monitor::MonitorConfig { let config = wx_monitor::MonitorConfig {
encrypted_session_dir, encrypted_session_dir,
key_material: acct.key_material.clone(), key_material: if monitor_derived_keys.is_empty() {
acct.key_material.clone()
} else {
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
},
params, params,
watch_mode: watch_mode.clone(), watch_mode: watch_mode.clone(),
poll_interval: Duration::from_millis(poll_ms), poll_interval: Duration::from_millis(poll_ms),
@@ -239,7 +247,6 @@ pub async fn cmd_serve(
// Capture values before moving db into Mutex // Capture values before moving db into Mutex
let fts_path_for_refresh = db.message_fts_path.clone(); let fts_path_for_refresh = db.message_fts_path.clone();
let raw_key_for_refresh = acct.raw_key;
// 5. Create refresh task channels // 5. Create refresh task channels
let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshTrigger>(64); let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshTrigger>(64);
@@ -398,7 +405,6 @@ pub async fn cmd_serve(
shutdown_bg.clone(), shutdown_bg.clone(),
) )
.with_fts(bg_state.fts_conn.clone(), fts_path_for_refresh) .with_fts(bg_state.fts_conn.clone(), fts_path_for_refresh)
.with_raw_key(raw_key_for_refresh)
.with_caches( .with_caches(
Some(Arc::clone(&bg_state.name2id_cache)), Some(Arc::clone(&bg_state.name2id_cache)),
Some(Arc::clone(&bg_state.media_db_paths)), Some(Arc::clone(&bg_state.media_db_paths)),
+5 -11
View File
@@ -6,7 +6,7 @@ use rusqlite::Connection;
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use wx_context::{ use wx_context::{
open_fts_connection, open_fts_connection_with_key, DecryptProgress, DecryptRequest, open_fts_connection, register_mm_fts_tokenizer, DecryptProgress, DecryptRequest,
PersistentCache, PersistentCache,
}; };
use wx_db::WechatDb; use wx_db::WechatDb;
@@ -35,8 +35,6 @@ pub struct RefreshTask {
fts_conn: Option<Arc<std::sync::Mutex<Connection>>>, fts_conn: Option<Arc<std::sync::Mutex<Connection>>>,
/// Path to FTS DB for reopening. /// Path to FTS DB for reopening.
fts_path: Option<PathBuf>, fts_path: Option<PathBuf>,
/// Raw key for encrypted FTS reopen.
raw_key: Option<[u8; 32]>,
/// Cache of name2id mapping — cleared when FTS is reopened. /// Cache of name2id mapping — cleared when FTS is reopened.
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>, name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>,
/// Cache of media DB paths — cleared on every refresh. /// Cache of media DB paths — cleared on every refresh.
@@ -61,18 +59,12 @@ impl RefreshTask {
shutdown, shutdown,
fts_conn: None, fts_conn: None,
fts_path: None, fts_path: None,
raw_key: None,
name2id_cache: None, name2id_cache: None,
media_db_paths: None, media_db_paths: None,
hardlink_db_conn: None, hardlink_db_conn: None,
} }
} }
pub fn with_raw_key(mut self, raw_key: Option<[u8; 32]>) -> Self {
self.raw_key = raw_key;
self
}
/// Set the independent FTS connection and path for refresh reopening. /// Set the independent FTS connection and path for refresh reopening.
pub fn with_fts( pub fn with_fts(
mut self, mut self,
@@ -128,7 +120,6 @@ impl RefreshTask {
let cache = self.cache.clone(); let cache = self.cache.clone();
let fts_conn = self.fts_conn.clone(); let fts_conn = self.fts_conn.clone();
let fts_path = self.fts_path.clone(); let fts_path = self.fts_path.clone();
let raw_key = self.raw_key;
let success = tokio::task::spawn_blocking(move || { let success = tokio::task::spawn_blocking(move || {
if let Some(cache) = cache { if let Some(cache) = cache {
// Decrypt-cache mode: decrypt then selective reopen // Decrypt-cache mode: decrypt then selective reopen
@@ -264,7 +255,10 @@ impl RefreshTask {
// Reopen independent FTS connection // Reopen independent FTS connection
if let (Some(fts_mutex), Some(path)) = (&fts_conn, &fts_path) { if let (Some(fts_mutex), Some(path)) = (&fts_conn, &fts_path) {
match open_fts_connection_with_key(path, raw_key.as_ref()) { match guard.open_related_readonly(path).and_then(|conn| {
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
Ok(conn)
}) {
Ok(new_conn) => { Ok(new_conn) => {
if let Ok(mut fts_guard) = fts_mutex.lock() if let Ok(mut fts_guard) = fts_mutex.lock()
as Result<std::sync::MutexGuard<'_, Connection>, _> as Result<std::sync::MutexGuard<'_, Connection>, _>
+6 -1
View File
@@ -318,9 +318,14 @@ pub async fn cmd_watch(
} }
let watch_mode = resolve_watch_mode(poll, fsnotify); let watch_mode = resolve_watch_mode(poll, fsnotify);
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
let config = wx_monitor::MonitorConfig { let config = wx_monitor::MonitorConfig {
encrypted_session_dir, encrypted_session_dir,
key_material: acct.key_material.clone(), key_material: if monitor_derived_keys.is_empty() {
acct.key_material.clone()
} else {
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
},
params, params,
watch_mode: watch_mode.clone(), watch_mode: watch_mode.clone(),
poll_interval: Duration::from_millis(poll_ms), poll_interval: Duration::from_millis(poll_ms),
+8 -7
View File
@@ -10,7 +10,7 @@ pub fn open_db_core(
) -> Result<(wx_db::WechatDb, Option<DecryptStats>), Box<dyn std::error::Error>> { ) -> Result<(wx_db::WechatDb, Option<DecryptStats>), Box<dyn std::error::Error>> {
if acct.raw_key.is_some() { if acct.raw_key.is_some() {
eprintln!("Direct encrypted open (SQLCipher)"); eprintln!("Direct encrypted open (SQLCipher)");
let db = wx_context::open_encrypted_db(acct)?; let db = wx_context::open_encrypted_db_core(acct)?;
Ok((db, None)) Ok((db, None))
} else { } else {
let params = &wx_decrypt::MACOS_4_1_7_31; let params = &wx_decrypt::MACOS_4_1_7_31;
@@ -18,7 +18,7 @@ pub fn open_db_core(
let stats = DecryptRequest::new() let stats = DecryptRequest::new()
.core() .core()
.execute_with_progress(&cache, progress)?; .execute_with_progress(&cache, progress)?;
let db = wx_db::WechatDb::open(cache.decrypted_root())?; let db = wx_db::WechatDb::open_core(cache.decrypted_root())?;
Ok((db, Some(stats))) Ok((db, Some(stats)))
} }
} }
@@ -139,9 +139,10 @@ pub fn effective_limit_all(all: bool, limit: usize) -> usize {
} }
} }
/// Attempt a remote API call via ThinClient; on connection/auth failure fall back to the /// Attempt a remote API call via ThinClient. In auto mode, fall back locally only when
/// local path. This encapsulates the `probe_health → remote_fn → should_fallback → local_fn` /// the initial health probe cannot reach/authenticate with a usable server. Once health
/// pattern shared by `search`, `contacts`, and `sessions`. /// succeeds, a failed business request is returned to the caller instead of launching an
/// expensive local SQLCipher query after waiting for the remote timeout.
pub fn try_remote_or_local<T>( pub fn try_remote_or_local<T>(
options: &ThinClientOptions, options: &ThinClientOptions,
remote_fn: impl FnOnce(&ThinClient) -> Result<T, ThinClientError>, remote_fn: impl FnOnce(&ThinClient) -> Result<T, ThinClientError>,
@@ -150,8 +151,8 @@ pub fn try_remote_or_local<T>(
) -> Result<T, Box<dyn std::error::Error>> { ) -> Result<T, Box<dyn std::error::Error>> {
if options.is_enabled() { if options.is_enabled() {
let client = ThinClient::new(options.clone()); let client = ThinClient::new(options.clone());
match client.probe_health().and_then(|_| remote_fn(&client)) { match client.probe_health() {
Ok(result) => return Ok(result), Ok(()) => return remote_fn(&client).map_err(Into::into),
Err(err) if err.should_fallback(options.mode) => { Err(err) if err.should_fallback(options.mode) => {
eprintln!( eprintln!(
"note: remote server unavailable, falling back to local {label} ({})", "note: remote server unavailable, falling back to local {label} ({})",
+39 -2
View File
@@ -4,6 +4,8 @@ use std::process::Command;
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tempfile::TempDir;
fn bin() -> &'static str { fn bin() -> &'static str {
env!("CARGO_BIN_EXE_wx-cli") env!("CARGO_BIN_EXE_wx-cli")
} }
@@ -278,7 +280,8 @@ fn server_only_fails_when_remote_unavailable() {
#[test] #[test]
fn unavailable_remote_falls_back_to_local() { fn unavailable_remote_falls_back_to_local() {
let output = Command::new(bin()) let (mut command, _home) = command_without_local_account();
let output = command
.args(["sessions", "--server-url", "http://127.0.0.1:9"]) .args(["sessions", "--server-url", "http://127.0.0.1:9"])
.output() .output()
.expect("run sessions fallback"); .expect("run sessions fallback");
@@ -314,7 +317,8 @@ fn no_server_bypasses_remote_probe() {
} }
}); });
let output = Command::new(bin()) let (mut command, _home) = command_without_local_account();
let output = command
.args([ .args([
"sessions", "sessions",
"--no-server", "--no-server",
@@ -333,6 +337,39 @@ fn no_server_bypasses_remote_probe() {
); );
} }
#[test]
fn healthy_server_business_transport_failure_does_not_fall_back() {
let (base_url, handle) = spawn_sequence_server(2, |_request, index| match index {
0 => http_response("200 OK", "{\"ready\":true}"),
// Close the second connection without a response. This is classified as an
// unavailable transport error, but health already proved the server was selected.
1 => String::new(),
_ => unreachable!(),
});
let output = Command::new(bin())
.args(["sessions", "--server-url", &base_url])
.output()
.expect("run sessions with failed business request");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("error:"));
assert!(!stderr.contains("falling back to local"));
handle.join().unwrap();
}
fn command_without_local_account() -> (Command, TempDir) {
let home = TempDir::new().expect("create isolated home");
let mut command = Command::new(bin());
command
.env("HOME", home.path())
.env_remove("WECHAT_CLI_DATA_DIR")
.env_remove("WECHAT_CLI_ACCOUNT")
.env_remove("WECHAT_CLI_KEY");
(command, home)
}
fn spawn_sequence_server( fn spawn_sequence_server(
expected_requests: usize, expected_requests: usize,
responder: impl Fn(String, usize) -> String + Send + 'static, responder: impl Fn(String, usize) -> String + Send + 'static,
+41 -2
View File
@@ -36,6 +36,25 @@ pub use progress::{DecryptProgress, DecryptStats};
pub use shard_routing::{route_shards_for_query, write_shard_metadata_sidecar}; pub use shard_routing::{route_shards_for_query, write_shard_metadata_sidecar};
pub use visibility::VisibilityIndex; pub use visibility::VisibilityIndex;
/// Read persisted per-database derived keys for this account.
/// Ephemeral `--key` contexts deliberately ignore the store because the supplied
/// raw key may not match its cached entries.
pub fn persisted_derived_keys(
account: &AccountContext,
) -> Result<Vec<wx_decrypt::EncKeyPair>, ContextError> {
if !account.writeback_enabled {
return Ok(Vec::new());
}
let store = wx_keychain::KeyStore::load_default()?;
Ok(match store.resolve_key_material(&account.account_id) {
Some(wx_decrypt::KeyMaterial::EncKeys(pairs)) => pairs,
Some(wx_decrypt::KeyMaterial::EncKey { key, salt }) => {
vec![wx_decrypt::EncKeyPair { key, salt }]
}
_ => Vec::new(),
})
}
/// Open encrypted WeChat DB directory directly (no pool, no FTS). /// Open encrypted WeChat DB directory directly (no pool, no FTS).
/// For one-shot commands: contacts, sessions, query, search, export. /// For one-shot commands: contacts, sessions, query, search, export.
pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> { pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
@@ -43,7 +62,25 @@ pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, Co
.raw_key .raw_key
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?; .ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
let encrypted_root = account.data_dir.join("db_storage"); let encrypted_root = account.data_dir.join("db_storage");
let db = wx_db::WechatDb::open_encrypted(&encrypted_root, raw_key)?; let derived_keys = persisted_derived_keys(account)?;
let db =
wx_db::WechatDb::open_encrypted_with_key_cache(&encrypted_root, raw_key, &derived_keys)?;
Ok(db)
}
/// Open only encrypted contact.db and session.db directly.
/// This avoids deriving keys for and scanning message shards for core-only commands.
pub fn open_encrypted_db_core(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
let raw_key = account
.raw_key
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
let encrypted_root = account.data_dir.join("db_storage");
let derived_keys = persisted_derived_keys(account)?;
let db = wx_db::WechatDb::open_encrypted_core_with_key_cache(
&encrypted_root,
raw_key,
&derived_keys,
)?;
Ok(db) Ok(db)
} }
@@ -56,9 +93,11 @@ pub fn open_encrypted_db_with_pool(
.raw_key .raw_key
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?; .ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
let encrypted_root = account.data_dir.join("db_storage"); let encrypted_root = account.data_dir.join("db_storage");
let db = wx_db::WechatDb::open_encrypted_with_pool( let derived_keys = persisted_derived_keys(account)?;
let db = wx_db::WechatDb::open_encrypted_with_pool_and_key_cache(
&encrypted_root, &encrypted_root,
raw_key, raw_key,
&derived_keys,
register_mm_fts_tokenizer, register_mm_fts_tokenizer,
)?; )?;
Ok(db) Ok(db)
+1
View File
@@ -12,6 +12,7 @@ thiserror = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
hex = "0.4" hex = "0.4"
wx-decrypt = { path = "../wx-decrypt" }
[dev-dependencies] [dev-dependencies]
insta = { version = "1", features = ["yaml"] } insta = { version = "1", features = ["yaml"] }
+2 -1
View File
@@ -414,7 +414,8 @@ impl WechatDb {
)?; )?;
for shard in &self.shards { for shard in &self.shards {
let shard_conn = WechatDb::open_shard_with_key(shard, self.raw_key.as_ref())?; let shard_conn =
WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref())?;
// List Msg_* tables in this shard // List Msg_* tables in this shard
let mut table_stmt = shard_conn.prepare( let mut table_stmt = shard_conn.prepare(
+23 -18
View File
@@ -12,7 +12,7 @@ use crate::model::{
effective_limit, split_local_type, AnchorMode, Message, MessageQuery, MessageQueryResult, effective_limit, split_local_type, AnchorMode, Message, MessageQuery, MessageQueryResult,
QueryStats, SortOrder, QueryStats, SortOrder,
}; };
use crate::open::{MessageShard, WechatDb}; use crate::open::{MessageShard, SqlcipherKey, WechatDb};
/// Dispatch mode for regular (non-anchor) queries. /// Dispatch mode for regular (non-anchor) queries.
enum RegularQueryMode { enum RegularQueryMode {
@@ -56,13 +56,13 @@ fn prepare_shard_query<'a>(
table_name: &str, table_name: &str,
warnings: &mut Vec<ShardWarning>, warnings: &mut Vec<ShardWarning>,
pooled_conn: Option<&'a Connection>, pooled_conn: Option<&'a Connection>,
raw_key: Option<&[u8; 32]>, sqlcipher_key: Option<&SqlcipherKey>,
) -> Option<PreparedShard<'a>> { ) -> Option<PreparedShard<'a>> {
let shard_path = shard.path.display().to_string(); let shard_path = shard.path.display().to_string();
let conn = match pooled_conn { let conn = match pooled_conn {
Some(conn) => ShardConnection::Borrowed(conn), Some(conn) => ShardConnection::Borrowed(conn),
None => match WechatDb::open_shard_with_key(shard, raw_key) { None => match WechatDb::open_shard_with_key(shard, sqlcipher_key) {
Ok(c) => ShardConnection::Owned(c), Ok(c) => ShardConnection::Owned(c),
Err(e) => { Err(e) => {
warnings.push(ShardWarning { warnings.push(ShardWarning {
@@ -176,7 +176,7 @@ impl WechatDb {
&table_name, &table_name,
&mut shard_warnings, &mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)), self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(), self.sqlcipher_key.as_ref(),
) { ) {
Some(p) => p, Some(p) => p,
None => continue, None => continue,
@@ -296,12 +296,12 @@ impl WechatDb {
for shard in &shards { for shard in &shards {
let count = if let Some(pool) = self.pool() { let count = if let Some(pool) = self.pool() {
if let Some(conn) = pool.get(&shard.path) { if let Some(conn) = pool.get(&shard.path) {
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter) Self::count_shard(conn, &sql, start_time, end_time, msg_type_filter)
} else { } else {
continue; continue;
} }
} else { } else {
match crate::open::open_connection(&shard.path, self.raw_key.as_ref()) { match crate::open::open_connection(&shard.path, self.sqlcipher_key.as_ref()) {
Ok(conn) => { Ok(conn) => {
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter) Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
} }
@@ -385,7 +385,7 @@ impl WechatDb {
table_name, table_name,
&mut shard_warnings, &mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)), self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(), self.sqlcipher_key.as_ref(),
) { ) {
Some(p) => p, Some(p) => p,
None => continue, None => continue,
@@ -476,7 +476,7 @@ impl WechatDb {
table_name, table_name,
&mut shard_warnings, &mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)), self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(), self.sqlcipher_key.as_ref(),
) { ) {
Some(p) => p, Some(p) => p,
None => continue, None => continue,
@@ -607,7 +607,7 @@ impl WechatDb {
table_name, table_name,
&mut shard_warnings, &mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)), self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(), self.sqlcipher_key.as_ref(),
) { ) {
Some(p) => p, Some(p) => p,
None => continue, None => continue,
@@ -683,7 +683,7 @@ impl WechatDb {
table_name, table_name,
&mut shard_warnings, &mut shard_warnings,
self.pool().and_then(|pool| pool.get(&shard.path)), self.pool().and_then(|pool| pool.get(&shard.path)),
self.raw_key.as_ref(), self.sqlcipher_key.as_ref(),
) { ) {
Some(p) => p, Some(p) => p,
None => continue, None => continue,
@@ -812,16 +812,21 @@ impl WechatDb {
known_usernames.iter().map(|u| (u.clone(), 0)).collect(); known_usernames.iter().map(|u| (u.clone(), 0)).collect();
for shard in self.all_shards() { for shard in self.all_shards() {
let conn = match WechatDb::open_shard_with_key(shard, self.raw_key.as_ref()) { let conn = if let Some(conn) = self.pool().and_then(|pool| pool.get(&shard.path)) {
Ok(c) => c, ShardConnection::Borrowed(conn)
Err(e) => { } else {
eprintln!( match WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref()) {
"warn: bulk_max_sort_seq: open shard {} failed: {e}", Ok(conn) => ShardConnection::Owned(conn),
shard.path.display() Err(e) => {
); eprintln!(
continue; "warn: bulk_max_sort_seq: open shard {} failed: {e}",
shard.path.display()
);
continue;
}
} }
}; };
let conn = conn.as_conn();
// Discover Msg_* tables in this shard // Discover Msg_* tables in this shard
let mut stmt = match conn let mut stmt = match conn
+314 -41
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::fmt; use std::fmt;
use std::os::raw::c_void; use std::os::raw::c_void;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use rusqlite::Connection; use rusqlite::Connection;
@@ -18,6 +18,111 @@ pub(crate) struct MessageShard {
pub end_unix: i64, pub end_unix: i64,
} }
/// A raw WeChat key plus an in-process cache of SQLCipher's derived keys.
///
/// SQLCipher normally runs its 256k-round PBKDF2 every time a connection is
/// opened. WeChat uses a different salt per database, but the same database is
/// often opened several times during one command (metadata scan, query, count,
/// refresh). Passing SQLCipher's raw keyspec lets us derive once per salt and
/// reuse the result for every subsequent connection.
#[derive(Clone)]
pub(crate) struct SqlcipherKey {
raw_key: [u8; 32],
derived_keys: Arc<Mutex<HashMap<[u8; 16], CachedKey>>>,
}
#[derive(Clone, Copy)]
struct CachedKey {
key: [u8; 32],
/// Preloaded keys come from the persisted key store and get one raw-key
/// fallback if validation fails. Keys derived in this process are trusted.
preloaded: bool,
}
impl SqlcipherKey {
fn new(raw_key: [u8; 32]) -> Self {
Self::with_preloaded(raw_key, &[])
}
fn with_preloaded(raw_key: [u8; 32], pairs: &[wx_decrypt::EncKeyPair]) -> Self {
let derived_keys = pairs
.iter()
.map(|pair| {
(
pair.salt,
CachedKey {
key: pair.key,
preloaded: true,
},
)
})
.collect();
Self {
raw_key,
derived_keys: Arc::new(Mutex::new(derived_keys)),
}
}
fn keyspec_for_path(&self, path: &Path) -> Result<(Vec<u8>, [u8; 16], bool), DbError> {
let salt = wx_decrypt::read_db_salt(path)
.map_err(|e| DbError::EncryptionKey(format!("failed to read database salt: {e}")))?;
let cached = {
let mut cache = self.derived_keys.lock().map_err(|_| {
DbError::EncryptionKey("derived-key cache lock was poisoned".into())
})?;
*cache.entry(salt).or_insert_with(|| {
let key = wx_decrypt::kdf::derive_enc_key(
&self.raw_key,
&salt,
&wx_decrypt::MACOS_4_1_7_31,
);
CachedKey {
key,
preloaded: false,
}
})
};
// SQLCipher raw-key syntax includes the original 16-byte database salt.
// Supplying this ASCII keyspec to sqlite3_key() skips SQLCipher's PBKDF2.
let keyspec = format!("x'{}{}'", hex::encode(cached.key), hex::encode(salt)).into_bytes();
Ok((keyspec, salt, cached.preloaded))
}
fn mark_verified(&self, salt: [u8; 16]) -> Result<(), DbError> {
let mut cache = self
.derived_keys
.lock()
.map_err(|_| DbError::EncryptionKey("derived-key cache lock was poisoned".into()))?;
if let Some(entry) = cache.get_mut(&salt) {
entry.preloaded = false;
}
Ok(())
}
fn rederive_keyspec(&self, salt: [u8; 16]) -> Result<Vec<u8>, DbError> {
let key =
wx_decrypt::kdf::derive_enc_key(&self.raw_key, &salt, &wx_decrypt::MACOS_4_1_7_31);
self.derived_keys
.lock()
.map_err(|_| DbError::EncryptionKey("derived-key cache lock was poisoned".into()))?
.insert(
salt,
CachedKey {
key,
preloaded: false,
},
);
Ok(format!("x'{}{}'", hex::encode(key), hex::encode(salt)).into_bytes())
}
#[cfg(test)]
fn cached_salt_count(&self) -> usize {
self.derived_keys.lock().unwrap().len()
}
}
/// Handle to an opened (decrypted) WeChat database directory. /// Handle to an opened (decrypted) WeChat database directory.
/// ///
/// Holds connections to contact/session databases and metadata about /// Holds connections to contact/session databases and metadata about
@@ -34,8 +139,8 @@ pub struct WechatDb {
pub contact_fts_path: Option<PathBuf>, pub contact_fts_path: Option<PathBuf>,
/// Optional pre-opened connection pool for serve mode. /// Optional pre-opened connection pool for serve mode.
pub(crate) pool: Option<ShardPool>, pub(crate) pool: Option<ShardPool>,
/// Raw key for encrypted direct open. Stored for reopen operations. /// Shared raw/derived key state for encrypted direct open and reopen operations.
pub(crate) raw_key: Option<[u8; 32]>, pub(crate) sqlcipher_key: Option<SqlcipherKey>,
/// Lazily initialized cache of label_id -> label_name from contact_label table. /// Lazily initialized cache of label_id -> label_name from contact_label table.
/// Cleared on `reopen_contacts()` so label changes are visible. /// Cleared on `reopen_contacts()` so label changes are visible.
pub(crate) label_cache: RwLock<Option<HashMap<String, String>>>, pub(crate) label_cache: RwLock<Option<HashMap<String, String>>>,
@@ -54,30 +159,59 @@ pub fn open_readonly_connection(
path: &Path, path: &Path,
raw_key: Option<&[u8; 32]>, raw_key: Option<&[u8; 32]>,
) -> Result<Connection, DbError> { ) -> Result<Connection, DbError> {
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; let key = raw_key.copied().map(SqlcipherKey::new);
if let Some(key) = raw_key { open_connection(path, key.as_ref())
unsafe {
let rc = rusqlite::ffi::sqlite3_key(conn.handle(), key.as_ptr() as *const c_void, 32);
if rc != 0 {
return Err(DbError::EncryptionKey(format!(
"sqlite3_key failed: rc={rc}"
)));
}
}
conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.map_err(|_| DbError::EncryptionKey("incorrect key or not an encrypted database".into()))?;
conn.execute_batch("PRAGMA query_only = ON")?;
}
Ok(conn)
} }
pub(crate) fn open_connection( pub(crate) fn open_connection(
path: &Path, path: &Path,
raw_key: Option<&[u8; 32]>, sqlcipher_key: Option<&SqlcipherKey>,
) -> Result<Connection, DbError> { ) -> Result<Connection, DbError> {
open_readonly_connection(path, raw_key) if let Some(key) = sqlcipher_key {
let (keyspec, salt, preloaded) = key.keyspec_for_path(path)?;
match open_connection_with_keyspec(path, &keyspec) {
Ok(conn) => {
if preloaded {
key.mark_verified(salt)?;
}
Ok(conn)
}
Err(DbError::EncryptionKey(_)) if preloaded => {
// Persisted entries are an optimization, never a single point of
// failure. Re-derive once from the raw key if an entry is stale.
let keyspec = key.rederive_keyspec(salt)?;
open_connection_with_keyspec(path, &keyspec)
}
Err(err) => Err(err),
}
} else {
Ok(Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)?)
}
}
fn open_connection_with_keyspec(path: &Path, keyspec: &[u8]) -> Result<Connection, DbError> {
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
unsafe {
let rc = rusqlite::ffi::sqlite3_key(
conn.handle(),
keyspec.as_ptr() as *const c_void,
keyspec.len() as i32,
);
if rc != 0 {
return Err(DbError::EncryptionKey(format!(
"sqlite3_key failed: rc={rc}"
)));
}
}
conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.map_err(|_| DbError::EncryptionKey("incorrect key or not an encrypted database".into()))?;
conn.execute_batch("PRAGMA query_only = ON")?;
Ok(conn)
} }
impl WechatDb { impl WechatDb {
@@ -87,7 +221,13 @@ impl WechatDb {
/// does not exist. Message shards are optional here; message queries will /// does not exist. Message shards are optional here; message queries will
/// return `DbError::NoShards` if no numbered shard is available. /// return `DbError::NoShards` if no numbered shard is available.
pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> { pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), None) Self::open_internal(path.as_ref(), None, true)
}
/// Open only contact.db and session.db, without scanning message shards.
/// Useful for contacts, sessions, and monitoring commands that never read messages.
pub fn open_core(path: impl AsRef<Path>) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), None, false)
} }
/// Open a decrypted WeChat database directory with a pre-opened /// Open a decrypted WeChat database directory with a pre-opened
@@ -104,7 +244,39 @@ impl WechatDb {
/// Open an encrypted WeChat database directory directly using `sqlite3_key()`. /// Open an encrypted WeChat database directory directly using `sqlite3_key()`.
pub fn open_encrypted(path: impl AsRef<Path>, raw_key: [u8; 32]) -> Result<Self, DbError> { pub fn open_encrypted(path: impl AsRef<Path>, raw_key: [u8; 32]) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), Some(raw_key)) Self::open_internal(path.as_ref(), Some(SqlcipherKey::new(raw_key)), true)
}
/// Open an encrypted directory and seed the per-salt cache with persisted
/// derived keys, falling back to the raw key for missing or stale entries.
pub fn open_encrypted_with_key_cache(
path: impl AsRef<Path>,
raw_key: [u8; 32],
pairs: &[wx_decrypt::EncKeyPair],
) -> Result<Self, DbError> {
Self::open_internal(
path.as_ref(),
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
true,
)
}
/// Open only encrypted contact.db and session.db, without scanning message shards.
pub fn open_encrypted_core(path: impl AsRef<Path>, raw_key: [u8; 32]) -> Result<Self, DbError> {
Self::open_internal(path.as_ref(), Some(SqlcipherKey::new(raw_key)), false)
}
/// Core-only variant of [`WechatDb::open_encrypted_with_key_cache`].
pub fn open_encrypted_core_with_key_cache(
path: impl AsRef<Path>,
raw_key: [u8; 32],
pairs: &[wx_decrypt::EncKeyPair],
) -> Result<Self, DbError> {
Self::open_internal(
path.as_ref(),
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
false,
)
} }
/// Open an encrypted WeChat database directory with a pre-opened /// Open an encrypted WeChat database directory with a pre-opened
@@ -114,35 +286,53 @@ impl WechatDb {
raw_key: [u8; 32], raw_key: [u8; 32],
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static, fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
) -> Result<Self, DbError> { ) -> Result<Self, DbError> {
Self::open_with_pool_internal(path, Some(raw_key), fts_init) Self::open_with_pool_internal(path, Some(SqlcipherKey::new(raw_key)), fts_init)
} }
fn open_internal(path: &Path, raw_key: Option<[u8; 32]>) -> Result<Self, DbError> { /// Pool variant seeded with persisted per-salt derived keys.
pub fn open_encrypted_with_pool_and_key_cache(
path: impl AsRef<Path>,
raw_key: [u8; 32],
pairs: &[wx_decrypt::EncKeyPair],
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
) -> Result<Self, DbError> {
Self::open_with_pool_internal(
path,
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
fts_init,
)
}
fn open_internal(
path: &Path,
sqlcipher_key: Option<SqlcipherKey>,
scan_message_shards: bool,
) -> Result<Self, DbError> {
if !path.exists() { if !path.exists() {
return Err(DbError::NotFound(path.display().to_string())); return Err(DbError::NotFound(path.display().to_string()));
} }
let key_ref = raw_key.as_ref(); let key_ref = sqlcipher_key.as_ref();
// Open contact.db // Open contact.db
let contact_path = path.join("contact").join("contact.db"); let contact_path = path.join("contact").join("contact.db");
if !contact_path.exists() { if !contact_path.exists() {
return Err(DbError::NotFound(contact_path.display().to_string())); return Err(DbError::NotFound(contact_path.display().to_string()));
} }
let contact_conn = open_readonly_connection(&contact_path, key_ref)?; let contact_conn = open_connection(&contact_path, key_ref)?;
// Open session.db // Open session.db
let session_path = path.join("session").join("session.db"); let session_path = path.join("session").join("session.db");
if !session_path.exists() { if !session_path.exists() {
return Err(DbError::NotFound(session_path.display().to_string())); return Err(DbError::NotFound(session_path.display().to_string()));
} }
let session_conn = open_readonly_connection(&session_path, key_ref)?; let session_conn = open_connection(&session_path, key_ref)?;
// Scan message shards // Scan message shards
let msg_dir = path.join("message"); let msg_dir = path.join("message");
let mut shards = Vec::new(); let mut shards = Vec::new();
if msg_dir.is_dir() { if scan_message_shards && msg_dir.is_dir() {
let mut entries: Vec<PathBuf> = std::fs::read_dir(&msg_dir)? let mut entries: Vec<PathBuf> = std::fs::read_dir(&msg_dir)?
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.map(|e| e.path()) .map(|e| e.path())
@@ -196,23 +386,23 @@ impl WechatDb {
} }
}, },
pool: None, pool: None,
raw_key, sqlcipher_key,
label_cache: RwLock::new(None), label_cache: RwLock::new(None),
}) })
} }
fn open_with_pool_internal( fn open_with_pool_internal(
path: impl AsRef<Path>, path: impl AsRef<Path>,
raw_key: Option<[u8; 32]>, sqlcipher_key: Option<SqlcipherKey>,
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static, fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
) -> Result<Self, DbError> { ) -> Result<Self, DbError> {
let mut db = Self::open_internal(path.as_ref(), raw_key)?; let mut db = Self::open_internal(path.as_ref(), sqlcipher_key.clone(), true)?;
let fts_init_arc: Arc<crate::pool::FtsInitFn> = Arc::new(fts_init); let fts_init_arc: Arc<crate::pool::FtsInitFn> = Arc::new(fts_init);
let pool = ShardPool::open( let pool = ShardPool::open(
&db.shards, &db.shards,
db.message_fts_path.as_deref(), db.message_fts_path.as_deref(),
Some(fts_init_arc), Some(fts_init_arc),
raw_key, sqlcipher_key,
)?; )?;
db.pool = Some(pool); db.pool = Some(pool);
Ok(db) Ok(db)
@@ -220,14 +410,14 @@ impl WechatDb {
/// Re-open the session.db connection to pick up external changes. /// Re-open the session.db connection to pick up external changes.
pub fn reopen_sessions(&mut self) -> Result<(), DbError> { pub fn reopen_sessions(&mut self) -> Result<(), DbError> {
self.session_conn = open_connection(&self.session_path, self.raw_key.as_ref())?; self.session_conn = open_connection(&self.session_path, self.sqlcipher_key.as_ref())?;
Ok(()) Ok(())
} }
/// Re-open the contact.db connection to pick up external changes. /// Re-open the contact.db connection to pick up external changes.
/// Also invalidates the label cache so it is reloaded on next query. /// Also invalidates the label cache so it is reloaded on next query.
pub fn reopen_contacts(&mut self) -> Result<(), DbError> { pub fn reopen_contacts(&mut self) -> Result<(), DbError> {
self.contact_conn = open_connection(&self.contact_path, self.raw_key.as_ref())?; self.contact_conn = open_connection(&self.contact_path, self.sqlcipher_key.as_ref())?;
*self.label_cache.write().unwrap() = None; *self.label_cache.write().unwrap() = None;
Ok(()) Ok(())
} }
@@ -270,6 +460,13 @@ impl WechatDb {
self.pool.as_ref() self.pool.as_ref()
} }
/// Open another database from the same encrypted account while reusing this
/// handle's derived-key cache. This is used by serve-mode auxiliary FTS and
/// media connections so refreshes do not re-run PBKDF2.
pub fn open_related_readonly(&self, path: &Path) -> Result<Connection, DbError> {
open_connection(path, self.sqlcipher_key.as_ref())
}
/// Return shards whose time range overlaps `[start, end]`. /// Return shards whose time range overlaps `[start, end]`.
pub(crate) fn shards_for_range(&self, start: i64, end: i64) -> Vec<&MessageShard> { pub(crate) fn shards_for_range(&self, start: i64, end: i64) -> Vec<&MessageShard> {
self.shards self.shards
@@ -307,9 +504,9 @@ impl WechatDb {
/// Open a SQLite connection to a specific shard, optionally encrypted. /// Open a SQLite connection to a specific shard, optionally encrypted.
pub(crate) fn open_shard_with_key( pub(crate) fn open_shard_with_key(
shard: &MessageShard, shard: &MessageShard,
raw_key: Option<&[u8; 32]>, sqlcipher_key: Option<&SqlcipherKey>,
) -> Result<Connection, DbError> { ) -> Result<Connection, DbError> {
open_readonly_connection(&shard.path, raw_key) open_connection(&shard.path, sqlcipher_key)
} }
} }
@@ -341,8 +538,8 @@ fn is_numbered_message_shard(path: &Path) -> bool {
/// Try to read the timestamp from a message shard's Timestamp table. /// Try to read the timestamp from a message shard's Timestamp table.
/// Returns 0 if the table does not exist or is empty. /// Returns 0 if the table does not exist or is empty.
fn read_shard_timestamp(path: &Path, raw_key: Option<&[u8; 32]>) -> i64 { fn read_shard_timestamp(path: &Path, sqlcipher_key: Option<&SqlcipherKey>) -> i64 {
let conn = match open_connection(path, raw_key) { let conn = match open_connection(path, sqlcipher_key) {
Ok(c) => c, Ok(c) => c,
Err(_) => return 0, Err(_) => return 0,
}; };
@@ -425,9 +622,35 @@ mod tests {
build_encrypted_db_storage(&root, &raw_key); build_encrypted_db_storage(&root, &raw_key);
let mut db = WechatDb::open_encrypted(&root, raw_key).unwrap(); let mut db = WechatDb::open_encrypted(&root, raw_key).unwrap();
let key = db.sqlcipher_key.clone().unwrap();
let cached_before = key.cached_salt_count();
assert_eq!(
cached_before, 3,
"contact, session, and message salts cached"
);
// Reopen should succeed (re-applies sqlite3_key) // Reopen should succeed (re-applies sqlite3_key)
db.reopen_sessions().unwrap(); db.reopen_sessions().unwrap();
db.reopen_contacts().unwrap(); db.reopen_contacts().unwrap();
assert_eq!(
key.cached_salt_count(),
cached_before,
"reopen must reuse derived keys instead of deriving again"
);
}
#[test]
fn open_core_does_not_scan_message_shards() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("db_storage");
std::fs::create_dir_all(root.join("contact")).unwrap();
std::fs::create_dir_all(root.join("session")).unwrap();
std::fs::create_dir_all(root.join("message")).unwrap();
Connection::open(root.join("contact/contact.db")).unwrap();
Connection::open(root.join("session/session.db")).unwrap();
std::fs::write(root.join("message/message_0.db"), b"not a sqlite database").unwrap();
let db = WechatDb::open_core(&root).unwrap();
assert!(db.shards.is_empty());
} }
#[test] #[test]
@@ -457,7 +680,57 @@ mod tests {
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);", "CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
); );
let conn = open_connection(&path, Some(&raw_key)).unwrap(); let key = SqlcipherKey::new(raw_key);
let conn = open_connection(&path, Some(&key)).unwrap();
let val: i64 = conn
.query_row("SELECT id FROM t", [], |r| r.get(0))
.unwrap();
assert_eq!(val, 42);
}
#[test]
fn preloaded_derived_key_opens_encrypted_database() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("enc.db");
let raw_key = [0xAB_u8; 32];
create_encrypted_db(
&path,
&raw_key,
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
);
let salt = wx_decrypt::read_db_salt(&path).unwrap();
let enc_key = wx_decrypt::kdf::derive_enc_key(&raw_key, &salt, &wx_decrypt::MACOS_4_1_7_31);
let key =
SqlcipherKey::with_preloaded(raw_key, &[wx_decrypt::EncKeyPair { key: enc_key, salt }]);
let conn = open_connection(&path, Some(&key)).unwrap();
let val: i64 = conn
.query_row("SELECT id FROM t", [], |r| r.get(0))
.unwrap();
assert_eq!(val, 42);
assert_eq!(key.cached_salt_count(), 1);
}
#[test]
fn stale_preloaded_key_falls_back_to_raw_key() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("enc.db");
let raw_key = [0xAB_u8; 32];
create_encrypted_db(
&path,
&raw_key,
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
);
let salt = wx_decrypt::read_db_salt(&path).unwrap();
let key = SqlcipherKey::with_preloaded(
raw_key,
&[wx_decrypt::EncKeyPair {
key: [0xCD; 32],
salt,
}],
);
let conn = open_connection(&path, Some(&key)).unwrap();
let val: i64 = conn let val: i64 = conn
.query_row("SELECT id FROM t", [], |r| r.get(0)) .query_row("SELECT id FROM t", [], |r| r.get(0))
.unwrap(); .unwrap();
+10 -10
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use rusqlite::Connection; use rusqlite::Connection;
use crate::error::DbError; use crate::error::DbError;
use crate::open::MessageShard; use crate::open::{MessageShard, SqlcipherKey};
pub(crate) type FtsInitFn = dyn Fn(&Connection) -> Result<(), String> + Send + Sync; pub(crate) type FtsInitFn = dyn Fn(&Connection) -> Result<(), String> + Send + Sync;
@@ -19,7 +19,7 @@ pub struct ShardPool {
fts_conn: Option<Connection>, fts_conn: Option<Connection>,
fts_path: Option<PathBuf>, fts_path: Option<PathBuf>,
fts_init: Option<Arc<FtsInitFn>>, fts_init: Option<Arc<FtsInitFn>>,
raw_key: Option<[u8; 32]>, sqlcipher_key: Option<SqlcipherKey>,
} }
impl std::fmt::Debug for ShardPool { impl std::fmt::Debug for ShardPool {
@@ -41,22 +41,22 @@ impl ShardPool {
shards: &[MessageShard], shards: &[MessageShard],
fts_path: Option<&Path>, fts_path: Option<&Path>,
fts_init: Option<Arc<FtsInitFn>>, fts_init: Option<Arc<FtsInitFn>>,
raw_key: Option<[u8; 32]>, sqlcipher_key: Option<SqlcipherKey>,
) -> Result<Self, DbError> { ) -> Result<Self, DbError> {
let mut conns = HashMap::with_capacity(shards.len()); let mut conns = HashMap::with_capacity(shards.len());
for shard in shards { for shard in shards {
let conn = crate::open::open_connection(&shard.path, raw_key.as_ref())?; let conn = crate::open::open_connection(&shard.path, sqlcipher_key.as_ref())?;
conns.insert(shard.path.clone(), conn); conns.insert(shard.path.clone(), conn);
} }
let fts_conn = match (fts_path, &fts_init) { let fts_conn = match (fts_path, &fts_init) {
(Some(path), Some(init)) => { (Some(path), Some(init)) => {
let conn = crate::open::open_connection(path, raw_key.as_ref())?; let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
init(&conn).map_err(DbError::FtsInit)?; init(&conn).map_err(DbError::FtsInit)?;
Some(conn) Some(conn)
} }
(Some(path), None) => { (Some(path), None) => {
let conn = crate::open::open_connection(path, raw_key.as_ref())?; let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
Some(conn) Some(conn)
} }
_ => None, _ => None,
@@ -67,7 +67,7 @@ impl ShardPool {
fts_conn, fts_conn,
fts_path: fts_path.map(|p| p.to_path_buf()), fts_path: fts_path.map(|p| p.to_path_buf()),
fts_init, fts_init,
raw_key, sqlcipher_key,
}) })
} }
@@ -79,7 +79,7 @@ impl ShardPool {
/// Close and reopen one shard connection. /// Close and reopen one shard connection.
pub fn reopen_shard(&mut self, path: &Path) -> Result<(), DbError> { pub fn reopen_shard(&mut self, path: &Path) -> Result<(), DbError> {
if self.conns.contains_key(path) { if self.conns.contains_key(path) {
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?; let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
self.conns.insert(path.to_path_buf(), conn); self.conns.insert(path.to_path_buf(), conn);
} }
Ok(()) Ok(())
@@ -93,7 +93,7 @@ impl ShardPool {
/// Close and reopen the FTS connection, re-registering the tokenizer. /// Close and reopen the FTS connection, re-registering the tokenizer.
pub fn reopen_fts(&mut self) -> Result<(), DbError> { pub fn reopen_fts(&mut self) -> Result<(), DbError> {
if let Some(path) = &self.fts_path { if let Some(path) = &self.fts_path {
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?; let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
if let Some(init) = &self.fts_init { if let Some(init) = &self.fts_init {
init(&conn).map_err(DbError::FtsInit)?; init(&conn).map_err(DbError::FtsInit)?;
} }
@@ -106,7 +106,7 @@ impl ShardPool {
pub fn reopen_all(&mut self) -> Result<(), DbError> { pub fn reopen_all(&mut self) -> Result<(), DbError> {
let paths: Vec<PathBuf> = self.conns.keys().cloned().collect(); let paths: Vec<PathBuf> = self.conns.keys().cloned().collect();
for path in paths { for path in paths {
let conn = crate::open::open_connection(&path, self.raw_key.as_ref())?; let conn = crate::open::open_connection(&path, self.sqlcipher_key.as_ref())?;
self.conns.insert(path, conn); self.conns.insert(path, conn);
} }
self.reopen_fts()?; self.reopen_fts()?;
+15 -3
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use futures_core::Stream; use futures_core::Stream;
use wx_db::{SessionQuery, WechatDb}; use wx_db::{SessionQuery, WechatDb};
use wx_decrypt::{CryptoParams, KeyMaterial}; use wx_decrypt::{CryptoParams, EncKeyPair, KeyMaterial};
use crate::cache::{DecryptCache, UpdateKind}; use crate::cache::{DecryptCache, UpdateKind};
use crate::error::MonitorError; use crate::error::MonitorError;
@@ -100,7 +100,19 @@ impl WechatMonitor {
let (db, cache) = if let (Some(raw_key), Some(ref encrypted_root)) = let (db, cache) = if let (Some(raw_key), Some(ref encrypted_root)) =
(config.raw_key, &config.encrypted_root) (config.raw_key, &config.encrypted_root)
{ {
let db = WechatDb::open_encrypted(encrypted_root, raw_key)?; let derived_keys: Vec<EncKeyPair> = match &config.key_material {
KeyMaterial::EncKeys(pairs) => pairs.clone(),
KeyMaterial::EncKey { key, salt } => vec![EncKeyPair {
key: *key,
salt: *salt,
}],
KeyMaterial::RawKey(_) => Vec::new(),
};
let db = WechatDb::open_encrypted_core_with_key_cache(
encrypted_root,
raw_key,
&derived_keys,
)?;
(db, None) (db, None)
} else { } else {
let mut cache = DecryptCache::new( let mut cache = DecryptCache::new(
@@ -109,7 +121,7 @@ impl WechatMonitor {
config.params, config.params,
)?; )?;
cache.initial_decrypt()?; cache.initial_decrypt()?;
let db = WechatDb::open(cache.decrypted_root())?; let db = WechatDb::open_core(cache.decrypted_root())?;
(db, Some(cache)) (db, Some(cache))
}; };