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..c8f3518d 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -5,6 +5,7 @@ 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"; const router = express.Router(); @@ -109,6 +110,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 +135,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 +268,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 +340,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; @@ -585,6 +630,7 @@ function formatCredentialOutput(credential: any): any { authType: credential.authType, username: credential.username, keyType: credential.keyType, + detectedKeyType: credential.detectedKeyType, usageCount: credential.usageCount || 0, lastUsed: credential.lastUsed, createdAt: credential.createdAt, @@ -661,4 +707,125 @@ 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" + }); + } +}); + 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..55d65568 --- /dev/null +++ b/src/backend/utils/ssh-key-utils.ts @@ -0,0 +1,444 @@ +// 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 + } + + 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 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'; +} + +// Try multiple import approaches for SSH2 +let ssh2Utils: any = null; + +try { + // Approach 1: Default import + console.log('Trying SSH2 default import...'); + const ssh2Default = require('ssh2'); + console.log('SSH2 default import result:', typeof ssh2Default); + console.log('SSH2 utils from default:', typeof ssh2Default?.utils); + + if (ssh2Default && ssh2Default.utils) { + ssh2Utils = ssh2Default.utils; + console.log('Using SSH2 from default import'); + } +} catch (error) { + console.log('SSH2 default import failed:', error instanceof Error ? error.message : error); +} + +if (!ssh2Utils) { + try { + // Approach 2: Direct utils import + console.log('Trying SSH2 utils direct import...'); + const ssh2UtilsDirect = require('ssh2').utils; + console.log('SSH2 utils direct import result:', typeof ssh2UtilsDirect); + + if (ssh2UtilsDirect) { + ssh2Utils = ssh2UtilsDirect; + console.log('Using SSH2 from direct utils import'); + } + } catch (error) { + console.log('SSH2 utils direct import failed:', error instanceof Error ? error.message : error); + } +} + +if (!ssh2Utils) { + console.error('Failed to import SSH2 utils with any method - using fallback detection'); +} + +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(); + // Handle SSH public key format properly + publicKey = publicKeyBuffer.toString('utf8').trim(); + console.log('Public key generated, length:', publicKey.length); + } 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); + } + } + + // 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/ui/Desktop/Apps/Credentials/CredentialEditor.tsx b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx index a99cdccd..30e45de1 100644 --- a/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx +++ b/src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx @@ -22,6 +22,9 @@ import { updateCredential, getCredentials, getCredentialDetails, + detectKeyType, + detectPublicKeyType, + validateKeyPair, } from "@/ui/main-axios"; import { useTranslation } from "react-i18next"; import type { @@ -45,6 +48,20 @@ export function CredentialEditor({ 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); + + const [keyPairValidation, setKeyPairValidation] = useState<{ + isValid: boolean | null; + loading: boolean; + error?: string; + }>({ isValid: null, loading: false }); + const keyPairValidationTimeoutRef = useRef(null); useEffect(() => { const fetchData = async () => { @@ -101,6 +118,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 +167,7 @@ export function CredentialEditor({ username: "", password: "", key: null, + publicKey: "", keyPassword: "", keyType: "auto", }, @@ -169,6 +188,7 @@ export function CredentialEditor({ username: fullCredentialDetails.username || "", password: "", key: null, + publicKey: "", keyPassword: "", keyType: "auto" as const, }; @@ -177,6 +197,7 @@ export function CredentialEditor({ formData.password = fullCredentialDetails.password || ""; } else if (defaultAuthType === "key") { formData.key = fullCredentialDetails.key || ""; + formData.publicKey = fullCredentialDetails.publicKey || ""; formData.keyPassword = fullCredentialDetails.keyPassword || ""; formData.keyType = (fullCredentialDetails.keyType as any) || ("auto" as const); @@ -196,6 +217,7 @@ export function CredentialEditor({ username: "", password: "", key: null, + publicKey: "", keyPassword: "", keyType: "auto", }); @@ -203,6 +225,140 @@ 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); + } + if (keyPairValidationTimeoutRef.current) { + clearTimeout(keyPairValidationTimeoutRef.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); + }; + + // Validate key pair function + const handleKeyPairValidation = async (privateKeyValue: string, publicKeyValue: string, keyPassword?: string) => { + if (!privateKeyValue || privateKeyValue.trim() === '' || !publicKeyValue || publicKeyValue.trim() === '') { + setKeyPairValidation({ isValid: null, loading: false }); + return; + } + + setKeyPairValidation({ isValid: null, loading: true }); + try { + const result = await validateKeyPair(privateKeyValue, publicKeyValue, keyPassword); + setKeyPairValidation({ + isValid: result.isValid, + loading: false, + error: result.error + }); + } catch (error) { + setKeyPairValidation({ + isValid: false, + loading: false, + error: error instanceof Error ? error.message : 'Unknown error during validation' + }); + } + }; + + // Debounced key pair validation + const debouncedKeyPairValidation = (privateKeyValue: string, publicKeyValue: string, keyPassword?: string) => { + if (keyPairValidationTimeoutRef.current) { + clearTimeout(keyPairValidationTimeoutRef.current); + } + keyPairValidationTimeoutRef.current = setTimeout(() => { + handleKeyPairValidation(privateKeyValue, publicKeyValue, keyPassword); + }, 1500); // Slightly longer delay since this is more expensive + }; + + const 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', + '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,6 +377,7 @@ export function CredentialEditor({ submitData.password = null; submitData.key = null; + submitData.publicKey = null; submitData.keyPassword = null; submitData.keyType = null; @@ -233,6 +390,7 @@ export function CredentialEditor({ } else { submitData.key = data.key; } + submitData.publicKey = data.publicKey; submitData.keyPassword = data.keyPassword; submitData.keyType = data.keyType; } @@ -600,17 +758,22 @@ export function CredentialEditor({ if (!editingCredential) { if (value === "upload") { form.setValue("key", null); - } else { + form.setValue("publicKey", ""); + } else if (value === "paste") { form.setValue("key", ""); + form.setValue("publicKey", ""); } } else { // For existing credentials, preserve the key data when switching methods const currentKey = fullCredentialDetails?.key || ""; + const currentPublicKey = fullCredentialDetails?.publicKey || ""; if (value === "paste") { form.setValue("key", currentKey); + form.setValue("publicKey", currentPublicKey); } else { // For upload mode, keep the current string value to show "existing key" status form.setValue("key", currentKey); + form.setValue("publicKey", currentPublicKey); } } }} @@ -625,52 +788,159 @@ export function CredentialEditor({ - ( - - - {t("credentials.sshPrivateKey")} - - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - +
+
+ {/* Key type detection display for uploaded files */} + {detectedKeyType && field.value instanceof File && ( +
+ Detected key type: + + {getFriendlyKeyTypeName(detectedKeyType)} - + {keyDetectionLoading && ( + (detecting...) + )} +
+ )} +
+ )} + /> + ( + + + {t("credentials.sshPublicKey")} ({t("credentials.optional")}) + + +
+ { + const file = e.target.files?.[0]; + if (file) { + try { + const fileContent = await file.text(); + field.onChange(fileContent); + // Detect public key type from uploaded file + debouncedPublicKeyDetection(fileContent); + // Trigger key pair validation if private key is available + const privateKeyValue = form.watch("key"); + if (privateKeyValue && typeof privateKeyValue === "string" && privateKeyValue.trim()) { + debouncedKeyPairValidation(privateKeyValue, fileContent, form.watch("keyPassword")); + } + } catch (error) { + console.error('Failed to read uploaded public key file:', error); + } + } else { + field.onChange(""); + setDetectedPublicKeyType(null); + } + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ {t("credentials.publicKeyNote")}
- -
- )} - /> + {/* Public key type detection display for upload mode */} + {detectedPublicKeyType && field.value && ( +
+ Detected key type: + + {getFriendlyKeyTypeName(detectedPublicKeyType)} + + {publicKeyDetectionLoading && ( + (detecting...) + )} +
+ )} + + )} + /> + {/* Show existing key content preview for upload mode */} {editingCredential && fullCredentialDetails?.key && typeof form.watch("key") === "string" && ( @@ -685,6 +955,15 @@ export function CredentialEditor({
Current SSH key content - {t("credentials.uploadFile")} to replace
+ {/* Show detected key type for existing credential */} + {fullCredentialDetails?.detectedKeyType && ( +
+ Key type: + + {getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)} + +
+ )}
)}
@@ -760,33 +1039,130 @@ export function CredentialEditor({
- ( - - - {t("credentials.sshPrivateKey")} - - -