feat(ssh): support keyboard-interactive 2FA for key and agent auth#1926
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
There was a problem hiding this comment.
💡 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".
| authenticators.append(KeyboardInteractiveAuthenticator( | ||
| password: nil, | ||
| totpProvider: buildTOTPProvider(config: config, credentials: credentials), | ||
| promptProvider: promptProvider | ||
| )) |
There was a problem hiding this comment.
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 👍 / 👎.
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
.keyboardInteractiveauth method and a libssh2 callback, but two gaps broke the reporter's case:buildAuthenticatoronly 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.unknownby 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.KeyFileAuthenticatorread that as "needs a passphrase" and popped a spurious "SSH Key Passphrase Required" dialog before the code prompt.Fix
KeyboardInteractiveAuthenticator. This is safe:CompositeAuthenticatorreturns 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.KeyboardInteractiveContext.responses): the password and auto-generated TOTP fast paths answer what they can, and everything else goes to a newPromptKeyboardInteractiveProvider. It renders one echo-aware field per prompt (secure when the server sets echo off), showing the prompt text verbatim, through the existing main-threadrunModal()bridge.classifyis 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.KeyFileAuthenticatorno longer prompts for a passphrase when the errno isLIBSSH2_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..cancelledreason, and aborts theCompositeAuthenticatorchain instead of falling through to the next method.The model stays byte-compatible:
SSHAuthMethodandTOTPModeare unchanged (TOTPModenow only controls the auto-generate convenience). DeletedPromptTOTPProvider; renamed the deadSSHTunnelCredentials.totpProviderDI seam tokeyboardInteractivePromptProvider.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: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.classifyincluding the"One-time password:"regression.buildAuthenticatorproduces a composite with a trailing keyboard-interactive authenticator for private-key and agent auth attotpMode == .none.isUserCancelledAuthenticationandAuthFailureReasoniterating.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 realAuthenticationMethods publickey,keyboard-interactiveserver (Google Authenticator PAM) to confirm no spurious passphrase dialog and that the code prompt appears.Docs
Updated the SSH tunneling and SSH profiles pages.