Add comprehensive SSH key management and validation features
- Add support for both private and public key storage - Implement automatic SSH key type detection for all major formats (RSA, Ed25519, ECDSA, DSA) - Add real-time key pair validation to verify private/public key correspondence - Enhance credential editor UI with unified key input interface supporting upload/paste - Improve file format support including extensionless files (id_rsa, id_ed25519, etc.) - Add comprehensive fallback detection for OpenSSH format keys - Implement debounced API calls for better UX during real-time validation - Update database schema with backward compatibility for existing credentials - Add API endpoints for key detection and pair validation - Fix SSH2 module integration issues in TypeScript environment 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -137,9 +137,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
authType: text("auth_type").notNull(),
|
authType: text("auth_type").notNull(),
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
password: text("password"),
|
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"),
|
keyPassword: text("key_password"),
|
||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
|
detectedKeyType: text("detected_key_type"),
|
||||||
usageCount: integer("usage_count").notNull().default(0),
|
usageCount: integer("usage_count").notNull().default(0),
|
||||||
lastUsed: text("last_used"),
|
lastUsed: text("last_used"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { eq, and, desc, sql } from "drizzle-orm";
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -109,6 +110,22 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
const plainKeyPassword =
|
const plainKeyPassword =
|
||||||
authType === "key" && keyPassword ? keyPassword : null;
|
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 = {
|
const credentialData = {
|
||||||
userId,
|
userId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -118,9 +135,12 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
authType,
|
authType,
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: plainPassword,
|
password: plainPassword,
|
||||||
key: plainKey,
|
key: plainKey, // backward compatibility
|
||||||
|
privateKey: keyInfo?.privateKey || plainKey,
|
||||||
|
publicKey: keyInfo?.publicKey || null,
|
||||||
keyPassword: plainKeyPassword,
|
keyPassword: plainKeyPassword,
|
||||||
keyType: keyType || null,
|
keyType: keyType || null,
|
||||||
|
detectedKeyType: keyInfo?.keyType || null,
|
||||||
usageCount: 0,
|
usageCount: 0,
|
||||||
lastUsed: null,
|
lastUsed: null,
|
||||||
};
|
};
|
||||||
@@ -248,7 +268,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
(output as any).password = credential.password;
|
(output as any).password = credential.password;
|
||||||
}
|
}
|
||||||
if (credential.key) {
|
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) {
|
if (credential.keyPassword) {
|
||||||
(output as any).keyPassword = 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;
|
updateFields.password = updateData.password || null;
|
||||||
}
|
}
|
||||||
if (updateData.key !== undefined) {
|
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) {
|
if (updateData.keyPassword !== undefined) {
|
||||||
updateFields.keyPassword = updateData.keyPassword || null;
|
updateFields.keyPassword = updateData.keyPassword || null;
|
||||||
@@ -585,6 +630,7 @@ function formatCredentialOutput(credential: any): any {
|
|||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
username: credential.username,
|
username: credential.username,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
|
detectedKeyType: credential.detectedKeyType,
|
||||||
usageCount: credential.usageCount || 0,
|
usageCount: credential.usageCount || 0,
|
||||||
lastUsed: credential.lastUsed,
|
lastUsed: credential.lastUsed,
|
||||||
createdAt: credential.createdAt,
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
key: credential.key,
|
key: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedSourceCredentials = {
|
resolvedSourceCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.key,
|
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
@@ -501,7 +501,7 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedEndpointCredentials = {
|
resolvedEndpointCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.key,
|
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
|
|||||||
444
src/backend/utils/ssh-key-utils.ts
Normal file
444
src/backend/utils/ssh-key-utils.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
'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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
updateCredential,
|
updateCredential,
|
||||||
getCredentials,
|
getCredentials,
|
||||||
getCredentialDetails,
|
getCredentialDetails,
|
||||||
|
detectKeyType,
|
||||||
|
detectPublicKeyType,
|
||||||
|
validateKeyPair,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
@@ -45,6 +48,20 @@ export function CredentialEditor({
|
|||||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
||||||
"upload",
|
"upload",
|
||||||
);
|
);
|
||||||
|
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
|
||||||
|
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
|
||||||
|
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<string | null>(null);
|
||||||
|
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false);
|
||||||
|
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const [keyPairValidation, setKeyPairValidation] = useState<{
|
||||||
|
isValid: boolean | null;
|
||||||
|
loading: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>({ isValid: null, loading: false });
|
||||||
|
const keyPairValidationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -101,6 +118,7 @@ export function CredentialEditor({
|
|||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
key: z.any().optional().nullable(),
|
key: z.any().optional().nullable(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
keyPassword: z.string().optional(),
|
keyPassword: z.string().optional(),
|
||||||
keyType: z
|
keyType: z
|
||||||
.enum([
|
.enum([
|
||||||
@@ -149,6 +167,7 @@ export function CredentialEditor({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
},
|
},
|
||||||
@@ -169,6 +188,7 @@ export function CredentialEditor({
|
|||||||
username: fullCredentialDetails.username || "",
|
username: fullCredentialDetails.username || "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto" as const,
|
keyType: "auto" as const,
|
||||||
};
|
};
|
||||||
@@ -177,6 +197,7 @@ export function CredentialEditor({
|
|||||||
formData.password = fullCredentialDetails.password || "";
|
formData.password = fullCredentialDetails.password || "";
|
||||||
} else if (defaultAuthType === "key") {
|
} else if (defaultAuthType === "key") {
|
||||||
formData.key = fullCredentialDetails.key || "";
|
formData.key = fullCredentialDetails.key || "";
|
||||||
|
formData.publicKey = fullCredentialDetails.publicKey || "";
|
||||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||||
formData.keyType =
|
formData.keyType =
|
||||||
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
||||||
@@ -196,6 +217,7 @@ export function CredentialEditor({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
});
|
});
|
||||||
@@ -203,6 +225,140 @@ export function CredentialEditor({
|
|||||||
}
|
}
|
||||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
}, [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<string, string> = {
|
||||||
|
'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) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
try {
|
try {
|
||||||
if (!data.name || data.name.trim() === "") {
|
if (!data.name || data.name.trim() === "") {
|
||||||
@@ -221,6 +377,7 @@ export function CredentialEditor({
|
|||||||
|
|
||||||
submitData.password = null;
|
submitData.password = null;
|
||||||
submitData.key = null;
|
submitData.key = null;
|
||||||
|
submitData.publicKey = null;
|
||||||
submitData.keyPassword = null;
|
submitData.keyPassword = null;
|
||||||
submitData.keyType = null;
|
submitData.keyType = null;
|
||||||
|
|
||||||
@@ -233,6 +390,7 @@ export function CredentialEditor({
|
|||||||
} else {
|
} else {
|
||||||
submitData.key = data.key;
|
submitData.key = data.key;
|
||||||
}
|
}
|
||||||
|
submitData.publicKey = data.publicKey;
|
||||||
submitData.keyPassword = data.keyPassword;
|
submitData.keyPassword = data.keyPassword;
|
||||||
submitData.keyType = data.keyType;
|
submitData.keyType = data.keyType;
|
||||||
}
|
}
|
||||||
@@ -600,17 +758,22 @@ export function CredentialEditor({
|
|||||||
if (!editingCredential) {
|
if (!editingCredential) {
|
||||||
if (value === "upload") {
|
if (value === "upload") {
|
||||||
form.setValue("key", null);
|
form.setValue("key", null);
|
||||||
} else {
|
form.setValue("publicKey", "");
|
||||||
|
} else if (value === "paste") {
|
||||||
form.setValue("key", "");
|
form.setValue("key", "");
|
||||||
|
form.setValue("publicKey", "");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For existing credentials, preserve the key data when switching methods
|
// For existing credentials, preserve the key data when switching methods
|
||||||
const currentKey = fullCredentialDetails?.key || "";
|
const currentKey = fullCredentialDetails?.key || "";
|
||||||
|
const currentPublicKey = fullCredentialDetails?.publicKey || "";
|
||||||
if (value === "paste") {
|
if (value === "paste") {
|
||||||
form.setValue("key", currentKey);
|
form.setValue("key", currentKey);
|
||||||
|
form.setValue("publicKey", currentPublicKey);
|
||||||
} else {
|
} else {
|
||||||
// For upload mode, keep the current string value to show "existing key" status
|
// For upload mode, keep the current string value to show "existing key" status
|
||||||
form.setValue("key", currentKey);
|
form.setValue("key", currentKey);
|
||||||
|
form.setValue("publicKey", currentPublicKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -625,52 +788,159 @@ export function CredentialEditor({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="upload" className="mt-4">
|
<TabsContent value="upload" className="mt-4">
|
||||||
<Controller
|
<div className="grid grid-cols-2 gap-4 items-start">
|
||||||
control={form.control}
|
<Controller
|
||||||
name="key"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="key"
|
||||||
<FormItem className="mb-4">
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem className="mb-4 flex flex-col">
|
||||||
{t("credentials.sshPrivateKey")}
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
</FormLabel>
|
{t("credentials.sshPrivateKey")}
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<div className="relative inline-block">
|
<FormControl>
|
||||||
<input
|
<div className="relative inline-block w-full">
|
||||||
id="key-upload"
|
<input
|
||||||
type="file"
|
id="key-upload"
|
||||||
accept=".pem,.key,.txt,.ppk"
|
type="file"
|
||||||
onChange={(e) => {
|
accept="*,.pem,.key,.txt,.ppk"
|
||||||
const file = e.target.files?.[0];
|
onChange={async (e) => {
|
||||||
field.onChange(file || null);
|
const file = e.target.files?.[0];
|
||||||
}}
|
field.onChange(file || null);
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
// Detect key type from uploaded file
|
||||||
<Button
|
if (file) {
|
||||||
type="button"
|
try {
|
||||||
variant="outline"
|
const fileContent = await file.text();
|
||||||
className="justify-start text-left"
|
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
|
||||||
>
|
// Trigger key pair validation if public key is available
|
||||||
<span
|
const publicKeyValue = form.watch("publicKey");
|
||||||
className="truncate"
|
if (publicKeyValue && publicKeyValue.trim()) {
|
||||||
title={
|
debouncedKeyPairValidation(fileContent, publicKeyValue, form.watch("keyPassword"));
|
||||||
field.value?.name ||
|
}
|
||||||
t("credentials.upload")
|
} catch (error) {
|
||||||
}
|
console.error('Failed to read uploaded file:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDetectedKeyType(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
>
|
>
|
||||||
{field.value instanceof File
|
<span
|
||||||
? field.value.name
|
className="truncate"
|
||||||
: typeof field.value === "string" && field.value && editingCredential
|
title={
|
||||||
? t("hosts.existingKey") + " - " + t("credentials.updateKey")
|
field.value?.name ||
|
||||||
: field.value
|
t("credentials.upload")
|
||||||
? t("credentials.updateKey")
|
}
|
||||||
: t("credentials.upload")}
|
>
|
||||||
|
{field.value instanceof File
|
||||||
|
? field.value.name
|
||||||
|
: typeof field.value === "string" && field.value && editingCredential
|
||||||
|
? t("hosts.existingKey") + " - " + t("credentials.updateKey")
|
||||||
|
: field.value
|
||||||
|
? t("credentials.updateKey")
|
||||||
|
: t("credentials.upload")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
{/* Key type detection display for uploaded files */}
|
||||||
|
{detectedKeyType && field.value instanceof File && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">Detected key type: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedKeyType === 'invalid' || detectedKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedKeyType)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
{keyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">(detecting...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4 flex flex-col">
|
||||||
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
|
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative inline-block w-full">
|
||||||
|
<input
|
||||||
|
id="public-key-upload"
|
||||||
|
type="file"
|
||||||
|
accept="*,.pub,.txt"
|
||||||
|
onChange={async (e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{field.value
|
||||||
|
? t("credentials.publicKeyUploaded")
|
||||||
|
: t("credentials.uploadPublicKey")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.publicKeyNote")}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
{/* Public key type detection display for upload mode */}
|
||||||
</FormItem>
|
{detectedPublicKeyType && field.value && (
|
||||||
)}
|
<div className="text-sm mt-2">
|
||||||
/>
|
<span className="text-muted-foreground">Detected key type: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedPublicKeyType)}
|
||||||
|
</span>
|
||||||
|
{publicKeyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">(detecting...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/* Show existing key content preview for upload mode */}
|
{/* Show existing key content preview for upload mode */}
|
||||||
{editingCredential && fullCredentialDetails?.key && typeof form.watch("key") === "string" && (
|
{editingCredential && fullCredentialDetails?.key && typeof form.watch("key") === "string" && (
|
||||||
<FormItem className="mb-4">
|
<FormItem className="mb-4">
|
||||||
@@ -685,6 +955,15 @@ export function CredentialEditor({
|
|||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Current SSH key content - {t("credentials.uploadFile")} to replace
|
Current SSH key content - {t("credentials.uploadFile")} to replace
|
||||||
</div>
|
</div>
|
||||||
|
{/* Show detected key type for existing credential */}
|
||||||
|
{fullCredentialDetails?.detectedKeyType && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">Key type: </span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||||
@@ -760,33 +1039,130 @@ export function CredentialEditor({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="paste" className="mt-4">
|
<TabsContent value="paste" className="mt-4">
|
||||||
<Controller
|
<div className="grid grid-cols-2 gap-4 items-start">
|
||||||
control={form.control}
|
<Controller
|
||||||
name="key"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="key"
|
||||||
<FormItem className="mb-4">
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem className="mb-4 flex flex-col">
|
||||||
{t("credentials.sshPrivateKey")}
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
</FormLabel>
|
{t("credentials.sshPrivateKey")}
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<textarea
|
<FormControl>
|
||||||
placeholder={t(
|
<textarea
|
||||||
"placeholders.pastePrivateKey",
|
placeholder={t(
|
||||||
)}
|
"placeholders.pastePrivateKey",
|
||||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
)}
|
||||||
value={
|
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
typeof field.value === "string"
|
value={
|
||||||
? field.value
|
typeof field.value === "string"
|
||||||
: ""
|
? field.value
|
||||||
}
|
: ""
|
||||||
onChange={(e) =>
|
}
|
||||||
field.onChange(e.target.value)
|
onChange={(e) => {
|
||||||
}
|
field.onChange(e.target.value);
|
||||||
/>
|
// Trigger key type detection with debounce
|
||||||
</FormControl>
|
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
|
||||||
</FormItem>
|
// Trigger key pair validation if public key is available
|
||||||
)}
|
const publicKeyValue = form.watch("publicKey");
|
||||||
/>
|
if (publicKeyValue && publicKeyValue.trim()) {
|
||||||
|
debouncedKeyPairValidation(e.target.value, publicKeyValue, form.watch("keyPassword"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{/* Key type detection display */}
|
||||||
|
{detectedKeyType && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">Detected key type: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedKeyType === 'invalid' || detectedKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedKeyType)}
|
||||||
|
</span>
|
||||||
|
{keyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">(detecting...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4 flex flex-col">
|
||||||
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
|
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
placeholder={t(
|
||||||
|
"placeholders.pastePublicKey",
|
||||||
|
)}
|
||||||
|
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
// Trigger public key type detection with debounce
|
||||||
|
debouncedPublicKeyDetection(e.target.value);
|
||||||
|
// Trigger key pair validation if private key is available
|
||||||
|
const privateKeyValue = form.watch("key");
|
||||||
|
if (privateKeyValue && typeof privateKeyValue === "string" && privateKeyValue.trim()) {
|
||||||
|
debouncedKeyPairValidation(privateKeyValue, e.target.value, form.watch("keyPassword"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.publicKeyNote")}
|
||||||
|
</div>
|
||||||
|
{/* Public key type detection display for paste mode */}
|
||||||
|
{detectedPublicKeyType && field.value && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">Detected key type: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedPublicKeyType)}
|
||||||
|
</span>
|
||||||
|
{publicKeyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">(detecting...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Key pair validation display */}
|
||||||
|
{(form.watch("key") && form.watch("publicKey") &&
|
||||||
|
typeof form.watch("key") === "string" && form.watch("key").trim() &&
|
||||||
|
form.watch("publicKey").trim()) && (
|
||||||
|
<div className="mt-4 p-3 border rounded-md bg-muted/50">
|
||||||
|
<div className="text-sm font-medium mb-1">Key Pair Validation</div>
|
||||||
|
{keyPairValidation.loading && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span>Validating key pair...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!keyPairValidation.loading && keyPairValidation.isValid === true && (
|
||||||
|
<div className="text-sm text-green-600 font-medium">
|
||||||
|
✓ Private and public keys match
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!keyPairValidation.loading && keyPairValidation.isValid === false && (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
✗ Keys do not match: {keyPairValidation.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1666,3 +1666,48 @@ export async function renameCredentialFolder(
|
|||||||
throw handleApiError(error, "rename credential folder");
|
throw handleApiError(error, "rename credential folder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function detectKeyType(
|
||||||
|
privateKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/detect-key-type", {
|
||||||
|
privateKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "detect key type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectPublicKeyType(
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/detect-public-key-type", {
|
||||||
|
publicKey,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "detect public key type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateKeyPair(
|
||||||
|
privateKey: string,
|
||||||
|
publicKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/validate-key-pair", {
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "validate key pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user