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:
2977094657
2026-01-01 16:28:33 +08:00
parent 67358deeef
commit c1712ba6dd
7 changed files with 233 additions and 75 deletions

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)

View 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

View File

@@ -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"],

View 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,
}

View File

@@ -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",