diff --git a/app/src/main/ipc-handlers.js b/app/src/main/ipc-handlers.js index 2390727..a216c8f 100644 --- a/app/src/main/ipc-handlers.js +++ b/app/src/main/ipc-handlers.js @@ -151,4 +151,26 @@ export function registerIpcHandlers() { return true; }); + // ==================== 安全管理 ==================== + + ipcMain.handle('mqtt:get-security-status', () => { + return mqttManager.getSecurityStatus(); + }); + + ipcMain.handle('mqtt:setup-master-password', (_, password) => { + return mqttManager.setupMasterPassword(password); + }); + + ipcMain.handle('mqtt:unlock', (_, password) => { + return mqttManager.unlock(password); + }); + + ipcMain.handle('mqtt:lock', () => { + return mqttManager.lock(); + }); + + ipcMain.handle('mqtt:remove-master-password', (_, password) => { + return mqttManager.removeMasterPassword(password); + }); + } diff --git a/app/src/main/mqtt-manager.js b/app/src/main/mqtt-manager.js index 5ce2814..ac6966c 100644 --- a/app/src/main/mqtt-manager.js +++ b/app/src/main/mqtt-manager.js @@ -1,6 +1,8 @@ +import { randomBytes } from 'crypto'; import { app } from 'electron'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; +import { createPasswordVerifier, decryptSecret, deriveKey, encryptSecret, verifyPassword } from './security.js'; import mqtt from 'mqtt'; @@ -36,6 +38,9 @@ class MqttManager { this.msgIdCounter = 0; // 用于生成唯一消息 id this.dataPath = join(app.getPath('userData'), 'frost-mqtt-data.json'); this.quickSendList = []; + this.security = null; + this.masterKey = null; + this.locked = false; this.loadData(); } @@ -77,16 +82,30 @@ class MqttManager { let raw = readFileSync(this.dataPath, 'utf-8'); let data = JSON.parse(raw); + this.security = data.security || null; + this.locked = Boolean(this.security?.enabled); + if (data.servers) { data.servers.forEach((s) => { + + let encryptedPassword = s.passwordEncrypted || ''; + s.status = 'disconnected'; + s.password = this.locked ? '' : (s.password || ''); + + if (encryptedPassword) { + s.passwordEncrypted = encryptedPassword; + } + s.topics = (s.topics || []).map((t) => ({ ...t, id: t.id || 'topic_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), comment: t.comment ?? '', subscribed: false, })); + this.servers.set(s.id, s); + }); } @@ -124,8 +143,13 @@ class MqttManager { try { let data = { + security: this.security, servers: Array.from(this.servers.values()).map((s) => ({ ...s, + password: this.security?.enabled ? undefined : (s.password || ''), + passwordEncrypted: this.security?.enabled && s.password + ? encryptSecret(s.password, this.masterKey) + : (this.security?.enabled ? s.passwordEncrypted || '' : undefined), status: 'disconnected', topics: s.topics.map((t) => ({ ...t, subscribed: false })), })), @@ -214,11 +238,109 @@ class MqttManager { // ==================== 服务器管理 ==================== + getSecurityStatus() { + return { enabled: Boolean(this.security?.enabled), locked: this.locked }; + } + + setupMasterPassword(password) { + + this.assertPassword(password); + + if (this.security?.enabled) { + throw new Error('主密码已设置'); + } + + let salt = randomBytes(16).toString('base64'); + + this.masterKey = deriveKey(password, Buffer.from(salt, 'base64')); + this.security = { enabled: true, salt, verifier: createPasswordVerifier(this.masterKey) }; + this.locked = false; + this.saveData(); + + return this.getSecurityStatus(); + + } + + unlock(password) { + + if (!this.security?.enabled) { + return this.getSecurityStatus(); + }; + + let key = deriveKey(password, Buffer.from(this.security.salt, 'base64')); + + if (!verifyPassword(key, this.security.verifier)) { + throw new Error('主密码错误'); + } + + try { + this.servers.forEach((server) => { + server.password = server.passwordEncrypted ? decryptSecret(server.passwordEncrypted, key) : ''; + }); + } catch { + throw new Error('服务器密码解密失败,数据可能已损坏'); + } + + this.masterKey = key; + this.locked = false; + + return this.getSecurityStatus(); + + } + + lock() { + + if (!this.security?.enabled || this.locked) { + return this.getSecurityStatus(); + } + + this.clients.forEach((_, id) => this.disconnect(id)); + this.servers.forEach((server) => { + server.passwordEncrypted = server.password + ? encryptSecret(server.password, this.masterKey) + : server.passwordEncrypted || ''; + server.password = ''; + }); + this.saveData(); + this.masterKey = null; + this.locked = true; + + return this.getSecurityStatus(); + + } + + removeMasterPassword(password) { + + if (!this.security?.enabled) { + return this.getSecurityStatus(); + } + + this.unlock(password); + this.security = null; + this.masterKey = null; + this.locked = false; + this.saveData(); + + return this.getSecurityStatus(); + + } + + assertPassword(password) { + if (typeof password !== 'string' || password.length < 4) { + throw new Error('主密码至少需要 4 个字符'); + } + } + + assertUnlocked() { + if (this.locked) throw new Error('应用已锁定,请先解锁'); + } + /** * 获取所有服务器列表 * @returns {MqttServer[]} 服务器配置数组 */ getServers() { + this.assertUnlocked(); return Array.from(this.servers.values()).map((s) => ({ ...s, password: s.password || '', @@ -231,6 +353,7 @@ class MqttManager { * @returns {MqttServer | null} 服务器配置或 null */ getServer(id) { + this.assertUnlocked(); return this.servers.get(id) || null; } @@ -293,6 +416,8 @@ class MqttManager { */ addServer(config) { + this.assertUnlocked(); + let id = 'srv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); let server = { id, @@ -318,6 +443,8 @@ class MqttManager { */ updateServer(id, config) { + this.assertUnlocked(); + let server = this.servers.get(id); if (!server) { @@ -337,6 +464,7 @@ class MqttManager { * @param {string} id - 服务器 id */ deleteServer(id) { + this.assertUnlocked(); // 先断开连接 this.disconnect(id); this.servers.delete(id); @@ -350,7 +478,11 @@ class MqttManager { * @returns {ServerConfig[]} 服务器配置数组 */ exportServers() { + + this.assertUnlocked(); + let schemaKeys = Object.keys(this.getServerConfigSchema()); + return Array.from(this.servers.values()).map((s) => { let exported = {}; for (let key of schemaKeys) { @@ -359,6 +491,7 @@ class MqttManager { exported.topics = (s.topics || []).map((t) => this.buildTopicExport(t)); return exported; }); + } /** @@ -381,6 +514,9 @@ class MqttManager { * @returns {MqttServer[]} 导入后的服务器列表 */ importServers(serverList) { + + this.assertUnlocked(); + if (!Array.isArray(serverList)) { throw new Error('导入格式错误:必须是服务器配置数组'); } @@ -396,6 +532,7 @@ class MqttManager { this.publishHistory.clear(); serverList.forEach((config) => { + if (!config.name || !config.host) { return; } @@ -411,10 +548,13 @@ class MqttManager { this.servers.set(id, server); this.messages.set(id, []); this.publishHistory.set(id, []); + }); this.saveData(); + return this.getServers(); + } // ==================== 快速发送 ==================== @@ -424,6 +564,7 @@ class MqttManager { * @returns {QuickSendItem[]} 快速发送项数组 */ getQuickSendList() { + this.assertUnlocked(); return this.quickSendList; } @@ -432,13 +573,19 @@ class MqttManager { * @param {QuickSendItem[]} list - 快速发送项数组 */ saveQuickSendList(list) { + + this.assertUnlocked(); + if (!Array.isArray(list)) { return; } + this.quickSendList = list.filter((item) => { return item && typeof item.id === 'string' && typeof item.topic === 'string'; }); + this.saveData(); + } // ==================== 连接管理 ==================== @@ -450,6 +597,8 @@ class MqttManager { */ connect(id) { + this.assertUnlocked(); + let server = this.servers.get(id); if (!server) { @@ -551,6 +700,8 @@ class MqttManager { */ disconnect(id) { + this.assertUnlocked(); + let client = this.clients.get(id); if (client) { @@ -614,10 +765,15 @@ class MqttManager { * @returns {MqttTopic | null} 添加后的主题对象或 null */ addTopic(serverId, topic, qos = 1, comment = '') { + + this.assertUnlocked(); + let server = this.servers.get(serverId); + if (!server) { return null; } + if (server.topics.find((t) => t.topic === topic)) { return null; } @@ -646,6 +802,8 @@ class MqttManager { */ removeTopic(serverId, topic) { + this.assertUnlocked(); + let server = this.servers.get(serverId); if (!server) { @@ -671,6 +829,8 @@ class MqttManager { */ subscribe(serverId, topic) { + this.assertUnlocked(); + let server = this.servers.get(serverId); if (!server) { @@ -695,6 +855,8 @@ class MqttManager { */ unsubscribe(serverId, topic) { + this.assertUnlocked(); + let client = this.clients.get(serverId); let server = this.servers.get(serverId); @@ -727,6 +889,8 @@ class MqttManager { */ updateTopic(serverId, topicId, newTopic, newQos, comment = '') { + this.assertUnlocked(); + let server = this.servers.get(serverId); if (!server) { @@ -785,6 +949,8 @@ class MqttManager { */ subscribeAll(serverId) { + this.assertUnlocked(); + let client = this.clients.get(serverId); let server = this.servers.get(serverId); @@ -835,6 +1001,8 @@ class MqttManager { */ publish(serverId, topic, payload, opts = {}) { + this.assertUnlocked(); + let client = this.clients.get(serverId); let server = this.servers.get(serverId); @@ -914,6 +1082,7 @@ class MqttManager { * @returns {MqttMessage[]} 消息数组 */ getMessages(serverId) { + this.assertUnlocked(); return this.messages.get(serverId) || []; } @@ -923,6 +1092,7 @@ class MqttManager { * @returns {PublishRecord[]} 发布历史数组 */ getPublishHistory(serverId) { + this.assertUnlocked(); return this.publishHistory.get(serverId) || []; } @@ -931,6 +1101,7 @@ class MqttManager { * @param {string} serverId - 服务器 id */ clearMessages(serverId) { + this.assertUnlocked(); this.messages.set(serverId, []); this.saveData(); } @@ -940,6 +1111,7 @@ class MqttManager { * @param {string} serverId - 服务器 id */ clearPublishHistory(serverId) { + this.assertUnlocked(); this.publishHistory.set(serverId, []); this.saveData(); } diff --git a/app/src/main/security.js b/app/src/main/security.js new file mode 100644 index 0000000..49692c5 --- /dev/null +++ b/app/src/main/security.js @@ -0,0 +1,36 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +const KEY_LENGTH = 32; +const VERIFIER = 'frost-mqtt-master-password-v1'; + +export function deriveKey(password, salt) { + return scryptSync(password, salt, KEY_LENGTH, { N: 16384, r: 8, p: 1 }); +} + +export function createPasswordVerifier(key) { + return createHash('sha256').update(key).update(VERIFIER).digest('hex'); +} + +export function verifyPassword(key, verifier) { + let expected = Buffer.from(createPasswordVerifier(key), 'hex'); + let actual = Buffer.from(verifier || '', 'hex'); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +export function encryptSecret(secret, key) { + let iv = randomBytes(12); + let cipher = createCipheriv('aes-256-gcm', key, iv); + let encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); + return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`; +} + +export function decryptSecret(value, key) { + let parts = String(value || '').split(':'); + if (parts.length !== 4 || parts[0] !== 'v1') throw new Error('加密数据格式无效'); + let decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1], 'base64')); + decipher.setAuthTag(Buffer.from(parts[2], 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(parts[3], 'base64')), + decipher.final(), + ]).toString('utf8'); +} diff --git a/app/src/preload/index.js b/app/src/preload/index.js index 713c12e..7799528 100644 --- a/app/src/preload/index.js +++ b/app/src/preload/index.js @@ -8,6 +8,11 @@ import { electronAPI } from '@electron-toolkit/preload'; const api = { // 服务器管理 + getSecurityStatus: () => ipcRenderer.invoke('mqtt:get-security-status'), + setupMasterPassword: (password) => ipcRenderer.invoke('mqtt:setup-master-password', password), + unlock: (password) => ipcRenderer.invoke('mqtt:unlock', password), + lock: () => ipcRenderer.invoke('mqtt:lock'), + removeMasterPassword: (password) => ipcRenderer.invoke('mqtt:remove-master-password', password), getServers: () => ipcRenderer.invoke('mqtt:get-servers'), addServer: (config) => ipcRenderer.invoke('mqtt:add-server', config), updateServer: (id, config) => ipcRenderer.invoke('mqtt:update-server', id, config), diff --git a/app/src/renderer/src/App.vue b/app/src/renderer/src/App.vue index 669d14d..f30fccf 100644 --- a/app/src/renderer/src/App.vue +++ b/app/src/renderer/src/App.vue @@ -1,10 +1,11 @@ + + diff --git a/app/src/renderer/src/components/Sidebar.vue b/app/src/renderer/src/components/Sidebar.vue index 9d8b216..ec15e70 100644 --- a/app/src/renderer/src/components/Sidebar.vue +++ b/app/src/renderer/src/components/Sidebar.vue @@ -4,7 +4,7 @@ import { NLayoutSider, NButton, NTag, useMessage, useDialog } from 'naive-ui'; import { useMqttStore } from '../stores/mqtt.js'; // 组件事件定义 -const emit = defineEmits(['add-server', 'edit-server']); +const emit = defineEmits(['add-server', 'edit-server', 'lock', 'security']); // 全局状态与消息/对话框 API const store = useMqttStore(); @@ -203,6 +203,22 @@ async function handleImportFile(event) { @click="handleExport" >导出配置 + diff --git a/app/src/renderer/src/stores/mqtt.js b/app/src/renderer/src/stores/mqtt.js index c840e05..7f5264b 100644 --- a/app/src/renderer/src/stores/mqtt.js +++ b/app/src/renderer/src/stores/mqtt.js @@ -103,6 +103,8 @@ const publishHistory = ref([]); */ const loading = ref(false); +const security = ref({ enabled: false, locked: false }); + /** * IPC 监听器清理函数集合 * @type {Array<() => void>} @@ -264,6 +266,12 @@ export function useMqttStore() { try { + security.value = await window.api.getSecurityStatus(); + + if (security.value.locked) { + return; + }; + let data = await window.api.getServers(); servers.value = data; @@ -279,6 +287,32 @@ export function useMqttStore() { } + async function unlock(password) { + await window.api.unlock(password); + security.value = await window.api.getSecurityStatus(); + let data = await window.api.getServers(); + servers.value = data; + sortServers(); + servers.value.forEach((srv) => sortTopics(srv)); + setupListeners(); + } + + async function setupMasterPassword(password) { + security.value = await window.api.setupMasterPassword(password); + } + + async function lock() { + security.value = await window.api.lock(); + servers.value = []; + activeServerId.value = null; + messages.value = []; + publishHistory.value = []; + } + + async function removeMasterPassword(password) { + security.value = await window.api.removeMasterPassword(password); + } + // ==================== 服务器管理 ==================== /** @@ -558,6 +592,7 @@ export function useMqttStore() { messages, publishHistory, loading, + security, // 计算属性 connectedServers, totalTopics, @@ -565,6 +600,10 @@ export function useMqttStore() { totalMessages, // 方法 init, + unlock, + setupMasterPassword, + lock, + removeMasterPassword, setActiveServer, addServer, updateServer,