diff --git a/electron/main.ts b/electron/main.ts index d29bf64..cb6dea8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1034,6 +1034,11 @@ function registerIpcHandlers() { return windowsHelloService.verify(message, targetWin) }) + // 验证应用锁状态(带签名校验,防篡改) + ipcMain.handle('auth:verifyEnabled', async () => { + return configService?.verifyAuthEnabled() ?? false + }) + // 导出相关 ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => { return exportService.getExportStats(sessionIds, options) diff --git a/electron/preload.ts b/electron/preload.ts index 674ee21..5a11899 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -24,7 +24,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 认证 auth: { - hello: (message?: string) => ipcRenderer.invoke('auth:hello', message) + hello: (message?: string) => ipcRenderer.invoke('auth:hello', message), + verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled') }, diff --git a/electron/services/config.ts b/electron/services/config.ts index d9eda16..beefeb7 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -1,7 +1,10 @@ import { join } from 'path' -import { app } from 'electron' +import { app, safeStorage } from 'electron' import Store from 'electron-store' +// safeStorage 加密后的前缀标记,用于区分明文和密文 +const SAFE_PREFIX = 'safe:' + interface ConfigSchema { // 数据库相关 dbPath: string // 数据库根目录 (xwechat_files) @@ -32,7 +35,7 @@ interface ConfigSchema { exportDefaultConcurrency: number analyticsExcludedUsernames: string[] - // 安全相关 + // 安全相关(通过 safeStorage 加密存储,JSON 中为密文) authEnabled: boolean authPassword: string // SHA-256 hash authUseHello: boolean @@ -48,6 +51,11 @@ interface ConfigSchema { wordCloudExcludeWords: string[] } +// 需要 safeStorage 加密的字段集合 +const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword']) +const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) +const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) + export class ConfigService { private static instance: ConfigService private store!: Store @@ -103,16 +111,221 @@ export class ConfigService { wordCloudExcludeWords: [] } }) + + // 首次启动时迁移旧版明文安全字段 + this.migrateAuthFields() } get(key: K): ConfigSchema[K] { - return this.store.get(key) + const raw = this.store.get(key) + + // 布尔型加密字段:存储为加密字符串,读取时解密还原为布尔值 + if (ENCRYPTED_BOOL_KEYS.has(key)) { + const str = typeof raw === 'string' ? raw : '' + if (!str || !str.startsWith(SAFE_PREFIX)) return raw + const decrypted = this.safeDecrypt(str) + return (decrypted === 'true') as ConfigSchema[K] + } + + // 数字型加密字段:存储为加密字符串,读取时解密还原为数字 + if (ENCRYPTED_NUMBER_KEYS.has(key)) { + const str = typeof raw === 'string' ? raw : '' + if (!str || !str.startsWith(SAFE_PREFIX)) return raw + const decrypted = this.safeDecrypt(str) + const num = Number(decrypted) + return (Number.isFinite(num) ? num : 0) as ConfigSchema[K] + } + + // 字符串型加密字段 + if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') { + return this.safeDecrypt(raw) as ConfigSchema[K] + } + + // wxidConfigs 中嵌套的敏感字段 + if (key === 'wxidConfigs' && raw && typeof raw === 'object') { + return this.decryptWxidConfigs(raw as any) as ConfigSchema[K] + } + + return raw } set(key: K, value: ConfigSchema[K]): void { - this.store.set(key, value) + let toStore = value + + // 布尔型加密字段:序列化为字符串后加密 + if (ENCRYPTED_BOOL_KEYS.has(key)) { + toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + } + // 数字型加密字段:序列化为字符串后加密 + else if (ENCRYPTED_NUMBER_KEYS.has(key)) { + toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + } + // 字符串型加密字段 + else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') { + toStore = this.safeEncrypt(value) as ConfigSchema[K] + } + // wxidConfigs 中嵌套的敏感字段 + else if (key === 'wxidConfigs' && value && typeof value === 'object') { + toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K] + } + + this.store.set(key, toStore) } + // === safeStorage 加解密 === + + private safeEncrypt(plaintext: string): string { + if (!plaintext) return '' + if (plaintext.startsWith(SAFE_PREFIX)) return plaintext + if (!safeStorage.isEncryptionAvailable()) return plaintext + const encrypted = safeStorage.encryptString(plaintext) + return SAFE_PREFIX + encrypted.toString('base64') + } + + private safeDecrypt(stored: string): string { + if (!stored) return '' + if (!stored.startsWith(SAFE_PREFIX)) { + return stored + } + if (!safeStorage.isEncryptionAvailable()) return '' + try { + const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') + return safeStorage.decryptString(buf) + } catch { + return '' + } + } + + // === 旧版本迁移 === + + // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 + private migrateAuthFields(): void { + if (!safeStorage.isEncryptionAvailable()) return + + // 迁移字符串型字段(decryptKey, imageAesKey, authPassword) + for (const key of ENCRYPTED_STRING_KEYS) { + const raw = this.store.get(key as keyof ConfigSchema) + if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX)) { + this.store.set(key as any, this.safeEncrypt(raw)) + } + } + + // 迁移布尔型字段(authEnabled, authUseHello) + for (const key of ENCRYPTED_BOOL_KEYS) { + const raw = this.store.get(key as keyof ConfigSchema) + // 如果是原始布尔值(未加密),转为加密字符串 + if (typeof raw === 'boolean') { + this.store.set(key as any, this.safeEncrypt(String(raw))) + } + } + + // 迁移数字型字段(imageXorKey) + for (const key of ENCRYPTED_NUMBER_KEYS) { + const raw = this.store.get(key as keyof ConfigSchema) + // 如果是原始数字值(未加密),转为加密字符串 + if (typeof raw === 'number') { + this.store.set(key as any, this.safeEncrypt(String(raw))) + } + } + + // 迁移 wxidConfigs 中的嵌套敏感字段 + const wxidConfigs = this.store.get('wxidConfigs') + if (wxidConfigs && typeof wxidConfigs === 'object') { + let needsUpdate = false + const updated = { ...wxidConfigs } + for (const [wxid, cfg] of Object.entries(updated)) { + if (cfg.decryptKey && !cfg.decryptKey.startsWith(SAFE_PREFIX)) { + updated[wxid] = { ...cfg, decryptKey: this.safeEncrypt(cfg.decryptKey) } + needsUpdate = true + } + if (cfg.imageAesKey && !cfg.imageAesKey.startsWith(SAFE_PREFIX)) { + updated[wxid] = { ...updated[wxid], imageAesKey: this.safeEncrypt(cfg.imageAesKey) } + needsUpdate = true + } + if (cfg.imageXorKey !== undefined && typeof cfg.imageXorKey === 'number') { + updated[wxid] = { ...updated[wxid], imageXorKey: this.safeEncrypt(String(cfg.imageXorKey)) as any } + needsUpdate = true + } + } + if (needsUpdate) { + this.store.set('wxidConfigs', updated) + } + } + + // 清理旧版 authSignature 字段(不再需要) + this.store.delete('authSignature' as any) + } + + // === wxidConfigs 加解密 === + + private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs)) { + result[wxid] = { ...cfg } + if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey) + if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey) + if (cfg.imageXorKey !== undefined) { + (result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey)) + } + } + return result + } + + private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs)) { + result[wxid] = { ...cfg } + if (cfg.decryptKey) result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey) + if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey) + if (cfg.imageXorKey !== undefined) { + const raw = cfg.imageXorKey as any + if (typeof raw === 'string' && raw.startsWith(SAFE_PREFIX)) { + const decrypted = this.safeDecrypt(raw) + const num = Number(decrypted) + result[wxid].imageXorKey = Number.isFinite(num) ? num : 0 + } + } + } + return result + } + + // === 应用锁验证 === + + // 验证应用锁状态,防篡改: + // - 所有 auth 字段都是 safeStorage 密文,删除/修改密文 → 解密失败 + // - 解密失败时,检查 authPassword 密文是否曾经存在(非空非默认值) + // 如果存在则说明被篡改,强制锁定 + verifyAuthEnabled(): boolean { + // 用 as any 绕过泛型推断,因为加密后实际存储的是字符串而非 boolean + const rawEnabled: any = this.store.get('authEnabled') + const rawPassword: any = this.store.get('authPassword') + + // 情况1:字段是加密密文,正常解密 + if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) { + const enabled = this.safeDecrypt(rawEnabled) === 'true' + const password = typeof rawPassword === 'string' ? this.safeDecrypt(rawPassword) : '' + + if (!enabled && !password) return false + return enabled + } + + // 情况2:字段是原始布尔值(旧版本,尚未迁移) + if (typeof rawEnabled === 'boolean') { + return rawEnabled + } + + // 情况3:字段被删除(electron-store 返回默认值 false)或被篡改为无法解密的值 + // 检查 authPassword 是否有密文残留(说明之前设置过密码) + if (typeof rawPassword === 'string' && rawPassword.startsWith(SAFE_PREFIX)) { + // 密码密文还在,说明之前启用过应用锁,字段被篡改了 → 强制锁定 + return true + } + + return false + } + + // === 其他 === + getCacheBasePath(): string { const configured = this.get('cachePath') if (configured && configured.trim().length > 0) { diff --git a/src/App.tsx b/src/App.tsx index 1473e18..06db6fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -312,7 +312,7 @@ function App() { const checkLock = async () => { // 并行获取配置,减少等待 const [enabled, useHello] = await Promise.all([ - configService.getAuthEnabled(), + window.electronAPI.auth.verifyEnabled(), configService.getAuthUseHello() ]) diff --git a/src/components/LockScreen.tsx b/src/components/LockScreen.tsx index 94d5701..6cb8b55 100644 --- a/src/components/LockScreen.tsx +++ b/src/components/LockScreen.tsx @@ -105,9 +105,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS try { const storedHash = await configService.getAuthPassword() + + // 兜底:如果没有设置过密码,直接放行并关闭应用锁 + if (!storedHash) { + await configService.setAuthEnabled(false) + handleUnlock() + return + } + const inputHash = await sha256(password) if (inputHash === storedHash) { + // 解锁成功,重新写入 authEnabled 以修复可能被篡改的签名 + await configService.setAuthEnabled(true) handleUnlock() } else { setError('密码错误') diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 014e0d9..0085b6d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react' import { useAppStore } from '../stores/appStore' -import * as configService from '../services/config' + import './Sidebar.scss' function Sidebar() { @@ -12,7 +12,7 @@ function Sidebar() { const setLocked = useAppStore(state => state.setLocked) useEffect(() => { - configService.getAuthEnabled().then(setAuthEnabled) + window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) }, []) const isActive = (path: string) => { diff --git a/src/components/Sns/SnsMediaGrid.tsx b/src/components/Sns/SnsMediaGrid.tsx index a2b832f..6200223 100644 --- a/src/components/Sns/SnsMediaGrid.tsx +++ b/src/components/Sns/SnsMediaGrid.tsx @@ -21,6 +21,7 @@ interface SnsMedia { interface SnsMediaGridProps { mediaList: SnsMedia[] + postType?: number onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onMediaDeleted?: () => void } @@ -80,7 +81,7 @@ const extractVideoFrame = async (videoPath: string): Promise => { }) } -const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { +const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { const [error, setError] = useState(false) const [deleted, setDeleted] = useState(false) const [loading, setLoading] = useState(true) @@ -96,6 +97,8 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr const isVideo = isSnsVideoUrl(media.url) const isLive = !!media.livePhoto const targetUrl = media.thumb || media.url + // type 7 的朋友圈媒体不需要解密,直接使用原始 URL + const skipDecrypt = postType === 7 // 视频重试:失败时重试最多2次,耗尽才标记删除 const videoRetryOrDelete = () => { @@ -119,7 +122,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr // For images, we proxy to get the local path/base64 const result = await window.electronAPI.sns.proxyImage({ url: targetUrl, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (cancelled) return @@ -134,7 +137,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr if (isLive && media.livePhoto?.url) { window.electronAPI.sns.proxyImage({ url: media.livePhoto.url, - key: media.livePhoto.key || media.key + key: skipDecrypt ? undefined : (media.livePhoto.key || media.key) }).then((res: any) => { if (!cancelled && res.success && res.videoPath) { setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`) @@ -150,7 +153,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr // Usually we need to call proxyImage with the video URL to decrypt it to cache const result = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (cancelled) return @@ -201,7 +204,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr try { const res = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (res.success && res.videoPath) { const local = `file://${res.videoPath.replace(/\\/g, '/')}` @@ -229,7 +232,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr try { const result = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (result.success) { @@ -334,7 +337,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr ) } -export const SnsMediaGrid: React.FC = ({ mediaList, onPreview, onMediaDeleted }) => { +export const SnsMediaGrid: React.FC = ({ mediaList, postType, onPreview, onMediaDeleted }) => { if (!mediaList || mediaList.length === 0) return null const count = mediaList.length @@ -350,7 +353,7 @@ export const SnsMediaGrid: React.FC = ({ mediaList, onPreview return (
{mediaList.map((media, idx) => ( - + ))}
) diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 6cef3b5..bf65dca 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -264,7 +264,7 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb {showMediaGrid && (
- setMediaDeleted(true) : undefined} /> + setMediaDeleted(true) : undefined} />
)} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 83d3c66..27b12e2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -279,7 +279,7 @@ function SettingsPage() { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() - const savedAuthEnabled = await configService.getAuthEnabled() + const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() setAuthEnabled(savedAuthEnabled) setAuthUseHello(savedAuthUseHello) @@ -2046,6 +2046,14 @@ function SettingsPage() { checked={authEnabled} onChange={async (e) => { const enabled = e.target.checked + if (enabled) { + // 检查是否已设置密码,未设置则阻止开启 + const storedHash = await configService.getAuthPassword() + if (!storedHash) { + showMessage('请先设置密码再启用应用锁', false) + return + } + } setAuthEnabled(enabled) await configService.setAuthEnabled(enabled) }} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index fe6fefa..f01814e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -19,6 +19,10 @@ export interface ElectronAPI { set: (key: string, value: unknown) => Promise clear: () => Promise } + auth: { + hello: (message?: string) => Promise<{ success: boolean; error?: string }> + verifyEnabled: () => Promise + } dialog: { openFile: (options?: Electron.OpenDialogOptions) => Promise openDirectory: (options?: Electron.OpenDialogOptions) => Promise diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b1ee881..b17b74f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface Window { // ... other methods ... auth: { hello: (message?: string) => Promise<{ success: boolean; error?: string }> + verifyEnabled: () => Promise } // For brevity, using 'any' for other parts or properly importing types if available. // In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts