diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index a7d7967..97e739f 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -185,6 +185,14 @@ export const useApi = () => { }) } + // 获取已保存的密钥(数据库密钥 + 图片密钥) + const getSavedKeys = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + const url = '/keys' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + // 批量解密所有图片 const decryptAllMedia = async (params = {}) => { return await request('/media/decrypt_all', { @@ -250,6 +258,7 @@ export const useApi = () => { openChatMediaFolder, downloadChatEmoji, saveMediaKeys, + getSavedKeys, decryptAllMedia, createChatExport, getChatExport, diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index aaa1d34..fb76aec 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -124,21 +124,7 @@
请使用 wx_key 获取后在此填写
-填写后会自动保存并下次回填
{{ mediaKeys.message }}
++ + 使用 wx_key 获取图片密钥;AES 可选(V4-V2 需要) +
@@ -394,7 +367,7 @@ import { ref, reactive, computed, onMounted } from 'vue' import { useApi } from '~/composables/useApi' -const { decryptDatabase, saveMediaKeys } = useApi() +const { decryptDatabase, saveMediaKeys, getSavedKeys } = useApi() const loading = ref(false) const error = ref('') @@ -423,8 +396,7 @@ const formErrors = reactive({ // 图片密钥相关 const mediaKeys = reactive({ xor_key: '', - aes_key: '', - message: '' + aes_key: '' }) // 手动输入密钥(从 wx_key 获取) @@ -436,7 +408,6 @@ const manualKeyErrors = reactive({ xor_key: '', aes_key: '' }) -const manualSaving = ref(false) const normalizeXorKey = (value) => { const raw = String(value || '').trim() @@ -455,7 +426,34 @@ const normalizeAesKey = (value) => { return { ok: true, value: raw.slice(0, 16), message: '' } } -const applyManualKeys = async (options = { save: false }) => { +const prefillKeysForAccount = async (account) => { + const acc = String(account || '').trim() + if (!acc) return + try { + const resp = await getSavedKeys({ account: acc }) + if (!resp || resp.status !== 'success') return + const keys = resp.keys || {} + + const dbKey = String(keys.db_key || '').trim() + if (dbKey && !String(formData.key || '').trim()) { + formData.key = dbKey + } + + const xorKey = String(keys.image_xor_key || '').trim() + if (xorKey && !String(manualKeys.xor_key || '').trim()) { + manualKeys.xor_key = xorKey + } + + const aesKey = String(keys.image_aes_key || '').trim() + if (aesKey && !String(manualKeys.aes_key || '').trim()) { + manualKeys.aes_key = aesKey + } + } catch (e) { + // ignore + } +} + +const applyManualKeys = () => { manualKeyErrors.xor_key = '' manualKeyErrors.aes_key = '' error.value = '' @@ -463,39 +461,24 @@ const applyManualKeys = async (options = { save: false }) => { const aes = normalizeAesKey(manualKeys.aes_key) if (!aes.ok) { manualKeyErrors.aes_key = aes.message - return + return false } - const hasXor = !!String(manualKeys.xor_key || '').trim() - if (options?.save || hasXor) { - const xor = normalizeXorKey(manualKeys.xor_key) - if (!xor.ok) { - manualKeyErrors.xor_key = xor.message - return - } - mediaKeys.xor_key = xor.value + mediaKeys.aes_key = aes.value + + const rawXor = String(manualKeys.xor_key || '').trim() + if (!rawXor) { + mediaKeys.xor_key = '' + return true } - if (aes.value) { - mediaKeys.aes_key = aes.value - } - - mediaKeys.message = options?.save ? '已保存' : '已应用' - - if (!options?.save) return - - try { - manualSaving.value = true - await saveMediaKeys({ - account: mediaAccount.value || null, - xor_key: mediaKeys.xor_key, - aes_key: aes.value || null - }) - } catch (e) { - mediaKeys.message = '保存失败(可继续解密)' - } finally { - manualSaving.value = false + const xor = normalizeXorKey(rawXor) + if (!xor.ok) { + manualKeyErrors.xor_key = xor.message + return false } + mediaKeys.xor_key = xor.value + return true } const clearManualKeys = () => { @@ -505,7 +488,6 @@ const clearManualKeys = () => { manualKeyErrors.aes_key = '' mediaKeys.xor_key = '' mediaKeys.aes_key = '' - mediaKeys.message = '' } // 图片解密相关 @@ -592,7 +574,7 @@ const handleDecrypt = async () => { // 进入图片密钥填写步骤 clearManualKeys() currentStep.value = 1 - mediaKeys.message = '' + await prefillKeysForAccount(mediaAccount.value) } else if (result.status === 'failed') { if (result.failure_count > 0 && result.success_count === 0) { error.value = result.message || '所有文件解密失败' @@ -689,21 +671,46 @@ const decryptAllImages = async () => { // 从密钥步骤进入图片解密步骤 const goToMediaDecryptStep = async () => { error.value = '' - // 用户填写了任一项时,尝试校验并应用(未填写则允许直接进入,后端会使用已保存密钥或报错提示) - if (manualKeys.xor_key || manualKeys.aes_key) { - await applyManualKeys({ save: false }) - if (manualKeyErrors.xor_key || manualKeyErrors.aes_key) return + // 校验并应用(未填写则允许直接进入,后端会使用已保存密钥或报错提示) + const ok = applyManualKeys() + if (!ok || manualKeyErrors.xor_key || manualKeyErrors.aes_key) return + + // 用户已输入 XOR 时,自动保存一次,避免下次重复输入(失败不影响继续) + if (mediaKeys.xor_key) { + try { + const aesVal = String(mediaKeys.aes_key || '').trim() + await saveMediaKeys({ + account: mediaAccount.value || null, + xor_key: mediaKeys.xor_key, + aes_key: aesVal ? aesVal : null + }) + } catch (e) { + // ignore + } } currentStep.value = 2 } // 跳过图片解密,直接查看聊天记录 -const skipToChat = () => { +const skipToChat = async () => { + try { + const ok = applyManualKeys() + if (ok && mediaKeys.xor_key) { + const aesVal = String(mediaKeys.aes_key || '').trim() + await saveMediaKeys({ + account: mediaAccount.value || null, + xor_key: mediaKeys.xor_key, + aes_key: aesVal ? aesVal : null + }) + } + } catch (e) { + // ignore + } navigateTo('/chat') } // 页面加载时检查是否有选中的账户 -onMounted(() => { +onMounted(async () => { if (process.client && typeof window !== 'undefined') { const selectedAccount = sessionStorage.getItem('selectedAccount') if (selectedAccount) { @@ -718,6 +725,7 @@ onMounted(() => { } // 清除sessionStorage sessionStorage.removeItem('selectedAccount') + await prefillKeysForAccount(mediaAccount.value) } catch (e) { console.error('解析账户信息失败:', e) } diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index 6b3da6f..3348df2 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -10,6 +10,7 @@ from .routers.chat_export import router as _chat_export_router from .routers.chat_media import router as _chat_media_router from .routers.decrypt import router as _decrypt_router from .routers.health import router as _health_router +from .routers.keys import router as _keys_router from .routers.media import router as _media_router from .routers.wechat_detection import router as _wechat_detection_router @@ -38,6 +39,7 @@ app.add_middleware( app.include_router(_health_router) app.include_router(_wechat_detection_router) app.include_router(_decrypt_router) +app.include_router(_keys_router) app.include_router(_media_router) app.include_router(_chat_router) app.include_router(_chat_export_router) diff --git a/src/wechat_decrypt_tool/key_store.py b/src/wechat_decrypt_tool/key_store.py new file mode 100644 index 0000000..f06addd --- /dev/null +++ b/src/wechat_decrypt_tool/key_store.py @@ -0,0 +1,69 @@ +import datetime +import json +from pathlib import Path +from typing import Any, Optional + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_KEY_STORE_PATH = _REPO_ROOT / "output" / "account_keys.json" + + +def _atomic_write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp.replace(path) + + +def load_account_keys_store() -> dict[str, Any]: + if not _KEY_STORE_PATH.exists(): + return {} + try: + data = json.loads(_KEY_STORE_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def get_account_keys_from_store(account: str) -> dict[str, Any]: + store = load_account_keys_store() + v = store.get(account, {}) + return v if isinstance(v, dict) else {} + + +def upsert_account_keys_in_store( + account: str, + *, + db_key: Optional[str] = None, + image_xor_key: Optional[str] = None, + image_aes_key: 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 = {} + + if db_key is not None: + item["db_key"] = str(db_key) + 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 + + try: + _atomic_write_json(_KEY_STORE_PATH, store) + except Exception: + # 不影响主流程:写入失败时静默忽略 + pass + + return item + diff --git a/src/wechat_decrypt_tool/routers/decrypt.py b/src/wechat_decrypt_tool/routers/decrypt.py index 376f30f..077f346 100644 --- a/src/wechat_decrypt_tool/routers/decrypt.py +++ b/src/wechat_decrypt_tool/routers/decrypt.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from ..logging_config import get_logger from ..path_fix import PathFixRoute +from ..key_store import upsert_account_keys_in_store from ..wechat_decrypt import decrypt_wechat_databases logger = get_logger(__name__) @@ -49,6 +50,13 @@ async def decrypt_databases(request: DecryptRequest): logger.info(f"解密完成: 成功 {results['successful_count']}/{results['total_databases']} 个数据库") + # 成功解密后,按账号保存数据库密钥(用于前端自动回填) + try: + for account_name in (results.get("account_results") or {}).keys(): + upsert_account_keys_in_store(str(account_name), db_key=request.key) + except Exception: + pass + return { "status": "completed" if results["status"] == "success" else "failed", "total_databases": results["total_databases"], diff --git a/src/wechat_decrypt_tool/routers/keys.py b/src/wechat_decrypt_tool/routers/keys.py new file mode 100644 index 0000000..20344e4 --- /dev/null +++ b/src/wechat_decrypt_tool/routers/keys.py @@ -0,0 +1,53 @@ +from typing import Optional + +from fastapi import APIRouter + +from ..key_store import get_account_keys_from_store +from ..media_helpers import _load_media_keys, _resolve_account_dir +from ..path_fix import PathFixRoute + +router = APIRouter(route_class=PathFixRoute) + + +@router.get("/api/keys", summary="获取账号已保存的密钥") +async def get_saved_keys(account: Optional[str] = None): + """获取账号的数据库密钥与图片密钥(用于前端自动回填)""" + account_name: Optional[str] = None + account_dir = None + + try: + account_dir = _resolve_account_dir(account) + account_name = account_dir.name + except Exception: + # 账号可能尚未解密;仍允许从全局 store 读取(如果传入了 account) + account_name = str(account or "").strip() or None + + keys: dict = {} + if account_name: + keys = get_account_keys_from_store(account_name) + + # 兼容:如果 store 里没有图片密钥,尝试从账号目录的 _media_keys.json 读取 + if account_dir and isinstance(keys, dict): + try: + media = _load_media_keys(account_dir) + if keys.get("image_xor_key") in (None, "") and media.get("xor") is not None: + keys["image_xor_key"] = f"0x{int(media['xor']):02X}" + if keys.get("image_aes_key") in (None, "") and str(media.get("aes") or "").strip(): + keys["image_aes_key"] = str(media.get("aes") or "").strip() + except Exception: + pass + + # 仅返回需要的字段 + result = { + "db_key": str(keys.get("db_key") or "").strip(), + "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(), + } + + return { + "status": "success", + "account": account_name, + "keys": result, + } + diff --git a/src/wechat_decrypt_tool/routers/media.py b/src/wechat_decrypt_tool/routers/media.py index 1359dce..6fb9350 100644 --- a/src/wechat_decrypt_tool/routers/media.py +++ b/src/wechat_decrypt_tool/routers/media.py @@ -20,6 +20,7 @@ from ..media_helpers import ( _try_find_decrypted_resource, ) from ..path_fix import PathFixRoute +from ..key_store import upsert_account_keys_in_store logger = get_logger(__name__) @@ -67,6 +68,14 @@ async def save_media_keys_api(request: MediaKeysSaveRequest): # 保存密钥 aes_key16 = aes_str[:16].encode("ascii", errors="ignore") if aes_str else None _save_media_keys(account_dir, xor_int, aes_key16) + try: + upsert_account_keys_in_store( + account_dir.name, + image_xor_key=f"0x{xor_int:02X}", + image_aes_key=aes_str[:16] if aes_str else "", + ) + except Exception: + pass return { "status": "success",