Add SSH key generation and deployment features #234
@@ -137,9 +137,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
authType: text("auth_type").notNull(),
|
||||
username: text("username").notNull(),
|
||||
password: text("password"),
|
||||
key: text("key", { length: 16384 }),
|
||||
key: text("key", { length: 16384 }), // backward compatibility
|
||||
privateKey: text("private_key", { length: 16384 }),
|
||||
publicKey: text("public_key", { length: 4096 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
detectedKeyType: text("detected_key_type"),
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
|
|
||||
createdAt: text("created_at")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
🛠️ Refactor suggestion ⚠️ Potential issue Weak/default crypto options in key generation.
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));
|
||||
|
🛠️ 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: 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"
|
||||
});
|
||||
}
|
||||
|
💡 Verification agent 🧩 Analysis chainAddress 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: 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.
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 -->
⚠️ 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.
🧰 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_⚠️ 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;
|
||||
}
|
||||
|
🛠️ Refactor suggestion ⚠️ Potential issue Command injection risk and fragile grep in deployment.
Apply: 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_🛠️ 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
527
src/backend/utils/ssh-key-utils.ts
Normal 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);
|
||||
|
||||
|
⚠️ 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: 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_⚠️ 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}`;
|
||||
}
|
||||
|
||||
|
⚠️ Potential issue Bug: Misencoding public key — You’re base64‑encoding the OpenSSH public key again, producing invalid keys. Apply this fix: 📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ 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);
|
||||
|
⚠️ Potential issue Don’t mark fallback heuristics as success. If ssh2 parsing fails, heuristics should not set 📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ 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'
|
||||
};
|
||||
|
⚠️ Potential issue Validation must be definitive — don’t return true without a match. Currently returns 📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
🛠️ Refactor suggestion Key type detection should react to keyPassword changes. Trigger detection when keyPassword changes if a key is present. Note: capture values via form.getValues() to avoid extra renders.
🤖 Prompt for AI Agents_🛠️ 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>
|
||||
|
||||
@@ -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) {
|
||||
|
🛠️ 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. And when calling: Also applies to: 100-107 🤖 Prompt for AI Agents_🛠️ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
🛠️ 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: Also applies to: 205-210 🤖 Prompt for AI Agents_🛠️ 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>
|
||||
🛠️ Refactor suggestion
⚠️ Potential issue
Encrypt private keys at rest; reconsider TEXT length hints.
privateKeyin plaintext is a high‑risk posture. Encrypt at rest (e.g., libsodium or AES‑GCM with app‑level KEK) and ensure strict access controls.publicKeyto avoid truncation risk from long comments.Also add a migration to backfill
privateKeyfromkeyand then phase outkey.📝 Committable suggestion