Files
Termix/src/backend/database/routes/credentials.ts
ZacharyZcR 5ccb52071d Eliminate JWT security vulnerability with unified encryption architecture
SECURITY FIX: Replace dangerous JWT_SECRET environment variable with
encrypted database storage using hardware-bound KEK protection.

Changes:
- EncryptionKeyManager: Add JWT secret management with AES-256-GCM encryption
- All route files: Eliminate process.env.JWT_SECRET dependencies
- Database server: Initialize JWT secret during startup with proper error handling
- Testing: Add comprehensive JWT secret management test coverage
- API: Add /encryption/regenerate-jwt endpoint for key rotation

Technical implementation:
- JWT secrets now use same protection as SSH keys (hardware fingerprint binding)
- 512-bit JWT secrets generated via crypto.randomBytes(64)
- KEK-protected storage prevents cross-device secret migration
- No backward compatibility for insecure environment variable approach

This eliminates the critical security flaw where JWT tokens could be
forged using the default "secret" value, achieving uniform security
architecture with no special cases.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 03:00:59 +08:00

1527 lines
44 KiB
TypeScript

import express from "express";
import { db } from "../db/index.js";
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
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 { EncryptedDBOperations } from "../../utils/encrypted-db-operations.js";
import {
parseSSHKey,
parsePublicKey,
detectKeyType,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
import crypto from "crypto";
import ssh2Pkg from "ssh2";
const { utils: ssh2Utils, Client } = ssh2Pkg;
// Direct SSH key generation with ssh2 - the right way
function generateSSHKeyPair(
keyType: string,
keySize?: number,
passphrase?: string,
): {
success: boolean;
privateKey?: string;
publicKey?: string;
error?: string;
} {
console.log("Generating SSH key pair with ssh2:", keyType);
try {
// Convert our keyType to ssh2 format
let ssh2Type = keyType;
const options: any = {};
if (keyType === "ssh-rsa") {
ssh2Type = "rsa";
options.bits = keySize || 2048;
} else if (keyType === "ssh-ed25519") {
ssh2Type = "ed25519";
} else if (keyType === "ecdsa-sha2-nistp256") {
ssh2Type = "ecdsa";
options.bits = 256; // ECDSA P-256 uses 256 bits
}
// Add passphrase protection if provided
if (passphrase && passphrase.trim()) {
options.passphrase = passphrase;
options.cipher = "aes128-cbc"; // Default cipher for encrypted private keys
}
// Use ssh2's native key generation
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
console.log("SSH key pair generated successfully!");
console.log("Private key length:", keyPair.private.length);
console.log("Public key preview:", keyPair.public.substring(0, 50) + "...");
return {
success: true,
privateKey: keyPair.private,
publicKey: keyPair.public,
};
} catch (error) {
console.error("SSH key generation failed:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "SSH key generation failed",
};
}
}
const router = express.Router();
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
function isNonEmptyString(val: any): val is string {
return typeof val === "string" && val.trim().length > 0;
}
async function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers["authorization"];
if (!authHeader || !authHeader.startsWith("Bearer ")) {
authLogger.warn("Missing or invalid Authorization header");
return res
.status(401)
.json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.split(" ")[1];
try {
const { EncryptionKeyManager } = await import("../../utils/encryption-key-manager.js");
const keyManager = EncryptionKeyManager.getInstance();
const jwtSecret = await keyManager.getJWTSecret();
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
authLogger.warn("Invalid or expired token");
return res.status(401).json({ error: "Invalid or expired token" });
}
}
// Create a new credential
// POST /credentials
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {
name,
description,
folder,
tags,
authType,
username,
password,
key,
keyPassword,
keyType,
} = req.body;
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(name) ||
!isNonEmptyString(username)
) {
authLogger.warn("Invalid credential creation data validation failed", {
operation: "credential_create",
userId,
hasName: !!name,
hasUsername: !!username,
});
return res.status(400).json({ error: "Name and username are required" });
}
if (!["password", "key"].includes(authType)) {
authLogger.warn("Invalid auth type provided", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: 'Auth type must be "password" or "key"' });
}
try {
if (authType === "password" && !password) {
authLogger.warn("Password required for password authentication", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: "Password is required for password authentication" });
}
if (authType === "key" && !key) {
authLogger.warn("SSH key required for key authentication", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: "SSH key is required for key authentication" });
}
const plainPassword = authType === "password" && password ? password : null;
const plainKey = authType === "key" && key ? key : null;
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(),
description: description?.trim() || null,
folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
authType,
username: username.trim(),
password: plainPassword,
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,
};
const created = (await EncryptedDBOperations.insert(
sshCredentials,
"ssh_credentials",
credentialData,
)) as typeof credentialData & { id: number };
authLogger.success(
`SSH credential created: ${name} (${authType}) by user ${userId}`,
{
operation: "credential_create_success",
userId,
credentialId: created.id,
name,
authType,
username,
},
);
res.status(201).json(formatCredentialOutput(created));
} catch (err) {
authLogger.error("Failed to create credential in database", err, {
operation: "credential_create",
userId,
name,
authType,
username,
});
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to create credential",
});
}
});
// Get all credentials for the authenticated user
// GET /credentials
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for credential fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const credentials = await EncryptedDBOperations.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt)),
"ssh_credentials",
);
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
} catch (err) {
authLogger.error("Failed to fetch credentials", err);
res.status(500).json({ error: "Failed to fetch credentials" });
}
});
// Get all unique credential folders for the authenticated user
// GET /credentials/folders
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for credential folder fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select({ folder: sshCredentials.folder })
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {};
result.forEach((r) => {
if (r.folder && r.folder.trim() !== "") {
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(
(folder) => folderCounts[folder] > 0,
);
res.json(folders);
} catch (err) {
authLogger.error("Failed to fetch credential folders", err);
res.status(500).json({ error: "Failed to fetch credential folders" });
}
});
// Get a specific credential by ID (with plain text secrets)
// GET /credentials/:id
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for credential fetch");
return res.status(400).json({ error: "Invalid request" });
}
try {
const credentials = await EncryptedDBOperations.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
);
if (credentials.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const credential = credentials[0];
const output = formatCredentialOutput(credential);
if (credential.password) {
(output as any).password = credential.password;
}
if (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;
}
res.json(output);
} catch (err) {
authLogger.error("Failed to fetch credential", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch credential",
});
}
});
// Update a credential
// PUT /credentials/:id
router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
const updateData = req.body;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for credential update");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const updateFields: any = {};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
if (updateData.folder !== undefined)
updateFields.folder = updateData.folder?.trim() || null;
if (updateData.tags !== undefined) {
updateFields.tags = Array.isArray(updateData.tags)
? updateData.tags.join(",")
: updateData.tags || "";
}
if (updateData.username !== undefined)
updateFields.username = updateData.username.trim();
if (updateData.authType !== undefined)
updateFields.authType = updateData.authType;
if (updateData.keyType !== undefined)
updateFields.keyType = updateData.keyType;
if (updateData.password !== undefined) {
updateFields.password = updateData.password || null;
}
if (updateData.key !== undefined) {
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;
}
if (Object.keys(updateFields).length === 0) {
const existing = await EncryptedDBOperations.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id))),
"ssh_credentials",
);
return res.json(formatCredentialOutput(existing[0]));
}
await EncryptedDBOperations.update(
sshCredentials,
"ssh_credentials",
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
updateFields,
);
const updated = await EncryptedDBOperations.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id))),
"ssh_credentials",
);
const credential = updated[0];
authLogger.success(
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
{
operation: "credential_update_success",
userId,
credentialId: parseInt(id),
name: credential.name,
authType: credential.authType,
username: credential.username,
},
);
res.json(formatCredentialOutput(updated[0]));
} catch (err) {
authLogger.error("Failed to update credential", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to update credential",
});
}
});
// Delete a credential
// DELETE /credentials/:id
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for credential deletion");
return res.status(400).json({ error: "Invalid request" });
}
try {
const credentialToDelete = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
if (credentialToDelete.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const hostsUsingCredential = await db
.select()
.from(sshData)
.where(
and(eq(sshData.credentialId, parseInt(id)), eq(sshData.userId, userId)),
);
if (hostsUsingCredential.length > 0) {
await db
.update(sshData)
.set({
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
})
.where(
and(
eq(sshData.credentialId, parseInt(id)),
eq(sshData.userId, userId),
),
);
}
await db
.delete(sshCredentialUsage)
.where(
and(
eq(sshCredentialUsage.credentialId, parseInt(id)),
eq(sshCredentialUsage.userId, userId),
),
);
await db
.delete(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
const credential = credentialToDelete[0];
authLogger.success(
`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`,
{
operation: "credential_delete_success",
userId,
credentialId: parseInt(id),
name: credential.name,
authType: credential.authType,
username: credential.username,
},
);
res.json({ message: "Credential deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete credential", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete credential",
});
}
});
// Apply a credential to an SSH host (for quick application)
// POST /credentials/:id/apply-to-host/:hostId
router.post(
"/:id/apply-to-host/:hostId",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id: credentialId, hostId } = req.params;
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
authLogger.warn("Invalid request for credential application");
return res.status(400).json({ error: "Invalid request" });
}
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId),
),
);
if (credentials.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const credential = credentials[0];
await db
.update(sshData)
.set({
credentialId: parseInt(credentialId),
username: credential.username,
authType: credential.authType,
password: null,
key: null,
keyPassword: null,
keyType: null,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
);
await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId),
hostId: parseInt(hostId),
userId,
});
await db
.update(sshCredentials)
.set({
usageCount: sql`${sshCredentials.usageCount}
+ 1`,
lastUsed: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.where(eq(sshCredentials.id, parseInt(credentialId)));
res.json({ message: "Credential applied to host successfully" });
} catch (err) {
authLogger.error("Failed to apply credential to host", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to apply credential to host",
});
}
},
);
// Get hosts using a specific credential
// GET /credentials/:id/hosts
router.get(
"/:id/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id: credentialId } = req.params;
if (!isNonEmptyString(userId) || !credentialId) {
authLogger.warn("Invalid request for credential hosts fetch");
return res.status(400).json({ error: "Invalid request" });
}
try {
const hosts = await db
.select()
.from(sshData)
.where(
and(
eq(sshData.credentialId, parseInt(credentialId)),
eq(sshData.userId, userId),
),
);
res.json(hosts.map((host) => formatSSHHostOutput(host)));
} catch (err) {
authLogger.error("Failed to fetch hosts using credential", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to fetch hosts using credential",
});
}
},
);
function formatCredentialOutput(credential: any): any {
return {
id: credential.id,
name: credential.name,
description: credential.description,
folder: credential.folder,
tags:
typeof credential.tags === "string"
? credential.tags
? credential.tags.split(",").filter(Boolean)
: []
: [],
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,
updatedAt: credential.updatedAt,
};
}
function formatSSHHostOutput(host: any): any {
return {
id: host.id,
userId: host.userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags:
typeof host.tags === "string"
? host.tags
? host.tags.split(",").filter(Boolean)
: []
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [],
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
}
// Rename a credential folder
// PUT /credentials/folders/rename
router.put(
"/folders/rename",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { oldName, newName } = req.body;
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
return res
.status(400)
.json({ error: "Both oldName and newName are required" });
}
if (oldName === newName) {
return res
.status(400)
.json({ error: "Old name and new name cannot be the same" });
}
try {
await db
.update(sshCredentials)
.set({ folder: newName })
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
),
);
res.json({ success: true, message: "Folder renamed successfully" });
} catch (error) {
authLogger.error("Error renaming credential folder:", error);
res.status(500).json({ error: "Failed to rename folder" });
}
},
);
// Detect SSH key type endpoint
// POST /credentials/detect-key-type
router.post(
"/detect-key-type",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
console.log("=== Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log("Calling parseSSHKey...");
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey result:", keyInfo);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
hasPublicKey: !!keyInfo.publicKey,
error: keyInfo.error || null,
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-key-type endpoint:", error);
authLogger.error("Failed to detect key type", error);
res.status(500).json({
error:
error instanceof Error ? error.message : "Failed to detect key type",
});
}
},
);
// Detect SSH public key type endpoint
// POST /credentials/detect-public-key-type
router.post(
"/detect-public-key-type",
authenticateJWT,
async (req: Request, res: Response) => {
const { publicKey } = req.body;
console.log("=== Public Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Public key provided:", !!publicKey);
console.log("Public key type:", typeof publicKey);
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling parsePublicKey...");
const keyInfo = parsePublicKey(publicKey);
console.log("parsePublicKey result:", keyInfo);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
error: keyInfo.error || null,
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-public-key-type endpoint:", error);
authLogger.error("Failed to detect public key type", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to detect public key type",
});
}
},
);
// Validate SSH key pair endpoint
// POST /credentials/validate-key-pair
router.post(
"/validate-key-pair",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
console.log("=== Key Pair Validation API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Public key provided:", !!publicKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling validateKeyPair...");
const validationResult = validateKeyPair(
privateKey,
publicKey,
keyPassword,
);
console.log("validateKeyPair result:", validationResult);
const response = {
isValid: validationResult.isValid,
privateKeyType: validationResult.privateKeyType,
publicKeyType: validationResult.publicKeyType,
generatedPublicKey: validationResult.generatedPublicKey,
error: validationResult.error || null,
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in validate-key-pair endpoint:", error);
authLogger.error("Failed to validate key pair", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to validate key pair",
});
}
},
);
// Generate new SSH key pair endpoint
// POST /credentials/generate-key-pair
router.post(
"/generate-key-pair",
authenticateJWT,
async (req: Request, res: Response) => {
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
console.log("=== Generate Key Pair API Called ===");
console.log("Key type:", keyType);
console.log("Key size:", keySize);
console.log("Has passphrase:", !!passphrase);
try {
// Generate SSH keys directly with ssh2
const result = generateSSHKeyPair(keyType, keySize, passphrase);
if (result.success && result.privateKey && result.publicKey) {
const response = {
success: true,
privateKey: result.privateKey,
publicKey: result.publicKey,
keyType: keyType,
format: "ssh",
algorithm: keyType,
keySize: keyType === "ssh-rsa" ? keySize : undefined,
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
};
console.log("SSH key pair generated successfully:", keyType);
res.json(response);
} else {
console.error("SSH key generation failed:", result.error);
res.status(500).json({
success: false,
error: result.error || "Failed to generate SSH key pair",
});
}
} catch (error) {
console.error("Exception in generate-key-pair endpoint:", error);
authLogger.error("Failed to generate key pair", error);
res.status(500).json({
success: false,
error:
error instanceof Error
? error.message
: "Failed to generate key pair",
});
}
},
);
// Generate public key from private key endpoint
// POST /credentials/generate-public-key
router.post(
"/generate-public-key",
authenticateJWT,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
console.log("=== Generate Public Key API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log(
"Using Node.js crypto to generate public key from private key...",
);
console.log("Private key length:", privateKey.length);
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
// First try to create private key object from the input
let privateKeyObj;
let parseAttempts = [];
// Attempt 1: Direct parsing with passphrase
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
passphrase: keyPassword,
});
console.log("Successfully parsed with passphrase method");
} catch (error) {
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
}
// Attempt 2: Direct parsing without passphrase
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey(privateKey);
console.log("Successfully parsed without passphrase");
} catch (error) {
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
}
}
// Attempt 3: Try with explicit format specification
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs8",
});
console.log("Successfully parsed as PKCS#8");
} catch (error) {
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
}
}
// Attempt 4: Try as PKCS#1 RSA
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "pkcs1",
});
console.log("Successfully parsed as PKCS#1 RSA");
} catch (error) {
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
}
}
// Attempt 5: Try as SEC1 EC
if (
!privateKeyObj &&
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: "pem",
type: "sec1",
});
console.log("Successfully parsed as SEC1 EC");
} catch (error) {
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
}
}
// Final attempt: Try using ssh2 as fallback
if (!privateKeyObj) {
console.log("Attempting fallback to parseSSHKey function...");
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey fallback result:", keyInfo);
if (keyInfo.success && keyInfo.publicKey) {
// Ensure SSH2 fallback also returns proper string
const publicKeyString = String(keyInfo.publicKey);
console.log(
"SSH2 fallback public key type:",
typeof publicKeyString,
);
console.log(
"SSH2 fallback public key length:",
publicKeyString.length,
);
return res.json({
success: true,
publicKey: publicKeyString,
keyType: keyInfo.keyType,
});
} else {
parseAttempts.push(
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
);
}
} catch (error) {
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
}
}
if (!privateKeyObj) {
console.error("All parsing attempts failed:", parseAttempts);
return res.status(400).json({
success: false,
error: "Unable to parse private key. Tried multiple formats.",
details: parseAttempts,
});
}
// Generate public key from private key
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
const publicKeyPem = publicKeyObj.export({
type: "spki",
format: "pem",
});
// Debug: Check what we're actually generating
console.log("Generated public key type:", typeof publicKeyPem);
console.log(
"Generated public key is Buffer:",
Buffer.isBuffer(publicKeyPem),
);
// Ensure publicKeyPem is a string
const publicKeyString =
typeof publicKeyPem === "string"
? publicKeyPem
: publicKeyPem.toString("utf8");
console.log("Public key string length:", publicKeyString.length);
console.log(
"Generated public key first 100 chars:",
publicKeyString.substring(0, 100),
);
console.log("Public key is string:", typeof publicKeyString === "string");
console.log(
"Public key contains PEM header:",
publicKeyString.includes("-----BEGIN PUBLIC KEY-----"),
);
// Detect key type from the private key object
let keyType = "unknown";
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
if (asymmetricKeyType === "rsa") {
keyType = "ssh-rsa";
} else if (asymmetricKeyType === "ed25519") {
keyType = "ssh-ed25519";
} else if (asymmetricKeyType === "ec") {
// For EC keys, we need to check the curve
keyType = "ecdsa-sha2-nistp256"; // Default assumption for P-256
}
// Use ssh2 to generate SSH format public key
let finalPublicKey = publicKeyString; // PEM fallback
let formatType = "pem";
try {
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
if (!(ssh2PrivateKey instanceof Error)) {
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
const base64Data = publicKeyBuffer.toString("base64");
finalPublicKey = `${keyType} ${base64Data}`;
formatType = "ssh";
console.log("SSH format public key generated!");
} else {
console.warn("ssh2 parsing failed, using PEM format");
}
} catch (sshError) {
console.warn("ssh2 failed, using PEM format");
}
const response = {
success: true,
publicKey: finalPublicKey,
keyType: keyType,
format: formatType,
};
console.log("Final response publicKey type:", typeof response.publicKey);
console.log("Final response publicKey format:", response.format);
console.log(
"Final response publicKey length:",
response.publicKey.length,
);
console.log(
"Public key generated successfully using crypto module:",
keyType,
);
res.json(response);
} catch (error) {
console.error("Exception in generate-public-key endpoint:", error);
authLogger.error("Failed to generate public key", error);
res.status(500).json({
success: false,
error:
error instanceof Error
? error.message
: "Failed to generate public key",
});
}
},
);
// SSH Key Deployment Function
async function deploySSHKeyToHost(
hostConfig: any,
publicKey: string,
credentialData: any,
): Promise<{ success: boolean; message?: string; error?: string }> {
return new Promise((resolve) => {
const conn = new Client();
let connectionTimeout: NodeJS.Timeout;
// Connection timeout
connectionTimeout = setTimeout(() => {
conn.destroy();
resolve({ success: false, error: "Connection timeout" });
}, 30000);
conn.on("ready", async () => {
clearTimeout(connectionTimeout);
try {
// Step 1: Create ~/.ssh directory if it doesn't exist
await new Promise<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",
});
}
try {
// Get credential details
const credential = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1);
if (!credential || credential.length === 0) {
return res.status(404).json({
success: false,
error: "Credential not found",
});
}
const credData = credential[0];
// Only support key-based credentials for deployment
if (credData.authType !== "key") {
return res.status(400).json({
success: false,
error: "Only SSH key-based credentials can be deployed",
});
}
if (!credData.publicKey) {
return res.status(400).json({
success: false,
error: "Public key is required for deployment",
});
}
// Get target host details
const targetHost = await db
.select()
.from(sshData)
.where(eq(sshData.id, targetHostId))
.limit(1);
if (!targetHost || targetHost.length === 0) {
return res.status(404).json({
success: false,
error: "Target host not found",
});
}
const hostData = targetHost[0];
// Prepare host configuration for connection
let hostConfig = {
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
authType: hostData.authType,
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword,
};
// If host uses credential authentication, resolve the credential
if (hostData.authType === "credential" && hostData.credentialId) {
const hostCredential = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, hostData.credentialId))
.limit(1);
if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0];
// Update hostConfig with credential data
hostConfig.authType = cred.authType;
hostConfig.username = cred.username; // Use credential's username
if (cred.authType === "password") {
hostConfig.password = cred.password;
} else if (cred.authType === "key") {
hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields
hostConfig.keyPassword = cred.keyPassword;
}
} else {
return res.status(400).json({
success: false,
error: "Host credential not found",
});
}
}
// Deploy the SSH key
const deployResult = await deploySSHKeyToHost(
hostConfig,
credData.publicKey,
credData,
);
if (deployResult.success) {
// Log successful deployment
authLogger.info(`SSH key deployed successfully`, {
credentialId,
targetHostId,
operation: "deploy_ssh_key",
});
res.json({
success: true,
message: deployResult.message || "SSH key deployed successfully",
});
} else {
authLogger.error(`SSH key deployment failed`, {
credentialId,
targetHostId,
error: deployResult.error,
operation: "deploy_ssh_key",
});
res.status(500).json({
success: false,
error: deployResult.error || "Deployment failed",
});
}
} catch (error) {
authLogger.error("Failed to deploy SSH key", error);
res.status(500).json({
success: false,
error:
error instanceof Error ? error.message : "Failed to deploy SSH key",
});
}
},
);
export default router;