diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 48de851d..9543e0b4 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -25,7 +25,10 @@ import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; -import { pickResolvedUsername } from "../../ssh/credential-username.js"; +import { + pickResolvedPassword, + pickResolvedUsername, +} from "../../ssh/credential-username.js"; import { isNonEmptyString, isValidPort, @@ -447,7 +450,12 @@ router.post( sshDataObj.key = key || null; sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; - sshDataObj.password = null; + sshDataObj.password = password || null; + } else if (effectiveAuthType === "credential") { + sshDataObj.password = password || null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else if (effectiveAuthType === "agent") { sshDataObj.password = null; sshDataObj.key = null; @@ -656,7 +664,7 @@ router.post( const cred = credentials[0]; - resolvedPassword = cred.password as string | undefined; + resolvedPassword = pickResolvedPassword(password, cred.password); resolvedKey = cred.privateKey as string | undefined; resolvedKeyPassword = cred.keyPassword as string | undefined; resolvedKeyType = cred.keyType as string | undefined; @@ -1043,7 +1051,12 @@ router.put( if (keyType) { sshDataObj.keyType = keyType; } - sshDataObj.password = null; + sshDataObj.password = password || null; + } else if (effectiveAuthType === "credential") { + sshDataObj.password = password || null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else if (effectiveAuthType === "agent") { sshDataObj.password = null; sshDataObj.key = null; @@ -2489,7 +2502,7 @@ async function resolveHostCredentials( const credential = credentials[0]; const resolvedHost: Record = { ...host, - password: credential.password, + password: pickResolvedPassword(host.password, credential.password), key: credential.key, keyPassword: credential.keyPassword, keyType: credential.keyType, diff --git a/src/backend/ssh/credential-username.test.ts b/src/backend/ssh/credential-username.test.ts index fc6a56e4..992de795 100644 --- a/src/backend/ssh/credential-username.test.ts +++ b/src/backend/ssh/credential-username.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { pickResolvedUsername, + pickResolvedPassword, expandOidcUsername, } from "./credential-username.js"; @@ -30,6 +31,27 @@ describe("pickResolvedUsername", () => { }); }); +describe("pickResolvedPassword", () => { + it("keeps the host-specific password ahead of the credential password", () => { + expect(pickResolvedPassword("host-pass", "credential-pass")).toBe( + "host-pass", + ); + }); + + it("falls back to the credential password when the host has none", () => { + expect(pickResolvedPassword("", "credential-pass")).toBe("credential-pass"); + expect(pickResolvedPassword(undefined, "credential-pass")).toBe( + "credential-pass", + ); + }); + + it("treats whitespace-only passwords as empty", () => { + expect(pickResolvedPassword(" ", "credential-pass")).toBe( + "credential-pass", + ); + }); +}); + describe("expandOidcUsername", () => { beforeEach(() => { vi.resetModules(); diff --git a/src/backend/ssh/credential-username.ts b/src/backend/ssh/credential-username.ts index 26f6d982..ba100396 100644 --- a/src/backend/ssh/credential-username.ts +++ b/src/backend/ssh/credential-username.ts @@ -21,6 +21,19 @@ export function pickResolvedUsername( return cred; } +/** + * A host can keep a per-host password while using a shared key credential. + * Prefer that host-specific value and fall back to the credential password. + */ +export function pickResolvedPassword( + hostPassword: unknown, + credentialPassword: unknown, +): string | undefined { + if (isNonEmptyString(hostPassword)) return hostPassword; + if (isNonEmptyString(credentialPassword)) return credentialPassword; + return undefined; +} + /** * Expands the `$oidc.preferred_username` placeholder in an SSH username to the * connecting user's OIDC identifier. Returns the username unchanged if it does diff --git a/src/backend/ssh/host-metrics.ts b/src/backend/ssh/host-metrics.ts index fcfe354f..d10a3334 100644 --- a/src/backend/ssh/host-metrics.ts +++ b/src/backend/ssh/host-metrics.ts @@ -4,7 +4,10 @@ import { createCorsMiddleware } from "../utils/cors-config.js"; import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { pickResolvedUsername } from "./credential-username.js"; +import { + pickResolvedPassword, + pickResolvedUsername, +} from "./credential-username.js"; import { getDb } from "../database/db/index.js"; import { hosts, sshCredentials } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; @@ -961,9 +964,10 @@ async function resolveHostCredentials( host.overrideCredentialUsername, ); - if (credential.password) { - baseHost.password = credential.password; - } + baseHost.password = pickResolvedPassword( + host.password, + credential.password, + ); if ( credential.key || (credential as Record).privateKey diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index 4759fc9b..f0e32ce7 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -4,6 +4,7 @@ import { eq, and } from "drizzle-orm"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { logger } from "../utils/logger.js"; import { + pickResolvedPassword, pickResolvedUsername, expandOidcUsername, } from "./credential-username.js"; @@ -190,7 +191,7 @@ export async function resolveHostById( if (credentials.length > 0) { const cred = credentials[0] as Record; - host.password = cred.password; + host.password = pickResolvedPassword(host.password, cred.password); // Prefer the normalised private key; fall back to raw key field host.key = (cred.privateKey || cred.key) as string | null; host.keyPassword = cred.keyPassword; diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index bf4511e5..30d30e24 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -592,6 +592,17 @@ export function HostEditor({ /> )} +
+ + setField("password", e.target.value)} + /> +
)} diff --git a/src/ui/sidebar/HostEditorData.test.ts b/src/ui/sidebar/HostEditorData.test.ts index 94eb4437..3ca95dfa 100644 --- a/src/ui/sidebar/HostEditorData.test.ts +++ b/src/ui/sidebar/HostEditorData.test.ts @@ -45,19 +45,19 @@ describe("buildHostEditorPayload auth field isolation", () => { expect(payload.password).toBe("newpass"); }); - it("only sends the credentialId when authType is credential", () => { + it("sends credentialId and optional password when authType is credential", () => { const form = { ...createHostEditorForm(null), authType: "credential" as const, credentialId: "7", - password: "leftover", + password: "host-specific-password", key: "leftover-key", }; const payload = buildHostEditorPayload(form, sshOnly); expect(payload.credentialId).toBe(7); - expect(payload.password).toBeNull(); + expect(payload.password).toBe("host-specific-password"); expect(payload.key).toBeNull(); }); diff --git a/src/ui/sidebar/HostEditorData.ts b/src/ui/sidebar/HostEditorData.ts index 99209325..29049f4d 100644 --- a/src/ui/sidebar/HostEditorData.ts +++ b/src/ui/sidebar/HostEditorData.ts @@ -273,7 +273,8 @@ export function buildHostEditorPayload( pin: form.pin, authType: form.authType, useWarpgate: form.useWarpgate, - password: usesPassword || usesKey ? form.password || null : null, + password: + usesPassword || usesKey || usesCredential ? form.password || null : null, key: usesKey ? form.key === "existing_key" ? undefined