From 1f97292aeb0bf36b7e23d0d86260457991f616d6 Mon Sep 17 00:00:00 2001 From: dalaolala Date: Tue, 3 Mar 2026 12:29:31 +0800 Subject: [PATCH] feat: encrypt sftp connect data --- backend/routes/sftp-quick.js | 18 +++++++- backend/server.js | 12 +++++ backend/utils/crypto.js | 41 +++++++++++++++++ frontend/package-lock.json | 7 +++ frontend/package.json | 1 + frontend/src/composables/useQuickSftp.js | 9 +++- frontend/src/utils/crypto.js | 56 ++++++++++++++++++++++++ 7 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 backend/utils/crypto.js create mode 100644 frontend/src/utils/crypto.js diff --git a/backend/routes/sftp-quick.js b/backend/routes/sftp-quick.js index 4e01f25..a4f5997 100644 --- a/backend/routes/sftp-quick.js +++ b/backend/routes/sftp-quick.js @@ -35,7 +35,23 @@ const sftpConnections = new Map(); // 连接SFTP - 快速连接专属 router.post('/connect', authenticateToken, async (req, res) => { try { - const { host, port, username, password, privateKey } = req.body; + let connectionInfo = req.body; + + // 如果请求体包含 key 和 data,说明是加密传输 + if (connectionInfo.key && connectionInfo.data) { + try { + const cryptoUtil = require('../utils/crypto'); + connectionInfo = cryptoUtil.decryptPayload(connectionInfo.key, connectionInfo.data); + } catch (err) { + console.error('解密载荷失败:', err); + return res.status(400).json({ + success: false, + message: '无效的加密数据结构' + }); + } + } + + const { host, port, username, password, privateKey } = connectionInfo; const userId = req.user.userId; if (!host || !username) { diff --git a/backend/server.js b/backend/server.js index 021de11..46e41e1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,6 +19,9 @@ const db = require('./config/database'); // Socket handlers const socketHandler = require('./socket/socketHandler'); +// Utilities +const cryptoUtil = require('./utils/crypto'); + // Load environment variables dotenv.config(); @@ -44,6 +47,11 @@ app.use('/api/sftp/quick', sftpQuickRoutes); app.use('/api/users', userRoutes); app.use('/api/admin', adminRoutes); +// Crypto Endpoint +app.get('/api/crypto/public-key', (req, res) => { + res.json({ publicKey: cryptoUtil.getPublicKey() }); +}); + // Socket.io connection handling socketHandler(io); @@ -65,6 +73,10 @@ db.authenticate() console.error('Unable to connect to the database:', err); }); +// Initialize RSA keys +cryptoUtil.initKeys(); +console.log('RSA Keys initialized for secure payload transmission.'); + const PORT = process.env.PORT || 3000; httpServer.listen(PORT, () => { console.log(`WebSSH server running on port ${PORT}`); diff --git a/backend/utils/crypto.js b/backend/utils/crypto.js new file mode 100644 index 0000000..7456d0c --- /dev/null +++ b/backend/utils/crypto.js @@ -0,0 +1,41 @@ +const crypto = require('crypto'); + +let publicKey = null; +let privateKey = null; + +function initKeys() { + if (publicKey && privateKey) return; + const { publicKey: pub, privateKey: priv } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + }); + publicKey = pub; + privateKey = priv; +} + +function getPublicKey() { + if (!publicKey) initKeys(); + return publicKey; +} + +function decryptPayload(encryptedAesKeyBase64, encryptedDataBase64) { + if (!privateKey) throw new Error('Keys not initialized'); + // 1. RSA 解密 AES 密钥 + const encryptedAesKeyBuffer = Buffer.from(encryptedAesKeyBase64, 'base64'); + const aesKeyStr = crypto.privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + encryptedAesKeyBuffer + ).toString('utf8'); + + const { key, iv } = JSON.parse(aesKeyStr); + + // 2. AES 解密数据 + const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key, 'hex'), Buffer.from(iv, 'hex')); + let decrypted = decipher.update(encryptedDataBase64, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + + return JSON.parse(decrypted); +} + +module.exports = { initKeys, getPublicKey, decryptPayload }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 17175a3..77609dc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.6.2", "crypto-js": "^4.2.0", "element-plus": "^2.3.14", + "jsencrypt": "^3.5.4", "pinia": "^2.1.7", "socket.io-client": "^4.7.5", "vue": "^3.3.4", @@ -1137,6 +1138,12 @@ "node": ">= 0.4" } }, + "node_modules/jsencrypt": { + "version": "3.5.4", + "resolved": "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.5.4.tgz", + "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", diff --git a/frontend/package.json b/frontend/package.json index 244d783..d773b0a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "axios": "^1.6.2", "crypto-js": "^4.2.0", "element-plus": "^2.3.14", + "jsencrypt": "^3.5.4", "pinia": "^2.1.7", "socket.io-client": "^4.7.5", "vue": "^3.3.4", diff --git a/frontend/src/composables/useQuickSftp.js b/frontend/src/composables/useQuickSftp.js index 31a6e4f..38f493d 100644 --- a/frontend/src/composables/useQuickSftp.js +++ b/frontend/src/composables/useQuickSftp.js @@ -1,5 +1,6 @@ import { ref } from 'vue'; import axios from 'axios'; +import { encryptPayload } from '../utils/crypto'; // 这是一个组合式函数(Composable)而不是全局 Pinia Store // 这样每次调用 useQuickSftp() 都会生成完全独立的响应式状态,非常适合多标签页互不干扰。 @@ -18,13 +19,17 @@ export function useQuickSftp() { loading.value = true; error.value = null; - const response = await axios.post('/api/sftp/quick/connect', { + // 使用 RSA + AES 混合加密传输载荷 + const rawPayload = { host: connectionInfo.host, port: connectionInfo.port, username: connectionInfo.username, password: connectionInfo.password, privateKey: connectionInfo.privateKey - }); + }; + const secureData = await encryptPayload(rawPayload); + + const response = await axios.post('/api/sftp/quick/connect', secureData); if (response.data.success) { isConnected.value = true; diff --git a/frontend/src/utils/crypto.js b/frontend/src/utils/crypto.js new file mode 100644 index 0000000..f754076 --- /dev/null +++ b/frontend/src/utils/crypto.js @@ -0,0 +1,56 @@ +import axios from 'axios'; +import JSEncrypt from 'jsencrypt'; +import CryptoJS from 'crypto-js'; + +let publicKeyCache = null; + +/** + * 获取后端 RSA 公钥 + */ +export async function getPublicKey() { + if (publicKeyCache) return publicKeyCache; + try { + const response = await axios.get('/api/crypto/public-key'); + publicKeyCache = response.data.publicKey; + return publicKeyCache; + } catch (error) { + console.error('获取公钥失败:', error); + throw error; + } +} + +/** + * 混合加密传输数据 (RSA + AES) + * @param {Object} data 原始对象数据 + * @returns {Promise<{key: string, data: string}>} 加密后的 payload + */ +export async function encryptPayload(data) { + const pubKey = await getPublicKey(); + + // 1. 生成一次性 AES-256 密钥 (32字节) 和 IV (16字节) + const aesKeyStr = CryptoJS.lib.WordArray.random(32).toString(CryptoJS.enc.Hex); + const ivStr = CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex); + + // 2. 使用 AES 加密真实数据 + const payloadStr = JSON.stringify(data); + const encryptedData = CryptoJS.AES.encrypt(payloadStr, CryptoJS.enc.Hex.parse(aesKeyStr), { + iv: CryptoJS.enc.Hex.parse(ivStr), + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7 + }).toString(); // 自动输出 base64 + + // 3. 使用 RSA 加密 AES 密钥和 IV + const encryptor = new JSEncrypt(); + encryptor.setPublicKey(pubKey); + const sessionKeyObj = JSON.stringify({ key: aesKeyStr, iv: ivStr }); + const encryptedKey = encryptor.encrypt(sessionKeyObj); + + if (!encryptedKey) { + throw new Error('RSA 加密失败,可能是数据过长或公钥无效'); + } + + return { + key: encryptedKey, + data: encryptedData + }; +}