mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user