diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ba7202bc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,257 @@ +# Security Guide for Termix + +## Database Encryption + +Termix implements AES-256-GCM encryption for sensitive data stored in the database. This protects SSH credentials, passwords, and authentication tokens from unauthorized access. + +### Encrypted Fields + +The following database fields are automatically encrypted: + +**Users Table:** +- `password_hash` - User password hashes +- `client_secret` - OIDC client secrets +- `totp_secret` - 2FA authentication seeds +- `totp_backup_codes` - 2FA backup codes + +**SSH Data Table:** +- `password` - SSH connection passwords +- `key` - SSH private keys +- `keyPassword` - SSH private key passphrases + +**SSH Credentials Table:** +- `password` - Stored SSH passwords +- `privateKey` - SSH private keys +- `keyPassword` - SSH private key passphrases + +### Configuration + +#### Required Environment Variables + +```bash +# Encryption master key (REQUIRED) +DB_ENCRYPTION_KEY=your-very-strong-encryption-key-32-chars-minimum +``` + +**⚠️ CRITICAL:** The encryption key must be: +- At least 16 characters long (32+ recommended) +- Cryptographically random +- Unique per installation +- Safely backed up + +#### Optional Settings + +```bash +# Enable/disable encryption (default: true) +ENCRYPTION_ENABLED=true + +# Reject unencrypted data (default: false) +FORCE_ENCRYPTION=false + +# Auto-encrypt legacy data (default: true) +MIGRATE_ON_ACCESS=true +``` + +### Initial Setup + +#### 1. Generate Encryption Key + +```bash +# Generate a secure random key (Linux/macOS) +openssl rand -hex 32 + +# Or using Node.js +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +#### 2. Set Environment Variable + +```bash +# Add to your .env file +echo "DB_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +``` + +#### 3. Validate Configuration + +```bash +# Test encryption setup +npm run test:encryption +``` + +### Migration from Unencrypted Database + +If you have an existing Termix installation with unencrypted data: + +#### 1. Backup Your Database + +```bash +# Create backup before migration +cp ./db/data/db.sqlite ./db/data/db-backup-$(date +%Y%m%d-%H%M%S).sqlite +``` + +#### 2. Run Migration + +```bash +# Set encryption key +export DB_ENCRYPTION_KEY="your-secure-key-here" + +# Test migration (dry run) +npm run migrate:encryption -- --dry-run + +# Run actual migration +npm run migrate:encryption +``` + +#### 3. Verify Migration + +```bash +# Check encryption status +curl http://localhost:8081/encryption/status + +# Test application functionality +npm run test:encryption production +``` + +### Security Best Practices + +#### Key Management + +1. **Generate unique keys** for each installation +2. **Store keys securely** (use environment variables, not config files) +3. **Backup keys safely** (encrypted backups in secure locations) +4. **Rotate keys periodically** (implement key rotation schedule) + +#### Deployment Security + +```bash +# Production Docker example +docker run -d \ + -e DB_ENCRYPTION_KEY="$(cat /secure/location/encryption.key)" \ + -e ENCRYPTION_ENABLED=true \ + -e FORCE_ENCRYPTION=true \ + -v termix-data:/app/data \ + ghcr.io/lukegus/termix:latest +``` + +#### File System Protection + +```bash +# Secure database directory permissions +chmod 700 ./db/data/ +chmod 600 ./db/data/db.sqlite + +# Use encrypted storage if possible +# Consider full disk encryption for production +``` + +### Monitoring and Alerting + +#### Health Checks + +The encryption system provides health check endpoints: + +```bash +# Check encryption status +GET /encryption/status + +# Response format: +{ + "encryption": { + "enabled": true, + "configValid": true, + "forceEncryption": false, + "migrateOnAccess": true + }, + "migration": { + "isEncryptionEnabled": true, + "migrationCompleted": true, + "migrationDate": "2024-01-15T10:30:00Z" + } +} +``` + +#### Log Monitoring + +Monitor logs for encryption-related events: + +```bash +# Encryption initialization +"Database encryption initialized successfully" + +# Migration events +"Migration completed for table: users" + +# Security warnings +"DB_ENCRYPTION_KEY not set, using default (INSECURE)" +``` + +### Troubleshooting + +#### Common Issues + +**1. "Decryption failed" errors** +- Verify `DB_ENCRYPTION_KEY` is correct +- Check if database was corrupted +- Restore from backup if necessary + +**2. Performance issues** +- Encryption adds ~1ms per operation +- Consider disabling `MIGRATE_ON_ACCESS` after migration +- Monitor CPU usage during large migrations + +**3. Key rotation** +```bash +# Generate new key +NEW_KEY=$(openssl rand -hex 32) + +# Update configuration +# Note: Requires re-encryption of all data +``` + +### Compliance Notes + +This encryption implementation helps meet requirements for: + +- **GDPR** - Personal data protection +- **SOC 2** - Data security controls +- **PCI DSS** - Sensitive data protection +- **HIPAA** - Healthcare data encryption (if applicable) + +### Security Limitations + +**What this protects against:** +- Database file theft +- Disk access by unauthorized users +- Data breaches from file system access + +**What this does NOT protect against:** +- Application-level vulnerabilities +- Memory dumps while application is running +- Attacks against the running application +- Social engineering attacks + +### Emergency Procedures + +#### Lost Encryption Key + +⚠️ **Data is unrecoverable without the encryption key** + +1. Check all backup locations +2. Restore from unencrypted backup if available +3. Contact system administrators + +#### Suspected Key Compromise + +1. **Immediately** generate new encryption key +2. Take application offline +3. Re-encrypt all sensitive data with new key +4. Investigate compromise source +5. Update security procedures + +### Support + +For security-related questions: +- Open issue: [GitHub Issues](https://github.com/LukeGus/Termix/issues) +- Discord: [Termix Community](https://discord.gg/jVQGdvHDrf) + +**Do not share encryption keys or sensitive debugging information in public channels.** \ No newline at end of file diff --git a/electron/main.cjs b/electron/main.cjs index 7c42cdf5..532eb535 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,6 +1,7 @@ -const { app, BrowserWindow, shell, ipcMain } = require("electron"); +const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron"); const path = require("path"); const fs = require("fs"); +const os = require("os"); let mainWindow = null; @@ -317,8 +318,185 @@ app.on("activate", () => { } }); +// ================== 拖拽功能实现 ================== + +// 临时文件管理 +const tempFiles = new Map(); // 存储临时文件路径映射 + +// 创建临时文件 +ipcMain.handle("create-temp-file", async (event, fileData) => { + try { + const { fileName, content, encoding = 'base64' } = fileData; + + // 创建临时目录 + const tempDir = path.join(os.tmpdir(), 'termix-drag-files'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // 生成临时文件路径 + const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); + const tempFilePath = path.join(tempDir, `${tempId}-${fileName}`); + + // 写入文件内容 + if (encoding === 'base64') { + const buffer = Buffer.from(content, 'base64'); + fs.writeFileSync(tempFilePath, buffer); + } else { + fs.writeFileSync(tempFilePath, content, 'utf8'); + } + + // 记录临时文件 + tempFiles.set(tempId, { + path: tempFilePath, + fileName: fileName, + createdAt: Date.now() + }); + + console.log(`Created temp file: ${tempFilePath}`); + return { success: true, tempId, path: tempFilePath }; + } catch (error) { + console.error("Error creating temp file:", error); + return { success: false, error: error.message }; + } +}); + +// 开始拖拽到桌面 +ipcMain.handle("start-drag-to-desktop", async (event, { tempId, fileName }) => { + try { + const tempFile = tempFiles.get(tempId); + if (!tempFile) { + throw new Error("Temporary file not found"); + } + + // 使用Electron的startDrag API + const iconPath = path.join(__dirname, "..", "public", "icon.png"); + const iconExists = fs.existsSync(iconPath); + + mainWindow.webContents.startDrag({ + file: tempFile.path, + icon: iconExists ? iconPath : undefined + }); + + console.log(`Started drag for: ${tempFile.path}`); + return { success: true }; + } catch (error) { + console.error("Error starting drag:", error); + return { success: false, error: error.message }; + } +}); + +// 清理临时文件 +ipcMain.handle("cleanup-temp-file", async (event, tempId) => { + try { + const tempFile = tempFiles.get(tempId); + if (tempFile && fs.existsSync(tempFile.path)) { + fs.unlinkSync(tempFile.path); + tempFiles.delete(tempId); + console.log(`Cleaned up temp file: ${tempFile.path}`); + } + return { success: true }; + } catch (error) { + console.error("Error cleaning up temp file:", error); + return { success: false, error: error.message }; + } +}); + +// 批量清理过期临时文件(5分钟过期) +const cleanupExpiredTempFiles = () => { + const now = Date.now(); + const maxAge = 5 * 60 * 1000; // 5分钟 + + for (const [tempId, tempFile] of tempFiles.entries()) { + if (now - tempFile.createdAt > maxAge) { + try { + if (fs.existsSync(tempFile.path)) { + fs.unlinkSync(tempFile.path); + } + tempFiles.delete(tempId); + console.log(`Auto-cleaned expired temp file: ${tempFile.path}`); + } catch (error) { + console.error("Error auto-cleaning temp file:", error); + } + } + } +}; + +// 每分钟清理一次过期临时文件 +setInterval(cleanupExpiredTempFiles, 60 * 1000); + +// 创建临时文件夹拖拽支持 +ipcMain.handle("create-temp-folder", async (event, folderData) => { + try { + const { folderName, files } = folderData; + + // 创建临时目录 + const tempDir = path.join(os.tmpdir(), 'termix-drag-folders'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); + const tempFolderPath = path.join(tempDir, `${tempId}-${folderName}`); + + // 递归创建文件夹结构 + const createFolderStructure = (basePath, fileList) => { + for (const file of fileList) { + const fullPath = path.join(basePath, file.relativePath); + const dirPath = path.dirname(fullPath); + + // 确保目录存在 + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + // 写入文件 + if (file.encoding === 'base64') { + const buffer = Buffer.from(file.content, 'base64'); + fs.writeFileSync(fullPath, buffer); + } else { + fs.writeFileSync(fullPath, file.content, 'utf8'); + } + } + }; + + fs.mkdirSync(tempFolderPath, { recursive: true }); + createFolderStructure(tempFolderPath, files); + + // 记录临时文件夹 + tempFiles.set(tempId, { + path: tempFolderPath, + fileName: folderName, + createdAt: Date.now(), + isFolder: true + }); + + console.log(`Created temp folder: ${tempFolderPath}`); + return { success: true, tempId, path: tempFolderPath }; + } catch (error) { + console.error("Error creating temp folder:", error); + return { success: false, error: error.message }; + } +}); + app.on("before-quit", () => { console.log("App is quitting..."); + + // 清理所有临时文件 + for (const [tempId, tempFile] of tempFiles.entries()) { + try { + if (fs.existsSync(tempFile.path)) { + if (tempFile.isFolder) { + fs.rmSync(tempFile.path, { recursive: true, force: true }); + } else { + fs.unlinkSync(tempFile.path); + } + } + } catch (error) { + console.error("Error cleaning up temp file on quit:", error); + } + } + tempFiles.clear(); }); app.on("will-quit", () => { diff --git a/electron/preload.js b/electron/preload.js index e1e436d8..02565c9c 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -22,6 +22,20 @@ contextBridge.exposeInMainWorld("electronAPI", { isDev: process.env.NODE_ENV === "development", invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + + // ================== 拖拽API ================== + + // 创建临时文件用于拖拽 + createTempFile: (fileData) => ipcRenderer.invoke("create-temp-file", fileData), + + // 创建临时文件夹用于拖拽 + createTempFolder: (folderData) => ipcRenderer.invoke("create-temp-folder", folderData), + + // 开始拖拽到桌面 + startDragToDesktop: (dragData) => ipcRenderer.invoke("start-drag-to-desktop", dragData), + + // 清理临时文件 + cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId), }); window.IS_ELECTRON = true; diff --git a/package-lock.json b/package-lock.json index d34f84e5..6c5a9cdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.6.0", "dependencies": { "@hookform/resolvers": "^5.1.1", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", @@ -28,6 +29,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", "@types/speakeasy": "^2.0.10", @@ -58,6 +60,7 @@ "i18next-http-backend": "^3.0.2", "jose": "^5.2.3", "jsonwebtoken": "^9.0.2", + "jszip": "^3.10.1", "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", @@ -68,6 +71,7 @@ "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-i18next": "^15.7.3", + "react-icons": "^5.5.0", "react-resizable-panels": "^3.0.3", "react-simple-keyboard": "^3.8.120", "react-xtermjs": "^1.0.10", @@ -2949,6 +2953,29 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@monaco-editor/loader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5149,6 +5176,15 @@ "@types/node": "*" } }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -5310,6 +5346,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", + "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -7588,7 +7631,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -10635,6 +10677,12 @@ "dev": true, "license": "MIT" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -10930,7 +10978,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/isbinaryfile": { @@ -11194,6 +11241,48 @@ "extsprintf": "^1.2.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/junk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", @@ -11296,6 +11385,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -12233,6 +12331,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/monaco-editor": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", + "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/trusted-types": "^1.0.6" + } + }, "node_modules/mri": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", @@ -12740,7 +12848,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -13371,7 +13478,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -13663,6 +13769,15 @@ } } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", @@ -14432,6 +14547,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14881,6 +15002,12 @@ "node": ">= 6" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 78bd621d..37c38f52 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,13 @@ "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"", "build:win-portable": "npm run build && electron-builder --win --dir", "build:win-installer": "npm run build && electron-builder --win --publish=never", - "build:linux-portable": "npm run build && electron-builder --linux --dir" + "build:linux-portable": "npm run build && electron-builder --linux --dir", + "test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js", + "migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js" }, "dependencies": { "@hookform/resolvers": "^5.1.1", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", @@ -41,6 +44,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", "@types/speakeasy": "^2.0.10", @@ -71,6 +75,7 @@ "i18next-http-backend": "^3.0.2", "jose": "^5.2.3", "jsonwebtoken": "^9.0.2", + "jszip": "^3.10.1", "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", @@ -81,6 +86,7 @@ "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-i18next": "^15.7.3", + "react-icons": "^5.5.0", "react-resizable-panels": "^3.0.3", "react-simple-keyboard": "^3.8.120", "react-xtermjs": "^1.0.10", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 50f169db..710b6ee3 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1,5 +1,6 @@ import express from "express"; import bodyParser from "body-parser"; +import multer from "multer"; import userRoutes from "./routes/users.js"; import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; @@ -10,6 +11,11 @@ import fs from "fs"; import path from "path"; import "dotenv/config"; import { databaseLogger, apiLogger } from "../utils/logger.js"; +import { DatabaseEncryption } from "../utils/database-encryption.js"; +import { EncryptionMigration } from "../utils/encryption-migration.js"; +import { DatabaseMigration } from "../utils/database-migration.js"; +import { DatabaseSQLiteExport } from "../utils/database-sqlite-export.js"; +import { DatabaseFileEncryption } from "../utils/database-file-encryption.js"; const app = express(); app.use( @@ -25,6 +31,33 @@ app.use( }), ); +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, 'uploads/'); + }, + filename: (req, file, cb) => { + // Preserve original filename with timestamp prefix to avoid conflicts + const timestamp = Date.now(); + cb(null, `${timestamp}-${file.originalname}`); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 100 * 1024 * 1024, // 100MB limit + }, + fileFilter: (req, file, cb) => { + // Allow SQLite files + if (file.originalname.endsWith('.termix-export.sqlite') || file.originalname.endsWith('.sqlite')) { + cb(null, true); + } else { + cb(new Error('Only .termix-export.sqlite files are allowed')); + } + } +}); + interface CacheEntry { data: any; timestamp: number; @@ -255,6 +288,336 @@ app.get("/releases/rss", async (req, res) => { } }); +app.get("/encryption/status", async (req, res) => { + try { + const detailedStatus = await DatabaseEncryption.getDetailedStatus(); + const migrationStatus = await EncryptionMigration.checkMigrationStatus(); + + res.json({ + encryption: detailedStatus, + migration: migrationStatus + }); + } catch (error) { + apiLogger.error("Failed to get encryption status", error, { + operation: "encryption_status" + }); + res.status(500).json({ error: "Failed to get encryption status" }); + } +}); + +app.post("/encryption/initialize", async (req, res) => { + try { + const { EncryptionKeyManager } = await import("../utils/encryption-key-manager.js"); + const keyManager = EncryptionKeyManager.getInstance(); + + const newKey = await keyManager.generateNewKey(); + await DatabaseEncryption.initialize({ masterPassword: newKey }); + + apiLogger.info("Encryption initialized via API", { + operation: "encryption_init_api" + }); + + res.json({ + success: true, + message: "Encryption initialized successfully", + keyPreview: newKey.substring(0, 8) + "..." + }); + } catch (error) { + apiLogger.error("Failed to initialize encryption", error, { + operation: "encryption_init_api_failed" + }); + res.status(500).json({ error: "Failed to initialize encryption" }); + } +}); + +app.post("/encryption/migrate", async (req, res) => { + try { + const { dryRun = false } = req.body; + + const migration = new EncryptionMigration({ + dryRun, + backupEnabled: true + }); + + if (dryRun) { + apiLogger.info("Starting encryption migration (dry run)", { + operation: "encryption_migrate_dry_run" + }); + + res.json({ + success: true, + message: "Dry run mode - no changes made", + dryRun: true + }); + } else { + apiLogger.info("Starting encryption migration", { + operation: "encryption_migrate" + }); + + await migration.runMigration(); + + res.json({ + success: true, + message: "Migration completed successfully" + }); + } + } catch (error) { + apiLogger.error("Migration failed", error, { + operation: "encryption_migrate_failed" + }); + res.status(500).json({ + error: "Migration failed", + details: error instanceof Error ? error.message : "Unknown error" + }); + } +}); + +app.post("/encryption/regenerate", async (req, res) => { + try { + await DatabaseEncryption.reinitializeWithNewKey(); + + apiLogger.warn("Encryption key regenerated via API", { + operation: "encryption_regenerate_api" + }); + + res.json({ + success: true, + message: "New encryption key generated", + warning: "All encrypted data must be re-encrypted" + }); + } catch (error) { + apiLogger.error("Failed to regenerate encryption key", error, { + operation: "encryption_regenerate_failed" + }); + res.status(500).json({ error: "Failed to regenerate encryption key" }); + } +}); + +// Database migration and backup endpoints +app.post("/database/export", async (req, res) => { + try { + const { customPath } = req.body; + + apiLogger.info("Starting SQLite database export via API", { + operation: "database_sqlite_export_api", + customPath: !!customPath + }); + + const exportPath = await DatabaseSQLiteExport.exportDatabase(customPath); + + res.json({ + success: true, + message: "Database exported successfully as SQLite", + exportPath, + size: fs.statSync(exportPath).size, + format: "sqlite" + }); + } catch (error) { + apiLogger.error("SQLite database export failed", error, { + operation: "database_sqlite_export_api_failed" + }); + res.status(500).json({ + error: "SQLite database export failed", + details: error instanceof Error ? error.message : "Unknown error" + }); + } +}); + +app.post("/database/import", upload.single('file'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const { backupCurrent = "true" } = req.body; + const backupCurrentBool = backupCurrent === "true"; + const importPath = req.file.path; + + apiLogger.info("Starting SQLite database import via API (additive mode)", { + operation: "database_sqlite_import_api", + importPath, + originalName: req.file.originalname, + fileSize: req.file.size, + mode: "additive", + backupCurrent: backupCurrentBool + }); + + // Validate export file first + // Check file extension using original filename + if (!req.file.originalname.endsWith('.termix-export.sqlite')) { + // Clean up uploaded file + fs.unlinkSync(importPath); + return res.status(400).json({ + error: "Invalid SQLite export file", + details: ["File must have .termix-export.sqlite extension"] + }); + } + + const validation = DatabaseSQLiteExport.validateExportFile(importPath); + if (!validation.valid) { + // Clean up uploaded file + fs.unlinkSync(importPath); + return res.status(400).json({ + error: "Invalid SQLite export file", + details: validation.errors + }); + } + + const result = await DatabaseSQLiteExport.importDatabase(importPath, { + replaceExisting: false, // Always use additive mode + backupCurrent: backupCurrentBool + }); + + // Clean up uploaded file + fs.unlinkSync(importPath); + + res.json({ + success: result.success, + message: result.success ? "SQLite database imported successfully" : "SQLite database import completed with errors", + imported: result.imported, + errors: result.errors, + warnings: result.warnings, + format: "sqlite" + }); + } catch (error) { + // Clean up uploaded file if it exists + if (req.file?.path) { + try { + fs.unlinkSync(req.file.path); + } catch (cleanupError) { + apiLogger.warn("Failed to clean up uploaded file", { + operation: "file_cleanup_failed", + filePath: req.file.path, + error: cleanupError instanceof Error ? cleanupError.message : 'Unknown error' + }); + } + } + + apiLogger.error("SQLite database import failed", error, { + operation: "database_sqlite_import_api_failed" + }); + res.status(500).json({ + error: "SQLite database import failed", + details: error instanceof Error ? error.message : "Unknown error" + }); + } +}); + +app.get("/database/export/:exportPath/info", async (req, res) => { + try { + const { exportPath } = req.params; + const decodedPath = decodeURIComponent(exportPath); + + const validation = DatabaseSQLiteExport.validateExportFile(decodedPath); + if (!validation.valid) { + return res.status(400).json({ + error: "Invalid SQLite export file", + details: validation.errors + }); + } + + res.json({ + valid: true, + metadata: validation.metadata, + format: "sqlite" + }); + } catch (error) { + apiLogger.error("Failed to get SQLite export info", error, { + operation: "sqlite_export_info_failed" + }); + res.status(500).json({ error: "Failed to get SQLite export information" }); + } +}); + +app.post("/database/backup", async (req, res) => { + try { + const { customPath } = req.body; + + apiLogger.info("Creating encrypted database backup via API", { + operation: "database_backup_api" + }); + + // Import required modules + const { databasePaths, getMemoryDatabaseBuffer } = await import("./db/index.js"); + + // Get current in-memory database as buffer + const dbBuffer = getMemoryDatabaseBuffer(); + + // Create backup directory + const backupDir = customPath || path.join(databasePaths.directory, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + // Generate backup filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`; + const backupPath = path.join(backupDir, backupFileName); + + // Create encrypted backup directly from memory buffer + DatabaseFileEncryption.encryptDatabaseFromBuffer(dbBuffer, backupPath); + + res.json({ + success: true, + message: "Encrypted backup created successfully", + backupPath, + size: fs.statSync(backupPath).size + }); + } catch (error) { + apiLogger.error("Database backup failed", error, { + operation: "database_backup_api_failed" + }); + res.status(500).json({ + error: "Database backup failed", + details: error instanceof Error ? error.message : "Unknown error" + }); + } +}); + +app.post("/database/restore", async (req, res) => { + try { + const { backupPath, targetPath } = req.body; + + if (!backupPath) { + return res.status(400).json({ error: "Backup path is required" }); + } + + apiLogger.info("Restoring database from backup via API", { + operation: "database_restore_api", + backupPath + }); + + // Validate backup file + if (!DatabaseFileEncryption.isEncryptedDatabaseFile(backupPath)) { + return res.status(400).json({ error: "Invalid encrypted backup file" }); + } + + // Check hardware compatibility + if (!DatabaseFileEncryption.validateHardwareCompatibility(backupPath)) { + return res.status(400).json({ + error: "Hardware fingerprint mismatch", + message: "This backup was created on different hardware and cannot be restored" + }); + } + + const restoredPath = DatabaseFileEncryption.restoreFromEncryptedBackup(backupPath, targetPath); + + res.json({ + success: true, + message: "Database restored successfully", + restoredPath + }); + } catch (error) { + apiLogger.error("Database restore failed", error, { + operation: "database_restore_api_failed" + }); + res.status(500).json({ + error: "Database restore failed", + details: error instanceof Error ? error.message : "Unknown error" + }); + } +}); + app.use("/users", userRoutes); app.use("/ssh", sshRoutes); app.use("/alerts", alertRoutes); @@ -278,7 +641,49 @@ app.use( ); const PORT = 8081; -app.listen(PORT, () => { + +async function initializeEncryption() { + try { + databaseLogger.info("Initializing database encryption...", { + operation: "encryption_init" + }); + + await DatabaseEncryption.initialize({ + encryptionEnabled: process.env.ENCRYPTION_ENABLED !== 'false', + forceEncryption: process.env.FORCE_ENCRYPTION === 'true', + migrateOnAccess: process.env.MIGRATE_ON_ACCESS !== 'false' + }); + + const status = await DatabaseEncryption.getDetailedStatus(); + if (status.configValid && status.key.keyValid) { + databaseLogger.success("Database encryption initialized successfully", { + operation: "encryption_init_complete", + enabled: status.enabled, + keyId: status.key.keyId, + hasStoredKey: status.key.hasKey + }); + } else { + databaseLogger.error("Database encryption configuration invalid", undefined, { + operation: "encryption_init_failed", + status + }); + } + } catch (error) { + databaseLogger.error("Failed to initialize database encryption", error, { + operation: "encryption_init_error" + }); + } +} + +app.listen(PORT, async () => { + // Ensure uploads directory exists + const uploadsDir = path.join(process.cwd(), 'uploads'); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + await initializeEncryption(); + databaseLogger.success(`Database API server started on port ${PORT}`, { operation: "server_start", port: PORT, @@ -290,6 +695,15 @@ app.listen(PORT, () => { "/health", "/version", "/releases/rss", + "/encryption/status", + "/encryption/initialize", + "/encryption/migrate", + "/encryption/regenerate", + "/database/export", + "/database/import", + "/database/export/:exportPath/info", + "/database/backup", + "/database/restore", ], }); }); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 1dd17218..c0d943df 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -4,6 +4,7 @@ import * as schema from "./schema.js"; import fs from "fs"; import path from "path"; import { databaseLogger } from "../../utils/logger.js"; +import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"; const dataDir = process.env.DATA_DIR || "./db/data"; const dbDir = path.resolve(dataDir); @@ -15,12 +16,139 @@ if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } +// Database file encryption configuration +const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== 'false'; const dbPath = path.join(dataDir, "db.sqlite"); +const encryptedDbPath = `${dbPath}.encrypted`; + +// Initialize database with file encryption support +let actualDbPath = ':memory:'; // Always use memory database +let memoryDatabase: Database.Database; +let isNewDatabase = false; + +if (enableFileEncryption) { + try { + // Check if encrypted database exists + if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) { + databaseLogger.info('Found encrypted database file, loading into memory...', { + operation: 'db_memory_load', + encryptedPath: encryptedDbPath + }); + + // Validate hardware compatibility + if (!DatabaseFileEncryption.validateHardwareCompatibility(encryptedDbPath)) { + databaseLogger.error('Hardware fingerprint mismatch for encrypted database', { + operation: 'db_decrypt_failed', + reason: 'hardware_mismatch' + }); + throw new Error('Cannot decrypt database: hardware fingerprint mismatch'); + } + + // Decrypt database content to memory buffer + const decryptedBuffer = DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath); + + // Create in-memory database from decrypted buffer + memoryDatabase = new Database(decryptedBuffer); + + databaseLogger.success('Existing database loaded into memory successfully', { + operation: 'db_memory_load_success', + bufferSize: decryptedBuffer.length, + inMemory: true + }); + } else { + // No encrypted database exists - create new in-memory database + databaseLogger.info('No encrypted database found, creating new in-memory database', { + operation: 'db_memory_create_new' + }); + + memoryDatabase = new Database(':memory:'); + isNewDatabase = true; + + // Check if there's an old unencrypted database to migrate + if (fs.existsSync(dbPath)) { + databaseLogger.info('Found existing unencrypted database, will migrate to memory', { + operation: 'db_migrate_to_memory', + oldPath: dbPath + }); + + // Load old database and copy its content to memory database + const oldDb = new Database(dbPath, { readonly: true }); + + // Get all table schemas and data from old database + const tables = oldDb.prepare(` + SELECT name, sql FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + `).all() as { name: string; sql: string }[]; + + // Create tables in memory database + for (const table of tables) { + memoryDatabase.exec(table.sql); + } + + // Copy data for each table + for (const table of tables) { + const rows = oldDb.prepare(`SELECT * FROM ${table.name}`).all(); + if (rows.length > 0) { + const columns = Object.keys(rows[0]); + const placeholders = columns.map(() => '?').join(', '); + const insertStmt = memoryDatabase.prepare( + `INSERT INTO ${table.name} (${columns.join(', ')}) VALUES (${placeholders})` + ); + + for (const row of rows) { + const values = columns.map(col => (row as any)[col]); + insertStmt.run(values); + } + } + } + + oldDb.close(); + + databaseLogger.success('Migrated existing database to memory', { + operation: 'db_migrate_to_memory_success' + }); + isNewDatabase = false; + } else { + databaseLogger.success('Created new in-memory database', { + operation: 'db_memory_create_success' + }); + } + } + } catch (error) { + databaseLogger.error('Failed to initialize memory database', error, { + operation: 'db_memory_init_failed' + }); + + // If file encryption is critical, fail fast + if (process.env.DB_FILE_ENCRYPTION_REQUIRED === 'true') { + throw error; + } + + // Create fallback in-memory database + databaseLogger.warn('Creating fallback in-memory database', { + operation: 'db_memory_fallback' + }); + memoryDatabase = new Database(':memory:'); + isNewDatabase = true; + } +} else { + // File encryption disabled - still use memory for consistency + databaseLogger.info('File encryption disabled, using in-memory database', { + operation: 'db_memory_no_encryption' + }); + memoryDatabase = new Database(':memory:'); + isNewDatabase = true; +} + databaseLogger.info(`Initializing SQLite database`, { operation: "db_init", - path: dbPath, + path: actualDbPath, + encrypted: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath), + inMemory: true, + isNewDatabase }); -const sqlite = new Database(dbPath); + +const sqlite = memoryDatabase; sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( @@ -250,6 +378,17 @@ const migrateSchema = () => { "INTEGER REFERENCES ssh_credentials(id)", ); + addColumnIfNotExists( + "ssh_data", + "require_password", + "INTEGER NOT NULL DEFAULT 1", + ); + + // SSH credentials table migrations for encryption support + addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); + addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); + addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); + addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); @@ -259,7 +398,7 @@ const migrateSchema = () => { }); }; -const initializeDatabase = async () => { +const initializeDatabase = async (): Promise => { migrateSchema(); try { @@ -292,15 +431,229 @@ const initializeDatabase = async () => { } }; -initializeDatabase().catch((error) => { - databaseLogger.error("Failed to initialize database", error, { - operation: "db_init", +// Function to save in-memory database to encrypted file +async function saveMemoryDatabaseToFile() { + if (!memoryDatabase || !enableFileEncryption) return; + + try { + // Export in-memory database to buffer + const buffer = memoryDatabase.serialize(); + + // Encrypt and save to file + DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath); + + databaseLogger.debug('In-memory database saved to encrypted file', { + operation: 'memory_db_save', + bufferSize: buffer.length, + encryptedPath: encryptedDbPath + }); + } catch (error) { + databaseLogger.error('Failed to save in-memory database', error, { + operation: 'memory_db_save_failed' + }); + } +} + +// Function to handle post-initialization file encryption and cleanup +async function handlePostInitFileEncryption() { + if (!enableFileEncryption) return; + + try { + // Clean up any existing unencrypted database files + if (fs.existsSync(dbPath)) { + databaseLogger.warn('Found unencrypted database file, removing for security', { + operation: 'db_security_cleanup_existing', + removingPath: dbPath + }); + + try { + fs.unlinkSync(dbPath); + databaseLogger.success('Unencrypted database file removed for security', { + operation: 'db_security_cleanup_complete', + removedPath: dbPath + }); + } catch (error) { + databaseLogger.warn('Could not remove unencrypted database file (may be locked)', { + operation: 'db_security_cleanup_deferred', + path: dbPath, + error: error instanceof Error ? error.message : 'Unknown error' + }); + + // Try again after a short delay + setTimeout(() => { + try { + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); + databaseLogger.success('Delayed cleanup: unencrypted database file removed', { + operation: 'db_security_cleanup_delayed_success', + removedPath: dbPath + }); + } + } catch (delayedError) { + databaseLogger.error('Failed to remove unencrypted database file even after delay', delayedError, { + operation: 'db_security_cleanup_delayed_failed', + path: dbPath + }); + } + }, 2000); + } + } + + // Always save the in-memory database (whether new or existing) + if (memoryDatabase) { + // Save immediately after initialization + await saveMemoryDatabaseToFile(); + + // Set up periodic saves every 5 minutes + setInterval(saveMemoryDatabaseToFile, 5 * 60 * 1000); + + databaseLogger.info('Periodic in-memory database saves configured', { + operation: 'memory_db_autosave_setup', + intervalMinutes: 5 + }); + } + } catch (error) { + databaseLogger.error('Failed to handle database file encryption/cleanup', error, { + operation: 'db_encrypt_cleanup_failed' + }); + + // Don't fail the entire initialization for this + } +} + +initializeDatabase() + .then(() => handlePostInitFileEncryption()) + .catch((error) => { + databaseLogger.error("Failed to initialize database", error, { + operation: "db_init", + }); + process.exit(1); }); - process.exit(1); -}); databaseLogger.success("Database connection established", { operation: "db_init", - path: dbPath, + path: actualDbPath, + hasEncryptedBackup: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath) }); + +// Cleanup function for database and temporary files +async function cleanupDatabase() { + // Save in-memory database before closing + if (memoryDatabase) { + try { + await saveMemoryDatabaseToFile(); + databaseLogger.info('In-memory database saved before shutdown', { + operation: 'shutdown_save' + }); + } catch (error) { + databaseLogger.error('Failed to save in-memory database before shutdown', error, { + operation: 'shutdown_save_failed' + }); + } + } + + // Close database connection + try { + if (sqlite) { + sqlite.close(); + databaseLogger.debug('Database connection closed', { + operation: 'db_close' + }); + } + } catch (error) { + databaseLogger.warn('Error closing database connection', { + operation: 'db_close_error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + + // Clean up temp directory + try { + const tempDir = path.join(dataDir, '.temp'); + if (fs.existsSync(tempDir)) { + const files = fs.readdirSync(tempDir); + for (const file of files) { + try { + fs.unlinkSync(path.join(tempDir, file)); + } catch { + // Ignore individual file cleanup errors + } + } + + try { + fs.rmdirSync(tempDir); + databaseLogger.debug('Temp directory cleaned up', { + operation: 'temp_dir_cleanup' + }); + } catch { + // Ignore directory removal errors + } + } + } catch (error) { + // Ignore temp directory cleanup errors + } +} + +// Register cleanup handlers +process.on('exit', () => { + // Synchronous cleanup only for exit event + if (sqlite) { + try { + sqlite.close(); + } catch {} + } +}); + +process.on('SIGINT', async () => { + databaseLogger.info('Received SIGINT, cleaning up...', { + operation: 'shutdown' + }); + await cleanupDatabase(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + databaseLogger.info('Received SIGTERM, cleaning up...', { + operation: 'shutdown' + }); + await cleanupDatabase(); + process.exit(0); +}); + +// Export database connection and file encryption utilities export const db = drizzle(sqlite, { schema }); +export const sqliteInstance = sqlite; // Export underlying SQLite instance for schema queries +export { DatabaseFileEncryption }; +export const databasePaths = { + main: actualDbPath, + encrypted: encryptedDbPath, + directory: dbDir, + inMemory: true +}; + +// Memory database buffer function +function getMemoryDatabaseBuffer(): Buffer { + if (!memoryDatabase) { + throw new Error('Memory database not initialized'); + } + + try { + // Export in-memory database to buffer + const buffer = memoryDatabase.serialize(); + + databaseLogger.debug('Memory database serialized to buffer', { + operation: 'memory_db_serialize', + bufferSize: buffer.length + }); + + return buffer; + } catch (error) { + databaseLogger.error('Failed to serialize memory database to buffer', error, { + operation: 'memory_db_serialize_failed' + }); + throw error; + } +} + +// Export save function for manual saves and buffer access +export { saveMemoryDatabaseToFile, getMemoryDatabaseBuffer }; diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 9e46d73a..dd764c22 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -45,6 +45,9 @@ export const sshData = sqliteTable("ssh_data", { authType: text("auth_type").notNull(), password: text("password"), + requirePassword: integer("require_password", { mode: "boolean" }) + .notNull() + .default(true), key: text("key", { length: 8192 }), keyPassword: text("key_password"), keyType: text("key_type"), @@ -137,9 +140,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", { authType: text("auth_type").notNull(), username: text("username").notNull(), password: text("password"), - key: text("key", { length: 16384 }), + key: text("key", { length: 16384 }), // backward compatibility + privateKey: text("private_key", { length: 16384 }), + publicKey: text("public_key", { length: 4096 }), keyPassword: text("key_password"), keyType: text("key_type"), + detectedKeyType: text("detected_key_type"), usageCount: integer("usage_count").notNull().default(0), lastUsed: text("last_used"), createdAt: text("created_at") diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index b6dbb62c..8b5f2092 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -5,6 +5,57 @@ import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; import { authLogger } from "../../utils/logger.js"; +import { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js"; +import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js"; +import crypto from "crypto"; +import ssh2Pkg from "ssh2"; +const { utils: ssh2Utils, Client } = ssh2Pkg; + +// Direct SSH key generation with ssh2 - the right way +function generateSSHKeyPair(keyType: string, keySize?: number, passphrase?: string): { success: boolean; privateKey?: string; publicKey?: string; error?: string } { + console.log('Generating SSH key pair with ssh2:', keyType); + + try { + // Convert our keyType to ssh2 format + let ssh2Type = keyType; + const options: any = {}; + + if (keyType === 'ssh-rsa') { + ssh2Type = 'rsa'; + options.bits = keySize || 2048; + } else if (keyType === 'ssh-ed25519') { + ssh2Type = 'ed25519'; + } else if (keyType === 'ecdsa-sha2-nistp256') { + ssh2Type = 'ecdsa'; + options.bits = 256; // ECDSA P-256 uses 256 bits + } + + // Add passphrase protection if provided + if (passphrase && passphrase.trim()) { + options.passphrase = passphrase; + options.cipher = 'aes128-cbc'; // Default cipher for encrypted private keys + } + + // Use ssh2's native key generation + const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options); + + console.log('SSH key pair generated successfully!'); + console.log('Private key length:', keyPair.private.length); + console.log('Public key preview:', keyPair.public.substring(0, 50) + '...'); + + return { + success: true, + privateKey: keyPair.private, + publicKey: keyPair.public + }; + } catch (error) { + console.error('SSH key generation failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'SSH key generation failed' + }; + } +} const router = express.Router(); @@ -109,6 +160,22 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { const plainKeyPassword = authType === "key" && keyPassword ? keyPassword : null; + let keyInfo = null; + if (authType === "key" && plainKey) { + keyInfo = parseSSHKey(plainKey, plainKeyPassword); + if (!keyInfo.success) { + authLogger.warn("SSH key parsing failed", { + operation: "credential_create", + userId, + name, + error: keyInfo.error, + }); + return res.status(400).json({ + error: `Invalid SSH key: ${keyInfo.error}` + }); + } + } + const credentialData = { userId, name: name.trim(), @@ -118,18 +185,21 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { authType, username: username.trim(), password: plainPassword, - key: plainKey, + key: plainKey, // backward compatibility + privateKey: keyInfo?.privateKey || plainKey, + publicKey: keyInfo?.publicKey || null, keyPassword: plainKeyPassword, keyType: keyType || null, + detectedKeyType: keyInfo?.keyType || null, usageCount: 0, lastUsed: null, }; - const result = await db - .insert(sshCredentials) - .values(credentialData) - .returning(); - const created = result[0]; + const created = await EncryptedDBOperations.insert( + sshCredentials, + 'ssh_credentials', + credentialData + ) as typeof credentialData & { id: number }; authLogger.success( `SSH credential created: ${name} (${authType}) by user ${userId}`, @@ -169,11 +239,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { } try { - const credentials = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .orderBy(desc(sshCredentials.updatedAt)); + const credentials = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where(eq(sshCredentials.userId, userId)).orderBy(desc(sshCredentials.updatedAt)), + 'ssh_credentials' + ); res.json(credentials.map((cred) => formatCredentialOutput(cred))); } catch (err) { @@ -227,15 +296,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => { } try { - const credentials = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), - ); + const credentials = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where(and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId), + )), + 'ssh_credentials' + ); if (credentials.length === 0) { return res.status(404).json({ error: "Credential not found" }); @@ -248,7 +315,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => { (output as any).password = credential.password; } if (credential.key) { - (output as any).key = credential.key; + (output as any).key = credential.key; // backward compatibility + } + if (credential.privateKey) { + (output as any).privateKey = credential.privateKey; + } + if (credential.publicKey) { + (output as any).publicKey = credential.publicKey; } if (credential.keyPassword) { (output as any).keyPassword = credential.keyPassword; @@ -314,35 +387,54 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => { updateFields.password = updateData.password || null; } if (updateData.key !== undefined) { - updateFields.key = updateData.key || null; + updateFields.key = updateData.key || null; // backward compatibility + + // Parse SSH key if provided + if (updateData.key && existing[0].authType === "key") { + const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword); + if (!keyInfo.success) { + authLogger.warn("SSH key parsing failed during update", { + operation: "credential_update", + userId, + credentialId: parseInt(id), + error: keyInfo.error, + }); + return res.status(400).json({ + error: `Invalid SSH key: ${keyInfo.error}` + }); + } + updateFields.privateKey = keyInfo.privateKey; + updateFields.publicKey = keyInfo.publicKey; + updateFields.detectedKeyType = keyInfo.keyType; + } } if (updateData.keyPassword !== undefined) { updateFields.keyPassword = updateData.keyPassword || null; } if (Object.keys(updateFields).length === 0) { - const existing = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))); + const existing = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where(eq(sshCredentials.id, parseInt(id))), + 'ssh_credentials' + ); return res.json(formatCredentialOutput(existing[0])); } - await db - .update(sshCredentials) - .set(updateFields) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), - ); + await EncryptedDBOperations.update( + sshCredentials, + 'ssh_credentials', + and( + eq(sshCredentials.id, parseInt(id)), + eq(sshCredentials.userId, userId), + ), + updateFields + ); - const updated = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))); + const updated = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where(eq(sshCredentials.id, parseInt(id))), + 'ssh_credentials' + ); const credential = updated[0]; authLogger.success( @@ -584,7 +676,9 @@ function formatCredentialOutput(credential: any): any { : [], authType: credential.authType, username: credential.username, + publicKey: credential.publicKey, keyType: credential.keyType, + detectedKeyType: credential.detectedKeyType, usageCount: credential.usageCount || 0, lastUsed: credential.lastUsed, createdAt: credential.createdAt, @@ -661,4 +755,642 @@ router.put( }, ); +// Detect SSH key type endpoint +// POST /credentials/detect-key-type +router.post("/detect-key-type", authenticateJWT, async (req: Request, res: Response) => { + const { privateKey, keyPassword } = req.body; + + console.log("=== Key Detection API Called ==="); + console.log("Request body keys:", Object.keys(req.body)); + console.log("Private key provided:", !!privateKey); + console.log("Private key type:", typeof privateKey); + + if (!privateKey || typeof privateKey !== "string") { + console.log("Invalid private key provided"); + return res.status(400).json({ error: "Private key is required" }); + } + + try { + console.log("Calling parseSSHKey..."); + const keyInfo = parseSSHKey(privateKey, keyPassword); + console.log("parseSSHKey result:", keyInfo); + + const response = { + success: keyInfo.success, + keyType: keyInfo.keyType, + detectedKeyType: keyInfo.keyType, + hasPublicKey: !!keyInfo.publicKey, + error: keyInfo.error || null + }; + + console.log("Sending response:", response); + res.json(response); + } catch (error) { + console.error("Exception in detect-key-type endpoint:", error); + authLogger.error("Failed to detect key type", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to detect key type" + }); + } +}); + +// Detect SSH public key type endpoint +// POST /credentials/detect-public-key-type +router.post("/detect-public-key-type", authenticateJWT, async (req: Request, res: Response) => { + const { publicKey } = req.body; + + console.log("=== Public Key Detection API Called ==="); + console.log("Request body keys:", Object.keys(req.body)); + console.log("Public key provided:", !!publicKey); + console.log("Public key type:", typeof publicKey); + + if (!publicKey || typeof publicKey !== "string") { + console.log("Invalid public key provided"); + return res.status(400).json({ error: "Public key is required" }); + } + + try { + console.log("Calling parsePublicKey..."); + const keyInfo = parsePublicKey(publicKey); + console.log("parsePublicKey result:", keyInfo); + + const response = { + success: keyInfo.success, + keyType: keyInfo.keyType, + detectedKeyType: keyInfo.keyType, + error: keyInfo.error || null + }; + + console.log("Sending response:", response); + res.json(response); + } catch (error) { + console.error("Exception in detect-public-key-type endpoint:", error); + authLogger.error("Failed to detect public key type", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to detect public key type" + }); + } +}); + +// Validate SSH key pair endpoint +// POST /credentials/validate-key-pair +router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Response) => { + const { privateKey, publicKey, keyPassword } = req.body; + + console.log("=== Key Pair Validation API Called ==="); + console.log("Request body keys:", Object.keys(req.body)); + console.log("Private key provided:", !!privateKey); + console.log("Public key provided:", !!publicKey); + + if (!privateKey || typeof privateKey !== "string") { + console.log("Invalid private key provided"); + return res.status(400).json({ error: "Private key is required" }); + } + + if (!publicKey || typeof publicKey !== "string") { + console.log("Invalid public key provided"); + return res.status(400).json({ error: "Public key is required" }); + } + + try { + console.log("Calling validateKeyPair..."); + const validationResult = validateKeyPair(privateKey, publicKey, keyPassword); + console.log("validateKeyPair result:", validationResult); + + const response = { + isValid: validationResult.isValid, + privateKeyType: validationResult.privateKeyType, + publicKeyType: validationResult.publicKeyType, + generatedPublicKey: validationResult.generatedPublicKey, + error: validationResult.error || null + }; + + console.log("Sending response:", response); + res.json(response); + } catch (error) { + console.error("Exception in validate-key-pair endpoint:", error); + authLogger.error("Failed to validate key pair", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to validate key pair" + }); + } +}); + +// Generate new SSH key pair endpoint +// POST /credentials/generate-key-pair +router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Response) => { + const { keyType = 'ssh-ed25519', keySize = 2048, passphrase } = req.body; + + console.log("=== Generate Key Pair API Called ==="); + console.log("Key type:", keyType); + console.log("Key size:", keySize); + console.log("Has passphrase:", !!passphrase); + + try { + // Generate SSH keys directly with ssh2 + const result = generateSSHKeyPair(keyType, keySize, passphrase); + + if (result.success && result.privateKey && result.publicKey) { + const response = { + success: true, + privateKey: result.privateKey, + publicKey: result.publicKey, + keyType: keyType, + format: 'ssh', + algorithm: keyType, + keySize: keyType === 'ssh-rsa' ? keySize : undefined, + curve: keyType === 'ecdsa-sha2-nistp256' ? 'nistp256' : undefined + }; + + console.log("SSH key pair generated successfully:", keyType); + res.json(response); + } else { + console.error("SSH key generation failed:", result.error); + res.status(500).json({ + success: false, + error: result.error || "Failed to generate SSH key pair" + }); + } + } catch (error) { + console.error("Exception in generate-key-pair endpoint:", error); + authLogger.error("Failed to generate key pair", error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : "Failed to generate key pair" + }); + } +}); + +// Generate public key from private key endpoint +// POST /credentials/generate-public-key +router.post("/generate-public-key", authenticateJWT, async (req: Request, res: Response) => { + const { privateKey, keyPassword } = req.body; + + console.log("=== Generate Public Key API Called ==="); + console.log("Request body keys:", Object.keys(req.body)); + console.log("Private key provided:", !!privateKey); + console.log("Private key type:", typeof privateKey); + + if (!privateKey || typeof privateKey !== "string") { + console.log("Invalid private key provided"); + return res.status(400).json({ error: "Private key is required" }); + } + + try { + console.log("Using Node.js crypto to generate public key from private key..."); + console.log("Private key length:", privateKey.length); + console.log("Private key first 100 chars:", privateKey.substring(0, 100)); + + // First try to create private key object from the input + let privateKeyObj; + let parseAttempts = []; + + // Attempt 1: Direct parsing with passphrase + try { + privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + passphrase: keyPassword + }); + console.log("Successfully parsed with passphrase method"); + } catch (error) { + parseAttempts.push(`Method 1 (with passphrase): ${error.message}`); + } + + // Attempt 2: Direct parsing without passphrase + if (!privateKeyObj) { + try { + privateKeyObj = crypto.createPrivateKey(privateKey); + console.log("Successfully parsed without passphrase"); + } catch (error) { + parseAttempts.push(`Method 2 (without passphrase): ${error.message}`); + } + } + + // Attempt 3: Try with explicit format specification + if (!privateKeyObj) { + try { + privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: 'pem', + type: 'pkcs8' + }); + console.log("Successfully parsed as PKCS#8"); + } catch (error) { + parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`); + } + } + + // Attempt 4: Try as PKCS#1 RSA + if (!privateKeyObj && privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) { + try { + privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: 'pem', + type: 'pkcs1' + }); + console.log("Successfully parsed as PKCS#1 RSA"); + } catch (error) { + parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`); + } + } + + // Attempt 5: Try as SEC1 EC + if (!privateKeyObj && privateKey.includes('-----BEGIN EC PRIVATE KEY-----')) { + try { + privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: 'pem', + type: 'sec1' + }); + console.log("Successfully parsed as SEC1 EC"); + } catch (error) { + parseAttempts.push(`Method 5 (SEC1): ${error.message}`); + } + } + + // Final attempt: Try using ssh2 as fallback + if (!privateKeyObj) { + console.log("Attempting fallback to parseSSHKey function..."); + try { + const keyInfo = parseSSHKey(privateKey, keyPassword); + console.log("parseSSHKey fallback result:", keyInfo); + + if (keyInfo.success && keyInfo.publicKey) { + // Ensure SSH2 fallback also returns proper string + const publicKeyString = String(keyInfo.publicKey); + console.log("SSH2 fallback public key type:", typeof publicKeyString); + console.log("SSH2 fallback public key length:", publicKeyString.length); + + return res.json({ + success: true, + publicKey: publicKeyString, + keyType: keyInfo.keyType + }); + } else { + parseAttempts.push(`SSH2 fallback: ${keyInfo.error || 'No public key generated'}`); + } + } catch (error) { + parseAttempts.push(`SSH2 fallback exception: ${error.message}`); + } + } + + if (!privateKeyObj) { + console.error("All parsing attempts failed:", parseAttempts); + return res.status(400).json({ + success: false, + error: "Unable to parse private key. Tried multiple formats.", + details: parseAttempts + }); + } + + // Generate public key from private key + const publicKeyObj = crypto.createPublicKey(privateKeyObj); + const publicKeyPem = publicKeyObj.export({ + type: 'spki', + format: 'pem' + }); + + // Debug: Check what we're actually generating + console.log("Generated public key type:", typeof publicKeyPem); + console.log("Generated public key is Buffer:", Buffer.isBuffer(publicKeyPem)); + + // Ensure publicKeyPem is a string + const publicKeyString = typeof publicKeyPem === 'string' ? publicKeyPem : publicKeyPem.toString('utf8'); + + console.log("Public key string length:", publicKeyString.length); + console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100)); + console.log("Public key is string:", typeof publicKeyString === 'string'); + console.log("Public key contains PEM header:", publicKeyString.includes('-----BEGIN PUBLIC KEY-----')); + + // Detect key type from the private key object + let keyType = 'unknown'; + const asymmetricKeyType = privateKeyObj.asymmetricKeyType; + + if (asymmetricKeyType === 'rsa') { + keyType = 'ssh-rsa'; + } else if (asymmetricKeyType === 'ed25519') { + keyType = 'ssh-ed25519'; + } else if (asymmetricKeyType === 'ec') { + // For EC keys, we need to check the curve + keyType = 'ecdsa-sha2-nistp256'; // Default assumption for P-256 + } + + // Use ssh2 to generate SSH format public key + let finalPublicKey = publicKeyString; // PEM fallback + let formatType = 'pem'; + + try { + const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword); + if (!(ssh2PrivateKey instanceof Error)) { + const publicKeyBuffer = ssh2PrivateKey.getPublicSSH(); + const base64Data = publicKeyBuffer.toString('base64'); + finalPublicKey = `${keyType} ${base64Data}`; + formatType = 'ssh'; + console.log("SSH format public key generated!"); + } else { + console.warn("ssh2 parsing failed, using PEM format"); + } + } catch (sshError) { + console.warn("ssh2 failed, using PEM format"); + } + + const response = { + success: true, + publicKey: finalPublicKey, + keyType: keyType, + format: formatType + }; + + console.log("Final response publicKey type:", typeof response.publicKey); + console.log("Final response publicKey format:", response.format); + console.log("Final response publicKey length:", response.publicKey.length); + console.log("Public key generated successfully using crypto module:", keyType); + + res.json(response); + } catch (error) { + console.error("Exception in generate-public-key endpoint:", error); + authLogger.error("Failed to generate public key", error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : "Failed to generate public key" + }); + } +}); + +// SSH Key Deployment Function +async function deploySSHKeyToHost( + hostConfig: any, + publicKey: string, + credentialData: any +): Promise<{ success: boolean; message?: string; error?: string }> { + return new Promise((resolve) => { + const conn = new Client(); + let connectionTimeout: NodeJS.Timeout; + + // Connection timeout + connectionTimeout = setTimeout(() => { + conn.destroy(); + resolve({ success: false, error: "Connection timeout" }); + }, 30000); + + conn.on('ready', async () => { + clearTimeout(connectionTimeout); + + try { + // Step 1: Create ~/.ssh directory if it doesn't exist + await new Promise((resolveCmd, rejectCmd) => { + conn.exec('mkdir -p ~/.ssh && chmod 700 ~/.ssh', (err, stream) => { + if (err) return rejectCmd(err); + + stream.on('close', (code) => { + if (code === 0) { + resolveCmd(); + } else { + rejectCmd(new Error(`mkdir command failed with code ${code}`)); + } + }); + }); + }); + + // Step 2: Check if public key already exists + const keyExists = await new Promise((resolveCheck, rejectCheck) => { + const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm + conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, (err, stream) => { + if (err) return rejectCheck(err); + + stream.on('close', (code) => { + resolveCheck(code === 0); // code 0 means key found + }); + }); + }); + + if (keyExists) { + conn.end(); + resolve({ success: true, message: "SSH key already deployed" }); + return; + } + + // Step 3: Add public key to authorized_keys + await new Promise((resolveAdd, rejectAdd) => { + const escapedKey = publicKey.replace(/'/g, "'\\''"); + conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => { + if (err) return rejectAdd(err); + + stream.on('close', (code) => { + if (code === 0) { + resolveAdd(); + } else { + rejectAdd(new Error(`Key deployment failed with code ${code}`)); + } + }); + }); + }); + + // Step 4: Verify deployment + const verifySuccess = await new Promise((resolveVerify, rejectVerify) => { + const keyPattern = publicKey.split(' ')[1]; + conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys`, (err, stream) => { + if (err) return rejectVerify(err); + + stream.on('close', (code) => { + resolveVerify(code === 0); + }); + }); + }); + + conn.end(); + + if (verifySuccess) { + resolve({ success: true, message: "SSH key deployed successfully" }); + } else { + resolve({ success: false, error: "Key deployment verification failed" }); + } + } catch (error) { + conn.end(); + resolve({ + success: false, + error: error instanceof Error ? error.message : "Deployment failed" + }); + } + }); + + conn.on('error', (err) => { + clearTimeout(connectionTimeout); + resolve({ success: false, error: err.message }); + }); + + // Connect to the target host + try { + const connectionConfig: any = { + host: hostConfig.ip, + port: hostConfig.port || 22, + username: hostConfig.username, + }; + + if (hostConfig.authType === 'password' && hostConfig.password) { + connectionConfig.password = hostConfig.password; + } else if (hostConfig.authType === 'key' && hostConfig.privateKey) { + connectionConfig.privateKey = hostConfig.privateKey; + if (hostConfig.keyPassword) { + connectionConfig.passphrase = hostConfig.keyPassword; + } + } else { + resolve({ success: false, error: "Invalid authentication configuration" }); + return; + } + + conn.connect(connectionConfig); + } catch (error) { + clearTimeout(connectionTimeout); + resolve({ + success: false, + error: error instanceof Error ? error.message : "Connection failed" + }); + } + }); +} + +// Deploy SSH Key to Host endpoint +// POST /credentials/:id/deploy-to-host +router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Response) => { + const credentialId = parseInt(req.params.id); + const { targetHostId } = req.body; + + + if (!credentialId || !targetHostId) { + return res.status(400).json({ + success: false, + error: "Credential ID and target host ID are required" + }); + } + + try { + // Get credential details + const credential = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, credentialId)) + .limit(1); + + if (!credential || credential.length === 0) { + return res.status(404).json({ + success: false, + error: "Credential not found" + }); + } + + const credData = credential[0]; + + // Only support key-based credentials for deployment + if (credData.authType !== 'key') { + return res.status(400).json({ + success: false, + error: "Only SSH key-based credentials can be deployed" + }); + } + + if (!credData.publicKey) { + return res.status(400).json({ + success: false, + error: "Public key is required for deployment" + }); + } + + // Get target host details + const targetHost = await db + .select() + .from(sshData) + .where(eq(sshData.id, targetHostId)) + .limit(1); + + if (!targetHost || targetHost.length === 0) { + return res.status(404).json({ + success: false, + error: "Target host not found" + }); + } + + const hostData = targetHost[0]; + + // Prepare host configuration for connection + let hostConfig = { + ip: hostData.ip, + port: hostData.port, + username: hostData.username, + authType: hostData.authType, + password: hostData.password, + privateKey: hostData.key, + keyPassword: hostData.keyPassword + }; + + // If host uses credential authentication, resolve the credential + if (hostData.authType === 'credential' && hostData.credentialId) { + const hostCredential = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, hostData.credentialId)) + .limit(1); + + if (hostCredential && hostCredential.length > 0) { + const cred = hostCredential[0]; + + // Update hostConfig with credential data + hostConfig.authType = cred.authType; + hostConfig.username = cred.username; // Use credential's username + + if (cred.authType === 'password') { + hostConfig.password = cred.password; + } else if (cred.authType === 'key') { + hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields + hostConfig.keyPassword = cred.keyPassword; + } + } else { + return res.status(400).json({ + success: false, + error: "Host credential not found" + }); + } + } + + // Deploy the SSH key + const deployResult = await deploySSHKeyToHost( + hostConfig, + credData.publicKey, + credData + ); + + if (deployResult.success) { + // Log successful deployment + authLogger.info(`SSH key deployed successfully`, { + credentialId, + targetHostId, + operation: "deploy_ssh_key" + }); + + res.json({ + success: true, + message: deployResult.message || "SSH key deployed successfully" + }); + } else { + authLogger.error(`SSH key deployment failed`, { + credentialId, + targetHostId, + error: deployResult.error, + operation: "deploy_ssh_key" + }); + + res.status(500).json({ + success: false, + error: deployResult.error || "Deployment failed" + }); + } + } catch (error) { + authLogger.error("Failed to deploy SSH key", error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : "Failed to deploy SSH key" + }); + } +}); + export default router; diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index b08d39dd..26c209b1 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -13,6 +13,7 @@ import type { Request, Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; import multer from "multer"; import { sshLogger } from "../../utils/logger.js"; +import { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js"; const router = express.Router(); @@ -62,7 +63,10 @@ router.get("/db/host/internal", async (req: Request, res: Response) => { return res.status(403).json({ error: "Forbidden" }); } try { - const data = await db.select().from(sshData); + const data = await EncryptedDBOperations.select( + db.select().from(sshData), + 'ssh_data' + ); const result = data.map((row: any) => { return { ...row, @@ -73,6 +77,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => { : [] : [], pin: !!row.pin, + requirePassword: !!row.requirePassword, enableTerminal: !!row.enableTerminal, enableTunnel: !!row.enableTunnel, tunnelConnections: row.tunnelConnections @@ -133,6 +138,7 @@ router.post( port, username, password, + requirePassword, authMethod, authType, credentialId, @@ -184,6 +190,7 @@ router.post( if (effectiveAuthType === "password") { sshDataObj.password = password || null; + sshDataObj.requirePassword = requirePassword !== false ? 1 : 0; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; @@ -192,12 +199,20 @@ router.post( sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; sshDataObj.password = null; + sshDataObj.requirePassword = 1; // Default to true for non-password auth + } else { + // For credential auth + sshDataObj.password = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + sshDataObj.requirePassword = 1; // Default to true for non-password auth } try { - const result = await db.insert(sshData).values(sshDataObj).returning(); + const result = await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj); - if (result.length === 0) { + if (!result) { sshLogger.warn("No host returned after creation", { operation: "host_create", userId, @@ -208,7 +223,7 @@ router.post( return res.status(500).json({ error: "Failed to create host" }); } - const createdHost = result[0]; + const createdHost = result; const baseHost = { ...createdHost, tags: @@ -218,6 +233,7 @@ router.post( : [] : [], pin: !!createdHost.pin, + requirePassword: !!createdHost.requirePassword, enableTerminal: !!createdHost.enableTerminal, enableTunnel: !!createdHost.enableTunnel, tunnelConnections: createdHost.tunnelConnections @@ -304,6 +320,7 @@ router.put( port, username, password, + requirePassword, authMethod, authType, credentialId, @@ -358,6 +375,7 @@ router.put( if (password) { sshDataObj.password = password; } + sshDataObj.requirePassword = requirePassword !== false ? 1 : 0; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; @@ -372,18 +390,28 @@ router.put( sshDataObj.keyType = keyType; } sshDataObj.password = null; + sshDataObj.requirePassword = 1; // Default to true for non-password auth + } else { + // For credential auth + sshDataObj.password = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + sshDataObj.requirePassword = 1; // Default to true for non-password auth } try { - await db - .update(sshData) - .set(sshDataObj) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + await EncryptedDBOperations.update( + sshData, + 'ssh_data', + and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)), + sshDataObj + ); - const updatedHosts = await db - .select() - .from(sshData) - .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); + const updatedHosts = await EncryptedDBOperations.select( + db.select().from(sshData).where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))), + 'ssh_data' + ); if (updatedHosts.length === 0) { sshLogger.warn("Updated host not found after update", { @@ -404,6 +432,7 @@ router.put( : [] : [], pin: !!updatedHost.pin, + requirePassword: !!updatedHost.requirePassword, enableTerminal: !!updatedHost.enableTerminal, enableTunnel: !!updatedHost.enableTunnel, tunnelConnections: updatedHost.tunnelConnections @@ -455,10 +484,10 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid userId" }); } try { - const data = await db - .select() - .from(sshData) - .where(eq(sshData.userId, userId)); + const data = await EncryptedDBOperations.select( + db.select().from(sshData).where(eq(sshData.userId, userId)), + 'ssh_data' + ); const result = await Promise.all( data.map(async (row: any) => { @@ -471,6 +500,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { : [] : [], pin: !!row.pin, + requirePassword: !!row.requirePassword, enableTerminal: !!row.enableTerminal, enableTunnel: !!row.enableTunnel, tunnelConnections: row.tunnelConnections @@ -1074,14 +1104,15 @@ router.put( } try { - const updatedHosts = await db - .update(sshData) - .set({ + const updatedHosts = await EncryptedDBOperations.update( + sshData, + 'ssh_data', + and(eq(sshData.userId, userId), eq(sshData.folder, oldName)), + { folder: newName, updatedAt: new Date().toISOString(), - }) - .where(and(eq(sshData.userId, userId), eq(sshData.folder, oldName))) - .returning(); + } + ); const updatedCredentials = await db .update(sshCredentials) @@ -1221,7 +1252,7 @@ router.post( updatedAt: new Date().toISOString(), }; - await db.insert(sshData).values(sshDataObj); + await EncryptedDBOperations.insert(sshData, 'ssh_data', sshDataObj); results.success++; } catch (error) { results.failed++; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 20c8f816..e932976c 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -5,6 +5,26 @@ import { db } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { fileLogger } from "../utils/logger.js"; +import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js"; + +// 可执行文件检测工具函数 +function isExecutableFile(permissions: string, fileName: string): boolean { + // 检查执行权限位 (user, group, other) + const hasExecutePermission = permissions[3] === 'x' || permissions[6] === 'x' || permissions[9] === 'x'; + + // 常见的脚本文件扩展名 + const scriptExtensions = ['.sh', '.py', '.pl', '.rb', '.js', '.php', '.bash', '.zsh', '.fish']; + const hasScriptExtension = scriptExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); + + // 常见的编译可执行文件(无扩展名或特定扩展名) + const executableExtensions = ['.bin', '.exe', '.out']; + const hasExecutableExtension = executableExtensions.some(ext => fileName.toLowerCase().endsWith(ext)); + + // 无扩展名且有执行权限的文件通常是可执行文件 + const hasNoExtension = !fileName.includes('.') && hasExecutePermission; + + return hasExecutePermission && (hasScriptExtension || hasExecutableExtension || hasNoExtension); +} const app = express(); @@ -85,56 +105,47 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { let resolvedCredentials = { password, sshKey, keyPassword, authType }; if (credentialId && hostId && userId) { try { - const credentials = await db - .select() - .from(sshCredentials) - .where( + const credentials = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, userId), ), - ); + ), + 'ssh_credentials' + ); if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { password: credential.password, - sshKey: credential.key, + sshKey: credential.privateKey || credential.key, // prefer new privateKey field keyPassword: credential.keyPassword, authType: credential.authType, }; } else { - fileLogger.warn("No credentials found in database for file manager", { - operation: "file_connect", - sessionId, + fileLogger.warn(`No credentials found for host ${hostId}`, { + operation: "ssh_credentials", hostId, credentialId, userId, }); } } catch (error) { - fileLogger.warn( - "Failed to resolve credentials from database for file manager", - { - operation: "file_connect", - sessionId, - hostId, - credentialId, - error: error instanceof Error ? error.message : "Unknown error", - }, - ); - } - } else if (credentialId && hostId) { - fileLogger.warn( - "Missing userId for credential resolution in file manager", - { - operation: "file_connect", - sessionId, + fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, { + operation: "ssh_credentials", hostId, credentialId, - hasUserId: !!userId, - }, - ); + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } else if (credentialId && hostId) { + fileLogger.warn("Missing userId for credential resolution in file manager", { + operation: "ssh_credentials", + hostId, + credentialId, + hasUserId: !!userId, + }); } const config: any = { @@ -311,20 +322,116 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { const parts = line.split(/\s+/); if (parts.length >= 9) { const permissions = parts[0]; - const name = parts.slice(8).join(" "); + const linkCount = parts[1]; + const owner = parts[2]; + const group = parts[3]; + const size = parseInt(parts[4], 10); + + // 日期可能占夨3个部分(月 日 时间)或者是(月 日 年) + let dateStr = ""; + let nameStartIndex = 8; + + if (parts[5] && parts[6] && parts[7]) { + // 常规格式: 月 日 时间/年 + dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; + } + + const name = parts.slice(nameStartIndex).join(" "); const isDirectory = permissions.startsWith("d"); const isLink = permissions.startsWith("l"); if (name === "." || name === "..") continue; + // 解析符号链接目标 + let actualName = name; + let linkTarget = undefined; + if (isLink && name.includes(" -> ")) { + const linkParts = name.split(" -> "); + actualName = linkParts[0]; + linkTarget = linkParts[1]; + } + files.push({ - name, + name: actualName, type: isDirectory ? "directory" : isLink ? "link" : "file", + size: isDirectory ? undefined : size, // 目录不显示大小 + modified: dateStr, + permissions, + owner, + group, + linkTarget, // 符号链接的目标 + path: `${sshPath.endsWith('/') ? sshPath : sshPath + '/'}${actualName}`, // 添加完整路径 + executable: !isDirectory && !isLink ? isExecutableFile(permissions, actualName) : false // 检测可执行文件 }); } } - res.json(files); + res.json({ files, path: sshPath }); + }); + }); +}); + +app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { + const sessionId = req.query.sessionId as string; + const sshConn = sshSessions[sessionId]; + const linkPath = decodeURIComponent(req.query.path as string); + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!linkPath) { + return res.status(400).json({ error: "Link path is required" }); + } + + sshConn.lastActive = Date.now(); + + const escapedPath = linkPath.replace(/'/g, "'\"'\"'"); + const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`; + + sshConn.client.exec(command, (err, stream) => { + if (err) { + fileLogger.error("SSH identifySymlink error:", err); + return res.status(500).json({ error: err.message }); + } + + let data = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error( + `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res.status(500).json({ error: `Command failed: ${errorData}` }); + } + + const [fileType, target] = data.trim().split("\n"); + + res.json({ + path: linkPath, + target: target, + type: fileType.toLowerCase().includes("directory") ? "directory" : "file" + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH identifySymlink stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } }); }); }); @@ -348,33 +455,87 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => { sshConn.lastActive = Date.now(); + // First check file size to prevent loading huge files + const MAX_READ_SIZE = 10 * 1024 * 1024; // 10MB - same as frontend limit const escapedPath = filePath.replace(/'/g, "'\"'\"'"); - sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { - if (err) { - fileLogger.error("SSH readFile error:", err); - return res.status(500).json({ error: err.message }); + + // Get file size first + sshConn.client.exec(`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, (sizeErr, sizeStream) => { + if (sizeErr) { + fileLogger.error("SSH file size check error:", sizeErr); + return res.status(500).json({ error: sizeErr.message }); } - let data = ""; - let errorData = ""; + let sizeData = ""; + let sizeErrorData = ""; - stream.on("data", (chunk: Buffer) => { - data += chunk.toString(); + sizeStream.on("data", (chunk: Buffer) => { + sizeData += chunk.toString(); }); - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); + sizeStream.stderr.on("data", (chunk: Buffer) => { + sizeErrorData += chunk.toString(); }); - stream.on("close", (code) => { - if (code !== 0) { - fileLogger.error( - `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, - ); - return res.status(500).json({ error: `Command failed: ${errorData}` }); + sizeStream.on("close", (sizeCode) => { + if (sizeCode !== 0) { + fileLogger.error(`File size check failed: ${sizeErrorData}`); + return res.status(500).json({ error: `Cannot check file size: ${sizeErrorData}` }); } - res.json({ content: data, path: filePath }); + const fileSize = parseInt(sizeData.trim(), 10); + + if (isNaN(fileSize)) { + fileLogger.error("Invalid file size response:", sizeData); + return res.status(500).json({ error: "Cannot determine file size" }); + } + + // Check if file is too large + if (fileSize > MAX_READ_SIZE) { + fileLogger.warn("File too large for reading", { + operation: "file_read", + sessionId, + filePath, + fileSize, + maxSize: MAX_READ_SIZE, + }); + return res.status(400).json({ + error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`, + fileSize, + maxSize: MAX_READ_SIZE, + tooLarge: true + }); + } + + // File size is acceptable, proceed with reading + sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { + if (err) { + fileLogger.error("SSH readFile error:", err); + return res.status(500).json({ error: err.message }); + } + + let data = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error( + `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res.status(500).json({ error: `Command failed: ${errorData}` }); + } + + res.json({ content: data, path: filePath }); + }); + }); }); }); }); @@ -1269,6 +1430,434 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => { }); }); +// New API for moving files/folders across directories (for cut operation) +app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { + const { sessionId, oldPath, newPath, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + if (!sshConn?.isConnected) { + return res.status(400).json({ error: "SSH connection not established" }); + } + + if (!oldPath || !newPath) { + return res + .status(400) + .json({ error: "Old path and new path are required" }); + } + + sshConn.lastActive = Date.now(); + + const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'"); + const escapedNewPath = newPath.replace(/'/g, "'\"'\"'"); + + const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; + + sshConn.client.exec(moveCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH moveItem error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err.message }); + } + return; + } + + let outputData = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + + if (chunk.toString().includes("Permission denied")) { + fileLogger.error(`Permission denied moving: ${oldPath}`); + if (!res.headersSent) { + return res.status(403).json({ + error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`, + toast: { + type: "error", + message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`, + }, + }); + } + return; + } + }); + + stream.on("close", (code) => { + if (outputData.includes("SUCCESS")) { + if (!res.headersSent) { + res.json({ + message: "Item moved successfully", + oldPath, + newPath, + toast: { + type: "success", + message: `Item moved: ${oldPath} -> ${newPath}`, + }, + }); + } + return; + } + + if (code !== 0) { + fileLogger.error( + `SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + if (!res.headersSent) { + return res.status(500).json({ + error: `Command failed: ${errorData}`, + toast: { type: "error", message: `Move failed: ${errorData}` }, + }); + } + return; + } + + if (!res.headersSent) { + res.json({ + message: "Item moved successfully", + oldPath, + newPath, + toast: { + type: "success", + message: `Item moved: ${oldPath} -> ${newPath}`, + }, + }); + } + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH moveItem stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } + }); + }); +}); + +app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { + const { + sessionId, + path: filePath, + hostId, + userId, + } = req.body; + + if (!sessionId || !filePath) { + fileLogger.warn("Missing download parameters", { + operation: "file_download", + sessionId, + hasFilePath: !!filePath, + }); + return res.status(400).json({ error: "Missing download parameters" }); + } + + const sshConn = sshSessions[sessionId]; + if (!sshConn || !sshConn.isConnected) { + fileLogger.warn("SSH session not found or not connected for download", { + operation: "file_download", + sessionId, + isConnected: sshConn?.isConnected, + }); + return res.status(400).json({ error: "SSH session not found or not connected" }); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + // Use SFTP to read file for binary safety + sshConn.client.sftp((err, sftp) => { + if (err) { + fileLogger.error("SFTP connection failed for download:", err); + return res.status(500).json({ error: "SFTP connection failed" }); + } + + // Get file stats first to check if it's a regular file and get size + sftp.stat(filePath, (statErr, stats) => { + if (statErr) { + fileLogger.error("File stat failed for download:", statErr); + return res.status(500).json({ error: `Cannot access file: ${statErr.message}` }); + } + + if (!stats.isFile()) { + fileLogger.warn("Attempted to download non-file", { + operation: "file_download", + sessionId, + filePath, + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + }); + return res.status(400).json({ error: "Cannot download directories or special files" }); + } + + // Check file size (limit to 100MB for safety) + const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB + if (stats.size > MAX_FILE_SIZE) { + fileLogger.warn("File too large for download", { + operation: "file_download", + sessionId, + filePath, + fileSize: stats.size, + maxSize: MAX_FILE_SIZE, + }); + return res.status(400).json({ + error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB` + }); + } + + // Read file content + sftp.readFile(filePath, (readErr, data) => { + if (readErr) { + fileLogger.error("File read failed for download:", readErr); + return res.status(500).json({ error: `Failed to read file: ${readErr.message}` }); + } + + // Convert to base64 for safe transport + const base64Content = data.toString('base64'); + const fileName = filePath.split('/').pop() || 'download'; + + fileLogger.success("File downloaded successfully", { + operation: "file_download", + sessionId, + filePath, + fileName, + fileSize: stats.size, + hostId, + userId, + }); + + res.json({ + content: base64Content, + fileName: fileName, + size: stats.size, + mimeType: getMimeType(fileName), + path: filePath, + }); + }); + }); + }); +}); + +// Copy SSH file/directory +app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { + const { sessionId, sourcePath, targetDir, hostId, userId } = req.body; + + if (!sessionId || !sourcePath || !targetDir) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + const sshConn = sshSessions[sessionId]; + if (!sshConn || !sshConn.isConnected) { + return res.status(400).json({ error: "SSH session not found or not connected" }); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + try { + // Extract source name + const sourceName = sourcePath.split('/').pop() || 'copied_item'; + + // First check if source file exists + const escapedSourceForCheck = sourcePath.replace(/'/g, "'\"'\"'"); + const checkExistsCommand = `test -e '${escapedSourceForCheck}'`; + const checkExists = await new Promise((resolve) => { + sshConn.client.exec(checkExistsCommand, (err, stream) => { + if (err) { + fileLogger.error("File existence check error:", err); + resolve(false); + return; + } + + stream.on("close", (code) => { + fileLogger.info("File existence check completed", { sourcePath, exists: code === 0 }); + resolve(code === 0); + }); + + stream.on("error", () => resolve(false)); + }); + }); + + if (!checkExists) { + return res.status(404).json({ + error: `Source file not found: ${sourcePath}`, + toast: { type: "error", message: `Source file not found: ${sourceName}` } + }); + } + + // Use timestamp for uniqueness + const timestamp = Date.now().toString().slice(-8); + const nameWithoutExt = sourceName.includes('.') + ? sourceName.substring(0, sourceName.lastIndexOf('.')) + : sourceName; + const extension = sourceName.includes('.') + ? sourceName.substring(sourceName.lastIndexOf('.')) + : ''; + + // Always use timestamp suffix to ensure uniqueness without SSH calls + const uniqueName = `${nameWithoutExt}_copy_${timestamp}${extension}`; + + fileLogger.info("Using timestamp-based unique name", { originalName: sourceName, uniqueName }); + const targetPath = `${targetDir}/${uniqueName}`; + + // Escape paths for shell commands + const escapedSource = sourcePath.replace(/'/g, "'\"'\"'"); + const escapedTarget = targetPath.replace(/'/g, "'\"'\"'"); + + // Use cp with explicit flags to avoid hanging on prompts + // -f: force overwrite without prompting + // -r: recursive for directories + // -p: preserve timestamps, permissions + const copyCommand = `cp -fpr '${escapedSource}' '${escapedTarget}' 2>&1`; + + fileLogger.info("Starting file copy operation", { + operation: "file_copy_start", + sessionId, + sourcePath, + targetPath, + uniqueName, + command: copyCommand.substring(0, 200) + "..." // Log truncated command + }); + + // Add timeout to prevent hanging + const commandTimeout = setTimeout(() => { + fileLogger.error("Copy command timed out after 20 seconds", { + sourcePath, + targetPath, + command: copyCommand + }); + if (!res.headersSent) { + res.status(500).json({ + error: "Copy operation timed out", + toast: { type: "error", message: "Copy operation timed out. SSH connection may be unstable." } + }); + } + }, 20000); // 20 second timeout for better responsiveness + + sshConn.client.exec(copyCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + fileLogger.error("SSH copyItem error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err.message }); + } + return; + } + + let errorData = ""; + let stdoutData = ""; + + // Monitor both stdout and stderr + stream.on("data", (data: Buffer) => { + const output = data.toString(); + stdoutData += output; + fileLogger.info("Copy command stdout", { output: output.substring(0, 200) }); + }); + + stream.stderr.on("data", (data: Buffer) => { + const output = data.toString(); + errorData += output; + fileLogger.info("Copy command stderr", { output: output.substring(0, 200) }); + }); + + stream.on("close", (code) => { + clearTimeout(commandTimeout); + fileLogger.info("Copy command completed", { code, errorData, hasError: errorData.length > 0 }); + + if (code !== 0) { + const fullErrorInfo = errorData || stdoutData || 'No error message available'; + fileLogger.error(`SSH copyItem command failed with code ${code}`, { + operation: "file_copy_failed", + sessionId, + sourcePath, + targetPath, + command: copyCommand, + exitCode: code, + errorData, + stdoutData, + fullErrorInfo + }); + if (!res.headersSent) { + return res.status(500).json({ + error: `Copy failed: ${fullErrorInfo}`, + toast: { type: "error", message: `Copy failed: ${fullErrorInfo}` }, + debug: { + sourcePath, + targetPath, + exitCode: code, + command: copyCommand + } + }); + } + return; + } + + fileLogger.success("Item copied successfully", { + operation: "file_copy", + sessionId, + sourcePath, + targetPath, + uniqueName, + hostId, + userId, + }); + + if (!res.headersSent) { + res.json({ + message: "Item copied successfully", + sourcePath, + targetPath, + uniqueName, + toast: { + type: "success", + message: `Successfully copied to: ${uniqueName}`, + }, + }); + } + }); + + stream.on("error", (streamErr) => { + clearTimeout(commandTimeout); + fileLogger.error("SSH copyItem stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } + }); + }); + + } catch (error: any) { + fileLogger.error("Copy operation error:", error); + res.status(500).json({ error: error.message }); + } +}); + +// Helper function to determine MIME type based on file extension +function getMimeType(fileName: string): string { + const ext = fileName.split('.').pop()?.toLowerCase(); + const mimeTypes: Record = { + 'txt': 'text/plain', + 'json': 'application/json', + 'js': 'text/javascript', + 'html': 'text/html', + 'css': 'text/css', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'pdf': 'application/pdf', + 'zip': 'application/zip', + 'tar': 'application/x-tar', + 'gz': 'application/gzip', + }; + return mimeTypes[ext || ''] || 'application/octet-stream'; +} + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); @@ -1279,6 +1868,108 @@ process.on("SIGTERM", () => { process.exit(0); }); +// 执行可执行文件 +app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { + const { sessionId, filePath, hostId, userId } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sshConn || !sshConn.isConnected) { + fileLogger.error("SSH connection not found or not connected for executeFile", { + operation: "execute_file", + sessionId, + hasConnection: !!sshConn, + isConnected: sshConn?.isConnected + }); + return res.status(400).json({ error: "SSH connection not available" }); + } + + if (!filePath) { + return res.status(400).json({ error: "File path is required" }); + } + + const escapedPath = filePath.replace(/'/g, "'\"'\"'"); + + // 检查文件是否存在且可执行 + const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`; + + sshConn.client.exec(checkCommand, (checkErr, checkStream) => { + if (checkErr) { + fileLogger.error("SSH executeFile check error:", checkErr); + return res.status(500).json({ error: "Failed to check file executability" }); + } + + let checkResult = ''; + checkStream.on("data", (data) => { + checkResult += data.toString(); + }); + + checkStream.on("close", (code) => { + if (!checkResult.includes("EXECUTABLE")) { + return res.status(400).json({ error: "File is not executable" }); + } + + // 执行文件 + const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`; + + fileLogger.info("Executing file", { + operation: "execute_file", + sessionId, + filePath, + command: executeCommand.substring(0, 100) + "..." + }); + + sshConn.client.exec(executeCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH executeFile error:", err); + return res.status(500).json({ error: "Failed to execute file" }); + } + + let output = ''; + let errorOutput = ''; + + stream.on("data", (data) => { + output += data.toString(); + }); + + stream.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + stream.on("close", (code) => { + // 从输出中提取退出代码 + const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/); + const actualExitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : code; + const cleanOutput = output.replace(/EXIT_CODE:\d+$/, '').trim(); + + fileLogger.info("File execution completed", { + operation: "execute_file", + sessionId, + filePath, + exitCode: actualExitCode, + outputLength: cleanOutput.length, + errorLength: errorOutput.length + }); + + res.json({ + success: true, + exitCode: actualExitCode, + output: cleanOutput, + error: errorOutput, + timestamp: new Date().toISOString() + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH executeFile stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: "Execution stream error" }); + } + }); + }); + }); + }); +}); + const PORT = 8084; app.listen(PORT, () => { fileLogger.success("File Manager API server started", { diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index bdc8ec50..7437a278 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -6,6 +6,7 @@ import { db } from "../database/db/index.js"; import { sshData, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { statsLogger } from "../utils/logger.js"; +import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js"; interface PooledConnection { client: Client; @@ -306,7 +307,10 @@ const hostStatuses: Map = new Map(); async function fetchAllHosts(): Promise { try { - const hosts = await db.select().from(sshData); + const hosts = await EncryptedDBOperations.select( + db.select().from(sshData), + 'ssh_data' + ); const hostsWithCredentials: SSHHostWithCredentials[] = []; for (const host of hosts) { @@ -333,7 +337,10 @@ async function fetchHostById( id: number, ): Promise { try { - const hosts = await db.select().from(sshData).where(eq(sshData.id, id)); + const hosts = await EncryptedDBOperations.select( + db.select().from(sshData).where(eq(sshData.id, id)), + 'ssh_data' + ); if (hosts.length === 0) { return undefined; @@ -351,6 +358,17 @@ async function resolveHostCredentials( host: any, ): Promise { try { + statsLogger.debug(`Resolving credentials for host ${host.id}`, { + operation: 'credential_resolve', + hostId: host.id, + authType: host.authType, + credentialId: host.credentialId, + hasPassword: !!host.password, + hasKey: !!host.key, + passwordLength: host.password?.length || 0, + keyLength: host.key?.length || 0 + }); + const baseHost: any = { id: host.id, name: host.name, @@ -380,18 +398,26 @@ async function resolveHostCredentials( if (host.credentialId) { try { - const credentials = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId), - eq(sshCredentials.userId, host.userId), - ), - ); + const credentials = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where(and( + eq(sshCredentials.id, host.credentialId), + eq(sshCredentials.userId, host.userId), + )), + 'ssh_credentials' + ); if (credentials.length > 0) { const credential = credentials[0]; + statsLogger.debug(`Using credential ${credential.id} for host ${host.id}`, { + operation: 'credential_resolve', + credentialId: credential.id, + authType: credential.authType, + hasPassword: !!credential.password, + hasKey: !!credential.key, + passwordLength: credential.password?.length || 0, + keyLength: credential.key?.length || 0 + }); + baseHost.credentialId = credential.id; baseHost.username = credential.username; baseHost.authType = credential.authType; @@ -421,9 +447,25 @@ async function resolveHostCredentials( addLegacyCredentials(baseHost, host); } } else { + statsLogger.debug(`Using legacy credentials for host ${host.id}`, { + operation: 'credential_resolve', + hasPassword: !!host.password, + hasKey: !!host.key, + passwordLength: host.password?.length || 0, + keyLength: host.key?.length || 0 + }); addLegacyCredentials(baseHost, host); } + statsLogger.debug(`Final resolved host ${host.id}`, { + operation: 'credential_resolve', + authType: baseHost.authType, + hasPassword: !!baseHost.password, + hasKey: !!baseHost.key, + passwordLength: baseHost.password?.length || 0, + keyLength: baseHost.key?.length || 0 + }); + return baseHost; } catch (error) { statsLogger.error( @@ -441,6 +483,18 @@ function addLegacyCredentials(baseHost: any, host: any): void { } function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { + statsLogger.debug(`Building SSH config for host ${host.ip}`, { + operation: 'ssh_config', + authType: host.authType, + hasPassword: !!host.password, + hasKey: !!host.key, + username: host.username, + passwordLength: host.password?.length || 0, + keyLength: host.key?.length || 0, + passwordType: typeof host.password, + passwordRaw: host.password ? JSON.stringify(host.password.substring(0, 20)) : null + }); + const base: ConnectConfig = { host: host.ip, port: host.port || 22, @@ -453,12 +507,26 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { if (!host.password) { throw new Error(`No password available for host ${host.ip}`); } + statsLogger.debug(`Using password auth for ${host.ip}`, { + operation: 'ssh_config', + passwordLength: host.password.length, + passwordFirst3: host.password.substring(0, 3), + passwordLast3: host.password.substring(host.password.length - 3), + passwordType: typeof host.password, + passwordIsString: typeof host.password === 'string' + }); (base as any).password = host.password; } else if (host.authType === "key") { if (!host.key) { throw new Error(`No SSH key available for host ${host.ip}`); } + statsLogger.debug(`Using key auth for ${host.ip}`, { + operation: 'ssh_config', + keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + '...', + hasPassphrase: !!host.keyPassword + }); + try { if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) { throw new Error("Invalid private key format"); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index cb1ec180..e01a00aa 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -4,6 +4,7 @@ import { db } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; +import { EncryptedDBOperations } from "../utils/encrypted-db-operations.js"; const wss = new WebSocketServer({ port: 8082 }); @@ -103,8 +104,10 @@ wss.on("connection", (ws: WebSocket) => { credentialId?: number; userId?: string; }; + initialPath?: string; + executeCommand?: string; }) { - const { cols, rows, hostConfig } = data; + const { cols, rows, hostConfig, initialPath, executeCommand } = data; const { id, ip, @@ -174,24 +177,44 @@ wss.on("connection", (ws: WebSocket) => { } }, 60000); + sshLogger.debug(`Terminal SSH setup`, { + operation: 'terminal_ssh', + hostId: id, + ip, + authType, + hasPassword: !!password, + passwordLength: password?.length || 0, + hasCredentialId: !!credentialId + }); + + if (password) { + sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, { + operation: 'terminal_ssh_password' + }); + } else { + sshLogger.debug(`No password provided`, { + operation: 'terminal_ssh_password' + }); + } + let resolvedCredentials = { password, key, keyPassword, keyType, authType }; if (credentialId && id && hostConfig.userId) { try { - const credentials = await db - .select() - .from(sshCredentials) - .where( + const credentials = await EncryptedDBOperations.select( + db.select().from(sshCredentials).where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, hostConfig.userId), ), - ); + ), + 'ssh_credentials' + ); if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { password: credential.password, - key: credential.key, + key: credential.privateKey || credential.key, // prefer new privateKey field keyPassword: credential.keyPassword, keyType: credential.keyType, authType: credential.authType, @@ -281,6 +304,34 @@ wss.on("connection", (ws: WebSocket) => { setupPingInterval(); + // Change to initial path if specified + if (initialPath && initialPath.trim() !== "") { + sshLogger.debug(`Changing to initial path: ${initialPath}`, { + operation: "ssh_initial_path", + hostId: id, + path: initialPath, + }); + + // Send cd command to change directory + const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; + stream.write(cdCommand); + } + + // Execute command if specified + if (executeCommand && executeCommand.trim() !== "") { + sshLogger.debug(`Executing command: ${executeCommand}`, { + operation: "ssh_execute_command", + hostId: id, + command: executeCommand, + }); + + // Wait a moment for the cd command to complete, then execute the command + setTimeout(() => { + const command = `${executeCommand}\n`; + stream.write(command); + }, 500); + } + ws.send( JSON.stringify({ type: "connected", message: "SSH connected" }), ); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 5d37c753..78daa7e3 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -455,7 +455,7 @@ async function connectSSHTunnel( const credential = credentials[0]; resolvedSourceCredentials = { password: credential.password, - sshKey: credential.key, + sshKey: credential.privateKey || credential.key, // prefer new privateKey field keyPassword: credential.keyPassword, keyType: credential.keyType, authMethod: credential.authType, @@ -501,7 +501,7 @@ async function connectSSHTunnel( const credential = credentials[0]; resolvedEndpointCredentials = { password: credential.password, - sshKey: credential.key, + sshKey: credential.privateKey || credential.key, // prefer new privateKey field keyPassword: credential.keyPassword, keyType: credential.keyType, authMethod: credential.authType, diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 83caf7ed..80fdc5de 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -2,10 +2,7 @@ // node ./dist/backend/starter.js import "./database/database.js"; -import "./ssh/terminal.js"; -import "./ssh/tunnel.js"; -import "./ssh/file-manager.js"; -import "./ssh/server-stats.js"; +import { DatabaseEncryption } from "./utils/database-encryption.js"; import { systemLogger, versionLogger } from "./utils/logger.js"; import "dotenv/config"; @@ -21,9 +18,21 @@ import "dotenv/config"; operation: "startup", }); + // Initialize database encryption before other services + await DatabaseEncryption.initialize(); + systemLogger.info("Database encryption initialized", { + operation: "encryption_init", + }); + + // Load modules that depend on encryption after initialization + await import("./ssh/terminal.js"); + await import("./ssh/tunnel.js"); + await import("./ssh/file-manager.js"); + await import("./ssh/server-stats.js"); + systemLogger.success("All backend services initialized successfully", { operation: "startup_complete", - services: ["database", "terminal", "tunnel", "file_manager", "stats"], + services: ["database", "encryption", "terminal", "tunnel", "file_manager", "stats"], version: version, }); diff --git a/src/backend/utils/database-encryption.ts b/src/backend/utils/database-encryption.ts new file mode 100644 index 00000000..57e2d55f --- /dev/null +++ b/src/backend/utils/database-encryption.ts @@ -0,0 +1,252 @@ +import { FieldEncryption } from './encryption.js'; +import { EncryptionKeyManager } from './encryption-key-manager.js'; +import { databaseLogger } from './logger.js'; + +interface EncryptionContext { + masterPassword: string; + encryptionEnabled: boolean; + forceEncryption: boolean; + migrateOnAccess: boolean; +} + +class DatabaseEncryption { + private static context: EncryptionContext | null = null; + + static async initialize(config: Partial = {}) { + const keyManager = EncryptionKeyManager.getInstance(); + const masterPassword = config.masterPassword || await keyManager.initializeKey(); + + this.context = { + masterPassword, + encryptionEnabled: config.encryptionEnabled ?? true, + forceEncryption: config.forceEncryption ?? false, + migrateOnAccess: config.migrateOnAccess ?? true + }; + + databaseLogger.info('Database encryption initialized', { + operation: 'encryption_init', + enabled: this.context.encryptionEnabled, + forceEncryption: this.context.forceEncryption, + dynamicKey: !config.masterPassword + }); + } + + static getContext(): EncryptionContext { + if (!this.context) { + throw new Error('DatabaseEncryption not initialized. Call initialize() first.'); + } + return this.context; + } + + static encryptRecord(tableName: string, record: any): any { + const context = this.getContext(); + if (!context.encryptionEnabled) return record; + + const encryptedRecord = { ...record }; + let hasEncryption = false; + + for (const [fieldName, value] of Object.entries(record)) { + if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) { + try { + const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`); + encryptedRecord[fieldName] = FieldEncryption.encryptField(value as string, fieldKey); + hasEncryption = true; + } catch (error) { + databaseLogger.error(`Failed to encrypt field ${tableName}.${fieldName}`, error, { + operation: 'field_encryption', + table: tableName, + field: fieldName + }); + throw error; + } + } + } + + if (hasEncryption) { + databaseLogger.debug(`Encrypted sensitive fields for ${tableName}`, { + operation: 'record_encryption', + table: tableName + }); + } + + return encryptedRecord; + } + + static decryptRecord(tableName: string, record: any): any { + const context = this.getContext(); + if (!record) return record; + + const decryptedRecord = { ...record }; + let hasDecryption = false; + let needsMigration = false; + + for (const [fieldName, value] of Object.entries(record)) { + if (FieldEncryption.shouldEncryptField(tableName, fieldName) && value) { + try { + const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`); + + if (FieldEncryption.isEncrypted(value as string)) { + decryptedRecord[fieldName] = FieldEncryption.decryptField(value as string, fieldKey); + hasDecryption = true; + } else if (context.encryptionEnabled && !context.forceEncryption) { + decryptedRecord[fieldName] = value; + needsMigration = context.migrateOnAccess; + } else if (context.forceEncryption) { + databaseLogger.warn(`Unencrypted field detected in force encryption mode`, { + operation: 'decryption_warning', + table: tableName, + field: fieldName + }); + decryptedRecord[fieldName] = value; + } + } catch (error) { + databaseLogger.error(`Failed to decrypt field ${tableName}.${fieldName}`, error, { + operation: 'field_decryption', + table: tableName, + field: fieldName + }); + + if (context.forceEncryption) { + throw error; + } else { + decryptedRecord[fieldName] = value; + } + } + } + } + + if (hasDecryption) { + databaseLogger.debug(`Decrypted sensitive fields for ${tableName}`, { + operation: 'record_decryption', + table: tableName + }); + } + + if (needsMigration) { + this.scheduleFieldMigration(tableName, record); + } + + return decryptedRecord; + } + + static decryptRecords(tableName: string, records: any[]): any[] { + if (!Array.isArray(records)) return records; + return records.map(record => this.decryptRecord(tableName, record)); + } + + private static scheduleFieldMigration(tableName: string, record: any) { + setTimeout(async () => { + try { + await this.migrateRecord(tableName, record); + } catch (error) { + databaseLogger.error(`Failed to migrate record ${tableName}:${record.id}`, error, { + operation: 'migration_failed', + table: tableName, + recordId: record.id + }); + } + }, 1000); + } + + static async migrateRecord(tableName: string, record: any): Promise { + const context = this.getContext(); + if (!context.encryptionEnabled || !context.migrateOnAccess) return record; + + let needsUpdate = false; + const updatedRecord = { ...record }; + + for (const [fieldName, value] of Object.entries(record)) { + if (FieldEncryption.shouldEncryptField(tableName, fieldName) && + value && !FieldEncryption.isEncrypted(value as string)) { + try { + const fieldKey = FieldEncryption.getFieldKey(context.masterPassword, `${tableName}.${fieldName}`); + updatedRecord[fieldName] = FieldEncryption.encryptField(value as string, fieldKey); + needsUpdate = true; + } catch (error) { + databaseLogger.error(`Failed to migrate field ${tableName}.${fieldName}`, error, { + operation: 'field_migration', + table: tableName, + field: fieldName, + recordId: record.id + }); + throw error; + } + } + } + + if (needsUpdate) { + databaseLogger.info(`Migrated record to encrypted format`, { + operation: 'record_migration', + table: tableName, + recordId: record.id + }); + } + + return updatedRecord; + } + + static validateConfiguration(): boolean { + try { + const context = this.getContext(); + const testData = 'test-encryption-data'; + const testKey = FieldEncryption.getFieldKey(context.masterPassword, 'test'); + + const encrypted = FieldEncryption.encryptField(testData, testKey); + const decrypted = FieldEncryption.decryptField(encrypted, testKey); + + return decrypted === testData; + } catch (error) { + databaseLogger.error('Encryption configuration validation failed', error, { + operation: 'config_validation' + }); + return false; + } + } + + static getEncryptionStatus() { + try { + const context = this.getContext(); + return { + enabled: context.encryptionEnabled, + forceEncryption: context.forceEncryption, + migrateOnAccess: context.migrateOnAccess, + configValid: this.validateConfiguration() + }; + } catch { + return { + enabled: false, + forceEncryption: false, + migrateOnAccess: false, + configValid: false + }; + } + } + + static async getDetailedStatus() { + const keyManager = EncryptionKeyManager.getInstance(); + const keyStatus = await keyManager.getEncryptionStatus(); + const encryptionStatus = this.getEncryptionStatus(); + + return { + ...encryptionStatus, + key: keyStatus, + initialized: this.context !== null + }; + } + + static async reinitializeWithNewKey(): Promise { + const keyManager = EncryptionKeyManager.getInstance(); + const newKey = await keyManager.regenerateKey(); + + this.context = null; + await this.initialize({ masterPassword: newKey }); + + databaseLogger.warn('Database encryption reinitialized with new key', { + operation: 'encryption_reinit', + requiresMigration: true + }); + } +} + +export { DatabaseEncryption }; +export type { EncryptionContext }; \ No newline at end of file diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts new file mode 100644 index 00000000..6efd4715 --- /dev/null +++ b/src/backend/utils/database-file-encryption.ts @@ -0,0 +1,477 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { HardwareFingerprint } from './hardware-fingerprint.js'; +import { databaseLogger } from './logger.js'; + +interface EncryptedFileMetadata { + iv: string; + tag: string; + version: string; + fingerprint: string; + salt: string; + algorithm: string; +} + +/** + * Database file encryption - encrypts the entire SQLite database file at rest + * This provides an additional security layer on top of field-level encryption + */ +class DatabaseFileEncryption { + private static readonly VERSION = 'v1'; + private static readonly ALGORITHM = 'aes-256-gcm'; + private static readonly KEY_ITERATIONS = 100000; + private static readonly ENCRYPTED_FILE_SUFFIX = '.encrypted'; + private static readonly METADATA_FILE_SUFFIX = '.meta'; + + /** + * Generate file encryption key from hardware fingerprint + */ + private static generateFileEncryptionKey(salt: Buffer): Buffer { + const hardwareFingerprint = HardwareFingerprint.generate(); + + const key = crypto.pbkdf2Sync( + hardwareFingerprint, + salt, + this.KEY_ITERATIONS, + 32, // 256 bits for AES-256 + 'sha256' + ); + + databaseLogger.debug('Generated file encryption key from hardware fingerprint', { + operation: 'file_key_generation', + iterations: this.KEY_ITERATIONS, + keyLength: key.length + }); + + return key; + } + + /** + * Encrypt database from buffer (for in-memory databases) + */ + static encryptDatabaseFromBuffer(buffer: Buffer, targetPath: string): string { + try { + // Generate encryption components + const salt = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const key = this.generateFileEncryptionKey(salt); + + // Encrypt the buffer + const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any; + const encrypted = Buffer.concat([ + cipher.update(buffer), + cipher.final() + ]); + const tag = cipher.getAuthTag(); + + // Create metadata + const metadata: EncryptedFileMetadata = { + iv: iv.toString('hex'), + tag: tag.toString('hex'), + version: this.VERSION, + fingerprint: HardwareFingerprint.generate().substring(0, 16), + salt: salt.toString('hex'), + algorithm: this.ALGORITHM + }; + + // Write encrypted file and metadata + const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; + fs.writeFileSync(targetPath, encrypted); + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + + databaseLogger.info('Database buffer encrypted successfully', { + operation: 'database_buffer_encryption', + targetPath, + bufferSize: buffer.length, + encryptedSize: encrypted.length, + fingerprintPrefix: metadata.fingerprint + }); + + return targetPath; + } catch (error) { + databaseLogger.error('Failed to encrypt database buffer', error, { + operation: 'database_buffer_encryption_failed', + targetPath + }); + throw new Error(`Database buffer encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Encrypt database file + */ + static encryptDatabaseFile(sourcePath: string, targetPath?: string): string { + if (!fs.existsSync(sourcePath)) { + throw new Error(`Source database file does not exist: ${sourcePath}`); + } + + const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + + try { + // Read source file + const sourceData = fs.readFileSync(sourcePath); + + // Generate encryption components + const salt = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const key = this.generateFileEncryptionKey(salt); + + // Encrypt the file + const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any; + const encrypted = Buffer.concat([ + cipher.update(sourceData), + cipher.final() + ]); + const tag = cipher.getAuthTag(); + + // Create metadata + const metadata: EncryptedFileMetadata = { + iv: iv.toString('hex'), + tag: tag.toString('hex'), + version: this.VERSION, + fingerprint: HardwareFingerprint.generate().substring(0, 16), + salt: salt.toString('hex'), + algorithm: this.ALGORITHM + }; + + // Write encrypted file and metadata + fs.writeFileSync(encryptedPath, encrypted); + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + + databaseLogger.info('Database file encrypted successfully', { + operation: 'database_file_encryption', + sourcePath, + encryptedPath, + fileSize: sourceData.length, + encryptedSize: encrypted.length, + fingerprintPrefix: metadata.fingerprint + }); + + return encryptedPath; + } catch (error) { + databaseLogger.error('Failed to encrypt database file', error, { + operation: 'database_file_encryption_failed', + sourcePath, + targetPath: encryptedPath + }); + throw new Error(`Database file encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Decrypt database file to buffer (for in-memory usage) + */ + static decryptDatabaseToBuffer(encryptedPath: string): Buffer { + if (!fs.existsSync(encryptedPath)) { + throw new Error(`Encrypted database file does not exist: ${encryptedPath}`); + } + + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + if (!fs.existsSync(metadataPath)) { + throw new Error(`Metadata file does not exist: ${metadataPath}`); + } + + try { + // Read metadata + const metadataContent = fs.readFileSync(metadataPath, 'utf8'); + const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + + // Validate metadata version + if (metadata.version !== this.VERSION) { + throw new Error(`Unsupported encryption version: ${metadata.version}`); + } + + // Validate hardware fingerprint + const currentFingerprint = HardwareFingerprint.generate().substring(0, 16); + if (metadata.fingerprint !== currentFingerprint) { + databaseLogger.warn('Hardware fingerprint mismatch for database buffer decryption', { + operation: 'database_buffer_decryption', + expected: metadata.fingerprint, + current: currentFingerprint + }); + throw new Error('Hardware fingerprint mismatch - database was encrypted on different hardware'); + } + + // Read encrypted data + const encryptedData = fs.readFileSync(encryptedPath); + + // Generate decryption key + const salt = Buffer.from(metadata.salt, 'hex'); + const key = this.generateFileEncryptionKey(salt); + + // Decrypt to buffer + const decipher = crypto.createDecipheriv( + metadata.algorithm, + key, + Buffer.from(metadata.iv, 'hex') + ) as any; + decipher.setAuthTag(Buffer.from(metadata.tag, 'hex')); + + const decryptedBuffer = Buffer.concat([ + decipher.update(encryptedData), + decipher.final() + ]); + + databaseLogger.info('Database decrypted to memory buffer', { + operation: 'database_buffer_decryption', + encryptedPath, + encryptedSize: encryptedData.length, + decryptedSize: decryptedBuffer.length, + fingerprintPrefix: metadata.fingerprint + }); + + return decryptedBuffer; + } catch (error) { + databaseLogger.error('Failed to decrypt database to buffer', error, { + operation: 'database_buffer_decryption_failed', + encryptedPath + }); + throw new Error(`Database buffer decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Decrypt database file + */ + static decryptDatabaseFile(encryptedPath: string, targetPath?: string): string { + if (!fs.existsSync(encryptedPath)) { + throw new Error(`Encrypted database file does not exist: ${encryptedPath}`); + } + + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + if (!fs.existsSync(metadataPath)) { + throw new Error(`Metadata file does not exist: ${metadataPath}`); + } + + const decryptedPath = targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, ''); + + try { + // Read metadata + const metadataContent = fs.readFileSync(metadataPath, 'utf8'); + const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + + // Validate metadata version + if (metadata.version !== this.VERSION) { + throw new Error(`Unsupported encryption version: ${metadata.version}`); + } + + // Validate hardware fingerprint + const currentFingerprint = HardwareFingerprint.generate().substring(0, 16); + if (metadata.fingerprint !== currentFingerprint) { + databaseLogger.warn('Hardware fingerprint mismatch for database file', { + operation: 'database_file_decryption', + expected: metadata.fingerprint, + current: currentFingerprint + }); + throw new Error('Hardware fingerprint mismatch - database was encrypted on different hardware'); + } + + // Read encrypted data + const encryptedData = fs.readFileSync(encryptedPath); + + // Generate decryption key + const salt = Buffer.from(metadata.salt, 'hex'); + const key = this.generateFileEncryptionKey(salt); + + // Decrypt the file + const decipher = crypto.createDecipheriv( + metadata.algorithm, + key, + Buffer.from(metadata.iv, 'hex') + ) as any; + decipher.setAuthTag(Buffer.from(metadata.tag, 'hex')); + + const decrypted = Buffer.concat([ + decipher.update(encryptedData), + decipher.final() + ]); + + // Write decrypted file + fs.writeFileSync(decryptedPath, decrypted); + + databaseLogger.info('Database file decrypted successfully', { + operation: 'database_file_decryption', + encryptedPath, + decryptedPath, + encryptedSize: encryptedData.length, + decryptedSize: decrypted.length, + fingerprintPrefix: metadata.fingerprint + }); + + return decryptedPath; + } catch (error) { + databaseLogger.error('Failed to decrypt database file', error, { + operation: 'database_file_decryption_failed', + encryptedPath, + targetPath: decryptedPath + }); + throw new Error(`Database file decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Check if a file is an encrypted database file + */ + static isEncryptedDatabaseFile(filePath: string): boolean { + const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; + + if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) { + return false; + } + + try { + const metadataContent = fs.readFileSync(metadataPath, 'utf8'); + const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + return metadata.version === this.VERSION && metadata.algorithm === this.ALGORITHM; + } catch { + return false; + } + } + + /** + * Get information about an encrypted database file + */ + static getEncryptedFileInfo(encryptedPath: string): { + version: string; + algorithm: string; + fingerprint: string; + isCurrentHardware: boolean; + fileSize: number; + } | null { + if (!this.isEncryptedDatabaseFile(encryptedPath)) { + return null; + } + + try { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const metadataContent = fs.readFileSync(metadataPath, 'utf8'); + const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + + const fileStats = fs.statSync(encryptedPath); + const currentFingerprint = HardwareFingerprint.generate().substring(0, 16); + + return { + version: metadata.version, + algorithm: metadata.algorithm, + fingerprint: metadata.fingerprint, + isCurrentHardware: metadata.fingerprint === currentFingerprint, + fileSize: fileStats.size + }; + } catch { + return null; + } + } + + /** + * Securely backup database by creating encrypted copy + */ + static createEncryptedBackup(databasePath: string, backupDir: string): string { + if (!fs.existsSync(databasePath)) { + throw new Error(`Database file does not exist: ${databasePath}`); + } + + // Ensure backup directory exists + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + // Generate backup filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`; + const backupPath = path.join(backupDir, backupFileName); + + try { + const encryptedPath = this.encryptDatabaseFile(databasePath, backupPath); + + databaseLogger.info('Encrypted database backup created', { + operation: 'database_backup', + sourcePath: databasePath, + backupPath: encryptedPath, + timestamp + }); + + return encryptedPath; + } catch (error) { + databaseLogger.error('Failed to create encrypted backup', error, { + operation: 'database_backup_failed', + sourcePath: databasePath, + backupDir + }); + throw error; + } + } + + /** + * Restore database from encrypted backup + */ + static restoreFromEncryptedBackup(backupPath: string, targetPath: string): string { + if (!this.isEncryptedDatabaseFile(backupPath)) { + throw new Error('Invalid encrypted backup file'); + } + + try { + const restoredPath = this.decryptDatabaseFile(backupPath, targetPath); + + databaseLogger.info('Database restored from encrypted backup', { + operation: 'database_restore', + backupPath, + restoredPath + }); + + return restoredPath; + } catch (error) { + databaseLogger.error('Failed to restore from encrypted backup', error, { + operation: 'database_restore_failed', + backupPath, + targetPath + }); + throw error; + } + } + + /** + * Validate hardware compatibility for encrypted file + */ + static validateHardwareCompatibility(encryptedPath: string): boolean { + try { + const info = this.getEncryptedFileInfo(encryptedPath); + return info?.isCurrentHardware ?? false; + } catch { + return false; + } + } + + /** + * Clean up temporary files + */ + static cleanupTempFiles(basePath: string): void { + try { + const tempFiles = [ + `${basePath}.tmp`, + `${basePath}${this.ENCRYPTED_FILE_SUFFIX}`, + `${basePath}${this.ENCRYPTED_FILE_SUFFIX}${this.METADATA_FILE_SUFFIX}` + ]; + + for (const tempFile of tempFiles) { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + databaseLogger.debug('Cleaned up temporary file', { + operation: 'temp_cleanup', + file: tempFile + }); + } + } + } catch (error) { + databaseLogger.warn('Failed to clean up temporary files', { + operation: 'temp_cleanup_failed', + basePath, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } +} + +export { DatabaseFileEncryption }; +export type { EncryptedFileMetadata }; \ No newline at end of file diff --git a/src/backend/utils/database-migration.ts b/src/backend/utils/database-migration.ts new file mode 100644 index 00000000..76754b61 --- /dev/null +++ b/src/backend/utils/database-migration.ts @@ -0,0 +1,437 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { DatabaseFileEncryption } from './database-file-encryption.js'; +import { DatabaseEncryption } from './database-encryption.js'; +import { FieldEncryption } from './encryption.js'; +import { HardwareFingerprint } from './hardware-fingerprint.js'; +import { databaseLogger } from './logger.js'; +import { db, databasePaths } from '../database/db/index.js'; +import { users, sshData, sshCredentials, settings, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts, sshCredentialUsage } from '../database/db/schema.js'; + +interface ExportMetadata { + version: string; + exportedAt: string; + exportId: string; + sourceHardwareFingerprint: string; + tableCount: number; + recordCount: number; + encryptedFields: string[]; +} + +interface MigrationExport { + metadata: ExportMetadata; + data: { + [tableName: string]: any[]; + }; +} + +interface ImportResult { + success: boolean; + imported: { + tables: number; + records: number; + }; + errors: string[]; + warnings: string[]; +} + +/** + * Database migration utility for exporting/importing data between different hardware + * Handles both field-level and file-level encryption/decryption during migration + */ +class DatabaseMigration { + private static readonly VERSION = 'v1'; + private static readonly EXPORT_FILE_EXTENSION = '.termix-export.json'; + + /** + * Export database for migration + * Decrypts all encrypted fields for transport to new hardware + */ + static async exportDatabase(exportPath?: string): Promise { + const exportId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + const defaultExportPath = path.join( + databasePaths.directory, + `termix-export-${timestamp.replace(/[:.]/g, '-')}${this.EXPORT_FILE_EXTENSION}` + ); + const actualExportPath = exportPath || defaultExportPath; + + try { + databaseLogger.info('Starting database export for migration', { + operation: 'database_export', + exportId, + exportPath: actualExportPath + }); + + // Define tables to export and their encryption status + const tablesToExport = [ + { name: 'users', table: users, hasEncryption: true }, + { name: 'ssh_data', table: sshData, hasEncryption: true }, + { name: 'ssh_credentials', table: sshCredentials, hasEncryption: true }, + { name: 'settings', table: settings, hasEncryption: false }, + { name: 'file_manager_recent', table: fileManagerRecent, hasEncryption: false }, + { name: 'file_manager_pinned', table: fileManagerPinned, hasEncryption: false }, + { name: 'file_manager_shortcuts', table: fileManagerShortcuts, hasEncryption: false }, + { name: 'dismissed_alerts', table: dismissedAlerts, hasEncryption: false }, + { name: 'ssh_credential_usage', table: sshCredentialUsage, hasEncryption: false } + ]; + + const exportData: MigrationExport = { + metadata: { + version: this.VERSION, + exportedAt: timestamp, + exportId, + sourceHardwareFingerprint: HardwareFingerprint.generate().substring(0, 16), + tableCount: 0, + recordCount: 0, + encryptedFields: [] + }, + data: {} + }; + + let totalRecords = 0; + + // Export each table + for (const tableInfo of tablesToExport) { + try { + databaseLogger.debug(`Exporting table: ${tableInfo.name}`, { + operation: 'table_export', + table: tableInfo.name, + hasEncryption: tableInfo.hasEncryption + }); + + // Query all records from the table + const records = await db.select().from(tableInfo.table); + + // Decrypt encrypted fields if necessary + let processedRecords = records; + if (tableInfo.hasEncryption && records.length > 0) { + processedRecords = records.map(record => { + try { + return DatabaseEncryption.decryptRecord(tableInfo.name, record); + } catch (error) { + databaseLogger.warn(`Failed to decrypt record in ${tableInfo.name}`, { + operation: 'export_decrypt_warning', + table: tableInfo.name, + recordId: (record as any).id, + error: error instanceof Error ? error.message : 'Unknown error' + }); + // Return original record if decryption fails + return record; + } + }); + + // Track which fields were encrypted + if (records.length > 0) { + const sampleRecord = records[0]; + for (const fieldName of Object.keys(sampleRecord)) { + if (FieldEncryption.shouldEncryptField(tableInfo.name, fieldName)) { + const fieldKey = `${tableInfo.name}.${fieldName}`; + if (!exportData.metadata.encryptedFields.includes(fieldKey)) { + exportData.metadata.encryptedFields.push(fieldKey); + } + } + } + } + } + + exportData.data[tableInfo.name] = processedRecords; + totalRecords += processedRecords.length; + + databaseLogger.debug(`Table ${tableInfo.name} exported`, { + operation: 'table_export_complete', + table: tableInfo.name, + recordCount: processedRecords.length + }); + } catch (error) { + databaseLogger.error(`Failed to export table ${tableInfo.name}`, error, { + operation: 'table_export_failed', + table: tableInfo.name + }); + throw error; + } + } + + // Update metadata + exportData.metadata.tableCount = tablesToExport.length; + exportData.metadata.recordCount = totalRecords; + + // Write export file + const exportContent = JSON.stringify(exportData, null, 2); + fs.writeFileSync(actualExportPath, exportContent, 'utf8'); + + databaseLogger.success('Database export completed successfully', { + operation: 'database_export_complete', + exportId, + exportPath: actualExportPath, + tableCount: exportData.metadata.tableCount, + recordCount: exportData.metadata.recordCount, + fileSize: exportContent.length + }); + + return actualExportPath; + } catch (error) { + databaseLogger.error('Database export failed', error, { + operation: 'database_export_failed', + exportId, + exportPath: actualExportPath + }); + throw new Error(`Database export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Import database from migration export + * Re-encrypts fields for the current hardware + */ + static async importDatabase(importPath: string, options: { + replaceExisting?: boolean; + backupCurrent?: boolean; + } = {}): Promise { + const { replaceExisting = false, backupCurrent = true } = options; + + if (!fs.existsSync(importPath)) { + throw new Error(`Import file does not exist: ${importPath}`); + } + + try { + databaseLogger.info('Starting database import from migration export', { + operation: 'database_import', + importPath, + replaceExisting, + backupCurrent + }); + + // Read and validate export file + const exportContent = fs.readFileSync(importPath, 'utf8'); + const exportData: MigrationExport = JSON.parse(exportContent); + + // Validate export format + if (exportData.metadata.version !== this.VERSION) { + throw new Error(`Unsupported export version: ${exportData.metadata.version}`); + } + + const result: ImportResult = { + success: false, + imported: { tables: 0, records: 0 }, + errors: [], + warnings: [] + }; + + // Create backup if requested + if (backupCurrent) { + try { + const backupPath = await this.createCurrentDatabaseBackup(); + databaseLogger.info('Current database backed up before import', { + operation: 'import_backup', + backupPath + }); + } catch (error) { + const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}`; + result.warnings.push(warningMsg); + databaseLogger.warn('Failed to create pre-import backup', { + operation: 'import_backup_failed', + error: warningMsg + }); + } + } + + // Import data table by table + for (const [tableName, tableData] of Object.entries(exportData.data)) { + try { + databaseLogger.debug(`Importing table: ${tableName}`, { + operation: 'table_import', + table: tableName, + recordCount: tableData.length + }); + + if (replaceExisting) { + // Clear existing data + const tableSchema = this.getTableSchema(tableName); + if (tableSchema) { + await db.delete(tableSchema); + databaseLogger.debug(`Cleared existing data from ${tableName}`, { + operation: 'table_clear', + table: tableName + }); + } + } + + // Process and encrypt records + for (const record of tableData) { + try { + // Re-encrypt sensitive fields for current hardware + const processedRecord = DatabaseEncryption.encryptRecord(tableName, record); + + // Insert record + const tableSchema = this.getTableSchema(tableName); + if (tableSchema) { + await db.insert(tableSchema).values(processedRecord); + } + } catch (error) { + const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; + result.errors.push(errorMsg); + databaseLogger.error('Failed to import record', error, { + operation: 'record_import_failed', + table: tableName, + recordId: record.id + }); + } + } + + result.imported.tables++; + result.imported.records += tableData.length; + + databaseLogger.debug(`Table ${tableName} imported`, { + operation: 'table_import_complete', + table: tableName, + recordCount: tableData.length + }); + } catch (error) { + const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; + result.errors.push(errorMsg); + databaseLogger.error('Failed to import table', error, { + operation: 'table_import_failed', + table: tableName + }); + } + } + + // Check if import was successful + result.success = result.errors.length === 0; + + if (result.success) { + databaseLogger.success('Database import completed successfully', { + operation: 'database_import_complete', + importPath, + tablesImported: result.imported.tables, + recordsImported: result.imported.records, + warnings: result.warnings.length + }); + } else { + databaseLogger.error('Database import completed with errors', undefined, { + operation: 'database_import_partial', + importPath, + tablesImported: result.imported.tables, + recordsImported: result.imported.records, + errorCount: result.errors.length, + warningCount: result.warnings.length + }); + } + + return result; + } catch (error) { + databaseLogger.error('Database import failed', error, { + operation: 'database_import_failed', + importPath + }); + throw new Error(`Database import failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Validate export file format and compatibility + */ + static validateExportFile(exportPath: string): { + valid: boolean; + metadata?: ExportMetadata; + errors: string[]; + } { + const result = { + valid: false, + metadata: undefined as ExportMetadata | undefined, + errors: [] as string[] + }; + + try { + if (!fs.existsSync(exportPath)) { + result.errors.push('Export file does not exist'); + return result; + } + + const exportContent = fs.readFileSync(exportPath, 'utf8'); + const exportData: MigrationExport = JSON.parse(exportContent); + + // Validate structure + if (!exportData.metadata || !exportData.data) { + result.errors.push('Invalid export file structure'); + return result; + } + + // Validate version + if (exportData.metadata.version !== this.VERSION) { + result.errors.push(`Unsupported export version: ${exportData.metadata.version}`); + return result; + } + + // Validate required metadata fields + const requiredFields = ['exportedAt', 'exportId', 'sourceHardwareFingerprint']; + for (const field of requiredFields) { + if (!exportData.metadata[field as keyof ExportMetadata]) { + result.errors.push(`Missing required metadata field: ${field}`); + } + } + + if (result.errors.length === 0) { + result.valid = true; + result.metadata = exportData.metadata; + } + + return result; + } catch (error) { + result.errors.push(`Failed to parse export file: ${error instanceof Error ? error.message : 'Unknown error'}`); + return result; + } + } + + /** + * Create backup of current database + */ + private static async createCurrentDatabaseBackup(): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupDir = path.join(databasePaths.directory, 'backups'); + + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + // Create encrypted backup + const backupPath = DatabaseFileEncryption.createEncryptedBackup( + databasePaths.main, + backupDir + ); + + return backupPath; + } + + /** + * Get table schema for database operations + */ + private static getTableSchema(tableName: string) { + const tableMap: { [key: string]: any } = { + 'users': users, + 'ssh_data': sshData, + 'ssh_credentials': sshCredentials, + 'settings': settings, + 'file_manager_recent': fileManagerRecent, + 'file_manager_pinned': fileManagerPinned, + 'file_manager_shortcuts': fileManagerShortcuts, + 'dismissed_alerts': dismissedAlerts, + 'ssh_credential_usage': sshCredentialUsage + }; + + return tableMap[tableName]; + } + + /** + * Get export file info without importing + */ + static getExportInfo(exportPath: string): ExportMetadata | null { + const validation = this.validateExportFile(exportPath); + return validation.valid ? validation.metadata! : null; + } +} + +export { DatabaseMigration }; +export type { ExportMetadata, MigrationExport, ImportResult }; \ No newline at end of file diff --git a/src/backend/utils/database-sqlite-export.ts b/src/backend/utils/database-sqlite-export.ts new file mode 100644 index 00000000..03cf900a --- /dev/null +++ b/src/backend/utils/database-sqlite-export.ts @@ -0,0 +1,649 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import Database from 'better-sqlite3'; +import { sql, eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { DatabaseEncryption } from './database-encryption.js'; +import { FieldEncryption } from './encryption.js'; +import { HardwareFingerprint } from './hardware-fingerprint.js'; +import { databaseLogger } from './logger.js'; +import { databasePaths, db, sqliteInstance } from '../database/db/index.js'; +import { sshData, sshCredentials, users } from '../database/db/schema.js'; + +interface ExportMetadata { + version: string; + exportedAt: string; + exportId: string; + sourceHardwareFingerprint: string; + tableCount: number; + recordCount: number; + encryptedFields: string[]; +} + +interface ImportResult { + success: boolean; + imported: { + tables: number; + records: number; + }; + errors: string[]; + warnings: string[]; +} + +/** + * SQLite database export/import utility for hardware migration + * Exports decrypted data to a new SQLite database file for hardware transfer + */ +class DatabaseSQLiteExport { + private static readonly VERSION = 'v1'; + private static readonly EXPORT_FILE_EXTENSION = '.termix-export.sqlite'; + private static readonly METADATA_TABLE = '_termix_export_metadata'; + + /** + * Export database as SQLite file for migration + * Creates a new SQLite database with decrypted data + */ + static async exportDatabase(exportPath?: string): Promise { + const exportId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + const defaultExportPath = path.join( + databasePaths.directory, + `termix-export-${timestamp.replace(/[:.]/g, '-')}${this.EXPORT_FILE_EXTENSION}` + ); + const actualExportPath = exportPath || defaultExportPath; + + try { + databaseLogger.info('Starting SQLite database export for migration', { + operation: 'database_sqlite_export', + exportId, + exportPath: actualExportPath + }); + + // Create new SQLite database for export + const exportDb = new Database(actualExportPath); + + // Define tables to export - only SSH-related data + const tablesToExport = [ + { name: 'ssh_data', hasEncryption: true }, + { name: 'ssh_credentials', hasEncryption: true } + ]; + + const exportMetadata: ExportMetadata = { + version: this.VERSION, + exportedAt: timestamp, + exportId, + sourceHardwareFingerprint: HardwareFingerprint.generate().substring(0, 16), + tableCount: 0, + recordCount: 0, + encryptedFields: [] + }; + + let totalRecords = 0; + + // Check total records in SSH tables for debugging + const totalSshData = await db.select().from(sshData); + const totalSshCredentials = await db.select().from(sshCredentials); + + databaseLogger.info(`Export preparation: found SSH data`, { + operation: 'export_data_check', + totalSshData: totalSshData.length, + totalSshCredentials: totalSshCredentials.length + }); + + // Create metadata table + exportDb.exec(` + CREATE TABLE ${this.METADATA_TABLE} ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + + // Copy schema and data for each table + for (const tableInfo of tablesToExport) { + try { + databaseLogger.debug(`Exporting SQLite table: ${tableInfo.name}`, { + operation: 'table_sqlite_export', + table: tableInfo.name, + hasEncryption: tableInfo.hasEncryption + }); + + // Create table in export database using consistent schema + if (tableInfo.name === 'ssh_data') { + // Create ssh_data table using exact schema matching Drizzle definition + const createTableSql = `CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + password TEXT, + require_password INTEGER NOT NULL DEFAULT 1, + key TEXT, + key_password TEXT, + key_type TEXT, + credential_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + default_path TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`; + exportDb.exec(createTableSql); + } else if (tableInfo.name === 'ssh_credentials') { + // Create ssh_credentials table using exact schema matching Drizzle definition + const createTableSql = `CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT, + password TEXT, + key_content TEXT, + key_password TEXT, + key_type TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`; + exportDb.exec(createTableSql); + } else { + databaseLogger.warn(`Unknown table ${tableInfo.name}, skipping`, { + operation: 'table_sqlite_export_skip', + table: tableInfo.name + }); + continue; + } + + // Query all records from tables using Drizzle + let records: any[]; + if (tableInfo.name === 'ssh_data') { + records = await db.select().from(sshData); + } else if (tableInfo.name === 'ssh_credentials') { + records = await db.select().from(sshCredentials); + } else { + records = []; + } + + databaseLogger.info(`Found ${records.length} records in ${tableInfo.name} for export`, { + operation: 'table_record_count', + table: tableInfo.name, + recordCount: records.length + }); + + // Decrypt encrypted fields if necessary + let processedRecords = records; + if (tableInfo.hasEncryption && records.length > 0) { + processedRecords = records.map(record => { + try { + return DatabaseEncryption.decryptRecord(tableInfo.name, record); + } catch (error) { + databaseLogger.warn(`Failed to decrypt record in ${tableInfo.name}`, { + operation: 'export_decrypt_warning', + table: tableInfo.name, + recordId: (record as any).id, + error: error instanceof Error ? error.message : 'Unknown error' + }); + return record; + } + }); + + // Track encrypted fields + const sampleRecord = records[0]; + for (const fieldName of Object.keys(sampleRecord)) { + if (this.shouldTrackEncryptedField(tableInfo.name, fieldName)) { + const fieldKey = `${tableInfo.name}.${fieldName}`; + if (!exportMetadata.encryptedFields.includes(fieldKey)) { + exportMetadata.encryptedFields.push(fieldKey); + } + } + } + } + + // Insert records into export database + if (processedRecords.length > 0) { + const sampleRecord = processedRecords[0]; + const tsFieldNames = Object.keys(sampleRecord); + + // Map TypeScript field names to database column names + const dbColumnNames = tsFieldNames.map(fieldName => { + // Map TypeScript field names to database column names + const fieldMappings: Record = { + 'userId': 'user_id', + 'authType': 'auth_type', + 'requirePassword': 'require_password', + 'keyPassword': 'key_password', + 'keyType': 'key_type', + 'credentialId': 'credential_id', + 'enableTerminal': 'enable_terminal', + 'enableTunnel': 'enable_tunnel', + 'tunnelConnections': 'tunnel_connections', + 'enableFileManager': 'enable_file_manager', + 'defaultPath': 'default_path', + 'createdAt': 'created_at', + 'updatedAt': 'updated_at', + 'keyContent': 'key_content' + }; + return fieldMappings[fieldName] || fieldName; + }); + + const placeholders = dbColumnNames.map(() => '?').join(', '); + const insertSql = `INSERT INTO ${tableInfo.name} (${dbColumnNames.join(', ')}) VALUES (${placeholders})`; + + const insertStmt = exportDb.prepare(insertSql); + + for (const record of processedRecords) { + const values = tsFieldNames.map(fieldName => { + const value: any = record[fieldName as keyof typeof record]; + // Convert values to SQLite-compatible types + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') { + return value; + } + if (Buffer.isBuffer(value)) { + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + // Convert objects and arrays to JSON strings + if (typeof value === 'object') { + return JSON.stringify(value); + } + // Fallback: convert to string + return String(value); + }); + insertStmt.run(values); + } + } + + totalRecords += processedRecords.length; + + databaseLogger.debug(`SQLite table ${tableInfo.name} exported`, { + operation: 'table_sqlite_export_complete', + table: tableInfo.name, + recordCount: processedRecords.length + }); + } catch (error) { + databaseLogger.error(`Failed to export SQLite table ${tableInfo.name}`, error, { + operation: 'table_sqlite_export_failed', + table: tableInfo.name + }); + throw error; + } + } + + // Update and store metadata + exportMetadata.tableCount = tablesToExport.length; + exportMetadata.recordCount = totalRecords; + + const insertMetadata = exportDb.prepare(`INSERT INTO ${this.METADATA_TABLE} (key, value) VALUES (?, ?)`); + insertMetadata.run('metadata', JSON.stringify(exportMetadata)); + + // Close export database + exportDb.close(); + + databaseLogger.success('SQLite database export completed successfully', { + operation: 'database_sqlite_export_complete', + exportId, + exportPath: actualExportPath, + tableCount: exportMetadata.tableCount, + recordCount: exportMetadata.recordCount, + fileSize: fs.statSync(actualExportPath).size + }); + + return actualExportPath; + } catch (error) { + databaseLogger.error('SQLite database export failed', error, { + operation: 'database_sqlite_export_failed', + exportId, + exportPath: actualExportPath + }); + throw new Error(`SQLite database export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Import database from SQLite export + * Re-encrypts fields for the current hardware + */ + static async importDatabase(importPath: string, options: { + replaceExisting?: boolean; + backupCurrent?: boolean; + } = {}): Promise { + const { replaceExisting = false, backupCurrent = true } = options; + + if (!fs.existsSync(importPath)) { + throw new Error(`Import file does not exist: ${importPath}`); + } + + try { + databaseLogger.info('Starting SQLite database import from export', { + operation: 'database_sqlite_import', + importPath, + replaceExisting, + backupCurrent + }); + + // Open import database + const importDb = new Database(importPath, { readonly: true }); + + // Validate export format + const metadataResult = importDb.prepare(` + SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata' + `).get() as { value: string } | undefined; + + if (!metadataResult) { + throw new Error('Invalid export file: missing metadata'); + } + + const metadata: ExportMetadata = JSON.parse(metadataResult.value); + if (metadata.version !== this.VERSION) { + throw new Error(`Unsupported export version: ${metadata.version}`); + } + + const result: ImportResult = { + success: false, + imported: { tables: 0, records: 0 }, + errors: [], + warnings: [] + }; + + // Get current admin user to assign imported SSH records + const adminUser = await db.select().from(users).where(eq(users.is_admin, true)).limit(1); + if (adminUser.length === 0) { + throw new Error('No admin user found in current database'); + } + const currentAdminUserId = adminUser[0].id; + + databaseLogger.debug(`Starting SSH data import - assigning to admin user ${currentAdminUserId}`, { + operation: 'ssh_data_import_start', + adminUserId: currentAdminUserId + }); + + // Create backup if requested + if (backupCurrent) { + try { + const backupPath = await this.createCurrentDatabaseBackup(); + databaseLogger.info('Current database backed up before import', { + operation: 'import_backup', + backupPath + }); + } catch (error) { + const warningMsg = `Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}`; + result.warnings.push(warningMsg); + databaseLogger.warn('Failed to create pre-import backup', { + operation: 'import_backup_failed', + error: warningMsg + }); + } + } + + // Get list of tables to import (excluding metadata table) + const tables = importDb.prepare(` + SELECT name FROM sqlite_master + WHERE type='table' AND name != '${this.METADATA_TABLE}' + `).all() as { name: string }[]; + + // Import data table by table + for (const tableRow of tables) { + const tableName = tableRow.name; + + try { + databaseLogger.debug(`Importing SQLite table: ${tableName}`, { + operation: 'table_sqlite_import', + table: tableName + }); + + // Use additive import - don't clear existing data + // This preserves all current data including admin SSH connections + databaseLogger.debug(`Using additive import for ${tableName}`, { + operation: 'table_additive_import', + table: tableName + }); + + // Get all records from import table + const records = importDb.prepare(`SELECT * FROM ${tableName}`).all(); + + // Process and encrypt records + for (const record of records) { + try { + // Import all SSH data without user filtering + + // Map database column names to TypeScript field names + const mappedRecord: any = {}; + const columnToFieldMappings: Record = { + 'user_id': 'userId', + 'auth_type': 'authType', + 'require_password': 'requirePassword', + 'key_password': 'keyPassword', + 'key_type': 'keyType', + 'credential_id': 'credentialId', + 'enable_terminal': 'enableTerminal', + 'enable_tunnel': 'enableTunnel', + 'tunnel_connections': 'tunnelConnections', + 'enable_file_manager': 'enableFileManager', + 'default_path': 'defaultPath', + 'created_at': 'createdAt', + 'updated_at': 'updatedAt', + 'key_content': 'keyContent' + }; + + // Convert database column names to TypeScript field names + for (const [dbColumn, value] of Object.entries(record)) { + const tsField = columnToFieldMappings[dbColumn] || dbColumn; + mappedRecord[tsField] = value; + } + + // Assign imported SSH records to current admin user to avoid foreign key constraint + if (tableName === 'ssh_data' && mappedRecord.userId) { + const originalUserId = mappedRecord.userId; + mappedRecord.userId = currentAdminUserId; + databaseLogger.debug(`Reassigned SSH record from user ${originalUserId} to admin ${currentAdminUserId}`, { + operation: 'user_reassignment', + originalUserId, + newUserId: currentAdminUserId + }); + } + + // Re-encrypt sensitive fields for current hardware + const processedRecord = DatabaseEncryption.encryptRecord(tableName, mappedRecord); + + // Insert record using Drizzle + try { + if (tableName === 'ssh_data') { + await db.insert(sshData).values(processedRecord).onConflictDoNothing(); + } else if (tableName === 'ssh_credentials') { + await db.insert(sshCredentials).values(processedRecord).onConflictDoNothing(); + } + } catch (error) { + // Handle any SQL errors gracefully + if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { + databaseLogger.debug(`Skipping duplicate record in ${tableName}`, { + operation: 'duplicate_record_skip', + table: tableName + }); + continue; + } + throw error; + } + } catch (error) { + const errorMsg = `Failed to import record in ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; + result.errors.push(errorMsg); + databaseLogger.error('Failed to import record', error, { + operation: 'record_sqlite_import_failed', + table: tableName, + recordId: (record as any).id + }); + } + } + + result.imported.tables++; + result.imported.records += records.length; + + databaseLogger.debug(`SQLite table ${tableName} imported`, { + operation: 'table_sqlite_import_complete', + table: tableName, + recordCount: records.length + }); + } catch (error) { + const errorMsg = `Failed to import table ${tableName}: ${error instanceof Error ? error.message : 'Unknown error'}`; + result.errors.push(errorMsg); + databaseLogger.error('Failed to import SQLite table', error, { + operation: 'table_sqlite_import_failed', + table: tableName + }); + } + } + + // Close import database + importDb.close(); + + // Check if import was successful + result.success = result.errors.length === 0; + + if (result.success) { + databaseLogger.success('SQLite database import completed successfully', { + operation: 'database_sqlite_import_complete', + importPath, + tablesImported: result.imported.tables, + recordsImported: result.imported.records, + warnings: result.warnings.length + }); + } else { + databaseLogger.error('SQLite database import completed with errors', undefined, { + operation: 'database_sqlite_import_partial', + importPath, + tablesImported: result.imported.tables, + recordsImported: result.imported.records, + errorCount: result.errors.length, + warningCount: result.warnings.length + }); + } + + return result; + } catch (error) { + databaseLogger.error('SQLite database import failed', error, { + operation: 'database_sqlite_import_failed', + importPath + }); + throw new Error(`SQLite database import failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Validate SQLite export file + */ + static validateExportFile(exportPath: string): { + valid: boolean; + metadata?: ExportMetadata; + errors: string[]; + } { + const result = { + valid: false, + metadata: undefined as ExportMetadata | undefined, + errors: [] as string[] + }; + + try { + if (!fs.existsSync(exportPath)) { + result.errors.push('Export file does not exist'); + return result; + } + + if (!exportPath.endsWith(this.EXPORT_FILE_EXTENSION)) { + result.errors.push('Invalid export file extension'); + return result; + } + + const exportDb = new Database(exportPath, { readonly: true }); + + try { + const metadataResult = exportDb.prepare(` + SELECT value FROM ${this.METADATA_TABLE} WHERE key = 'metadata' + `).get() as { value: string } | undefined; + + if (!metadataResult) { + result.errors.push('Missing export metadata'); + return result; + } + + const metadata: ExportMetadata = JSON.parse(metadataResult.value); + + if (metadata.version !== this.VERSION) { + result.errors.push(`Unsupported export version: ${metadata.version}`); + return result; + } + + result.valid = true; + result.metadata = metadata; + } finally { + exportDb.close(); + } + + return result; + } catch (error) { + result.errors.push(`Failed to validate export file: ${error instanceof Error ? error.message : 'Unknown error'}`); + return result; + } + } + + /** + * Get export file info without importing + */ + static getExportInfo(exportPath: string): ExportMetadata | null { + const validation = this.validateExportFile(exportPath); + return validation.valid ? validation.metadata! : null; + } + + /** + * Create backup of current database + */ + private static async createCurrentDatabaseBackup(): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupDir = path.join(databasePaths.directory, 'backups'); + + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + + // Create SQLite backup + const backupPath = path.join(backupDir, `database-backup-${timestamp}.sqlite`); + + // Copy current database file + fs.copyFileSync(databasePaths.main, backupPath); + + return backupPath; + } + + /** + * Get table schema for database operations + * NOTE: This method is deprecated - we now use raw SQL to avoid FK issues + */ + private static getTableSchema(tableName: string) { + return null; // No longer used + } + + /** + * Check if a field should be tracked as encrypted + */ + private static shouldTrackEncryptedField(tableName: string, fieldName: string): boolean { + try { + return FieldEncryption.shouldEncryptField(tableName, fieldName); + } catch { + return false; + } + } +} + +export { DatabaseSQLiteExport }; +export type { ExportMetadata, ImportResult }; \ No newline at end of file diff --git a/src/backend/utils/encrypted-db-operations.ts b/src/backend/utils/encrypted-db-operations.ts new file mode 100644 index 00000000..3c866c53 --- /dev/null +++ b/src/backend/utils/encrypted-db-operations.ts @@ -0,0 +1,214 @@ +import { db } from '../database/db/index.js'; +import { DatabaseEncryption } from './database-encryption.js'; +import { databaseLogger } from './logger.js'; +import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; + +type TableName = 'users' | 'ssh_data' | 'ssh_credentials'; + +class EncryptedDBOperations { + static async insert>( + table: SQLiteTable, + tableName: TableName, + data: T + ): Promise { + try { + const encryptedData = DatabaseEncryption.encryptRecord(tableName, data); + const result = await db.insert(table).values(encryptedData).returning(); + + // Decrypt the returned data to ensure consistency + const decryptedResult = DatabaseEncryption.decryptRecord(tableName, result[0]); + + databaseLogger.debug(`Inserted encrypted record into ${tableName}`, { + operation: 'encrypted_insert', + table: tableName + }); + + return decryptedResult as T; + } catch (error) { + databaseLogger.error(`Failed to insert encrypted record into ${tableName}`, error, { + operation: 'encrypted_insert_failed', + table: tableName + }); + throw error; + } + } + + static async select>( + query: any, + tableName: TableName + ): Promise { + try { + const results = await query; + const decryptedResults = DatabaseEncryption.decryptRecords(tableName, results); + + databaseLogger.debug(`Selected and decrypted ${decryptedResults.length} records from ${tableName}`, { + operation: 'encrypted_select', + table: tableName, + count: decryptedResults.length + }); + + return decryptedResults; + } catch (error) { + databaseLogger.error(`Failed to select/decrypt records from ${tableName}`, error, { + operation: 'encrypted_select_failed', + table: tableName + }); + throw error; + } + } + + static async selectOne>( + query: any, + tableName: TableName + ): Promise { + try { + const result = await query; + if (!result) return undefined; + + const decryptedResult = DatabaseEncryption.decryptRecord(tableName, result); + + databaseLogger.debug(`Selected and decrypted single record from ${tableName}`, { + operation: 'encrypted_select_one', + table: tableName + }); + + return decryptedResult; + } catch (error) { + databaseLogger.error(`Failed to select/decrypt single record from ${tableName}`, error, { + operation: 'encrypted_select_one_failed', + table: tableName + }); + throw error; + } + } + + static async update>( + table: SQLiteTable, + tableName: TableName, + where: any, + data: Partial + ): Promise { + try { + const encryptedData = DatabaseEncryption.encryptRecord(tableName, data); + const result = await db.update(table).set(encryptedData).where(where).returning(); + + databaseLogger.debug(`Updated encrypted record in ${tableName}`, { + operation: 'encrypted_update', + table: tableName + }); + + return result as T[]; + } catch (error) { + databaseLogger.error(`Failed to update encrypted record in ${tableName}`, error, { + operation: 'encrypted_update_failed', + table: tableName + }); + throw error; + } + } + + static async delete( + table: SQLiteTable, + tableName: TableName, + where: any + ): Promise { + try { + const result = await db.delete(table).where(where).returning(); + + databaseLogger.debug(`Deleted record from ${tableName}`, { + operation: 'encrypted_delete', + table: tableName + }); + + return result; + } catch (error) { + databaseLogger.error(`Failed to delete record from ${tableName}`, error, { + operation: 'encrypted_delete_failed', + table: tableName + }); + throw error; + } + } + + static async migrateExistingRecords(tableName: TableName): Promise { + let migratedCount = 0; + + try { + databaseLogger.info(`Starting encryption migration for ${tableName}`, { + operation: 'migration_start', + table: tableName + }); + + let table: SQLiteTable; + let records: any[]; + + switch (tableName) { + case 'users': + const { users } = await import('../database/db/schema.js'); + table = users; + records = await db.select().from(users); + break; + case 'ssh_data': + const { sshData } = await import('../database/db/schema.js'); + table = sshData; + records = await db.select().from(sshData); + break; + case 'ssh_credentials': + const { sshCredentials } = await import('../database/db/schema.js'); + table = sshCredentials; + records = await db.select().from(sshCredentials); + break; + default: + throw new Error(`Unknown table: ${tableName}`); + } + + for (const record of records) { + try { + const migratedRecord = await DatabaseEncryption.migrateRecord(tableName, record); + + if (JSON.stringify(migratedRecord) !== JSON.stringify(record)) { + const { eq } = await import('drizzle-orm'); + await db.update(table).set(migratedRecord).where(eq((table as any).id, record.id)); + migratedCount++; + } + } catch (error) { + databaseLogger.error(`Failed to migrate record ${record.id} in ${tableName}`, error, { + operation: 'migration_record_failed', + table: tableName, + recordId: record.id + }); + } + } + + databaseLogger.success(`Migration completed for ${tableName}`, { + operation: 'migration_complete', + table: tableName, + migratedCount, + totalRecords: records.length + }); + + return migratedCount; + } catch (error) { + databaseLogger.error(`Migration failed for ${tableName}`, error, { + operation: 'migration_failed', + table: tableName + }); + throw error; + } + } + + static async healthCheck(): Promise { + try { + const status = DatabaseEncryption.getEncryptionStatus(); + return status.configValid && status.enabled; + } catch (error) { + databaseLogger.error('Encryption health check failed', error, { + operation: 'health_check_failed' + }); + return false; + } + } +} + +export { EncryptedDBOperations }; +export type { TableName }; \ No newline at end of file diff --git a/src/backend/utils/encryption-key-manager.ts b/src/backend/utils/encryption-key-manager.ts new file mode 100644 index 00000000..0d65e41c --- /dev/null +++ b/src/backend/utils/encryption-key-manager.ts @@ -0,0 +1,323 @@ +import crypto from 'crypto'; +import { db } from '../database/db/index.js'; +import { settings } from '../database/db/schema.js'; +import { eq } from 'drizzle-orm'; +import { databaseLogger } from './logger.js'; +import { MasterKeyProtection } from './master-key-protection.js'; + +interface EncryptionKeyInfo { + hasKey: boolean; + keyId?: string; + createdAt?: string; + algorithm: string; +} + +class EncryptionKeyManager { + private static instance: EncryptionKeyManager; + private currentKey: string | null = null; + private keyInfo: EncryptionKeyInfo | null = null; + + private constructor() {} + + static getInstance(): EncryptionKeyManager { + if (!this.instance) { + this.instance = new EncryptionKeyManager(); + } + return this.instance; + } + + private encodeKey(key: string): string { + return MasterKeyProtection.encryptMasterKey(key); + } + + private decodeKey(encodedKey: string): string { + if (MasterKeyProtection.isProtectedKey(encodedKey)) { + return MasterKeyProtection.decryptMasterKey(encodedKey); + } + + databaseLogger.warn('Found legacy base64-encoded key, migrating to KEK protection', { + operation: 'key_migration_legacy' + }); + const buffer = Buffer.from(encodedKey, 'base64'); + return buffer.toString('hex'); + } + + async initializeKey(): Promise { + databaseLogger.info('Initializing encryption key system...', { + operation: 'key_init' + }); + + try { + let existingKey = await this.getStoredKey(); + + if (existingKey) { + databaseLogger.success('Found existing encryption key', { + operation: 'key_init', + hasKey: true + }); + this.currentKey = existingKey; + return existingKey; + } + + const environmentKey = process.env.DB_ENCRYPTION_KEY; + if (environmentKey && environmentKey !== 'default-key-change-me') { + if (!this.validateKeyStrength(environmentKey)) { + databaseLogger.error('Environment encryption key is too weak', undefined, { + operation: 'key_init', + source: 'environment', + keyLength: environmentKey.length + }); + throw new Error('DB_ENCRYPTION_KEY is too weak. Must be at least 32 characters with good entropy.'); + } + + databaseLogger.info('Using encryption key from environment variable', { + operation: 'key_init', + source: 'environment' + }); + + await this.storeKey(environmentKey); + this.currentKey = environmentKey; + return environmentKey; + } + + const newKey = await this.generateNewKey(); + databaseLogger.warn('Generated new encryption key - PLEASE BACKUP THIS KEY', { + operation: 'key_init', + generated: true, + keyPreview: newKey.substring(0, 8) + '...' + }); + + return newKey; + + } catch (error) { + databaseLogger.error('Failed to initialize encryption key', error, { + operation: 'key_init_failed' + }); + throw error; + } + } + + async generateNewKey(): Promise { + const newKey = crypto.randomBytes(32).toString('hex'); + const keyId = crypto.randomBytes(8).toString('hex'); + + await this.storeKey(newKey, keyId); + this.currentKey = newKey; + + databaseLogger.success('Generated new encryption key', { + operation: 'key_generated', + keyId, + keyLength: newKey.length + }); + + return newKey; + } + + private async storeKey(key: string, keyId?: string): Promise { + const now = new Date().toISOString(); + const id = keyId || crypto.randomBytes(8).toString('hex'); + + const keyData = { + key: this.encodeKey(key), + keyId: id, + createdAt: now, + algorithm: 'aes-256-gcm' + }; + + const encodedData = JSON.stringify(keyData); + + try { + const existing = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key')); + + if (existing.length > 0) { + await db.update(settings) + .set({ value: encodedData }) + .where(eq(settings.key, 'db_encryption_key')); + } else { + await db.insert(settings).values({ + key: 'db_encryption_key', + value: encodedData + }); + } + + const existingCreated = await db.select().from(settings).where(eq(settings.key, 'encryption_key_created')); + + if (existingCreated.length > 0) { + await db.update(settings) + .set({ value: now }) + .where(eq(settings.key, 'encryption_key_created')); + } else { + await db.insert(settings).values({ + key: 'encryption_key_created', + value: now + }); + } + + this.keyInfo = { + hasKey: true, + keyId: id, + createdAt: now, + algorithm: 'aes-256-gcm' + }; + + } catch (error) { + databaseLogger.error('Failed to store encryption key', error, { + operation: 'key_store_failed' + }); + throw error; + } + } + + private async getStoredKey(): Promise { + try { + const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key')); + + if (result.length === 0) { + return null; + } + + const encodedData = result[0].value; + let keyData; + + try { + keyData = JSON.parse(encodedData); + } catch { + databaseLogger.warn('Found legacy base64-encoded key data, migrating', { + operation: 'key_data_migration_legacy' + }); + keyData = JSON.parse(Buffer.from(encodedData, 'base64').toString()); + } + + this.keyInfo = { + hasKey: true, + keyId: keyData.keyId, + createdAt: keyData.createdAt, + algorithm: keyData.algorithm + }; + + const decodedKey = this.decodeKey(keyData.key); + + if (!MasterKeyProtection.isProtectedKey(keyData.key)) { + databaseLogger.info('Auto-migrating legacy key to KEK protection', { + operation: 'key_auto_migration', + keyId: keyData.keyId + }); + await this.storeKey(decodedKey, keyData.keyId); + } + + return decodedKey; + + } catch (error) { + databaseLogger.error('Failed to retrieve stored encryption key', error, { + operation: 'key_retrieve_failed' + }); + return null; + } + } + + getCurrentKey(): string | null { + return this.currentKey; + } + + async getKeyInfo(): Promise { + if (!this.keyInfo) { + const hasKey = await this.getStoredKey() !== null; + return { + hasKey, + algorithm: 'aes-256-gcm' + }; + } + return this.keyInfo; + } + + async regenerateKey(): Promise { + databaseLogger.info('Regenerating encryption key', { + operation: 'key_regenerate' + }); + + const oldKeyInfo = await this.getKeyInfo(); + const newKey = await this.generateNewKey(); + + databaseLogger.warn('Encryption key regenerated - ALL DATA MUST BE RE-ENCRYPTED', { + operation: 'key_regenerated', + oldKeyId: oldKeyInfo.keyId, + newKeyId: this.keyInfo?.keyId + }); + + return newKey; + } + + private validateKeyStrength(key: string): boolean { + if (key.length < 32) return false; + + const hasLower = /[a-z]/.test(key); + const hasUpper = /[A-Z]/.test(key); + const hasDigit = /\d/.test(key); + const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(key); + + const entropyTest = new Set(key).size / key.length; + + const complexity = Number(hasLower) + Number(hasUpper) + Number(hasDigit) + Number(hasSpecial); + return complexity >= 3 && entropyTest > 0.4; + } + + async validateKey(key?: string): Promise { + const testKey = key || this.currentKey; + if (!testKey) return false; + + try { + const testData = 'validation-test-' + Date.now(); + const testBuffer = Buffer.from(testKey, 'hex'); + + if (testBuffer.length !== 32) { + return false; + } + + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', testBuffer, iv) as any; + cipher.update(testData, 'utf8'); + cipher.final(); + cipher.getAuthTag(); + + return true; + } catch { + return false; + } + } + + isInitialized(): boolean { + return this.currentKey !== null; + } + + async getEncryptionStatus() { + const keyInfo = await this.getKeyInfo(); + const isValid = await this.validateKey(); + const kekProtected = await this.isKEKProtected(); + + return { + hasKey: keyInfo.hasKey, + keyValid: isValid, + keyId: keyInfo.keyId, + createdAt: keyInfo.createdAt, + algorithm: keyInfo.algorithm, + initialized: this.isInitialized(), + kekProtected, + kekValid: kekProtected ? MasterKeyProtection.validateProtection() : false + }; + } + + private async isKEKProtected(): Promise { + try { + const result = await db.select().from(settings).where(eq(settings.key, 'db_encryption_key')); + if (result.length === 0) return false; + + const keyData = JSON.parse(result[0].value); + return MasterKeyProtection.isProtectedKey(keyData.key); + } catch { + return false; + } + } +} + +export { EncryptionKeyManager }; +export type { EncryptionKeyInfo }; \ No newline at end of file diff --git a/src/backend/utils/encryption-migration.ts b/src/backend/utils/encryption-migration.ts new file mode 100644 index 00000000..39f8ae12 --- /dev/null +++ b/src/backend/utils/encryption-migration.ts @@ -0,0 +1,395 @@ +#!/usr/bin/env node +import { DatabaseEncryption } from './database-encryption.js'; +import { EncryptedDBOperations } from './encrypted-db-operations.js'; +import { EncryptionKeyManager } from './encryption-key-manager.js'; +import { databaseLogger } from './logger.js'; +import { db } from '../database/db/index.js'; +import { settings } from '../database/db/schema.js'; +import { eq, sql } from 'drizzle-orm'; + +interface MigrationConfig { + masterPassword?: string; + forceEncryption?: boolean; + backupEnabled?: boolean; + dryRun?: boolean; +} + +class EncryptionMigration { + private config: MigrationConfig; + + constructor(config: MigrationConfig = {}) { + this.config = { + masterPassword: config.masterPassword, + forceEncryption: config.forceEncryption ?? false, + backupEnabled: config.backupEnabled ?? true, + dryRun: config.dryRun ?? false + }; + } + + async runMigration(): Promise { + databaseLogger.info('Starting database encryption migration', { + operation: 'migration_start', + dryRun: this.config.dryRun, + forceEncryption: this.config.forceEncryption + }); + + try { + await this.validatePrerequisites(); + + if (this.config.backupEnabled && !this.config.dryRun) { + await this.createBackup(); + } + + await this.initializeEncryption(); + await this.migrateTables(); + await this.updateSettings(); + await this.verifyMigration(); + + databaseLogger.success('Database encryption migration completed successfully', { + operation: 'migration_complete' + }); + + } catch (error) { + databaseLogger.error('Migration failed', error, { + operation: 'migration_failed' + }); + throw error; + } + } + + private async validatePrerequisites(): Promise { + databaseLogger.info('Validating migration prerequisites', { + operation: 'validation' + }); + + // Check if KEK-managed encryption key exists + const keyManager = EncryptionKeyManager.getInstance(); + + if (!this.config.masterPassword) { + // Try to get current key from KEK manager + try { + const currentKey = keyManager.getCurrentKey(); + if (!currentKey) { + // Initialize key if not available + const initializedKey = await keyManager.initializeKey(); + this.config.masterPassword = initializedKey; + } else { + this.config.masterPassword = currentKey; + } + } catch (error) { + throw new Error('Failed to retrieve encryption key from KEK manager. Please ensure encryption is properly initialized.'); + } + } + + // Validate key strength + if (this.config.masterPassword.length < 16) { + throw new Error('Master password must be at least 16 characters long'); + } + + // Test database connection + try { + await db.select().from(settings).limit(1); + } catch (error) { + throw new Error('Database connection failed'); + } + + databaseLogger.success('Prerequisites validation passed', { + operation: 'validation_complete', + keySource: 'kek_manager' + }); + } + + private async createBackup(): Promise { + databaseLogger.info('Creating database backup before migration', { + operation: 'backup_start' + }); + + try { + const fs = await import('fs'); + const path = await import('path'); + const dataDir = process.env.DATA_DIR || './db/data'; + const dbPath = path.join(dataDir, 'db.sqlite'); + const backupPath = path.join(dataDir, `db-backup-${Date.now()}.sqlite`); + + if (fs.existsSync(dbPath)) { + fs.copyFileSync(dbPath, backupPath); + databaseLogger.success(`Database backup created: ${backupPath}`, { + operation: 'backup_complete', + backupPath + }); + } + } catch (error) { + databaseLogger.error('Failed to create backup', error, { + operation: 'backup_failed' + }); + throw error; + } + } + + private async initializeEncryption(): Promise { + databaseLogger.info('Initializing encryption system', { + operation: 'encryption_init' + }); + + DatabaseEncryption.initialize({ + masterPassword: this.config.masterPassword!, + encryptionEnabled: true, + forceEncryption: this.config.forceEncryption, + migrateOnAccess: true + }); + + const isHealthy = await EncryptedDBOperations.healthCheck(); + if (!isHealthy) { + throw new Error('Encryption system health check failed'); + } + + databaseLogger.success('Encryption system initialized successfully', { + operation: 'encryption_init_complete' + }); + } + + private async migrateTables(): Promise { + const tables: Array<'users' | 'ssh_data' | 'ssh_credentials'> = [ + 'users', + 'ssh_data', + 'ssh_credentials' + ]; + + let totalMigrated = 0; + + for (const tableName of tables) { + databaseLogger.info(`Starting migration for table: ${tableName}`, { + operation: 'table_migration_start', + table: tableName + }); + + try { + if (this.config.dryRun) { + databaseLogger.info(`[DRY RUN] Would migrate table: ${tableName}`, { + operation: 'dry_run_table', + table: tableName + }); + continue; + } + + const migratedCount = await EncryptedDBOperations.migrateExistingRecords(tableName); + totalMigrated += migratedCount; + + databaseLogger.success(`Migration completed for table: ${tableName}`, { + operation: 'table_migration_complete', + table: tableName, + migratedCount + }); + + } catch (error) { + databaseLogger.error(`Migration failed for table: ${tableName}`, error, { + operation: 'table_migration_failed', + table: tableName + }); + throw error; + } + } + + databaseLogger.success(`All tables migrated successfully`, { + operation: 'all_tables_migrated', + totalMigrated + }); + } + + private async updateSettings(): Promise { + if (this.config.dryRun) { + databaseLogger.info('[DRY RUN] Would update encryption settings', { + operation: 'dry_run_settings' + }); + return; + } + + try { + const encryptionSettings = [ + { key: 'encryption_enabled', value: 'true' }, + { key: 'encryption_migration_completed', value: new Date().toISOString() }, + { key: 'encryption_version', value: '1.0' } + ]; + + for (const setting of encryptionSettings) { + const existing = await db.select().from(settings).where(eq(settings.key, setting.key)); + + if (existing.length > 0) { + await db.update(settings).set({ value: setting.value }).where(eq(settings.key, setting.key)); + } else { + await db.insert(settings).values(setting); + } + } + + databaseLogger.success('Encryption settings updated', { + operation: 'settings_updated' + }); + + } catch (error) { + databaseLogger.error('Failed to update settings', error, { + operation: 'settings_update_failed' + }); + throw error; + } + } + + private async verifyMigration(): Promise { + databaseLogger.info('Verifying migration integrity', { + operation: 'verification_start' + }); + + try { + const status = DatabaseEncryption.getEncryptionStatus(); + + if (!status.enabled || !status.configValid) { + throw new Error('Encryption system verification failed'); + } + + const testResult = await this.performTestEncryption(); + if (!testResult) { + throw new Error('Test encryption/decryption failed'); + } + + databaseLogger.success('Migration verification completed successfully', { + operation: 'verification_complete', + status + }); + + } catch (error) { + databaseLogger.error('Migration verification failed', error, { + operation: 'verification_failed' + }); + throw error; + } + } + + private async performTestEncryption(): Promise { + try { + const { FieldEncryption } = await import('./encryption.js'); + const testData = `test-data-${Date.now()}`; + const testKey = FieldEncryption.getFieldKey(this.config.masterPassword!, 'test'); + + const encrypted = FieldEncryption.encryptField(testData, testKey); + const decrypted = FieldEncryption.decryptField(encrypted, testKey); + + return decrypted === testData; + } catch { + return false; + } + } + + static async checkMigrationStatus(): Promise<{ + isEncryptionEnabled: boolean; + migrationCompleted: boolean; + migrationRequired: boolean; + migrationDate?: string; + }> { + try { + const encryptionEnabled = await db.select().from(settings).where(eq(settings.key, 'encryption_enabled')); + const migrationCompleted = await db.select().from(settings).where(eq(settings.key, 'encryption_migration_completed')); + + const isEncryptionEnabled = encryptionEnabled.length > 0 && encryptionEnabled[0].value === 'true'; + const isMigrationCompleted = migrationCompleted.length > 0; + + // Check if migration is actually required by looking for unencrypted sensitive data + const migrationRequired = await this.checkIfMigrationRequired(); + + return { + isEncryptionEnabled, + migrationCompleted: isMigrationCompleted, + migrationRequired, + migrationDate: isMigrationCompleted ? migrationCompleted[0].value : undefined + }; + } catch (error) { + databaseLogger.error('Failed to check migration status', error, { + operation: 'status_check_failed' + }); + throw error; + } + } + + static async checkIfMigrationRequired(): Promise { + try { + // Import table schemas + const { sshData, sshCredentials } = await import('../database/db/schema.js'); + + // Check if there's any unencrypted sensitive data in ssh_data + const sshDataCount = await db.select({ count: sql`count(*)` }).from(sshData); + if (sshDataCount[0].count > 0) { + // Sample a few records to check if they contain unencrypted data + const sampleData = await db.select().from(sshData).limit(5); + for (const record of sampleData) { + if (record.password && !this.looksEncrypted(record.password)) { + return true; // Found unencrypted password + } + if (record.key && !this.looksEncrypted(record.key)) { + return true; // Found unencrypted key + } + } + } + + // Check if there's any unencrypted sensitive data in ssh_credentials + const credentialsCount = await db.select({ count: sql`count(*)` }).from(sshCredentials); + if (credentialsCount[0].count > 0) { + const sampleCredentials = await db.select().from(sshCredentials).limit(5); + for (const record of sampleCredentials) { + if (record.password && !this.looksEncrypted(record.password)) { + return true; // Found unencrypted password + } + if (record.privateKey && !this.looksEncrypted(record.privateKey)) { + return true; // Found unencrypted private key + } + if (record.keyPassword && !this.looksEncrypted(record.keyPassword)) { + return true; // Found unencrypted key password + } + } + } + + return false; // No unencrypted sensitive data found + } catch (error) { + databaseLogger.warn('Failed to check if migration required, assuming required', { + operation: 'migration_check_failed', + error: error instanceof Error ? error.message : 'Unknown error' + }); + return true; // If we can't check, assume migration is required for safety + } + } + + private static looksEncrypted(data: string): boolean { + if (!data) return true; // Empty data doesn't need encryption + + try { + // Check if it looks like our encrypted format: {"data":"...","iv":"...","tag":"..."} + const parsed = JSON.parse(data); + return !!(parsed.data && parsed.iv && parsed.tag); + } catch { + // If it's not JSON, check if it's a reasonable length for encrypted data + // Encrypted data is typically much longer than plaintext + return data.length > 100 && data.includes('='); // Base64-like characteristics + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const config: MigrationConfig = { + masterPassword: process.env.DB_ENCRYPTION_KEY, + forceEncryption: process.env.FORCE_ENCRYPTION === 'true', + backupEnabled: process.env.BACKUP_ENABLED !== 'false', + dryRun: process.env.DRY_RUN === 'true' + }; + + const migration = new EncryptionMigration(config); + + migration.runMigration() + .then(() => { + console.log('Migration completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('Migration failed:', error.message); + process.exit(1); + }); +} + +export { EncryptionMigration }; +export type { MigrationConfig }; \ No newline at end of file diff --git a/src/backend/utils/encryption-test.ts b/src/backend/utils/encryption-test.ts new file mode 100644 index 00000000..d8f9e7e7 --- /dev/null +++ b/src/backend/utils/encryption-test.ts @@ -0,0 +1,293 @@ +#!/usr/bin/env node +import { FieldEncryption } from './encryption.js'; +import { DatabaseEncryption } from './database-encryption.js'; +import { EncryptedDBOperations } from './encrypted-db-operations.js'; +import { databaseLogger } from './logger.js'; + +class EncryptionTest { + private testPassword = 'test-master-password-for-validation'; + + async runAllTests(): Promise { + console.log('🔐 Starting Termix Database Encryption Tests...\n'); + + const tests = [ + { name: 'Basic Encryption/Decryption', test: () => this.testBasicEncryption() }, + { name: 'Field Encryption Detection', test: () => this.testFieldDetection() }, + { name: 'Key Derivation', test: () => this.testKeyDerivation() }, + { name: 'Database Encryption Context', test: () => this.testDatabaseContext() }, + { name: 'Record Encryption/Decryption', test: () => this.testRecordOperations() }, + { name: 'Backward Compatibility', test: () => this.testBackwardCompatibility() }, + { name: 'Error Handling', test: () => this.testErrorHandling() }, + { name: 'Performance Test', test: () => this.testPerformance() } + ]; + + let passedTests = 0; + let totalTests = tests.length; + + for (const test of tests) { + try { + console.log(`⏳ Running: ${test.name}...`); + await test.test(); + console.log(`✅ PASSED: ${test.name}\n`); + passedTests++; + } catch (error) { + console.log(`❌ FAILED: ${test.name}`); + console.log(` Error: ${error instanceof Error ? error.message : 'Unknown error'}\n`); + } + } + + const success = passedTests === totalTests; + console.log(`\n🎯 Test Results: ${passedTests}/${totalTests} tests passed`); + + if (success) { + console.log('🎉 All encryption tests PASSED! System is ready for production.'); + } else { + console.log('⚠️ Some tests FAILED! Please review the implementation.'); + } + + return success; + } + + private async testBasicEncryption(): Promise { + const testData = 'Hello, World! This is sensitive data.'; + const key = FieldEncryption.getFieldKey(this.testPassword, 'test-field'); + + const encrypted = FieldEncryption.encryptField(testData, key); + const decrypted = FieldEncryption.decryptField(encrypted, key); + + if (decrypted !== testData) { + throw new Error(`Decryption mismatch: expected "${testData}", got "${decrypted}"`); + } + + if (!FieldEncryption.isEncrypted(encrypted)) { + throw new Error('Encrypted data not detected as encrypted'); + } + + if (FieldEncryption.isEncrypted(testData)) { + throw new Error('Plain text incorrectly detected as encrypted'); + } + } + + private async testFieldDetection(): Promise { + const testCases = [ + { table: 'users', field: 'password_hash', shouldEncrypt: true }, + { table: 'users', field: 'username', shouldEncrypt: false }, + { table: 'ssh_data', field: 'password', shouldEncrypt: true }, + { table: 'ssh_data', field: 'ip', shouldEncrypt: false }, + { table: 'ssh_credentials', field: 'privateKey', shouldEncrypt: true }, + { table: 'unknown_table', field: 'any_field', shouldEncrypt: false } + ]; + + for (const testCase of testCases) { + const result = FieldEncryption.shouldEncryptField(testCase.table, testCase.field); + if (result !== testCase.shouldEncrypt) { + throw new Error( + `Field detection failed for ${testCase.table}.${testCase.field}: ` + + `expected ${testCase.shouldEncrypt}, got ${result}` + ); + } + } + } + + private async testKeyDerivation(): Promise { + const password = 'test-password'; + const fieldType1 = 'users.password_hash'; + const fieldType2 = 'ssh_data.password'; + + const key1a = FieldEncryption.getFieldKey(password, fieldType1); + const key1b = FieldEncryption.getFieldKey(password, fieldType1); + const key2 = FieldEncryption.getFieldKey(password, fieldType2); + + if (!key1a.equals(key1b)) { + throw new Error('Same field type should produce identical keys'); + } + + if (key1a.equals(key2)) { + throw new Error('Different field types should produce different keys'); + } + + const differentPasswordKey = FieldEncryption.getFieldKey('different-password', fieldType1); + if (key1a.equals(differentPasswordKey)) { + throw new Error('Different passwords should produce different keys'); + } + } + + private async testDatabaseContext(): Promise { + DatabaseEncryption.initialize({ + masterPassword: this.testPassword, + encryptionEnabled: true, + forceEncryption: false, + migrateOnAccess: true + }); + + const status = DatabaseEncryption.getEncryptionStatus(); + if (!status.enabled) { + throw new Error('Encryption should be enabled'); + } + + if (!status.configValid) { + throw new Error('Configuration should be valid'); + } + } + + private async testRecordOperations(): Promise { + const testRecord = { + id: 'test-id-123', + username: 'testuser', + password_hash: 'sensitive-password-hash', + is_admin: false + }; + + const encrypted = DatabaseEncryption.encryptRecord('users', testRecord); + const decrypted = DatabaseEncryption.decryptRecord('users', encrypted); + + if (decrypted.username !== testRecord.username) { + throw new Error('Non-sensitive field should remain unchanged'); + } + + if (decrypted.password_hash !== testRecord.password_hash) { + throw new Error('Sensitive field should be properly decrypted'); + } + + if (!FieldEncryption.isEncrypted(encrypted.password_hash)) { + throw new Error('Sensitive field should be encrypted in stored record'); + } + } + + private async testBackwardCompatibility(): Promise { + const plaintextRecord = { + id: 'legacy-id-456', + username: 'legacyuser', + password_hash: 'plain-text-password-hash', + is_admin: false + }; + + const decrypted = DatabaseEncryption.decryptRecord('users', plaintextRecord); + + if (decrypted.password_hash !== plaintextRecord.password_hash) { + throw new Error('Plain text fields should be returned as-is for backward compatibility'); + } + + if (decrypted.username !== plaintextRecord.username) { + throw new Error('Non-sensitive fields should be unchanged'); + } + } + + private async testErrorHandling(): Promise { + const key = FieldEncryption.getFieldKey(this.testPassword, 'test'); + + try { + FieldEncryption.decryptField('invalid-json-data', key); + throw new Error('Should have thrown error for invalid JSON'); + } catch (error) { + if (!error || !(error as Error).message.includes('decryption failed')) { + throw new Error('Should throw appropriate decryption error'); + } + } + + try { + const fakeEncrypted = JSON.stringify({ data: 'fake', iv: 'fake', tag: 'fake' }); + FieldEncryption.decryptField(fakeEncrypted, key); + throw new Error('Should have thrown error for invalid encrypted data'); + } catch (error) { + if (!error || !(error as Error).message.includes('Decryption failed')) { + throw new Error('Should throw appropriate error for corrupted data'); + } + } + } + + private async testPerformance(): Promise { + const testData = 'Performance test data that is reasonably long to simulate real SSH keys and passwords.'; + const key = FieldEncryption.getFieldKey(this.testPassword, 'performance-test'); + + const iterations = 100; + const startTime = Date.now(); + + for (let i = 0; i < iterations; i++) { + const encrypted = FieldEncryption.encryptField(testData, key); + const decrypted = FieldEncryption.decryptField(encrypted, key); + + if (decrypted !== testData) { + throw new Error(`Performance test failed at iteration ${i}`); + } + } + + const endTime = Date.now(); + const totalTime = endTime - startTime; + const avgTime = totalTime / iterations; + + console.log(` ⚡ Performance: ${iterations} encrypt/decrypt cycles in ${totalTime}ms (${avgTime.toFixed(2)}ms avg)`); + + if (avgTime > 50) { + console.log(' ⚠️ Warning: Encryption operations are slower than expected'); + } + } + + static async validateProduction(): Promise { + console.log('🔒 Validating production encryption setup...\n'); + + try { + const encryptionKey = process.env.DB_ENCRYPTION_KEY; + + if (!encryptionKey) { + console.log('❌ DB_ENCRYPTION_KEY environment variable not set'); + return false; + } + + if (encryptionKey === 'default-key-change-me') { + console.log('❌ DB_ENCRYPTION_KEY is using default value (INSECURE)'); + return false; + } + + if (encryptionKey.length < 16) { + console.log('❌ DB_ENCRYPTION_KEY is too short (minimum 16 characters)'); + return false; + } + + DatabaseEncryption.initialize({ + masterPassword: encryptionKey, + encryptionEnabled: true + }); + + const status = DatabaseEncryption.getEncryptionStatus(); + if (!status.configValid) { + console.log('❌ Encryption configuration validation failed'); + return false; + } + + console.log('✅ Production encryption setup is valid'); + return true; + + } catch (error) { + console.log(`❌ Production validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + return false; + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const testMode = process.argv[2]; + + if (testMode === 'production') { + EncryptionTest.validateProduction() + .then((success) => { + process.exit(success ? 0 : 1); + }) + .catch((error) => { + console.error('Test execution failed:', error); + process.exit(1); + }); + } else { + const test = new EncryptionTest(); + test.runAllTests() + .then((success) => { + process.exit(success ? 0 : 1); + }) + .catch((error) => { + console.error('Test execution failed:', error); + process.exit(1); + }); + } +} + +export { EncryptionTest }; \ No newline at end of file diff --git a/src/backend/utils/encryption.ts b/src/backend/utils/encryption.ts new file mode 100644 index 00000000..6e72b8b5 --- /dev/null +++ b/src/backend/utils/encryption.ts @@ -0,0 +1,143 @@ +import crypto from 'crypto'; + +interface EncryptedData { + data: string; + iv: string; + tag: string; + salt?: string; +} + +interface EncryptionConfig { + algorithm: string; + keyLength: number; + ivLength: number; + saltLength: number; + iterations: number; +} + +class FieldEncryption { + private static readonly CONFIG: EncryptionConfig = { + algorithm: 'aes-256-gcm', + keyLength: 32, + ivLength: 16, + saltLength: 32, + iterations: 100000, + }; + + private static readonly ENCRYPTED_FIELDS = { + users: ['password_hash', 'client_secret', 'totp_secret', 'totp_backup_codes', 'oidc_identifier'], + ssh_data: ['password', 'key', 'keyPassword'], + ssh_credentials: ['password', 'privateKey', 'keyPassword', 'key', 'publicKey'] + }; + + static isEncrypted(value: string | null): boolean { + if (!value) return false; + try { + const parsed = JSON.parse(value); + return !!(parsed.data && parsed.iv && parsed.tag); + } catch { + return false; + } + } + + static deriveKey(password: string, salt: Buffer, keyType: string): Buffer { + const masterKey = crypto.pbkdf2Sync( + password, + salt, + this.CONFIG.iterations, + this.CONFIG.keyLength, + 'sha256' + ); + + return Buffer.from(crypto.hkdfSync( + 'sha256', + masterKey, + salt, + keyType, + this.CONFIG.keyLength + )); + } + + static encrypt(plaintext: string, key: Buffer): EncryptedData { + if (!plaintext) return { data: '', iv: '', tag: '' }; + + const iv = crypto.randomBytes(this.CONFIG.ivLength); + const cipher = crypto.createCipheriv(this.CONFIG.algorithm, key, iv) as any; + cipher.setAAD(Buffer.from('termix-field-encryption')); + + let encrypted = cipher.update(plaintext, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const tag = cipher.getAuthTag(); + + return { + data: encrypted, + iv: iv.toString('hex'), + tag: tag.toString('hex') + }; + } + + static decrypt(encryptedData: EncryptedData, key: Buffer): string { + if (!encryptedData.data) return ''; + + try { + const decipher = crypto.createDecipheriv(this.CONFIG.algorithm, key, Buffer.from(encryptedData.iv, 'hex')) as any; + decipher.setAAD(Buffer.from('termix-field-encryption')); + decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex')); + + let decrypted = decipher.update(encryptedData.data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + static encryptField(value: string, fieldKey: Buffer): string { + if (!value) return ''; + if (this.isEncrypted(value)) return value; + + const encrypted = this.encrypt(value, fieldKey); + return JSON.stringify(encrypted); + } + + static decryptField(value: string, fieldKey: Buffer): string { + if (!value) return ''; + if (!this.isEncrypted(value)) return value; + + try { + const encrypted: EncryptedData = JSON.parse(value); + return this.decrypt(encrypted, fieldKey); + } catch (error) { + throw new Error(`Field decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + static getFieldKey(masterPassword: string, fieldType: string): Buffer { + const salt = crypto.createHash('sha256').update(`termix-${fieldType}`).digest(); + return this.deriveKey(masterPassword, salt, fieldType); + } + + static shouldEncryptField(tableName: string, fieldName: string): boolean { + const tableFields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS]; + return tableFields ? tableFields.includes(fieldName) : false; + } + + static generateSalt(): string { + return crypto.randomBytes(this.CONFIG.saltLength).toString('hex'); + } + + static validateEncryptionHealth(encryptedValue: string, key: Buffer): boolean { + try { + if (!this.isEncrypted(encryptedValue)) return false; + const decrypted = this.decryptField(encryptedValue, key); + return decrypted !== ''; + } catch { + return false; + } + } +} + +export { FieldEncryption }; +export type { EncryptedData, EncryptionConfig }; \ No newline at end of file diff --git a/src/backend/utils/hardware-fingerprint.ts b/src/backend/utils/hardware-fingerprint.ts new file mode 100644 index 00000000..617e65dc --- /dev/null +++ b/src/backend/utils/hardware-fingerprint.ts @@ -0,0 +1,369 @@ +import crypto from 'crypto'; +import os from 'os'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import { databaseLogger } from './logger.js'; + +interface HardwareInfo { + cpuId?: string; + motherboardUuid?: string; + diskSerial?: string; + biosSerial?: string; + tpmInfo?: string; + macAddresses?: string[]; +} + +/** + * 硬件指纹生成器 - 使用真实硬件特征生成稳定的设备指纹 + * 相比软件环境指纹,硬件指纹在虚拟化和容器环境中更加稳定 + */ +class HardwareFingerprint { + private static readonly CACHE_KEY = 'cached_hardware_fingerprint'; + private static cachedFingerprint: string | null = null; + + /** + * 生成硬件指纹 + * 优先级:缓存 > 环境变量 > 硬件检测 + */ + static generate(): string { + try { + // 1. 检查缓存 + if (this.cachedFingerprint) { + return this.cachedFingerprint; + } + + // 2. 检查环境变量覆盖 + const envFingerprint = process.env.TERMIX_HARDWARE_SEED; + if (envFingerprint && envFingerprint.length >= 32) { + databaseLogger.info('Using hardware seed from environment variable', { + operation: 'hardware_fingerprint_env' + }); + this.cachedFingerprint = this.hashFingerprint(envFingerprint); + return this.cachedFingerprint; + } + + // 3. 检测真实硬件信息 + const hwInfo = this.detectHardwareInfo(); + const fingerprint = this.generateFromHardware(hwInfo); + + this.cachedFingerprint = fingerprint; + + databaseLogger.info('Generated hardware fingerprint', { + operation: 'hardware_fingerprint_generation', + fingerprintPrefix: fingerprint.substring(0, 8), + detectedComponents: Object.keys(hwInfo).filter(key => hwInfo[key as keyof HardwareInfo]) + }); + + return fingerprint; + } catch (error) { + databaseLogger.error('Hardware fingerprint generation failed', error, { + operation: 'hardware_fingerprint_failed' + }); + + // 回退到基本的环境指纹 + return this.generateFallbackFingerprint(); + } + } + + /** + * 检测硬件信息 + */ + private static detectHardwareInfo(): HardwareInfo { + const platform = os.platform(); + const hwInfo: HardwareInfo = {}; + + try { + switch (platform) { + case 'linux': + hwInfo.cpuId = this.getLinuxCpuId(); + hwInfo.motherboardUuid = this.getLinuxMotherboardUuid(); + hwInfo.diskSerial = this.getLinuxDiskSerial(); + hwInfo.biosSerial = this.getLinuxBiosSerial(); + break; + + case 'win32': + hwInfo.cpuId = this.getWindowsCpuId(); + hwInfo.motherboardUuid = this.getWindowsMotherboardUuid(); + hwInfo.diskSerial = this.getWindowsDiskSerial(); + hwInfo.biosSerial = this.getWindowsBiosSerial(); + break; + + case 'darwin': + hwInfo.cpuId = this.getMacOSCpuId(); + hwInfo.motherboardUuid = this.getMacOSMotherboardUuid(); + hwInfo.diskSerial = this.getMacOSDiskSerial(); + hwInfo.biosSerial = this.getMacOSBiosSerial(); + break; + } + + // 所有平台都尝试获取MAC地址 + hwInfo.macAddresses = this.getStableMacAddresses(); + + } catch (error) { + databaseLogger.error('Some hardware detection failed', error, { + operation: 'hardware_detection_partial_failure', + platform + }); + } + + return hwInfo; + } + + /** + * Linux平台硬件信息获取 + */ + private static getLinuxCpuId(): string | undefined { + try { + // 尝试多种方法获取CPU信息 + const methods = [ + () => fs.readFileSync('/proc/cpuinfo', 'utf8').match(/processor\s*:\s*(\d+)/)?.[1], + () => execSync('dmidecode -t processor | grep "ID:" | head -1', { encoding: 'utf8' }).trim(), + () => execSync('cat /proc/cpuinfo | grep "cpu family\\|model\\|stepping" | md5sum', { encoding: 'utf8' }).split(' ')[0] + ]; + + for (const method of methods) { + try { + const result = method(); + if (result && result.length > 0) return result; + } catch { /* 继续尝试下一种方法 */ } + } + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getLinuxMotherboardUuid(): string | undefined { + try { + // 尝试多种方法获取主板UUID + const methods = [ + () => fs.readFileSync('/sys/class/dmi/id/product_uuid', 'utf8').trim(), + () => fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(), + () => execSync('dmidecode -s system-uuid', { encoding: 'utf8' }).trim() + ]; + + for (const method of methods) { + try { + const result = method(); + if (result && result.length > 0 && result !== 'Not Settable') return result; + } catch { /* 继续尝试下一种方法 */ } + } + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getLinuxDiskSerial(): string | undefined { + try { + // 获取根分区所在磁盘的序列号 + const rootDisk = execSync("df / | tail -1 | awk '{print $1}' | sed 's/[0-9]*$//'", { encoding: 'utf8' }).trim(); + if (rootDisk) { + const serial = execSync(`udevadm info --name=${rootDisk} | grep ID_SERIAL= | cut -d= -f2`, { encoding: 'utf8' }).trim(); + if (serial && serial.length > 0) return serial; + } + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getLinuxBiosSerial(): string | undefined { + try { + const methods = [ + () => fs.readFileSync('/sys/class/dmi/id/board_serial', 'utf8').trim(), + () => execSync('dmidecode -s baseboard-serial-number', { encoding: 'utf8' }).trim() + ]; + + for (const method of methods) { + try { + const result = method(); + if (result && result.length > 0 && result !== 'Not Specified') return result; + } catch { /* 继续尝试下一种方法 */ } + } + } catch { /* 忽略错误 */ } + return undefined; + } + + /** + * Windows平台硬件信息获取 + */ + private static getWindowsCpuId(): string | undefined { + try { + const result = execSync('wmic cpu get ProcessorId /value', { encoding: 'utf8' }); + const match = result.match(/ProcessorId=(.+)/); + return match?.[1]?.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getWindowsMotherboardUuid(): string | undefined { + try { + const result = execSync('wmic csproduct get UUID /value', { encoding: 'utf8' }); + const match = result.match(/UUID=(.+)/); + return match?.[1]?.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getWindowsDiskSerial(): string | undefined { + try { + const result = execSync('wmic diskdrive get SerialNumber /value', { encoding: 'utf8' }); + const match = result.match(/SerialNumber=(.+)/); + return match?.[1]?.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getWindowsBiosSerial(): string | undefined { + try { + const result = execSync('wmic baseboard get SerialNumber /value', { encoding: 'utf8' }); + const match = result.match(/SerialNumber=(.+)/); + return match?.[1]?.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + /** + * macOS平台硬件信息获取 + */ + private static getMacOSCpuId(): string | undefined { + try { + const result = execSync('sysctl -n machdep.cpu.brand_string', { encoding: 'utf8' }); + return result.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getMacOSMotherboardUuid(): string | undefined { + try { + const result = execSync('system_profiler SPHardwareDataType | grep "Hardware UUID"', { encoding: 'utf8' }); + const match = result.match(/Hardware UUID:\s*(.+)/); + return match?.[1]?.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getMacOSDiskSerial(): string | undefined { + try { + const result = execSync('system_profiler SPStorageDataType | grep "Serial Number"', { encoding: 'utf8' }); + const match = result.match(/Serial Number:\s*(.+)/); + return match?.[1]?.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + private static getMacOSBiosSerial(): string | undefined { + try { + const result = execSync('system_profiler SPHardwareDataType | grep "Serial Number"', { encoding: 'utf8' }); + const match = result.match(/Serial Number \(system\):\s*(.+)/); + return match?.[1]?.trim(); + } catch { /* 忽略错误 */ } + return undefined; + } + + /** + * 获取稳定的MAC地址 + * 排除虚拟接口和临时接口 + */ + private static getStableMacAddresses(): string[] { + try { + const networkInterfaces = os.networkInterfaces(); + const macAddresses: string[] = []; + + for (const [interfaceName, interfaces] of Object.entries(networkInterfaces)) { + if (!interfaces) continue; + + // 排除虚拟接口和Docker接口 + if (interfaceName.match(/^(lo|docker|veth|br-|virbr)/)) continue; + + for (const iface of interfaces) { + if (!iface.internal && + iface.mac && + iface.mac !== '00:00:00:00:00:00' && + !iface.mac.startsWith('02:42:')) { // Docker接口特征 + macAddresses.push(iface.mac); + } + } + } + + return macAddresses.sort(); // 排序确保一致性 + } catch { + return []; + } + } + + /** + * 从硬件信息生成指纹 + */ + private static generateFromHardware(hwInfo: HardwareInfo): string { + const components = [ + hwInfo.motherboardUuid, // 最稳定的标识符 + hwInfo.cpuId, + hwInfo.biosSerial, + hwInfo.diskSerial, + hwInfo.macAddresses?.join(','), + os.platform(), // 操作系统平台 + os.arch() // CPU架构 + ].filter(Boolean); // 过滤空值 + + if (components.length === 0) { + throw new Error('No hardware identifiers found'); + } + + return this.hashFingerprint(components.join('|')); + } + + /** + * 生成回退指纹(当硬件检测失败时) + */ + private static generateFallbackFingerprint(): string { + const fallbackComponents = [ + os.hostname(), + os.platform(), + os.arch(), + process.cwd(), + 'fallback-mode' + ]; + + databaseLogger.warn('Using fallback fingerprint due to hardware detection failure', { + operation: 'hardware_fingerprint_fallback' + }); + + return this.hashFingerprint(fallbackComponents.join('|')); + } + + /** + * 标准化指纹哈希 + */ + private static hashFingerprint(data: string): string { + return crypto.createHash('sha256').update(data).digest('hex'); + } + + /** + * 获取硬件指纹信息(用于调试和显示) + */ + static getHardwareInfo(): HardwareInfo & { fingerprint: string } { + const hwInfo = this.detectHardwareInfo(); + return { + ...hwInfo, + fingerprint: this.generate().substring(0, 16) + }; + } + + /** + * 验证当前硬件指纹 + */ + static validateFingerprint(expectedFingerprint: string): boolean { + try { + const currentFingerprint = this.generate(); + return currentFingerprint === expectedFingerprint; + } catch { + return false; + } + } + + /** + * 清除缓存(用于测试) + */ + static clearCache(): void { + this.cachedFingerprint = null; + } +} + +export { HardwareFingerprint }; +export type { HardwareInfo }; \ No newline at end of file diff --git a/src/backend/utils/master-key-protection.ts b/src/backend/utils/master-key-protection.ts new file mode 100644 index 00000000..0b7c0739 --- /dev/null +++ b/src/backend/utils/master-key-protection.ts @@ -0,0 +1,196 @@ +import crypto from 'crypto'; +import { databaseLogger } from './logger.js'; +import { HardwareFingerprint } from './hardware-fingerprint.js'; + +interface ProtectedKeyData { + data: string; + iv: string; + tag: string; + version: string; + fingerprint: string; +} + +class MasterKeyProtection { + private static readonly VERSION = 'v1'; + private static readonly KEK_SALT = 'termix-kek-salt-v1'; + private static readonly KEK_ITERATIONS = 50000; + + private static generateDeviceFingerprint(): string { + try { + const fingerprint = HardwareFingerprint.generate(); + + databaseLogger.debug('Generated hardware fingerprint', { + operation: 'hardware_fingerprint_generation', + fingerprintPrefix: fingerprint.substring(0, 8) + }); + + return fingerprint; + } catch (error) { + databaseLogger.error('Failed to generate hardware fingerprint', error, { + operation: 'hardware_fingerprint_generation_failed' + }); + throw new Error('Hardware fingerprint generation failed'); + } + } + + + + private static deriveKEK(): Buffer { + const fingerprint = this.generateDeviceFingerprint(); + const salt = Buffer.from(this.KEK_SALT); + + const kek = crypto.pbkdf2Sync( + fingerprint, + salt, + this.KEK_ITERATIONS, + 32, + 'sha256' + ); + + databaseLogger.debug('Derived KEK from hardware fingerprint', { + operation: 'kek_derivation', + iterations: this.KEK_ITERATIONS + }); + + return kek; + } + + static encryptMasterKey(masterKey: string): string { + if (!masterKey) { + throw new Error('Master key cannot be empty'); + } + + try { + const kek = this.deriveKEK(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', kek, iv) as any; + + let encrypted = cipher.update(masterKey, 'hex', 'hex'); + encrypted += cipher.final('hex'); + const tag = cipher.getAuthTag(); + + const protectedData: ProtectedKeyData = { + data: encrypted, + iv: iv.toString('hex'), + tag: tag.toString('hex'), + version: this.VERSION, + fingerprint: this.generateDeviceFingerprint().substring(0, 16) + }; + + const result = JSON.stringify(protectedData); + + databaseLogger.info('Master key encrypted with hardware KEK', { + operation: 'master_key_encryption', + version: this.VERSION, + fingerprintPrefix: protectedData.fingerprint + }); + + return result; + } catch (error) { + databaseLogger.error('Failed to encrypt master key', error, { + operation: 'master_key_encryption_failed' + }); + throw new Error('Master key encryption failed'); + } + } + + static decryptMasterKey(encryptedKey: string): string { + if (!encryptedKey) { + throw new Error('Encrypted key cannot be empty'); + } + + try { + const protectedData: ProtectedKeyData = JSON.parse(encryptedKey); + + if (protectedData.version !== this.VERSION) { + throw new Error(`Unsupported protection version: ${protectedData.version}`); + } + + const currentFingerprint = this.generateDeviceFingerprint().substring(0, 16); + if (protectedData.fingerprint !== currentFingerprint) { + databaseLogger.warn('Hardware fingerprint mismatch detected', { + operation: 'master_key_decryption', + expected: protectedData.fingerprint, + current: currentFingerprint + }); + throw new Error('Hardware fingerprint mismatch - key was encrypted on different hardware'); + } + + const kek = this.deriveKEK(); + const decipher = crypto.createDecipheriv('aes-256-gcm', kek, Buffer.from(protectedData.iv, 'hex')) as any; + decipher.setAuthTag(Buffer.from(protectedData.tag, 'hex')); + + let decrypted = decipher.update(protectedData.data, 'hex', 'hex'); + decrypted += decipher.final('hex'); + + databaseLogger.debug('Master key decrypted successfully', { + operation: 'master_key_decryption', + version: protectedData.version + }); + + return decrypted; + } catch (error) { + databaseLogger.error('Failed to decrypt master key', error, { + operation: 'master_key_decryption_failed' + }); + throw new Error(`Master key decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + static isProtectedKey(data: string): boolean { + try { + const parsed = JSON.parse(data); + return !!(parsed.data && parsed.iv && parsed.tag && parsed.version && parsed.fingerprint); + } catch { + return false; + } + } + + static validateProtection(): boolean { + try { + const testKey = crypto.randomBytes(32).toString('hex'); + const encrypted = this.encryptMasterKey(testKey); + const decrypted = this.decryptMasterKey(encrypted); + + const isValid = decrypted === testKey; + + databaseLogger.info('Master key protection validation completed', { + operation: 'protection_validation', + result: isValid ? 'passed' : 'failed' + }); + + return isValid; + } catch (error) { + databaseLogger.error('Master key protection validation failed', error, { + operation: 'protection_validation_failed' + }); + return false; + } + } + + static getProtectionInfo(encryptedKey: string): { + version: string; + fingerprint: string; + isCurrentDevice: boolean; + } | null { + try { + if (!this.isProtectedKey(encryptedKey)) { + return null; + } + + const protectedData: ProtectedKeyData = JSON.parse(encryptedKey); + const currentFingerprint = this.generateDeviceFingerprint().substring(0, 16); + + return { + version: protectedData.version, + fingerprint: protectedData.fingerprint, + isCurrentDevice: protectedData.fingerprint === currentFingerprint + }; + } catch { + return null; + } + } +} + +export { MasterKeyProtection }; +export type { ProtectedKeyData }; \ No newline at end of file diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts new file mode 100644 index 00000000..438b0c27 --- /dev/null +++ b/src/backend/utils/ssh-key-utils.ts @@ -0,0 +1,527 @@ +// Import SSH2 using ES modules +import ssh2Pkg from 'ssh2'; +const ssh2Utils = ssh2Pkg.utils; + +// Simple fallback SSH key type detection +function detectKeyTypeFromContent(keyContent: string): string { + const content = keyContent.trim(); + + // Check for OpenSSH format headers + if (content.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) { + // Look for key type indicators in the content + if (content.includes('ssh-ed25519') || content.includes('AAAAC3NzaC1lZDI1NTE5')) { + return 'ssh-ed25519'; + } + if (content.includes('ssh-rsa') || content.includes('AAAAB3NzaC1yc2E')) { + return 'ssh-rsa'; + } + if (content.includes('ecdsa-sha2-nistp256')) { + return 'ecdsa-sha2-nistp256'; + } + if (content.includes('ecdsa-sha2-nistp384')) { + return 'ecdsa-sha2-nistp384'; + } + if (content.includes('ecdsa-sha2-nistp521')) { + return 'ecdsa-sha2-nistp521'; + } + + // For OpenSSH format, try to detect by analyzing the base64 content structure + try { + const base64Content = content + .replace('-----BEGIN OPENSSH PRIVATE KEY-----', '') + .replace('-----END OPENSSH PRIVATE KEY-----', '') + .replace(/\s/g, ''); + + // OpenSSH format starts with "openssh-key-v1" followed by key type + const decoded = Buffer.from(base64Content, 'base64').toString('binary'); + + if (decoded.includes('ssh-rsa')) { + return 'ssh-rsa'; + } + if (decoded.includes('ssh-ed25519')) { + return 'ssh-ed25519'; + } + if (decoded.includes('ecdsa-sha2-nistp256')) { + return 'ecdsa-sha2-nistp256'; + } + if (decoded.includes('ecdsa-sha2-nistp384')) { + return 'ecdsa-sha2-nistp384'; + } + if (decoded.includes('ecdsa-sha2-nistp521')) { + return 'ecdsa-sha2-nistp521'; + } + + // Default to RSA for OpenSSH format if we can't detect specifically + return 'ssh-rsa'; + } catch (error) { + console.warn('Failed to decode OpenSSH key content:', error); + // If decoding fails, default to RSA as it's most common for OpenSSH format + return 'ssh-rsa'; + } + } + + // Check for traditional PEM headers + if (content.includes('-----BEGIN RSA PRIVATE KEY-----')) { + return 'ssh-rsa'; + } + if (content.includes('-----BEGIN DSA PRIVATE KEY-----')) { + return 'ssh-dss'; + } + if (content.includes('-----BEGIN EC PRIVATE KEY-----')) { + return 'ecdsa-sha2-nistp256'; // Default ECDSA type + } + + // Check for PKCS#8 format (modern format) + if (content.includes('-----BEGIN PRIVATE KEY-----')) { + // Try to decode and analyze the DER structure for better detection + try { + const base64Content = content + .replace('-----BEGIN PRIVATE KEY-----', '') + .replace('-----END PRIVATE KEY-----', '') + .replace(/\s/g, ''); + + const decoded = Buffer.from(base64Content, 'base64'); + const decodedString = decoded.toString('binary'); + + // Check for algorithm identifiers in the DER structure + if (decodedString.includes('1.2.840.113549.1.1.1')) { + // RSA OID + return 'ssh-rsa'; + } else if (decodedString.includes('1.2.840.10045.2.1')) { + // EC Private Key OID - this indicates ECDSA + if (decodedString.includes('1.2.840.10045.3.1.7')) { + // prime256v1 curve OID + return 'ecdsa-sha2-nistp256'; + } + return 'ecdsa-sha2-nistp256'; // Default to P-256 + } else if (decodedString.includes('1.3.101.112')) { + // Ed25519 OID + return 'ssh-ed25519'; + } + } catch (error) { + // If decoding fails, fall back to length-based detection + console.warn('Failed to decode private key for type detection:', error); + } + + // Fallback: Try to detect key type from the content structure + // This is a fallback for PKCS#8 format keys + if (content.length < 800) { + // Ed25519 keys are typically shorter + return 'ssh-ed25519'; + } else if (content.length > 1600) { + // RSA keys are typically longer + return 'ssh-rsa'; + } else { + // ECDSA keys are typically medium length + return 'ecdsa-sha2-nistp256'; + } + } + + return 'unknown'; +} + +// Detect public key type from public key content +function detectPublicKeyTypeFromContent(publicKeyContent: string): string { + const content = publicKeyContent.trim(); + + // SSH public keys start with the key type + if (content.startsWith('ssh-rsa ')) { + return 'ssh-rsa'; + } + if (content.startsWith('ssh-ed25519 ')) { + return 'ssh-ed25519'; + } + if (content.startsWith('ecdsa-sha2-nistp256 ')) { + return 'ecdsa-sha2-nistp256'; + } + if (content.startsWith('ecdsa-sha2-nistp384 ')) { + return 'ecdsa-sha2-nistp384'; + } + if (content.startsWith('ecdsa-sha2-nistp521 ')) { + return 'ecdsa-sha2-nistp521'; + } + if (content.startsWith('ssh-dss ')) { + return 'ssh-dss'; + } + + // Check for PEM format public keys + if (content.includes('-----BEGIN PUBLIC KEY-----')) { + // Try to decode the base64 content to detect key type + try { + const base64Content = content + .replace('-----BEGIN PUBLIC KEY-----', '') + .replace('-----END PUBLIC KEY-----', '') + .replace(/\s/g, ''); + + const decoded = Buffer.from(base64Content, 'base64'); + const decodedString = decoded.toString('binary'); + + // Check for algorithm identifiers in the DER structure + if (decodedString.includes('1.2.840.113549.1.1.1')) { + // RSA OID + return 'ssh-rsa'; + } else if (decodedString.includes('1.2.840.10045.2.1')) { + // EC Public Key OID - this indicates ECDSA + if (decodedString.includes('1.2.840.10045.3.1.7')) { + // prime256v1 curve OID + return 'ecdsa-sha2-nistp256'; + } + return 'ecdsa-sha2-nistp256'; // Default to P-256 + } else if (decodedString.includes('1.3.101.112')) { + // Ed25519 OID + return 'ssh-ed25519'; + } + } catch (error) { + // If decoding fails, fall back to length-based detection + console.warn('Failed to decode public key for type detection:', error); + } + + // Fallback: Try to guess based on key length + if (content.length < 400) { + return 'ssh-ed25519'; + } else if (content.length > 600) { + return 'ssh-rsa'; + } else { + return 'ecdsa-sha2-nistp256'; + } + } + + if (content.includes('-----BEGIN RSA PUBLIC KEY-----')) { + return 'ssh-rsa'; + } + + // Check for base64 encoded key data patterns + if (content.includes('AAAAB3NzaC1yc2E')) { + return 'ssh-rsa'; + } + if (content.includes('AAAAC3NzaC1lZDI1NTE5')) { + return 'ssh-ed25519'; + } + if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY')) { + return 'ecdsa-sha2-nistp256'; + } + if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ')) { + return 'ecdsa-sha2-nistp384'; + } + if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE')) { + return 'ecdsa-sha2-nistp521'; + } + if (content.includes('AAAAB3NzaC1kc3M')) { + return 'ssh-dss'; + } + + return 'unknown'; +} + +export interface KeyInfo { + privateKey: string; + publicKey: string; + keyType: string; + success: boolean; + error?: string; +} + +export interface PublicKeyInfo { + publicKey: string; + keyType: string; + success: boolean; + error?: string; +} + +export interface KeyPairValidationResult { + isValid: boolean; + privateKeyType: string; + publicKeyType: string; + generatedPublicKey?: string; + error?: string; +} + +/** + * Parse SSH private key and extract public key and type information + */ +export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInfo { + console.log('=== SSH Key Parsing Debug ==='); + console.log('Key length:', privateKeyData?.length || 'undefined'); + console.log('First 100 chars:', privateKeyData?.substring(0, 100) || 'undefined'); + console.log('ssh2Utils available:', typeof ssh2Utils); + console.log('parseKey function available:', typeof ssh2Utils?.parseKey); + + try { + let keyType = 'unknown'; + let publicKey = ''; + let useSSH2 = false; + + // Try SSH2 first if available + if (ssh2Utils && typeof ssh2Utils.parseKey === 'function') { + try { + console.log('Calling ssh2Utils.parseKey...'); + const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase); + console.log('parseKey returned:', typeof parsedKey, parsedKey instanceof Error ? parsedKey.message : 'success'); + + if (!(parsedKey instanceof Error)) { + // Extract key type + if (parsedKey.type) { + keyType = parsedKey.type; + } + console.log('Extracted key type:', keyType); + + // Generate public key in SSH format + try { + console.log('Attempting to generate public key...'); + const publicKeyBuffer = parsedKey.getPublicSSH(); + console.log('Public key buffer type:', typeof publicKeyBuffer); + console.log('Public key buffer is Buffer:', Buffer.isBuffer(publicKeyBuffer)); + + // ssh2's getPublicSSH() returns binary SSH protocol data, not text + // We need to convert this to proper SSH public key format + if (Buffer.isBuffer(publicKeyBuffer)) { + // Convert binary SSH data to base64 and create proper SSH key format + const base64Data = publicKeyBuffer.toString('base64'); + + // Create proper SSH public key format: "keytype base64data" + if (keyType === 'ssh-rsa') { + publicKey = `ssh-rsa ${base64Data}`; + } else if (keyType === 'ssh-ed25519') { + publicKey = `ssh-ed25519 ${base64Data}`; + } else if (keyType.startsWith('ecdsa-')) { + publicKey = `${keyType} ${base64Data}`; + } else { + publicKey = `${keyType} ${base64Data}`; + } + + console.log('Generated SSH public key format, length:', publicKey.length); + console.log('Public key starts with:', publicKey.substring(0, 50)); + } else { + console.warn('Unexpected public key buffer type'); + publicKey = ''; + } + } catch (error) { + console.warn('Failed to generate public key:', error); + publicKey = ''; + } + + useSSH2 = true; + console.log(`SSH key parsed successfully with SSH2: ${keyType}`); + } else { + console.warn('SSH2 parsing failed:', parsedKey.message); + } + } catch (error) { + console.warn('SSH2 parsing exception:', error instanceof Error ? error.message : error); + } + } else { + console.warn('SSH2 parseKey function not available'); + } + + // Fallback to content-based detection + if (!useSSH2) { + console.log('Using fallback key type detection...'); + keyType = detectKeyTypeFromContent(privateKeyData); + console.log(`Fallback detected key type: ${keyType}`); + + // For fallback, we can't generate public key but the detection is still useful + publicKey = ''; + + if (keyType !== 'unknown') { + console.log(`SSH key type detected successfully with fallback: ${keyType}`); + } + } + + return { + privateKey: privateKeyData, + publicKey, + keyType, + success: keyType !== 'unknown' + }; + } catch (error) { + console.error('Exception during SSH key parsing:', error); + console.error('Error stack:', error instanceof Error ? error.stack : 'No stack'); + + // Final fallback - try content detection + try { + const fallbackKeyType = detectKeyTypeFromContent(privateKeyData); + if (fallbackKeyType !== 'unknown') { + console.log(`Final fallback detection successful: ${fallbackKeyType}`); + return { + privateKey: privateKeyData, + publicKey: '', + keyType: fallbackKeyType, + success: true + }; + } + } catch (fallbackError) { + console.error('Even fallback detection failed:', fallbackError); + } + + return { + privateKey: privateKeyData, + publicKey: '', + keyType: 'unknown', + success: false, + error: error instanceof Error ? error.message : 'Unknown error parsing key' + }; + } +} + +/** + * Parse SSH public key and extract type information + */ +export function parsePublicKey(publicKeyData: string): PublicKeyInfo { + console.log('=== SSH Public Key Parsing Debug ==='); + console.log('Public key length:', publicKeyData?.length || 'undefined'); + console.log('First 100 chars:', publicKeyData?.substring(0, 100) || 'undefined'); + + try { + const keyType = detectPublicKeyTypeFromContent(publicKeyData); + console.log(`Public key type detected: ${keyType}`); + + return { + publicKey: publicKeyData, + keyType, + success: keyType !== 'unknown' + }; + } catch (error) { + console.error('Exception during SSH public key parsing:', error); + return { + publicKey: publicKeyData, + keyType: 'unknown', + success: false, + error: error instanceof Error ? error.message : 'Unknown error parsing public key' + }; + } +} + +/** + * Detect SSH key type from private key content + */ +export function detectKeyType(privateKeyData: string): string { + try { + const parsedKey = ssh2Utils.parseKey(privateKeyData); + if (parsedKey instanceof Error) { + return 'unknown'; + } + return parsedKey.type || 'unknown'; + } catch (error) { + return 'unknown'; + } +} + +/** + * Get friendly key type name + */ +export function getFriendlyKeyTypeName(keyType: string): string { + const keyTypeMap: Record = { + 'ssh-rsa': 'RSA', + 'ssh-ed25519': 'Ed25519', + 'ecdsa-sha2-nistp256': 'ECDSA P-256', + 'ecdsa-sha2-nistp384': 'ECDSA P-384', + 'ecdsa-sha2-nistp521': 'ECDSA P-521', + 'ssh-dss': 'DSA', + 'rsa-sha2-256': 'RSA-SHA2-256', + 'rsa-sha2-512': 'RSA-SHA2-512', + 'unknown': 'Unknown' + }; + + return keyTypeMap[keyType] || keyType; +} + +/** + * Validate if a private key and public key form a valid key pair + */ +export function validateKeyPair(privateKeyData: string, publicKeyData: string, passphrase?: string): KeyPairValidationResult { + console.log('=== Key Pair Validation Debug ==='); + console.log('Private key length:', privateKeyData?.length || 'undefined'); + console.log('Public key length:', publicKeyData?.length || 'undefined'); + + try { + // First parse the private key and try to generate public key + const privateKeyInfo = parseSSHKey(privateKeyData, passphrase); + const publicKeyInfo = parsePublicKey(publicKeyData); + + console.log('Private key parsing result:', privateKeyInfo.success, privateKeyInfo.keyType); + console.log('Public key parsing result:', publicKeyInfo.success, publicKeyInfo.keyType); + + if (!privateKeyInfo.success) { + return { + isValid: false, + privateKeyType: privateKeyInfo.keyType, + publicKeyType: publicKeyInfo.keyType, + error: `Invalid private key: ${privateKeyInfo.error}` + }; + } + + if (!publicKeyInfo.success) { + return { + isValid: false, + privateKeyType: privateKeyInfo.keyType, + publicKeyType: publicKeyInfo.keyType, + error: `Invalid public key: ${publicKeyInfo.error}` + }; + } + + // Check if key types match + if (privateKeyInfo.keyType !== publicKeyInfo.keyType) { + return { + isValid: false, + privateKeyType: privateKeyInfo.keyType, + publicKeyType: publicKeyInfo.keyType, + error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}` + }; + } + + // If we have a generated public key from the private key, compare them + if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) { + const generatedPublicKey = privateKeyInfo.publicKey.trim(); + const providedPublicKey = publicKeyData.trim(); + + console.log('Generated public key length:', generatedPublicKey.length); + console.log('Provided public key length:', providedPublicKey.length); + + // Compare the key data part (excluding comments) + const generatedKeyParts = generatedPublicKey.split(' '); + const providedKeyParts = providedPublicKey.split(' '); + + if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) { + // Compare key type and key data (first two parts) + const generatedKeyData = generatedKeyParts[0] + ' ' + generatedKeyParts[1]; + const providedKeyData = providedKeyParts[0] + ' ' + providedKeyParts[1]; + + console.log('Generated key data:', generatedKeyData.substring(0, 50) + '...'); + console.log('Provided key data:', providedKeyData.substring(0, 50) + '...'); + + if (generatedKeyData === providedKeyData) { + return { + isValid: true, + privateKeyType: privateKeyInfo.keyType, + publicKeyType: publicKeyInfo.keyType, + generatedPublicKey: generatedPublicKey + }; + } else { + return { + isValid: false, + privateKeyType: privateKeyInfo.keyType, + publicKeyType: publicKeyInfo.keyType, + generatedPublicKey: generatedPublicKey, + error: 'Public key does not match the private key' + }; + } + } + } + + // If we can't generate public key or compare, just check if types match + return { + isValid: true, // Assume valid if types match and no errors + privateKeyType: privateKeyInfo.keyType, + publicKeyType: publicKeyInfo.keyType, + error: 'Unable to verify key pair match, but key types are compatible' + }; + + } catch (error) { + console.error('Exception during key pair validation:', error); + return { + isValid: false, + privateKeyType: 'unknown', + publicKeyType: 'unknown', + error: error instanceof Error ? error.message : 'Unknown error during validation' + }; + } +} \ No newline at end of file diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f40340c4..5f74a015 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -130,7 +130,27 @@ "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "failedToRenameFolder": "Failed to rename folder", "movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully", - "failedToMoveToFolder": "Failed to move credential to folder" + "failedToMoveToFolder": "Failed to move credential to folder", + "sshPublicKey": "SSH Public Key", + "publicKeyNote": "Public key is optional but recommended for key validation", + "publicKeyUploaded": "Public Key Uploaded", + "uploadPublicKey": "Upload Public Key", + "uploadPrivateKeyFile": "Upload Private Key File", + "uploadPublicKeyFile": "Upload Public Key File", + "privateKeyRequiredForGeneration": "Private key is required to generate public key", + "failedToGeneratePublicKey": "Failed to generate public key", + "generatePublicKey": "Generate from Private Key", + "publicKeyGeneratedSuccessfully": "Public key generated successfully", + "detectedKeyType": "Detected key type", + "detectingKeyType": "detecting...", + "optional": "Optional", + "generateKeyPair": "Generate New Key Pair", + "generateEd25519": "Generate Ed25519", + "generateECDSA": "Generate ECDSA", + "generateRSA": "Generate RSA", + "keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully", + "failedToGenerateKeyPair": "Failed to generate key pair", + "generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form." }, "sshTools": { "title": "SSH Tools", @@ -353,7 +373,121 @@ "deleteUser": "Delete user {{username}}? This cannot be undone.", "userDeletedSuccessfully": "User {{username}} deleted successfully", "failedToDeleteUser": "Failed to delete user", - "overrideUserInfoUrl": "Override User Info URL (not required)" + "overrideUserInfoUrl": "Override User Info URL (not required)", + "databaseSecurity": "Database Security", + "encryptionStatus": "Encryption Status", + "enabled": "Enabled", + "disabled": "Disabled", + "keyId": "Key ID", + "created": "Created", + "migrationStatus": "Migration Status", + "migrationCompleted": "Migration completed", + "migrationRequired": "Migration required", + "deviceProtectedMasterKey": "Environment-Protected Master Key", + "legacyKeyStorage": "Legacy Key Storage", + "masterKeyEncryptedWithDeviceFingerprint": "Master key encrypted with environment fingerprint (KEK protection active)", + "keyNotProtectedByDeviceBinding": "Key not protected by environment binding (upgrade recommended)", + "valid": "Valid", + "initializeDatabaseEncryption": "Initialize Database Encryption", + "enableAes256EncryptionWithDeviceBinding": "Enable AES-256 encryption with environment-bound master key protection. This creates enterprise-grade security for SSH keys, passwords, and authentication tokens.", + "featuresEnabled": "Features enabled:", + "aes256GcmAuthenticatedEncryption": "AES-256-GCM authenticated encryption", + "deviceFingerprintMasterKeyProtection": "Environment fingerprint master key protection (KEK)", + "pbkdf2KeyDerivation": "PBKDF2 key derivation with 100K iterations", + "automaticKeyManagement": "Automatic key management and rotation", + "initializing": "Initializing...", + "initializeEnterpriseEncryption": "Initialize Enterprise Encryption", + "migrateExistingData": "Migrate Existing Data", + "encryptExistingUnprotectedData": "Encrypt existing unprotected data in your database. This process is safe and creates automatic backups.", + "testMigrationDryRun": "Verify Encryption Compatibility", + "migrating": "Migrating...", + "migrateData": "Migrate Data", + "securityInformation": "Security Information", + "sshPrivateKeysEncryptedWithAes256": "SSH private keys and passwords are encrypted with AES-256-GCM", + "userAuthTokensProtected": "User authentication tokens and 2FA secrets are protected", + "masterKeysProtectedByDeviceFingerprint": "Master encryption keys are protected by device fingerprint (KEK)", + "keysBoundToServerInstance": "Keys are bound to current server environment (migratable via environment variables)", + "pbkdf2HkdfKeyDerivation": "PBKDF2 + HKDF key derivation with 100K iterations", + "backwardCompatibleMigration": "All data remains backward compatible during migration", + "enterpriseGradeSecurityActive": "Enterprise-Grade Security Active", + "masterKeysProtectedByDeviceBinding": "Your master encryption keys are protected by environment fingerprinting. This uses server hostname, paths, and other environment info to generate protection keys. To migrate servers, set the DB_ENCRYPTION_KEY environment variable on the new server.", + "important": "Important", + "keepEncryptionKeysSecure": "Ensure data security: regularly backup your database files and server configuration. To migrate to a new server, set the DB_ENCRYPTION_KEY environment variable on the new environment, or maintain the same hostname and directory structure.", + "loadingEncryptionStatus": "Loading encryption status...", + "testMigrationDescription": "Verify that existing data can be safely migrated to encrypted format without actually modifying any data", + "serverMigrationGuide": "Server Migration Guide", + "migrationInstructions": "To migrate encrypted data to a new server: 1) Backup database files, 2) Set environment variable DB_ENCRYPTION_KEY=\"your-key\" on new server, 3) Restore database files", + "environmentProtection": "Environment Protection", + "environmentProtectionDesc": "Protects encryption keys based on server environment info (hostname, paths, etc.), migratable via environment variables", + "verificationCompleted": "Compatibility verification completed - no data was changed", + "verificationInProgress": "Verification completed", + "dataMigrationCompleted": "Data migration completed successfully!", + "migrationCompleted": "Migration completed", + "verificationFailed": "Compatibility verification failed", + "migrationFailed": "Migration failed", + "runningVerification": "Running compatibility verification...", + "startingMigration": "Starting migration...", + "hardwareFingerprintSecurity": "Hardware Fingerprint Security", + "hardwareBoundEncryption": "Hardware-Bound Encryption Active", + "masterKeysNowProtectedByHardwareFingerprint": "Master keys are now protected by real hardware fingerprinting instead of environment variables", + "cpuSerialNumberDetection": "CPU serial number detection", + "motherboardUuidIdentification": "Motherboard UUID identification", + "diskSerialNumberVerification": "Disk serial number verification", + "biosSerialNumberCheck": "BIOS serial number check", + "stableMacAddressFiltering": "Stable MAC address filtering", + "databaseFileEncryption": "Database File Encryption", + "dualLayerProtection": "Dual-Layer Protection Active", + "bothFieldAndFileEncryptionActive": "Both field-level and file-level encryption are now active for maximum security", + "fieldLevelAes256Encryption": "Field-level AES-256 encryption for sensitive data", + "fileLevelDatabaseEncryption": "File-level database encryption with hardware binding", + "hardwareBoundFileKeys": "Hardware-bound file encryption keys", + "automaticEncryptedBackups": "Automatic encrypted backup creation", + "createEncryptedBackup": "Create Encrypted Backup", + "creatingBackup": "Creating Backup...", + "backupCreated": "Backup Created", + "encryptedBackupCreatedSuccessfully": "Encrypted backup created successfully", + "backupCreationFailed": "Backup creation failed", + "databaseMigration": "Database Migration", + "exportForMigration": "Export for Migration", + "exportDatabaseForHardwareMigration": "Export database as SQLite file with decrypted data for migration to new hardware", + "exportDatabase": "Export SQLite Database", + "exporting": "Exporting...", + "exportCreated": "SQLite Export Created", + "exportContainsDecryptedData": "SQLite export contains decrypted data - keep secure!", + "databaseExportedSuccessfully": "SQLite database exported successfully", + "databaseExportFailed": "SQLite database export failed", + "importFromMigration": "Import from Migration", + "importDatabaseFromAnotherSystem": "Import SQLite database from another system or hardware", + "importDatabase": "Import SQLite Database", + "importing": "Importing...", + "selectedFile": "Selected SQLite File", + "importWillReplaceExistingData": "SQLite import will replace existing data - backup recommended!", + "pleaseSelectImportFile": "Please select a SQLite import file", + "databaseImportedSuccessfully": "SQLite database imported successfully", + "databaseImportFailed": "SQLite database import failed", + "manageEncryptionAndBackups": "Manage encryption keys, database security, and backup operations", + "activeSecurityFeatures": "Currently active security measures and protections", + "deviceBindingTechnology": "Advanced hardware-based key protection technology", + "backupAndRecovery": "Secure backup creation and database recovery options", + "crossSystemDataTransfer": "Export and import databases across different systems", + "noMigrationNeeded": "No migration needed", + "encryptionKey": "Encryption Key", + "keyProtection": "Key Protection", + "active": "Active", + "legacy": "Legacy", + "dataStatus": "Data Status", + "encrypted": "Encrypted", + "needsMigration": "Needs Migration", + "ready": "Ready", + "initializeEncryption": "Initialize Encryption", + "initialize": "Initialize", + "test": "Test", + "migrate": "Migrate", + "backup": "Backup", + "createBackup": "Create Backup", + "exportImport": "Export/Import", + "export": "Export", + "import": "Import" }, "hosts": { "title": "Host Manager", @@ -398,6 +532,7 @@ "mustSelectValidSshConfig": "Must select a valid SSH configuration from the list", "addHost": "Add Host", "editHost": "Edit Host", + "cloneHost": "Clone Host", "updateHost": "Update Host", "hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!", "hostAddedSuccessfully": "Host \"{{name}}\" added successfully!", @@ -441,6 +576,8 @@ "upload": "Upload", "authentication": "Authentication", "password": "Password", + "requirePassword": "Require Password", + "requirePasswordDescription": "When disabled, sessions can be saved without entering a password", "key": "Key", "credential": "Credential", "selectCredential": "Select Credential", @@ -518,6 +655,7 @@ "folder": "Folder", "connectToSsh": "Connect to SSH to use file operations", "uploadFile": "Upload File", + "downloadFile": "Download File", "newFile": "New File", "newFolder": "New Folder", "rename": "Rename", @@ -530,7 +668,9 @@ "clickToSelectFile": "Click to select a file", "chooseFile": "Choose File", "uploading": "Uploading...", + "downloading": "Downloading...", "uploadingFile": "Uploading {{name}}...", + "downloadingFile": "Downloading {{name}}...", "creatingFile": "Creating {{name}}...", "creatingFolder": "Creating {{name}}...", "deletingItem": "Deleting {{type}} {{name}}...", @@ -552,11 +692,42 @@ "renaming": "Renaming...", "fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully", "failedToUploadFile": "Failed to upload file", + "fileDownloadedSuccessfully": "File \"{{name}}\" downloaded successfully", + "failedToDownloadFile": "Failed to download file", + "noFileContent": "No file content received", + "filePath": "File Path", "fileCreatedSuccessfully": "File \"{{name}}\" created successfully", "failedToCreateFile": "Failed to create file", "folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully", "failedToCreateFolder": "Failed to create folder", "itemDeletedSuccessfully": "{{type}} deleted successfully", + "itemsDeletedSuccessfully": "{{count}} items deleted successfully", + "failedToDeleteItems": "Failed to delete items", + "dragFilesToUpload": "Drop files here to upload", + "emptyFolder": "This folder is empty", + "itemCount": "{{count}} items", + "selectedCount": "{{count}} selected", + "searchFiles": "Search files...", + "upload": "Upload", + "selectHostToStart": "Select a host to start file management", + "failedToConnect": "Failed to connect to SSH", + "failedToLoadDirectory": "Failed to load directory", + "noSSHConnection": "No SSH connection available", + "enterFolderName": "Enter folder name:", + "enterFileName": "Enter file name:", + "copy": "Copy", + "cut": "Cut", + "paste": "Paste", + "delete": "Delete", + "properties": "Properties", + "preview": "Preview", + "refresh": "Refresh", + "downloadFiles": "Download {{count}} files", + "copyFiles": "Copy {{count}} items", + "cutFiles": "Cut {{count}} items", + "deleteFiles": "Delete {{count}} items", + "filesCopiedToClipboard": "{{count}} items copied to clipboard", + "filesCutToClipboard": "{{count}} items cut to clipboard", "failedToDeleteItem": "Failed to delete item", "itemRenamedSuccessfully": "{{type}} renamed successfully", "failedToRenameItem": "Failed to rename item", @@ -617,7 +788,40 @@ "sshStatusCheckTimeout": "SSH status check timed out", "sshReconnectionTimeout": "SSH reconnection timed out", "saveOperationTimeout": "Save operation timed out", - "cannotSaveFile": "Cannot save file" + "cannotSaveFile": "Cannot save file", + "dragSystemFilesToUpload": "Drag system files here to upload", + "dragFilesToWindowToDownload": "Drag files outside window to download", + "openTerminalHere": "Open Terminal Here", + "run": "Run", + "saveToSystem": "Save to System", + "selectLocationToSave": "Select Location to Save", + "openTerminalInFolder": "Open Terminal in This Folder", + "openTerminalInFileLocation": "Open Terminal at File Location", + "terminalWithPath": "Terminal - {{host}}:{{path}}", + "runningFile": "Running - {{file}}", + "onlyRunExecutableFiles": "Can only run executable files", + "noHostSelected": "No host selected", + "starred": "Starred", + "shortcuts": "Shortcuts", + "directories": "Directories", + "removedFromRecentFiles": "Removed \"{{name}}\" from recent files", + "removeFailed": "Remove failed", + "unpinnedSuccessfully": "Unpinned \"{{name}}\" successfully", + "unpinFailed": "Unpin failed", + "removedShortcut": "Removed shortcut \"{{name}}\"", + "removeShortcutFailed": "Remove shortcut failed", + "clearedAllRecentFiles": "Cleared all recent files", + "clearFailed": "Clear failed", + "removeFromRecentFiles": "Remove from recent files", + "clearAllRecentFiles": "Clear all recent files", + "unpinFile": "Unpin file", + "removeShortcut": "Remove shortcut", + "saveFilesToSystem": "Save {{count}} files to system", + "saveToSystem": "Save to system", + "pinFile": "Pin file", + "addToShortcuts": "Add to shortcuts", + "selectLocationToSave": "Select location to save", + "downloadToDefaultLocation": "Download to default location" }, "tunnels": { "title": "SSH Tunnels", @@ -878,6 +1082,7 @@ "password": "password", "keyPassword": "key password", "pastePrivateKey": "Paste your private key here...", + "pastePublicKey": "Paste your public key here...", "credentialName": "My SSH Server", "description": "SSH credential description", "searchCredentials": "Search credentials by name, username, or tags...", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 370d906f..e902cdae 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -129,7 +129,27 @@ "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", "failedToRenameFolder": "重命名文件夹失败", "movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"", - "failedToMoveToFolder": "移动凭据到文件夹失败" + "failedToMoveToFolder": "移动凭据到文件夹失败", + "sshPublicKey": "SSH公钥", + "publicKeyNote": "公钥是可选的,但建议提供以验证密钥对", + "publicKeyUploaded": "公钥已上传", + "uploadPublicKey": "上传公钥", + "uploadPrivateKeyFile": "上传私钥文件", + "uploadPublicKeyFile": "上传公钥文件", + "privateKeyRequiredForGeneration": "生成公钥需要先输入私钥", + "failedToGeneratePublicKey": "生成公钥失败", + "generatePublicKey": "从私钥生成", + "publicKeyGeneratedSuccessfully": "公钥生成成功", + "detectedKeyType": "检测到的密钥类型", + "detectingKeyType": "检测中...", + "optional": "可选", + "generateKeyPair": "生成新的密钥对", + "generateEd25519": "生成 Ed25519", + "generateECDSA": "生成 ECDSA", + "generateRSA": "生成 RSA", + "keyPairGeneratedSuccessfully": "{{keyType}} 密钥对生成成功", + "failedToGenerateKeyPair": "生成密钥对失败", + "generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。" }, "sshTools": { "title": "SSH 工具", @@ -339,7 +359,121 @@ "failedToRemoveAdminStatus": "移除管理员权限失败", "userDeletedSuccessfully": "用户 {{username}} 删除成功", "failedToDeleteUser": "删除用户失败", - "overrideUserInfoUrl": "覆盖用户信息 URL(非必填)" + "overrideUserInfoUrl": "覆盖用户信息 URL(非必填)", + "databaseSecurity": "数据库安全", + "encryptionStatus": "加密状态", + "enabled": "已启用", + "disabled": "已禁用", + "keyId": "密钥 ID", + "created": "创建时间", + "migrationStatus": "迁移状态", + "migrationCompleted": "迁移完成", + "migrationRequired": "需要迁移", + "deviceProtectedMasterKey": "环境保护主密钥", + "legacyKeyStorage": "传统密钥存储", + "masterKeyEncryptedWithDeviceFingerprint": "主密钥已通过环境指纹加密(KEK 保护已激活)", + "keyNotProtectedByDeviceBinding": "密钥未受环境绑定保护(建议升级)", + "valid": "有效", + "initializeDatabaseEncryption": "初始化数据库加密", + "enableAes256EncryptionWithDeviceBinding": "启用具有环境绑定主密钥保护的 AES-256 加密。这为 SSH 密钥、密码和身份验证令牌创建企业级安全保护。", + "featuresEnabled": "启用的功能:", + "aes256GcmAuthenticatedEncryption": "AES-256-GCM 认证加密", + "deviceFingerprintMasterKeyProtection": "环境指纹主密钥保护 (KEK)", + "pbkdf2KeyDerivation": "PBKDF2 密钥推导(10万次迭代)", + "automaticKeyManagement": "自动密钥管理和轮换", + "initializing": "初始化中...", + "initializeEnterpriseEncryption": "初始化企业级加密", + "migrateExistingData": "迁移现有数据", + "encryptExistingUnprotectedData": "加密数据库中现有的未保护数据。此过程安全可靠,会自动创建备份。", + "testMigrationDryRun": "验证加密兼容性", + "migrating": "迁移中...", + "migrateData": "迁移数据", + "securityInformation": "安全信息", + "sshPrivateKeysEncryptedWithAes256": "SSH 私钥和密码使用 AES-256-GCM 加密", + "userAuthTokensProtected": "用户认证令牌和 2FA 密钥受到保护", + "masterKeysProtectedByDeviceFingerprint": "主加密密钥受设备指纹保护 (KEK)", + "keysBoundToServerInstance": "密钥绑定到当前服务器环境(可通过环境变量迁移)", + "pbkdf2HkdfKeyDerivation": "PBKDF2 + HKDF 密钥推导(10万次迭代)", + "backwardCompatibleMigration": "迁移过程中所有数据保持向后兼容", + "enterpriseGradeSecurityActive": "企业级安全已激活", + "masterKeysProtectedByDeviceBinding": "您的主加密密钥受环境指纹保护。这基于服务器的主机名、路径等环境信息生成保护密钥。如需迁移服务器,可通过设置 DB_ENCRYPTION_KEY 环境变量来实现数据迁移。", + "important": "重要提示", + "keepEncryptionKeysSecure": "确保数据安全:定期备份数据库文件和服务器配置。如需迁移到新服务器,请在新环境中设置 DB_ENCRYPTION_KEY 环境变量,或保持相同的主机名和目录结构。", + "loadingEncryptionStatus": "正在加载加密状态...", + "testMigrationDescription": "验证现有数据是否可以安全地迁移到加密格式,不会实际修改任何数据", + "serverMigrationGuide": "服务器迁移指南", + "migrationInstructions": "要将加密数据迁移到新服务器:1) 备份数据库文件,2) 在新服务器设置环境变量 DB_ENCRYPTION_KEY=\"你的密钥\",3) 恢复数据库文件", + "environmentProtection": "环境保护", + "environmentProtectionDesc": "基于服务器环境信息(主机名、路径等)保护加密密钥,可通过环境变量实现迁移", + "verificationCompleted": "兼容性验证完成 - 未修改任何数据", + "verificationInProgress": "验证完成", + "dataMigrationCompleted": "数据迁移完成!", + "migrationCompleted": "迁移完成", + "verificationFailed": "兼容性验证失败", + "migrationFailed": "迁移失败", + "runningVerification": "正在进行兼容性验证...", + "startingMigration": "开始迁移...", + "hardwareFingerprintSecurity": "硬件指纹安全", + "hardwareBoundEncryption": "硬件绑定加密已激活", + "masterKeysNowProtectedByHardwareFingerprint": "主密钥现在受真实硬件指纹保护,而非环境变量", + "cpuSerialNumberDetection": "CPU 序列号检测", + "motherboardUuidIdentification": "主板 UUID 识别", + "diskSerialNumberVerification": "磁盘序列号验证", + "biosSerialNumberCheck": "BIOS 序列号检查", + "stableMacAddressFiltering": "稳定 MAC 地址过滤", + "databaseFileEncryption": "数据库文件加密", + "dualLayerProtection": "双层保护已激活", + "bothFieldAndFileEncryptionActive": "字段级和文件级加密现均已激活,提供最大安全保护", + "fieldLevelAes256Encryption": "敏感数据的字段级 AES-256 加密", + "fileLevelDatabaseEncryption": "硬件绑定的文件级数据库加密", + "hardwareBoundFileKeys": "硬件绑定的文件加密密钥", + "automaticEncryptedBackups": "自动加密备份创建", + "createEncryptedBackup": "创建加密备份", + "creatingBackup": "创建备份中...", + "backupCreated": "备份已创建", + "encryptedBackupCreatedSuccessfully": "加密备份创建成功", + "backupCreationFailed": "备份创建失败", + "databaseMigration": "数据库迁移", + "exportForMigration": "导出用于迁移", + "exportDatabaseForHardwareMigration": "导出 SQLite 格式的解密数据库以迁移到新硬件", + "exportDatabase": "导出 SQLite 数据库", + "exporting": "导出中...", + "exportCreated": "SQLite 导出已创建", + "exportContainsDecryptedData": "SQLite 导出包含解密数据 - 请保持安全!", + "databaseExportedSuccessfully": "SQLite 数据库导出成功", + "databaseExportFailed": "SQLite 数据库导出失败", + "importFromMigration": "从迁移导入", + "importDatabaseFromAnotherSystem": "从其他系统或硬件导入 SQLite 数据库", + "importDatabase": "导入 SQLite 数据库", + "importing": "导入中...", + "selectedFile": "选定 SQLite 文件", + "importWillReplaceExistingData": "SQLite 导入将替换现有数据 - 建议备份!", + "pleaseSelectImportFile": "请选择 SQLite 导入文件", + "databaseImportedSuccessfully": "SQLite 数据库导入成功", + "databaseImportFailed": "SQLite 数据库导入失败", + "manageEncryptionAndBackups": "管理加密密钥、数据库安全和备份操作", + "activeSecurityFeatures": "当前活跃的安全措施和保护功能", + "deviceBindingTechnology": "高级硬件密钥保护技术", + "backupAndRecovery": "安全备份创建和数据库恢复选项", + "crossSystemDataTransfer": "跨系统数据库导出和导入", + "noMigrationNeeded": "无需迁移", + "encryptionKey": "加密密钥", + "keyProtection": "密钥保护", + "active": "已激活", + "legacy": "旧版", + "dataStatus": "数据状态", + "encrypted": "已加密", + "needsMigration": "需要迁移", + "ready": "就绪", + "initializeEncryption": "初始化加密", + "initialize": "初始化", + "test": "测试", + "migrate": "迁移", + "backup": "备份", + "createBackup": "创建备份", + "exportImport": "导出/导入", + "export": "导出", + "import": "导入" }, "hosts": { "title": "主机管理", @@ -384,6 +518,7 @@ "mustSelectValidSshConfig": "必须从列表中选择有效的 SSH 配置", "addHost": "添加主机", "editHost": "编辑主机", + "cloneHost": "克隆主机", "deleteHost": "删除主机", "authType": "认证类型", "passwordAuth": "密码", @@ -441,6 +576,8 @@ "upload": "上传", "authentication": "认证方式", "password": "密码", + "requirePassword": "要求密码", + "requirePasswordDescription": "禁用时,可以在不输入密码的情况下保存会话", "key": "密钥", "credential": "凭证", "selectCredential": "选择凭证", @@ -533,6 +670,7 @@ "folder": "文件夹", "connectToSsh": "连接 SSH 以使用文件操作", "uploadFile": "上传文件", + "downloadFile": "下载文件", "newFile": "新建文件", "newFolder": "新建文件夹", "rename": "重命名", @@ -545,7 +683,9 @@ "clickToSelectFile": "点击选择文件", "chooseFile": "选择文件", "uploading": "上传中...", + "downloading": "下载中...", "uploadingFile": "正在上传 {{name}}...", + "downloadingFile": "正在下载 {{name}}...", "creatingFile": "正在创建 {{name}}...", "creatingFolder": "正在创建 {{name}}...", "deletingItem": "正在删除 {{type}} {{name}}...", @@ -567,11 +707,42 @@ "renaming": "重命名中...", "fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功", "failedToUploadFile": "上传文件失败", + "fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功", + "failedToDownloadFile": "下载文件失败", + "noFileContent": "未收到文件内容", + "filePath": "文件路径", "fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功", "failedToCreateFile": "创建文件失败", "folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功", "failedToCreateFolder": "创建文件夹失败", "itemDeletedSuccessfully": "{{type}}删除成功", + "itemsDeletedSuccessfully": "{{count}} 个项目删除成功", + "failedToDeleteItems": "删除项目失败", + "dragFilesToUpload": "拖拽文件到这里上传", + "emptyFolder": "此文件夹为空", + "itemCount": "{{count}} 个项目", + "selectedCount": "已选择 {{count}} 个", + "searchFiles": "搜索文件...", + "upload": "上传", + "selectHostToStart": "选择主机开始文件管理", + "failedToConnect": "连接SSH失败", + "failedToLoadDirectory": "加载目录失败", + "noSSHConnection": "无SSH连接可用", + "enterFolderName": "输入文件夹名称:", + "enterFileName": "输入文件名称:", + "copy": "复制", + "cut": "剪切", + "paste": "粘贴", + "delete": "删除", + "properties": "属性", + "preview": "预览", + "refresh": "刷新", + "downloadFiles": "下载 {{count}} 个文件", + "copyFiles": "复制 {{count}} 个项目", + "cutFiles": "剪切 {{count}} 个项目", + "deleteFiles": "删除 {{count}} 个项目", + "filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板", + "filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板", "failedToDeleteItem": "删除项目失败", "itemRenamedSuccessfully": "{{type}}重命名成功", "failedToRenameItem": "重命名项目失败", @@ -608,6 +779,18 @@ "confirmDeleteMessage": "确定要删除 {{name}} 吗?", "deleteDirectoryWarning": "这将删除文件夹及其所有内容。", "actionCannotBeUndone": "此操作无法撤销。", + "dragSystemFilesToUpload": "拖拽系统文件到此处上传", + "dragFilesToWindowToDownload": "拖拽文件到窗口外下载", + "openTerminalHere": "在此处打开终端", + "run": "运行", + "saveToSystem": "保存到系统", + "selectLocationToSave": "选择位置保存", + "openTerminalInFolder": "在此文件夹打开终端", + "openTerminalInFileLocation": "在文件位置打开终端", + "terminalWithPath": "终端 - {{host}}:{{path}}", + "runningFile": "运行 - {{file}}", + "onlyRunExecutableFiles": "只能运行可执行文件", + "noHostSelected": "没有选择主机", "recent": "最近的", "pinned": "固定的", "folderShortcuts": "文件夹快捷方式", @@ -624,7 +807,28 @@ "sshStatusCheckTimeout": "SSH 状态检查超时", "sshReconnectionTimeout": "SSH 重新连接超时", "saveOperationTimeout": "保存操作超时", - "cannotSaveFile": "无法保存文件" + "cannotSaveFile": "无法保存文件", + "starred": "收藏", + "shortcuts": "快捷方式", + "directories": "目录", + "removedFromRecentFiles": "已从最近访问中移除\"{{name}}\"", + "removeFailed": "移除失败", + "unpinnedSuccessfully": "已取消固定\"{{name}}\"", + "unpinFailed": "取消固定失败", + "removedShortcut": "已移除快捷方式\"{{name}}\"", + "removeShortcutFailed": "移除快捷方式失败", + "clearedAllRecentFiles": "已清除所有最近访问记录", + "clearFailed": "清除失败", + "removeFromRecentFiles": "从最近访问中移除", + "clearAllRecentFiles": "清除所有最近访问", + "unpinFile": "取消固定", + "removeShortcut": "移除快捷方式", + "saveFilesToSystem": "保存 {{count}} 个文件到系统", + "saveToSystem": "保存到系统", + "pinFile": "固定文件", + "addToShortcuts": "添加到快捷方式", + "selectLocationToSave": "选择位置保存", + "downloadToDefaultLocation": "下载到默认位置" }, "tunnels": { "title": "SSH 隧道", @@ -874,6 +1078,7 @@ "searchCredentials": "按名称、用户名或标签搜索凭据...", "keyPassword": "密钥密码", "pastePrivateKey": "在此粘贴您的私钥...", + "pastePublicKey": "在此粘贴您的公钥...", "sshConfig": "端点 SSH 配置", "homePath": "/home", "clientId": "您的客户端 ID", diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts new file mode 100644 index 00000000..3e615387 --- /dev/null +++ b/src/types/electron.d.ts @@ -0,0 +1,66 @@ +export interface ElectronAPI { + getAppVersion: () => Promise; + getPlatform: () => Promise; + + getServerConfig: () => Promise; + saveServerConfig: (config: any) => Promise; + testServerConnection: (serverUrl: string) => Promise; + + showSaveDialog: (options: any) => Promise; + showOpenDialog: (options: any) => Promise; + + onUpdateAvailable: (callback: Function) => void; + onUpdateDownloaded: (callback: Function) => void; + + removeAllListeners: (channel: string) => void; + isElectron: boolean; + isDev: boolean; + + invoke: (channel: string, ...args: any[]) => Promise; + + // 拖拽API + createTempFile: (fileData: { + fileName: string; + content: string; + encoding?: 'base64' | 'utf8'; + }) => Promise<{ + success: boolean; + tempId?: string; + path?: string; + error?: string; + }>; + + createTempFolder: (folderData: { + folderName: string; + files: Array<{ + relativePath: string; + content: string; + encoding?: 'base64' | 'utf8'; + }>; + }) => Promise<{ + success: boolean; + tempId?: string; + path?: string; + error?: string; + }>; + + startDragToDesktop: (dragData: { + tempId: string; + fileName: string; + }) => Promise<{ + success: boolean; + error?: string; + }>; + + cleanupTempFile: (tempId: string) => Promise<{ + success: boolean; + error?: string; + }>; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + IS_ELECTRON: boolean; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 706c8828..b0ea7d37 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -70,6 +70,7 @@ export interface Credential { username: string; password?: string; key?: string; + publicKey?: string; keyPassword?: string; keyType?: string; usageCount: number; @@ -87,6 +88,7 @@ export interface CredentialData { username: string; password?: string; key?: string; + publicKey?: string; keyPassword?: string; keyType?: string; } @@ -180,8 +182,15 @@ export interface FileItem { name: string; path: string; isPinned?: boolean; - type: "file" | "directory"; + type: "file" | "directory" | "link"; sshSessionId?: string; + size?: number; + modified?: string; + permissions?: string; + owner?: string; + group?: string; + linkTarget?: string; + executable?: boolean; } export interface ShortcutItem { diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index 8b2f8cc9..86be205a 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -21,7 +21,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table.tsx"; -import { Shield, Trash2, Users } from "lucide-react"; +import { Shield, Trash2, Users, Database, Key, Lock, Download, Upload, HardDrive, FileArchive } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; @@ -82,6 +82,20 @@ export function AdminSettings({ null, ); + // Database encryption state + const [encryptionStatus, setEncryptionStatus] = React.useState(null); + const [encryptionLoading, setEncryptionLoading] = React.useState(false); + const [migrationLoading, setMigrationLoading] = React.useState(false); + const [migrationProgress, setMigrationProgress] = React.useState(""); + + // Database migration state + const [exportLoading, setExportLoading] = React.useState(false); + const [importLoading, setImportLoading] = React.useState(false); + const [backupLoading, setBackupLoading] = React.useState(false); + const [importFile, setImportFile] = React.useState(null); + const [exportPath, setExportPath] = React.useState(""); + const [backupPath, setBackupPath] = React.useState(""); + React.useEffect(() => { const jwt = getCookie("jwt"); if (!jwt) return; @@ -103,6 +117,7 @@ export function AdminSettings({ } }); fetchUsers(); + fetchEncryptionStatus(); }, []); React.useEffect(() => { @@ -251,6 +266,213 @@ export function AdminSettings({ ); }; + const fetchEncryptionStatus = async () => { + if (isElectron()) { + const serverUrl = (window as any).configuredServerUrl; + if (!serverUrl) return; + } + + try { + const jwt = getCookie("jwt"); + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/encryption/status` + : "http://localhost:8081/encryption/status"; + + const response = await fetch(apiUrl, { + headers: { + "Authorization": `Bearer ${jwt}`, + "Content-Type": "application/json" + } + }); + + if (response.ok) { + const data = await response.json(); + setEncryptionStatus(data); + } + } catch (err) { + console.error("Failed to fetch encryption status:", err); + } + }; + + const handleInitializeEncryption = async () => { + setEncryptionLoading(true); + try { + const jwt = getCookie("jwt"); + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/encryption/initialize` + : "http://localhost:8081/encryption/initialize"; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${jwt}`, + "Content-Type": "application/json" + }, + }); + + if (response.ok) { + const result = await response.json(); + toast.success("Database encryption initialized successfully!"); + await fetchEncryptionStatus(); + } else { + throw new Error("Failed to initialize encryption"); + } + } catch (err) { + toast.error("Failed to initialize encryption"); + } finally { + setEncryptionLoading(false); + } + }; + + const handleMigrateData = async (dryRun: boolean = false) => { + setMigrationLoading(true); + setMigrationProgress(dryRun ? t("admin.runningVerification") : t("admin.startingMigration")); + + try { + const jwt = getCookie("jwt"); + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/encryption/migrate` + : "http://localhost:8081/encryption/migrate"; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${jwt}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ dryRun }), + }); + + if (response.ok) { + const result = await response.json(); + if (dryRun) { + toast.success(t("admin.verificationCompleted")); + setMigrationProgress(t("admin.verificationInProgress")); + } else { + toast.success(t("admin.dataMigrationCompleted")); + setMigrationProgress(t("admin.migrationCompleted")); + await fetchEncryptionStatus(); + } + } else { + throw new Error("Migration failed"); + } + } catch (err) { + toast.error(dryRun ? t("admin.verificationFailed") : t("admin.migrationFailed")); + setMigrationProgress("Failed"); + } finally { + setMigrationLoading(false); + setTimeout(() => setMigrationProgress(""), 3000); + } + }; + + // Database export/import handlers + const handleExportDatabase = async () => { + setExportLoading(true); + try { + const jwt = getCookie("jwt"); + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/database/export` + : "http://localhost:8081/database/export"; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${jwt}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + }); + + if (response.ok) { + const result = await response.json(); + setExportPath(result.exportPath); + toast.success(t("admin.databaseExportedSuccessfully")); + } else { + throw new Error("Export failed"); + } + } catch (err) { + toast.error(t("admin.databaseExportFailed")); + } finally { + setExportLoading(false); + } + }; + + const handleImportDatabase = async () => { + if (!importFile) { + toast.error(t("admin.pleaseSelectImportFile")); + return; + } + + setImportLoading(true); + try { + const jwt = getCookie("jwt"); + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/database/import` + : "http://localhost:8081/database/import"; + + // Create FormData for file upload + const formData = new FormData(); + formData.append('file', importFile); + formData.append('backupCurrent', 'true'); + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${jwt}`, + }, + body: formData + }); + + if (response.ok) { + const result = await response.json(); + if (result.success) { + toast.success(t("admin.databaseImportedSuccessfully")); + setImportFile(null); + await fetchEncryptionStatus(); // Refresh status + } else { + toast.error(`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`); + } + } else { + throw new Error("Import failed"); + } + } catch (err) { + toast.error(t("admin.databaseImportFailed")); + } finally { + setImportLoading(false); + } + }; + + const handleCreateBackup = async () => { + setBackupLoading(true); + try { + const jwt = getCookie("jwt"); + const apiUrl = isElectron() + ? `${(window as any).configuredServerUrl}/database/backup` + : "http://localhost:8081/database/backup"; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${jwt}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + }); + + if (response.ok) { + const result = await response.json(); + setBackupPath(result.backupPath); + toast.success(t("admin.encryptedBackupCreatedSuccessfully")); + } else { + throw new Error("Backup failed"); + } + } catch (err) { + toast.error(t("admin.backupCreationFailed")); + } finally { + setBackupLoading(false); + } + }; + const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const bottomMarginPx = 8; @@ -295,6 +517,10 @@ export function AdminSettings({ {t("admin.adminManagement")} + + +{t("admin.databaseSecurity")} + @@ -680,6 +906,202 @@ export function AdminSettings({ + + +
+
+ +

{t("admin.databaseSecurity")}

+
+ + {encryptionStatus && ( +
+ {/* Status Overview */} +
+
+
+ {encryptionStatus.encryption?.enabled ? ( + + ) : ( + + )} +
+
{t("admin.encryptionStatus")}
+
+ {encryptionStatus.encryption?.enabled ? t("admin.enabled") : t("admin.disabled")} +
+
+
+
+ +
+
+ +
+
{t("admin.keyProtection")}
+
+ {encryptionStatus.encryption?.key?.kekProtected ? t("admin.active") : t("admin.legacy")} +
+
+
+
+ +
+
+ +
+
{t("admin.dataStatus")}
+
+ {encryptionStatus.migration?.migrationCompleted + ? t("admin.encrypted") + : encryptionStatus.migration?.migrationRequired + ? t("admin.needsMigration") + : t("admin.ready")} +
+
+
+
+
+ + {/* Actions */} +
+ {!encryptionStatus.encryption?.key?.hasKey ? ( +
+
+
+ +

{t("admin.initializeEncryption")}

+
+ +
+
+ ) : ( + <> + {encryptionStatus.migration?.migrationRequired && ( +
+
+
+ +

{t("admin.migrateData")}

+
+ {migrationProgress && ( +
{migrationProgress}
+ )} +
+ + +
+
+
+ )} + +
+
+
+ +

{t("admin.backup")}

+
+ + {backupPath && ( +
+
{backupPath}
+
+ )} +
+
+ + )} + +
+
+
+ +

{t("admin.exportImport")}

+
+
+ + {exportPath && ( +
+
{exportPath}
+
+ )} +
+
+ setImportFile(e.target.files?.[0] || null)} + className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground" + /> + +
+
+
+
+
+ )} + + {!encryptionStatus && ( +
+
{t("admin.loadingEncryptionStatus")}
+
+ )} +
+
diff --git a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx index f0401878..52652ae0 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx @@ -22,6 +22,10 @@ import { updateCredential, getCredentials, getCredentialDetails, + detectKeyType, + detectPublicKeyType, + generatePublicKeyFromPrivate, + generateKeyPair, } from "@/ui/main-axios"; import { useTranslation } from "react-i18next"; import type { @@ -42,9 +46,14 @@ export function CredentialEditor({ useState(null); const [authTab, setAuthTab] = useState<"password" | "key">("password"); - const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">( - "upload", - ); + const [detectedKeyType, setDetectedKeyType] = useState(null); + const [keyDetectionLoading, setKeyDetectionLoading] = useState(false); + const keyDetectionTimeoutRef = useRef(null); + + const [detectedPublicKeyType, setDetectedPublicKeyType] = useState(null); + const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false); + const publicKeyDetectionTimeoutRef = useRef(null); + useEffect(() => { const fetchData = async () => { @@ -101,6 +110,7 @@ export function CredentialEditor({ username: z.string().min(1), password: z.string().optional(), key: z.any().optional().nullable(), + publicKey: z.string().optional(), keyPassword: z.string().optional(), keyType: z .enum([ @@ -149,6 +159,7 @@ export function CredentialEditor({ username: "", password: "", key: null, + publicKey: "", keyPassword: "", keyType: "auto", }, @@ -169,6 +180,7 @@ export function CredentialEditor({ username: fullCredentialDetails.username || "", password: "", key: null, + publicKey: "", keyPassword: "", keyType: "auto" as const, }; @@ -176,7 +188,8 @@ export function CredentialEditor({ if (defaultAuthType === "password") { formData.password = fullCredentialDetails.password || ""; } else if (defaultAuthType === "key") { - formData.key = "existing_key"; + formData.key = fullCredentialDetails.key || ""; + formData.publicKey = fullCredentialDetails.publicKey || ""; formData.keyPassword = fullCredentialDetails.keyPassword || ""; formData.keyType = (fullCredentialDetails.keyType as any) || ("auto" as const); @@ -196,6 +209,7 @@ export function CredentialEditor({ username: "", password: "", key: null, + publicKey: "", keyPassword: "", keyType: "auto", }); @@ -203,6 +217,104 @@ export function CredentialEditor({ } }, [editingCredential?.id, fullCredentialDetails, form]); + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (keyDetectionTimeoutRef.current) { + clearTimeout(keyDetectionTimeoutRef.current); + } + if (publicKeyDetectionTimeoutRef.current) { + clearTimeout(publicKeyDetectionTimeoutRef.current); + } + }; + }, []); + + // Detect key type function + const handleKeyTypeDetection = async (keyValue: string, keyPassword?: string) => { + if (!keyValue || keyValue.trim() === '') { + setDetectedKeyType(null); + return; + } + + setKeyDetectionLoading(true); + try { + const result = await detectKeyType(keyValue, keyPassword); + if (result.success) { + setDetectedKeyType(result.keyType); + } else { + setDetectedKeyType('invalid'); + console.warn('Key detection failed:', result.error); + } + } catch (error) { + setDetectedKeyType('error'); + console.error('Key type detection error:', error); + } finally { + setKeyDetectionLoading(false); + } + }; + + // Debounced key type detection + const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => { + if (keyDetectionTimeoutRef.current) { + clearTimeout(keyDetectionTimeoutRef.current); + } + keyDetectionTimeoutRef.current = setTimeout(() => { + handleKeyTypeDetection(keyValue, keyPassword); + }, 1000); + }; + + // Detect public key type function + const handlePublicKeyTypeDetection = async (publicKeyValue: string) => { + if (!publicKeyValue || publicKeyValue.trim() === '') { + setDetectedPublicKeyType(null); + return; + } + + setPublicKeyDetectionLoading(true); + try { + const result = await detectPublicKeyType(publicKeyValue); + if (result.success) { + setDetectedPublicKeyType(result.keyType); + } else { + setDetectedPublicKeyType('invalid'); + console.warn('Public key detection failed:', result.error); + } + } catch (error) { + setDetectedPublicKeyType('error'); + console.error('Public key type detection error:', error); + } finally { + setPublicKeyDetectionLoading(false); + } + }; + + // Debounced public key type detection + const debouncedPublicKeyDetection = (publicKeyValue: string) => { + if (publicKeyDetectionTimeoutRef.current) { + clearTimeout(publicKeyDetectionTimeoutRef.current); + } + publicKeyDetectionTimeoutRef.current = setTimeout(() => { + handlePublicKeyTypeDetection(publicKeyValue); + }, 1000); + }; + + + const getFriendlyKeyTypeName = (keyType: string): string => { + const keyTypeMap: Record = { + 'ssh-rsa': 'RSA (SSH)', + 'ssh-ed25519': 'Ed25519 (SSH)', + 'ecdsa-sha2-nistp256': 'ECDSA P-256 (SSH)', + 'ecdsa-sha2-nistp384': 'ECDSA P-384 (SSH)', + 'ecdsa-sha2-nistp521': 'ECDSA P-521 (SSH)', + 'ssh-dss': 'DSA (SSH)', + 'rsa-sha2-256': 'RSA-SHA2-256', + 'rsa-sha2-512': 'RSA-SHA2-512', + 'invalid': 'Invalid Key', + 'error': 'Detection Error', + 'unknown': 'Unknown' + }; + return keyTypeMap[keyType] || keyType; + }; + const onSubmit = async (data: FormData) => { try { if (!data.name || data.name.trim() === "") { @@ -221,20 +333,15 @@ export function CredentialEditor({ submitData.password = null; submitData.key = null; + submitData.publicKey = null; submitData.keyPassword = null; submitData.keyType = null; if (data.authType === "password") { submitData.password = data.password; } else if (data.authType === "key") { - if (data.key instanceof File) { - const keyContent = await data.key.text(); - submitData.key = keyContent; - } else if (data.key === "existing_key") { - delete submitData.key; - } else { - submitData.key = data.key; - } + submitData.key = data.key; + submitData.publicKey = data.publicKey; submitData.keyPassword = data.keyPassword; submitData.keyType = data.keyType; } @@ -259,11 +366,17 @@ export function CredentialEditor({ form.reset(); } catch (error) { - toast.error(t("credentials.failedToSaveCredential")); + console.error("Credential save error:", error); + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error(t("credentials.failedToSaveCredential")); + } } }; const [tagInput, setTagInput] = useState(""); + const [keyGenerationPassphrase, setKeyGenerationPassphrase] = useState(""); const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); const folderInputRef = useRef(null); @@ -305,38 +418,6 @@ export function CredentialEditor({ }; }, [folderDropdownOpen]); - const keyTypeOptions = [ - { value: "auto", label: t("hosts.autoDetect") }, - { value: "ssh-rsa", label: t("hosts.rsa") }, - { value: "ssh-ed25519", label: t("hosts.ed25519") }, - { value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") }, - { value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") }, - { value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") }, - { value: "ssh-dss", label: t("hosts.dsa") }, - { value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") }, - { value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") }, - ]; - - const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); - const keyTypeButtonRef = useRef(null); - const keyTypeDropdownRef = useRef(null); - - useEffect(() => { - function onClickOutside(event: MouseEvent) { - if ( - keyTypeDropdownOpen && - keyTypeDropdownRef.current && - !keyTypeDropdownRef.current.contains(event.target as Node) && - keyTypeButtonRef.current && - !keyTypeButtonRef.current.contains(event.target as Node) - ) { - setKeyTypeDropdownOpen(false); - } - } - - document.addEventListener("mousedown", onClickOutside); - return () => document.removeEventListener("mousedown", onClickOutside); - }, [keyTypeDropdownOpen]); return (
- { - setKeyInputMethod(value as "upload" | "paste"); - if (value === "upload") { - form.setValue("key", null); - } else { - form.setValue("key", ""); - } - }} - className="w-full" - > - - - {t("hosts.uploadFile")} - - - {t("hosts.pasteKey")} - - - - ( - - - {t("credentials.sshPrivateKey")} - - -
- + {/* Generate Key Pair Buttons */} +
+ + {t("credentials.generateKeyPair")} + + + {/* Key Generation Passphrase Input */} +
+ + {t("credentials.keyPassword")} ({t("credentials.optional")}) + + setKeyGenerationPassphrase(e.target.value)} + className="max-w-xs" + /> +
+ {t("credentials.keyPassphraseOptional")} +
+
+ +
+ + + +
+
+ {t("credentials.generateKeyPairNote")} +
+
+
+ ( + + + {t("credentials.sshPrivateKey")} + +
+
+ { + const file = e.target.files?.[0]; + if (file) { + try { + const fileContent = await file.text(); + field.onChange(fileContent); + debouncedKeyDetection(fileContent, form.watch("keyPassword")); + } catch (error) { + console.error('Failed to read uploaded file:', error); + } + } + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+ +