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