mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-02 22:10:50 +08:00
feat(keys): 自动保存密钥并支持前端回填
- 新增 output/account_keys.json 账号密钥存储(db_key / image_xor_key / image_aes_key) - 新增 /api/keys 查询已保存密钥;缺失时兜底从账号目录 _media_keys.json 读取图片密钥 - 数据库解密成功后按账号写入 db_key;保存图片密钥时同步写入 store(失败静默不影响主流程) - 前端解密页进入图片密钥步骤自动回填;进入下一步/跳过时自动保存一次
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -124,21 +124,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#000000e6]">图片密钥</h2>
|
||||
<p class="text-sm text-[#7F7F7F]">请使用 wx_key 获取后在此填写</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 获取密钥说明 -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-800">
|
||||
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="underline">wx_key</a> 获取密钥;AES 可选(V4-V2 需要)。
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-[#7F7F7F]">填写后会自动保存并下次回填</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,25 +154,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="applyManualKeys({ save: true })"
|
||||
:disabled="manualSaving"
|
||||
class="inline-flex items-center px-4 py-2 bg-[#10AEEF] text-white rounded-lg font-medium hover:bg-[#0D9BD9] transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
{{ manualSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearManualKeys"
|
||||
class="inline-flex items-center px-4 py-2 bg-white text-[#7F7F7F] border border-[#EDEDED] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="mediaKeys.message" class="text-xs text-[#7F7F7F] mt-3">{{ mediaKeys.message }}</p>
|
||||
<p class="mt-3 text-xs text-[#7F7F7F] flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 获取图片密钥;AES 可选(V4-V2 需要)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
69
src/wechat_decrypt_tool/key_store.py
Normal file
69
src/wechat_decrypt_tool/key_store.py
Normal file
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
53
src/wechat_decrypt_tool/routers/keys.py
Normal file
53
src/wechat_decrypt_tool/routers/keys.py
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user