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
+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);
}