Skip to content

feat(ssh): support keyboard-interactive 2FA for key and agent auth#1926

Merged
datlechin merged 1 commit into
mainfrom
fix/ssh-keyboard-interactive-2fa
Jul 21, 2026
Merged

feat(ssh): support keyboard-interactive 2FA for key and agent auth#1926
datlechin merged 1 commit into
mainfrom
fix/ssh-keyboard-interactive-2fa

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #1920

Problem

TablePro could not open an SSH tunnel to a server that requires keyboard-interactive authentication, the common shape for 2FA through Google Authenticator or another PAM module. The reporter's server uses AuthenticationMethods publickey,keyboard-interactive: a private key first, then a one-time code. There was no way to answer the code prompt, so the connect failed. This works in TablePlus.

The app already had a .keyboardInteractive auth method and a libssh2 callback, but two gaps broke the reporter's case:

  1. The second factor was never wired for key or agent auth. buildAuthenticator only appended a keyboard-interactive authenticator to a private-key or agent chain when a TOTP mode was set, and the form hid the 2FA section for those methods. So a key-auth connection sent no keyboard-interactive response at all.
  2. Prompts were guessed, never shown. The callback keyword-matched each prompt into password / TOTP / unknown and answered unknown by resending the SSH password. Nothing displayed the server's actual prompt for a free-form response.

A third, related bug surfaced while tracing this: libssh2 does not honor partial_success, so a key accepted as the first factor returns the same error code as a rejected key. KeyFileAuthenticator read that as "needs a passphrase" and popped a spurious "SSH Key Passphrase Required" dialog before the code prompt.

Fix

  • Wire keyboard-interactive into every method except None. Private-key and agent chains always append a terminal KeyboardInteractiveAuthenticator. This is safe: CompositeAuthenticator returns as soon as the key alone authenticates, and libssh2 never invokes the callback if the server did not offer keyboard-interactive. The form's 2FA section now shows for every method except None.
  • Show the server's real prompt. Replaced the classify-and-answer step with a per-prompt responder chain (KeyboardInteractiveContext.responses): the password and auto-generated TOTP fast paths answer what they can, and everything else goes to a new PromptKeyboardInteractiveProvider. It renders one echo-aware field per prompt (secure when the server sets echo off), showing the prompt text verbatim, through the existing main-thread runModal() bridge. classify is now a fast-path hint only, so an unrecognized prompt goes to the user instead of leaking the password. Its "password"-before-code precedence bug ("One-time password:") is fixed.
  • Stop the spurious passphrase dialog. KeyFileAuthenticator no longer prompts for a passphrase when the errno is LIBSSH2_ERROR_AUTHENTICATION_FAILED (the wire-level rejection or partial-success code); it still prompts for genuine local decrypt or file errors, so passphrase handling is unchanged.
  • Cancel aborts cleanly. Cancelling the prompt fills empty responses, records a .cancelled reason, and aborts the CompositeAuthenticator chain instead of falling through to the next method.

The model stays byte-compatible: SSHAuthMethod and TOTPMode are unchanged (TOTPMode now only controls the auto-generate convenience). Deleted PromptTOTPProvider; renamed the dead SSHTunnelCredentials.totpProvider DI seam to keyboardInteractivePromptProvider.

Out of scope: a connect-phase deadline. It is a pre-existing gap across TCP, handshake, and every auth method, and a fixed deadline must not kill the connection while a prompt is legitimately open. The dialog's own Cancel is the auth-phase escape.

Tests

Pure-logic coverage, no live session or runModal() in the test target:

  • Prompt decode from length-delimited UTF-8 bytes, and echo to secure/plain field mapping.
  • responses(...): password and TOTP fast paths, the direct Add support for SSH two-factor authentication / keyboard-interactive authentication #1920 regression (an unrecognized prompt with a configured password routes to the user, never leaks the password), multi-prompt index mapping, mixed fast-path plus interactive ordering, cancel fills empty responses, and cancel suppresses re-prompting.
  • classify including the "One-time password:" regression.
  • buildAuthenticator produces a composite with a trailing keyboard-interactive authenticator for private-key and agent auth at totpMode == .none.
  • isUserCancelledAuthentication and AuthFailureReason iterating .allCases.

Validation before merge

The errno gate and the cancel-abort can't be unit-tested without a live or Dockerized sshd. Worth a manual check against a real AuthenticationMethods publickey,keyboard-interactive server (Google Authenticator PAM) to confirm no spurious passphrase dialog and that the code prompt appears.

Docs

Updated the SSH tunneling and SSH profiles pages.

@datlechin
datlechin merged commit 76106c8 into main Jul 21, 2026
4 checks passed
@datlechin
datlechin deleted the fix/ssh-keyboard-interactive-2fa branch July 21, 2026 06:36
@mintlify

mintlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟡 Building Jul 21, 2026, 6:36 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df53a09d84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +523 to +527
authenticators.append(KeyboardInteractiveAuthenticator(
password: nil,
totpProvider: buildTOTPProvider(config: config, credentials: credentials),
promptProvider: promptProvider
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reach keyboard-interactive before exhausting agent identities

On AuthenticationMethods publickey,keyboard-interactive servers, libssh2 reports a first-factor agent key as an authentication failure (as noted in this change), so AgentAuthenticator continues offering every remaining key in the agent before this trailing authenticator runs. With an agent holding enough identities to hit sshd's MaxAuthTries (commonly six), the server disconnects before the verification prompt is reached—even when the first offered agent key was the correct public-key factor. This makes the newly advertised SSH-agent 2FA flow fail for typical multi-key agents; interleave the keyboard-interactive attempt with agent/key attempts or otherwise preserve the partial-success path.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for SSH two-factor authentication / keyboard-interactive authentication

1 participant