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