feat(security): 新增桌面端应用锁

新增 4 位 PIN、手动锁定、启动锁定和系统闲置锁定。

新增主进程与 preload 能力、桌面端设置面板、全局锁屏覆盖层及四语言界面。

Fixes #190

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
xyz-225648andClaude authored and digua committed 2026-07-18 11:49:09 +08:00
1 parent 32bd268af4
commit 84b371e73d
17 files changed
+1115 -6

No files matched your search

+7
View File
@@ -10,6 +10,7 @@ import { initAnalytics } from './analytics'
import { logger } from './logger'
import { prepareDesktopRuntime } from './app/bootstrap'
import { createMainWindow, markAppQuitting } from './window/main-window'
import { initLockManager, cleanupLockManager } from './security/lock-manager'
class MainProcess {
mainWindow: BrowserWindow | null
@@ -104,6 +105,11 @@ class MainProcess {
console.log('[Main] Registering IPC handlers...')
mainIpcMain(this.mainWindow)
console.log('[Main] IPC handlers registered')
// 初始化应用锁(在 IPC 注册之后)
console.log('[Main] Initializing app lock...')
initLockManager(this.mainWindow)
console.log('[Main] App lock initialized')
}
app.on('browser-window-created', (_, window) => {
@@ -144,6 +150,7 @@ class MainProcess {
app.on('will-quit', () => {
stopInternalServer().catch(() => {})
cleanup()
cleanupLockManager()
})
})
}
+2
View File
@@ -15,6 +15,7 @@ import { registerNetworkHandlers } from './network'
import { registerAnalyticsHandlers } from '../analytics'
import { registerApiHandlers, initApiServer, cleanupApiServer } from './api'
import { registerDemoHandlers } from './demo'
import { registerSecurityHandlers } from './security'
import { cleanupArchiveImportSources } from '../import/archive-source-runtime'
// 导入 Worker 模块(用于异步分析查询和流式导入)
import * as worker from '../worker/workerManager'
@@ -46,6 +47,7 @@ const mainIpcMain = (win: BrowserWindow) => {
registerAnalyticsHandlers()
registerApiHandlers(context)
registerDemoHandlers(context)
registerSecurityHandlers(context)
// 启动 ChatLab API 服务(异步,不阻塞 IPC 注册)
initApiServer(context).catch((err) => {
+25
View File
@@ -0,0 +1,25 @@
import { ipcMain } from 'electron'
import type { IpcContext } from './types'
import {
changePassword,
getLockConfig,
getLockState,
lockApp,
resetAppLockPassword,
setPassword,
unlockApp,
updateLockConfig,
} from '../security/lock-manager'
export function registerSecurityHandlers(_ctx: IpcContext): void {
ipcMain.handle('app-lock:getConfig', getLockConfig)
ipcMain.handle('app-lock:getState', getLockState)
ipcMain.handle('app-lock:lock', lockApp)
ipcMain.handle('app-lock:unlock', (_event, password: unknown) => unlockApp(password))
ipcMain.handle('app-lock:setPassword', (_event, password: unknown) => setPassword(password))
ipcMain.handle('app-lock:changePassword', (_event, oldPassword: unknown, newPassword: unknown) =>
changePassword(oldPassword, newPassword)
)
ipcMain.handle('app-lock:resetPassword', resetAppLockPassword)
ipcMain.handle('app-lock:updateConfig', (_event, updates: unknown) => updateLockConfig(updates))
}
@@ -0,0 +1,52 @@
import * as crypto from 'node:crypto'
const PBKDF2_ITERATIONS = 600000
const SALT_LENGTH = 32
const HASH_LENGTH = 64
const HEX_PATTERN = /^[0-9a-f]+$/i
export interface PasswordHash {
hash: string
salt: string
version: 1
}
export function isPasswordHash(value: unknown): value is PasswordHash {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (
record.version === 1 &&
typeof record.hash === 'string' &&
record.hash.length === HASH_LENGTH * 2 &&
HEX_PATTERN.test(record.hash) &&
typeof record.salt === 'string' &&
record.salt.length === SALT_LENGTH * 2 &&
HEX_PATTERN.test(record.salt)
)
}
function derivePassword(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, PBKDF2_ITERATIONS, HASH_LENGTH, 'sha512', (error, hash) => {
if (error) reject(error)
else resolve(hash)
})
})
}
export async function hashPassword(password: string): Promise<PasswordHash> {
const salt = crypto.randomBytes(SALT_LENGTH)
const hash = await derivePassword(password, salt)
return {
hash: hash.toString('hex'),
salt: salt.toString('hex'),
version: 1,
}
}
export async function verifyPasswordHash(password: string, stored: PasswordHash): Promise<boolean> {
if (!isPasswordHash(stored)) return false
const expected = Buffer.from(stored.hash, 'hex')
const actual = await derivePassword(password, Buffer.from(stored.salt, 'hex'))
return crypto.timingSafeEqual(actual, expected)
}
+336
View File
@@ -0,0 +1,336 @@
import { powerMonitor, type BrowserWindow } from 'electron'
import * as fs from 'node:fs'
import { join } from 'node:path'
import type {
AppLockConfig,
AppLockConfigUpdate,
AppLockResult,
AppLockState,
AppLockUnlockResult,
} from '../../shared/types'
import { logger } from '../logger'
import { getPathProvider } from '../paths/provider'
import { hashPassword, isPasswordHash, verifyPasswordHash, type PasswordHash } from './crypto-utils'
interface LockSettings {
idleTimeoutMinutes: number
lockOnStartup: boolean
}
const LOCK_CONFIG_FILE = 'app-lock.json'
const LOCK_FLAG_FILE = '.app-lock-flag'
const PIN_PATTERN = /^\d{4}$/
const IDLE_CHECK_INTERVAL_MS = 10000
const IDLE_TIMEOUT_OPTIONS = new Set([0, 1, 3, 5, 10, 15, 30, 60])
const DEFAULT_LOCK_SETTINGS: LockSettings = {
idleTimeoutMinutes: 0,
lockOnStartup: false,
}
let lockState: AppLockState = 'unlocked'
let currentSettings: LockSettings = { ...DEFAULT_LOCK_SETTINGS }
let storedPasswordHash: PasswordHash | null = null
let idleTimer: ReturnType<typeof setInterval> | null = null
let isTransitioning = false
let mainWindowRef: BrowserWindow | null = null
function getSettingsDir(): string {
return getPathProvider().getSettingsDir()
}
function getLockConfigPath(): string {
return join(getSettingsDir(), LOCK_CONFIG_FILE)
}
function getLockFlagPath(): string {
return join(getSettingsDir(), LOCK_FLAG_FILE)
}
function isValidIdleTimeout(value: unknown): value is number {
return typeof value === 'number' && IDLE_TIMEOUT_OPTIONS.has(value)
}
function loadConfig(): boolean {
currentSettings = { ...DEFAULT_LOCK_SETTINGS }
storedPasswordHash = null
try {
const configPath = getLockConfigPath()
if (!fs.existsSync(configPath)) return false
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as unknown
if (!data || typeof data !== 'object' || Array.isArray(data)) {
logger.warn('App lock config ignored because it is not an object')
return false
}
const record = data as Record<string, unknown>
currentSettings = {
idleTimeoutMinutes: isValidIdleTimeout(record.idleTimeoutMinutes)
? record.idleTimeoutMinutes
: DEFAULT_LOCK_SETTINGS.idleTimeoutMinutes,
lockOnStartup:
typeof record.lockOnStartup === 'boolean' ? record.lockOnStartup : DEFAULT_LOCK_SETTINGS.lockOnStartup,
}
storedPasswordHash = isPasswordHash(record.passwordHash) ? record.passwordHash : null
return true
} catch (error) {
logger.error(`App lock config ignored because it is invalid: ${error instanceof Error ? error.message : error}`)
return false
}
}
function saveConfig(settings: LockSettings, passwordHash: PasswordHash | null): boolean {
const configPath = getLockConfigPath()
try {
fs.mkdirSync(getSettingsDir(), { recursive: true })
const record: Record<string, unknown> = { ...settings }
if (passwordHash) record.passwordHash = passwordHash
fs.writeFileSync(configPath, JSON.stringify(record, null, 2), 'utf-8')
return true
} catch (error) {
logger.error(`Failed to save app lock config: ${error instanceof Error ? error.message : error}`)
return false
}
}
function setLockFlag(): boolean {
try {
fs.writeFileSync(getLockFlagPath(), Date.now().toString(), 'utf-8')
return true
} catch (error) {
logger.error(`Failed to write app lock flag: ${error instanceof Error ? error.message : error}`)
return false
}
}
function clearLockFlag(): boolean {
try {
const flagPath = getLockFlagPath()
if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath)
return true
} catch (error) {
logger.error(`Failed to clear app lock flag: ${error instanceof Error ? error.message : error}`)
return false
}
}
function hasLockFlag(): boolean {
try {
return fs.existsSync(getLockFlagPath())
} catch {
return false
}
}
function isEnabled(): boolean {
return storedPasswordHash !== null
}
function notifyLockState(): void {
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send('app-lock-state-changed', lockState === 'locked')
}
}
export function initLockManager(mainWindow: BrowserWindow): void {
mainWindowRef = mainWindow
lockState = 'unlocked'
isTransitioning = false
const loaded = loadConfig()
if (!loaded || !isEnabled()) clearLockFlag()
const wasLocked = isEnabled() && hasLockFlag()
logger.info(`App lock initialized: enabled=${isEnabled()}, wasLocked=${wasLocked}`)
if (isEnabled() && (currentSettings.lockOnStartup || wasLocked)) {
lockApp()
} else if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) {
startIdleTimer()
}
}
export function lockApp(): AppLockResult {
if (lockState === 'locked') return { success: true }
if (isTransitioning) return { success: false, error: 'busy' }
if (!isEnabled()) return { success: false, error: 'disabled' }
isTransitioning = true
try {
if (!setLockFlag()) return { success: false, error: 'save-failed' }
lockState = 'locked'
stopIdleTimer()
logger.info('App locked')
notifyLockState()
return { success: true }
} finally {
isTransitioning = false
}
}
export async function unlockApp(password: unknown): Promise<AppLockUnlockResult> {
if (lockState === 'unlocked') return { success: true }
if (isTransitioning) return { success: false, error: 'busy' }
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
if (typeof password !== 'string' || !PIN_PATTERN.test(password)) {
return { success: false, error: 'wrong-password', wrongPassword: true }
}
isTransitioning = true
try {
if (!(await verifyPasswordHash(password, storedPasswordHash))) {
return { success: false, error: 'wrong-password', wrongPassword: true }
}
clearLockFlag()
lockState = 'unlocked'
logger.info('App unlocked')
notifyLockState()
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
return { success: true }
} finally {
isTransitioning = false
}
}
export async function setPassword(newPassword: unknown): Promise<AppLockResult> {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (typeof newPassword !== 'string' || !PIN_PATTERN.test(newPassword)) {
return { success: false, error: 'invalid-pin' }
}
if (storedPasswordHash) return { success: false, error: 'password-already-set' }
isTransitioning = true
try {
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
logger.info('App lock password initialized')
return { success: true }
} catch (error) {
logger.error(`Failed to set app lock password: ${error instanceof Error ? error.message : error}`)
return { success: false, error: 'internal-error' }
} finally {
isTransitioning = false
}
}
export async function changePassword(oldPassword: unknown, newPassword: unknown): Promise<AppLockResult> {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
if (typeof oldPassword !== 'string' || typeof newPassword !== 'string' || !PIN_PATTERN.test(newPassword)) {
return { success: false, error: 'invalid-pin' }
}
if (oldPassword === newPassword) return { success: false, error: 'same-password' }
isTransitioning = true
try {
if (!(await verifyPasswordHash(oldPassword, storedPasswordHash))) {
return { success: false, error: 'wrong-password' }
}
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
logger.info('App lock password changed')
return { success: true }
} catch (error) {
logger.error(`Failed to change app lock password: ${error instanceof Error ? error.message : error}`)
return { success: false, error: 'internal-error' }
} finally {
isTransitioning = false
}
}
export function resetAppLockPassword(): AppLockResult {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (!saveConfig(currentSettings, null)) return { success: false, error: 'save-failed' }
storedPasswordHash = null
stopIdleTimer()
clearLockFlag()
logger.info('App lock disabled and password cleared')
return { success: true }
}
export function getLockConfig(): AppLockConfig {
return {
enabled: isEnabled(),
...currentSettings,
}
}
function parseConfigUpdate(value: unknown): AppLockConfigUpdate | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
if (Object.keys(record).some((key) => key !== 'idleTimeoutMinutes' && key !== 'lockOnStartup')) return null
const updates: AppLockConfigUpdate = {}
if ('idleTimeoutMinutes' in record) {
if (!isValidIdleTimeout(record.idleTimeoutMinutes)) return null
updates.idleTimeoutMinutes = record.idleTimeoutMinutes
}
if ('lockOnStartup' in record) {
if (typeof record.lockOnStartup !== 'boolean') return null
updates.lockOnStartup = record.lockOnStartup
}
return updates
}
export function updateLockConfig(value: unknown): AppLockResult & { config?: AppLockConfig } {
if (isTransitioning) return { success: false, error: 'busy' }
const updates = parseConfigUpdate(value)
if (!updates) return { success: false, error: 'invalid-config' }
const nextSettings = { ...currentSettings, ...updates }
if (!saveConfig(nextSettings, storedPasswordHash)) return { success: false, error: 'save-failed' }
currentSettings = nextSettings
if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
else stopIdleTimer()
logger.info(`App lock config updated: ${JSON.stringify(updates)}`)
return { success: true, config: getLockConfig() }
}
export function getLockState(): AppLockState {
return lockState
}
function checkSystemIdle(): void {
if (!isEnabled() || lockState === 'locked' || currentSettings.idleTimeoutMinutes <= 0) return
try {
if (powerMonitor.getSystemIdleTime() >= currentSettings.idleTimeoutMinutes * 60) {
logger.info(`App idle timeout reached (${currentSettings.idleTimeoutMinutes} min)`)
lockApp()
}
} catch (error) {
logger.error(`Failed to read system idle time: ${error instanceof Error ? error.message : error}`)
stopIdleTimer()
}
}
function startIdleTimer(): void {
stopIdleTimer()
idleTimer = setInterval(checkSystemIdle, IDLE_CHECK_INTERVAL_MS)
checkSystemIdle()
}
function stopIdleTimer(): void {
if (!idleTimer) return
clearInterval(idleTimer)
idleTimer = null
}
export function cleanupLockManager(): void {
stopIdleTimer()
mainWindowRef = null
logger.info('App lock manager cleaned up')
}
+18
View File
@@ -0,0 +1,18 @@
import { ipcRenderer } from 'electron'
import type { SecurityApi } from '../../shared/types'
export const securityApi: SecurityApi = {
getConfig: () => ipcRenderer.invoke('app-lock:getConfig'),
getState: () => ipcRenderer.invoke('app-lock:getState'),
lock: () => ipcRenderer.invoke('app-lock:lock'),
unlock: (password) => ipcRenderer.invoke('app-lock:unlock', password),
setPassword: (password) => ipcRenderer.invoke('app-lock:setPassword', password),
changePassword: (oldPassword, newPassword) => ipcRenderer.invoke('app-lock:changePassword', oldPassword, newPassword),
resetPassword: () => ipcRenderer.invoke('app-lock:resetPassword'),
updateConfig: (updates) => ipcRenderer.invoke('app-lock:updateConfig', updates),
onLockStateChanged: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, locked: boolean) => callback(locked)
ipcRenderer.on('app-lock-state-changed', handler)
return () => ipcRenderer.removeListener('app-lock-state-changed', handler)
},
}
+3 -1
View File
@@ -1,6 +1,6 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { ImportProgress, ExportProgress } from '../../../src/types/base'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../shared/types'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo, SecurityApi } from '../shared/types'
import type { TimeFilter } from '@openchatlab/shared-types'
// 迁移相关类型
@@ -299,6 +299,7 @@ declare global {
networkApi: NetworkApi
apiServerApi: ApiServerApi
internalApi: InternalApi
securityApi: SecurityApi
}
}
@@ -334,4 +335,5 @@ export {
ApiServerStatus,
InternalApi,
InternalEndpoint,
SecurityApi,
}
+4
View File
@@ -12,6 +12,7 @@ import { aiApi } from './apis/ai'
import { networkApi, cacheApi } from './apis/utils'
import { apiServerApi } from './apis/api-server'
import { internalApi } from './apis/internal-api'
import { securityApi } from './apis/security'
// 为渲染进程提供统一的类型入口,避免 type-only import 指向无导出的运行时代码。
export type { PreprocessConfig } from './apis/ai'
@@ -41,6 +42,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('networkApi', networkApi)
contextBridge.exposeInMainWorld('apiServerApi', apiServerApi)
contextBridge.exposeInMainWorld('internalApi', internalApi)
contextBridge.exposeInMainWorld('securityApi', securityApi)
} catch (error) {
console.error(error)
}
@@ -61,4 +63,6 @@ if (process.contextIsolated) {
window.apiServerApi = apiServerApi
// @ts-ignore (define in dts)
window.internalApi = internalApi
// @ts-ignore (define in dts)
window.securityApi = securityApi
}
+47
View File
@@ -41,3 +41,50 @@ export interface AgentRuntimeStatus {
totalUsage: TokenUsage
updatedAt: number
}
export type AppLockState = 'unlocked' | 'locked'
export type AppLockErrorCode =
| 'busy'
| 'locked'
| 'disabled'
| 'invalid-pin'
| 'password-already-set'
| 'password-not-set'
| 'same-password'
| 'wrong-password'
| 'save-failed'
| 'invalid-config'
| 'internal-error'
export interface AppLockConfig {
enabled: boolean
idleTimeoutMinutes: number
lockOnStartup: boolean
}
export interface AppLockConfigUpdate {
idleTimeoutMinutes?: number
lockOnStartup?: boolean
}
export interface AppLockResult {
success: boolean
error?: AppLockErrorCode
}
export interface AppLockUnlockResult extends AppLockResult {
wrongPassword?: boolean
}
export interface SecurityApi {
getConfig: () => Promise<AppLockConfig>
getState: () => Promise<AppLockState>
lock: () => Promise<AppLockResult>
unlock: (password: string) => Promise<AppLockUnlockResult>
setPassword: (newPassword: string) => Promise<AppLockResult>
changePassword: (oldPassword: string, newPassword: string) => Promise<AppLockResult>
resetPassword: () => Promise<AppLockResult>
updateConfig: (updates: AppLockConfigUpdate) => Promise<AppLockResult & { config?: AppLockConfig }>
onLockStateChanged: (callback: (locked: boolean) => void) => () => void
}