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", "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");
+2
View File
@@ -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"),
@@ -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,
+2
View File
@@ -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 {
+6 -2
View File
@@ -173,13 +173,17 @@ export async function resolveHostById(
if (credentials.length > 0) {
const cred = credentials[0] as Record<string, unknown>;
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<string, unknown>).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", {
+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
// 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<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
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];
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<string, unknown>).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<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,
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<string, unknown>)
.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<string, unknown>)
.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(
+21
View File
@@ -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";
}
+8
View File
@@ -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;
}
+11 -1
View File
@@ -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",
+53
View File
@@ -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<HTMLInputElement>(null);
const certFileInputRef = useRef<HTMLInputElement>(null);
const setCredField = <K extends keyof typeof credForm>(
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"
/>
</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>
@@ -6922,6 +6973,8 @@ export function HostManager({
passphrase: (full as any).hasKeyPassword
? "existing_key_password"
: "",
certPublicKey:
(full as any).certPublicKey ?? "",
});
} catch {
setEditingCredential(cred);