From be18d2c5029a57ee7ccf830cbc2b2544613fc408 Mon Sep 17 00:00:00 2001 From: Marc Reinke Date: Sat, 23 May 2026 00:06:55 +0200 Subject: [PATCH] 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 --- src/backend/database/db/index.ts | 2 + src/backend/database/db/schema.ts | 2 + src/backend/database/routes/credentials.ts | 10 ++ src/backend/ssh/auth-manager.ts | 2 + src/backend/ssh/host-resolver.ts | 8 +- src/backend/ssh/opkssh-cert-auth.ts | 118 ++++++++++++---- src/backend/ssh/terminal.ts | 44 ++++++ src/backend/utils/ssh-key-utils.ts | 21 +++ src/locales/en.json | 12 +- src/types/index.ts | 8 ++ .../credentials/CredentialEditor.tsx | 7 + .../credentials/CredentialViewer.tsx | 23 ++++ .../tabs/CredentialAuthenticationTab.tsx | 130 ++++++++++++++++++ 13 files changed, 360 insertions(+), 27 deletions(-) diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 5acce909f..bcc3825eb 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 0e0d89d97..c634f2969 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -234,6 +234,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 58e13c97f..c665d02b4 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, }; @@ -440,6 +443,9 @@ router.get( if (credential.publicKey) { output.publicKey = credential.publicKey; } + if (credential.certPublicKey) { + output.certPublicKey = credential.certPublicKey; + } if (credential.keyPassword) { output.keyPassword = credential.keyPassword; } @@ -572,6 +578,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( @@ -947,6 +956,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 46a506548..903ea215b 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 { @@ -81,6 +82,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 94d2f7933..148027a1d 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -126,13 +126,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 33bdcb82f..6b8eb65b9 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 90fa717d3..0a1b41161 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1245,6 +1245,7 @@ wss.on("connection", async (ws: WebSocket, req) => { keyPassword, keyType, authType, + certPublicKey: undefined as string | undefined, }; const authMethodNotAvailable = false; if (id && userId && !password && !key) { @@ -1259,6 +1260,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", @@ -1286,6 +1289,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) { @@ -2239,6 +2244,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 a4c9efc42..3addc2d89 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/locales/en.json b/src/locales/en.json index aa4b82e22..6100e9fa9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -189,7 +189,17 @@ "failedToDeployKey": "Failed to deploy SSH key", "clickToRenameFolder": "Click to rename folder", "renameFolder": "Rename folder", - "idLabel": "ID:" + "idLabel": "ID:", + "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" }, "quickConnect": { "title": "Quick Connect", diff --git a/src/types/index.ts b/src/types/index.ts index f39913e53..368a44469 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -201,6 +201,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; @@ -222,6 +226,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; @@ -241,6 +247,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/desktop/apps/host-manager/credentials/CredentialEditor.tsx b/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx index a90349080..2e0990383 100644 --- a/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx +++ b/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx @@ -132,6 +132,8 @@ export function CredentialEditor({ password: z.string().optional(), key: z.any().optional().nullable(), publicKey: z.string().optional(), + /** CA-signed certificate file content (id_ed25519-cert.pub) */ + certPublicKey: z.string().optional(), keyPassword: z.string().optional(), keyType: z .enum([ @@ -184,6 +186,7 @@ export function CredentialEditor({ password: "", key: null, publicKey: "", + certPublicKey: "", keyPassword: "", keyType: "auto", }, @@ -215,6 +218,7 @@ export function CredentialEditor({ if (authTab === "password") { form.setValue("key", null, { shouldValidate: true }); form.setValue("publicKey", "", { shouldValidate: true }); + form.setValue("certPublicKey", "", { shouldValidate: true }); form.setValue("keyPassword", "", { shouldValidate: true }); form.setValue("keyType", "auto", { shouldValidate: true }); } else if (authTab === "key") { @@ -253,6 +257,7 @@ export function CredentialEditor({ } else if (defaultAuthType === "key") { formData.key = fullCredentialDetails.key || ""; formData.publicKey = fullCredentialDetails.publicKey || ""; + formData.certPublicKey = fullCredentialDetails.certPublicKey || ""; formData.keyPassword = fullCredentialDetails.keyPassword || ""; formData.keyType = (fullCredentialDetails.keyType as string) || ("auto" as const); @@ -273,6 +278,7 @@ export function CredentialEditor({ password: "", key: null, publicKey: "", + certPublicKey: "", keyPassword: "", keyType: "auto", }); @@ -404,6 +410,7 @@ export function CredentialEditor({ } else if (data.authType === "key") { submitData.key = data.key; submitData.publicKey = data.publicKey; + submitData.certPublicKey = data.certPublicKey || null; submitData.keyPassword = data.keyPassword; submitData.keyType = data.keyType; } diff --git a/src/ui/desktop/apps/host-manager/credentials/CredentialViewer.tsx b/src/ui/desktop/apps/host-manager/credentials/CredentialViewer.tsx index 56032cd01..00fce9201 100644 --- a/src/ui/desktop/apps/host-manager/credentials/CredentialViewer.tsx +++ b/src/ui/desktop/apps/host-manager/credentials/CredentialViewer.tsx @@ -444,6 +444,29 @@ const CredentialViewer: React.FC = ({ "keyPassword", t("credentials.keyPassphrase"), )} + + {/* CA Certificate indicator */} +
+ +
+
+ {t("credentials.caCertificate")} +
+ {credentialDetails.hasCertPublicKey || + credentialDetails.certPublicKey ? ( + + {t("credentials.certLoaded")} + + ) : ( + + {t("credentials.noCaCert")} + + )} +
+
)} diff --git a/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx b/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx index 7bb98fbd6..3ea400382 100644 --- a/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx +++ b/src/ui/desktop/apps/host-manager/credentials/tabs/CredentialAuthenticationTab.tsx @@ -6,6 +6,7 @@ import { } from "@/components/ui/form.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { Button } from "@/components/ui/button.tsx"; +import { Badge } from "@/components/ui/badge.tsx"; import { Tabs, TabsContent, @@ -24,6 +25,20 @@ import { } from "@/ui/main-axios.ts"; import type { CredentialAuthenticationTabProps } from "./shared/tab-types"; +/** Map an OpenSSH cert type string to a human-readable label. */ +function getCertTypeName(certContent: string): string { + const firstWord = certContent.trim().split(/\s+/)[0] ?? ""; + const map: Record = { + "ssh-ed25519-cert-v01@openssh.com": "Ed25519 Certificate", + "ssh-rsa-cert-v01@openssh.com": "RSA Certificate", + "ecdsa-sha2-nistp256-cert-v01@openssh.com": "ECDSA P-256 Certificate", + "ecdsa-sha2-nistp384-cert-v01@openssh.com": "ECDSA P-384 Certificate", + "ecdsa-sha2-nistp521-cert-v01@openssh.com": "ECDSA P-521 Certificate", + "sk-ssh-ed25519-cert-v01@openssh.com": "SK-Ed25519 Certificate", + }; + return map[firstWord] ?? (firstWord.includes("-cert-") ? firstWord : ""); +} + export function CredentialAuthenticationTab({ form, authTab, @@ -489,6 +504,121 @@ export function CredentialAuthenticationTab({ )} /> + {/* CA Certificate (-cert.pub) */} +
+ + {t("credentials.caCertificate")} + +

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

+ { + const certTypeName = field.value + ? getCertTypeName(field.value) + : ""; + return ( + +
+
+ { + const file = e.target.files?.[0]; + if (file) { + try { + const content = await file.text(); + field.onChange(content.trim()); + } catch { + toast.error( + t("credentials.failedToGeneratePublicKey"), + ); + } + } + // reset so the same file can be re-selected + e.target.value = ""; + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+ {field.value && ( + + )} +
+ + field.onChange(value)} + placeholder={t("credentials.pasteOrUploadCert")} + theme={editorTheme} + className="border border-input rounded-md overflow-hidden" + minHeight="60px" + basicSetup={{ + lineNumbers: false, + foldGutter: false, + dropCursor: false, + allowMultipleSelections: false, + highlightSelectionMatches: false, + searchKeymap: false, + scrollPastEnd: false, + }} + extensions={[ + EditorView.theme({ + ".cm-scroller": { + overflow: "auto", + scrollbarWidth: "thin", + scrollbarColor: + "var(--scrollbar-thumb) var(--scrollbar-track)", + }, + }), + ]} + /> + + {field.value && certTypeName && ( +
+ + {t("credentials.certTypeLabel")}: + + + {certTypeName} + +
+ )} + {field.value && !certTypeName && ( +

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

+ )} +
+ ); + }} + /> +
+