diff --git a/src/main/ipc/ssh-passphrase.ts b/src/main/ipc/ssh-passphrase.ts index e1e2104537..bb9cc1e1c4 100644 --- a/src/main/ipc/ssh-passphrase.ts +++ b/src/main/ipc/ssh-passphrase.ts @@ -19,7 +19,8 @@ export function requestCredential( getMainWindow: () => BrowserWindow | null, targetId: string, kind: SshCredentialKind, - detail: string + detail: string, + echo?: boolean ): Promise { const requestId = randomUUID() return new Promise((resolve) => { @@ -39,7 +40,7 @@ export function requestCredential( const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail }) + win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail, echo }) } else { pendingRequests.delete(requestId) clearTimeout(timer) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index affb1aba00..d1976751f3 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -510,9 +510,9 @@ function registerPowerMonitorReconnect(): void { function createSshConnectionCallbacks(): SshConnectionCallbacks { return { - onCredentialRequest: (targetId, kind, detail) => { + onCredentialRequest: (targetId, kind, detail, echo) => { credentialRequestedForTarget.add(targetId) - return requestCredential(getCurrentMainWindow, targetId, kind, detail) + return requestCredential(getCurrentMainWindow, targetId, kind, detail, echo) }, onStateChange: (targetId: string, state: SshConnectionState) => { if (testingTargets.has(targetId)) { diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 50f2eb66ff..d68c5e7518 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -448,6 +448,11 @@ describe('buildConnectConfig', () => { expect(config.keepaliveInterval).toBe(15_000) }) + it('enables keyboard-interactive auth so MFA challenges can be answered', () => { + const config = buildConnectConfig(makeTarget(), null) + expect(config.tryKeyboard).toBe(true) + }) + it('uses agent auth when no explicit key and SSH_AUTH_SOCK is set', () => { const config = buildConnectConfig(makeTarget(), null) expect(config.agent).toBe('/tmp/agent.sock') diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index de9e0b6c63..9f828fde52 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -13,14 +13,17 @@ import { export { findDefaultKeyFile, resolveAgentSocket } from './ssh-auth-resolution' -export type SshCredentialKind = 'passphrase' | 'password' +export type SshCredentialKind = 'passphrase' | 'password' | 'keyboard-interactive' export type SshConnectionCallbacks = { onStateChange: (targetId: string, state: SshConnectionState) => void + // Why: echo carries the server's RFC 4256 echo flag for keyboard-interactive + // prompts so the UI knows whether the typed response may be shown. onCredentialRequest?: ( targetId: string, kind: SshCredentialKind, - detail: string + detail: string, + echo?: boolean ) => Promise } @@ -187,7 +190,11 @@ export function buildConnectConfig( port: effectivePort, username: effectiveUser, readyTimeout: CONNECT_TIMEOUT_MS, - keepaliveInterval: 15_000 + keepaliveInterval: 15_000, + // Why: lets servers deliver keyboard-interactive challenges (RFC 4256, + // commonly MFA after a partial password success). SshConnection answers + // them through the credential prompt callback. + tryKeyboard: true } const shouldIncludeAgent = options.includeAgent ?? true diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index e8c5d71234..8a950efe29 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -11,7 +11,9 @@ let connectBehavior: 'ready' | 'error' = 'ready' let connectErrorMessage = '' let connectErrorCode = '' let destroyErrorMessage = '' -let connectSequence: ('ready' | Error)[] = [] +// Why: 'silent' leaves the connect attempt pending so a test can drive auth +// events (e.g. keyboard-interactive prompts) through emitSshEvent itself. +let connectSequence: ('ready' | 'silent' | Error)[] = [] let execBehavior: 'callback' | 'pending' = 'callback' let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null let sftpBehavior: 'callback' | 'pending' = 'callback' @@ -40,6 +42,10 @@ vi.mock('ssh2', () => { // to decide which log line to emit. A real Socket instance lets the test // exercise the "enabled" branch instead of the "skipped (proxy socket)" branch. _sock: Socket | undefined = new Socket() + // Why: production code clears ssh2's internal handshake timer when a + // keyboard-interactive prompt arrives (and warns when the field is + // missing); a dummy timer keeps that path exercised without the warning. + _readyTimeout: NodeJS.Timeout = setTimeout(() => {}, 0) lastExecCommand?: string lastConnectConfig?: unknown constructor() { @@ -69,6 +75,9 @@ vi.mock('ssh2', () => { emitSshEvent('ready') return } + if (next === 'silent') { + return + } if (connectBehavior === 'error') { const err = new Error(connectErrorMessage) as NodeJS.ErrnoException if (connectErrorCode) { @@ -657,6 +666,131 @@ describe('SshConnection', () => { } }) + it('completes password + keyboard-interactive MFA auth and forwards the prompt', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + connectSequence = [new Error('All configured authentication methods failed'), 'silent'] + const onCredentialRequest = vi.fn(async (_targetId: string, kind: string) => + kind === 'password' ? 'password-123' : '1' + ) + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + await vi.waitFor(() => { + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + expect(clientInstances).toHaveLength(2) + }) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + 'Choose a verification method:', + '', + [{ prompt: 'Passcode or option (1-2):', echo: true }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['1'])) + emitSshEvent('ready') + await connectPromise + + expect(conn.getState().status).toBe('connected') + const retryConfig = clientInstances[1].lastConnectConfig as { + password?: string + tryKeyboard?: boolean + } + expect(retryConfig.tryKeyboard).toBe(true) + expect(retryConfig.password).toBe('password-123') + expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', 'example.com') + expect(onCredentialRequest).toHaveBeenCalledWith( + 'target-1', + 'keyboard-interactive', + 'Choose a verification method:\nPasscode or option (1-2):', + true + ) + }) + + it('auto-answers a keyboard-interactive password prompt with the cached password', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + connectSequence = [new Error('All configured authentication methods failed'), 'silent'] + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + await vi.waitFor(() => { + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + expect(clientInstances).toHaveLength(2) + }) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Password: ', echo: false }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['password-123'])) + emitSshEvent('ready') + await connectPromise + + expect(conn.getState().status).toBe('connected') + expect(onCredentialRequest).toHaveBeenCalledTimes(1) + }) + + it('caches a password collected through a keyboard-interactive prompt', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + connectSequence = ['silent'] + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + await vi.waitFor(() => + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + ) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Password: ', echo: false }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['password-123'])) + emitSshEvent('ready') + await connectPromise + + expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', 'example.com') + expect(conn.hasCachedCredential()).toBe(true) + }) + + it('does not fall back to the password prompt after a cancelled keyboard-interactive prompt', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + connectSequence = ['silent'] + const onCredentialRequest = vi.fn(async () => null) + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + connectPromise.catch(() => {}) + await vi.waitFor(() => + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + ) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Duo passcode:', echo: false }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith([])) + emitSshEvent('error', new Error('All configured authentication methods failed')) + + await expect(connectPromise).rejects.toThrow('All configured authentication methods failed') + expect(onCredentialRequest).toHaveBeenCalledTimes(1) + expect(conn.getState().status).toBe('auth-failed') + }) + it('wraps exec commands as a single line that csh/tcsh login shells cannot break', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 2856d33b2f..b9ea46d271 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -2,7 +2,7 @@ import * as net from 'node:net' import { Client as SshClient } from 'ssh2' import type { ChildProcess } from 'node:child_process' -import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2' +import type { ClientChannel, ConnectConfig, Prompt, SFTPWrapper } from 'ssh2' import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types' import { getOrcaControlSocketPath, @@ -38,6 +38,7 @@ import { type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' +import { collectKeyboardInteractiveResponses } from './ssh-keyboard-interactive' import type { RemoteHostPlatform } from './ssh-remote-platform' import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' @@ -83,6 +84,7 @@ export class SshConnection { private disposed = false private cachedPassphrase: string | null = null private cachedPassword: string | null = null + private keyboardInteractiveCancelled = false private connectGeneration = 0 constructor(target: SshTarget, callbacks: SshConnectionCallbacks) { @@ -524,6 +526,7 @@ export class SshConnection { this.setState('connecting') this.proxyProcess?.kill() this.proxyProcess = null + this.keyboardInteractiveCancelled = false const connectGeneration = ++this.connectGeneration const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( @@ -688,6 +691,15 @@ export class SshConnection { throw authError } + // Why: a cancelled keyboard-interactive prompt is an explicit user + // decision; falling through to the password prompt would ask again for + // the same login. + if (this.keyboardInteractiveCancelled) { + this.proxyProcess?.kill() + this.proxyProcess = null + throw authError + } + // Why: prompt for passphrase on encrypted-key error, then retry with // a fresh proxy socket (ssh2 may have destroyed the original). if (isPassphraseError(authError) && !this.cachedPassphrase && !passphrasePromptHandled) { @@ -1027,6 +1039,74 @@ export class SshConnection { const cleanupStartupListeners = (): void => { client.off('ready', onReady) client.off('error', onStartupError) + client.off('keyboard-interactive', onKeyboardInteractive) + } + + // Why: MFA servers deliver their challenges via keyboard-interactive + // (RFC 4256), typically after password auth partially succeeds. Without + // a handler ssh2 skips the method and the whole auth fails with "All + // configured authentication methods failed". + const kiState = { passwordAutoAnswered: false } + const onKeyboardInteractive = ( + _name: string, + instructions: string, + _lang: string, + prompts: Prompt[], + finish: (responses: string[]) => void + ): void => { + // Why: readyTimeout budgets the network handshake, but these prompts + // wait on a human (push approval, OTP entry). A prompt proves the + // server is alive; from here the server's LoginGraceTime and the + // credential dialog timeout bound the wait. + const readyTimeout = (client as unknown as { _readyTimeout?: NodeJS.Timeout })._readyTimeout + if (readyTimeout !== undefined) { + clearTimeout(readyTimeout) + } else { + // Why: this reaches into ssh2 internals — if the field moves, the + // handshake timeout silently cuts MFA prompts short again. Make + // that breakage visible. + console.warn( + `[ssh] Could not clear the handshake timeout for ${this.target.label}; keyboard-interactive prompts may be cut short` + ) + } + collectKeyboardInteractiveResponses( + { + targetId: this.target.id, + hostDetail: config.host || this.target.label, + requestCredential: this.callbacks.onCredentialRequest, + getCachedPassword: () => this.cachedPassword, + setCachedPassword: (value) => { + this.cachedPassword = value + }, + markCancelled: () => { + this.keyboardInteractiveCancelled = true + }, + isCancelled: () => this.keyboardInteractiveCancelled, + state: kiState + }, + instructions, + prompts + ).then( + (responses) => { + // Why: a late answer must not write to a client that already + // timed out or was torn down by disconnect/reconnect. + if (!settled) { + finish(responses ?? []) + } + }, + (err) => { + // Why: distinguishes a real failure (e.g. a broken credential + // prompter) from an expected user cancellation in the logs. + console.warn( + `[ssh] keyboard-interactive handling failed for ${this.target.label}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + if (!settled) { + finish([]) + } + } + ) } const swallowLateStartupError = (): void => { // Why: ssh2 can emit another socket error while a failed or cancelled @@ -1090,6 +1170,7 @@ export class SshConnection { client.on('ready', onReady) client.on('error', onStartupError) + client.on('keyboard-interactive', onKeyboardInteractive) client.connect(config) }) } diff --git a/src/main/ssh/ssh-keyboard-interactive.test.ts b/src/main/ssh/ssh-keyboard-interactive.test.ts new file mode 100644 index 0000000000..6f16c74249 --- /dev/null +++ b/src/main/ssh/ssh-keyboard-interactive.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest' +import { + collectKeyboardInteractiveResponses, + formatKeyboardInteractivePromptDetail, + isKeyboardInteractivePasswordPrompt, + type KeyboardInteractiveSession +} from './ssh-keyboard-interactive' + +function createSession( + overrides: Partial = {} +): KeyboardInteractiveSession & { cancelled: { value: boolean } } { + const cancelled = { value: false } + return { + targetId: 'target-1', + hostDetail: 'example.com', + requestCredential: vi.fn(async () => 'answer'), + getCachedPassword: () => null, + setCachedPassword: vi.fn(), + markCancelled: () => { + cancelled.value = true + }, + isCancelled: () => cancelled.value, + state: { passwordAutoAnswered: false }, + cancelled, + ...overrides + } +} + +describe('isKeyboardInteractivePasswordPrompt', () => { + it('matches masked password prompts', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Password: ', echo: false })).toBe(true) + expect(isKeyboardInteractivePasswordPrompt({ prompt: "user@host's PASSWORD:" })).toBe(true) + }) + + it('rejects echoed prompts even when they mention a password', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Password: ', echo: true })).toBe(false) + }) + + it('rejects one-time password and OTP prompts', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'One-time password:', echo: false })).toBe( + false + ) + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'OTP password code:', echo: false })).toBe( + false + ) + }) + + it('rejects verification prompts that never mention a password', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Passcode:', echo: false })).toBe(false) + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Duo push sent', echo: false })).toBe( + false + ) + }) +}) + +describe('formatKeyboardInteractivePromptDetail', () => { + it('joins instructions and prompt on separate lines', () => { + expect(formatKeyboardInteractivePromptDetail('Pick an option:', 'Passcode: ')).toBe( + 'Pick an option:\nPasscode:' + ) + }) + + it('drops the missing half', () => { + expect(formatKeyboardInteractivePromptDetail('', 'Passcode: ')).toBe('Passcode:') + expect(formatKeyboardInteractivePromptDetail('Approve the push. ', '')).toBe( + 'Approve the push.' + ) + }) +}) + +describe('collectKeyboardInteractiveResponses', () => { + it('forwards verification prompts with instructions and the echo flag', async () => { + const requestCredential = vi.fn(async () => '1') + const session = createSession({ requestCredential }) + + const responses = await collectKeyboardInteractiveResponses(session, 'Choose an option:', [ + { prompt: 'Passcode or option (1-2):', echo: true } + ]) + + expect(responses).toEqual(['1']) + expect(requestCredential).toHaveBeenCalledWith( + 'target-1', + 'keyboard-interactive', + 'Choose an option:\nPasscode or option (1-2):', + true + ) + }) + + it('answers a password prompt from the cache without prompting', async () => { + const requestCredential = vi.fn(async () => 'unused') + const session = createSession({ + requestCredential, + getCachedPassword: () => 'password-123' + }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['password-123']) + expect(requestCredential).not.toHaveBeenCalled() + expect(session.state.passwordAutoAnswered).toBe(true) + }) + + it('re-prompts when the server rejects the auto-answered cached password', async () => { + const requestCredential = vi.fn(async () => 'corrected-password') + const setCachedPassword = vi.fn() + const session = createSession({ + requestCredential, + getCachedPassword: () => 'stale-password', + setCachedPassword, + state: { passwordAutoAnswered: true } + }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['corrected-password']) + expect(requestCredential).toHaveBeenCalledWith('target-1', 'password', 'example.com') + expect(setCachedPassword).toHaveBeenCalledWith('corrected-password') + }) + + it('collects and caches the password when nothing is cached yet', async () => { + const requestCredential = vi.fn(async () => 'password-123') + const setCachedPassword = vi.fn() + const session = createSession({ requestCredential, setCachedPassword }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false }, + { prompt: 'Duo push approval', echo: false } + ]) + + expect(responses).toEqual(['password-123', 'password-123']) + expect(requestCredential).toHaveBeenNthCalledWith(1, 'target-1', 'password', 'example.com') + expect(requestCredential).toHaveBeenNthCalledWith( + 2, + 'target-1', + 'keyboard-interactive', + 'Duo push approval', + false + ) + expect(setCachedPassword).toHaveBeenCalledWith('password-123') + }) + + it('returns null and marks the session cancelled when the user dismisses a prompt', async () => { + const session = createSession({ requestCredential: vi.fn(async () => null) }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Duo passcode:', echo: false } + ]) + + expect(responses).toBeNull() + expect(session.isCancelled()).toBe(true) + }) + + it('short-circuits every later round once cancelled', async () => { + const requestCredential = vi.fn(async () => 'answer') + const session = createSession({ requestCredential }) + session.markCancelled() + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Duo passcode:', echo: false } + ]) + + expect(responses).toBeNull() + expect(requestCredential).not.toHaveBeenCalled() + }) + + it('returns null when no credential prompter is available', async () => { + const session = createSession({ requestCredential: undefined }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toBeNull() + }) + + it('accepts an empty response without caching it as a password', async () => { + const requestCredential = vi.fn(async () => '') + const setCachedPassword = vi.fn() + const session = createSession({ requestCredential, setCachedPassword }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['']) + expect(setCachedPassword).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-keyboard-interactive.ts b/src/main/ssh/ssh-keyboard-interactive.ts new file mode 100644 index 0000000000..7186fbdac4 --- /dev/null +++ b/src/main/ssh/ssh-keyboard-interactive.ts @@ -0,0 +1,94 @@ +import type { Prompt } from 'ssh2' +import type { SshConnectionCallbacks } from './ssh-connection-utils' + +// Why: servers commonly reuse keyboard-interactive (RFC 4256) to collect the +// login password itself before issuing MFA challenges. Password-looking +// prompts route through the password credential flow (and its cache) so +// reconnects stay silent; every other prompt needs a fresh human answer. +const PASSWORD_PROMPT = /password/i +// Why: a one-time code answered with the cached login password would burn MFA +// attempts (and can lock the account) on every reconnect. +const ONE_TIME_PROMPT = /one.?time|otp/i + +export function isKeyboardInteractivePasswordPrompt(prompt: Prompt): boolean { + return ( + prompt.echo !== true && + PASSWORD_PROMPT.test(prompt.prompt) && + !ONE_TIME_PROMPT.test(prompt.prompt) + ) +} + +export function formatKeyboardInteractivePromptDetail( + instructions: string, + promptText: string +): string { + const trimmedInstructions = instructions.trim() + const trimmedPrompt = promptText.trim() + if (!trimmedInstructions || !trimmedPrompt) { + return trimmedInstructions || trimmedPrompt + } + return `${trimmedInstructions}\n${trimmedPrompt}` +} + +export type KeyboardInteractiveSession = { + targetId: string + hostDetail: string + requestCredential: SshConnectionCallbacks['onCredentialRequest'] + getCachedPassword: () => string | null + setCachedPassword: (value: string) => void + markCancelled: () => void + isCancelled: () => boolean + // Why: one cached-password auto-answer per connection attempt — a second + // password prompt in the same attempt means the server rejected the cached + // value, so the user must be asked again instead of looping a bad password. + state: { passwordAutoAnswered: boolean } +} + +// Answers one keyboard-interactive round. Returns null when the user +// cancelled (or no prompter is available); callers respond with no answers so +// the server fails the round and the regular auth-error flow takes over. +export async function collectKeyboardInteractiveResponses( + session: KeyboardInteractiveSession, + instructions: string, + prompts: Prompt[] +): Promise { + if (session.isCancelled()) { + return null + } + const responses: string[] = [] + for (const prompt of prompts) { + const value = isKeyboardInteractivePasswordPrompt(prompt) + ? await answerPasswordPrompt(session) + : await session.requestCredential?.( + session.targetId, + 'keyboard-interactive', + formatKeyboardInteractivePromptDetail(instructions, prompt.prompt), + prompt.echo + ) + if (value == null) { + session.markCancelled() + return null + } + responses.push(value) + } + return responses +} + +async function answerPasswordPrompt( + session: KeyboardInteractiveSession +): Promise { + const cached = session.getCachedPassword() + if (cached != null && !session.state.passwordAutoAnswered) { + session.state.passwordAutoAnswered = true + return cached + } + const value = await session.requestCredential?.(session.targetId, 'password', session.hostDetail) + if (value == null) { + return value + } + session.state.passwordAutoAnswered = true + if (value) { + session.setCachedPassword(value) + } + return value +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2ce823028c..8efcdfc6dd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3115,8 +3115,9 @@ export type PreloadApi = { callback: (data: { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean }) => void ) => () => void onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 912e1852cf..29d154ae5e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4228,8 +4228,9 @@ const api = { callback: (data: { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean }) => void ): (() => void) => { const listener = ( @@ -4237,8 +4238,9 @@ const api = { data: { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean } ) => callback(data) ipcRenderer.on('ssh:credential-request', listener) diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx index 7cfb4dbd0b..27910e6672 100644 --- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx +++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx @@ -61,7 +61,10 @@ export function SshPassphraseDialog(): React.JSX.Element | null { ) const handleSubmit = useCallback(async () => { - if (!request || !value) { + // Why: keyboard-interactive servers may accept an empty response (e.g. + // plain Enter to trigger a default MFA push); passwords and passphrases + // are never empty. + if (!request || (!value && request.kind !== 'keyboard-interactive')) { return } setSubmitting(true) @@ -107,6 +110,7 @@ export function SshPassphraseDialog(): React.JSX.Element | null { const label = targetLabels.get(request.targetId) ?? request.targetId const isPassword = request.kind === 'password' + const isKeyboardInteractive = request.kind === 'keyboard-interactive' return ( !isOpen && void handleCancel()}> @@ -120,15 +124,31 @@ export function SshPassphraseDialog(): React.JSX.Element | null { > - {isPassword - ? translate('auto.components.settings.SshPassphraseDialog.106bd57f4a', 'SSH Password') - : translate( - 'auto.components.settings.SshPassphraseDialog.1f3dde805d', - 'SSH Key Passphrase' - )} + {isKeyboardInteractive + ? translate( + 'auto.components.settings.SshPassphraseDialog.6d2b7b77b7', + 'SSH Verification' + ) + : isPassword + ? translate( + 'auto.components.settings.SshPassphraseDialog.106bd57f4a', + 'SSH Password' + ) + : translate( + 'auto.components.settings.SshPassphraseDialog.1f3dde805d', + 'SSH Key Passphrase' + )} - {isPassword ? ( + {isKeyboardInteractive ? ( + <> + {translate( + 'auto.components.settings.SshPassphraseDialog.baf23a0fcf', + 'Respond to the verification prompt from' + )} + {label} + + ) : isPassword ? ( <> {translate( 'auto.components.settings.SshPassphraseDialog.dbf9b6f2d0', @@ -150,24 +170,26 @@ export function SshPassphraseDialog(): React.JSX.Element | null {
setValue(e.target.value)} onKeyDown={(e) => { @@ -177,15 +199,20 @@ export function SshPassphraseDialog(): React.JSX.Element | null { } }} placeholder={ - isPassword + isKeyboardInteractive ? translate( - 'auto.components.settings.SshPassphraseDialog.abaa0dc653', - 'Enter password' - ) - : translate( - 'auto.components.settings.SshPassphraseDialog.c3ce71aad6', - 'Enter passphrase' + 'auto.components.settings.SshPassphraseDialog.7effa48279', + 'Enter response' ) + : isPassword + ? translate( + 'auto.components.settings.SshPassphraseDialog.abaa0dc653', + 'Enter password' + ) + : translate( + 'auto.components.settings.SshPassphraseDialog.c3ce71aad6', + 'Enter passphrase' + ) } className="h-8 text-sm" disabled={submitting} @@ -200,8 +227,12 @@ export function SshPassphraseDialog(): React.JSX.Element | null { > {translate('auto.components.settings.SshPassphraseDialog.d5a234456f', 'Cancel')} - diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5fa8b146a6..bf27fac843 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6593,7 +6593,10 @@ "8a349e3fac": "Passphrase for {{value0}}", "cab3d5f5a5": "Password for {{value0}}", "1f3dde805d": "SSH Key Passphrase", - "106bd57f4a": "SSH Password" + "106bd57f4a": "SSH Password", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "a883f5a00f": "terminal timeout: {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 45e1afec7a..e8068579eb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6556,7 +6556,10 @@ "8a349e3fac": "Frase de contraseña para {{value0}}", "cab3d5f5a5": "Contraseña para {{value0}}", "1f3dde805d": "Frase de contraseña de clave SSH", - "106bd57f4a": "Contraseña SSH" + "106bd57f4a": "Contraseña SSH", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "Conectar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c454ec9ce4..23c6c2c719 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6578,7 +6578,10 @@ "8a349e3fac": "{{value0}} のパスフレーズ", "cab3d5f5a5": "{{value0}} のパスワード", "1f3dde805d": "SSH キーのパスフレーズ", - "106bd57f4a": "SSH パスワード" + "106bd57f4a": "SSH パスワード", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "接続", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b1f7cca348..8aa8ebba79 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6541,7 +6541,10 @@ "8a349e3fac": "{{value0}}의 암호", "cab3d5f5a5": "{{value0}}의 비밀번호", "1f3dde805d": "SSH 키 암호", - "106bd57f4a": "SSH 비밀번호" + "106bd57f4a": "SSH 비밀번호", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "연결", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1e59503c12..991e712b8f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6541,7 +6541,10 @@ "8a349e3fac": "{{value0}} 的密码", "cab3d5f5a5": "{{value0}} 的密码", "1f3dde805d": "SSH 密钥密码", - "106bd57f4a": "SSH 密码" + "106bd57f4a": "SSH 密码", + "6d2b7b77b7": "SSH Verification", + "baf23a0fcf": "Respond to the verification prompt from", + "7effa48279": "Enter response" }, "SshTargetCard": { "ec6543cee9": "连接", diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 6b80e05435..74ce3e7c08 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -24,8 +24,11 @@ export type RemoteWorkspaceSyncStatus = { export type SshCredentialRequest = { requestId: string targetId: string - kind: 'passphrase' | 'password' + kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + /** RFC 4256 echo flag for keyboard-interactive prompts: when true the + * server allows the typed response to be shown. */ + echo?: boolean } export type SshSlice = {