feat: add CA-signed certificate authentication for SSH credentials

Support OpenSSH certificate-based authentication (-cert.pub files) in
the Credentials manager. When a CA-signed certificate is stored alongside
a private key, Termix uses it during SSH connection establishment so that
servers relying on certificate-based authorization work out of the box.

Changes:
- db/schema.ts: add cert_public_key column to ssh_credentials table
- db/index.ts: auto-migration via addColumnIfNotExists
- routes/credentials.ts: expose certPublicKey in create/update/get endpoints
- ssh/auth-manager.ts: include certPublicKey in ResolvedCredentials
- ssh/host-resolver.ts: propagate certPublicKey when resolving credentials
- ssh/opkssh-cert-auth.ts: refactor shared logic into _applyCertToConnection;
  export new setupCACertAuth() with optional passphrase support
- ssh/terminal.ts: call setupCACertAuth() when a certificate is present
- utils/ssh-key-utils.ts: detect all OpenSSH cert types in public key parser
- types/index.ts: add certPublicKey to Credential, CredentialBackend,
  CredentialData interfaces
- CredentialAuthenticationTab.tsx: new CA Certificate section with file
  upload, paste editor and automatic cert-type badge
- CredentialEditor.tsx: certPublicKey wired into form schema and submit
- CredentialViewer.tsx: show certificate status in security tab
- locales/en.json: add i18n strings for new UI elements
This commit is contained in:
Marc Reinke
2026-05-23 00:06:55 +02:00
committed by LukeGus
parent d3a2992898
commit 698a5648ec
11 changed files with 256 additions and 27 deletions
+2
View File
@@ -710,6 +710,8 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "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_password", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
+2
View File
@@ -256,6 +256,8 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
keyType: text("key_type"), keyType: text("key_type"),
detectedKeyType: text("detected_key_type"), detectedKeyType: text("detected_key_type"),
certPublicKey: text("cert_public_key", { length: 8192 }),
systemPassword: text("system_password"), systemPassword: text("system_password"),
systemKey: text("system_key", { length: 16384 }), systemKey: text("system_key", { length: 16384 }),
systemKeyPassword: text("system_key_password"), systemKeyPassword: text("system_key_password"),
@@ -147,6 +147,7 @@ router.post(
key, key,
keyPassword, keyPassword,
keyType, keyType,
certPublicKey,
} = req.body; } = req.body;
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) { if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
@@ -230,6 +231,8 @@ router.post(
keyPassword: plainKeyPassword, keyPassword: plainKeyPassword,
keyType: keyType || null, keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null, detectedKeyType: keyInfo?.keyType || null,
certPublicKey:
authType === "key" && certPublicKey ? certPublicKey.trim() : null,
usageCount: 0, usageCount: 0,
lastUsed: null, lastUsed: null,
}; };
@@ -436,6 +439,12 @@ router.get(
if (credential.publicKey) { if (credential.publicKey) {
output.publicKey = credential.publicKey; output.publicKey = credential.publicKey;
} }
if (credential.certPublicKey) {
output.certPublicKey = credential.certPublicKey;
}
if (credential.keyPassword) {
output.keyPassword = credential.keyPassword;
}
res.json(output); res.json(output);
} catch (err) { } catch (err) {
@@ -565,6 +574,9 @@ router.put(
if (updateData.keyPassword !== undefined) { if (updateData.keyPassword !== undefined) {
updateFields.keyPassword = updateData.keyPassword || null; updateFields.keyPassword = updateData.keyPassword || null;
} }
if (updateData.certPublicKey !== undefined) {
updateFields.certPublicKey = updateData.certPublicKey?.trim() || null;
}
if (Object.keys(updateFields).length === 0) { if (Object.keys(updateFields).length === 0) {
const existing = await SimpleDBOps.select( const existing = await SimpleDBOps.select(
@@ -940,6 +952,7 @@ function formatCredentialOutput(
authType: credential.authType, authType: credential.authType,
username: credential.username || null, username: credential.username || null,
publicKey: credential.publicKey, publicKey: credential.publicKey,
hasCertPublicKey: !!credential.certPublicKey,
keyType: credential.keyType, keyType: credential.keyType,
detectedKeyType: credential.detectedKeyType, detectedKeyType: credential.detectedKeyType,
usageCount: credential.usageCount || 0, usageCount: credential.usageCount || 0,
+2
View File
@@ -10,6 +10,7 @@ interface ResolvedCredentials {
key?: Buffer; key?: Buffer;
keyPassword?: string; keyPassword?: string;
authType?: string; authType?: string;
certPublicKey?: string;
} }
interface HostConfig { interface HostConfig {
@@ -82,6 +83,7 @@ export class SSHAuthManager {
: undefined, : undefined,
keyPassword: (cred.keyPassword as string) || undefined, keyPassword: (cred.keyPassword as string) || undefined,
authType: (cred.authType as string) || "none", authType: (cred.authType as string) || "none",
certPublicKey: (cred.certPublicKey as string) || undefined,
}; };
} }
} else { } else {
+6 -2
View File
@@ -173,13 +173,17 @@ export async function resolveHostById(
if (credentials.length > 0) { if (credentials.length > 0) {
const cred = credentials[0] as Record<string, unknown>; const cred = credentials[0] as Record<string, unknown>;
host.password = cred.password; 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.keyPassword = cred.keyPassword;
host.keyType = cred.keyType; host.keyType = cred.keyType;
// CA-signed certificate for cert-based auth
(host as Record<string, unknown>).certPublicKey =
cred.certPublicKey || null;
if (!host.overrideCredentialUsername) { if (!host.overrideCredentialUsername) {
host.username = cred.username; host.username = cred.username;
} }
host.authType = cred.key ? "key" : cred.password ? "password" : "none"; host.authType = host.key ? "key" : host.password ? "password" : "none";
} }
} catch (e) { } catch (e) {
sshLogger.warn("Failed to resolve credential for host", { sshLogger.warn("Failed to resolve credential for host", {
+94 -24
View File
@@ -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 // ssh2 doesn't support OpenSSH cert auth natively — this module grafts
// the certificate onto the parsed key, wraps ECDSA signing to convert // the certificate onto the parsed key, wraps ECDSA signing to convert
// DER → SSH wire format, and patches Protocol.authPK to use the base // DER → SSH wire format, and patches Protocol.authPK to use the base
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype). // 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 { import type {
AnyAuthMethod, AnyAuthMethod,
@@ -62,44 +65,39 @@ type OPKSSHNextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false, authInfo: AuthenticationType | AnyAuthMethod | false,
) => void; ) => 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, config: ConnectConfig,
client: Client, client: Client,
token: OPKSSHToken, privKey: ParsedPrivateKey,
username: string, certStr: string,
): Promise<void> { ): Promise<void> {
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 // 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 '<type> <base64>' string",
);
}
const certType = certParts[0]; const certType = certParts[0];
privKey.type = certType;
const certBlob = Buffer.from(certParts[1], "base64"); 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( const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
(s) => String(s) === "Symbol(Public key SSH)", (s) => String(s) === "Symbol(Public key SSH)",
); );
if (!pubSSHSym) { if (!pubSSHSym) {
throw new Error( 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; privKey[pubSSHSym] = certBlob;
// Wrap sign() for ECDSA cert keys // Wrap sign() for ECDSA cert keys (DER → SSH wire format)
if (privKey.type.startsWith("ecdsa-")) { if (privKey.type.startsWith("ecdsa-")) {
const origSign = privKey.sign.bind(privKey); const origSign = privKey.sign.bind(privKey);
privKey.sign = (data: Buffer, algo?: string) => { privKey.sign = (data: Buffer, algo?: string) => {
@@ -145,7 +143,7 @@ export async function setupOPKSSHCertAuth(
certAuthAttempted = true; certAuthAttempted = true;
next({ next({
type: "publickey", type: "publickey",
username, username: (config as Record<string, unknown>).username as string,
key: privKey as unknown as PublicKeyAuthMethod["key"], key: privKey as unknown as PublicKeyAuthMethod["key"],
}); });
} else { } else {
@@ -296,3 +294,75 @@ export async function setupOPKSSHCertAuth(
return connectedClient; 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<void> {
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<string, unknown>).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<void> {
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<string, unknown>).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);
}
+44
View File
@@ -1290,6 +1290,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
keyPassword, keyPassword,
keyType, keyType,
authType, authType,
certPublicKey: undefined as string | undefined,
}; };
const authMethodNotAvailable = false; const authMethodNotAvailable = false;
if (id && userId && !password && !key) { if (id && userId && !password && !key) {
@@ -1304,6 +1305,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
keyPassword: keyPassword || resolvedHost.keyPassword, keyPassword: keyPassword || resolvedHost.keyPassword,
keyType: resolvedHost.keyType, keyType: resolvedHost.keyType,
authType: resolvedHost.authType, authType: resolvedHost.authType,
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
.certPublicKey as string | undefined,
}; };
sendLog( sendLog(
"auth", "auth",
@@ -1331,6 +1334,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
keyPassword: keyPassword || resolvedHost.keyPassword, keyPassword: keyPassword || resolvedHost.keyPassword,
keyType: resolvedHost.keyType, keyType: resolvedHost.keyType,
authType: resolvedHost.authType, authType: resolvedHost.authType,
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
.certPublicKey as string | undefined,
}; };
} }
} catch (error) { } catch (error) {
@@ -2281,6 +2286,45 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (resolvedCredentials.password) { if (resolvedCredentials.password) {
connectConfig.password = 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) { } catch (keyError) {
sshLogger.error("SSH key format error: " + keyError.message); sshLogger.error("SSH key format error: " + keyError.message);
ws.send( ws.send(
+21
View File
@@ -103,6 +103,27 @@ function detectKeyTypeFromContent(keyContent: string): string {
function detectPublicKeyTypeFromContent(publicKeyContent: string): string { function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
const content = publicKeyContent.trim(); 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 ")) { if (content.startsWith("ssh-rsa ")) {
return "ssh-rsa"; return "ssh-rsa";
} }
+8
View File
@@ -235,6 +235,10 @@ export interface Credential {
password?: string; password?: string;
key?: string; key?: string;
publicKey?: 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; keyPassword?: string;
keyType?: string; keyType?: string;
usageCount: number; usageCount: number;
@@ -256,6 +260,8 @@ export interface CredentialBackend {
key: string; key: string;
privateKey?: string; privateKey?: string;
publicKey?: string; publicKey?: string;
/** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */
certPublicKey?: string;
keyPassword: string | null; keyPassword: string | null;
keyType?: string; keyType?: string;
detectedKeyType: string; detectedKeyType: string;
@@ -275,6 +281,8 @@ export interface CredentialData {
password?: string; password?: string;
key?: string; key?: string;
publicKey?: string; publicKey?: string;
/** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */
certPublicKey?: string | null;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
} }
+11 -1
View File
@@ -10,7 +10,17 @@
"sshKey": "SSH Key", "sshKey": "SSH Key",
"uploadPrivateKeyFile": "Upload Private Key File", "uploadPrivateKeyFile": "Upload Private Key File",
"searchCredentials": "Search credentials...", "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": { "homepage": {
"failedToLoadAlerts": "Failed to load alerts", "failedToLoadAlerts": "Failed to load alerts",
+53
View File
@@ -4932,11 +4932,13 @@ function CredentialEditorView({
value: credential?.value ?? "", value: credential?.value ?? "",
publicKey: credential?.publicKey ?? "", publicKey: credential?.publicKey ?? "",
passphrase: credential?.passphrase ?? "", passphrase: credential?.passphrase ?? "",
certPublicKey: (credential as any)?.certPublicKey ?? "",
})); }));
const { t } = useTranslation(); const { t } = useTranslation();
const [generatingKey, setGeneratingKey] = useState(false); const [generatingKey, setGeneratingKey] = useState(false);
const [generatingPublicKey, setGeneratingPublicKey] = useState(false); const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
const credFileInputRef = useRef<HTMLInputElement>(null); const credFileInputRef = useRef<HTMLInputElement>(null);
const certFileInputRef = useRef<HTMLInputElement>(null);
const setCredField = <K extends keyof typeof credForm>( const setCredField = <K extends keyof typeof credForm>(
k: K, k: K,
v: (typeof credForm)[K], v: (typeof credForm)[K],
@@ -4961,6 +4963,8 @@ function CredentialEditorView({
: credForm.value || null : credForm.value || null
: null, : null,
publicKey: credForm.type === "key" ? credForm.publicKey : null, publicKey: credForm.type === "key" ? credForm.publicKey : null,
certPublicKey:
credForm.type === "key" ? credForm.certPublicKey || null : null,
keyPassword: keyPassword:
credForm.type === "key" credForm.type === "key"
? credForm.passphrase === "existing_key_password" ? credForm.passphrase === "existing_key_password"
@@ -5103,6 +5107,7 @@ function CredentialEditorView({
type: m as any, type: m as any,
value: "", value: "",
publicKey: "", publicKey: "",
certPublicKey: "",
passphrase: "", 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" 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"
/> />
</div> </div>
<div className="flex flex-col gap-1.5 p-3 border border-border bg-muted/20">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.caCertificate")}
</label>
{credForm.certPublicKey && (
<button
type="button"
className="text-[10px] text-destructive hover:text-destructive/80"
onClick={() => setCredField("certPublicKey", "")}
>
{t("credentials.clearCert")}
</button>
)}
</div>
<p className="text-[10px] text-muted-foreground">
{t("credentials.caCertificateDescription")}
</p>
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1 self-start"
onClick={() => certFileInputRef.current?.click()}
>
<Upload className="size-3" /> {t("credentials.uploadCertFile")}
</button>
<input
ref={certFileInputRef}
type="file"
accept=".pub,.txt"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
setCredField("certPublicKey", text.trim());
e.target.value = "";
}}
/>
<textarea
placeholder={t("credentials.pasteOrUploadCert")}
rows={2}
value={credForm.certPublicKey}
onChange={(e) => setCredField("certPublicKey", e.target.value)}
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"
/>
</div>
</div> </div>
)} )}
</div> </div>
@@ -6922,6 +6973,8 @@ export function HostManager({
passphrase: (full as any).hasKeyPassword passphrase: (full as any).hasKeyPassword
? "existing_key_password" ? "existing_key_password"
: "", : "",
certPublicKey:
(full as any).certPublicKey ?? "",
}); });
} catch { } catch {
setEditingCredential(cred); setEditingCredential(cred);