mirror of
https://github.com/pandorafuture/wx-cli.git
synced 2026-08-29 04:00:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4129e59184 |
@@ -13,7 +13,7 @@ jobs:
|
||||
fmt:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
clippy:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
Generated
+9
-54
@@ -245,15 +245,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
@@ -420,12 +411,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -526,15 +511,6 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -630,22 +606,11 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"block-buffer",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@@ -979,16 +944,7 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1648,8 +1604,8 @@ version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac 0.12.1",
|
||||
"digest",
|
||||
"hmac",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
@@ -2056,7 +2012,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3219,7 +3175,7 @@ dependencies = [
|
||||
"dashmap",
|
||||
"filetime",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"rayon",
|
||||
"rusqlite",
|
||||
@@ -3247,7 +3203,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"wx-decrypt",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
@@ -3257,7 +3212,7 @@ version = "0.7.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"hmac 0.13.0",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
@@ -3272,7 +3227,7 @@ dependencies = [
|
||||
"cbc",
|
||||
"chrono",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"hmac",
|
||||
"libc",
|
||||
"mach2",
|
||||
"regex",
|
||||
@@ -3311,7 +3266,7 @@ dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-core",
|
||||
"hmac 0.13.0",
|
||||
"hmac",
|
||||
"notify",
|
||||
"pbkdf2",
|
||||
"rusqlite",
|
||||
|
||||
-15
@@ -8,18 +8,3 @@ edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/pandorafuture/wx-cli"
|
||||
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
|
||||
|
||||
@@ -77,23 +77,24 @@ pub fn cmd_query(
|
||||
|
||||
if options.is_enabled() && !preserve_local_warning {
|
||||
let client = ThinClient::new(options.clone());
|
||||
match client.probe_health() {
|
||||
Ok(()) => {
|
||||
let envelope = fetch_remote_query(
|
||||
&client,
|
||||
contact,
|
||||
since,
|
||||
until,
|
||||
msg_type.clone(),
|
||||
effective_limit,
|
||||
offset,
|
||||
order.clone(),
|
||||
around_sort_seq,
|
||||
around_server_id,
|
||||
context,
|
||||
after_sort_seq,
|
||||
show_hidden,
|
||||
)?;
|
||||
match client.probe_health().and_then(|_| {
|
||||
fetch_remote_query(
|
||||
&client,
|
||||
contact,
|
||||
since,
|
||||
until,
|
||||
msg_type.clone(),
|
||||
effective_limit,
|
||||
offset,
|
||||
order.clone(),
|
||||
around_sort_seq,
|
||||
around_server_id,
|
||||
context,
|
||||
after_sort_seq,
|
||||
show_hidden,
|
||||
)
|
||||
}) {
|
||||
Ok(envelope) => {
|
||||
let is_group = envelope
|
||||
.items
|
||||
.first()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wx_context::{register_mm_fts_tokenizer, AccountContext, ContactResolver, ResolveParams};
|
||||
use wx_context::{
|
||||
open_fts_connection_with_key, AccountContext, ContactResolver, ResolveParams,
|
||||
};
|
||||
|
||||
use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions};
|
||||
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
|
||||
@@ -62,10 +64,7 @@ fn load_local_search(
|
||||
|
||||
// --- Native FTS search → fallback to scan ---
|
||||
let use_fallback = match db.message_fts_path.as_deref() {
|
||||
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)
|
||||
}) {
|
||||
Some(fts_path) => match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) {
|
||||
Ok(conn) => {
|
||||
match wx_db::native_fts::search_message_fts(
|
||||
&conn,
|
||||
|
||||
@@ -19,8 +19,8 @@ use tokio::signal::unix::SignalKind;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use wx_context::{
|
||||
register_mm_fts_tokenizer, write_shard_metadata_sidecar, AccountContext, ContactResolver,
|
||||
DecryptRequest, PersistentCache, ResolveParams,
|
||||
open_fts_connection_with_key, register_mm_fts_tokenizer, write_shard_metadata_sidecar,
|
||||
AccountContext, ContactResolver, DecryptRequest, PersistentCache, ResolveParams,
|
||||
};
|
||||
|
||||
use crate::util::{print_cache_stats, print_detection_note};
|
||||
@@ -122,10 +122,7 @@ pub async fn cmd_serve(
|
||||
|
||||
// 3b. Open independent FTS connection (outside WechatDb Mutex)
|
||||
let fts_conn = db.message_fts_path.as_deref().and_then(|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)
|
||||
}) {
|
||||
match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) {
|
||||
Ok(conn) => {
|
||||
if let Ok(mode) =
|
||||
conn.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
|
||||
@@ -199,7 +196,7 @@ pub async fn cmd_serve(
|
||||
|
||||
// 3c. Open hardlink.db connection (pooled, outside WechatDb Mutex)
|
||||
let hardlink_db_conn = if hardlink_db_path.exists() {
|
||||
match db.open_related_readonly(&hardlink_db_path) {
|
||||
match wx_db::open_readonly_connection(&hardlink_db_path, acct.raw_key.as_ref()) {
|
||||
Ok(conn) => {
|
||||
eprintln!("server/hardlink: opened pooled connection");
|
||||
Some(conn)
|
||||
@@ -225,14 +222,9 @@ pub async fn cmd_serve(
|
||||
}
|
||||
|
||||
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
||||
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
|
||||
let config = wx_monitor::MonitorConfig {
|
||||
encrypted_session_dir,
|
||||
key_material: if monitor_derived_keys.is_empty() {
|
||||
acct.key_material.clone()
|
||||
} else {
|
||||
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
|
||||
},
|
||||
key_material: acct.key_material.clone(),
|
||||
params,
|
||||
watch_mode: watch_mode.clone(),
|
||||
poll_interval: Duration::from_millis(poll_ms),
|
||||
@@ -247,6 +239,7 @@ pub async fn cmd_serve(
|
||||
|
||||
// Capture values before moving db into Mutex
|
||||
let fts_path_for_refresh = db.message_fts_path.clone();
|
||||
let raw_key_for_refresh = acct.raw_key;
|
||||
|
||||
// 5. Create refresh task channels
|
||||
let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshTrigger>(64);
|
||||
@@ -405,6 +398,7 @@ pub async fn cmd_serve(
|
||||
shutdown_bg.clone(),
|
||||
)
|
||||
.with_fts(bg_state.fts_conn.clone(), fts_path_for_refresh)
|
||||
.with_raw_key(raw_key_for_refresh)
|
||||
.with_caches(
|
||||
Some(Arc::clone(&bg_state.name2id_cache)),
|
||||
Some(Arc::clone(&bg_state.media_db_paths)),
|
||||
|
||||
@@ -6,7 +6,7 @@ use rusqlite::Connection;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use wx_context::{
|
||||
open_fts_connection, register_mm_fts_tokenizer, DecryptProgress, DecryptRequest,
|
||||
open_fts_connection, open_fts_connection_with_key, DecryptProgress, DecryptRequest,
|
||||
PersistentCache,
|
||||
};
|
||||
use wx_db::WechatDb;
|
||||
@@ -35,6 +35,8 @@ pub struct RefreshTask {
|
||||
fts_conn: Option<Arc<std::sync::Mutex<Connection>>>,
|
||||
/// Path to FTS DB for reopening.
|
||||
fts_path: Option<PathBuf>,
|
||||
/// Raw key for encrypted FTS reopen.
|
||||
raw_key: Option<[u8; 32]>,
|
||||
/// Cache of name2id mapping — cleared when FTS is reopened.
|
||||
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>,
|
||||
/// Cache of media DB paths — cleared on every refresh.
|
||||
@@ -59,12 +61,18 @@ impl RefreshTask {
|
||||
shutdown,
|
||||
fts_conn: None,
|
||||
fts_path: None,
|
||||
raw_key: None,
|
||||
name2id_cache: None,
|
||||
media_db_paths: 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.
|
||||
pub fn with_fts(
|
||||
mut self,
|
||||
@@ -120,6 +128,7 @@ impl RefreshTask {
|
||||
let cache = self.cache.clone();
|
||||
let fts_conn = self.fts_conn.clone();
|
||||
let fts_path = self.fts_path.clone();
|
||||
let raw_key = self.raw_key;
|
||||
let success = tokio::task::spawn_blocking(move || {
|
||||
if let Some(cache) = cache {
|
||||
// Decrypt-cache mode: decrypt then selective reopen
|
||||
@@ -255,10 +264,7 @@ impl RefreshTask {
|
||||
|
||||
// Reopen independent FTS connection
|
||||
if let (Some(fts_mutex), Some(path)) = (&fts_conn, &fts_path) {
|
||||
match guard.open_related_readonly(path).and_then(|conn| {
|
||||
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
|
||||
Ok(conn)
|
||||
}) {
|
||||
match open_fts_connection_with_key(path, raw_key.as_ref()) {
|
||||
Ok(new_conn) => {
|
||||
if let Ok(mut fts_guard) = fts_mutex.lock()
|
||||
as Result<std::sync::MutexGuard<'_, Connection>, _>
|
||||
|
||||
@@ -318,14 +318,9 @@ pub async fn cmd_watch(
|
||||
}
|
||||
|
||||
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
||||
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
|
||||
let config = wx_monitor::MonitorConfig {
|
||||
encrypted_session_dir,
|
||||
key_material: if monitor_derived_keys.is_empty() {
|
||||
acct.key_material.clone()
|
||||
} else {
|
||||
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
|
||||
},
|
||||
key_material: acct.key_material.clone(),
|
||||
params,
|
||||
watch_mode: watch_mode.clone(),
|
||||
poll_interval: Duration::from_millis(poll_ms),
|
||||
|
||||
@@ -10,7 +10,7 @@ pub fn open_db_core(
|
||||
) -> Result<(wx_db::WechatDb, Option<DecryptStats>), Box<dyn std::error::Error>> {
|
||||
if acct.raw_key.is_some() {
|
||||
eprintln!("Direct encrypted open (SQLCipher)");
|
||||
let db = wx_context::open_encrypted_db_core(acct)?;
|
||||
let db = wx_context::open_encrypted_db(acct)?;
|
||||
Ok((db, None))
|
||||
} else {
|
||||
let params = &wx_decrypt::MACOS_4_1_7_31;
|
||||
@@ -18,7 +18,7 @@ pub fn open_db_core(
|
||||
let stats = DecryptRequest::new()
|
||||
.core()
|
||||
.execute_with_progress(&cache, progress)?;
|
||||
let db = wx_db::WechatDb::open_core(cache.decrypted_root())?;
|
||||
let db = wx_db::WechatDb::open(cache.decrypted_root())?;
|
||||
Ok((db, Some(stats)))
|
||||
}
|
||||
}
|
||||
@@ -139,10 +139,9 @@ pub fn effective_limit_all(all: bool, limit: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt a remote API call via ThinClient. In auto mode, fall back locally only when
|
||||
/// the initial health probe cannot reach/authenticate with a usable server. Once health
|
||||
/// succeeds, a failed business request is returned to the caller instead of launching an
|
||||
/// expensive local SQLCipher query after waiting for the remote timeout.
|
||||
/// Attempt a remote API call via ThinClient; on connection/auth failure fall back to the
|
||||
/// local path. This encapsulates the `probe_health → remote_fn → should_fallback → local_fn`
|
||||
/// pattern shared by `search`, `contacts`, and `sessions`.
|
||||
pub fn try_remote_or_local<T>(
|
||||
options: &ThinClientOptions,
|
||||
remote_fn: impl FnOnce(&ThinClient) -> Result<T, ThinClientError>,
|
||||
@@ -151,8 +150,8 @@ pub fn try_remote_or_local<T>(
|
||||
) -> Result<T, Box<dyn std::error::Error>> {
|
||||
if options.is_enabled() {
|
||||
let client = ThinClient::new(options.clone());
|
||||
match client.probe_health() {
|
||||
Ok(()) => return remote_fn(&client).map_err(Into::into),
|
||||
match client.probe_health().and_then(|_| remote_fn(&client)) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(err) if err.should_fallback(options.mode) => {
|
||||
eprintln!(
|
||||
"note: remote server unavailable, falling back to local {label} ({})",
|
||||
|
||||
@@ -4,8 +4,6 @@ use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_wx-cli")
|
||||
}
|
||||
@@ -280,8 +278,7 @@ fn server_only_fails_when_remote_unavailable() {
|
||||
|
||||
#[test]
|
||||
fn unavailable_remote_falls_back_to_local() {
|
||||
let (mut command, _home) = command_without_local_account();
|
||||
let output = command
|
||||
let output = Command::new(bin())
|
||||
.args(["sessions", "--server-url", "http://127.0.0.1:9"])
|
||||
.output()
|
||||
.expect("run sessions fallback");
|
||||
@@ -317,8 +314,7 @@ fn no_server_bypasses_remote_probe() {
|
||||
}
|
||||
});
|
||||
|
||||
let (mut command, _home) = command_without_local_account();
|
||||
let output = command
|
||||
let output = Command::new(bin())
|
||||
.args([
|
||||
"sessions",
|
||||
"--no-server",
|
||||
@@ -337,39 +333,6 @@ 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(
|
||||
expected_requests: usize,
|
||||
responder: impl Fn(String, usize) -> String + Send + 'static,
|
||||
|
||||
@@ -21,7 +21,7 @@ tempfile = "3"
|
||||
filetime = "0.2"
|
||||
aes = "0.9"
|
||||
cbc = "0.2"
|
||||
hmac = "0.13"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
pbkdf2 = { version = "0.12", features = ["hmac"] }
|
||||
hex = "0.4"
|
||||
|
||||
@@ -36,25 +36,6 @@ pub use progress::{DecryptProgress, DecryptStats};
|
||||
pub use shard_routing::{route_shards_for_query, write_shard_metadata_sidecar};
|
||||
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).
|
||||
/// For one-shot commands: contacts, sessions, query, search, export.
|
||||
pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
|
||||
@@ -62,25 +43,7 @@ pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, Co
|
||||
.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_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,
|
||||
)?;
|
||||
let db = wx_db::WechatDb::open_encrypted(&encrypted_root, raw_key)?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -93,11 +56,9 @@ pub fn open_encrypted_db_with_pool(
|
||||
.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_with_pool_and_key_cache(
|
||||
let db = wx_db::WechatDb::open_encrypted_with_pool(
|
||||
&encrypted_root,
|
||||
raw_key,
|
||||
&derived_keys,
|
||||
register_mm_fts_tokenizer,
|
||||
)?;
|
||||
Ok(db)
|
||||
|
||||
@@ -12,7 +12,6 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
wx-decrypt = { path = "../wx-decrypt" }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
|
||||
@@ -414,8 +414,7 @@ impl WechatDb {
|
||||
)?;
|
||||
|
||||
for shard in &self.shards {
|
||||
let shard_conn =
|
||||
WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref())?;
|
||||
let shard_conn = WechatDb::open_shard_with_key(shard, self.raw_key.as_ref())?;
|
||||
|
||||
// List Msg_* tables in this shard
|
||||
let mut table_stmt = shard_conn.prepare(
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::model::{
|
||||
effective_limit, split_local_type, AnchorMode, Message, MessageQuery, MessageQueryResult,
|
||||
QueryStats, SortOrder,
|
||||
};
|
||||
use crate::open::{MessageShard, SqlcipherKey, WechatDb};
|
||||
use crate::open::{MessageShard, WechatDb};
|
||||
|
||||
/// Dispatch mode for regular (non-anchor) queries.
|
||||
enum RegularQueryMode {
|
||||
@@ -56,13 +56,13 @@ fn prepare_shard_query<'a>(
|
||||
table_name: &str,
|
||||
warnings: &mut Vec<ShardWarning>,
|
||||
pooled_conn: Option<&'a Connection>,
|
||||
sqlcipher_key: Option<&SqlcipherKey>,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
) -> Option<PreparedShard<'a>> {
|
||||
let shard_path = shard.path.display().to_string();
|
||||
|
||||
let conn = match pooled_conn {
|
||||
Some(conn) => ShardConnection::Borrowed(conn),
|
||||
None => match WechatDb::open_shard_with_key(shard, sqlcipher_key) {
|
||||
None => match WechatDb::open_shard_with_key(shard, raw_key) {
|
||||
Ok(c) => ShardConnection::Owned(c),
|
||||
Err(e) => {
|
||||
warnings.push(ShardWarning {
|
||||
@@ -176,7 +176,7 @@ impl WechatDb {
|
||||
&table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
self.raw_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -296,12 +296,12 @@ impl WechatDb {
|
||||
for shard in &shards {
|
||||
let count = if let Some(pool) = self.pool() {
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
match crate::open::open_connection(&shard.path, self.sqlcipher_key.as_ref()) {
|
||||
match crate::open::open_connection(&shard.path, self.raw_key.as_ref()) {
|
||||
Ok(conn) => {
|
||||
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
|
||||
}
|
||||
@@ -385,7 +385,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
self.raw_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -476,7 +476,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
self.raw_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -607,7 +607,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
self.raw_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -683,7 +683,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
self.raw_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -812,21 +812,16 @@ impl WechatDb {
|
||||
known_usernames.iter().map(|u| (u.clone(), 0)).collect();
|
||||
|
||||
for shard in self.all_shards() {
|
||||
let conn = if let Some(conn) = self.pool().and_then(|pool| pool.get(&shard.path)) {
|
||||
ShardConnection::Borrowed(conn)
|
||||
} else {
|
||||
match WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref()) {
|
||||
Ok(conn) => ShardConnection::Owned(conn),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
|
||||
shard.path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let conn = match WechatDb::open_shard_with_key(shard, self.raw_key.as_ref()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
|
||||
shard.path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let conn = conn.as_conn();
|
||||
|
||||
// Discover Msg_* tables in this shard
|
||||
let mut stmt = match conn
|
||||
|
||||
+41
-314
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::os::raw::c_void;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
@@ -18,111 +18,6 @@ pub(crate) struct MessageShard {
|
||||
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.
|
||||
///
|
||||
/// Holds connections to contact/session databases and metadata about
|
||||
@@ -139,8 +34,8 @@ pub struct WechatDb {
|
||||
pub contact_fts_path: Option<PathBuf>,
|
||||
/// Optional pre-opened connection pool for serve mode.
|
||||
pub(crate) pool: Option<ShardPool>,
|
||||
/// Shared raw/derived key state for encrypted direct open and reopen operations.
|
||||
pub(crate) sqlcipher_key: Option<SqlcipherKey>,
|
||||
/// Raw key for encrypted direct open. Stored for reopen operations.
|
||||
pub(crate) raw_key: Option<[u8; 32]>,
|
||||
/// Lazily initialized cache of label_id -> label_name from contact_label table.
|
||||
/// Cleared on `reopen_contacts()` so label changes are visible.
|
||||
pub(crate) label_cache: RwLock<Option<HashMap<String, String>>>,
|
||||
@@ -159,59 +54,30 @@ pub fn open_readonly_connection(
|
||||
path: &Path,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
) -> Result<Connection, DbError> {
|
||||
let key = raw_key.copied().map(SqlcipherKey::new);
|
||||
open_connection(path, key.as_ref())
|
||||
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
if let Some(key) = raw_key {
|
||||
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(
|
||||
path: &Path,
|
||||
sqlcipher_key: Option<&SqlcipherKey>,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
) -> Result<Connection, DbError> {
|
||||
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)
|
||||
open_readonly_connection(path, raw_key)
|
||||
}
|
||||
|
||||
impl WechatDb {
|
||||
@@ -221,13 +87,7 @@ impl WechatDb {
|
||||
/// does not exist. Message shards are optional here; message queries will
|
||||
/// return `DbError::NoShards` if no numbered shard is available.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> {
|
||||
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)
|
||||
Self::open_internal(path.as_ref(), None)
|
||||
}
|
||||
|
||||
/// Open a decrypted WeChat database directory with a pre-opened
|
||||
@@ -244,39 +104,7 @@ impl WechatDb {
|
||||
|
||||
/// 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> {
|
||||
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,
|
||||
)
|
||||
Self::open_internal(path.as_ref(), Some(raw_key))
|
||||
}
|
||||
|
||||
/// Open an encrypted WeChat database directory with a pre-opened
|
||||
@@ -286,53 +114,35 @@ impl WechatDb {
|
||||
raw_key: [u8; 32],
|
||||
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
||||
) -> Result<Self, DbError> {
|
||||
Self::open_with_pool_internal(path, Some(SqlcipherKey::new(raw_key)), fts_init)
|
||||
Self::open_with_pool_internal(path, Some(raw_key), fts_init)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
fn open_internal(path: &Path, raw_key: Option<[u8; 32]>) -> Result<Self, DbError> {
|
||||
if !path.exists() {
|
||||
return Err(DbError::NotFound(path.display().to_string()));
|
||||
}
|
||||
|
||||
let key_ref = sqlcipher_key.as_ref();
|
||||
let key_ref = raw_key.as_ref();
|
||||
|
||||
// Open contact.db
|
||||
let contact_path = path.join("contact").join("contact.db");
|
||||
if !contact_path.exists() {
|
||||
return Err(DbError::NotFound(contact_path.display().to_string()));
|
||||
}
|
||||
let contact_conn = open_connection(&contact_path, key_ref)?;
|
||||
let contact_conn = open_readonly_connection(&contact_path, key_ref)?;
|
||||
|
||||
// Open session.db
|
||||
let session_path = path.join("session").join("session.db");
|
||||
if !session_path.exists() {
|
||||
return Err(DbError::NotFound(session_path.display().to_string()));
|
||||
}
|
||||
let session_conn = open_connection(&session_path, key_ref)?;
|
||||
let session_conn = open_readonly_connection(&session_path, key_ref)?;
|
||||
|
||||
// Scan message shards
|
||||
let msg_dir = path.join("message");
|
||||
let mut shards = Vec::new();
|
||||
|
||||
if scan_message_shards && msg_dir.is_dir() {
|
||||
if msg_dir.is_dir() {
|
||||
let mut entries: Vec<PathBuf> = std::fs::read_dir(&msg_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
@@ -386,23 +196,23 @@ impl WechatDb {
|
||||
}
|
||||
},
|
||||
pool: None,
|
||||
sqlcipher_key,
|
||||
raw_key,
|
||||
label_cache: RwLock::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn open_with_pool_internal(
|
||||
path: impl AsRef<Path>,
|
||||
sqlcipher_key: Option<SqlcipherKey>,
|
||||
raw_key: Option<[u8; 32]>,
|
||||
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
||||
) -> Result<Self, DbError> {
|
||||
let mut db = Self::open_internal(path.as_ref(), sqlcipher_key.clone(), true)?;
|
||||
let mut db = Self::open_internal(path.as_ref(), raw_key)?;
|
||||
let fts_init_arc: Arc<crate::pool::FtsInitFn> = Arc::new(fts_init);
|
||||
let pool = ShardPool::open(
|
||||
&db.shards,
|
||||
db.message_fts_path.as_deref(),
|
||||
Some(fts_init_arc),
|
||||
sqlcipher_key,
|
||||
raw_key,
|
||||
)?;
|
||||
db.pool = Some(pool);
|
||||
Ok(db)
|
||||
@@ -410,14 +220,14 @@ impl WechatDb {
|
||||
|
||||
/// Re-open the session.db connection to pick up external changes.
|
||||
pub fn reopen_sessions(&mut self) -> Result<(), DbError> {
|
||||
self.session_conn = open_connection(&self.session_path, self.sqlcipher_key.as_ref())?;
|
||||
self.session_conn = open_connection(&self.session_path, self.raw_key.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-open the contact.db connection to pick up external changes.
|
||||
/// Also invalidates the label cache so it is reloaded on next query.
|
||||
pub fn reopen_contacts(&mut self) -> Result<(), DbError> {
|
||||
self.contact_conn = open_connection(&self.contact_path, self.sqlcipher_key.as_ref())?;
|
||||
self.contact_conn = open_connection(&self.contact_path, self.raw_key.as_ref())?;
|
||||
*self.label_cache.write().unwrap() = None;
|
||||
Ok(())
|
||||
}
|
||||
@@ -460,13 +270,6 @@ impl WechatDb {
|
||||
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]`.
|
||||
pub(crate) fn shards_for_range(&self, start: i64, end: i64) -> Vec<&MessageShard> {
|
||||
self.shards
|
||||
@@ -504,9 +307,9 @@ impl WechatDb {
|
||||
/// Open a SQLite connection to a specific shard, optionally encrypted.
|
||||
pub(crate) fn open_shard_with_key(
|
||||
shard: &MessageShard,
|
||||
sqlcipher_key: Option<&SqlcipherKey>,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
) -> Result<Connection, DbError> {
|
||||
open_connection(&shard.path, sqlcipher_key)
|
||||
open_readonly_connection(&shard.path, raw_key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,8 +341,8 @@ fn is_numbered_message_shard(path: &Path) -> bool {
|
||||
|
||||
/// Try to read the timestamp from a message shard's Timestamp table.
|
||||
/// Returns 0 if the table does not exist or is empty.
|
||||
fn read_shard_timestamp(path: &Path, sqlcipher_key: Option<&SqlcipherKey>) -> i64 {
|
||||
let conn = match open_connection(path, sqlcipher_key) {
|
||||
fn read_shard_timestamp(path: &Path, raw_key: Option<&[u8; 32]>) -> i64 {
|
||||
let conn = match open_connection(path, raw_key) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
@@ -622,35 +425,9 @@ mod tests {
|
||||
build_encrypted_db_storage(&root, &raw_key);
|
||||
|
||||
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)
|
||||
db.reopen_sessions().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]
|
||||
@@ -680,57 +457,7 @@ mod tests {
|
||||
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
|
||||
);
|
||||
|
||||
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 conn = open_connection(&path, Some(&raw_key)).unwrap();
|
||||
let val: i64 = conn
|
||||
.query_row("SELECT id FROM t", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
+10
-10
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::DbError;
|
||||
use crate::open::{MessageShard, SqlcipherKey};
|
||||
use crate::open::MessageShard;
|
||||
|
||||
pub(crate) type FtsInitFn = dyn Fn(&Connection) -> Result<(), String> + Send + Sync;
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct ShardPool {
|
||||
fts_conn: Option<Connection>,
|
||||
fts_path: Option<PathBuf>,
|
||||
fts_init: Option<Arc<FtsInitFn>>,
|
||||
sqlcipher_key: Option<SqlcipherKey>,
|
||||
raw_key: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ShardPool {
|
||||
@@ -41,22 +41,22 @@ impl ShardPool {
|
||||
shards: &[MessageShard],
|
||||
fts_path: Option<&Path>,
|
||||
fts_init: Option<Arc<FtsInitFn>>,
|
||||
sqlcipher_key: Option<SqlcipherKey>,
|
||||
raw_key: Option<[u8; 32]>,
|
||||
) -> Result<Self, DbError> {
|
||||
let mut conns = HashMap::with_capacity(shards.len());
|
||||
for shard in shards {
|
||||
let conn = crate::open::open_connection(&shard.path, sqlcipher_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(&shard.path, raw_key.as_ref())?;
|
||||
conns.insert(shard.path.clone(), conn);
|
||||
}
|
||||
|
||||
let fts_conn = match (fts_path, &fts_init) {
|
||||
(Some(path), Some(init)) => {
|
||||
let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, raw_key.as_ref())?;
|
||||
init(&conn).map_err(DbError::FtsInit)?;
|
||||
Some(conn)
|
||||
}
|
||||
(Some(path), None) => {
|
||||
let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, raw_key.as_ref())?;
|
||||
Some(conn)
|
||||
}
|
||||
_ => None,
|
||||
@@ -67,7 +67,7 @@ impl ShardPool {
|
||||
fts_conn,
|
||||
fts_path: fts_path.map(|p| p.to_path_buf()),
|
||||
fts_init,
|
||||
sqlcipher_key,
|
||||
raw_key,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ impl ShardPool {
|
||||
/// Close and reopen one shard connection.
|
||||
pub fn reopen_shard(&mut self, path: &Path) -> Result<(), DbError> {
|
||||
if self.conns.contains_key(path) {
|
||||
let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?;
|
||||
self.conns.insert(path.to_path_buf(), conn);
|
||||
}
|
||||
Ok(())
|
||||
@@ -93,7 +93,7 @@ impl ShardPool {
|
||||
/// Close and reopen the FTS connection, re-registering the tokenizer.
|
||||
pub fn reopen_fts(&mut self) -> Result<(), DbError> {
|
||||
if let Some(path) = &self.fts_path {
|
||||
let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?;
|
||||
if let Some(init) = &self.fts_init {
|
||||
init(&conn).map_err(DbError::FtsInit)?;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ impl ShardPool {
|
||||
pub fn reopen_all(&mut self) -> Result<(), DbError> {
|
||||
let paths: Vec<PathBuf> = self.conns.keys().cloned().collect();
|
||||
for path in paths {
|
||||
let conn = crate::open::open_connection(&path, self.sqlcipher_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(&path, self.raw_key.as_ref())?;
|
||||
self.conns.insert(path, conn);
|
||||
}
|
||||
self.reopen_fts()?;
|
||||
|
||||
@@ -8,7 +8,7 @@ aes = "0.9"
|
||||
cbc = "0.2"
|
||||
pbkdf2 = { version = "0.12", features = ["sha2"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.13"
|
||||
hmac = "0.12"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -23,6 +23,6 @@ libc = "0.2"
|
||||
[dev-dependencies]
|
||||
aes = "0.9"
|
||||
cbc = "0.2"
|
||||
hmac = "0.13"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -17,7 +17,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
[dev-dependencies]
|
||||
aes = "0.9"
|
||||
cbc = "0.2"
|
||||
hmac = "0.13"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
pbkdf2 = { version = "0.12", features = ["sha2"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use futures_core::Stream;
|
||||
use wx_db::{SessionQuery, WechatDb};
|
||||
use wx_decrypt::{CryptoParams, EncKeyPair, KeyMaterial};
|
||||
use wx_decrypt::{CryptoParams, KeyMaterial};
|
||||
|
||||
use crate::cache::{DecryptCache, UpdateKind};
|
||||
use crate::error::MonitorError;
|
||||
@@ -100,19 +100,7 @@ impl WechatMonitor {
|
||||
let (db, cache) = if let (Some(raw_key), Some(ref encrypted_root)) =
|
||||
(config.raw_key, &config.encrypted_root)
|
||||
{
|
||||
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,
|
||||
)?;
|
||||
let db = WechatDb::open_encrypted(encrypted_root, raw_key)?;
|
||||
(db, None)
|
||||
} else {
|
||||
let mut cache = DecryptCache::new(
|
||||
@@ -121,7 +109,7 @@ impl WechatMonitor {
|
||||
config.params,
|
||||
)?;
|
||||
cache.initial_decrypt()?;
|
||||
let db = WechatDb::open_core(cache.decrypted_root())?;
|
||||
let db = WechatDb::open(cache.decrypted_root())?;
|
||||
(db, Some(cache))
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user