mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
520 lines
15 KiB
TypeScript
520 lines
15 KiB
TypeScript
import { createPrivateKey } from "node:crypto";
|
|
import sshpk from "sshpk";
|
|
import { getErrorMessage } from "./error-message.js";
|
|
import ssh2Pkg from "ssh2";
|
|
const ssh2Utils = ssh2Pkg.utils;
|
|
const { parsePrivateKey } = sshpk;
|
|
|
|
function detectKeyTypeFromContent(keyContent: string): string {
|
|
const content = keyContent.trim();
|
|
|
|
if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
|
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";
|
|
}
|
|
|
|
try {
|
|
const base64Content = content
|
|
.replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
|
|
.replace("-----END OPENSSH PRIVATE KEY-----", "")
|
|
.replace(/\s/g, "");
|
|
|
|
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";
|
|
}
|
|
|
|
return "ssh-rsa";
|
|
} catch {
|
|
return "ssh-rsa";
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
if (content.includes("-----BEGIN PRIVATE KEY-----")) {
|
|
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");
|
|
|
|
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
|
return "ssh-rsa";
|
|
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
|
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
|
return "ecdsa-sha2-nistp256";
|
|
}
|
|
return "ecdsa-sha2-nistp256";
|
|
} else if (decodedString.includes("1.3.101.112")) {
|
|
return "ssh-ed25519";
|
|
}
|
|
} catch {
|
|
// expected - base64 decode may fail for some key formats
|
|
}
|
|
|
|
if (content.length < 800) {
|
|
return "ssh-ed25519";
|
|
} else if (content.length > 1600) {
|
|
return "ssh-rsa";
|
|
} else {
|
|
return "ecdsa-sha2-nistp256";
|
|
}
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
|
const content = publicKeyContent.trim();
|
|
|
|
// OpenSSH certificate types (must be checked before plain key types)
|
|
if (content.startsWith("ssh-ed25519-cert-v01@openssh.com ")) {
|
|
return "ssh-ed25519-cert-v01@openssh.com";
|
|
}
|
|
if (content.startsWith("ssh-rsa-cert-v01@openssh.com ")) {
|
|
return "ssh-rsa-cert-v01@openssh.com";
|
|
}
|
|
if (content.startsWith("ecdsa-sha2-nistp256-cert-v01@openssh.com ")) {
|
|
return "ecdsa-sha2-nistp256-cert-v01@openssh.com";
|
|
}
|
|
if (content.startsWith("ecdsa-sha2-nistp384-cert-v01@openssh.com ")) {
|
|
return "ecdsa-sha2-nistp384-cert-v01@openssh.com";
|
|
}
|
|
if (content.startsWith("ecdsa-sha2-nistp521-cert-v01@openssh.com ")) {
|
|
return "ecdsa-sha2-nistp521-cert-v01@openssh.com";
|
|
}
|
|
if (content.startsWith("sk-ssh-ed25519-cert-v01@openssh.com ")) {
|
|
return "sk-ssh-ed25519-cert-v01@openssh.com";
|
|
}
|
|
|
|
// Plain public keys
|
|
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";
|
|
}
|
|
|
|
if (content.includes("-----BEGIN PUBLIC KEY-----")) {
|
|
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");
|
|
|
|
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
|
return "ssh-rsa";
|
|
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
|
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
|
return "ecdsa-sha2-nistp256";
|
|
}
|
|
return "ecdsa-sha2-nistp256";
|
|
} else if (decodedString.includes("1.3.101.112")) {
|
|
return "ssh-ed25519";
|
|
}
|
|
} catch {
|
|
// expected - base64 decode may fail for some key formats
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const PUTTY_PRIVATE_KEY_RE = /^PuTTY-User-Key-File-(\d+):\s*(.+)$/m;
|
|
|
|
function normalizePrivateKeyText(privateKeyData: string): string {
|
|
return privateKeyData.trim().replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
}
|
|
|
|
function getUnsupportedPrivateKeyError(privateKeyData: string): string {
|
|
const puttyMatch = PUTTY_PRIVATE_KEY_RE.exec(privateKeyData.trim());
|
|
if (puttyMatch) {
|
|
const [, version, type] = puttyMatch;
|
|
return `Unsupported PuTTY PPK v${version} private key format (${type}). Convert the key to OpenSSH format with PuTTYgen, or use a PuTTY PPK v2 RSA/DSA key.`;
|
|
}
|
|
|
|
return "Unsupported private key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.";
|
|
}
|
|
|
|
const SECURITY_KEY_ERROR =
|
|
"This is a security key (FIDO2) key file. It only holds a handle, so the key itself must sign on your device. Load it with ssh-add and use SSH agent authentication in the desktop app.";
|
|
|
|
/** An OpenSSH private key file for a FIDO2 security key (sk-ssh-ed25519, sk-ecdsa). */
|
|
export function isSecurityKeyFile(privateKeyData: string): boolean {
|
|
const match = privateKeyData.match(
|
|
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/,
|
|
);
|
|
if (!match) return false;
|
|
try {
|
|
const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
|
|
return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test(
|
|
body.toString("latin1"),
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function preparePrivateKeyForSSH2(
|
|
privateKeyData: string,
|
|
passphrase?: string,
|
|
): Buffer {
|
|
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
|
|
let cleanKey = normalizePrivateKeyText(privateKeyData);
|
|
if (
|
|
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
|
|
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
|
|
) {
|
|
try {
|
|
const pem = createPrivateKey({ key: cleanKey, format: "pem", passphrase })
|
|
.export({ type: "pkcs8", format: "pem" })
|
|
.toString();
|
|
cleanKey = parsePrivateKey(pem, "pkcs8").toString("openssh");
|
|
} catch {
|
|
throw new Error(
|
|
cleanKey.startsWith("-----BEGIN ENCRYPTED PRIVATE KEY-----")
|
|
? "Could not decrypt PKCS#8 private key; check the passphrase"
|
|
: "Invalid or unsupported PKCS#8 private key",
|
|
);
|
|
}
|
|
const parsed = ssh2Utils.parseKey(cleanKey);
|
|
if (parsed instanceof Error) throw parsed;
|
|
}
|
|
const keyInfo = parseSSHKey(cleanKey, passphrase);
|
|
|
|
if (!keyInfo.success) {
|
|
throw new Error(keyInfo.error || getUnsupportedPrivateKeyError(cleanKey));
|
|
}
|
|
|
|
return Buffer.from(cleanKey, "utf8");
|
|
}
|
|
|
|
export function isPrivateKeyPassphraseError(error: unknown): boolean {
|
|
return /passphrase/i.test(getErrorMessage(error, ""));
|
|
}
|
|
|
|
export function parseSSHKey(
|
|
privateKeyData: string,
|
|
passphrase?: string,
|
|
): KeyInfo {
|
|
try {
|
|
const cleanKey = normalizePrivateKeyText(privateKeyData);
|
|
let keyType = "unknown";
|
|
let publicKey = "";
|
|
let useSSH2 = false;
|
|
|
|
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
|
try {
|
|
const parsedKey = ssh2Utils.parseKey(cleanKey, passphrase);
|
|
|
|
if (parsedKey instanceof Error) {
|
|
throw parsedKey;
|
|
} else {
|
|
if (parsedKey.type) {
|
|
keyType = parsedKey.type;
|
|
}
|
|
|
|
try {
|
|
const publicKeyBuffer = parsedKey.getPublicSSH();
|
|
|
|
if (Buffer.isBuffer(publicKeyBuffer)) {
|
|
const base64Data = publicKeyBuffer.toString("base64");
|
|
|
|
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}`;
|
|
}
|
|
} else {
|
|
publicKey = "";
|
|
}
|
|
} catch {
|
|
publicKey = "";
|
|
}
|
|
|
|
useSSH2 = true;
|
|
}
|
|
} catch {
|
|
// expected - ssh2 key parsing may fail
|
|
}
|
|
}
|
|
|
|
if (!useSSH2) {
|
|
keyType = detectKeyTypeFromContent(cleanKey);
|
|
|
|
publicKey = "";
|
|
}
|
|
|
|
return {
|
|
privateKey: cleanKey,
|
|
publicKey,
|
|
keyType,
|
|
success: keyType !== "unknown",
|
|
error:
|
|
keyType === "unknown"
|
|
? getUnsupportedPrivateKeyError(cleanKey)
|
|
: undefined,
|
|
};
|
|
} catch (error) {
|
|
try {
|
|
const cleanKey = normalizePrivateKeyText(privateKeyData);
|
|
const fallbackKeyType = detectKeyTypeFromContent(cleanKey);
|
|
if (fallbackKeyType !== "unknown") {
|
|
return {
|
|
privateKey: cleanKey,
|
|
publicKey: "",
|
|
keyType: fallbackKeyType,
|
|
success: true,
|
|
};
|
|
}
|
|
} catch {
|
|
// expected - fallback key type detection may fail
|
|
}
|
|
|
|
const parserError = getErrorMessage(error, "");
|
|
const isPuttyKey = PUTTY_PRIVATE_KEY_RE.test(privateKeyData.trim());
|
|
|
|
return {
|
|
privateKey: privateKeyData,
|
|
publicKey: "",
|
|
keyType: "unknown",
|
|
success: false,
|
|
error:
|
|
isPuttyKey && /unsupported|parse|format/i.test(parserError)
|
|
? getUnsupportedPrivateKeyError(privateKeyData)
|
|
: parserError || getUnsupportedPrivateKeyError(privateKeyData),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
|
try {
|
|
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
|
|
|
return {
|
|
publicKey: publicKeyData,
|
|
keyType,
|
|
success: keyType !== "unknown",
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
publicKey: publicKeyData,
|
|
keyType: "unknown",
|
|
success: false,
|
|
error: getErrorMessage(error, "Unknown error parsing public key"),
|
|
};
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function validateKeyPair(
|
|
privateKeyData: string,
|
|
publicKeyData: string,
|
|
passphrase?: string,
|
|
): KeyPairValidationResult {
|
|
try {
|
|
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
|
const publicKeyInfo = parsePublicKey(publicKeyData);
|
|
|
|
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}`,
|
|
};
|
|
}
|
|
|
|
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 (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
|
|
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
|
const providedPublicKey = publicKeyData.trim();
|
|
|
|
const generatedKeyParts = generatedPublicKey.split(" ");
|
|
const providedKeyParts = providedPublicKey.split(" ");
|
|
|
|
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
|
|
const generatedKeyData =
|
|
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
|
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
|
|
|
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",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
isValid: true,
|
|
privateKeyType: privateKeyInfo.keyType,
|
|
publicKeyType: publicKeyInfo.keyType,
|
|
error: "Unable to verify key pair match, but key types are compatible",
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
isValid: false,
|
|
privateKeyType: "unknown",
|
|
publicKeyType: "unknown",
|
|
error: getErrorMessage(error, "Unknown error during validation"),
|
|
};
|
|
}
|
|
}
|