Add SSH key generation and deployment features #234

Merged
ZacharyZcR merged 6 commits from main into dev-1.7.0 2025-09-15 02:29:32 +00:00
12 changed files with 2338 additions and 276 deletions

View File

@@ -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"),
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

⚠️ Potential issue

Encrypt private keys at rest; reconsider TEXT length hints.

  • Storing privateKey in plaintext is a high‑risk posture. Encrypt at rest (e.g., libsodium or AES‑GCM with app‑level KEK) and ensure strict access controls.
  • SQLite does not enforce TEXT length; the hints are cosmetic. Consider dropping length or increasing publicKey to avoid truncation risk from long comments.
-  key: text("key", { length: 16384 }), // backward compatibility
-  privateKey: text("private_key", { length: 16384 }),
-  publicKey: text("public_key", { length: 4096 }),
+  key: text("key"), // backward compatibility
+  privateKey: text("private_key"), // store encrypted blob if possible
+  publicKey: text("public_key"),

Also add a migration to backfill privateKey from key and then phase out key.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  key: text("key"), // backward compatibility
  privateKey: text("private_key"), // store encrypted blob if possible
  publicKey: text("public_key"),
  keyPassword: text("key_password"),
  keyType: text("key_type"),
  detectedKeyType: text("detected_key_type"),
  usageCount: integer("usage_count").notNull().default(0),
_🛠️ Refactor suggestion_ _⚠️ Potential issue_ **Encrypt private keys at rest; reconsider TEXT length hints.** - Storing `privateKey` in plaintext is a high‑risk posture. Encrypt at rest (e.g., libsodium or AES‑GCM with app‑level KEK) and ensure strict access controls. - SQLite does not enforce TEXT length; the hints are cosmetic. Consider dropping length or increasing `publicKey` to avoid truncation risk from long comments. ```diff - key: text("key", { length: 16384 }), // backward compatibility - privateKey: text("private_key", { length: 16384 }), - publicKey: text("public_key", { length: 4096 }), + key: text("key"), // backward compatibility + privateKey: text("private_key"), // store encrypted blob if possible + publicKey: text("public_key"), ``` Also add a migration to backfill `privateKey` from `key` and then phase out `key`. <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion key: text("key"), // backward compatibility privateKey: text("private_key"), // store encrypted blob if possible publicKey: text("public_key"), keyPassword: text("key_password"), keyType: text("key_type"), detectedKeyType: text("detected_key_type"), usageCount: integer("usage_count").notNull().default(0), ``` </details> <!-- suggestion_end --> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
createdAt: text("created_at")

View File

@@ -5,6 +5,56 @@ import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { authLogger } from "../../utils/logger.js";
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js";
import crypto from "crypto";
import ssh2Pkg from "ssh2";
const { utils: ssh2Utils, Client } = ssh2Pkg;
// Direct SSH key generation with ssh2 - the right way
function generateSSHKeyPair(keyType: string, keySize?: number, passphrase?: string): { success: boolean; privateKey?: string; publicKey?: string; error?: string } {
console.log('Generating SSH key pair with ssh2:', keyType);
try {
// Convert our keyType to ssh2 format
let ssh2Type = keyType;
const options: any = {};
if (keyType === 'ssh-rsa') {
ssh2Type = 'rsa';
options.bits = keySize || 2048;
} else if (keyType === 'ssh-ed25519') {
ssh2Type = 'ed25519';
} else if (keyType === 'ecdsa-sha2-nistp256') {
ssh2Type = 'ecdsa';
options.bits = 256; // ECDSA P-256 uses 256 bits
}
// Add passphrase protection if provided
if (passphrase && passphrase.trim()) {
options.passphrase = passphrase;
options.cipher = 'aes128-cbc'; // Default cipher for encrypted private keys
}
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

⚠️ Potential issue

Weak/default crypto options in key generation.

  • For passphrase-protected keys, don’t force aes128-cbc. Prefer ssh2 defaults (bcrypt KDF + aes256-ctr) or explicitly set stronger cipher.
- if (passphrase && passphrase.trim()) {
-   options.passphrase = passphrase;
-   options.cipher = 'aes128-cbc';
- }
+ if (passphrase && passphrase.trim()) {
+   options.passphrase = passphrase;
+   // rely on ssh2's modern defaults (OpenSSH new-format with bcrypt KDF)
+ }

Also consider exposing ECDSA curves via options.curve instead of bits for clarity.

Also applies to: 38-50

_🛠️ Refactor suggestion_ _⚠️ Potential issue_ **Weak/default crypto options in key generation.** - For passphrase-protected keys, don’t force aes128-cbc. Prefer ssh2 defaults (bcrypt KDF + aes256-ctr) or explicitly set stronger cipher. ```diff - if (passphrase && passphrase.trim()) { - options.passphrase = passphrase; - options.cipher = 'aes128-cbc'; - } + if (passphrase && passphrase.trim()) { + options.passphrase = passphrase; + // rely on ssh2's modern defaults (OpenSSH new-format with bcrypt KDF) + } ``` Also consider exposing ECDSA curves via options.curve instead of bits for clarity. Also applies to: 38-50 <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
// Use ssh2's native key generation
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
console.log('SSH key pair generated successfully!');
console.log('Private key length:', keyPair.private.length);
console.log('Public key preview:', keyPair.public.substring(0, 50) + '...');
return {
success: true,
privateKey: keyPair.private,
publicKey: keyPair.public
};
} catch (error) {
console.error('SSH key generation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'SSH key generation failed'
};
}
}
const router = express.Router();
@@ -109,6 +159,22 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const plainKeyPassword =
authType === "key" && keyPassword ? keyPassword : null;
let keyInfo = null;
if (authType === "key" && plainKey) {
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed", {
operation: "credential_create",
userId,
name,
error: keyInfo.error,
});
return res.status(400).json({
error: `Invalid SSH key: ${keyInfo.error}`
});
}
}
const credentialData = {
userId,
name: name.trim(),
@@ -118,9 +184,12 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
authType,
username: username.trim(),
password: plainPassword,
key: plainKey,
key: plainKey, // backward compatibility
privateKey: keyInfo?.privateKey || plainKey,
publicKey: keyInfo?.publicKey || null,
keyPassword: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
usageCount: 0,
lastUsed: null,
};
@@ -248,7 +317,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
(output as any).password = credential.password;
}
if (credential.key) {
(output as any).key = credential.key;
(output as any).key = credential.key; // backward compatibility
}
if (credential.privateKey) {
(output as any).privateKey = credential.privateKey;
}
if (credential.publicKey) {
(output as any).publicKey = credential.publicKey;
}
if (credential.keyPassword) {
(output as any).keyPassword = credential.keyPassword;
@@ -314,7 +389,26 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
updateFields.password = updateData.password || null;
}
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
updateFields.key = updateData.key || null; // backward compatibility
// Parse SSH key if provided
if (updateData.key && existing[0].authType === "key") {
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed during update", {
operation: "credential_update",
userId,
credentialId: parseInt(id),
error: keyInfo.error,
});
return res.status(400).json({
error: `Invalid SSH key: ${keyInfo.error}`
});
}
updateFields.privateKey = keyInfo.privateKey;
updateFields.publicKey = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
}
}
if (updateData.keyPassword !== undefined) {
updateFields.keyPassword = updateData.keyPassword || null;
@@ -584,7 +678,9 @@ function formatCredentialOutput(credential: any): any {
: [],
authType: credential.authType,
username: credential.username,
publicKey: credential.publicKey,
keyType: credential.keyType,
detectedKeyType: credential.detectedKeyType,
usageCount: credential.usageCount || 0,
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
@@ -661,4 +757,642 @@ router.put(
},
);
// Detect SSH key type endpoint
// POST /credentials/detect-key-type
router.post("/detect-key-type", authenticateJWT, async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
console.log("=== Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log("Calling parseSSHKey...");
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey result:", keyInfo);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
hasPublicKey: !!keyInfo.publicKey,
error: keyInfo.error || null
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-key-type endpoint:", error);
authLogger.error("Failed to detect key type", error);
res.status(500).json({
error: error instanceof Error ? error.message : "Failed to detect key type"
});
}
});
// Detect SSH public key type endpoint
// POST /credentials/detect-public-key-type
router.post("/detect-public-key-type", authenticateJWT, async (req: Request, res: Response) => {
const { publicKey } = req.body;
console.log("=== Public Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Public key provided:", !!publicKey);
console.log("Public key type:", typeof publicKey);
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling parsePublicKey...");
const keyInfo = parsePublicKey(publicKey);
console.log("parsePublicKey result:", keyInfo);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
error: keyInfo.error || null
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-public-key-type endpoint:", error);
authLogger.error("Failed to detect public key type", error);
res.status(500).json({
error: error instanceof Error ? error.message : "Failed to detect public key type"
});
}
});
// Validate SSH key pair endpoint
// POST /credentials/validate-key-pair
router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
console.log("=== Key Pair Validation API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Public key provided:", !!publicKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling validateKeyPair...");
const validationResult = validateKeyPair(privateKey, publicKey, keyPassword);
console.log("validateKeyPair result:", validationResult);
const response = {
isValid: validationResult.isValid,
privateKeyType: validationResult.privateKeyType,
publicKeyType: validationResult.publicKeyType,
generatedPublicKey: validationResult.generatedPublicKey,
error: validationResult.error || null
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in validate-key-pair endpoint:", error);
authLogger.error("Failed to validate key pair", error);
res.status(500).json({
error: error instanceof Error ? error.message : "Failed to validate key pair"
});
}
});
// Generate new SSH key pair endpoint
// POST /credentials/generate-key-pair
router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Response) => {
const { keyType = 'ssh-ed25519', keySize = 2048, passphrase } = req.body;
console.log("=== Generate Key Pair API Called ===");
console.log("Key type:", keyType);
console.log("Key size:", keySize);
console.log("Has passphrase:", !!passphrase);
try {
// Generate SSH keys directly with ssh2
const result = generateSSHKeyPair(keyType, keySize, passphrase);
if (result.success && result.privateKey && result.publicKey) {
const response = {
success: true,
privateKey: result.privateKey,
publicKey: result.publicKey,
keyType: keyType,
format: 'ssh',
algorithm: keyType,
keySize: keyType === 'ssh-rsa' ? keySize : undefined,
curve: keyType === 'ecdsa-sha2-nistp256' ? 'nistp256' : undefined
};
console.log("SSH key pair generated successfully:", keyType);
res.json(response);
} else {
console.error("SSH key generation failed:", result.error);
res.status(500).json({
success: false,
error: result.error || "Failed to generate SSH key pair"
});
}
} catch (error) {
console.error("Exception in generate-key-pair endpoint:", error);
authLogger.error("Failed to generate key pair", error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Failed to generate key pair"
});
}
});
// Generate public key from private key endpoint
// POST /credentials/generate-public-key
router.post("/generate-public-key", authenticateJWT, async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
console.log("=== Generate Public Key API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log("Using Node.js crypto to generate public key from private key...");
console.log("Private key length:", privateKey.length);
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

⚠️ Potential issue

Secret leakage: verbose logs print key material and lengths. Remove before release.

Logging “first 100 chars” and similar leaks private/public keys. Use structured logs without secrets.

Apply redaction:

- console.log("Private key first 100 chars:", privateKey.substring(0, 100));
+ // Redacted debug removed

- console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100));
+ // Redacted debug removed

Repeat across all key-handling endpoints. Gate any remaining diagnostics behind a DEBUG flag and never log key contents.

Also applies to: 799-835, 837-879, 891-899, 941-969, 1039-1046, 1055-1066, 1084-1097, 1106-1110

_🛠️ Refactor suggestion_ _⚠️ Potential issue_ **Secret leakage: verbose logs print key material and lengths. Remove before release.** Logging “first 100 chars” and similar leaks private/public keys. Use structured logs without secrets. Apply redaction: ```diff - console.log("Private key first 100 chars:", privateKey.substring(0, 100)); + // Redacted debug removed - console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100)); + // Redacted debug removed ``` Repeat across all key-handling endpoints. Gate any remaining diagnostics behind a DEBUG flag and never log key contents. Also applies to: 799-835, 837-879, 891-899, 941-969, 1039-1046, 1055-1066, 1084-1097, 1106-1110 <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
// First try to create private key object from the input
let privateKeyObj;
let parseAttempts = [];
// Attempt 1: Direct parsing with passphrase
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
passphrase: keyPassword
});
console.log("Successfully parsed with passphrase method");
} catch (error) {
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
}
// Attempt 2: Direct parsing without passphrase
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey(privateKey);
console.log("Successfully parsed without passphrase");
} catch (error) {
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
}
}
// Attempt 3: Try with explicit format specification
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: 'pem',
type: 'pkcs8'
});
console.log("Successfully parsed as PKCS#8");
} catch (error) {
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
}
}
// Attempt 4: Try as PKCS#1 RSA
if (!privateKeyObj && privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: 'pem',
type: 'pkcs1'
});
console.log("Successfully parsed as PKCS#1 RSA");
} catch (error) {
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
}
}
// Attempt 5: Try as SEC1 EC
if (!privateKeyObj && privateKey.includes('-----BEGIN EC PRIVATE KEY-----')) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: 'pem',
type: 'sec1'
});
console.log("Successfully parsed as SEC1 EC");
} catch (error) {
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
}
}
// Final attempt: Try using ssh2 as fallback
if (!privateKeyObj) {
console.log("Attempting fallback to parseSSHKey function...");
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey fallback result:", keyInfo);
if (keyInfo.success && keyInfo.publicKey) {
// Ensure SSH2 fallback also returns proper string
const publicKeyString = String(keyInfo.publicKey);
console.log("SSH2 fallback public key type:", typeof publicKeyString);
console.log("SSH2 fallback public key length:", publicKeyString.length);
return res.json({
success: true,
publicKey: publicKeyString,
keyType: keyInfo.keyType
});
} else {
parseAttempts.push(`SSH2 fallback: ${keyInfo.error || 'No public key generated'}`);
}
} catch (error) {
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
}
}
if (!privateKeyObj) {
console.error("All parsing attempts failed:", parseAttempts);
return res.status(400).json({
success: false,
error: "Unable to parse private key. Tried multiple formats.",
details: parseAttempts
});
}
// Generate public key from private key
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
const publicKeyPem = publicKeyObj.export({
type: 'spki',
format: 'pem'
});
// Debug: Check what we're actually generating
console.log("Generated public key type:", typeof publicKeyPem);
console.log("Generated public key is Buffer:", Buffer.isBuffer(publicKeyPem));
// Ensure publicKeyPem is a string
const publicKeyString = typeof publicKeyPem === 'string' ? publicKeyPem : publicKeyPem.toString('utf8');
console.log("Public key string length:", publicKeyString.length);
console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100));
console.log("Public key is string:", typeof publicKeyString === 'string');
console.log("Public key contains PEM header:", publicKeyString.includes('-----BEGIN PUBLIC KEY-----'));
// Detect key type from the private key object
let keyType = 'unknown';
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
if (asymmetricKeyType === 'rsa') {
keyType = 'ssh-rsa';
} else if (asymmetricKeyType === 'ed25519') {
keyType = 'ssh-ed25519';
} else if (asymmetricKeyType === 'ec') {
// For EC keys, we need to check the curve
keyType = 'ecdsa-sha2-nistp256'; // Default assumption for P-256
}
// Use ssh2 to generate SSH format public key
let finalPublicKey = publicKeyString; // PEM fallback
let formatType = 'pem';
try {
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
if (!(ssh2PrivateKey instanceof Error)) {
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
const base64Data = publicKeyBuffer.toString('base64');
finalPublicKey = `${keyType} ${base64Data}`;
formatType = 'ssh';
console.log("SSH format public key generated!");
} else {
console.warn("ssh2 parsing failed, using PEM format");
}
} catch (sshError) {
console.warn("ssh2 failed, using PEM format");
}
const response = {
success: true,
publicKey: finalPublicKey,
keyType: keyType,
format: formatType
};
console.log("Final response publicKey type:", typeof response.publicKey);
console.log("Final response publicKey format:", response.format);
console.log("Final response publicKey length:", response.publicKey.length);
console.log("Public key generated successfully using crypto module:", keyType);
res.json(response);
} catch (error) {
console.error("Exception in generate-public-key endpoint:", error);
authLogger.error("Failed to generate public key", error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Failed to generate public key"
});
}
});
// SSH Key Deployment Function
async function deploySSHKeyToHost(
hostConfig: any,
publicKey: string,
credentialData: any
): Promise<{ success: boolean; message?: string; error?: string }> {
return new Promise((resolve) => {
const conn = new Client();
let connectionTimeout: NodeJS.Timeout;
// Connection timeout
connectionTimeout = setTimeout(() => {
conn.destroy();
resolve({ success: false, error: "Connection timeout" });
}, 30000);
conn.on('ready', async () => {
clearTimeout(connectionTimeout);
try {
// Step 1: Create ~/.ssh directory if it doesn't exist
await new Promise<void>((resolveCmd, rejectCmd) => {
conn.exec('mkdir -p ~/.ssh && chmod 700 ~/.ssh', (err, stream) => {
if (err) return rejectCmd(err);
stream.on('close', (code) => {
if (code === 0) {
resolveCmd();
} else {
rejectCmd(new Error(`mkdir command failed with code ${code}`));
}
});
});
});
// Step 2: Check if public key already exists
const keyExists = await new Promise<boolean>((resolveCheck, rejectCheck) => {
const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, (err, stream) => {
if (err) return rejectCheck(err);
stream.on('close', (code) => {
resolveCheck(code === 0); // code 0 means key found
});
});
});
if (keyExists) {
conn.end();
resolve({ success: true, message: "SSH key already deployed" });
return;
}
// Step 3: Add public key to authorized_keys
await new Promise<void>((resolveAdd, rejectAdd) => {
const escapedKey = publicKey.replace(/'/g, "'\\''");
conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => {
if (err) return rejectAdd(err);
stream.on('close', (code) => {
if (code === 0) {
resolveAdd();
} else {
rejectAdd(new Error(`Key deployment failed with code ${code}`));
}
});
});
});
// Step 4: Verify deployment
const verifySuccess = await new Promise<boolean>((resolveVerify, rejectVerify) => {
const keyPattern = publicKey.split(' ')[1];
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys`, (err, stream) => {
if (err) return rejectVerify(err);
stream.on('close', (code) => {
resolveVerify(code === 0);
});
});
});
conn.end();
if (verifySuccess) {
resolve({ success: true, message: "SSH key deployed successfully" });
} else {
resolve({ success: false, error: "Key deployment verification failed" });
}
} catch (error) {
conn.end();
resolve({
success: false,
error: error instanceof Error ? error.message : "Deployment failed"
});
}
});
conn.on('error', (err) => {
clearTimeout(connectionTimeout);
resolve({ success: false, error: err.message });
});
// Connect to the target host
try {
const connectionConfig: any = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
};
if (hostConfig.authType === 'password' && hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === 'key' && hostConfig.privateKey) {
connectionConfig.privateKey = hostConfig.privateKey;
if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword;
}
} else {
resolve({ success: false, error: "Invalid authentication configuration" });
return;
}
conn.connect(connectionConfig);
} catch (error) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: error instanceof Error ? error.message : "Connection failed"
});
}
});
}
// Deploy SSH Key to Host endpoint
// POST /credentials/:id/deploy-to-host
router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Response) => {
const credentialId = parseInt(req.params.id);
const { targetHostId } = req.body;
if (!credentialId || !targetHostId) {
return res.status(400).json({
success: false,
error: "Credential ID and target host ID are required"
});
}
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

💡 Verification agent

🧩 Analysis chain

Address Gitleaks finding (private-key).

The flagged lines are due to debug logs printing key material. Removing/redacting them resolves the alert.

Run to confirm no key contents are logged:


🏁 Script executed:

#!/bin/bash
rg -nP -C0 "(first 100 chars|Private key length|Public key.*first 100|console\.log\(.+private key|console\.log\(.+public key)" src/backend | sed -n '1,200p'

Length of output: 2283


Remove/redact debug logs that print SSH private/public key material

Gitleaks flagged console logs that expose key contents. Remove or redact any logs that print keys, substrings, or raw key content; log only presence/status or a hashed/truncated indicator.

  • src/backend/database/routes/credentials.ts:42 — console.log('Private key length:', keyPair.private.length)
  • src/backend/database/routes/credentials.ts:942–944 — console.log("Private key length:", privateKey.length); console.log("Private key first 100 chars:", privateKey.substring(0, 100))
  • src/backend/database/routes/credentials.ts:1023–1024, 1056–1063 — SSH2 fallback/public key type/length and "Generated public key first 100 chars" logs
  • src/backend/utils/ssh-key-utils.ts:270, 292, 432, 476–477 — logs printing generated/provided key lengths/content

Replace with non-sensitive logs (e.g., privateKeyProvided: true, generatedPublicKey: true) or redact values (e.g., "[REDACTED]" or a short hash). Re-run scans after changes.

🧰 Tools
🪛 Gitleaks (8.27.2)

[high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.

(private-key)

_💡 Verification agent_ <details> <summary>🧩 Analysis chain</summary> **Address Gitleaks finding (private-key).** The flagged lines are due to debug logs printing key material. Removing/redacting them resolves the alert. Run to confirm no key contents are logged: --- 🏁 Script executed: ```shell #!/bin/bash rg -nP -C0 "(first 100 chars|Private key length|Public key.*first 100|console\.log\(.+private key|console\.log\(.+public key)" src/backend | sed -n '1,200p' ``` Length of output: 2283 --- </details> **Remove/redact debug logs that print SSH private/public key material** Gitleaks flagged console logs that expose key contents. Remove or redact any logs that print keys, substrings, or raw key content; log only presence/status or a hashed/truncated indicator. - src/backend/database/routes/credentials.ts:42 — console.log('Private key length:', keyPair.private.length) - src/backend/database/routes/credentials.ts:942–944 — console.log("Private key length:", privateKey.length); console.log("Private key first 100 chars:", privateKey.substring(0, 100)) - src/backend/database/routes/credentials.ts:1023–1024, 1056–1063 — SSH2 fallback/public key type/length and "Generated public key first 100 chars" logs - src/backend/utils/ssh-key-utils.ts:270, 292, 432, 476–477 — logs printing generated/provided key lengths/content Replace with non-sensitive logs (e.g., privateKeyProvided: true, generatedPublicKey: true) or redact values (e.g., "[REDACTED]" or a short hash). Re-run scans after changes. <details> <summary>🧰 Tools</summary> <details> <summary>🪛 Gitleaks (8.27.2)</summary> [high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption. (private-key) </details> </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue

generate-public-key may return PEM; enforce SSH format or fail.

The UI and PR promise SSH-format output. If ssh2 conversion fails, returning PEM breaks deployment. Prefer returning 400 with guidance rather than a PEM fallback.

- let finalPublicKey = publicKeyString; // PEM fallback
- let formatType = 'pem';
+ let finalPublicKey = '';
+ let formatType = 'ssh';
...
- } catch (sshError) {
-   console.warn("ssh2 failed, using PEM format");
- }
+ } catch (sshError) {
+   return res.status(400).json({
+     success: false,
+     error: "Unable to derive SSH-format public key from the provided private key"
+   });
+ }

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Gitleaks (8.27.2)

[high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.

(private-key)

🤖 Prompt for AI Agents
In src/backend/database/routes/credentials.ts around lines 926 to 1120, the
endpoint currently falls back to returning a PEM public key if ssh2 conversion
fails; change this so the endpoint only returns an SSH-formatted public key and
returns a 400 error (with helpful guidance and parseAttempts details) when ssh2
conversion fails instead of returning PEM. Concretely: remove/stop using the PEM
fallback as the finalPublicKey; after attempting ssh2Utils.parseKey, if it fails
or returns an Error respond with res.status(400).json({ success:false, error:
"Unable to produce SSH-format public key; provide an OpenSSH private key or
correct passphrase", details: parseAttempts }) (include any parseAttempts
collected), and only return success when ssh2 produced the SSH public key; keep
existing logging but adjust messages to reflect the hard failure path.
_⚠️ Potential issue_ **generate-public-key may return PEM; enforce SSH format or fail.** The UI and PR promise SSH-format output. If ssh2 conversion fails, returning PEM breaks deployment. Prefer returning 400 with guidance rather than a PEM fallback. ```diff - let finalPublicKey = publicKeyString; // PEM fallback - let formatType = 'pem'; + let finalPublicKey = ''; + let formatType = 'ssh'; ... - } catch (sshError) { - console.warn("ssh2 failed, using PEM format"); - } + } catch (sshError) { + return res.status(400).json({ + success: false, + error: "Unable to derive SSH-format public key from the provided private key" + }); + } ``` > Committable suggestion skipped: line range outside the PR's diff. <details> <summary>🧰 Tools</summary> <details> <summary>🪛 Gitleaks (8.27.2)</summary> [high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption. (private-key) </details> </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/database/routes/credentials.ts around lines 926 to 1120, the endpoint currently falls back to returning a PEM public key if ssh2 conversion fails; change this so the endpoint only returns an SSH-formatted public key and returns a 400 error (with helpful guidance and parseAttempts details) when ssh2 conversion fails instead of returning PEM. Concretely: remove/stop using the PEM fallback as the finalPublicKey; after attempting ssh2Utils.parseKey, if it fails or returns an Error respond with res.status(400).json({ success:false, error: "Unable to produce SSH-format public key; provide an OpenSSH private key or correct passphrase", details: parseAttempts }) (include any parseAttempts collected), and only return success when ssh2 produced the SSH public key; keep existing logging but adjust messages to reflect the hard failure path. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
try {
// Get credential details
const credential = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1);
if (!credential || credential.length === 0) {
return res.status(404).json({
success: false,
error: "Credential not found"
});
}
const credData = credential[0];
// Only support key-based credentials for deployment
if (credData.authType !== 'key') {
return res.status(400).json({
success: false,
error: "Only SSH key-based credentials can be deployed"
});
}
if (!credData.publicKey) {
return res.status(400).json({
success: false,
error: "Public key is required for deployment"
});
}
// Get target host details
const targetHost = await db
.select()
.from(sshData)
.where(eq(sshData.id, targetHostId))
.limit(1);
if (!targetHost || targetHost.length === 0) {
return res.status(404).json({
success: false,
error: "Target host not found"
});
}
const hostData = targetHost[0];
// Prepare host configuration for connection
let hostConfig = {
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
authType: hostData.authType,
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword
};
// If host uses credential authentication, resolve the credential
if (hostData.authType === 'credential' && hostData.credentialId) {
const hostCredential = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, hostData.credentialId))
.limit(1);
if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0];
// Update hostConfig with credential data
hostConfig.authType = cred.authType;
hostConfig.username = cred.username; // Use credential's username
if (cred.authType === 'password') {
hostConfig.password = cred.password;
} else if (cred.authType === 'key') {
hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields
hostConfig.keyPassword = cred.keyPassword;
}
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

⚠️ Potential issue

Command injection risk and fragile grep in deployment.

  • echo with single-quote escaping is insufficient if publicKey contains newlines; prefer SFTP or at minimum printf + grep -F.
  • Use fixed-string grep (-Fq) on key data, not regex.

Apply:

- const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm
- conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ...
+ const keyPattern = publicKey.split(' ')[1] || '';
+ conn.exec(`grep -Fq "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ...

- const escapedKey = publicKey.replace(/'/g, "'\\''");
- conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ...
+ const sanitized = publicKey.replace(/\r?\n/g, '').trim();
+ const escaped = sanitized.replace(/'/g, "'\\''");
+ conn.exec(`printf '%s\\n' '${escaped}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ...

Recommended: replace shell appends with SFTP append to ~/.ssh/authorized_keys to eliminate shell injection entirely.

Also applies to: 1191-1200, 1176-1189

🤖 Prompt for AI Agents
In src/backend/database/routes/credentials.ts around lines 1123-1201 (and
similarly at 1176-1189 and 1191-1200), the current deploySSHKeyToHost uses shell
echo with ad-hoc single-quote escaping and plain grep which is vulnerable to
command-injection and breaks on keys containing newlines; replace the
shell-based append and grep with SFTP-based file operations and fixed-string
grep: open ~/.ssh/authorized_keys via the SSH client's sftp() API (create ~/.ssh
with proper mode first), read and check for the key using a fixed-string search
(or use grep -Fq if you must exec), and if missing append the publicKey via an
SFTP write/append stream and set file mode to 600—this eliminates shell
interpolation and handles newlines safely while ensuring grep uses -Fq for
fixed-string matching.
_🛠️ Refactor suggestion_ _⚠️ Potential issue_ **Command injection risk and fragile grep in deployment.** - echo with single-quote escaping is insufficient if publicKey contains newlines; prefer SFTP or at minimum printf + grep -F. - Use fixed-string grep (-Fq) on key data, not regex. Apply: ```diff - const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm - conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ... + const keyPattern = publicKey.split(' ')[1] || ''; + conn.exec(`grep -Fq "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ... - const escapedKey = publicKey.replace(/'/g, "'\\''"); - conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ... + const sanitized = publicKey.replace(/\r?\n/g, '').trim(); + const escaped = sanitized.replace(/'/g, "'\\''"); + conn.exec(`printf '%s\\n' '${escaped}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ... ``` Recommended: replace shell appends with SFTP append to ~/.ssh/authorized_keys to eliminate shell injection entirely. Also applies to: 1191-1200, 1176-1189 <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/database/routes/credentials.ts around lines 1123-1201 (and similarly at 1176-1189 and 1191-1200), the current deploySSHKeyToHost uses shell echo with ad-hoc single-quote escaping and plain grep which is vulnerable to command-injection and breaks on keys containing newlines; replace the shell-based append and grep with SFTP-based file operations and fixed-string grep: open ~/.ssh/authorized_keys via the SSH client's sftp() API (create ~/.ssh with proper mode first), read and check for the key using a fixed-string search (or use grep -Fq if you must exec), and if missing append the publicKey via an SFTP write/append stream and set file mode to 600—this eliminates shell interpolation and handles newlines safely while ensuring grep uses -Fq for fixed-string matching. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
} else {
return res.status(400).json({
success: false,
error: "Host credential not found"
});
}
}
// Deploy the SSH key
const deployResult = await deploySSHKeyToHost(
hostConfig,
credData.publicKey,
credData
);
if (deployResult.success) {
// Log successful deployment
authLogger.info(`SSH key deployed successfully`, {
credentialId,
targetHostId,
operation: "deploy_ssh_key"
});
res.json({
success: true,
message: deployResult.message || "SSH key deployed successfully"
});
} else {
authLogger.error(`SSH key deployment failed`, {
credentialId,
targetHostId,
error: deployResult.error,
operation: "deploy_ssh_key"
});
res.status(500).json({
success: false,
error: deployResult.error || "Deployment failed"
});
}
} catch (error) {
authLogger.error("Failed to deploy SSH key", error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Failed to deploy SSH key"
});
}
});
export default router;

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,527 @@
// Import SSH2 using ES modules
import ssh2Pkg from 'ssh2';
const ssh2Utils = ssh2Pkg.utils;
// Simple fallback SSH key type detection
function detectKeyTypeFromContent(keyContent: string): string {
const content = keyContent.trim();
// Check for OpenSSH format headers
if (content.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) {
// Look for key type indicators in the content
if (content.includes('ssh-ed25519') || content.includes('AAAAC3NzaC1lZDI1NTE5')) {
return 'ssh-ed25519';
}
if (content.includes('ssh-rsa') || content.includes('AAAAB3NzaC1yc2E')) {
return 'ssh-rsa';
}
if (content.includes('ecdsa-sha2-nistp256')) {
return 'ecdsa-sha2-nistp256';
}
if (content.includes('ecdsa-sha2-nistp384')) {
return 'ecdsa-sha2-nistp384';
}
if (content.includes('ecdsa-sha2-nistp521')) {
return 'ecdsa-sha2-nistp521';
}
// For OpenSSH format, try to detect by analyzing the base64 content structure
try {
const base64Content = content
.replace('-----BEGIN OPENSSH PRIVATE KEY-----', '')
.replace('-----END OPENSSH PRIVATE KEY-----', '')
.replace(/\s/g, '');
// OpenSSH format starts with "openssh-key-v1" followed by key type
const decoded = Buffer.from(base64Content, 'base64').toString('binary');
if (decoded.includes('ssh-rsa')) {
return 'ssh-rsa';
}
if (decoded.includes('ssh-ed25519')) {
return 'ssh-ed25519';
}
if (decoded.includes('ecdsa-sha2-nistp256')) {
return 'ecdsa-sha2-nistp256';
}
if (decoded.includes('ecdsa-sha2-nistp384')) {
return 'ecdsa-sha2-nistp384';
}
if (decoded.includes('ecdsa-sha2-nistp521')) {
return 'ecdsa-sha2-nistp521';
}
// Default to RSA for OpenSSH format if we can't detect specifically
return 'ssh-rsa';
} catch (error) {
console.warn('Failed to decode OpenSSH key content:', error);
// If decoding fails, default to RSA as it's most common for OpenSSH format
return 'ssh-rsa';
}
}
// Check for traditional PEM headers
if (content.includes('-----BEGIN RSA PRIVATE KEY-----')) {
return 'ssh-rsa';
}
if (content.includes('-----BEGIN DSA PRIVATE KEY-----')) {
return 'ssh-dss';
}
if (content.includes('-----BEGIN EC PRIVATE KEY-----')) {
return 'ecdsa-sha2-nistp256'; // Default ECDSA type
}
// Check for PKCS#8 format (modern format)
if (content.includes('-----BEGIN PRIVATE KEY-----')) {
// Try to decode and analyze the DER structure for better detection
try {
const base64Content = content
.replace('-----BEGIN PRIVATE KEY-----', '')
.replace('-----END PRIVATE KEY-----', '')
.replace(/\s/g, '');
const decoded = Buffer.from(base64Content, 'base64');
const decodedString = decoded.toString('binary');
// Check for algorithm identifiers in the DER structure
if (decodedString.includes('1.2.840.113549.1.1.1')) {
// RSA OID
return 'ssh-rsa';
} else if (decodedString.includes('1.2.840.10045.2.1')) {
// EC Private Key OID - this indicates ECDSA
if (decodedString.includes('1.2.840.10045.3.1.7')) {
// prime256v1 curve OID
return 'ecdsa-sha2-nistp256';
}
return 'ecdsa-sha2-nistp256'; // Default to P-256
} else if (decodedString.includes('1.3.101.112')) {
// Ed25519 OID
return 'ssh-ed25519';
}
} catch (error) {
// If decoding fails, fall back to length-based detection
console.warn('Failed to decode private key for type detection:', error);
}
// Fallback: Try to detect key type from the content structure
// This is a fallback for PKCS#8 format keys
if (content.length < 800) {
// Ed25519 keys are typically shorter
return 'ssh-ed25519';
} else if (content.length > 1600) {
// RSA keys are typically longer
return 'ssh-rsa';
} else {
// ECDSA keys are typically medium length
return 'ecdsa-sha2-nistp256';
}
}
return 'unknown';
}
// Detect public key type from public key content
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
const content = publicKeyContent.trim();
// SSH public keys start with the key type
if (content.startsWith('ssh-rsa ')) {
return 'ssh-rsa';
}
if (content.startsWith('ssh-ed25519 ')) {
return 'ssh-ed25519';
}
if (content.startsWith('ecdsa-sha2-nistp256 ')) {
return 'ecdsa-sha2-nistp256';
}
if (content.startsWith('ecdsa-sha2-nistp384 ')) {
return 'ecdsa-sha2-nistp384';
}
if (content.startsWith('ecdsa-sha2-nistp521 ')) {
return 'ecdsa-sha2-nistp521';
}
if (content.startsWith('ssh-dss ')) {
return 'ssh-dss';
}
// Check for PEM format public keys
if (content.includes('-----BEGIN PUBLIC KEY-----')) {
// Try to decode the base64 content to detect key type
try {
const base64Content = content
.replace('-----BEGIN PUBLIC KEY-----', '')
.replace('-----END PUBLIC KEY-----', '')
.replace(/\s/g, '');
const decoded = Buffer.from(base64Content, 'base64');
const decodedString = decoded.toString('binary');
// Check for algorithm identifiers in the DER structure
if (decodedString.includes('1.2.840.113549.1.1.1')) {
// RSA OID
return 'ssh-rsa';
} else if (decodedString.includes('1.2.840.10045.2.1')) {
// EC Public Key OID - this indicates ECDSA
if (decodedString.includes('1.2.840.10045.3.1.7')) {
// prime256v1 curve OID
return 'ecdsa-sha2-nistp256';
}
return 'ecdsa-sha2-nistp256'; // Default to P-256
} else if (decodedString.includes('1.3.101.112')) {
// Ed25519 OID
return 'ssh-ed25519';
}
} catch (error) {
// If decoding fails, fall back to length-based detection
console.warn('Failed to decode public key for type detection:', error);
}
// Fallback: Try to guess based on key length
if (content.length < 400) {
return 'ssh-ed25519';
} else if (content.length > 600) {
return 'ssh-rsa';
} else {
return 'ecdsa-sha2-nistp256';
}
}
if (content.includes('-----BEGIN RSA PUBLIC KEY-----')) {
return 'ssh-rsa';
}
// Check for base64 encoded key data patterns
if (content.includes('AAAAB3NzaC1yc2E')) {
return 'ssh-rsa';
}
if (content.includes('AAAAC3NzaC1lZDI1NTE5')) {
return 'ssh-ed25519';
}
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY')) {
return 'ecdsa-sha2-nistp256';
}
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ')) {
return 'ecdsa-sha2-nistp384';
}
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE')) {
return 'ecdsa-sha2-nistp521';
}
if (content.includes('AAAAB3NzaC1kc3M')) {
return 'ssh-dss';
}
return 'unknown';
}
export interface KeyInfo {
privateKey: string;
publicKey: string;
keyType: string;
success: boolean;
error?: string;
}
export interface PublicKeyInfo {
publicKey: string;
keyType: string;
success: boolean;
error?: string;
}
export interface KeyPairValidationResult {
isValid: boolean;
privateKeyType: string;
publicKeyType: string;
generatedPublicKey?: string;
error?: string;
}
/**
* Parse SSH private key and extract public key and type information
*/
export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInfo {
console.log('=== SSH Key Parsing Debug ===');
console.log('Key length:', privateKeyData?.length || 'undefined');
console.log('First 100 chars:', privateKeyData?.substring(0, 100) || 'undefined');
console.log('ssh2Utils available:', typeof ssh2Utils);
console.log('parseKey function available:', typeof ssh2Utils?.parseKey);
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue

Do not log key material (even partial).

Printing the first 100 chars of private/public keys (and derived key data) is a severe secret‑leak risk.

Apply this sanitization:

-  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);
+  // debug disabled; avoid logging sensitive key material

... (public key parser)
-  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');
+  // debug disabled; avoid logging sensitive key material

... (validation)
-  console.log('=== Key Pair Validation Debug ===');
-  console.log('Private key length:', privateKeyData?.length || 'undefined');
-  console.log('Public key length:', publicKeyData?.length || 'undefined');
+  // debug disabled; avoid logging sensitive key material

-        console.log('Generated key data:', generatedKeyData.substring(0, 50) + '...');
-        console.log('Provided key data:', providedKeyData.substring(0, 50) + '...');
+        // do not log key data

If you need diagnostics, gate redacted logs behind a dedicated DEBUG flag and never print raw key content.

Also applies to: 369-372, 431-434, 488-490

🤖 Prompt for AI Agents
In src/backend/utils/ssh-key-utils.ts around lines 243-248 (and also address
similar logging at 369-372, 431-434, 488-490): the current debug prints reveal
private/public key material (first 100 chars) which must be removed; replace
those console.log statements so they never output raw key bytes—either remove
them or gate them behind a dedicated DEBUG flag and only emit non-secret
diagnostics such as key length, presence booleans, or a redacted/hashing
indicator (e.g., show length or a fixed string like "<REDACTED>" or a truncated
hash), and ensure any gated logging explicitly excludes any substring of the key
itself and that the DEBUG flag is checked before evaluating any key-derived
values.
_⚠️ Potential issue_ **Do not log key material (even partial).** Printing the first 100 chars of private/public keys (and derived key data) is a severe secret‑leak risk. Apply this sanitization: ```diff - 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); + // debug disabled; avoid logging sensitive key material ... (public key parser) - 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'); + // debug disabled; avoid logging sensitive key material ... (validation) - console.log('=== Key Pair Validation Debug ==='); - console.log('Private key length:', privateKeyData?.length || 'undefined'); - console.log('Public key length:', publicKeyData?.length || 'undefined'); + // debug disabled; avoid logging sensitive key material - console.log('Generated key data:', generatedKeyData.substring(0, 50) + '...'); - console.log('Provided key data:', providedKeyData.substring(0, 50) + '...'); + // do not log key data ``` If you need diagnostics, gate redacted logs behind a dedicated DEBUG flag and never print raw key content. Also applies to: 369-372, 431-434, 488-490 <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/utils/ssh-key-utils.ts around lines 243-248 (and also address similar logging at 369-372, 431-434, 488-490): the current debug prints reveal private/public key material (first 100 chars) which must be removed; replace those console.log statements so they never output raw key bytes—either remove them or gate them behind a dedicated DEBUG flag and only emit non-secret diagnostics such as key length, presence booleans, or a redacted/hashing indicator (e.g., show length or a fixed string like "<REDACTED>" or a truncated hash), and ensure any gated logging explicitly excludes any substring of the key itself and that the DEBUG flag is checked before evaluating any key-derived values. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
try {
let keyType = 'unknown';
let publicKey = '';
let useSSH2 = false;
// Try SSH2 first if available
if (ssh2Utils && typeof ssh2Utils.parseKey === 'function') {
try {
console.log('Calling ssh2Utils.parseKey...');
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
console.log('parseKey returned:', typeof parsedKey, parsedKey instanceof Error ? parsedKey.message : 'success');
if (!(parsedKey instanceof Error)) {
// Extract key type
if (parsedKey.type) {
keyType = parsedKey.type;
}
console.log('Extracted key type:', keyType);
// Generate public key in SSH format
try {
console.log('Attempting to generate public key...');
const publicKeyBuffer = parsedKey.getPublicSSH();
console.log('Public key buffer type:', typeof publicKeyBuffer);
console.log('Public key buffer is Buffer:', Buffer.isBuffer(publicKeyBuffer));
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
// We need to convert this to proper SSH public key format
if (Buffer.isBuffer(publicKeyBuffer)) {
// Convert binary SSH data to base64 and create proper SSH key format
const base64Data = publicKeyBuffer.toString('base64');
// Create proper SSH public key format: "keytype base64data"
if (keyType === 'ssh-rsa') {
publicKey = `ssh-rsa ${base64Data}`;
} else if (keyType === 'ssh-ed25519') {
publicKey = `ssh-ed25519 ${base64Data}`;
} else if (keyType.startsWith('ecdsa-')) {
publicKey = `${keyType} ${base64Data}`;
} else {
publicKey = `${keyType} ${base64Data}`;
}
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue

Bug: Misencoding public key — getPublicSSH() already returns OpenSSH text.

You’re base64‑encoding the OpenSSH public key again, producing invalid keys.

Apply this fix:

-              const base64Data = publicKeyBuffer.toString('base64');
-
-              // Create proper SSH public key format: "keytype base64data"
-              if (keyType === 'ssh-rsa') {
-                publicKey = `ssh-rsa ${base64Data}`;
-              } else if (keyType === 'ssh-ed25519') {
-                publicKey = `ssh-ed25519 ${base64Data}`;
-              } else if (keyType.startsWith('ecdsa-')) {
-                publicKey = `${keyType} ${base64Data}`;
-              } else {
-                publicKey = `${keyType} ${base64Data}`;
-              }
+              // ssh2 returns the OpenSSH public key as an ASCII buffer: "type base64data"
+              publicKey = publicKeyBuffer.toString('ascii').trim();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

              // ssh2 returns the OpenSSH public key as an ASCII buffer: "type base64data"
              publicKey = publicKeyBuffer.toString('ascii').trim();
🤖 Prompt for AI Agents
In src/backend/utils/ssh-key-utils.ts around lines 278-291, the code is
base64-encoding an OpenSSH public key buffer and re-prepending key type which
produces invalid keys; instead, use the buffer's UTF-8 text directly (e.g.
publicKey = publicKeyBuffer.toString('utf8').trim()) and remove the conditional
keyType-based re-encoding/concatenation so the already-formatted OpenSSH public
key returned by getPublicSSH() is used as-is.
_⚠️ Potential issue_ **Bug: Misencoding public key — `getPublicSSH()` already returns OpenSSH text.** You’re base64‑encoding the OpenSSH public key again, producing invalid keys. Apply this fix: ```diff - const base64Data = publicKeyBuffer.toString('base64'); - - // Create proper SSH public key format: "keytype base64data" - if (keyType === 'ssh-rsa') { - publicKey = `ssh-rsa ${base64Data}`; - } else if (keyType === 'ssh-ed25519') { - publicKey = `ssh-ed25519 ${base64Data}`; - } else if (keyType.startsWith('ecdsa-')) { - publicKey = `${keyType} ${base64Data}`; - } else { - publicKey = `${keyType} ${base64Data}`; - } + // ssh2 returns the OpenSSH public key as an ASCII buffer: "type base64data" + publicKey = publicKeyBuffer.toString('ascii').trim(); ``` <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion // ssh2 returns the OpenSSH public key as an ASCII buffer: "type base64data" publicKey = publicKeyBuffer.toString('ascii').trim(); ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/utils/ssh-key-utils.ts around lines 278-291, the code is base64-encoding an OpenSSH public key buffer and re-prepending key type which produces invalid keys; instead, use the buffer's UTF-8 text directly (e.g. publicKey = publicKeyBuffer.toString('utf8').trim()) and remove the conditional keyType-based re-encoding/concatenation so the already-formatted OpenSSH public key returned by getPublicSSH() is used as-is. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
console.log('Generated SSH public key format, length:', publicKey.length);
console.log('Public key starts with:', publicKey.substring(0, 50));
} else {
console.warn('Unexpected public key buffer type');
publicKey = '';
}
} catch (error) {
console.warn('Failed to generate public key:', error);
publicKey = '';
}
useSSH2 = true;
console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
} else {
console.warn('SSH2 parsing failed:', parsedKey.message);
}
} catch (error) {
console.warn('SSH2 parsing exception:', error instanceof Error ? error.message : error);
}
} else {
console.warn('SSH2 parseKey function not available');
}
// Fallback to content-based detection
if (!useSSH2) {
console.log('Using fallback key type detection...');
keyType = detectKeyTypeFromContent(privateKeyData);
console.log(`Fallback detected key type: ${keyType}`);
// For fallback, we can't generate public key but the detection is still useful
publicKey = '';
if (keyType !== 'unknown') {
console.log(`SSH key type detected successfully with fallback: ${keyType}`);
}
}
return {
privateKey: privateKeyData,
publicKey,
keyType,
success: keyType !== 'unknown'
};
} catch (error) {
console.error('Exception during SSH key parsing:', error);
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue

Don’t mark fallback heuristics as success.

If ssh2 parsing fails, heuristics should not set success: true. Return success: false unless we positively parsed and/or generated a public key.

-      publicKey = '';
-      if (keyType !== 'unknown') {
-        console.log(`SSH key type detected successfully with fallback: ${keyType}`);
-      }
+      publicKey = '';
-      success: keyType !== 'unknown'
+      success: useSSH2
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

      publicKey = '';
    }

    return {
      privateKey: privateKeyData,
      publicKey,
      keyType,
      success: useSSH2
    };
  } catch (error) {
    console.error('Exception during SSH key parsing:', error);
🤖 Prompt for AI Agents
In src/backend/utils/ssh-key-utils.ts around lines 324–336, the current fallback
heuristics can set success: true when ssh2 parsing failed; change this so
success is only true when we positively parsed (or generated) a public key.
Implement a boolean (e.g., parsedWithParser) that is set only when the primary
ssh2 parser succeeds, and use success: parsedWithParser && Boolean(publicKey)
(or, if you generate a public key from a private key, set parsedWithParser when
generation succeeds). Ensure fallback detection still sets keyType and logs but
does not flip success to true.
_⚠️ Potential issue_ **Don’t mark fallback heuristics as success.** If ssh2 parsing fails, heuristics should not set `success: true`. Return `success: false` unless we positively parsed and/or generated a public key. ```diff - publicKey = ''; - if (keyType !== 'unknown') { - console.log(`SSH key type detected successfully with fallback: ${keyType}`); - } + publicKey = ''; ``` ```diff - success: keyType !== 'unknown' + success: useSSH2 ``` <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion publicKey = ''; } return { privateKey: privateKeyData, publicKey, keyType, success: useSSH2 }; } catch (error) { console.error('Exception during SSH key parsing:', error); ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/utils/ssh-key-utils.ts around lines 324–336, the current fallback heuristics can set success: true when ssh2 parsing failed; change this so success is only true when we positively parsed (or generated) a public key. Implement a boolean (e.g., parsedWithParser) that is set only when the primary ssh2 parser succeeds, and use success: parsedWithParser && Boolean(publicKey) (or, if you generate a public key from a private key, set parsedWithParser when generation succeeds). Ensure fallback detection still sets keyType and logs but does not flip success to true. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
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'
};
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue

Validation must be definitive — don’t return true without a match.

Currently returns isValid: true when types match but key matching is unverified. That can accept mismatched pairs.

-    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'
-    };
+    return {
+      isValid: false,
+      privateKeyType: privateKeyInfo.keyType,
+      publicKeyType: publicKeyInfo.keyType,
+      error: 'Unable to verify key pair match'
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    // If we can't generate public key or compare, just check if types match
    return {
      isValid: false,
      privateKeyType: privateKeyInfo.keyType,
      publicKeyType: publicKeyInfo.keyType,
      error: 'Unable to verify key pair match'
    };
🤖 Prompt for AI Agents
In src/backend/utils/ssh-key-utils.ts around lines 510 to 516, the function
currently returns isValid: true when public/private key matching couldn't be
verified but key types match; change this to return isValid: false in that
fallback case so the validation is definitive, update the error message to state
verification was not possible and that the pair should not be trusted (e.g.,
"Unable to verify key pair match — treat as invalid"), and ensure privateKeyType
and publicKeyType are still returned for debugging; do not assume validity when
matching could not be confirmed.
_⚠️ Potential issue_ **Validation must be definitive — don’t return true without a match.** Currently returns `isValid: true` when types match but key matching is unverified. That can accept mismatched pairs. ```diff - 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' - }; + return { + isValid: false, + privateKeyType: privateKeyInfo.keyType, + publicKeyType: publicKeyInfo.keyType, + error: 'Unable to verify key pair match' + }; ``` <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion // If we can't generate public key or compare, just check if types match return { isValid: false, privateKeyType: privateKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType, error: 'Unable to verify key pair match' }; ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/utils/ssh-key-utils.ts around lines 510 to 516, the function currently returns isValid: true when public/private key matching couldn't be verified but key types match; change this to return isValid: false in that fallback case so the validation is definitive, update the error message to state verification was not possible and that the pair should not be trusted (e.g., "Unable to verify key pair match — treat as invalid"), and ensure privateKeyType and publicKeyType are still returned for debugging; do not assume validity when matching could not be confirmed. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
} 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'
};
}
}

View File

@@ -130,7 +130,27 @@
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder",
"movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully",
"failedToMoveToFolder": "Failed to move credential to folder"
"failedToMoveToFolder": "Failed to move credential to folder",
"sshPublicKey": "SSH Public Key",
"publicKeyNote": "Public key is optional but recommended for key validation",
"publicKeyUploaded": "Public Key Uploaded",
"uploadPublicKey": "Upload Public Key",
"uploadPrivateKeyFile": "Upload Private Key File",
"uploadPublicKeyFile": "Upload Public Key File",
"privateKeyRequiredForGeneration": "Private key is required to generate public key",
"failedToGeneratePublicKey": "Failed to generate public key",
"generatePublicKey": "Generate from Private Key",
"publicKeyGeneratedSuccessfully": "Public key generated successfully",
"detectedKeyType": "Detected key type",
"detectingKeyType": "detecting...",
"optional": "Optional",
"generateKeyPair": "Generate New Key Pair",
"generateEd25519": "Generate Ed25519",
"generateECDSA": "Generate ECDSA",
"generateRSA": "Generate RSA",
"keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully",
"failedToGenerateKeyPair": "Failed to generate key pair",
"generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form."
},
"sshTools": {
"title": "SSH Tools",
@@ -878,6 +898,7 @@
"password": "password",
"keyPassword": "key password",
"pastePrivateKey": "Paste your private key here...",
"pastePublicKey": "Paste your public key here...",
"credentialName": "My SSH Server",
"description": "SSH credential description",
"searchCredentials": "Search credentials by name, username, or tags...",

View File

@@ -129,7 +129,27 @@
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
"failedToRenameFolder": "重命名文件夹失败",
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
"failedToMoveToFolder": "移动凭据到文件夹失败"
"failedToMoveToFolder": "移动凭据到文件夹失败",
"sshPublicKey": "SSH公钥",
"publicKeyNote": "公钥是可选的,但建议提供以验证密钥对",
"publicKeyUploaded": "公钥已上传",
"uploadPublicKey": "上传公钥",
"uploadPrivateKeyFile": "上传私钥文件",
"uploadPublicKeyFile": "上传公钥文件",
"privateKeyRequiredForGeneration": "生成公钥需要先输入私钥",
"failedToGeneratePublicKey": "生成公钥失败",
"generatePublicKey": "从私钥生成",
"publicKeyGeneratedSuccessfully": "公钥生成成功",
"detectedKeyType": "检测到的密钥类型",
"detectingKeyType": "检测中...",
"optional": "可选",
"generateKeyPair": "生成新的密钥对",
"generateEd25519": "生成 Ed25519",
"generateECDSA": "生成 ECDSA",
"generateRSA": "生成 RSA",
"keyPairGeneratedSuccessfully": "{{keyType}} 密钥对生成成功",
"failedToGenerateKeyPair": "生成密钥对失败",
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。"
},
"sshTools": {
"title": "SSH 工具",
@@ -874,6 +894,7 @@
"searchCredentials": "按名称、用户名或标签搜索凭据...",
"keyPassword": "密钥密码",
"pastePrivateKey": "在此粘贴您的私钥...",
"pastePublicKey": "在此粘贴您的公钥...",
"sshConfig": "端点 SSH 配置",
"homePath": "/home",
"clientId": "您的客户端 ID",

View File

@@ -70,6 +70,7 @@ export interface Credential {
username: string;
password?: string;
key?: string;
publicKey?: string;
keyPassword?: string;
keyType?: string;
usageCount: number;
@@ -87,6 +88,7 @@ export interface CredentialData {
username: string;
password?: string;
key?: string;
publicKey?: string;
keyPassword?: string;
keyType?: string;
}

View File

@@ -22,6 +22,10 @@ import {
updateCredential,
getCredentials,
getCredentialDetails,
detectKeyType,
detectPublicKeyType,
generatePublicKeyFromPrivate,
generateKeyPair,
} from "@/ui/main-axios";
import { useTranslation } from "react-i18next";
import type {
@@ -42,9 +46,14 @@ export function CredentialEditor({
useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<"password" | "key">("password");
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
"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);
useEffect(() => {
const fetchData = async () => {
@@ -101,6 +110,7 @@ export function CredentialEditor({
username: z.string().min(1),
password: z.string().optional(),
key: z.any().optional().nullable(),
publicKey: z.string().optional(),
keyPassword: z.string().optional(),
keyType: z
.enum([
@@ -149,6 +159,7 @@ export function CredentialEditor({
username: "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto",
},
@@ -169,6 +180,7 @@ export function CredentialEditor({
username: fullCredentialDetails.username || "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto" as const,
};
@@ -176,7 +188,8 @@ export function CredentialEditor({
if (defaultAuthType === "password") {
formData.password = fullCredentialDetails.password || "";
} else if (defaultAuthType === "key") {
formData.key = "existing_key";
formData.key = fullCredentialDetails.key || "";
formData.publicKey = fullCredentialDetails.publicKey || "";
formData.keyPassword = fullCredentialDetails.keyPassword || "";
formData.keyType =
(fullCredentialDetails.keyType as any) || ("auto" as const);
@@ -196,6 +209,7 @@ export function CredentialEditor({
username: "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto",
});
@@ -203,6 +217,104 @@ export function CredentialEditor({
}
}, [editingCredential?.id, fullCredentialDetails, form]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (keyDetectionTimeoutRef.current) {
clearTimeout(keyDetectionTimeoutRef.current);
}
if (publicKeyDetectionTimeoutRef.current) {
clearTimeout(publicKeyDetectionTimeoutRef.current);
}
};
}, []);
// Detect key type function
const handleKeyTypeDetection = async (keyValue: string, keyPassword?: string) => {
if (!keyValue || keyValue.trim() === '') {
setDetectedKeyType(null);
return;
}
setKeyDetectionLoading(true);
try {
const result = await detectKeyType(keyValue, keyPassword);
if (result.success) {
setDetectedKeyType(result.keyType);
} else {
setDetectedKeyType('invalid');
console.warn('Key detection failed:', result.error);
}
} catch (error) {
setDetectedKeyType('error');
console.error('Key type detection error:', error);
} finally {
setKeyDetectionLoading(false);
}
};
// Debounced key type detection
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
if (keyDetectionTimeoutRef.current) {
clearTimeout(keyDetectionTimeoutRef.current);
}
keyDetectionTimeoutRef.current = setTimeout(() => {
handleKeyTypeDetection(keyValue, keyPassword);
}, 1000);
};
// Detect public key type function
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
if (!publicKeyValue || publicKeyValue.trim() === '') {
setDetectedPublicKeyType(null);
return;
}
setPublicKeyDetectionLoading(true);
try {
const result = await detectPublicKeyType(publicKeyValue);
if (result.success) {
setDetectedPublicKeyType(result.keyType);
} else {
setDetectedPublicKeyType('invalid');
console.warn('Public key detection failed:', result.error);
}
} catch (error) {
setDetectedPublicKeyType('error');
console.error('Public key type detection error:', error);
} finally {
setPublicKeyDetectionLoading(false);
}
};
// Debounced public key type detection
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
if (publicKeyDetectionTimeoutRef.current) {
clearTimeout(publicKeyDetectionTimeoutRef.current);
}
publicKeyDetectionTimeoutRef.current = setTimeout(() => {
handlePublicKeyTypeDetection(publicKeyValue);
}, 1000);
};
const getFriendlyKeyTypeName = (keyType: string): string => {
const keyTypeMap: Record<string, string> = {
'ssh-rsa': 'RSA (SSH)',
'ssh-ed25519': 'Ed25519 (SSH)',
'ecdsa-sha2-nistp256': 'ECDSA P-256 (SSH)',
'ecdsa-sha2-nistp384': 'ECDSA P-384 (SSH)',
'ecdsa-sha2-nistp521': 'ECDSA P-521 (SSH)',
'ssh-dss': 'DSA (SSH)',
'rsa-sha2-256': 'RSA-SHA2-256',
'rsa-sha2-512': 'RSA-SHA2-512',
'invalid': 'Invalid Key',
'error': 'Detection Error',
'unknown': 'Unknown'
};
return keyTypeMap[keyType] || keyType;
};
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

Key type detection should react to keyPassword changes.

Trigger detection when keyPassword changes if a key is present.

useEffect(() => {
  const keyVal = form.watch("key");
  const pass = form.watch("keyPassword");
  if (typeof keyVal === "string" && keyVal.trim()) {
    debouncedKeyDetection(keyVal, pass);
  }
-}, []);
+}, [form.watch("keyPassword")]);

Note: capture values via form.getValues() to avoid extra renders.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx around lines 239 to 254,
key type detection only runs when the key value changes and does not react to
changes to keyPassword; update the component to also trigger detection when
keyPassword changes (but only if a key is present) by reading current values
with form.getValues() inside the effect/handler to avoid extra renders, call
detectKeyType with the latest key and password, and keep the existing
success/error handling and loading state management.
_🛠️ Refactor suggestion_ **Key type detection should react to keyPassword changes.** Trigger detection when keyPassword changes if a key is present. ```diff useEffect(() => { const keyVal = form.watch("key"); const pass = form.watch("keyPassword"); if (typeof keyVal === "string" && keyVal.trim()) { debouncedKeyDetection(keyVal, pass); } -}, []); +}, [form.watch("keyPassword")]); ``` Note: capture values via form.getValues() to avoid extra renders. > Committable suggestion skipped: line range outside the PR's diff. <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx around lines 239 to 254, key type detection only runs when the key value changes and does not react to changes to keyPassword; update the component to also trigger detection when keyPassword changes (but only if a key is present) by reading current values with form.getValues() inside the effect/handler to avoid extra renders, call detectKeyType with the latest key and password, and keep the existing success/error handling and loading state management. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
const onSubmit = async (data: FormData) => {
try {
if (!data.name || data.name.trim() === "") {
@@ -221,20 +333,15 @@ export function CredentialEditor({
submitData.password = null;
submitData.key = null;
submitData.publicKey = null;
submitData.keyPassword = null;
submitData.keyType = null;
if (data.authType === "password") {
submitData.password = data.password;
} else if (data.authType === "key") {
if (data.key instanceof File) {
const keyContent = await data.key.text();
submitData.key = keyContent;
} else if (data.key === "existing_key") {
delete submitData.key;
} else {
submitData.key = data.key;
}
submitData.key = data.key;
submitData.publicKey = data.publicKey;
submitData.keyPassword = data.keyPassword;
submitData.keyType = data.keyType;
}
@@ -259,11 +366,17 @@ export function CredentialEditor({
form.reset();
} catch (error) {
toast.error(t("credentials.failedToSaveCredential"));
console.error("Credential save error:", error);
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error(t("credentials.failedToSaveCredential"));
}
}
};
const [tagInput, setTagInput] = useState("");
const [keyGenerationPassphrase, setKeyGenerationPassphrase] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
@@ -305,38 +418,6 @@ export function CredentialEditor({
};
}, [folderDropdownOpen]);
const keyTypeOptions = [
{ value: "auto", label: t("hosts.autoDetect") },
{ value: "ssh-rsa", label: t("hosts.rsa") },
{ value: "ssh-ed25519", label: t("hosts.ed25519") },
{ value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
{ value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
{ value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
{ value: "ssh-dss", label: t("hosts.dsa") },
{ value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
{ value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (
keyTypeDropdownOpen &&
keyTypeDropdownRef.current &&
!keyTypeDropdownRef.current.contains(event.target as Node) &&
keyTypeButtonRef.current &&
!keyTypeButtonRef.current.contains(event.target as Node)
) {
setKeyTypeDropdownOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [keyTypeDropdownOpen]);
return (
<div
@@ -589,74 +670,305 @@ export function CredentialEditor({
/>
</TabsContent>
<TabsContent value="key">
<Tabs
value={keyInputMethod}
onValueChange={(value) => {
setKeyInputMethod(value as "upload" | "paste");
if (value === "upload") {
form.setValue("key", null);
} else {
form.setValue("key", "");
}
}}
className="w-full"
>
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
<TabsTrigger value="upload">
{t("hosts.uploadFile")}
</TabsTrigger>
<TabsTrigger value="paste">
{t("hosts.pasteKey")}
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="mt-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>
{t("credentials.sshPrivateKey")}
</FormLabel>
<FormControl>
<div className="relative inline-block">
<input
id="key-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
<div className="mt-4">
{/* Generate Key Pair Buttons */}
<div className="mb-4 p-4 bg-muted/20 border border-muted rounded-md">
<FormLabel className="mb-3 font-bold block">
{t("credentials.generateKeyPair")}
</FormLabel>
{/* Key Generation Passphrase Input */}
<div className="mb-3">
<FormLabel className="text-sm mb-2 block">
{t("credentials.keyPassword")} ({t("credentials.optional")})
</FormLabel>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
value={keyGenerationPassphrase}
onChange={(e) => setKeyGenerationPassphrase(e.target.value)}
className="max-w-xs"
/>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.keyPassphraseOptional")}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ssh-ed25519', undefined, keyGenerationPassphrase);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "Ed25519" }));
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
}
} catch (error) {
console.error('Failed to generate Ed25519 key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
}
}}
>
{t("credentials.generateEd25519")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ecdsa-sha2-nistp256', undefined, keyGenerationPassphrase);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "ECDSA" }));
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
}
} catch (error) {
console.error('Failed to generate ECDSA key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
}
}}
>
{t("credentials.generateECDSA")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ssh-rsa', 2048, keyGenerationPassphrase);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "RSA" }));
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
}
} catch (error) {
console.error('Failed to generate RSA key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
}
}}
>
{t("credentials.generateRSA")}
</Button>
</div>
<div className="text-xs text-muted-foreground mt-2">
{t("credentials.generateKeyPairNote")}
</div>
</div>
<div className="grid grid-cols-2 gap-4 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-2">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
} catch (error) {
console.error('Failed to read uploaded file:', error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
</div>
<FormControl>
<textarea
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={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || null);
field.onChange(e.target.value);
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </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">({t("credentials.detectingKeyType")})</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>
<div className="mb-2 flex gap-2">
<div className="relative inline-block flex-1">
<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);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error('Failed to read uploaded public key file:', error);
}
}
}}
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">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="justify-start text-left"
>
<span
className="truncate"
title={
field.value?.name ||
t("credentials.upload")
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (!privateKey || typeof privateKey !== "string" || !privateKey.trim()) {
toast.error(t("credentials.privateKeyRequiredForGeneration"));
return;
}
>
{field.value === "existing_key"
? t("hosts.existingKey")
: field.value
? editingCredential
? t("credentials.updateKey")
: field.value.name
: t("credentials.upload")}
</span>
try {
const keyPassword = form.watch("keyPassword");
const result = await generatePublicKeyFromPrivate(privateKey, keyPassword);
if (result.success && result.publicKey) {
// Set the generated public key
field.onChange(result.publicKey);
// Trigger public key detection
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.publicKeyGeneratedSuccessfully"));
} else {
toast.error(result.error || t("credentials.failedToGeneratePublicKey"));
}
} catch (error) {
console.error('Failed to generate public key:', error);
toast.error(t("credentials.failedToGeneratePublicKey"));
}
}}
>
{t("credentials.generatePublicKey")}
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-15 gap-4 mt-4">
<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);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.publicKeyNote")}
</div>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </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">({t("credentials.detectingKeyType")})</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
@@ -674,161 +986,8 @@ export function CredentialEditor({
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
>
{keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="paste" className="mt-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>
{t("credentials.sshPrivateKey")}
</FormLabel>
<FormControl>
<textarea
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={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) =>
field.onChange(e.target.value)
}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
>
{keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</div>
</TabsContent>
</Tabs>
</TabsContent>

View File

@@ -9,6 +9,21 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
@@ -29,12 +44,17 @@ import {
Pencil,
X,
Check,
Upload,
Server,
User,
} from "lucide-react";
import {
getCredentials,
deleteCredential,
updateCredential,
renameCredentialFolder,
deployCredentialToHost,
getSSHHosts,
} from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -65,12 +85,27 @@ export function CredentialsManager({
const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false);
const [showDeployDialog, setShowDeployDialog] = useState(false);
const [deployingCredential, setDeployingCredential] = useState<Credential | null>(null);
const [availableHosts, setAvailableHosts] = useState<any[]>([]);
const [selectedHostId, setSelectedHostId] = useState<string>("");
const [deployLoading, setDeployLoading] = useState(false);
const dragCounter = useRef(0);
useEffect(() => {
fetchCredentials();
fetchHosts();
}, []);
const fetchHosts = async () => {
try {
const hosts = await getSSHHosts();
setAvailableHosts(hosts);
} catch (err) {
console.error('Failed to fetch hosts:', err);
}
};
const fetchCredentials = async () => {
try {
setLoading(true);
@@ -90,6 +125,49 @@ export function CredentialsManager({
}
};
const handleDeploy = (credential: Credential) => {
if (credential.authType !== 'key') {
toast.error("Only SSH key-based credentials can be deployed");
return;
}
if (!credential.publicKey) {
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

Type safety for hosts; avoid any[].

Use SSHHost[] for availableHosts and number for selectedHostId where possible. This reduces parseInt churn and runtime errors.

- const [availableHosts, setAvailableHosts] = useState<any[]>([]);
- const [selectedHostId, setSelectedHostId] = useState<string>("");
+ const [availableHosts, setAvailableHosts] = useState<SSHHost[]>([]);
+ const [selectedHostId, setSelectedHostId] = useState<string>("");

And when calling:

- parseInt(selectedHostId)
+ Number(selectedHostId)

Also applies to: 100-107

🤖 Prompt for AI Agents
In src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx around lines 88 to 93
(also applies to lines 100-107), the state uses any[] and string for
host-related values causing extra parseInt usage and weak typing; change
availableHosts to SSHHost[] and selectedHostId to number (initialize
selectedHostId as 0 or -1 as sentinel), update setAvailableHosts usages to
push/fetch SSHHost objects, remove parseInt calls by ensuring host IDs are
numeric where provided, and update any event handlers, dropdown/select value
props, comparisons, and API call params to accept and use number IDs instead of
strings so type-safety is preserved across the component.
_🛠️ Refactor suggestion_ **Type safety for hosts; avoid any[].** Use SSHHost[] for availableHosts and number for selectedHostId where possible. This reduces parseInt churn and runtime errors. ```diff - const [availableHosts, setAvailableHosts] = useState<any[]>([]); - const [selectedHostId, setSelectedHostId] = useState<string>(""); + const [availableHosts, setAvailableHosts] = useState<SSHHost[]>([]); + const [selectedHostId, setSelectedHostId] = useState<string>(""); ``` And when calling: ```diff - parseInt(selectedHostId) + Number(selectedHostId) ``` Also applies to: 100-107 <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx around lines 88 to 93 (also applies to lines 100-107), the state uses any[] and string for host-related values causing extra parseInt usage and weak typing; change availableHosts to SSHHost[] and selectedHostId to number (initialize selectedHostId as 0 or -1 as sentinel), update setAvailableHosts usages to push/fetch SSHHost objects, remove parseInt calls by ensuring host IDs are numeric where provided, and update any event handlers, dropdown/select value props, comparisons, and API call params to accept and use number IDs instead of strings so type-safety is preserved across the component. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
toast.error("Public key is required for deployment");
return;
}
setDeployingCredential(credential);
setSelectedHostId("");
setShowDeployDialog(true);
};
const performDeploy = async () => {
if (!deployingCredential || !selectedHostId) {
toast.error("Please select a target host");
return;
}
setDeployLoading(true);
try {
const result = await deployCredentialToHost(
deployingCredential.id,
parseInt(selectedHostId)
);
if (result.success) {
toast.success(result.message || "SSH key deployed successfully");
setShowDeployDialog(false);
setDeployingCredential(null);
setSelectedHostId("");
} else {
toast.error(result.error || "Deployment failed");
}
} catch (error) {
console.error('Deployment error:', error);
toast.error("Failed to deploy SSH key");
} finally {
setDeployLoading(false);
}
};
const handleDelete = async (credentialId: number, credentialName: string) => {
confirmWithToast(
t("credentials.confirmDeleteCredential", { name: credentialName }),
@@ -577,6 +655,26 @@ export function CredentialsManager({
<p>Edit credential</p>
</TooltipContent>
</Tooltip>
{credential.authType === 'key' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDeploy(credential);
}}
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-500/10"
>
<Upload className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Deploy SSH key to host</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -687,6 +785,145 @@ export function CredentialsManager({
}}
/>
)}
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
<SheetHeader className="space-y-6 pb-8">
<SheetTitle className="flex items-center space-x-4">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<Upload className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<div className="text-xl font-semibold">Deploy SSH Key</div>
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
Deploy public key to target server
</div>
</div>
</SheetTitle>
</SheetHeader>
<div className="space-y-6">
{/* Credential Information Card */}
{deployingCredential && (
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
<Key className="h-4 w-4 mr-2 text-zinc-500" />
Source Credential
</h4>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Name</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.name || deployingCredential.username}
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Username</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.username}
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Key Type</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.keyType || 'SSH Key'}
</div>
</div>
</div>
</div>
</div>
)}
{/* Target Host Selection */}
<div className="space-y-3">
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
<Server className="h-4 w-4 mr-2 text-zinc-500" />
Target Host
</label>
<Select value={selectedHostId} onValueChange={setSelectedHostId}>
<SelectTrigger className="h-12 border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/50">
<SelectValue placeholder="Choose a host to deploy to..." />
</SelectTrigger>
<SelectContent>
{availableHosts.map((host) => (
<SelectItem key={host.id} value={host.id.toString()}>
<div className="flex items-center gap-3 py-1">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<Server className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
{host.name || host.ip}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{host.username}@{host.ip}:{host.port}
</div>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Information Note */}
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-start space-x-3">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Deployment Process</p>
<p className="text-blue-700 dark:text-blue-300">
This will safely add the public key to the target host's ~/.ssh/authorized_keys file
without overwriting existing keys. The operation is reversible.
</p>
</div>
</div>
</div>
</div>
<SheetFooter className="mt-8 flex space-x-3">
<Button
variant="outline"
onClick={() => setShowDeployDialog(false)}
disabled={deployLoading}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={performDeploy}
disabled={!selectedHostId || deployLoading}
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
>
{deployLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
Deploying...
</div>
) : (
<div className="flex items-center">
<Upload className="h-4 w-4 mr-2" />
Deploy SSH Key
</div>
)}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -1506,7 +1506,7 @@ export async function getCredentials(): Promise<any> {
const response = await authApi.get("/credentials");
return response.data;
} catch (error) {
handleApiError(error, "fetch credentials");
throw handleApiError(error, "fetch credentials");
}
}
@@ -1515,7 +1515,7 @@ export async function getCredentialDetails(credentialId: number): Promise<any> {
const response = await authApi.get(`/credentials/${credentialId}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch credential details");
throw handleApiError(error, "fetch credential details");
}
}
@@ -1524,7 +1524,7 @@ export async function createCredential(credentialData: any): Promise<any> {
const response = await authApi.post("/credentials", credentialData);
return response.data;
} catch (error) {
handleApiError(error, "create credential");
throw handleApiError(error, "create credential");
}
}
@@ -1539,7 +1539,7 @@ export async function updateCredential(
);
return response.data;
} catch (error) {
handleApiError(error, "update credential");
throw handleApiError(error, "update credential");
}
}
@@ -1548,7 +1548,7 @@ export async function deleteCredential(credentialId: number): Promise<any> {
const response = await authApi.delete(`/credentials/${credentialId}`);
return response.data;
} catch (error) {
handleApiError(error, "delete credential");
throw handleApiError(error, "delete credential");
}
}
@@ -1594,7 +1594,7 @@ export async function applyCredentialToHost(
);
return response.data;
} catch (error) {
handleApiError(error, "apply credential to host");
throw handleApiError(error, "apply credential to host");
}
}
@@ -1604,7 +1604,7 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
return response.data;
} catch (error) {
handleApiError(error, "remove credential from host");
throw handleApiError(error, "remove credential from host");
}
}
@@ -1620,7 +1620,7 @@ export async function migrateHostToCredential(
);
return response.data;
} catch (error) {
handleApiError(error, "migrate host to credential");
throw handleApiError(error, "migrate host to credential");
}
}
@@ -1663,6 +1663,98 @@ export async function renameCredentialFolder(
});
return response.data;
} catch (error) {
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");
}
}
export async function generatePublicKeyFromPrivate(
privateKey: string,
keyPassword?: string,
): Promise<any> {
try {
const response = await authApi.post("/credentials/generate-public-key", {
privateKey,
keyPassword,
});
return response.data;
} catch (error) {
throw handleApiError(error, "generate public key from private key");
}
}
export async function generateKeyPair(
keyType: 'ssh-ed25519' | 'ssh-rsa' | 'ecdsa-sha2-nistp256',
keySize?: number,
passphrase?: string,
): Promise<any> {
try {
const response = await authApi.post("/credentials/generate-key-pair", {
keyType,
keySize,
passphrase,
});
return response.data;
} catch (error) {
throw handleApiError(error, "generate SSH key pair");
}
}
export async function deployCredentialToHost(
credentialId: number,
targetHostId: number,
): Promise<any> {
try {
const response = await authApi.post(
`/credentials/${credentialId}/deploy-to-host`,
{ targetHostId }
);
return response.data;
} catch (error) {
throw handleApiError(error, "deploy credential to host");
}
}

266
unified_key_section.tsx Normal file
View File

@@ -0,0 +1,266 @@
<TabsContent value="key">
<div className="space-y-6">
{/* Private Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</FormLabel>
<FormControl>
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
field.onChange(file);
try {
const fileContent = await file.text();
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
} catch (error) {
console.error('Failed to read uploaded file:', error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{field.value instanceof File
? field.value.name
: t("credentials.upload")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
{/* Text Input */}
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
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={typeof field.value === "string" ? field.value : ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Key type detection display */}
{detectedKeyType && (
<div className="text-sm">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
<span className={`font-medium ${
detectedKeyType === 'invalid' || detectedKeyType === 'error'
? 'text-destructive'
: 'text-green-600'
}`}>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

Avoid importing backend-only utils in the frontend; inline or move key-type map to a shared UI module.

getFriendlyKeyTypeName lives under backend/utils; bundlers may pull server-only deps. Create a small UI helper or shared constants and import from there.

Proposed UI helper:

export const friendlyKeyType = (t: 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','error':'Error','unknown':'Unknown'} as const)[t] ?? t;

Also applies to: 205-210

🤖 Prompt for AI Agents
In unified_key_section.tsx around lines 85-91 (and similarly at 205-210) you are
importing a backend-only helper getFriendlyKeyTypeName which can pull
server-only deps into the frontend; replace that usage by creating a small
UI/shared helper (e.g., a friendlyKeyType map/function in a ui/helpers or
shared/constants module) that maps key type strings to display names, export it
with proper typings, import that helper into unified_key_section.tsx, and update
the JSX to call the new helper instead of the backend util so the frontend
bundle has no server-only dependency.
_🛠️ Refactor suggestion_ **Avoid importing backend-only utils in the frontend; inline or move key-type map to a shared UI module.** getFriendlyKeyTypeName lives under backend/utils; bundlers may pull server-only deps. Create a small UI helper or shared constants and import from there. Proposed UI helper: ```ts export const friendlyKeyType = (t: 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','error':'Error','unknown':'Unknown'} as const)[t] ?? t; ``` Also applies to: 205-210 <details> <summary>🤖 Prompt for AI Agents</summary> ``` In unified_key_section.tsx around lines 85-91 (and similarly at 205-210) you are importing a backend-only helper getFriendlyKeyTypeName which can pull server-only deps into the frontend; replace that usage by creating a small UI/shared helper (e.g., a friendlyKeyType map/function in a ui/helpers or shared/constants module) that maps key type strings to display names, export it with proper typings, import that helper into unified_key_section.tsx, and update the JSX to call the new helper instead of the backend util so the frontend bundle has no server-only dependency. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detecting")}...)</span>
)}
</div>
)}
{/* Show existing private key for editing */}
{editingCredential && fullCredentialDetails?.key && (
<FormItem>
<FormLabel>{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[120px] w-full rounded-md border border-input bg-muted 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={fullCredentialDetails.key}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentKeyContent")}
</div>
{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>
)}
</div>
{/* Public Key Section */}
<div className="space-y-4">
<FormLabel className="text-sm font-medium">
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
</FormLabel>
<div className="grid grid-cols-2 gap-4">
{/* File Upload */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.uploadFile")}
</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);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error('Failed to read uploaded public key file:', error);
}
}
}}
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>
</FormItem>
)}
/>
{/* Text Input */}
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs text-muted-foreground">
{t("hosts.pasteKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t("placeholders.pastePublicKey")}
className="flex min-h-[80px] 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);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Public key type detection */}
{detectedPublicKeyType && form.watch("publicKey") && (
<div className="text-sm">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </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">({t("credentials.detecting")}...)</span>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
{t("credentials.publicKeyNote")}
</div>
{/* Show existing public key for editing */}
{editingCredential && fullCredentialDetails?.publicKey && (
<FormItem>
<FormLabel>{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})</FormLabel>
<FormControl>
<textarea
readOnly
className="flex min-h-[80px] w-full rounded-md border border-input bg-muted 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={fullCredentialDetails.publicKey}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.currentPublicKeyContent")}
</div>
</FormItem>
)}
</div>
{/* Generate Public Key Button */}
{form.watch("key") && (
<div className="mt-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeneratePublicKey}
disabled={generatePublicKeyLoading}
className="w-full"
>
{generatePublicKeyLoading ? (
<>
<span className="mr-2">{t("credentials.generating")}...</span>
</>
) : (
<>
<span>{t("credentials.generatePublicKey")}</span>
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{t("credentials.generatePublicKeyNote")}
</p>
</div>
)}
</div>
</TabsContent>