From e43af6f1fed445691284b6ede309b2f23ad3639b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com> Date: Fri, 7 Aug 2026 01:39:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E8=B4=A6=E5=8F=B7=E8=BA=AB=E4=BB=BD=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次连接前读取当前账号昵称和头像 从账号目录安全推导其他账号 wxid 避免账号串号 --- src/main/services/account-discovery.ts | 22 ++- src/main/services/local-account-identity.ts | 149 ++++++++++++++++++ .../src/components/DatabaseConnectionPage.tsx | 13 +- tests/component/database-connection.test.tsx | 47 ++++++ tests/unit/account-discovery.test.ts | 51 ++++++ 5 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 src/main/services/local-account-identity.ts diff --git a/src/main/services/account-discovery.ts b/src/main/services/account-discovery.ts index cf2e4b8..fd1851a 100644 --- a/src/main/services/account-discovery.ts +++ b/src/main/services/account-discovery.ts @@ -4,6 +4,11 @@ import path from 'path' import type { AccountDiscoveryResult, WechatAccountCandidate } from '../../shared/database-key' import { DatabaseKeyStore } from '../database-key-store' import { getBootstrapCache } from './bootstrap-cache' +import { + accountDirectoryBelongsToIdentity, + deriveAccountWxid, + readLocalAccountIdentity +} from './local-account-identity' import { validateDbRoot } from './settings-store' function accountId(accountRoot: string): string { @@ -27,16 +32,27 @@ export async function discoverAccounts( .map((entry) => path.join(normalizedInput, entry.name)) .filter((candidate) => fs.existsSync(path.join(candidate, 'db_storage'))) + const localIdentity = readLocalAccountIdentity( + isAccount ? path.dirname(normalizedInput) : normalizedInput + ) + const identityMatches = localIdentity + ? roots.filter((accountRoot) => + accountDirectoryBelongsToIdentity(path.basename(accountRoot), localIdentity.wxid) + ) + : [] + const identityRoot = identityMatches.length === 1 ? identityMatches[0] : undefined + const accounts: WechatAccountCandidate[] = await Promise.all( roots.map(async (accountRoot) => { const cached = getBootstrapCache(accountRoot)?.self + const identity = identityRoot === accountRoot ? localIdentity : null return { id: accountId(accountRoot), accountRoot, directoryName: path.basename(accountRoot), - wxid: cached?.wxid, - nickname: cached?.nickname, - avatar: cached?.avatar, + wxid: identity?.wxid || cached?.wxid || deriveAccountWxid(path.basename(accountRoot)), + nickname: identity?.nickname || cached?.nickname, + avatar: cached?.avatar || identity?.avatar, hasSavedDbKey: (await keyStore.getStatus(accountRoot)).saved, loginStatus: currentAccountRoot ? path.resolve(currentAccountRoot).toLowerCase() === diff --git a/src/main/services/local-account-identity.ts b/src/main/services/local-account-identity.ts new file mode 100644 index 0000000..9c40888 --- /dev/null +++ b/src/main/services/local-account-identity.ts @@ -0,0 +1,149 @@ +import crypto from 'crypto' +import fs from 'fs-extra' +import path from 'path' + +export interface LocalAccountIdentity { + wxid: string + nickname?: string + avatar?: string +} + +interface VarUint { + value: number + end: number +} + +interface EncodedRecord { + key: string + value: Buffer + end: number +} + +const PROFILE_FILE = path.join('all_users', 'config', 'global_config') +const FILE_PREFIX_BYTES = 4 +const MAX_FILE_BYTES = 8 * 1024 * 1024 +const MAX_RECORD_BYTES = 16 * 1024 +const CIPHER_KEY = Buffer.from('xwechat_crypt_key', 'utf8').subarray(0, 16) +const CIPHER_IV = Buffer.alloc(16) +const PROFILE_FIELDS = { + wxid: 'mmkv_key_user_name', + nickname: 'mmkv_key_nick_name', + avatar: 'mmkv_key_head_img_url' +} as const + +function decodeVarUint(buffer: Buffer, offset: number, limit = buffer.length): VarUint | null { + let value = 0 + let shift = 0 + + for (let cursor = offset; cursor < limit && shift <= 28; cursor += 1, shift += 7) { + const byte = buffer[cursor] + value += (byte & 0x7f) * 2 ** shift + if ((byte & 0x80) === 0) return { value, end: cursor + 1 } + } + return null +} + +function decodeRecord(buffer: Buffer, offset: number): EncodedRecord | null { + const keySize = decodeVarUint(buffer, offset) + if (!keySize || keySize.value < 1 || keySize.value > 128) return null + + const keyEnd = keySize.end + keySize.value + if (keyEnd > buffer.length) return null + const key = buffer.toString('utf8', keySize.end, keyEnd) + if (!key.startsWith('mmkv_key_') || !/^[\x20-\x7e]+$/.test(key)) return null + + const valueSize = decodeVarUint(buffer, keyEnd) + if (!valueSize || valueSize.value < 1 || valueSize.value > MAX_RECORD_BYTES) return null + const valueEnd = valueSize.end + valueSize.value + if (valueEnd > buffer.length) return null + + return { key, value: buffer.subarray(valueSize.end, valueEnd), end: valueEnd } +} + +function decodeTextValue(value: Buffer): string { + const textSize = decodeVarUint(value, 0) + if (!textSize || textSize.end + textSize.value !== value.length) return '' + const text = value.toString('utf8', textSize.end).replace(/\0+$/g, '').trim() + if (!text || text.includes('\ufffd')) return '' + const hasControlCharacter = Array.from(text).some((character) => { + const code = character.charCodeAt(0) + return code < 32 && code !== 9 && code !== 10 && code !== 13 + }) + return hasControlCharacter ? '' : text +} + +function collectProfileFields(buffer: Buffer): Map { + const fields = new Map() + const wanted = new Set(Object.values(PROFILE_FIELDS)) + + for (let offset = 0; offset < buffer.length && fields.size < wanted.size; ) { + const record = decodeRecord(buffer, offset) + if (!record) { + offset += 1 + continue + } + if (wanted.has(record.key)) { + const text = decodeTextValue(record.value) + if (text) fields.set(record.key, text) + } + offset = record.end + } + + return fields +} + +function normalizeAvatar(value?: string): string | undefined { + if (!value) return undefined + try { + const url = new URL(value) + if (url.protocol === 'http:') url.protocol = 'https:' + return url.protocol === 'https:' ? url.toString() : undefined + } catch { + return undefined + } +} + +export function readLocalAccountIdentity(dataRoot: string): LocalAccountIdentity | null { + const file = path.join(path.resolve(dataRoot), PROFILE_FILE) + try { + const stat = fs.statSync(file) + if (!stat.isFile() || stat.size <= FILE_PREFIX_BYTES || stat.size > MAX_FILE_BYTES) return null + + const source = fs.readFileSync(file) + const decipher = crypto.createDecipheriv('aes-128-cfb', CIPHER_KEY, CIPHER_IV) + decipher.setAutoPadding(false) + const decoded = Buffer.concat([ + decipher.update(source.subarray(FILE_PREFIX_BYTES)), + decipher.final() + ]) + const fields = collectProfileFields(decoded) + const wxid = fields.get(PROFILE_FIELDS.wxid) || '' + if (!/^[a-zA-Z0-9_-]{3,128}$/.test(wxid)) return null + + return { + wxid, + nickname: fields.get(PROFILE_FIELDS.nickname) || undefined, + avatar: normalizeAvatar(fields.get(PROFILE_FIELDS.avatar)) + } + } catch { + return null + } +} + +export function accountDirectoryBelongsToIdentity(directoryName: string, wxid: string): boolean { + const directory = directoryName.trim().toLowerCase() + const identity = wxid.trim().toLowerCase() + if (!identity) return false + if (directory === identity) return true + if (!directory.startsWith(`${identity}_`)) return false + return /^[a-z0-9]{4}$/.test(directory.slice(identity.length + 1)) +} + +export function deriveAccountWxid(directoryName: string): string | undefined { + const directory = directoryName.trim() + if (!directory) return undefined + const wxidPrefix = directory.match(/^(wxid_[^_]+)/i) + if (wxidPrefix) return wxidPrefix[1] + const suffixed = directory.match(/^(.+)_([a-z0-9]{4})$/i) + return suffixed?.[1] || undefined +} diff --git a/src/renderer/src/components/DatabaseConnectionPage.tsx b/src/renderer/src/components/DatabaseConnectionPage.tsx index 14dc631..5980d1f 100644 --- a/src/renderer/src/components/DatabaseConnectionPage.tsx +++ b/src/renderer/src/components/DatabaseConnectionPage.tsx @@ -338,9 +338,18 @@ export function DatabaseConnectionPage({ - {account.nickname || `账号目录 ${account.directoryName || '待识别'}`} + {account.nickname || + (account.wxid + ? `微信账号 ${account.wxid}` + : `账号目录 ${account.directoryName || '待识别'}`)} - {account.wxid || '连接后读取微信号'} + + {account.nickname + ? account.wxid || '微信号未读取' + : account.wxid + ? '昵称和头像需连接此账号后读取' + : '连接后读取微信号、昵称和头像'} + {account.accountRoot} diff --git a/tests/component/database-connection.test.tsx b/tests/component/database-connection.test.tsx index 249dde5..8143151 100644 --- a/tests/component/database-connection.test.tsx +++ b/tests/component/database-connection.test.tsx @@ -69,6 +69,53 @@ function renderPage( } describe('DatabaseConnectionPage', () => { + it('renders a discovered nickname and avatar before connection', () => { + const { container } = renderPage({ + mode: 'automatic', + accounts: [ + { + id: 'account-a', + accountRoot: 'C:\\fixture\\account-a', + directoryName: 'account-a', + nickname: '首次识别账号', + wxid: 'fixture_account', + avatar: 'https://wx.qlogo.cn/fixture/avatar', + hasSavedDbKey: false, + loginStatus: 'unknown', + selectedByInput: true + } + ] + }) + + expect(screen.getByText('首次识别账号')).toBeVisible() + expect(screen.getByText('fixture_account')).toBeVisible() + expect(container.querySelector('.database-account-avatar img')).toHaveAttribute( + 'src', + 'https://wx.qlogo.cn/fixture/avatar' + ) + }) + + it('shows a directory-derived wxid without pretending profile data is available', () => { + renderPage({ + mode: 'automatic', + accounts: [ + { + id: 'account-b', + accountRoot: 'C:\\fixture\\wxid_fixture_b_ab12', + directoryName: 'wxid_fixture_b_ab12', + wxid: 'wxid_fixture', + hasSavedDbKey: false, + loginStatus: 'other', + selectedByInput: false + } + ], + selectedAccountId: '' + }) + + expect(screen.getByText('微信账号 wxid_fixture')).toBeVisible() + expect(screen.getByText('昵称和头像需连接此账号后读取')).toBeVisible() + }) + it('keeps connect disabled until a valid 64-character key is supplied', () => { const { rerender, props } = renderPage() expect(screen.getByRole('button', { name: '连接数据库' })).toBeDisabled() diff --git a/tests/unit/account-discovery.test.ts b/tests/unit/account-discovery.test.ts index 6e96f08..3fd360b 100644 --- a/tests/unit/account-discovery.test.ts +++ b/tests/unit/account-discovery.test.ts @@ -12,6 +12,13 @@ vi.mock('electron', () => ({ })) import { discoverAccounts } from '../../src/main/services/account-discovery' +import { + accountDirectoryBelongsToIdentity, + deriveAccountWxid +} from '../../src/main/services/local-account-identity' + +const ENCRYPTED_PROFILE_FIXTURE = + 'AQAAAO94jcgf4UG6tCOnxpkapWihufN03upWNEXBfttmtsNHGfPwTv6d4rHJ5BQ9fTQWVc8QuHn1cCk1TmQ4eYW4iHKHEGgyGn/3mcpxlJIxlkt/y7IFifofGw8UShRYcOa8j59W2EML986dq+OWo/cN19iq3PHiMhbDLugGiWYdeDMAIPI/' describe('account discovery', () => { let root: string @@ -66,4 +73,48 @@ describe('account discovery', () => { expect(result.preselectedAccountId).toBe(result.accounts[0].id) expect(result.accounts[0].selectedByInput).toBe(true) }) + + it('adds the local profile only to its exact account directory before unlock', async () => { + const accountRoot = path.join(root, 'fixture_account_ab12') + await fs.ensureDir(path.join(accountRoot, 'db_storage')) + const profileFile = path.join(root, 'all_users', 'config', 'global_config') + await fs.ensureDir(path.dirname(profileFile)) + await fs.writeFile(profileFile, Buffer.from(ENCRYPTED_PROFILE_FIXTURE, 'base64')) + + const result = await discoverAccounts(root, keyStore as never) + + expect( + result.accounts.find((account) => account.directoryName === 'fixture_account_ab12') + ).toMatchObject({ + wxid: 'fixture_account', + nickname: '测试账号', + avatar: 'https://wx.qlogo.cn/fixture/avatar' + }) + expect(result.accounts.find((account) => account.directoryName === 'account-a')).toMatchObject({ + wxid: undefined, + nickname: undefined, + avatar: undefined + }) + + const directResult = await discoverAccounts(accountRoot, keyStore as never) + expect(directResult.accounts[0]).toMatchObject({ + wxid: 'fixture_account', + nickname: '测试账号' + }) + }) + + it('uses strict account-directory suffix matching', () => { + expect(accountDirectoryBelongsToIdentity('fixture_account', 'fixture_account')).toBe(true) + expect(accountDirectoryBelongsToIdentity('fixture_account_ab12', 'fixture_account')).toBe(true) + expect(accountDirectoryBelongsToIdentity('fixture_account_z9q2', 'fixture_account')).toBe(true) + expect(accountDirectoryBelongsToIdentity('fixture_account_other', 'fixture_account')).toBe( + false + ) + expect(accountDirectoryBelongsToIdentity('fixture_account2_ab12', 'fixture_account')).toBe( + false + ) + expect(deriveAccountWxid('wxid_iuq1a00d79c212_00fa')).toBe('wxid_iuq1a00d79c212') + expect(deriveAccountWxid('a969409112_d784')).toBe('a969409112') + expect(deriveAccountWxid('account-a')).toBeUndefined() + }) })