feat: 支持自动获取并安全保存数据库密钥

This commit is contained in:
电摇小子
2026-06-30 10:12:25 +08:00
parent c00d581d9e
commit 10dc74e2a1
7 changed files with 482 additions and 9 deletions
+63
View File
@@ -0,0 +1,63 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
export interface StoredKeyResult {
success: boolean
key?: string
error?: string
}
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
export const isValidDatabaseKey = (value: string): boolean =>
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
export class DatabaseKeyStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
}
async load(): Promise<StoredKeyResult> {
try {
if (!(await fs.pathExists(this.filePath))) return { success: true }
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
const encrypted = await fs.readFile(this.filePath)
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
if (!isValidDatabaseKey(key)) return { success: false, error: '已保存的密钥格式无效' }
return { success: true, key }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async save(rawKey: string): Promise<StoredKeyResult> {
const key = normalizeDatabaseKey(rawKey)
if (!isValidDatabaseKey(key)) {
return { success: false, error: '密钥必须是 64 位十六进制字符' }
}
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
try {
await fs.ensureDir(path.dirname(this.filePath))
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
await fs.chmod(this.filePath, 0o600)
return { success: true, key }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async clear(): Promise<{ success: boolean; error?: string }> {
try {
await fs.remove(this.filePath)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
+27
View File
@@ -13,11 +13,15 @@ import {
import { ImageDecryptService } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { DatabaseKeyStore } from './database-key-store'
import { KeyServiceMac } from './key-service-mac'
let wechatDb: WechatDb | null = null
let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore()
const keyServiceMac = new KeyServiceMac()
const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000'
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
@@ -119,6 +123,29 @@ app.whenReady().then(() => {
}
})
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(clipboardKey)
})
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
ipcMain.handle('key:autoGetDbKey', async (event) => {
const result = await keyServiceMac.autoGetDbKey((message) => {
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
})
if (!result.success || !result.key) return result
const saved = await databaseKeyStore.save(result.key)
return {
...result,
saved: saved.success,
warning: saved.success ? undefined : saved.error
}
})
ipcMain.handle('db:getContacts', (_, filter?: string) => {
if (!wechatDb) return []
+160
View File
@@ -0,0 +1,160 @@
import { app } from 'electron'
import { execFile } from 'child_process'
import fs from 'fs-extra'
import path from 'path'
import { promisify } from 'util'
import { isValidDatabaseKey } from './database-key-store'
const execFileAsync = promisify(execFile)
export interface DatabaseKeyResult {
success: boolean
key?: string
error?: string
code?: string
}
export class KeyServiceMac {
private getHelperPath(): string {
const candidates = app.isPackaged
? [
path.join(process.resourcesPath, 'resources', 'xkey_helper'),
path.join(process.resourcesPath, 'xkey_helper')
]
: [
path.join(process.cwd(), 'resources', 'xkey_helper'),
path.join(app.getAppPath(), 'resources', 'xkey_helper')
]
const helperPath = candidates.find((candidate) => fs.existsSync(candidate))
if (!helperPath) throw new Error('找不到 xkey_helper')
return helperPath
}
private async isSipEnabled(): Promise<boolean> {
try {
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
return stdout.toLowerCase().includes('enabled')
} catch {
return false
}
}
private async getWeChatPid(): Promise<number> {
const commands: [string, string[]][] = [
['/usr/bin/pgrep', ['-x', 'WeChat']],
['/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']]
]
for (const [command, args] of commands) {
try {
const { stdout } = await execFileAsync(command, args)
const pids = stdout
.split(/\r?\n/)
.map((value) => Number.parseInt(value.trim(), 10))
.filter((value) => Number.isFinite(value) && value > 0)
if (pids.length) return Math.max(...pids)
} catch {
// Try the next process lookup strategy.
}
}
throw new Error('未找到微信主进程,请先启动并登录微信')
}
private parseHelperOutput(output: string): DatabaseKeyResult {
const payloads: Record<string, unknown>[] = []
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
try {
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
} catch {
// Ignore helper progress that is not JSON.
}
}
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
if (!isValidDatabaseKey(rawKey)) {
const errorPayload = payloads.find((item) => typeof item.result === 'string')
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
const code = parsedError?.[1]?.toUpperCase()
const detail = parsedError?.[2]?.trim() || ''
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
return {
success: false,
code,
error:
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
'建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
}
}
if (code === 'SCAN_FAILED') {
return {
success: false,
code,
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
}
}
return {
success: false,
code,
error: rawError || '密钥工具未返回有效的 64 位密钥'
}
}
return { success: true, key: rawKey }
}
async autoGetDbKey(
onStatus?: (message: string) => void,
timeoutMs = 60_000
): Promise<DatabaseKeyResult> {
if (process.platform !== 'darwin') {
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
}
if (await this.isSipEnabled()) {
return {
success: false,
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
}
}
try {
onStatus?.('正在查找微信进程...')
const pid = await this.getWeChatPid()
const helperPath = this.getHelperPath()
const waitMs = Math.max(30_000, timeoutMs)
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
onStatus?.('正在请求管理员授权...')
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
`set timeoutSec to ${timeoutSeconds}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'end timeout',
'return "OK::" & outText',
'on error errMsg number errNum',
'return "ERR::" & errNum & "::" & errMsg',
'end try'
]
onStatus?.('授权后请保持微信已登录并活动...')
const { stdout } = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap((line) => ['-e', line]),
{ timeout: waitMs + 20_000 }
)
const output = String(stdout).trim()
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
if (output.startsWith('ERR::')) {
return {
success: false,
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
}
}
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
+12
View File
@@ -61,6 +61,18 @@ declare global {
) => Promise<{ success: boolean; data?: string; error?: string }>
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
autoGetDbKey: () => Promise<{
success: boolean
key?: string
error?: string
code?: string
saved?: boolean
warning?: string
}>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
}
}
}
+11 -1
View File
@@ -23,7 +23,17 @@ const api = {
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath)
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
onDbKeyStatus: (callback: (payload: { message: string }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
callback(payload)
ipcRenderer.on('key:dbKeyStatus', listener)
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
}
}
if (process.contextIsolated) {
+120 -7
View File
@@ -3,6 +3,8 @@ import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow'
import { Contact, Message } from '../../shared/types'
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
@@ -12,6 +14,32 @@ function App(): React.ReactElement {
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [dateRange, setDateRange] = useState('today') // 默认为今天
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('')
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
const [showDbKey, setShowDbKey] = useState(false)
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
React.useEffect(() => {
let active = true
void window.api.getSavedDbKey().then((result) => {
if (!active) return
if (result.success && result.key) {
setDbKey(result.key)
setDbKeyStatus('已加载安全保存的密钥')
setDbKeyStatusKind('success')
}
})
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
if (!active) return
setDbKeyStatus(message)
setDbKeyStatusKind('normal')
})
return () => {
active = false
unsubscribe()
}
}, [])
// useEffect(() => {
// if (import.meta.env.VITE_DB_KEY) {
@@ -38,6 +66,55 @@ function App(): React.ReactElement {
}
}
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
setDbKeyStatus('正在准备获取密钥...')
setDbKeyStatusKind('normal')
setShowMacKeyFaq(false)
try {
const result = await window.api.autoGetDbKey()
if (!result.success || !result.key) {
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
throw new Error(result.error || '获取密钥失败')
}
setDbKey(result.key)
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
} catch (error) {
setDbKeyStatus(error instanceof Error ? error.message : String(error))
setDbKeyStatusKind('error')
} finally {
setIsFetchingDbKey(false)
}
}
const handlePasteAndSaveDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.pasteAndSaveDbKey()
if (result.success && result.key) {
setDbKey(result.key)
setDbKeyStatus('已从剪贴板粘贴并安全保存')
setDbKeyStatusKind('success')
} else {
setDbKeyStatus(result.error || '粘贴并保存失败')
setDbKeyStatusKind('error')
}
}
const handleClearSavedDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.clearSavedDbKey()
if (!result.success) {
setDbKeyStatus(result.error || '清除密钥失败')
setDbKeyStatusKind('error')
return
}
setDbKey('')
setDbKeyStatus('已清除保存的密钥')
setDbKeyStatusKind('normal')
}
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
@@ -146,16 +223,52 @@ function App(): React.ReactElement {
<div className="login-modal">
<div className="login-box">
<h2>Enter WeChat DB Key</h2>
<input
type="password"
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
/>
<div className="login-input-wrapper">
<input
type={showDbKey ? 'text' : 'password'}
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
/>
<button
type="button"
className="login-input-toggle"
onClick={() => setShowDbKey(!showDbKey)}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
{showDbKey ? '👁️' : '👁️‍🗨️'}
</button>
</div>
<button
className="login-btn login-btn-secondary"
onClick={handleAutoGetDbKey}
disabled={isFetchingDbKey}
>
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
</button>
<button className="login-btn login-btn-secondary" onClick={handlePasteAndSaveDbKey}>
</button>
<button className="login-btn" onClick={() => handleLogin()}>
Connect
</button>
<button className="login-clear-btn" onClick={handleClearSavedDbKey}>
</button>
{dbKeyStatus && (
<div className={`login-key-status ${dbKeyStatusKind}`}>{dbKeyStatus}</div>
)}
{showMacKeyFaq && (
<a
className="login-key-help-link"
href={MAC_KEY_FAQ_URL}
target="_blank"
rel="noreferrer"
>
macOS
</a>
)}
</div>
</div>
)
+89 -1
View File
@@ -654,7 +654,7 @@ body {
background-color: #fff;
padding: 20px;
border-radius: 8px;
width: 300px;
width: 360px;
text-align: center;
}
@@ -666,6 +666,27 @@ body {
border-radius: 4px;
}
.login-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.login-input-wrapper .login-input {
padding-right: 40px;
margin: 10px 0 10px 0;
}
.login-input-toggle {
position: absolute;
right: 8px;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 18px;
}
.login-btn {
background-color: #07c160;
color: #fff;
@@ -673,12 +694,79 @@ body {
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
width: 100%;
margin-top: 8px;
}
.login-btn:hover {
background-color: #06ad56;
}
.login-btn:disabled {
cursor: wait;
opacity: 0.65;
}
.login-btn-secondary {
border: 1px solid #cfd5d8;
background: #fff;
color: #30383d;
}
.login-btn-secondary:hover {
border-color: #07c160;
background: #f2fbf6;
color: #078f49;
}
.login-clear-btn {
margin-top: 10px;
border: 0;
background: transparent;
color: #8a949a;
cursor: pointer;
font-size: 12px;
}
.login-clear-btn:hover {
color: #d33b3b;
}
.login-key-status {
margin-top: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #f3f5f6;
color: #59636a;
font-size: 12px;
line-height: 1.45;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
.login-key-status.success {
background: #eefaf3;
color: #078f49;
}
.login-key-status.error {
background: #fff1f0;
color: #c73737;
}
.login-key-help-link {
display: inline-block;
margin-top: 9px;
color: #1677ff;
font-size: 12px;
text-decoration: none;
}
.login-key-help-link:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
top: 0;