Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/backend/database/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions src/backend/database/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
10 changes: 10 additions & 0 deletions src/backend/database/routes/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ router.post(
key,
keyPassword,
keyType,
certPublicKey,
} = req.body;

if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/backend/ssh/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface ResolvedCredentials {
key?: Buffer;
keyPassword?: string;
authType?: string;
certPublicKey?: string;
}

interface HostConfig {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions src/backend/ssh/host-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,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", {
Expand Down
118 changes: 94 additions & 24 deletions src/backend/ssh/opkssh-cert-auth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
44 changes: 44 additions & 0 deletions src/backend/ssh/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<string, unknown>)
.certPublicKey as string | undefined,
};
sendLog(
"auth",
Expand Down Expand Up @@ -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<string, unknown>)
.certPublicKey as string | undefined,
};
}
} catch (error) {
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions src/backend/utils/ssh-key-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
12 changes: 11 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading