fix(key): 支持手动指定微信安装目录并校验 db key 来源

- /api/get_keys 支持传入 wechat_install_path,兼容安装目录与 Weixin.exe / WeChat.exe

- 解密完成后保存 db key 的来源路径与别名,避免历史密钥被错误账号复用

- 解密页按 account + db_storage_path 回填已保存密钥,并补充相关测试覆盖
This commit is contained in:
2977094657
2026-04-23 21:32:02 +08:00
parent ec2a84af18
commit 0987167c4a
12 changed files with 729 additions and 77 deletions
+84 -13
View File
@@ -29,6 +29,8 @@ from .media_helpers import _resolve_account_dir, _resolve_account_wxid_dir
logger = logging.getLogger(__name__)
WECHAT_EXECUTABLE_NAMES = ("Weixin.exe", "WeChat.exe")
def _summarize_aes_key(value: Any) -> str:
raw = str(value or "").strip()
@@ -109,19 +111,72 @@ def _resolve_wxid_dir_for_image_key(
raise FileNotFoundError("无法定位该账号的 wxid_dir,请传入有效的 db_storage_path 或先完成数据库解密")
def _normalize_user_path(value: Any) -> str:
raw = str(value or "").strip().strip('"').strip("'")
if not raw:
return ""
try:
return os.path.normpath(os.path.expandvars(raw))
except Exception:
return raw
def _read_wechat_version_from_exe(exe_path: str) -> str:
normalized = _normalize_user_path(exe_path)
if not normalized:
return ""
try:
import win32api
version_info = win32api.GetFileVersionInfo(normalized, "\\")
return (
f"{version_info['FileVersionMS'] >> 16}."
f"{version_info['FileVersionMS'] & 0xFFFF}."
f"{version_info['FileVersionLS'] >> 16}."
f"{version_info['FileVersionLS'] & 0xFFFF}"
)
except Exception:
return ""
def _resolve_manual_wechat_exe_path(wechat_install_path: Optional[str] = None) -> str:
normalized = _normalize_user_path(wechat_install_path)
if not normalized:
return ""
candidate = Path(normalized).expanduser()
executable_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
if candidate.is_file():
if candidate.name.lower() not in executable_names:
raise RuntimeError("手动路径必须指向微信安装目录,或直接指向 Weixin.exe / WeChat.exe")
return str(candidate)
if candidate.is_dir():
for exe_name in WECHAT_EXECUTABLE_NAMES:
exe_path = candidate / exe_name
if exe_path.is_file():
return str(exe_path)
raise RuntimeError("手动指定的微信安装目录中未找到 Weixin.exe 或 WeChat.exe")
raise RuntimeError(f"手动指定的微信安装目录不存在: {candidate}")
# ====================== 以下是hook逻辑 ======================================
class WeChatKeyFetcher:
def __init__(self):
self.process_name = "Weixin.exe"
self.process_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
self.timeout_seconds = 60
def _is_wechat_process(self, name: Any) -> bool:
return str(name or "").strip().lower() in self.process_names
def kill_wechat(self):
"""检测并查杀微信进程"""
killed = False
for proc in psutil.process_iter(['pid', 'name']):
try:
if proc.info['name'] == self.process_name:
if self._is_wechat_process(proc.info['name']):
logger.info(f"Killing WeChat process: {proc.info['pid']}")
proc.terminate()
killed = True
@@ -134,11 +189,14 @@ class WeChatKeyFetcher:
def launch_wechat(self, exe_path: str) -> int:
"""启动微信并返回 PID"""
try:
process = subprocess.Popen(exe_path)
normalized_exe_path = _normalize_user_path(exe_path)
process = subprocess.Popen(normalized_exe_path)
time.sleep(2)
candidates = []
target_process_name = Path(normalized_exe_path).name.lower()
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
if proc.info['name'] == self.process_name:
proc_name = str(proc.info.get('name') or "").strip().lower()
if proc_name == target_process_name or self._is_wechat_process(proc_name):
candidates.append(proc)
if candidates:
@@ -152,19 +210,32 @@ class WeChatKeyFetcher:
logger.error(f"启动微信失败: {e}")
raise RuntimeError(f"无法启动微信: {e}")
def fetch_db_key(self) -> dict:
def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict:
"""调用 wx_key 仅获取数据库密钥 (Hook 模式)"""
if wx_key is None:
raise RuntimeError("wx_key 模块未安装或加载失败")
install_info = detect_wechat_installation()
exe_path = install_info.get('wechat_exe_path')
version = install_info.get('wechat_version')
manual_path = _normalize_user_path(wechat_install_path)
if manual_path:
exe_path = _resolve_manual_wechat_exe_path(manual_path)
version = _read_wechat_version_from_exe(exe_path)
logger.info(
"[db_key] 使用手动指定的微信安装路径: input=%s exe_path=%s version=%s",
manual_path,
exe_path,
version or "unknown",
)
else:
install_info = detect_wechat_installation()
exe_path = _normalize_user_path(install_info.get('wechat_exe_path'))
version = str(install_info.get('wechat_version') or "").strip()
if not exe_path or not version:
raise RuntimeError("无法自动定位微信安装路径或版本")
if not exe_path:
raise RuntimeError("无法自动定位微信安装路径,请手动填写微信安装目录")
if not Path(exe_path).is_file():
raise RuntimeError(f"微信可执行文件不存在: {exe_path}")
logger.info(f"Detect WeChat: {version} at {exe_path}")
logger.info(f"Detect WeChat: {version or 'unknown'} at {exe_path}")
self.kill_wechat()
pid = self.launch_wechat(exe_path)
@@ -204,9 +275,9 @@ class WeChatKeyFetcher:
"db_key": found_db_key
}
def get_db_key_workflow():
def get_db_key_workflow(wechat_install_path: Optional[str] = None):
fetcher = WeChatKeyFetcher()
return fetcher.fetch_db_key()
return fetcher.fetch_db_key(wechat_install_path=wechat_install_path)
# ============================== 以下是图片密钥逻辑 =====================================
+44 -5
View File
@@ -1,13 +1,41 @@
import datetime
import json
from pathlib import Path
from typing import Any, Optional
from typing import Any, Iterable, Optional
from .app_paths import get_account_keys_path
_KEY_STORE_PATH = get_account_keys_path()
def normalize_key_store_path(path_value: Optional[str]) -> str:
raw = str(path_value or "").strip()
if not raw:
return ""
try:
return str(Path(raw).expanduser().resolve())
except Exception:
try:
return str(Path(raw).expanduser())
except Exception:
return raw
def _normalize_account_aliases(*values: Optional[str], aliases: Optional[Iterable[str]] = None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for value in [*values, *(list(aliases or []))]:
key = str(value or "").strip()
if (not key) or (key in seen):
continue
seen.add(key)
out.append(key)
return out
def _atomic_write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
@@ -40,25 +68,36 @@ def upsert_account_keys_in_store(
db_key: Optional[str] = None,
image_xor_key: Optional[str] = None,
image_aes_key: Optional[str] = None,
aliases: Optional[Iterable[str]] = None,
db_key_source_wxid_dir: Optional[str] = None,
db_key_source_db_storage_path: Optional[str] = None,
) -> dict[str, Any]:
account = str(account or "").strip()
if not account:
return {}
store = load_account_keys_store()
item = store.get(account, {})
if not isinstance(item, dict):
item = {}
target_accounts = _normalize_account_aliases(account, aliases=aliases)
item: dict[str, Any] = {}
for target_account in target_accounts:
existing = store.get(target_account, {})
if isinstance(existing, dict) and existing:
item = dict(existing)
break
if db_key is not None:
item["db_key"] = str(db_key)
item["db_key_source_wxid_dir"] = normalize_key_store_path(db_key_source_wxid_dir)
item["db_key_source_db_storage_path"] = normalize_key_store_path(db_key_source_db_storage_path)
if image_xor_key is not None:
item["image_xor_key"] = str(image_xor_key)
if image_aes_key is not None:
item["image_aes_key"] = str(image_aes_key)
item["updated_at"] = datetime.datetime.now().isoformat(timespec="seconds")
store[account] = item
for target_account in target_accounts:
store[target_account] = dict(item)
try:
_atomic_write_json(_KEY_STORE_PATH, store)
+38 -4
View File
@@ -118,6 +118,38 @@ def _acquire_decrypt_account_guards(accounts: Any, *, reason: str) -> list[tuple
return guards
def _save_db_key_for_account(account: str, key: str, account_result: dict[str, Any] | None) -> None:
payload = dict(account_result or {})
success_count = int(payload.get("success") or 0)
if success_count <= 0:
logger.info("[decrypt] skip saving db key for failed account=%s success=%s", account, success_count)
return
source_wxid_dir = str(payload.get("source_wxid_dir") or "").strip()
source_db_storage_path = str(payload.get("source_db_storage_path") or "").strip()
aliases: list[str] = []
if source_wxid_dir:
wxid_dir_name = str(Path(source_wxid_dir).name or "").strip()
if wxid_dir_name and wxid_dir_name != str(account or "").strip():
aliases.append(wxid_dir_name)
upsert_account_keys_in_store(
str(account),
db_key=key,
aliases=aliases,
db_key_source_wxid_dir=source_wxid_dir or None,
db_key_source_db_storage_path=source_db_storage_path or None,
)
logger.info(
"[decrypt] saved db key account=%s aliases=%s source_wxid_dir=%s source_db_storage_path=%s",
str(account),
aliases,
source_wxid_dir,
source_db_storage_path,
)
class DecryptRequest(BaseModel):
"""解密请求模型"""
@@ -170,8 +202,8 @@ async def decrypt_databases(request: DecryptRequest):
# 成功解密后,按账号保存数据库密钥(用于前端自动回填)
try:
for account_name in (results.get("account_results") or {}).keys():
upsert_account_keys_in_store(str(account_name), db_key=request.key)
for account_name, account_result in (results.get("account_results") or {}).items():
_save_db_key_for_account(str(account_name), request.key, account_result)
except Exception:
pass
@@ -417,6 +449,8 @@ async def decrypt_databases_stream(
"success": account_success,
"failed": len(dbs) - account_success,
"output_dir": str(account_output_dir),
"source_db_storage_path": str(source_db_storage_path),
"source_wxid_dir": str(wxid_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"db_diagnostics": account_db_diagnostics,
@@ -481,8 +515,8 @@ async def decrypt_databases_stream(
# Save db key for frontend autofill.
try:
for account in (account_results or {}).keys():
upsert_account_keys_in_store(str(account), db_key=k)
for account, account_result in (account_results or {}).items():
_save_db_key_for_account(str(account), k, account_result)
except Exception:
pass
+158 -8
View File
@@ -1,9 +1,10 @@
from pathlib import Path
from typing import Optional
from fastapi import APIRouter
from ..logging_config import get_logger
from ..key_store import get_account_keys_from_store
from ..key_store import get_account_keys_from_store, normalize_key_store_path
from ..key_service import get_db_key_workflow, get_image_key_integrated_workflow
from ..media_helpers import _load_media_keys, _resolve_account_dir
from ..path_fix import PathFixRoute
@@ -21,8 +22,106 @@ def _summarize_aes_key(value: str) -> str:
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
def _resolve_requested_wxid_dir(*, db_storage_path: Optional[str] = None, wxid_dir: Optional[str] = None) -> str:
explicit_wxid_dir = str(wxid_dir or "").strip()
if explicit_wxid_dir:
return normalize_key_store_path(explicit_wxid_dir)
raw_db_storage_path = str(db_storage_path or "").strip()
if not raw_db_storage_path:
return ""
candidate = Path(raw_db_storage_path).expanduser()
try:
if str(candidate.name or "").lower() == "db_storage":
return normalize_key_store_path(str(candidate.parent))
except Exception:
pass
try:
if str((candidate / "db_storage").name or "").lower() == "db_storage":
return normalize_key_store_path(str(candidate))
except Exception:
pass
return ""
def _build_saved_key_candidates(account_name: Optional[str], request_account: Optional[str], request_wxid_dir: str) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for value in [
Path(request_wxid_dir).name if request_wxid_dir else "",
str(account_name or "").strip(),
str(request_account or "").strip(),
]:
key = str(value or "").strip()
if (not key) or (key in seen):
continue
seen.add(key)
out.append(key)
return out
def _evaluate_db_key_candidate(
*,
store_account: str,
keys: dict,
account_name: Optional[str],
request_wxid_dir: str,
request_db_storage_path: str,
) -> tuple[bool, int, str]:
db_key = str(keys.get("db_key") or "").strip()
if not db_key:
return False, -1, ""
source_wxid_dir = normalize_key_store_path(keys.get("db_key_source_wxid_dir"))
source_db_storage_path = normalize_key_store_path(keys.get("db_key_source_db_storage_path"))
request_wxid_dir_name = Path(request_wxid_dir).name if request_wxid_dir else ""
source_wxid_dir_name = Path(source_wxid_dir).name if source_wxid_dir else ""
if request_db_storage_path and source_db_storage_path:
if source_db_storage_path == request_db_storage_path:
return True, 400, ""
return (
False,
0,
f"Saved db key source does not match current db_storage_path. request={request_db_storage_path} stored={source_db_storage_path}",
)
if request_wxid_dir and source_wxid_dir:
if (source_wxid_dir == request_wxid_dir) or (
source_wxid_dir_name and source_wxid_dir_name == request_wxid_dir_name
):
return True, 300, ""
return (
False,
0,
f"Saved db key source does not match current wxid_dir. request={request_wxid_dir_name} stored={source_wxid_dir_name or source_wxid_dir}",
)
if request_wxid_dir_name:
if store_account == request_wxid_dir_name:
return True, 200, ""
if account_name and request_wxid_dir_name == str(account_name or "").strip():
return True, 100, ""
return (
False,
0,
f"Legacy saved db key is ambiguous for current wxid_dir={request_wxid_dir_name}. Please fetch a fresh db key.",
)
return True, 50, ""
@router.get("/api/keys", summary="获取账号已保存的密钥")
async def get_saved_keys(account: Optional[str] = None):
async def get_saved_keys(
account: Optional[str] = None,
db_storage_path: Optional[str] = None,
wxid_dir: Optional[str] = None,
):
"""获取账号的数据库密钥与图片密钥(用于前端自动回填)"""
account_name: Optional[str] = None
account_dir = None
@@ -34,16 +133,56 @@ async def get_saved_keys(account: Optional[str] = None):
# 账号可能尚未解密;仍允许从全局 store 读取(如果传入了 account
account_name = str(account or "").strip() or None
request_db_storage_path = normalize_key_store_path(db_storage_path)
request_wxid_dir = _resolve_requested_wxid_dir(db_storage_path=db_storage_path, wxid_dir=wxid_dir)
candidate_accounts = _build_saved_key_candidates(account_name, account, request_wxid_dir)
logger.info(
"[keys] get_saved_keys start: request_account=%s resolved_account=%s account_dir=%s",
"[keys] get_saved_keys start: request_account=%s resolved_account=%s account_dir=%s db_storage_path=%s wxid_dir=%s candidates=%s",
str(account or "").strip(),
str(account_name or ""),
str(account_dir) if account_dir else "",
request_db_storage_path,
request_wxid_dir,
candidate_accounts,
)
keys: dict = {}
if account_name:
keys = get_account_keys_from_store(account_name)
selected_db_key_account = ""
selected_db_key_score = -1
db_key_blocked_reason = ""
db_key_source_wxid_dir = ""
db_key_source_db_storage_path = ""
for candidate_account in candidate_accounts:
candidate_keys = get_account_keys_from_store(candidate_account)
if not isinstance(candidate_keys, dict) or not candidate_keys:
continue
if not str(keys.get("image_xor_key") or "").strip():
keys["image_xor_key"] = str(candidate_keys.get("image_xor_key") or "").strip()
if not str(keys.get("image_aes_key") or "").strip():
keys["image_aes_key"] = str(candidate_keys.get("image_aes_key") or "").strip()
if not str(keys.get("updated_at") or "").strip():
keys["updated_at"] = str(candidate_keys.get("updated_at") or "").strip()
ok, score, blocked_reason = _evaluate_db_key_candidate(
store_account=candidate_account,
keys=candidate_keys,
account_name=account_name,
request_wxid_dir=request_wxid_dir,
request_db_storage_path=request_db_storage_path,
)
if ok and score > selected_db_key_score:
selected_db_key_score = score
selected_db_key_account = candidate_account
keys["db_key"] = str(candidate_keys.get("db_key") or "").strip()
db_key_source_wxid_dir = normalize_key_store_path(candidate_keys.get("db_key_source_wxid_dir"))
db_key_source_db_storage_path = normalize_key_store_path(candidate_keys.get("db_key_source_db_storage_path"))
if str(candidate_keys.get("updated_at") or "").strip():
keys["updated_at"] = str(candidate_keys.get("updated_at") or "").strip()
elif (not ok) and blocked_reason and (not db_key_blocked_reason):
db_key_blocked_reason = blocked_reason
# 兼容:如果 store 里没有图片密钥,尝试从账号目录的 _media_keys.json 读取
if account_dir and isinstance(keys, dict):
@@ -62,11 +201,18 @@ async def get_saved_keys(account: Optional[str] = None):
"image_xor_key": str(keys.get("image_xor_key") or "").strip(),
"image_aes_key": str(keys.get("image_aes_key") or "").strip(),
"updated_at": str(keys.get("updated_at") or "").strip(),
"db_key_source_wxid_dir": db_key_source_wxid_dir,
"db_key_source_db_storage_path": db_key_source_db_storage_path,
"db_key_store_account": selected_db_key_account,
"db_key_blocked_reason": db_key_blocked_reason,
}
logger.info(
"[keys] get_saved_keys done: account=%s db_key_present=%s xor_key=%s aes_key=%s updated_at=%s",
"[keys] get_saved_keys done: account=%s db_key_present=%s db_key_store_account=%s db_key_source_wxid_dir=%s blocked_reason=%s xor_key=%s aes_key=%s updated_at=%s",
str(account_name or ""),
bool(result["db_key"]),
result["db_key_store_account"],
result["db_key_source_wxid_dir"],
result["db_key_blocked_reason"],
result["image_xor_key"],
_summarize_aes_key(result["image_aes_key"]),
result["updated_at"],
@@ -80,7 +226,7 @@ async def get_saved_keys(account: Optional[str] = None):
@router.get("/api/get_keys", summary="自动获取微信数据库与图片密钥")
async def get_wechat_db_key():
async def get_wechat_db_key(wechat_install_path: Optional[str] = None):
"""
自动流程:
1. 结束微信进程
@@ -89,7 +235,11 @@ async def get_wechat_db_key():
4. 抓取 DB 与 图片密钥(AES + XOR)并返回
"""
try:
keys_data = get_db_key_workflow()
logger.info(
"[keys] get_wechat_db_key start: wechat_install_path=%s",
str(wechat_install_path or "").strip(),
)
keys_data = get_db_key_workflow(wechat_install_path=wechat_install_path)
return {
"status": 0,
@@ -731,6 +731,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
"success": account_success,
"failed": len(databases) - account_success,
"output_dir": str(account_output_dir),
"source_db_storage_path": str(source_db_storage_path),
"source_wxid_dir": str(wxid_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"db_diagnostics": account_db_diagnostics,