v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨

核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
  解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'

UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线

架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化

质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
This commit is contained in:
Logan.Li committed 2026-04-22 11:15:41 +08:00
1 parent 37e65be823
commit 514d62644e
77 files changed
+2563 -931

No files matched your search

+100 -15
View File
@@ -1,15 +1,31 @@
import crypto from 'crypto'
import { promisify } from 'util'
const ALGORITHM = 'aes-256-gcm'
const KEY_LENGTH = 32
const IV_LENGTH = 16
const TAG_LENGTH = 16
const _TAG_LENGTH = 16
const SALT_LENGTH = 32
const ITERATIONS = 100000
// OWASP 2023 建议至少 600,000 次(针对 SHA256)
const ITERATIONS = 600000
const CRYPTO_VERSION = 'v1'
// 从密码派生密钥
function deriveKey(password: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256')
// 异步 PBKDF2 避免阻塞事件循环
const pbkdf2Async = promisify(crypto.pbkdf2)
/**
* 从密码异步派生密钥
* 使用异步版本避免阻塞 Electron 主进程事件循环
*/
async function deriveKeyAsync(password: string, salt: Buffer): Promise<Buffer> {
return pbkdf2Async(password, salt, ITERATIONS, KEY_LENGTH, 'sha512')
}
/**
* 从密码同步派生密钥(仅在启动等必要时使用)
*/
function deriveKeySync(password: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha512')
}
// 生成随机密钥(用于主密钥)
@@ -17,18 +33,26 @@ export function generateMasterKey(): string {
return crypto.randomBytes(KEY_LENGTH).toString('hex')
}
// 加密数据
/**
* 加密数据
* 格式: version:salt:iv:tag:encrypted (全部 hex)
* version 用于未来加密方案升级时的安全迁移
*/
export function encrypt(plaintext: string, password: string): string {
const salt = crypto.randomBytes(SALT_LENGTH)
const key = deriveKey(password, salt)
const key = deriveKeySync(password, salt)
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
// 格式: salt:iv:tag:encrypted (全部 hex)
// 立即清零密钥内存
key.fill(0)
// 格式: version:salt:iv:tag:encrypted (全部 hex)
return [
CRYPTO_VERSION,
salt.toString('hex'),
iv.toString('hex'),
tag.toString('hex'),
@@ -36,10 +60,50 @@ export function encrypt(plaintext: string, password: string): string {
].join(':')
}
// 解密数据
/**
* 异步加密(推荐,不阻塞事件循环)
*/
export async function encryptAsync(plaintext: string, password: string): Promise<string> {
const salt = crypto.randomBytes(SALT_LENGTH)
const key = await deriveKeyAsync(password, salt)
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
// 立即清零密钥内存
key.fill(0)
return [
CRYPTO_VERSION,
salt.toString('hex'),
iv.toString('hex'),
tag.toString('hex'),
encrypted.toString('hex'),
].join(':')
}
/**
* 解密数据
* 支持带版本标识的新格式和不带版本标识的旧格式(向后兼容)
*/
export function decrypt(ciphertext: string, password: string): string {
try {
const [saltHex, ivHex, tagHex, encryptedHex] = ciphertext.split(':')
const parts = ciphertext.split(':')
// 判断格式:新格式有5部分(含version),旧格式有4部分
let saltHex: string, ivHex: string, tagHex: string, encryptedHex: string
if (parts.length === 5) {
// 新格式: version:salt:iv:tag:encrypted
[, saltHex, ivHex, tagHex, encryptedHex] = parts
} else if (parts.length === 4) {
// 旧格式: salt:iv:tag:encrypted(向后兼容)
[saltHex, ivHex, tagHex, encryptedHex] = parts
} else {
throw new Error('Invalid ciphertext format')
}
if (!saltHex || !ivHex || !tagHex || !encryptedHex) {
throw new Error('Invalid ciphertext format')
@@ -50,22 +114,32 @@ export function decrypt(ciphertext: string, password: string): string {
const tag = Buffer.from(tagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const key = deriveKey(password, salt)
const key = deriveKeySync(password, salt)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
// 清零密钥内存
key.fill(0)
return decrypted.toString('utf8')
} catch (error) {
throw new Error('Decryption failed: ' + (error as Error).message)
}
}
// 简单加密(不带 salt,用于可逆加密)
/**
* 简单加密(使用主密钥,不经过 PBKDF2)
* 用于内部数据加密,密钥长度必须正确
*/
export function simpleEncrypt(plaintext: string, key: string): string {
const iv = crypto.randomBytes(IV_LENGTH)
const keyBuffer = Buffer.from(key, 'hex').slice(0, KEY_LENGTH)
const keyBuffer = Buffer.from(key, 'hex')
// 验证密钥长度
if (keyBuffer.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH} bytes, got ${keyBuffer.length}`)
}
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
@@ -74,15 +148,26 @@ export function simpleEncrypt(plaintext: string, key: string): string {
return iv.toString('hex') + ':' + tag.toString('hex') + ':' + encrypted.toString('hex')
}
// 简单解密
/**
* 简单解密
*/
export function simpleDecrypt(ciphertext: string, key: string): string {
try {
const [ivHex, tagHex, encryptedHex] = ciphertext.split(':')
if (!ivHex || !tagHex || !encryptedHex) {
throw new Error('Invalid ciphertext format')
}
const iv = Buffer.from(ivHex, 'hex')
const tag = Buffer.from(tagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const keyBuffer = Buffer.from(key, 'hex').slice(0, KEY_LENGTH)
const keyBuffer = Buffer.from(key, 'hex')
// 验证密钥长度
if (keyBuffer.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH} bytes, got ${keyBuffer.length}`)
}
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv)
decipher.setAuthTag(tag)