diff --git a/Casks/termix.rb b/Casks/termix.rb index e72035ad..c3d6a715 100644 --- a/Casks/termix.rb +++ b/Casks/termix.rb @@ -1,6 +1,6 @@ cask "termix" do version "2.2.1" - sha256 "d0eedef5d32a4aa68c1ba27c67bbc9d1d281310a3b27d2589f10134b86e81ba2" + sha256 "7fe7bde53df568c3212a212e6dc0ca4b77c24a3d3d9b740dddadcd765330e93d" url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg" name "Termix" diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 19bb0ea4..d39dadad 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -710,6 +710,8 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); + addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT"); + addColumnIfNotExists("ssh_credentials", "system_password", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index aea9bbdc..b96a2441 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -256,6 +256,8 @@ export const sshCredentials = sqliteTable("ssh_credentials", { keyType: text("key_type"), detectedKeyType: text("detected_key_type"), + certPublicKey: text("cert_public_key", { length: 8192 }), + systemPassword: text("system_password"), systemKey: text("system_key", { length: 16384 }), systemKeyPassword: text("system_key_password"), diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 9e177f01..6f922175 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -147,6 +147,7 @@ router.post( key, keyPassword, keyType, + certPublicKey, } = req.body; if (!isNonEmptyString(userId) || !isNonEmptyString(name)) { @@ -230,6 +231,8 @@ router.post( keyPassword: plainKeyPassword, keyType: keyType || null, detectedKeyType: keyInfo?.keyType || null, + certPublicKey: + authType === "key" && certPublicKey ? certPublicKey.trim() : null, usageCount: 0, lastUsed: null, }; @@ -436,6 +439,12 @@ router.get( if (credential.publicKey) { output.publicKey = credential.publicKey; } + if (credential.certPublicKey) { + output.certPublicKey = credential.certPublicKey; + } + if (credential.keyPassword) { + output.keyPassword = credential.keyPassword; + } res.json(output); } catch (err) { @@ -565,6 +574,9 @@ router.put( if (updateData.keyPassword !== undefined) { updateFields.keyPassword = updateData.keyPassword || null; } + if (updateData.certPublicKey !== undefined) { + updateFields.certPublicKey = updateData.certPublicKey?.trim() || null; + } if (Object.keys(updateFields).length === 0) { const existing = await SimpleDBOps.select( @@ -940,6 +952,7 @@ function formatCredentialOutput( authType: credential.authType, username: credential.username || null, publicKey: credential.publicKey, + hasCertPublicKey: !!credential.certPublicKey, keyType: credential.keyType, detectedKeyType: credential.detectedKeyType, usageCount: credential.usageCount || 0, diff --git a/src/backend/ssh/auth-manager.ts b/src/backend/ssh/auth-manager.ts index 62b7745a..e79e2db8 100644 --- a/src/backend/ssh/auth-manager.ts +++ b/src/backend/ssh/auth-manager.ts @@ -10,6 +10,7 @@ interface ResolvedCredentials { key?: Buffer; keyPassword?: string; authType?: string; + certPublicKey?: string; } interface HostConfig { @@ -82,6 +83,7 @@ export class SSHAuthManager { : undefined, keyPassword: (cred.keyPassword as string) || undefined, authType: (cred.authType as string) || "none", + certPublicKey: (cred.certPublicKey as string) || undefined, }; } } else { diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index 110969b2..b741794a 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -173,13 +173,17 @@ export async function resolveHostById( if (credentials.length > 0) { const cred = credentials[0] as Record; host.password = cred.password; - host.key = cred.key; + // Prefer the normalised private key; fall back to raw key field + host.key = (cred.privateKey || cred.key) as string | null; host.keyPassword = cred.keyPassword; host.keyType = cred.keyType; + // CA-signed certificate for cert-based auth + (host as Record).certPublicKey = + cred.certPublicKey || null; if (!host.overrideCredentialUsername) { host.username = cred.username; } - host.authType = cred.key ? "key" : cred.password ? "password" : "none"; + host.authType = host.key ? "key" : host.password ? "password" : "none"; } } catch (e) { sshLogger.warn("Failed to resolve credential for host", { diff --git a/src/backend/ssh/opkssh-cert-auth.ts b/src/backend/ssh/opkssh-cert-auth.ts index 33bdcb82..6b8eb65b 100644 --- a/src/backend/ssh/opkssh-cert-auth.ts +++ b/src/backend/ssh/opkssh-cert-auth.ts @@ -1,8 +1,11 @@ -// OPKSSH certificate authentication workarounds for ssh2. +// SSH certificate authentication workarounds for ssh2. // ssh2 doesn't support OpenSSH cert auth natively — this module grafts // the certificate onto the parsed key, wraps ECDSA signing to convert // DER → SSH wire format, and patches Protocol.authPK to use the base // algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype). +// +// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase) +// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported) import type { AnyAuthMethod, @@ -62,44 +65,39 @@ type OPKSSHNextAuthHandler = ( authInfo: AuthenticationType | AnyAuthMethod | false, ) => void; -export async function setupOPKSSHCertAuth( +// ── Internal implementation ────────────────────────────────────────────────── +// Grafts an OpenSSH certificate onto an already-parsed private key object and +// patches the ssh2 client so that certificate-based publickey auth succeeds. + +async function _applyCertToConnection( config: ConnectConfig, client: Client, - token: OPKSSHToken, - username: string, + privKey: ParsedPrivateKey, + certStr: string, ): Promise { - const { createRequire } = await import("node:module"); - const esmRequire = createRequire(import.meta.url); - const { - utils: { parseKey }, - } = esmRequire("ssh2"); - - const parsed = parseKey(Buffer.from(token.privateKey)); - if (parsed instanceof Error || !parsed) { - throw new Error("Failed to parse OPKSSH private key"); - } - const privKey = ( - Array.isArray(parsed) ? parsed[0] : parsed - ) as ParsedPrivateKey; - // Extract cert type and blob from the stored certificate - const certParts = token.sshCert.trim().split(/\s+/); + const certParts = certStr.trim().split(/\s+/); + if (certParts.length < 2) { + throw new Error( + "Invalid certificate format: expected ' ' string", + ); + } const certType = certParts[0]; - privKey.type = certType; const certBlob = Buffer.from(certParts[1], "base64"); - // Replace the internal public SSH blob with the full certificate + // Graft cert type and blob onto the parsed private key + privKey.type = certType; const pubSSHSym = Object.getOwnPropertySymbols(privKey).find( (s) => String(s) === "Symbol(Public key SSH)", ); if (!pubSSHSym) { throw new Error( - "Cannot find public SSH symbol on parsed key, ssh2 internals may have changed", + "Cannot find public SSH symbol on parsed key; ssh2 internals may have changed", ); } privKey[pubSSHSym] = certBlob; - // Wrap sign() for ECDSA cert keys + // Wrap sign() for ECDSA cert keys (DER → SSH wire format) if (privKey.type.startsWith("ecdsa-")) { const origSign = privKey.sign.bind(privKey); privKey.sign = (data: Buffer, algo?: string) => { @@ -145,7 +143,7 @@ export async function setupOPKSSHCertAuth( certAuthAttempted = true; next({ type: "publickey", - username, + username: (config as Record).username as string, key: privKey as unknown as PublicKeyAuthMethod["key"], }); } else { @@ -296,3 +294,75 @@ export async function setupOPKSSHCertAuth( return connectedClient; }; } + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Set up OPKSSH certificate authentication on an ssh2 Client. + * The OPKSSH private key is assumed to be unencrypted (no passphrase). + */ +export async function setupOPKSSHCertAuth( + config: ConnectConfig, + client: Client, + token: OPKSSHToken, + username: string, +): Promise { + const { createRequire } = await import("node:module"); + const esmRequire = createRequire(import.meta.url); + const { + utils: { parseKey }, + } = esmRequire("ssh2"); + + // Store username in config so the authHandler can access it + (config as Record).username = username; + + const parsed = parseKey(Buffer.from(token.privateKey)); + if (parsed instanceof Error || !parsed) { + throw new Error("Failed to parse OPKSSH private key"); + } + const privKey = ( + Array.isArray(parsed) ? parsed[0] : parsed + ) as ParsedPrivateKey; + + await _applyCertToConnection(config, client, privKey, token.sshCert); +} + +/** + * Set up CA-signed certificate authentication on an ssh2 Client. + * Supports passphrase-protected private keys. + * The cert content is the full text of the -cert.pub file + * (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA..."). + */ +export async function setupCACertAuth( + config: ConnectConfig, + client: Client, + privateKey: Buffer | string, + certPublicKey: string, + username: string, + passphrase?: string, +): Promise { + const { createRequire } = await import("node:module"); + const esmRequire = createRequire(import.meta.url); + const { + utils: { parseKey }, + } = esmRequire("ssh2"); + + // Store username in config so the authHandler can access it + (config as Record).username = username; + + const keyBuf = Buffer.isBuffer(privateKey) + ? privateKey + : Buffer.from(privateKey, "utf8"); + + const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf); + + if (parsed instanceof Error || !parsed) { + const errMsg = parsed instanceof Error ? parsed.message : "unknown error"; + throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`); + } + const privKey = ( + Array.isArray(parsed) ? parsed[0] : parsed + ) as ParsedPrivateKey; + + await _applyCertToConnection(config, client, privKey, certPublicKey); +} diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 0115df18..65b1705a 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1290,6 +1290,7 @@ wss.on("connection", async (ws: WebSocket, req) => { keyPassword, keyType, authType, + certPublicKey: undefined as string | undefined, }; const authMethodNotAvailable = false; if (id && userId && !password && !key) { @@ -1304,6 +1305,8 @@ wss.on("connection", async (ws: WebSocket, req) => { keyPassword: keyPassword || resolvedHost.keyPassword, keyType: resolvedHost.keyType, authType: resolvedHost.authType, + certPublicKey: (resolvedHost as unknown as Record) + .certPublicKey as string | undefined, }; sendLog( "auth", @@ -1331,6 +1334,8 @@ wss.on("connection", async (ws: WebSocket, req) => { keyPassword: keyPassword || resolvedHost.keyPassword, keyType: resolvedHost.keyType, authType: resolvedHost.authType, + certPublicKey: (resolvedHost as unknown as Record) + .certPublicKey as string | undefined, }; } } catch (error) { @@ -2281,6 +2286,45 @@ wss.on("connection", async (ws: WebSocket, req) => { if (resolvedCredentials.password) { connectConfig.password = resolvedCredentials.password; } + + // Apply CA-signed certificate if one is stored in the credential + if ( + resolvedCredentials.certPublicKey && + resolvedCredentials.certPublicKey.trim() + ) { + try { + const { setupCACertAuth } = await import("./opkssh-cert-auth.js"); + await setupCACertAuth( + connectConfig, + sshConn, + connectConfig.privateKey as Buffer, + resolvedCredentials.certPublicKey, + username, + resolvedCredentials.keyPassword, + ); + sendLog("auth", "info", "CA certificate authentication configured"); + sshLogger.info("CA cert auth configured", { + operation: "ca_cert_auth_configured", + userId, + hostId: id, + }); + } catch (certError) { + sendLog( + "auth", + "warning", + "CA certificate setup failed – falling back to key-only auth", + ); + sshLogger.warn("CA cert auth setup failed", { + operation: "ca_cert_auth_setup_failed", + userId, + hostId: id, + error: + certError instanceof Error + ? certError.message + : String(certError), + }); + } + } } catch (keyError) { sshLogger.error("SSH key format error: " + keyError.message); ws.send( diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index a4c9efc4..3addc2d8 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -103,6 +103,27 @@ function detectKeyTypeFromContent(keyContent: string): string { 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"; } diff --git a/src/types/index.ts b/src/types/index.ts index c5acd6d7..a8910122 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -235,6 +235,10 @@ export interface Credential { password?: string; key?: string; publicKey?: string; + /** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */ + certPublicKey?: string; + /** True when a cert is stored but certPublicKey content is redacted in list responses */ + hasCertPublicKey?: boolean; keyPassword?: string; keyType?: string; usageCount: number; @@ -256,6 +260,8 @@ export interface CredentialBackend { key: string; privateKey?: string; publicKey?: string; + /** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */ + certPublicKey?: string; keyPassword: string | null; keyType?: string; detectedKeyType: string; @@ -275,6 +281,8 @@ export interface CredentialData { password?: string; key?: string; publicKey?: string; + /** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */ + certPublicKey?: string | null; keyPassword?: string; keyType?: string; } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 8b8ba430..65e772a0 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -10,7 +10,17 @@ "sshKey": "SSH Key", "uploadPrivateKeyFile": "Upload Private Key File", "searchCredentials": "Search credentials...", - "addCredential": "Add Credential" + "addCredential": "Add Credential", + "caCertificate": "CA Certificate (-cert.pub)", + "caCertificateDescription": "Optional: Upload or paste the CA-signed certificate file (e.g. id_ed25519-cert.pub). Required when your SSH server uses certificate-based authorization.", + "uploadCertFile": "Upload -cert.pub File", + "clearCert": "Clear", + "certLoaded": "Certificate loaded", + "certPublicKeyLabel": "CA Certificate", + "certTypeLabel": "Certificate type", + "pasteOrUploadCert": "Paste or upload a -cert.pub certificate...", + "hasCaCert": "Has CA Certificate", + "noCaCert": "No CA Certificate" }, "homepage": { "failedToLoadAlerts": "Failed to load alerts", diff --git a/src/ui/sidebar/HostManager.tsx b/src/ui/sidebar/HostManager.tsx index 61c73705..23607c0e 100644 --- a/src/ui/sidebar/HostManager.tsx +++ b/src/ui/sidebar/HostManager.tsx @@ -4932,11 +4932,13 @@ function CredentialEditorView({ value: credential?.value ?? "", publicKey: credential?.publicKey ?? "", passphrase: credential?.passphrase ?? "", + certPublicKey: (credential as any)?.certPublicKey ?? "", })); const { t } = useTranslation(); const [generatingKey, setGeneratingKey] = useState(false); const [generatingPublicKey, setGeneratingPublicKey] = useState(false); const credFileInputRef = useRef(null); + const certFileInputRef = useRef(null); const setCredField = ( k: K, v: (typeof credForm)[K], @@ -4961,6 +4963,8 @@ function CredentialEditorView({ : credForm.value || null : null, publicKey: credForm.type === "key" ? credForm.publicKey : null, + certPublicKey: + credForm.type === "key" ? credForm.certPublicKey || null : null, keyPassword: credForm.type === "key" ? credForm.passphrase === "existing_key_password" @@ -5103,6 +5107,7 @@ function CredentialEditorView({ type: m as any, value: "", publicKey: "", + certPublicKey: "", passphrase: "", })) } @@ -5329,6 +5334,52 @@ function CredentialEditorView({ className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono" /> +
+
+ + {credForm.certPublicKey && ( + + )} +
+

+ {t("credentials.caCertificateDescription")} +

+ + { + const file = e.target.files?.[0]; + if (!file) return; + const text = await file.text(); + setCredField("certPublicKey", text.trim()); + e.target.value = ""; + }} + /> +