diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 9e46d73a..7e64d754 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -137,9 +137,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..51e0bef4 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -5,6 +5,56 @@ 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 { 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 +159,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,9 +184,12 @@ 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, }; @@ -248,7 +317,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,7 +389,26 @@ 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; @@ -584,7 +678,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 +757,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/ssh/terminal.ts b/src/backend/ssh/terminal.ts index cb1ec180..638bebc2 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -191,7 +191,7 @@ wss.on("connection", (ws: WebSocket) => { 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, 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/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..1926015a 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", @@ -878,6 +898,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..e9b168a6 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 工具", @@ -874,6 +894,7 @@ "searchCredentials": "按名称、用户名或标签搜索凭据...", "keyPassword": "密钥密码", "pastePrivateKey": "在此粘贴您的私钥...", + "pastePublicKey": "在此粘贴您的公钥...", "sshConfig": "端点 SSH 配置", "homePath": "/home", "clientId": "您的客户端 ID", diff --git a/src/types/index.ts b/src/types/index.ts index 706c8828..dbc1d987 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; } 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" + /> + +
+
+ +