diff --git a/src/attachment/image_key/linux.rs b/src/attachment/image_key/linux.rs new file mode 100644 index 0000000..4100ab2 --- /dev/null +++ b/src/attachment/image_key/linux.rs @@ -0,0 +1,11 @@ +use anyhow::{bail, Result}; + +use super::{ImageKeyMaterial, ImageKeyProvider}; + +pub struct LinuxImageKeyProvider; + +impl ImageKeyProvider for LinuxImageKeyProvider { + fn get_key(&self, _wxid: &str) -> Result { + bail!("Linux V2 图片 key 当前未实现;请先用 legacy/V1 图片或在 README 中标注 unsupported") + } +} diff --git a/src/attachment/image_key/macos.rs b/src/attachment/image_key/macos.rs index 234d4e5..127d81c 100644 --- a/src/attachment/image_key/macos.rs +++ b/src/attachment/image_key/macos.rs @@ -1,10 +1,423 @@ //! macOS V2 image AES key 提取。 //! -//! 主路径:从 `~/Library/Containers/com.tencent.xinWeChat/Data/Documents/key__*.statistic` -//! 文件名拿 uin,然后 `md5(str(uin) + sanitize(wxid)).hex()[:16]` 派生 AES key。 +//! 主路径:从 `key__*.statistic` 文件名拿 uin,然后 +//! `md5(str(uin) + normalize(wxid)).hex()[:16]` 派生 AES key。 //! -//! Fallback:枚举 uin 候选 2^24 个(`uint32`,但 wxid 4-byte 前缀只看后 24 bit), -//! 通过 `md5(str(uin))[:4] == wxid 后 4 字节` 匹配。 -//! 上游 `find_image_key_macos.py` 实测 1-2s 完成。 -//! -//! ⚠️ codex 落实现。 +//! fallback:通过 `md5(str(uin))[:4] == wxid_suffix` + `uin & 0xff == xor_key` +//! 把搜索空间压到 2^24,再用 V2 模板反验 AES key。 + +use anyhow::{bail, Context, Result}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; + +use crate::config; + +use super::{ + attach_root_for_db_dir, configured_db_dir_for_wxid, derive_xor_key_from_v2_dat, + find_v2_template_ciphertexts, join_components, normalize_wxid, verify_aes_key, wxid_from_db_dir, + ImageKeyMaterial, ImageKeyProvider, +}; + +pub struct MacosImageKeyProvider { + configured_db_dir: Result, + cache: Mutex>, +} + +impl MacosImageKeyProvider { + pub fn from_current_config() -> Self { + let configured_db_dir = config::load_config() + .map(|cfg| cfg.db_dir) + .map_err(|err| err.to_string()); + Self { + configured_db_dir, + cache: Mutex::new(HashMap::new()), + } + } +} + +impl ImageKeyProvider for MacosImageKeyProvider { + fn get_key(&self, wxid: &str) -> Result { + let cache_key = normalize_wxid(wxid); + if let Some(found) = self.cache.lock().unwrap().get(&cache_key).copied() { + return Ok(found); + } + + let configured_db_dir = self + .configured_db_dir + .as_ref() + .map_err(|err| anyhow::anyhow!("读取 config.db_dir 失败: {}", err))?; + let db_dir = configured_db_dir_for_wxid(configured_db_dir, wxid); + let attach_dir = attach_root_for_db_dir(&db_dir); + let key = derive_key_for_paths(&db_dir, &attach_dir)?; + self.cache.lock().unwrap().insert(cache_key, key); + Ok(key) + } +} + +fn derive_key_for_paths(db_dir: &Path, attach_dir: &Path) -> Result { + let templates = find_v2_template_ciphertexts(attach_dir, 3, 64)?; + if templates.is_empty() { + bail!("在 {} 下找不到 V2 模板文件", attach_dir.display()); + } + + if let Some(found) = find_via_kvcomm(db_dir, &templates)? { + return Ok(found); + } + + let (wxid_full, wxid_norm, suffix) = + extract_wxid_parts(db_dir).context("db_dir 不含可用于 fallback 的 wxid 4 位后缀")?; + let (xor_key, _votes, _total) = derive_xor_key_from_v2_dat(attach_dir, 10, 3)? + .context("V2 .dat 样本不足,无法投票反推 xor_key")?; + + for wxid in preferred_wxid_candidates(&wxid_full, &wxid_norm) { + if let Some(aes_key) = bruteforce_aes_key(xor_key, &suffix, wxid, &templates)? { + return Ok(ImageKeyMaterial { aes_key, xor_key }); + } + } + + bail!("macOS V2 图片 key 派生失败") +} + +fn find_via_kvcomm(db_dir: &Path, templates: &[[u8; 16]]) -> Result> { + let Some(kvcomm_dir) = find_existing_kvcomm_dir(db_dir) else { + return Ok(None); + }; + + let codes = collect_kvcomm_codes(&kvcomm_dir)?; + if codes.is_empty() { + return Ok(None); + } + let wxids = collect_wxid_candidates(db_dir); + if wxids.is_empty() { + return Ok(None); + } + + for wxid in wxids { + for code in &codes { + let candidate = derive_image_key_material(*code, &wxid); + if verify_aes_key(&candidate.aes_key, templates) { + return Ok(Some(candidate)); + } + } + } + Ok(None) +} + +fn derive_image_key_material(code: u32, wxid: &str) -> ImageKeyMaterial { + let xor_key = (code & 0xFF) as u8; + let digest = format!("{:x}", md5::compute(format!("{}{}", code, wxid))); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&digest.as_bytes()[..16]); + ImageKeyMaterial { aes_key, xor_key } +} + +fn collect_wxid_candidates(db_dir: &Path) -> Vec { + let Some(raw) = wxid_from_db_dir(db_dir) else { + return Vec::new(); + }; + let mut out = vec![raw.clone()]; + let normalized = normalize_wxid(&raw); + if normalized != raw { + out.push(normalized); + } + out +} + +fn extract_wxid_parts(db_dir: &Path) -> Option<(String, String, String)> { + let raw = wxid_from_db_dir(db_dir)?; + let idx = raw.rfind('_')?; + let suffix = &raw[idx + 1..]; + if suffix.len() != 4 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + Some((raw.clone(), normalize_wxid(&raw), suffix.to_ascii_lowercase())) +} + +fn preferred_wxid_candidates<'a>(raw: &'a str, normalized: &'a str) -> Vec<&'a str> { + if raw == normalized { + vec![raw] + } else { + vec![normalized, raw] + } +} + +fn derive_kvcomm_dir_candidates(db_dir: &Path) -> Vec { + let parts: Vec = db_dir + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect(); + + let mut candidates = Vec::new(); + if let Some(idx) = parts.iter().position(|part| part == "xwechat_files") { + let documents_root = join_components(&parts[..idx]); + candidates.push(documents_root.join("app_data/net/kvcomm")); + candidates.push(documents_root.join("xwechat/net/kvcomm")); + if idx >= 1 { + let container_root = join_components(&parts[..idx - 1]); + candidates.push( + container_root + .join("Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm"), + ); + candidates.push( + container_root.join("Library/Application Support/com.tencent.xinWeChat/net/kvcomm"), + ); + } + } + if let Some(home) = dirs::home_dir() { + candidates.push( + home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm"), + ); + } + + let mut dedup = Vec::new(); + for candidate in candidates { + if !dedup.contains(&candidate) { + dedup.push(candidate); + } + } + dedup +} + +fn find_existing_kvcomm_dir(db_dir: &Path) -> Option { + derive_kvcomm_dir_candidates(db_dir) + .into_iter() + .find(|path| path.is_dir()) +} + +fn collect_kvcomm_codes(kvcomm_dir: &Path) -> Result> { + let mut codes = std::collections::BTreeSet::new(); + for entry in std::fs::read_dir(kvcomm_dir)? { + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(|value| value.to_string()) else { + continue; + }; + let Some(rest) = name.strip_prefix("key_") else { + continue; + }; + let Some((code, _)) = rest.split_once('_') else { + continue; + }; + if let Ok(code) = code.parse::() { + codes.insert(code); + } + } + Ok(codes.into_iter().collect()) +} + +fn bruteforce_aes_key( + xor_key: u8, + suffix_hex: &str, + wxid: &str, + templates: &[[u8; 16]], +) -> Result> { + let suffix = hex_prefix_to_bytes(suffix_hex)?; + let workers = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1) + .max(1); + let total = 1u32 << 24; + let chunk = total / workers as u32; + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + let wxid = Arc::new(wxid.as_bytes().to_vec()); + let templates = Arc::new(templates.to_vec()); + + std::thread::scope(|scope| { + for idx in 0..workers { + let start = idx as u32 * chunk; + let end = if idx + 1 == workers { + total + } else { + (idx as u32 + 1) * chunk + }; + let stop = Arc::clone(&stop); + let tx = tx.clone(); + let wxid = Arc::clone(&wxid); + let templates = Arc::clone(&templates); + scope.spawn(move || { + for upper in start..end { + if stop.load(Ordering::Relaxed) { + break; + } + let uin = (upper << 8) | xor_key as u32; + let uin_ascii = uin.to_string(); + let digest = md5::compute(uin_ascii.as_bytes()); + if digest.0[0] != suffix[0] || digest.0[1] != suffix[1] { + continue; + } + + let mut input = Vec::with_capacity(uin_ascii.len() + wxid.len()); + input.extend_from_slice(uin_ascii.as_bytes()); + input.extend_from_slice(&wxid); + let aes_hex = format!("{:x}", md5::compute(input)); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&aes_hex.as_bytes()[..16]); + if verify_aes_key(&aes_key, &templates) { + stop.store(true, Ordering::Relaxed); + let _ = tx.send(aes_key); + break; + } + } + }); + } + }); + drop(tx); + Ok(rx.try_iter().next()) +} + +fn hex_prefix_to_bytes(hex: &str) -> Result<[u8; 2]> { + if hex.len() != 4 { + bail!("wxid suffix 不是 4 位 hex: {}", hex); + } + let hi = u8::from_str_radix(&hex[..2], 16)?; + let lo = u8::from_str_radix(&hex[2..], 16)?; + Ok([hi, lo]) +} + +#[cfg(test)] +mod tests { + use super::{derive_key_for_paths, find_existing_kvcomm_dir}; + use super::collect_wxid_candidates; + use crate::attachment::image_key::normalize_wxid; + use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; + use aes::Aes128; + use std::fs; + use std::path::Path; + + fn temp_dir(label: &str) -> std::path::PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "wx-cli-image-key-macos-{}-{:?}", + label, + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_v2_template(path: &Path, aes_key: &[u8; 16], xor_key: u8, plaintext: &[u8; 16]) { + let cipher = Aes128::new(aes_key.into()); + let mut block = GenericArray::clone_from_slice(plaintext); + cipher.encrypt_block(&mut block); + + let mut data = Vec::new(); + data.extend_from_slice(&crate::attachment::decoder::V2_MAGIC); + data.extend_from_slice(&0u32.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + data.push(0); + data.extend_from_slice(&block); + data.push(0); + data.push(0xD9 ^ xor_key); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, data).unwrap(); + } + + #[test] + fn normalize_wxid_matches_expected_shapes() { + assert_eq!(normalize_wxid("wxid_abc_def"), "wxid_abc"); + assert_eq!(normalize_wxid("your_wxid_a1b2"), "your_wxid"); + assert_eq!(normalize_wxid("plain"), "plain"); + } + + #[test] + fn kvcomm_path_detection_works() { + let dir = temp_dir("kvcomm"); + let db_dir = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage", + ); + let kvcomm = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm", + ); + fs::create_dir_all(&db_dir).unwrap(); + fs::create_dir_all(&kvcomm).unwrap(); + assert_eq!(find_existing_kvcomm_dir(&db_dir), Some(kvcomm)); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn derives_key_via_kvcomm() { + let dir = temp_dir("via-kvcomm"); + let db_dir = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage", + ); + let attach = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/msg/attach/chat/2026-05/Img", + ); + let kvcomm = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm", + ); + fs::create_dir_all(&db_dir).unwrap(); + fs::create_dir_all(&kvcomm).unwrap(); + fs::write(kvcomm.join("key_42_x.statistic"), b"").unwrap(); + + let digest = format!("{:x}", md5::compute("42your_wxid")); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&digest.as_bytes()[..16]); + write_v2_template( + &attach.join("sample_t.dat"), + &aes_key, + 42, + b"\xFF\xD8\xFFtemplate-001!", + ); + + let derived = derive_key_for_paths(&db_dir, db_dir.parent().unwrap().join("msg/attach").as_path()) + .unwrap(); + assert_eq!(derived.aes_key, aes_key); + assert_eq!(derived.xor_key, 42); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn derives_key_via_bruteforce_fallback() { + let dir = temp_dir("via-fallback"); + let suffix = format!("{:x}", md5::compute("42")) + .chars() + .take(4) + .collect::(); + let raw_wxid = format!("mywxid_{}", suffix); + let db_dir = dir.join(format!( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{}/db_storage", + raw_wxid + )); + let attach = dir.join(format!( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{}/msg/attach/chat/2026-05/Img", + raw_wxid + )); + fs::create_dir_all(&db_dir).unwrap(); + + let digest = format!("{:x}", md5::compute("42mywxid")); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&digest.as_bytes()[..16]); + for idx in 0..3 { + write_v2_template( + &attach.join(format!("sample{}_t.dat", idx)), + &aes_key, + 42, + b"\xFF\xD8\xFFtemplate-001!", + ); + } + + let derived = derive_key_for_paths(&db_dir, db_dir.parent().unwrap().join("msg/attach").as_path()) + .unwrap(); + assert_eq!(derived.aes_key, aes_key); + assert_eq!(derived.xor_key, 42); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn collects_raw_and_normalized_wxid() { + let dir = temp_dir("wxid"); + let db_dir = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage", + ); + fs::create_dir_all(&db_dir).unwrap(); + let wxids = collect_wxid_candidates(&db_dir); + assert_eq!(wxids, vec!["your_wxid_a1b2".to_string(), "your_wxid".to_string()]); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/src/attachment/image_key/mod.rs b/src/attachment/image_key/mod.rs index ec4f8ad..74eee30 100644 --- a/src/attachment/image_key/mod.rs +++ b/src/attachment/image_key/mod.rs @@ -1,7 +1,5 @@ //! V2 image AES key 提取 — 平台相关。 //! -//! ⚠️ 此模块由 codex 落地。本文件只放公共 trait + 平台 dispatch 占位。 -//! //! 路径: //! - macOS:磁盘派生(`key__*.statistic` 文件名拿 uin → `md5(str(uin) + wxid)[:16]`) //! + brute-force fallback(`md5(str(uin))[:4] == wxid_suffix` 枚举 2^24) @@ -9,26 +7,336 @@ //! 反验(`find_image_key.py` / `find_image_key.c` 已写实) //! - Linux:上游空白;当前不实现,遇到 V2 .dat 返回 unsupported 错误 -#[allow(dead_code)] +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(target_os = "macos")] pub mod macos; -#[allow(dead_code)] +#[cfg(target_os = "windows")] pub mod windows; use anyhow::Result; +use regex::bytes::Regex; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::attachment::decoder::{detect_image_format, V2_MAGIC}; + +/// V2 图片真正需要的是两份材料: +/// - 16 字节 ASCII AES key +/// - XOR key(macOS 上来自 uin & 0xff,不是总能硬编码成 0x88) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImageKeyMaterial { + pub aes_key: [u8; 16], + pub xor_key: u8, +} /// 单个 wxid 的 V2 image key 提取接口。 /// -/// 实现者负责跨调用缓存(一台机器上同一 wxid 的 image key 在微信不重启时是稳定的)。 +/// 实现者负责跨调用缓存(一台机器上同一 wxid 的 image key 在微信不重启时通常稳定)。 pub trait ImageKeyProvider { - /// 返回当前 wxid 的 16 字节 AES key。失败要带可执行的诊断(例如「macOS 没找到 - /// kvcomm cache,请确认微信已登录」/「Windows 进程不在跑」)。 - fn get_aes_key(&self, wxid: &str) -> Result<[u8; 16]>; + fn get_key(&self, wxid: &str) -> Result; + + fn get_aes_key(&self, wxid: &str) -> Result<[u8; 16]> { + Ok(self.get_key(wxid)?.aes_key) + } + + fn get_xor_key(&self, wxid: &str) -> Result { + Ok(self.get_key(wxid)?.xor_key) + } } -/// 平台默认实现(codex 后续填)。 -/// -/// 调用方目前可以直接传 `None`,让 resolver 在遇到 V2 .dat 时报「image key 未提取」错。 +/// 平台默认实现。 pub fn default_provider() -> Option> { - // TODO(codex): 按 cfg(target_os) 返回 macOS / Windows / 不支持 + #[cfg(target_os = "macos")] + { + return Some(Box::new(macos::MacosImageKeyProvider::from_current_config())); + } + #[cfg(target_os = "windows")] + { + return Some(Box::new(windows::WindowsImageKeyProvider::from_current_config())); + } + #[cfg(target_os = "linux")] + { + return Some(Box::new(linux::LinuxImageKeyProvider)); + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + None + } +} + +pub(crate) fn configured_db_dir_for_wxid(configured_db_dir: &Path, requested_wxid: &str) -> PathBuf { + if requested_wxid.trim().is_empty() { + return configured_db_dir.to_path_buf(); + } + + let configured_leaf = wxid_from_db_dir(configured_db_dir); + if let Some(leaf) = configured_leaf.as_deref() { + if same_wxid(leaf, requested_wxid) { + return configured_db_dir.to_path_buf(); + } + } + + xwechat_files_root(configured_db_dir) + .map(|root| root.join(requested_wxid).join("db_storage")) + .unwrap_or_else(|| configured_db_dir.to_path_buf()) +} + +pub(crate) fn wxid_from_db_dir(db_dir: &Path) -> Option { + let mut components = db_dir + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()); + while let Some(component) = components.next() { + if component == "xwechat_files" { + return components.next(); + } + } None } + +pub(crate) fn xwechat_files_root(db_dir: &Path) -> Option { + let parts: Vec<_> = db_dir + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect(); + let idx = parts.iter().position(|part| part == "xwechat_files")?; + Some(join_components(&parts[..=idx])) +} + +pub(crate) fn normalize_wxid(raw: &str) -> String { + let raw = raw.trim(); + if raw.is_empty() { + return String::new(); + } + if let Some(stripped) = raw.strip_prefix("wxid_") { + let head = stripped.split('_').next().unwrap_or(stripped); + return format!("wxid_{}", head); + } + if let Some((base, suffix)) = raw.rsplit_once('_') { + if suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return base.to_string(); + } + } + raw.to_string() +} + +pub(crate) fn same_wxid(a: &str, b: &str) -> bool { + a == b || normalize_wxid(a) == normalize_wxid(b) +} + +pub(crate) fn join_components(parts: &[String]) -> PathBuf { + let mut out = if parts.first().map(|part| part.is_empty()).unwrap_or(false) { + PathBuf::from("/") + } else { + PathBuf::new() + }; + for part in parts { + if part.is_empty() { + continue; + } + out.push(part); + } + out +} + +pub(crate) fn attach_root_for_db_dir(db_dir: &Path) -> PathBuf { + db_dir + .parent() + .map(|base| base.join("msg").join("attach")) + .unwrap_or_else(|| PathBuf::from("msg/attach")) +} + +pub(crate) fn find_v2_template_ciphertexts( + attach_dir: &Path, + max_templates: usize, + max_files: usize, +) -> Result> { + if !attach_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut out = collect_templates_with_suffix(attach_dir, "_t.dat", max_templates, max_files)?; + if out.is_empty() { + out = collect_templates_with_suffix(attach_dir, ".dat", max_templates, max_files)?; + } + Ok(out) +} + +pub(crate) fn derive_xor_key_from_v2_dat( + attach_dir: &Path, + sample: usize, + min_samples: usize, +) -> Result> { + if !attach_dir.is_dir() { + return Ok(None); + } + let mut votes = Vec::new(); + visit_files(attach_dir, &mut |path| -> Result { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return Ok(false); + }; + if !name.ends_with(".dat") { + return Ok(false); + } + + let meta = fs::metadata(path)?; + if meta.len() < 0x20 { + return Ok(false); + } + + let bytes = fs::read(path)?; + if bytes.starts_with(&V2_MAGIC) { + let last = *bytes.last().unwrap(); + votes.push(last ^ 0xD9); + if votes.len() >= sample { + return Ok(true); + } + } + Ok(false) + })?; + + if votes.len() < min_samples { + return Ok(None); + } + + let mut counts = [0usize; 256]; + for vote in &votes { + counts[*vote as usize] += 1; + } + let (xor_key, top_votes) = counts + .iter() + .enumerate() + .max_by_key(|(_, count)| *count) + .map(|(idx, count)| (idx as u8, *count)) + .expect("votes 非空"); + Ok(Some((xor_key, top_votes, votes.len()))) +} + +pub(crate) fn verify_aes_key(aes_key: &[u8; 16], templates: &[[u8; 16]]) -> bool { + !templates.is_empty() + && templates + .iter() + .all(|template| decrypt_template_block(aes_key, template).is_some()) +} + +pub(crate) fn ascii_alnum_candidates<'a>(buf: &'a [u8], len: usize) -> Vec<&'a [u8]> { + let re = match len { + 16 => regex16(), + 32 => regex32(), + _ => return Vec::new(), + }; + + re.find_iter(buf) + .filter_map(|matched| { + let start = matched.start(); + let end = matched.end(); + let left_ok = start == 0 || !buf[start - 1].is_ascii_alphanumeric(); + let right_ok = end == buf.len() || !buf[end].is_ascii_alphanumeric(); + (left_ok && right_ok).then_some(&buf[start..end]) + }) + .collect() +} + +fn collect_templates_with_suffix( + dir: &Path, + suffix: &str, + max_templates: usize, + max_files: usize, +) -> Result> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut examined = 0usize; + visit_files(dir, &mut |path| -> Result { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return Ok(false); + }; + if !name.ends_with(suffix) { + return Ok(false); + } + examined += 1; + let bytes = fs::read(path)?; + if bytes.len() >= 0x1F && bytes.starts_with(&V2_MAGIC) { + let template: [u8; 16] = bytes[0x0F..0x1F].try_into().unwrap(); + if seen.insert(template) { + out.push(template); + if out.len() >= max_templates { + return Ok(true); + } + } + } + Ok(examined >= max_files && !out.is_empty()) + })?; + Ok(out) +} + +fn visit_files(dir: &Path, f: &mut F) -> Result +where + F: FnMut(&Path) -> Result, +{ + let mut entries: Vec = fs::read_dir(dir)? + .flatten() + .map(|entry| entry.path()) + .collect(); + entries.sort(); + + for path in entries { + if path.is_dir() { + if visit_files(&path, f)? { + return Ok(true); + } + continue; + } + if f(&path)? { + return Ok(true); + } + } + Ok(false) +} + +fn decrypt_template_block(aes_key: &[u8; 16], ciphertext: &[u8; 16]) -> Option<&'static str> { + use aes::cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit}; + + let cipher = aes::Aes128::new(aes_key.into()); + let mut block = GenericArray::clone_from_slice(ciphertext); + cipher.decrypt_block(&mut block); + let block: [u8; 16] = block.as_slice().try_into().ok()?; + let format = detect_image_format(&block); + (format != "bin").then_some(format) +} + +fn regex16() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"[A-Za-z0-9]{16}").unwrap()) +} + +fn regex32() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"[A-Za-z0-9]{32}").unwrap()) +} + +#[cfg(test)] +mod tests { + use super::{ascii_alnum_candidates, normalize_wxid, same_wxid}; + + #[test] + fn regex_candidates_respect_boundaries() { + let buf = b"xx 0123456789ABCDef yy"; + let hits = ascii_alnum_candidates(buf, 16); + assert_eq!(hits, vec![&buf[3..19]]); + } + + #[test] + fn regex_candidates_ignore_embedded_runs() { + let buf = b"x0123456789ABCDefz"; + assert!(ascii_alnum_candidates(buf, 16).is_empty()); + } + + #[test] + fn wxid_normalization_matches_expected_forms() { + assert_eq!(normalize_wxid("wxid_abc_def"), "wxid_abc"); + assert_eq!(normalize_wxid("your_wxid_a1b2"), "your_wxid"); + assert!(same_wxid("your_wxid_a1b2", "your_wxid")); + } +} diff --git a/src/attachment/image_key/windows.rs b/src/attachment/image_key/windows.rs index 1a0080a..0b7acd8 100644 --- a/src/attachment/image_key/windows.rs +++ b/src/attachment/image_key/windows.rs @@ -1,10 +1,238 @@ //! Windows V2 image AES key 提取。 //! -//! 扫 `Weixin.exe` 进程内存,匹配模式 `(?, + cache: Mutex>, +} + +impl WindowsImageKeyProvider { + pub fn from_current_config() -> Self { + let configured_db_dir = config::load_config() + .map(|cfg| cfg.db_dir) + .map_err(|err| err.to_string()); + Self { + configured_db_dir, + cache: Mutex::new(HashMap::new()), + } + } +} + +impl ImageKeyProvider for WindowsImageKeyProvider { + fn get_key(&self, wxid: &str) -> Result { + let cache_key = wxid.trim().to_string(); + if let Some(found) = self.cache.lock().unwrap().get(&cache_key).copied() { + return Ok(found); + } + + let configured_db_dir = self + .configured_db_dir + .as_ref() + .map_err(|err| anyhow::anyhow!("读取 config.db_dir 失败: {}", err))?; + let db_dir = configured_db_dir_for_wxid(configured_db_dir, wxid); + let attach_dir = attach_root_for_db_dir(&db_dir); + let key = derive_key_for_paths(&attach_dir)?; + self.cache.lock().unwrap().insert(cache_key, key); + Ok(key) + } +} + +fn derive_key_for_paths(attach_dir: &std::path::Path) -> Result { + let templates = find_v2_template_ciphertexts(attach_dir, 3, 64)?; + if templates.is_empty() { + bail!("在 {} 下找不到 V2 模板文件", attach_dir.display()); + } + let xor_key = derive_xor_key_from_v2_dat(attach_dir, 10, 3)? + .map(|(key, _, _)| key) + .unwrap_or(0x88); + + let pid = find_wechat_pid().context("找不到 Weixin.exe 进程,请确认微信正在运行")?; + let process = unsafe { + OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid) + .context("OpenProcess 失败,请以管理员权限运行")? + }; + + let aes_key = scan_memory_for_key(process, &templates); + unsafe { + let _ = CloseHandle(process); + } + + Ok(ImageKeyMaterial { + aes_key: aes_key?, + xor_key, + }) +} + +fn find_wechat_pid() -> Option { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? }; + let mut entry = PROCESSENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + unsafe { + if Process32First(snapshot, &mut entry).is_err() { + let _ = CloseHandle(snapshot); + return None; + } + loop { + let name = + std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8).to_string_lossy(); + if name.eq_ignore_ascii_case("Weixin.exe") { + let pid = entry.th32ProcessID; + let _ = CloseHandle(snapshot); + return Some(pid); + } + if Process32Next(snapshot, &mut entry).is_err() { + break; + } + } + let _ = CloseHandle(snapshot); + } + None +} + +fn scan_memory_for_key(process: HANDLE, templates: &[[u8; 16]]) -> Result<[u8; 16]> { + let mut seen = HashSet::<[u8; 16]>::new(); + let mut address = 0usize; + + loop { + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + let ret = unsafe { + VirtualQueryEx( + process, + Some(address as *const _), + &mut mbi, + std::mem::size_of::(), + ) + }; + if ret == 0 { + break; + } + + let base = mbi.BaseAddress as usize; + let size = mbi.RegionSize; + if mbi.State == MEM_COMMIT && is_candidate_page(mbi.Protect.0) && size <= MAX_REGION_SIZE { + if let Some(aes_key) = scan_region(process, base, size, templates, &mut seen)? { + return Ok(aes_key); + } + } + + address = base.saturating_add(size); + if address == 0 { + break; + } + } + + bail!("Windows 进程内存里没有找到可验证的 V2 AES key") +} + +fn scan_region( + process: HANDLE, + base: usize, + size: usize, + templates: &[[u8; 16]], + seen: &mut HashSet<[u8; 16]>, +) -> Result> { + let overlap = 31usize; + let mut offset = 0usize; + + while offset < size { + let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset); + let addr = base + offset; + let mut buf = vec![0u8; chunk_size]; + let mut bytes_read = 0usize; + + let ok = unsafe { + ReadProcessMemory( + process, + addr as *const _, + buf.as_mut_ptr() as *mut _, + chunk_size, + Some(&mut bytes_read), + ) + .is_ok() + }; + + if ok && bytes_read > 0 { + buf.truncate(bytes_read); + if let Some(key) = scan_candidate_buffer(&buf, templates, seen) { + return Ok(Some(key)); + } + } + + offset += if chunk_size > overlap { + chunk_size - overlap + } else { + chunk_size + }; + } + + Ok(None) +} + +fn scan_candidate_buffer( + buf: &[u8], + templates: &[[u8; 16]], + seen: &mut HashSet<[u8; 16]>, +) -> Option<[u8; 16]> { + for candidate in ascii_alnum_candidates(buf, 32) { + let mut key = [0u8; 16]; + key.copy_from_slice(&candidate[..16]); + if seen.insert(key) && verify_aes_key(&key, templates) { + return Some(key); + } + } + for candidate in ascii_alnum_candidates(buf, 16) { + let mut key = [0u8; 16]; + key.copy_from_slice(candidate); + if seen.insert(key) && verify_aes_key(&key, templates) { + return Some(key); + } + } + None +} + +fn is_candidate_page(protect: u32) -> bool { + if protect == PAGE_NOACCESS.0 || (protect & PAGE_GUARD.0) != 0 { + return false; + } + let base = protect & !(PAGE_GUARD.0 | PAGE_NOCACHE.0 | PAGE_WRITECOMBINE.0); + matches!( + base, + value if value == PAGE_READWRITE.0 + || value == PAGE_WRITECOPY.0 + || value == PAGE_EXECUTE_READWRITE.0 + || value == PAGE_EXECUTE_WRITECOPY.0 + ) +}