Files
orca/src/main/ssh/ssh-keyboard-interactive.ts
T
93d8b1f042 fix(ssh): complete keyboard-interactive MFA prompt handling (#15588)
Honor SSH keyboard-interactive prompt echo and empty responses, reuse login
passwords without replaying rejected values, and stop cancelled or stale
credential requests from continuing authentication or restoring the cache.

Original implementation: Junho Kim (#8750).
Port and follow-up work: Allen (#15588).

Verified with 2,773 SSH/credential tests, full typecheck, changed-code quality,
a production Electron build, real-socket MFA fixtures, and rendered UI checks.

Fixes #8622

Co-authored-by: Junho Kim <arkimjh@illinois.edu>
Co-authored-by: microdaery <microdaery@gapp.nthu.edu.tw>
2026-09-27 01:12:29 -07:00

112 lines
3.8 KiB
TypeScript

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
// Why: bounds a malicious/misbehaving server's prompt text before it reaches the credential dialog.
const PROMPT_DETAIL_MAX = 4_096
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()
const detail =
!trimmedInstructions || !trimmedPrompt
? trimmedInstructions || trimmedPrompt
: `${trimmedInstructions}\n${trimmedPrompt}`
return detail.slice(0, PROMPT_DETAIL_MAX)
}
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[],
onPromptStart?: () => void
): Promise<string[] | null> {
if (session.isCancelled()) {
return null
}
// Why: a missing prompter is a capability gap, not a user decision — it
// must return null WITHOUT calling markCancelled(), or SshConnection would
// treat it as an explicit decline and skip its passphrase/password rungs.
if (!session.requestCredential) {
return null
}
const responses: string[] = []
for (const prompt of prompts) {
onPromptStart?.()
const value = isKeyboardInteractivePasswordPrompt(prompt)
? await answerPasswordPrompt(session)
: await session.requestCredential(
session.targetId,
'keyboard-interactive',
formatKeyboardInteractivePromptDetail(instructions, prompt.prompt),
prompt.echo
)
if (session.isCancelled()) {
return null
}
if (value == null) {
session.markCancelled()
return null
}
responses.push(value)
}
return responses
}
async function answerPasswordPrompt(
session: KeyboardInteractiveSession
): Promise<string | null | undefined> {
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 (session.isCancelled()) {
return null
}
if (value == null) {
return value
}
session.state.passwordAutoAnswered = true
if (value) {
session.setCachedPassword(value)
}
return value
}