修复朋友圈封面信息被错误解析的问题;解决了一些安全问题

This commit is contained in:
cc
2026-02-25 12:12:08 +08:00
parent b3741a5cf4
commit 411f8a8d61
11 changed files with 263 additions and 18 deletions

View File

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

View File

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

View File

@@ -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<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
export class ConfigService {
private static instance: ConfigService
private store!: Store<ConfigSchema>
@@ -103,16 +111,221 @@ export class ConfigService {
wordCloudExcludeWords: []
}
})
// 首次启动时迁移旧版明文安全字段
this.migrateAuthFields()
}
get<K extends keyof ConfigSchema>(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<K extends keyof ConfigSchema>(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) {

View File

@@ -312,7 +312,7 @@ function App() {
const checkLock = async () => {
// 并行获取配置,减少等待
const [enabled, useHello] = await Promise.all([
configService.getAuthEnabled(),
window.electronAPI.auth.verifyEnabled(),
configService.getAuthUseHello()
])

View File

@@ -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('密码错误')

View File

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

View File

@@ -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<string> => {
})
}
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<SnsMediaGridProps> = ({ mediaList, onPreview, onMediaDeleted }) => {
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
if (!mediaList || mediaList.length === 0) return null
const count = mediaList.length
@@ -350,7 +353,7 @@ export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview
return (
<div className={`sns-media-grid ${gridClass}`}>
{mediaList.map((media, idx) => (
<MediaItem key={idx} media={media} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
))}
</div>
)

View File

@@ -264,7 +264,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
{showMediaGrid && (
<div className="post-media-container">
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
</div>
)}

View File

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

View File

@@ -19,6 +19,10 @@ export interface ElectronAPI {
set: (key: string, value: unknown) => Promise<void>
clear: () => Promise<boolean>
}
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
verifyEnabled: () => Promise<boolean>
}
dialog: {
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>

1
src/vite-env.d.ts vendored
View File

@@ -5,6 +5,7 @@ interface Window {
// ... other methods ...
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
verifyEnabled: () => Promise<boolean>
}
// 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