2026-07-08 23:25:58 +08:00
|
|
|
|
"""DB 状态协调:DB 状态解析 / 密钥自动提取 / DB 可读性检查 / 重试装饰器。
|
|
|
|
|
|
|
|
|
|
|
|
从 server.py 抽出的 DB 协调逻辑,供 routes/ 与 app.py 调用。
|
|
|
|
|
|
所有函数通过 _state(from config.py)访问全局状态。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import functools
|
2026-07-09 19:59:50 +08:00
|
|
|
|
import inspect
|
2026-07-08 23:25:58 +08:00
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import time
|
|
|
|
|
|
from dataclasses import dataclass
|
2026-07-09 19:59:50 +08:00
|
|
|
|
from typing import Optional, get_type_hints
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
from woc_bridge.config import _state, _require_db_reader, _require_xdotool
|
|
|
|
|
|
from woc_bridge.db.decryptor import Decryptor, _resolve_page1_key_material
|
|
|
|
|
|
from woc_bridge.db.key_extractor import KeyExtractor, _get_wechat_pids
|
|
|
|
|
|
from woc_bridge.models import BridgeError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("woc-bridge")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# with_db_retry 装饰器
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def with_db_retry(func):
|
|
|
|
|
|
"""路由装饰器:遇到 DB_ENCRYPTED 时强制重新提取 key 并重试一次。
|
|
|
|
|
|
|
|
|
|
|
|
DB 加密异常可能发生在:
|
|
|
|
|
|
- _check_db_readable():contact.db 探针 key 失效
|
|
|
|
|
|
- DB 读取操作:message_0.db 等 DB 的 key 失效(探针未覆盖)
|
|
|
|
|
|
|
|
|
|
|
|
捕获 DB_ENCRYPTED 后,阻塞调用 _auto_extract_with_lock(force=True) 重新提取 key,
|
|
|
|
|
|
成功后重试一次路由;提取失败则原样抛出。只重试一次,避免死循环。
|
|
|
|
|
|
|
|
|
|
|
|
发送类接口(send_text 等)同样安全:DB_ENCRYPTED 只会在发送前的
|
|
|
|
|
|
DB 读取阶段(_resolve_display_name)抛出,尚未执行实际 UI 发送。
|
|
|
|
|
|
"""
|
|
|
|
|
|
@functools.wraps(func)
|
|
|
|
|
|
async def wrapper(*args, **kwargs):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await func(*args, **kwargs)
|
|
|
|
|
|
except BridgeError as e:
|
|
|
|
|
|
if e.code != "DB_ENCRYPTED":
|
|
|
|
|
|
raise
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"%s 遇到 DB_ENCRYPTED,强制重新提取 key 后重试一次: %s",
|
|
|
|
|
|
func.__name__, e.message,
|
|
|
|
|
|
)
|
|
|
|
|
|
extracted = await _auto_extract_with_lock(force=True)
|
|
|
|
|
|
if not extracted:
|
|
|
|
|
|
raise
|
|
|
|
|
|
logger.info("%s key 重新提取成功,重试请求", func.__name__)
|
|
|
|
|
|
return await func(*args, **kwargs)
|
2026-07-09 19:59:50 +08:00
|
|
|
|
|
|
|
|
|
|
# functools.wraps 复制的 __annotations__ 是字符串化注解(因 from __future__
|
|
|
|
|
|
# import annotations),FastAPI 解析 wrapper 签名时拿到字符串注解,无法识别
|
|
|
|
|
|
# Pydantic body 参数,会把它当 query 参数处理导致 422。用 get_type_hints
|
|
|
|
|
|
# 解析为真实类型,并据此重建 __signature__,让 FastAPI 能正确识别参数类型。
|
|
|
|
|
|
try:
|
|
|
|
|
|
hints = get_type_hints(func, include_extras=True)
|
|
|
|
|
|
wrapper.__annotations__ = hints
|
|
|
|
|
|
# 基于原始函数签名重建 wrapper 的 __signature__,替换 (*args, **kwargs)
|
|
|
|
|
|
orig_sig = inspect.signature(func)
|
|
|
|
|
|
new_params = []
|
|
|
|
|
|
for name, param in orig_sig.parameters.items():
|
|
|
|
|
|
annotation = hints.get(name, param.annotation)
|
|
|
|
|
|
new_params.append(param.replace(annotation=annotation))
|
|
|
|
|
|
wrapper.__signature__ = orig_sig.replace(
|
|
|
|
|
|
parameters=new_params,
|
|
|
|
|
|
return_annotation=hints.get("return", orig_sig.return_annotation),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# 解析失败时保持 functools.wraps 的默认行为
|
|
|
|
|
|
pass
|
2026-07-08 23:25:58 +08:00
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _resolve_db_state_tuple(供 MessageStreamer 使用的适配器)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _resolve_db_state_tuple() -> tuple[bool, Optional[str]]:
|
|
|
|
|
|
"""供 MessageStreamer 使用的 DB 状态解析适配器。
|
|
|
|
|
|
|
|
|
|
|
|
把 _resolve_db_state() 的 DbState 降维为 (db_accessible, db_error_code),
|
|
|
|
|
|
供 MessageStreamer 解耦 server.py 内部数据结构。
|
|
|
|
|
|
"""
|
|
|
|
|
|
state = await _resolve_db_state()
|
|
|
|
|
|
return (state.db_accessible, state.db_error_code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# DbState 与 DB 状态缓存
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class DbState:
|
|
|
|
|
|
"""DB 状态解析结果(由 _resolve_db_state 返回)。
|
|
|
|
|
|
|
|
|
|
|
|
统一三处调用方(get_status / _check_db_readable / diagnostic_run)的
|
|
|
|
|
|
DB 状态判断逻辑,避免重复代码与行为不一致。
|
|
|
|
|
|
"""
|
|
|
|
|
|
status: str # ok / not_found / need_init / key_invalid / unreadable
|
|
|
|
|
|
db_accessible: bool # 是否可读(ok 或 有有效 key)
|
|
|
|
|
|
db_error_code: Optional[str] # 不可读时的错误码
|
|
|
|
|
|
salt_hex: Optional[str] # 当前 DB 的 salt(加密 DB 才有)
|
|
|
|
|
|
key_hex: Optional[str] # 当前 DB 对应的有效 key(无则 None)
|
|
|
|
|
|
need_extract: bool # 无有效 key 且需要自动提取
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# DB 状态短时缓存:避免高频 /api/status 重复读 salt/验证 key
|
|
|
|
|
|
_db_state_cache: Optional[tuple[float, DbState]] = None
|
|
|
|
|
|
_DB_STATE_CACHE_TTL = 1.0 # 秒
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _invalidate_db_state_cache() -> None:
|
|
|
|
|
|
"""失效 DB 状态缓存(key 变化、提取完成时调用)。"""
|
|
|
|
|
|
global _db_state_cache
|
|
|
|
|
|
_db_state_cache = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _resolve_db_state
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _resolve_db_state() -> DbState:
|
|
|
|
|
|
"""统一解析 DB 状态、salt、有效 key、pid 失效检测、env/api key 验证。
|
|
|
|
|
|
|
|
|
|
|
|
本函数只做"状态解析",不触发自动提取;提取由调用方按 need_extract 决定。
|
|
|
|
|
|
单次请求内 salt/pid 只读一次,结果携带在 DbState 中供调用方复用。
|
|
|
|
|
|
带 1 秒短时缓存,减少高频轮询时的重复 I/O。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
DbState:含 status / db_accessible / db_error_code / salt_hex
|
|
|
|
|
|
/ key_hex / need_extract
|
|
|
|
|
|
"""
|
|
|
|
|
|
global _db_state_cache
|
|
|
|
|
|
|
|
|
|
|
|
# 检查缓存
|
|
|
|
|
|
if _db_state_cache is not None:
|
|
|
|
|
|
cached_at, cached_state = _db_state_cache
|
|
|
|
|
|
if time.monotonic() - cached_at < _DB_STATE_CACHE_TTL:
|
|
|
|
|
|
return cached_state
|
|
|
|
|
|
|
|
|
|
|
|
db_reader = _require_db_reader()
|
|
|
|
|
|
|
|
|
|
|
|
status = await asyncio.to_thread(db_reader.check_db_status)
|
|
|
|
|
|
|
|
|
|
|
|
# 明文 DB 或不可达:直接返回
|
|
|
|
|
|
if status == "ok":
|
|
|
|
|
|
result = DbState(
|
|
|
|
|
|
status=status, db_accessible=True, db_error_code=None,
|
|
|
|
|
|
salt_hex=None, key_hex=None, need_extract=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
_db_state_cache = (time.monotonic(), result)
|
|
|
|
|
|
return result
|
|
|
|
|
|
if status in ("not_found", "unreadable"):
|
|
|
|
|
|
result = DbState(
|
|
|
|
|
|
status=status, db_accessible=False, db_error_code=status,
|
|
|
|
|
|
salt_hex=None, key_hex=None, need_extract=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
_db_state_cache = (time.monotonic(), result)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
# status in ("need_init", "key_invalid"):加密 DB
|
|
|
|
|
|
salt_hex = await asyncio.to_thread(_read_db_salt, db_reader)
|
|
|
|
|
|
key = db_reader._get_effective_key(salt_hex) if salt_hex else db_reader._get_effective_key()
|
|
|
|
|
|
|
|
|
|
|
|
# auto_extract 的 key 在进程重启后失效
|
|
|
|
|
|
if key and _state.key_cache.cached and _state.key_cache.source == "auto_extract":
|
|
|
|
|
|
wechat_pid_info = await _get_wechat_pid()
|
|
|
|
|
|
current_pid = wechat_pid_info[0] if wechat_pid_info else None
|
|
|
|
|
|
current_start_time = wechat_pid_info[1] if wechat_pid_info else None
|
|
|
|
|
|
if not _state.key_cache.is_valid_for_pid(current_pid, current_start_time):
|
|
|
|
|
|
_state.key_cache.clear()
|
|
|
|
|
|
db_reader.invalidate_decrypted_cache()
|
|
|
|
|
|
key = None
|
|
|
|
|
|
|
|
|
|
|
|
# env/api 注入的 key 若未验证,快速验证 page1(4KB),避免无效 key 反复失败
|
|
|
|
|
|
if key and _state.key_cache.cached and _state.key_cache.source in ("env", "api") and not _state.key_cache.verified:
|
|
|
|
|
|
verified, _key_mode = await asyncio.to_thread(_verify_db_key, db_reader, key)
|
|
|
|
|
|
if verified:
|
|
|
|
|
|
_state.key_cache.set_meta(verified=True)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# env/api key 验证失败:仅清默认 key,保留持久化 salt_to_key
|
|
|
|
|
|
db_reader.clear_default_key()
|
|
|
|
|
|
key = None
|
|
|
|
|
|
|
|
|
|
|
|
# 有有效 key:DB 可读
|
|
|
|
|
|
if key:
|
|
|
|
|
|
result = DbState(
|
|
|
|
|
|
status=status, db_accessible=True, db_error_code="encrypted_key_ok",
|
|
|
|
|
|
salt_hex=salt_hex, key_hex=key, need_extract=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
_db_state_cache = (time.monotonic(), result)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
# 无有效 key
|
|
|
|
|
|
init_in_progress = _state.init_state.state == "running"
|
|
|
|
|
|
if init_in_progress:
|
|
|
|
|
|
db_error_code = "init_in_progress"
|
|
|
|
|
|
elif _state.config.auto_extract_enabled:
|
|
|
|
|
|
db_error_code = "need_init"
|
|
|
|
|
|
else:
|
|
|
|
|
|
db_error_code = "encrypted_no_key"
|
|
|
|
|
|
|
|
|
|
|
|
result = DbState(
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
db_accessible=False,
|
|
|
|
|
|
db_error_code=db_error_code,
|
|
|
|
|
|
salt_hex=salt_hex,
|
|
|
|
|
|
key_hex=None,
|
|
|
|
|
|
need_extract=_state.config.auto_extract_enabled and not init_in_progress,
|
|
|
|
|
|
)
|
|
|
|
|
|
_db_state_cache = (time.monotonic(), result)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _auto_extract_with_lock
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _auto_extract_with_lock(force: bool = False) -> Optional[dict[str, str]]:
|
|
|
|
|
|
"""带锁的自动密钥提取,结果同步到 init_state。
|
|
|
|
|
|
|
|
|
|
|
|
供 diagnostic_autofix 直接调用(阻塞等待结果);
|
|
|
|
|
|
_check_db_readable 通过 asyncio.create_task 在后台调用(非阻塞);
|
|
|
|
|
|
with_db_retry 装饰器在 DB_ENCRYPTED 时强制调用(force=True)。
|
|
|
|
|
|
串行化:通过 extract_lock 确保同一时间只有一个提取任务。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
force: True 时跳过 _resolve_db_state 探针检查,强制重新扫描内存。
|
|
|
|
|
|
用于 contact.db 探针通过但 message_0.db key 失效的场景
|
|
|
|
|
|
(探针误判 DB 可读,常规路径不会触发重新提取)。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
salt_hex -> enc_key_hex 映射;失败返回 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
async with _state.extract_lock():
|
|
|
|
|
|
# 双重检查:持锁后可能已被其他请求提取成功(force 模式跳过此检查)
|
|
|
|
|
|
if not force:
|
|
|
|
|
|
state = await _resolve_db_state()
|
|
|
|
|
|
if state.db_accessible:
|
|
|
|
|
|
return _state.db_reader.get_keys() if _state.db_reader else None
|
|
|
|
|
|
|
|
|
|
|
|
if _state.init_state.state == "running":
|
|
|
|
|
|
# 已在运行(不应该发生,lock 串行化),返回 None
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 标记运行中,阻止后续并发请求重复扫描
|
|
|
|
|
|
_state.init_state.state = "running"
|
|
|
|
|
|
_state.init_state.progress_pct = 0.0
|
|
|
|
|
|
_state.init_state.message = "正在扫描内存提取密钥"
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
extracted = await _try_auto_extract_key()
|
|
|
|
|
|
if extracted:
|
|
|
|
|
|
_state.init_state.set_success(
|
|
|
|
|
|
len(extracted),
|
|
|
|
|
|
f"成功提取 {len(extracted)} 个密钥",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
_state.init_state.set_failed("自动提取密钥失败")
|
|
|
|
|
|
return extracted
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
_state.init_state.set_failed(str(e))
|
|
|
|
|
|
return None
|
|
|
|
|
|
finally:
|
|
|
|
|
|
# 兜底:异常路径未设置 success/failed 时回退 idle
|
|
|
|
|
|
if _state.init_state.state == "running":
|
|
|
|
|
|
_state.init_state.state = "idle"
|
|
|
|
|
|
_state.init_state.progress_pct = None
|
|
|
|
|
|
_state.init_state.message = None
|
|
|
|
|
|
# 失效 DB 状态缓存,让下次查询看到新 key
|
|
|
|
|
|
_invalidate_db_state_cache()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _check_db_readable
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _check_db_readable() -> None:
|
|
|
|
|
|
"""检查 DB 是否可读,不可读时抛出对应错误码。
|
|
|
|
|
|
|
|
|
|
|
|
非阻塞设计(1.4.0+):
|
|
|
|
|
|
- not_found → DB_NOT_FOUND
|
|
|
|
|
|
- unreadable → DB_NOT_FOUND
|
|
|
|
|
|
- ok 或有有效 key → 不抛异常
|
|
|
|
|
|
- 无有效 key 且提取进行中 → 抛 DB_ENCRYPTED(提示稍后重试)
|
|
|
|
|
|
- 无有效 key 且需提取 → 后台触发提取(不等待),立即抛 DB_ENCRYPTED
|
|
|
|
|
|
- 无有效 key 且提取已失败 → 抛 DB_ENCRYPTED(提示注入 key)
|
|
|
|
|
|
- 自动提取未启用 → 抛 DB_ENCRYPTED(提示注入 key)
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
BridgeError(DB_NOT_FOUND / DB_ENCRYPTED)
|
|
|
|
|
|
"""
|
|
|
|
|
|
db_reader = _require_db_reader()
|
|
|
|
|
|
|
|
|
|
|
|
state = await _resolve_db_state()
|
|
|
|
|
|
|
|
|
|
|
|
if state.status == "not_found":
|
|
|
|
|
|
raise BridgeError(code="DB_NOT_FOUND", message="未在 /config 下找到微信消息 DB")
|
|
|
|
|
|
if state.status == "unreadable":
|
|
|
|
|
|
raise BridgeError(code="DB_NOT_FOUND", message="DB 文件存在但不可读(权限问题)")
|
|
|
|
|
|
if state.db_accessible:
|
|
|
|
|
|
# ok 或有有效 key:不抛异常
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# 无有效 key
|
|
|
|
|
|
init_state = _state.init_state.state
|
|
|
|
|
|
|
|
|
|
|
|
if init_state == "running":
|
|
|
|
|
|
pct = _state.init_state.progress_pct or 0
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_ENCRYPTED",
|
|
|
|
|
|
message=f"DB 已加密,密钥提取进行中({pct:.0f}%),请稍后重试",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not state.need_extract:
|
|
|
|
|
|
# 自动提取未启用,或已提取过但当前 salt 无匹配
|
|
|
|
|
|
if init_state == "failed":
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_ENCRYPTED",
|
|
|
|
|
|
message="DB 已加密(SQLCipher),自动提取密钥失败,需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY",
|
|
|
|
|
|
)
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_ENCRYPTED",
|
|
|
|
|
|
message="DB 已加密(SQLCipher),需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# need_extract=True:后台触发提取(非阻塞),立即返回 503
|
|
|
|
|
|
asyncio.create_task(_auto_extract_with_lock())
|
|
|
|
|
|
raise BridgeError(
|
|
|
|
|
|
code="DB_ENCRYPTED",
|
|
|
|
|
|
message="DB 已加密(SQLCipher),已触发后台密钥提取,请稍后重试",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _verify_db_key
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _verify_db_key(
|
|
|
|
|
|
db_reader: DbReader,
|
|
|
|
|
|
key_hex: str,
|
|
|
|
|
|
salt_hex: str | None = None,
|
|
|
|
|
|
) -> tuple[bool, Optional[str]]:
|
|
|
|
|
|
"""验证 key 是否匹配当前 DB 文件,并返回 key_mode。
|
|
|
|
|
|
|
|
|
|
|
|
用 db_reader._find_db_path 找到 DB 文件,调 Decryptor.verify_key 验证。
|
2026-07-16 01:19:47 +08:00
|
|
|
|
验证通过时一并返回 key_mode(enc_key / key_material),
|
2026-07-08 23:25:58 +08:00
|
|
|
|
避免调用方再读一次 page1。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
db_reader: DB 读取器
|
|
|
|
|
|
key_hex: 64 位十六进制密钥
|
|
|
|
|
|
salt_hex: 未使用(保留参数兼容旧调用),自动读取当前 DB salt
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(verified, key_mode): verified 为是否验证通过;
|
|
|
|
|
|
key_mode 为密钥形态(验证失败时为 None)
|
|
|
|
|
|
"""
|
|
|
|
|
|
db_path = db_reader._find_db_path()
|
|
|
|
|
|
if db_path is None:
|
|
|
|
|
|
return (False, None)
|
|
|
|
|
|
try:
|
|
|
|
|
|
decryptor = Decryptor(key_hex)
|
|
|
|
|
|
verified = decryptor.verify_key(db_path)
|
|
|
|
|
|
if not verified:
|
|
|
|
|
|
return (False, None)
|
|
|
|
|
|
# 验证通过,获取 key_mode(复用已读的 page1 逻辑)
|
|
|
|
|
|
key_mode = _get_key_mode(key_hex, db_path)
|
|
|
|
|
|
return (True, key_mode)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return (False, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _read_db_salt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _read_db_salt(db_reader: DbReader) -> str | None:
|
|
|
|
|
|
"""读取当前 DB 文件的 salt(同步阻塞)。"""
|
|
|
|
|
|
db_path = db_reader._find_db_path()
|
|
|
|
|
|
if db_path is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return db_reader._read_db_salt(db_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _get_wechat_pid
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _get_wechat_pid() -> tuple[int, float] | None:
|
|
|
|
|
|
"""获取微信主进程 PID 与启动时间。
|
|
|
|
|
|
|
|
|
|
|
|
使用与 key_extractor 一致的进程识别逻辑(检查 /proc/<pid>/comm 与 exe),
|
|
|
|
|
|
避免 `pgrep -f xwechat` 漏掉主进程(主进程命令行不含 xwechat)。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(pid, start_time) 或 None(进程未运行)
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
pids = await asyncio.to_thread(_get_wechat_pids)
|
|
|
|
|
|
if not pids:
|
|
|
|
|
|
return None
|
|
|
|
|
|
# 取内存占用最大的进程作为主进程
|
|
|
|
|
|
pid, _rss_kb = pids[0]
|
|
|
|
|
|
# 读 /proc/<pid>/stat 的第 22 字段(start_time,单位 jiffies)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(f"/proc/{pid}/stat", "r") as f:
|
|
|
|
|
|
stat_fields = f.read().split()
|
|
|
|
|
|
start_time = float(stat_fields[21]) if len(stat_fields) > 21 else 0.0
|
|
|
|
|
|
except (OSError, IndexError, ValueError):
|
|
|
|
|
|
start_time = 0.0
|
|
|
|
|
|
return (pid, start_time)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _try_auto_extract_key
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _try_auto_extract_key() -> dict[str, str] | None:
|
|
|
|
|
|
"""尝试从微信进程内存自动提取 SQLCipher 密钥。
|
|
|
|
|
|
|
|
|
|
|
|
流程:
|
|
|
|
|
|
1. 检查 auto_extract_enabled 开关
|
|
|
|
|
|
2. 检查微信进程是否运行(pgrep)
|
|
|
|
|
|
3. 获取全部微信 PID 候选(不只取第一个)
|
|
|
|
|
|
4. 找到加密 DB 目录作为探针
|
|
|
|
|
|
5. 对每个 PID 调 KeyExtractor.extract_all_keys() 提取 salt->enc_key 映射
|
|
|
|
|
|
6. 成功则缓存到 KeyCache(含 pid / start_time)并注入 DbReader
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
salt_hex -> enc_key_hex 映射字典;失败返回 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not _state.config.auto_extract_enabled:
|
|
|
|
|
|
return None
|
|
|
|
|
|
_require_db_reader()
|
|
|
|
|
|
_require_xdotool()
|
|
|
|
|
|
|
|
|
|
|
|
# 检查微信进程是否运行
|
|
|
|
|
|
wechat_running = await _state.xdotool.is_wechat_running()
|
|
|
|
|
|
if not wechat_running:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 找到加密 DB 目录作为探针
|
|
|
|
|
|
db_path = await asyncio.to_thread(_state.db_reader._find_db_path)
|
|
|
|
|
|
if db_path is None:
|
|
|
|
|
|
logger.warning("自动提取密钥失败:未找到 DB 路径")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 优先扫描整个 /config/xwechat_files,让 KeyExtractor 自己探测所有微信 PID
|
|
|
|
|
|
# 原 pgrep -f xwechat 会漏掉主进程(主进程命令行里没有 xwechat 字样)
|
|
|
|
|
|
db_dir = "/config/xwechat_files"
|
|
|
|
|
|
if not os.path.isdir(db_dir):
|
|
|
|
|
|
db_dir = os.path.dirname(db_path)
|
|
|
|
|
|
logger.info("自动提取密钥开始,探针目录: %s", db_dir)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# pid=0 让 KeyExtractor 用 /proc 自探测,比 pgrep 更可靠
|
|
|
|
|
|
extractor = KeyExtractor(0, db_path)
|
|
|
|
|
|
key_map = await asyncio.to_thread(extractor.extract_all_keys, db_dir)
|
|
|
|
|
|
if key_map:
|
|
|
|
|
|
# 记录成功时关联的 PID(取 key_map 中第一个地址反推太麻烦,
|
|
|
|
|
|
# 这里用 _get_wechat_pid 拿到主进程 PID 做缓存校验)
|
|
|
|
|
|
wechat_pid_info = await _get_wechat_pid()
|
|
|
|
|
|
wechat_pid = wechat_pid_info[0] if wechat_pid_info else 0
|
|
|
|
|
|
start_time = wechat_pid_info[1] if wechat_pid_info else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# KeyExtractor 内部已通过 page 1 HMAC 验证,无需再次全库验证
|
|
|
|
|
|
# 统一通过 db_reader.set_keys 转发到 KeyCache.set_key_map,
|
|
|
|
|
|
# 避免 key_cache 与 db_reader 两份重复同步
|
|
|
|
|
|
_state.db_reader.set_keys(
|
|
|
|
|
|
key_map,
|
|
|
|
|
|
source="auto_extract",
|
|
|
|
|
|
pid=wechat_pid,
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
)
|
|
|
|
|
|
_invalidate_db_state_cache()
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"自动提取 DB 密钥成功(主 PID=%s,%d salts),来源:微信进程内存扫描",
|
|
|
|
|
|
wechat_pid,
|
|
|
|
|
|
len(key_map),
|
|
|
|
|
|
)
|
|
|
|
|
|
return key_map
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("自动提取密钥异常: %s", e)
|
|
|
|
|
|
|
|
|
|
|
|
logger.warning("自动提取 DB 密钥失败:扫描全部微信进程后未找到有效密钥")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# _get_key_mode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_key_mode(key_hex: str, db_path: str) -> str | None:
|
2026-07-16 01:19:47 +08:00
|
|
|
|
"""获取 key 形态(enc_key / key_material)。
|
2026-07-08 23:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
通过读取 DB 第 1 页 + _resolve_page1_key_material 验证后返回 mode。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(db_path, "rb") as f:
|
|
|
|
|
|
page1 = f.read(4096)
|
|
|
|
|
|
key_bytes = bytes.fromhex(key_hex)
|
|
|
|
|
|
result = _resolve_page1_key_material(key_bytes, page1)
|
|
|
|
|
|
if result:
|
|
|
|
|
|
return result[2]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return None
|
2026-07-16 01:19:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# run_auto_extract_sync(供后台线程同步调用的密钥提取入口)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def run_auto_extract_sync(force: bool = True) -> Optional[dict[str, str]]:
|
|
|
|
|
|
"""供后台线程同步调用的密钥提取入口。
|
|
|
|
|
|
|
|
|
|
|
|
通过 asyncio.run_coroutine_threadsafe 在主事件循环中执行
|
|
|
|
|
|
_auto_extract_with_lock,确保与 asyncio 路径共享同一把 extract_lock,
|
|
|
|
|
|
避免并发 set_keys() 竞态。
|
|
|
|
|
|
|
|
|
|
|
|
供 routes/db.py 的 _init_task 调用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
loop = asyncio.get_event_loop()
|
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
|
# 无事件循环(纯线程环境),创建临时 loop
|
|
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
|
asyncio.set_event_loop(loop)
|
|
|
|
|
|
result = loop.run_until_complete(_auto_extract_with_lock(force=force))
|
|
|
|
|
|
loop.close()
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
if loop.is_running():
|
|
|
|
|
|
# 主事件循环运行中,用 run_coroutine_threadsafe 投递
|
|
|
|
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
|
|
|
|
_auto_extract_with_lock(force=force), loop
|
|
|
|
|
|
)
|
|
|
|
|
|
return future.result(timeout=120) # 提取最长 120s
|
|
|
|
|
|
else:
|
|
|
|
|
|
return loop.run_until_complete(_auto_extract_with_lock(force=force))
|