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", "public_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "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_password", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
|
||||||
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
|
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
|
||||||
|
|||||||
@@ -256,6 +256,8 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
detectedKeyType: text("detected_key_type"),
|
detectedKeyType: text("detected_key_type"),
|
||||||
|
|
||||||
|
certPublicKey: text("cert_public_key", { length: 8192 }),
|
||||||
|
|
||||||
systemPassword: text("system_password"),
|
systemPassword: text("system_password"),
|
||||||
systemKey: text("system_key", { length: 16384 }),
|
systemKey: text("system_key", { length: 16384 }),
|
||||||
systemKeyPassword: text("system_key_password"),
|
systemKeyPassword: text("system_key_password"),
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ router.post(
|
|||||||
key,
|
key,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
keyType,
|
keyType,
|
||||||
|
certPublicKey,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||||
@@ -230,6 +231,8 @@ router.post(
|
|||||||
keyPassword: plainKeyPassword,
|
keyPassword: plainKeyPassword,
|
||||||
keyType: keyType || null,
|
keyType: keyType || null,
|
||||||
detectedKeyType: keyInfo?.keyType || null,
|
detectedKeyType: keyInfo?.keyType || null,
|
||||||
|
certPublicKey:
|
||||||
|
authType === "key" && certPublicKey ? certPublicKey.trim() : null,
|
||||||
usageCount: 0,
|
usageCount: 0,
|
||||||
lastUsed: null,
|
lastUsed: null,
|
||||||
};
|
};
|
||||||
@@ -436,6 +439,12 @@ router.get(
|
|||||||
if (credential.publicKey) {
|
if (credential.publicKey) {
|
||||||
output.publicKey = credential.publicKey;
|
output.publicKey = credential.publicKey;
|
||||||
}
|
}
|
||||||
|
if (credential.certPublicKey) {
|
||||||
|
output.certPublicKey = credential.certPublicKey;
|
||||||
|
}
|
||||||
|
if (credential.keyPassword) {
|
||||||
|
output.keyPassword = credential.keyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
res.json(output);
|
res.json(output);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -565,6 +574,9 @@ router.put(
|
|||||||
if (updateData.keyPassword !== undefined) {
|
if (updateData.keyPassword !== undefined) {
|
||||||
updateFields.keyPassword = updateData.keyPassword || null;
|
updateFields.keyPassword = updateData.keyPassword || null;
|
||||||
}
|
}
|
||||||
|
if (updateData.certPublicKey !== undefined) {
|
||||||
|
updateFields.certPublicKey = updateData.certPublicKey?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updateFields).length === 0) {
|
if (Object.keys(updateFields).length === 0) {
|
||||||
const existing = await SimpleDBOps.select(
|
const existing = await SimpleDBOps.select(
|
||||||
@@ -940,6 +952,7 @@ function formatCredentialOutput(
|
|||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
username: credential.username || null,
|
username: credential.username || null,
|
||||||
publicKey: credential.publicKey,
|
publicKey: credential.publicKey,
|
||||||
|
hasCertPublicKey: !!credential.certPublicKey,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
detectedKeyType: credential.detectedKeyType,
|
detectedKeyType: credential.detectedKeyType,
|
||||||
usageCount: credential.usageCount || 0,
|
usageCount: credential.usageCount || 0,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ResolvedCredentials {
|
|||||||
key?: Buffer;
|
key?: Buffer;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
authType?: string;
|
authType?: string;
|
||||||
|
certPublicKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostConfig {
|
interface HostConfig {
|
||||||
@@ -82,6 +83,7 @@ export class SSHAuthManager {
|
|||||||
: undefined,
|
: undefined,
|
||||||
keyPassword: (cred.keyPassword as string) || undefined,
|
keyPassword: (cred.keyPassword as string) || undefined,
|
||||||
authType: (cred.authType as string) || "none",
|
authType: (cred.authType as string) || "none",
|
||||||
|
certPublicKey: (cred.certPublicKey as string) || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -173,13 +173,17 @@ export async function resolveHostById(
|
|||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const cred = credentials[0] as Record<string, unknown>;
|
const cred = credentials[0] as Record<string, unknown>;
|
||||||
host.password = cred.password;
|
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.keyPassword = cred.keyPassword;
|
||||||
host.keyType = cred.keyType;
|
host.keyType = cred.keyType;
|
||||||
|
// CA-signed certificate for cert-based auth
|
||||||
|
(host as Record<string, unknown>).certPublicKey =
|
||||||
|
cred.certPublicKey || null;
|
||||||
if (!host.overrideCredentialUsername) {
|
if (!host.overrideCredentialUsername) {
|
||||||
host.username = cred.username;
|
host.username = cred.username;
|
||||||
}
|
}
|
||||||
host.authType = cred.key ? "key" : cred.password ? "password" : "none";
|
host.authType = host.key ? "key" : host.password ? "password" : "none";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sshLogger.warn("Failed to resolve credential for host", {
|
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
|
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
|
||||||
// the certificate onto the parsed key, wraps ECDSA signing to convert
|
// the certificate onto the parsed key, wraps ECDSA signing to convert
|
||||||
// DER → SSH wire format, and patches Protocol.authPK to use the base
|
// DER → SSH wire format, and patches Protocol.authPK to use the base
|
||||||
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
|
// 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 {
|
import type {
|
||||||
AnyAuthMethod,
|
AnyAuthMethod,
|
||||||
@@ -62,44 +65,39 @@ type OPKSSHNextAuthHandler = (
|
|||||||
authInfo: AuthenticationType | AnyAuthMethod | false,
|
authInfo: AuthenticationType | AnyAuthMethod | false,
|
||||||
) => void;
|
) => 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,
|
config: ConnectConfig,
|
||||||
client: Client,
|
client: Client,
|
||||||
token: OPKSSHToken,
|
privKey: ParsedPrivateKey,
|
||||||
username: string,
|
certStr: string,
|
||||||
): Promise<void> {
|
): 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
|
// 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];
|
const certType = certParts[0];
|
||||||
privKey.type = certType;
|
|
||||||
const certBlob = Buffer.from(certParts[1], "base64");
|
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(
|
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
|
||||||
(s) => String(s) === "Symbol(Public key SSH)",
|
(s) => String(s) === "Symbol(Public key SSH)",
|
||||||
);
|
);
|
||||||
if (!pubSSHSym) {
|
if (!pubSSHSym) {
|
||||||
throw new Error(
|
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;
|
privKey[pubSSHSym] = certBlob;
|
||||||
|
|
||||||
// Wrap sign() for ECDSA cert keys
|
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
|
||||||
if (privKey.type.startsWith("ecdsa-")) {
|
if (privKey.type.startsWith("ecdsa-")) {
|
||||||
const origSign = privKey.sign.bind(privKey);
|
const origSign = privKey.sign.bind(privKey);
|
||||||
privKey.sign = (data: Buffer, algo?: string) => {
|
privKey.sign = (data: Buffer, algo?: string) => {
|
||||||
@@ -145,7 +143,7 @@ export async function setupOPKSSHCertAuth(
|
|||||||
certAuthAttempted = true;
|
certAuthAttempted = true;
|
||||||
next({
|
next({
|
||||||
type: "publickey",
|
type: "publickey",
|
||||||
username,
|
username: (config as Record<string, unknown>).username as string,
|
||||||
key: privKey as unknown as PublicKeyAuthMethod["key"],
|
key: privKey as unknown as PublicKeyAuthMethod["key"],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -296,3 +294,75 @@ export async function setupOPKSSHCertAuth(
|
|||||||
return connectedClient;
|
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,
|
keyPassword,
|
||||||
keyType,
|
keyType,
|
||||||
authType,
|
authType,
|
||||||
|
certPublicKey: undefined as string | undefined,
|
||||||
};
|
};
|
||||||
const authMethodNotAvailable = false;
|
const authMethodNotAvailable = false;
|
||||||
if (id && userId && !password && !key) {
|
if (id && userId && !password && !key) {
|
||||||
@@ -1304,6 +1305,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||||
keyType: resolvedHost.keyType,
|
keyType: resolvedHost.keyType,
|
||||||
authType: resolvedHost.authType,
|
authType: resolvedHost.authType,
|
||||||
|
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||||
|
.certPublicKey as string | undefined,
|
||||||
};
|
};
|
||||||
sendLog(
|
sendLog(
|
||||||
"auth",
|
"auth",
|
||||||
@@ -1331,6 +1334,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||||
keyType: resolvedHost.keyType,
|
keyType: resolvedHost.keyType,
|
||||||
authType: resolvedHost.authType,
|
authType: resolvedHost.authType,
|
||||||
|
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||||
|
.certPublicKey as string | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2281,6 +2286,45 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (resolvedCredentials.password) {
|
if (resolvedCredentials.password) {
|
||||||
connectConfig.password = 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) {
|
} catch (keyError) {
|
||||||
sshLogger.error("SSH key format error: " + keyError.message);
|
sshLogger.error("SSH key format error: " + keyError.message);
|
||||||
ws.send(
|
ws.send(
|
||||||
|
|||||||
@@ -103,6 +103,27 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
|||||||
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||||
const content = publicKeyContent.trim();
|
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 ")) {
|
if (content.startsWith("ssh-rsa ")) {
|
||||||
return "ssh-rsa";
|
return "ssh-rsa";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ export interface Credential {
|
|||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
publicKey?: 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;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
@@ -256,6 +260,8 @@ export interface CredentialBackend {
|
|||||||
key: string;
|
key: string;
|
||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
|
/** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */
|
||||||
|
certPublicKey?: string;
|
||||||
keyPassword: string | null;
|
keyPassword: string | null;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
detectedKeyType: string;
|
detectedKeyType: string;
|
||||||
@@ -275,6 +281,8 @@ export interface CredentialData {
|
|||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
|
/** CA-signed certificate file content (e.g. id_ed25519-cert.pub) */
|
||||||
|
certPublicKey?: string | null;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -10,7 +10,17 @@
|
|||||||
"sshKey": "SSH Key",
|
"sshKey": "SSH Key",
|
||||||
"uploadPrivateKeyFile": "Upload Private Key File",
|
"uploadPrivateKeyFile": "Upload Private Key File",
|
||||||
"searchCredentials": "Search credentials...",
|
"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": {
|
"homepage": {
|
||||||
"failedToLoadAlerts": "Failed to load alerts",
|
"failedToLoadAlerts": "Failed to load alerts",
|
||||||
|
|||||||
@@ -4932,11 +4932,13 @@ function CredentialEditorView({
|
|||||||
value: credential?.value ?? "",
|
value: credential?.value ?? "",
|
||||||
publicKey: credential?.publicKey ?? "",
|
publicKey: credential?.publicKey ?? "",
|
||||||
passphrase: credential?.passphrase ?? "",
|
passphrase: credential?.passphrase ?? "",
|
||||||
|
certPublicKey: (credential as any)?.certPublicKey ?? "",
|
||||||
}));
|
}));
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [generatingKey, setGeneratingKey] = useState(false);
|
const [generatingKey, setGeneratingKey] = useState(false);
|
||||||
const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
|
const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
|
||||||
const credFileInputRef = useRef<HTMLInputElement>(null);
|
const credFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const certFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const setCredField = <K extends keyof typeof credForm>(
|
const setCredField = <K extends keyof typeof credForm>(
|
||||||
k: K,
|
k: K,
|
||||||
v: (typeof credForm)[K],
|
v: (typeof credForm)[K],
|
||||||
@@ -4961,6 +4963,8 @@ function CredentialEditorView({
|
|||||||
: credForm.value || null
|
: credForm.value || null
|
||||||
: null,
|
: null,
|
||||||
publicKey: credForm.type === "key" ? credForm.publicKey : null,
|
publicKey: credForm.type === "key" ? credForm.publicKey : null,
|
||||||
|
certPublicKey:
|
||||||
|
credForm.type === "key" ? credForm.certPublicKey || null : null,
|
||||||
keyPassword:
|
keyPassword:
|
||||||
credForm.type === "key"
|
credForm.type === "key"
|
||||||
? credForm.passphrase === "existing_key_password"
|
? credForm.passphrase === "existing_key_password"
|
||||||
@@ -5103,6 +5107,7 @@ function CredentialEditorView({
|
|||||||
type: m as any,
|
type: m as any,
|
||||||
value: "",
|
value: "",
|
||||||
publicKey: "",
|
publicKey: "",
|
||||||
|
certPublicKey: "",
|
||||||
passphrase: "",
|
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"
|
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 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -6922,6 +6973,8 @@ export function HostManager({
|
|||||||
passphrase: (full as any).hasKeyPassword
|
passphrase: (full as any).hasKeyPassword
|
||||||
? "existing_key_password"
|
? "existing_key_password"
|
||||||
: "",
|
: "",
|
||||||
|
certPublicKey:
|
||||||
|
(full as any).certPublicKey ?? "",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setEditingCredential(cred);
|
setEditingCredential(cred);
|
||||||
|
|||||||
Reference in New Issue
Block a user