mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { getErrorMessage } from "./error-message.js";
|
|
import ssh2Pkg from "ssh2";
|
|
const ssh2Utils = ssh2Pkg.utils;
|
|
|
|
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;
|
|
|
|
export 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.";
|
|
}
|
|
|
|
export function preparePrivateKeyForSSH2(
|
|
privateKeyData: string,
|
|
passphrase?: string,
|
|
): Buffer {
|
|
const cleanKey = normalizePrivateKeyText(privateKeyData);
|
|
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"),
|
|
};
|
|
}
|
|
}
|