From df53a09d8477ce7f4071f57a5a3b00af4b0972e5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 21 Jul 2026 13:35:45 +0700 Subject: [PATCH] feat(ssh): support keyboard-interactive 2FA for key and agent auth (#1920) --- CHANGELOG.md | 1 + .../SSH/Auth/CompositeAuthenticator.swift | 2 + .../KeyboardInteractiveAuthenticator.swift | 178 +++++++----- .../SSH/Auth/KeyboardInteractivePrompt.swift | 31 +++ .../KeyboardInteractivePromptProvider.swift | 15 + .../PromptKeyboardInteractiveProvider.swift | 81 ++++++ .../Core/SSH/Auth/PromptTOTPProvider.swift | 50 ---- TablePro/Core/SSH/Auth/TOTPProvider.swift | 6 +- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 77 +++--- TablePro/Core/SSH/SSHTunnelManager.swift | 17 +- .../Connection/ConnectionSSHTunnelView.swift | 6 +- .../Connection/SSHProfileEditorView.swift | 12 +- .../TableProMobile.xcodeproj/project.pbxproj | 2 - .../SSH/Auth/AuthFailureReasonTests.swift | 31 ++- .../SSH/Auth/BuildAuthenticatorTests.swift | 169 ++++++------ .../Auth/CompositeAuthenticatorTests.swift | 35 +++ .../KeyboardInteractiveContextTests.swift | 260 ++++++++++++++---- docs/databases/ssh-tunneling.mdx | 12 +- docs/features/ssh-profiles.mdx | 2 +- 19 files changed, 663 insertions(+), 324 deletions(-) create mode 100644 TablePro/Core/SSH/Auth/KeyboardInteractivePrompt.swift create mode 100644 TablePro/Core/SSH/Auth/KeyboardInteractivePromptProvider.swift create mode 100644 TablePro/Core/SSH/Auth/PromptKeyboardInteractiveProvider.swift delete mode 100644 TablePro/Core/SSH/Auth/PromptTOTPProvider.swift create mode 100644 TableProTests/Core/SSH/Auth/CompositeAuthenticatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c2818ea..70247b96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - SSH tunnels can authenticate with no password or key, for hosts that handle SSH auth themselves like Tailscale SSH. Pick **None** as the SSH auth method. (#1907) +- SSH tunnels can complete keyboard-interactive verification, so servers that require a private key plus a one-time code (2FA through Google Authenticator or another PAM module) now connect. TablePro shows the server's prompt and you type the response. (#1920) ### Fixed diff --git a/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift b/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift index 6b75bb1bb..4c8e1bfd3 100644 --- a/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift @@ -21,6 +21,8 @@ internal struct CompositeAuthenticator: SSHAuthenticator { Self.logger.debug("Trying authenticator \(index + 1)/\(authenticators.count)") do { try authenticator.authenticate(session: session, username: username) + } catch let error as SSHTunnelError where error.isUserCancelledAuthentication { + throw error } catch { Self.logger.debug("Authenticator \(index + 1) failed: \(error)") lastError = error diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index a4da3f9a8..8462f87b5 100644 --- a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -8,53 +8,106 @@ import os import CLibSSH2 -/// Prompt type classification for keyboard-interactive authentication -internal enum KBDINTPromptType { +/// How a keyboard-interactive prompt should be answered without asking the user. +/// +/// The classification is a fast-path hint only: it decides when a pre-known credential (the SSH +/// password, an auto-generated TOTP code) can answer a prompt. Anything not claimed here defers to +/// the interactive prompt, so a weak keyword match is a missed shortcut, not a wrong answer. +internal enum KBDINTPromptType: Equatable { case password case totp - case unknown + case unmatched } -/// Context passed through the libssh2 session abstract pointer to the C callback. +/// Context reached from the C callback through the libssh2 session abstract pointer. /// -/// TOTP codes are fetched lazily inside the callback (not upfront) so that: -/// - `AutoTOTPProvider` generates a code that's still valid when PAM validates it. The -/// upfront approach raced the 30-second window during the SSH handshake. -/// - When the server retries the kbd-int session after a wrong code (PAM defaults to -/// 3 prompts), each retry calls `provideCode(attempt:)` again, matching how OpenSSH -/// re-prompts the user. +/// TOTP codes and user prompts are resolved lazily inside the callback (not upfront) so that a +/// generated code is still valid when PAM checks it, and so the server can re-prompt within one +/// session (PAM defaults to 3 attempts) with each retry driving a fresh code or dialog. The C +/// callback can't throw across the libssh2 boundary, so failures and cancellation are recorded here +/// and surface after `libssh2_userauth_keyboard_interactive_ex` returns. internal final class KeyboardInteractiveContext { let password: String? let totpProvider: (any TOTPProvider)? - var totpAttemptCount: Int = 0 - var lastTotpError: Error? - - init(password: String?, totpProvider: (any TOTPProvider)?) { + let promptProvider: any KeyboardInteractivePromptProvider + private(set) var totpAttemptCount = 0 + private(set) var interactiveAttemptCount = 0 + private(set) var userCancelled = false + var lastError: Error? + + init( + password: String?, + totpProvider: (any TOTPProvider)?, + promptProvider: any KeyboardInteractivePromptProvider + ) { self.password = password self.totpProvider = totpProvider + self.promptProvider = promptProvider } - /// Fetches the next TOTP code. Errors from the provider (user cancelled, missing - /// secret) are stored in `lastTotpError` and surface at the end of the kbd-int session. - /// The C callback can't throw across the libssh2 boundary, so we record the failure - /// and report it after `libssh2_userauth_keyboard_interactive_ex` returns. func nextTotpCode() -> String { guard let totpProvider else { return "" } defer { totpAttemptCount += 1 } do { return try totpProvider.provideCode(attempt: totpAttemptCount) } catch { - lastTotpError = error + lastError = error return "" } } + + /// Resolve one response per prompt. Known prompts are answered from the password / TOTP + /// fast path; the rest go to the interactive prompt in a single dialog. Every index is filled + /// (empty string on cancel) so libssh2 never frees an unset response. + func responses(name: String, instruction: String, prompts: [KeyboardInteractivePrompt]) -> [String] { + var results = [String?](repeating: nil, count: prompts.count) + var pendingIndices: [Int] = [] + + for (index, prompt) in prompts.enumerated() { + switch KeyboardInteractiveAuthenticator.classify(prompt.text) { + case .password where password != nil: + results[index] = password + case .totp where totpProvider != nil: + results[index] = nextTotpCode() + default: + pendingIndices.append(index) + } + } + + guard !pendingIndices.isEmpty, !userCancelled else { + return results.map { $0 ?? "" } + } + + let challenge = KeyboardInteractiveChallenge( + name: name, + instruction: instruction, + prompts: pendingIndices.map { prompts[$0] } + ) + + do { + let answers = try promptProvider.provideResponses(for: challenge, attempt: interactiveAttemptCount) + interactiveAttemptCount += 1 + guard answers.count == pendingIndices.count else { + userCancelled = true + lastError = SSHTunnelError.authenticationFailed(reason: .cancelled) + return results.map { $0 ?? "" } + } + for (offset, index) in pendingIndices.enumerated() { + results[index] = answers[offset] + } + } catch { + userCancelled = true + lastError = error + } + + return results.map { $0 ?? "" } + } } /// C-compatible callback for libssh2 keyboard-interactive authentication. /// -/// libssh2 calls this for each authentication challenge. The context (password/TOTP code) -/// is retrieved from the session abstract pointer. Responses are allocated with `strdup` -/// because libssh2 will `free` them. +/// libssh2 invokes this synchronously for each challenge. Responses are allocated with `strdup` +/// because libssh2 `free`s them, and every slot is filled even on cancel. private let kbdintCallback: @convention(c) ( UnsafePointer?, Int32, UnsafePointer?, Int32, @@ -62,7 +115,7 @@ private let kbdintCallback: @convention(c) ( UnsafePointer?, UnsafeMutablePointer?, UnsafeMutablePointer? -) -> Void = { _, _, _, _, numPrompts, prompts, responses, abstract in +) -> Void = { namePtr, nameLen, instructionPtr, instructionLen, numPrompts, prompts, responses, abstract in guard numPrompts > 0, let prompts, let responses, @@ -74,32 +127,34 @@ private let kbdintCallback: @convention(c) ( let context = Unmanaged.fromOpaque(contextPtr) .takeUnretainedValue() - for i in 0.. KeyboardInteractivePrompt in + let prompt = prompts[index] + let bytes: [UInt8] if let textPtr = prompt.text, prompt.length > 0 { - let buffer = UnsafeBufferPointer(start: textPtr, count: Int(prompt.length)) - promptText = String(decoding: buffer, as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + bytes = Array(UnsafeBufferPointer(start: textPtr, count: Int(prompt.length))) } else { - promptText = "" + bytes = [] } + return KeyboardInteractivePrompt(utf8Bytes: bytes, echo: prompt.echo != 0) + } - let promptType = KeyboardInteractiveAuthenticator.classifyPrompt(promptText) - - let responseText: String - switch promptType { - case .password: - responseText = context.password ?? "" - case .totp: - responseText = context.nextTotpCode() - case .unknown: - // Fall back to password for unrecognized prompts - responseText = context.password ?? "" - } + let answers = context.responses(name: name, instruction: instruction, prompts: decodedPrompts) - let duplicated = strdup(responseText) ?? strdup("") - responses[i].text = duplicated - responses[i].length = duplicated.map { UInt32(strlen($0)) } ?? 0 + for index in 0..?, length: Int32) -> String { + guard let pointer, length > 0 else { return "" } + return pointer.withMemoryRebound(to: UInt8.self, capacity: Int(length)) { bytes in + String(decoding: UnsafeBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion } } @@ -111,25 +166,25 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { let password: String? let totpProvider: (any TOTPProvider)? + let promptProvider: any KeyboardInteractivePromptProvider func authenticate(session: OpaquePointer, username: String) throws { - // Hand the provider to the callback so it can fetch a fresh code on every challenge - // (see KeyboardInteractiveContext doc comment for why this isn't done upfront). - let context = KeyboardInteractiveContext(password: password, totpProvider: totpProvider) + let context = KeyboardInteractiveContext( + password: password, + totpProvider: totpProvider, + promptProvider: promptProvider + ) let contextPtr = Unmanaged.passRetained(context).toOpaque() defer { - // Balance the passRetained call Unmanaged.fromOpaque(contextPtr).release() } - // Store context pointer in the session's abstract field let abstractPtr = libssh2_session_abstract(session) let previousAbstract = abstractPtr?.pointee abstractPtr?.pointee = contextPtr defer { - // Restore previous abstract value abstractPtr?.pointee = previousAbstract } @@ -141,9 +196,7 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { kbdintCallback ) - // Surface a totpProvider error (e.g. user cancelled the NSAlert) verbatim. It's - // already an SSHTunnelError with the right reason. - if let providerError = context.lastTotpError { + if let providerError = context.lastError { throw providerError } @@ -153,23 +206,18 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { libssh2_session_last_error(session, &msgPtr, &msgLen, 0) let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error" Self.logger.error("Keyboard-interactive authentication failed: \(detail)") - // If a TOTP code was actually delivered to the server, the rejection is most - // likely about that code. Point the user at the authenticator, not the password. - let reason: AuthFailureReason = context.totpAttemptCount > 0 ? .verificationCode : .password + let reason: AuthFailureReason = context.interactiveAttemptCount > 0 + ? .keyboardInteractive + : (context.totpAttemptCount > 0 ? .verificationCode : .password) throw SSHTunnelError.authenticationFailed(reason: reason) } Self.logger.info("Keyboard-interactive authentication succeeded") } - /// Classify a keyboard-interactive prompt to determine which credential to supply - static func classifyPrompt(_ promptText: String) -> KBDINTPromptType { + static func classify(_ promptText: String) -> KBDINTPromptType { let lower = promptText.lowercased() - if lower.contains("password") { - return .password - } - if lower.contains("verification") || lower.contains("code") || lower.contains("otp") || lower.contains("token") || lower.contains("totp") || lower.contains("2fa") || @@ -177,6 +225,10 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { return .totp } - return .unknown + if lower.contains("password") { + return .password + } + + return .unmatched } } diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractivePrompt.swift b/TablePro/Core/SSH/Auth/KeyboardInteractivePrompt.swift new file mode 100644 index 000000000..21ec8da05 --- /dev/null +++ b/TablePro/Core/SSH/Auth/KeyboardInteractivePrompt.swift @@ -0,0 +1,31 @@ +// +// KeyboardInteractivePrompt.swift +// TablePro +// + +import Foundation + +internal struct KeyboardInteractivePrompt: Sendable, Equatable { + let text: String + let echo: Bool + + init(text: String, echo: Bool) { + self.text = text + self.echo = echo + } + + init(utf8Bytes: [UInt8], echo: Bool) { + let decoded = utf8Bytes.isEmpty + ? "" + : String(decoding: utf8Bytes, as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion + self.init(text: decoded, echo: echo) + } + + var isSecure: Bool { !echo } +} + +internal struct KeyboardInteractiveChallenge: Sendable, Equatable { + let name: String + let instruction: String + let prompts: [KeyboardInteractivePrompt] +} diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractivePromptProvider.swift b/TablePro/Core/SSH/Auth/KeyboardInteractivePromptProvider.swift new file mode 100644 index 000000000..259ed965c --- /dev/null +++ b/TablePro/Core/SSH/Auth/KeyboardInteractivePromptProvider.swift @@ -0,0 +1,15 @@ +// +// KeyboardInteractivePromptProvider.swift +// TablePro +// + +import Foundation + +/// Supplies responses for a keyboard-interactive challenge the SSH server issues mid-handshake. +/// +/// One response per prompt, in the challenge's prompt order. Implementations throw when the user +/// declines to answer (cancelled), which aborts the authentication chain instead of silently +/// trying the next method. +internal protocol KeyboardInteractivePromptProvider: Sendable { + func provideResponses(for challenge: KeyboardInteractiveChallenge, attempt: Int) throws -> [String] +} diff --git a/TablePro/Core/SSH/Auth/PromptKeyboardInteractiveProvider.swift b/TablePro/Core/SSH/Auth/PromptKeyboardInteractiveProvider.swift new file mode 100644 index 000000000..c55791e02 --- /dev/null +++ b/TablePro/Core/SSH/Auth/PromptKeyboardInteractiveProvider.swift @@ -0,0 +1,81 @@ +// +// PromptKeyboardInteractiveProvider.swift +// TablePro +// + +import AppKit +import Foundation + +/// Prompts the user for keyboard-interactive responses via a modal NSAlert. +/// +/// The SSH server's own prompt text is shown verbatim, one field per prompt, with each field +/// masked or visible per the prompt's echo flag. The call blocks the SSH worker thread until the +/// user answers. `runModal()` is intentional: the caller may already be on the main thread via +/// `DispatchQueue.main.sync`, where `beginSheetModal` plus a semaphore would deadlock. +internal final class PromptKeyboardInteractiveProvider: KeyboardInteractivePromptProvider, @unchecked Sendable { + func provideResponses(for challenge: KeyboardInteractiveChallenge, attempt: Int) throws -> [String] { + let responses = Thread.isMainThread + ? showAlert(for: challenge, attempt: attempt) + : DispatchQueue.main.sync { showAlert(for: challenge, attempt: attempt) } + + guard let responses else { + throw SSHTunnelError.authenticationFailed(reason: .cancelled) + } + return responses + } + + private func showAlert(for challenge: KeyboardInteractiveChallenge, attempt: Int) -> [String]? { + let alert = NSAlert() + alert.messageText = attempt == 0 + ? String(localized: "SSH Verification Required") + : String(localized: "SSH Verification Rejected") + alert.informativeText = informativeText(for: challenge, attempt: attempt) + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Connect")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + + var fields: [NSTextField] = [] + for prompt in challenge.prompts { + let caption = NSTextField(labelWithString: prompt.text.isEmpty + ? String(localized: "Response") + : prompt.text) + caption.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + caption.textColor = .secondaryLabelColor + + let field: NSTextField = prompt.isSecure ? NSSecureTextField() : NSTextField() + field.translatesAutoresizingMaskIntoConstraints = false + field.widthAnchor.constraint(equalToConstant: 260).isActive = true + + stack.addArrangedSubview(caption) + stack.addArrangedSubview(field) + fields.append(field) + } + + stack.layoutSubtreeIfNeeded() + stack.frame = NSRect(origin: .zero, size: stack.fittingSize) + alert.accessoryView = stack + alert.layout() + alert.window.initialFirstResponder = fields.first + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + return fields.map(\.stringValue) + } + + private func informativeText(for challenge: KeyboardInteractiveChallenge, attempt: Int) -> String { + if attempt > 0 { + return String(localized: "The previous response wasn't accepted. Try again.") + } + if !challenge.instruction.isEmpty { + return challenge.instruction + } + if !challenge.name.isEmpty { + return challenge.name + } + return String(localized: "The SSH server is asking for additional verification.") + } +} diff --git a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift deleted file mode 100644 index dff4bf946..000000000 --- a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// PromptTOTPProvider.swift -// TablePro -// - -import AppKit -import Foundation - -/// Prompts the user for a TOTP code via a modal NSAlert dialog. -/// -/// This provider blocks the calling thread while the alert is displayed on the main thread. -/// It is intended for interactive SSH sessions where no TOTP secret is configured. -internal final class PromptTOTPProvider: TOTPProvider, @unchecked Sendable { - func provideCode(attempt: Int) throws -> String { - if Thread.isMainThread { - return try handleResult(showAlert(attempt: attempt)) - } - return try handleResult(DispatchQueue.main.sync { showAlert(attempt: attempt) }) - } - - // Note: runModal() is intentional here. This method runs on the main thread - // (via DispatchQueue.main.sync from provideCode), so beginSheetModal + semaphore would deadlock. - private func showAlert(attempt: Int) -> String? { - let alert = NSAlert() - alert.messageText = attempt == 0 - ? String(localized: "Verification Code Required") - : String(localized: "Verification Code Rejected") - alert.informativeText = attempt == 0 - ? String(localized: "Enter the TOTP verification code for SSH authentication.") - : String(localized: "The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code.") - alert.alertStyle = .informational - alert.addButton(withTitle: String(localized: "Connect")) - alert.addButton(withTitle: String(localized: "Cancel")) - - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - textField.placeholderString = "000000" - alert.accessoryView = textField - alert.window.initialFirstResponder = textField - - let response = alert.runModal() - return response == .alertFirstButtonReturn ? textField.stringValue : nil - } - - private func handleResult(_ code: String?) throws -> String { - guard let totpCode = code, !totpCode.isEmpty else { - throw SSHTunnelError.authenticationFailed(reason: .verificationCode) - } - return totpCode - } -} diff --git a/TablePro/Core/SSH/Auth/TOTPProvider.swift b/TablePro/Core/SSH/Auth/TOTPProvider.swift index dd9362d16..5bcfaef1c 100644 --- a/TablePro/Core/SSH/Auth/TOTPProvider.swift +++ b/TablePro/Core/SSH/Auth/TOTPProvider.swift @@ -8,10 +8,8 @@ import Foundation /// Protocol for providing TOTP verification codes internal protocol TOTPProvider: Sendable { /// Generate or obtain a TOTP code. - /// - Parameter attempt: 0 for the first prompt in a session, 1+ for retries when the - /// server rejected an earlier code (wrong digits, expired window). Implementations - /// may use this to vary UI affordances. `PromptTOTPProvider` shows a "previous code - /// was rejected" hint when `attempt > 0`. + /// - Parameter attempt: 0 for the first code in a session, 1+ for retries when the + /// server rejected an earlier code (wrong digits, expired window). /// - Returns: The TOTP code string. /// - Throws: `SSHTunnelError` if the code cannot be obtained (user cancelled, no secret). func provideCode(attempt: Int) throws -> String diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index cd20f0a92..8739b387c 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -13,7 +13,7 @@ internal struct SSHTunnelCredentials: Sendable { let sshPassword: String? let keyPassphrase: String? let totpSecret: String? - let totpProvider: (any TOTPProvider)? + let keyboardInteractivePromptProvider: (any KeyboardInteractivePromptProvider)? } /// Creates fully-connected and authenticated SSH tunnels using libssh2. @@ -462,6 +462,8 @@ internal enum LibSSH2TunnelFactory { resolved: ResolvedSSHTarget, credentials: SSHTunnelCredentials ) throws -> any SSHAuthenticator { + let promptProvider = credentials.keyboardInteractivePromptProvider ?? PromptKeyboardInteractiveProvider() + switch config.authMethod { case .password: // Always pair password with a keyboard-interactive fallback that reuses the same @@ -473,10 +475,13 @@ internal enum LibSSH2TunnelFactory { logger.error("SSH password is nil (Keychain lookup may have failed) for \(resolved.host)") throw SSHTunnelError.authenticationFailed(reason: .password) } - let totpProvider = buildTOTPProvider(config: config, credentials: credentials) return CompositeAuthenticator(authenticators: [ PasswordAuthenticator(password: sshPassword), - KeyboardInteractiveAuthenticator(password: sshPassword, totpProvider: totpProvider), + KeyboardInteractiveAuthenticator( + password: sshPassword, + totpProvider: buildTOTPProvider(config: config, credentials: credentials), + promptProvider: promptProvider + ), ]) case .privateKey: @@ -492,15 +497,12 @@ internal enum LibSSH2TunnelFactory { canPrompt: true ) } - if config.totpMode != .none { - authenticators.append(KeyboardInteractiveAuthenticator( - password: nil, - totpProvider: buildTOTPProvider(config: config, credentials: credentials) - )) - } - return authenticators.count == 1 - ? authenticators[0] - : CompositeAuthenticator(authenticators: authenticators) + authenticators.append(KeyboardInteractiveAuthenticator( + password: nil, + totpProvider: buildTOTPProvider(config: config, credentials: credentials), + promptProvider: promptProvider + )) + return CompositeAuthenticator(authenticators: authenticators) case .sshAgent: let socketPath: String? = resolved.agentSocketPath.isEmpty @@ -518,22 +520,19 @@ internal enum LibSSH2TunnelFactory { )) } - if config.totpMode != .none { - authenticators.append(KeyboardInteractiveAuthenticator( - password: nil, - totpProvider: buildTOTPProvider(config: config, credentials: credentials) - )) - } + authenticators.append(KeyboardInteractiveAuthenticator( + password: nil, + totpProvider: buildTOTPProvider(config: config, credentials: credentials), + promptProvider: promptProvider + )) - return authenticators.count == 1 - ? authenticators[0] - : CompositeAuthenticator(authenticators: authenticators) + return CompositeAuthenticator(authenticators: authenticators) case .keyboardInteractive: - let totpProvider = buildTOTPProvider(config: config, credentials: credentials) return KeyboardInteractiveAuthenticator( password: credentials.sshPassword, - totpProvider: totpProvider + totpProvider: buildTOTPProvider(config: config, credentials: credentials), + promptProvider: promptProvider ) case .none: @@ -601,7 +600,13 @@ internal enum LibSSH2TunnelFactory { addToAgentIfNeeded(path: expandedPath) return } catch { - // Auth failed — key likely needs a passphrase we don't have yet + // A wire-level rejection (or a partial success the server accepted as one + // factor of publickey,keyboard-interactive) reports AUTHENTICATION_FAILED. No + // passphrase can change that, so rethrow instead of prompting; any other errno + // means the local key file needs a passphrase we don't have yet. + guard libssh2_session_last_errno(session) != LIBSSH2_ERROR_AUTHENTICATION_FAILED else { + throw error + } } // 2. Prompt the user if allowed (key is encrypted, no stored passphrase) @@ -684,23 +689,17 @@ internal enum LibSSH2TunnelFactory { config: SSHConfiguration, credentials: SSHTunnelCredentials ) -> (any TOTPProvider)? { - switch config.totpMode { - case .none: + guard config.totpMode == .autoGenerate else { return nil } + guard let secret = credentials.totpSecret, + let generator = TOTPGenerator.fromBase32Secret( + secret, + algorithm: config.totpAlgorithm.toGeneratorAlgorithm, + digits: config.totpDigits, + period: config.totpPeriod + ) else { return nil - case .autoGenerate: - guard let secret = credentials.totpSecret, - let generator = TOTPGenerator.fromBase32Secret( - secret, - algorithm: config.totpAlgorithm.toGeneratorAlgorithm, - digits: config.totpDigits, - period: config.totpPeriod - ) else { - return nil - } - return AutoTOTPProvider(generator: generator) - case .promptAtConnect: - return credentials.totpProvider ?? PromptTOTPProvider() } + return AutoTOTPProvider(generator: generator) } // MARK: - Channel Operations diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index a7da9f36d..6f1cf3367 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -11,12 +11,14 @@ import os /// Why an SSH authentication attempt failed. Drives the user-facing error string so the /// alert points at the actual cause (wrong OTP, missing key, agent rejection) instead of /// the catch-all "credentials or private key" message. -enum AuthFailureReason: Sendable, Equatable { +enum AuthFailureReason: Sendable, Equatable, CaseIterable { case password case verificationCode case privateKey case agentRejected case passwordlessRejected + case keyboardInteractive + case cancelled case generic } @@ -51,6 +53,10 @@ enum SSHTunnelError: Error, LocalizedError, Equatable { return String(localized: "SSH agent did not authenticate. Run ssh-add -l to check loaded keys.") case .passwordlessRejected: return String(localized: "The SSH server did not accept passwordless authentication. Choose Password, Private Key, or SSH Agent.") + case .keyboardInteractive: + return String(localized: "SSH verification rejected. Check your response and try again.") + case .cancelled: + return String(localized: "SSH authentication cancelled.") case .generic: return String(localized: "SSH authentication failed. Check your credentials or private key.") } @@ -75,6 +81,13 @@ enum SSHTunnelError: Error, LocalizedError, Equatable { } } +extension SSHTunnelError { + var isUserCancelledAuthentication: Bool { + guard case .authenticationFailed(reason: .cancelled) = self else { return false } + return true + } +} + /// Manages SSH tunnels for database connections using libssh2 actor SSHTunnelManager: TunnelManaging { static let shared = SSHTunnelManager() @@ -135,7 +148,7 @@ actor SSHTunnelManager: TunnelManaging { sshPassword: sshPassword, keyPassphrase: keyPassphrase, totpSecret: totpSecret, - totpProvider: nil + keyboardInteractivePromptProvider: nil ) // Try ports until one works diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift index 52bd72da8..4407b1286 100644 --- a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift +++ b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift @@ -223,7 +223,7 @@ struct ConnectionSSHTunnelView: View { } } - if sshState.authMethod == .keyboardInteractive || sshState.authMethod == .password { + if sshState.authMethod != .none { Section(String(localized: "Two-Factor Authentication")) { Picker(String(localized: "TOTP"), selection: $sshState.totpMode) { ForEach(TOTPMode.allCases) { mode in @@ -247,8 +247,8 @@ struct ConnectionSSHTunnelView: View { Text("30s").tag(30) Text("60s").tag(60) } - } else if sshState.totpMode == .promptAtConnect { - Text(String(localized: "You will be prompted for a verification code each time you connect.")) + } else { + Text(String(localized: "If the SSH server asks for a verification code, TablePro prompts you for it when you connect.")) .font(.caption) .foregroundStyle(.secondary) } diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 9bd7903e4..5b0985955 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -75,7 +75,7 @@ struct SSHProfileEditorView: View { serverSection authenticationSection - if authMethod == .keyboardInteractive || authMethod == .password { + if authMethod != .none { totpSection } @@ -213,8 +213,8 @@ struct SSHProfileEditorView: View { Text("30s").tag(30) Text("60s").tag(60) } - } else if totpMode == .promptAtConnect { - Text(String(localized: "You will be prompted for a verification code each time you connect.")) + } else { + Text(String(localized: "If the SSH server asks for a verification code, TablePro prompts you for it when you connect.")) .font(.caption) .foregroundStyle(.secondary) } @@ -448,8 +448,6 @@ struct SSHProfileEditorView: View { testSucceeded = false testError = nil - let testTotpMode: TOTPMode = totpMode == .promptAtConnect ? .none : totpMode - let config = SSHConfiguration( enabled: true, host: host, @@ -459,7 +457,7 @@ struct SSHProfileEditorView: View { privateKeyPath: privateKeyPath, agentSocketPath: resolvedAgentSocketPath, jumpHosts: jumpHosts, - totpMode: testTotpMode, + totpMode: totpMode, totpAlgorithm: totpAlgorithm, totpDigits: totpDigits, totpPeriod: totpPeriod @@ -469,7 +467,7 @@ struct SSHProfileEditorView: View { sshPassword: sshPassword.isEmpty ? nil : sshPassword, keyPassphrase: keyPassphrase.isEmpty ? nil : keyPassphrase, totpSecret: totpSecret.isEmpty ? nil : totpSecret, - totpProvider: nil + keyboardInteractivePromptProvider: nil ) testTask = Task { diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 5139d8490..ccc0c5fd2 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -155,7 +155,6 @@ 5A87ED432F7F88F200D028D0 /* CompositeAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeAuthenticator.swift; sourceTree = ""; }; 5A87ED442F7F88F200D028D0 /* KeyboardInteractiveAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardInteractiveAuthenticator.swift; sourceTree = ""; }; 5A87ED452F7F88F200D028D0 /* PasswordAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordAuthenticator.swift; sourceTree = ""; }; - 5A87ED462F7F88F200D028D0 /* PromptTOTPProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptTOTPProvider.swift; sourceTree = ""; }; 5A87ED472F7F88F200D028D0 /* PublicKeyAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicKeyAuthenticator.swift; sourceTree = ""; }; 5A87ED482F7F88F200D028D0 /* SSHAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAuthenticator.swift; sourceTree = ""; }; 5A87ED492F7F88F200D028D0 /* TOTPProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPProvider.swift; sourceTree = ""; }; @@ -829,7 +828,6 @@ 5A87ED432F7F88F200D028D0 /* CompositeAuthenticator.swift */, 5A87ED442F7F88F200D028D0 /* KeyboardInteractiveAuthenticator.swift */, 5A87ED452F7F88F200D028D0 /* PasswordAuthenticator.swift */, - 5A87ED462F7F88F200D028D0 /* PromptTOTPProvider.swift */, 5A87ED472F7F88F200D028D0 /* PublicKeyAuthenticator.swift */, 5A87ED482F7F88F200D028D0 /* SSHAuthenticator.swift */, 5A87ED492F7F88F200D028D0 /* TOTPProvider.swift */, diff --git a/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift b/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift index 1229047bb..c4d44b032 100644 --- a/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift +++ b/TableProTests/Core/SSH/Auth/AuthFailureReasonTests.swift @@ -9,9 +9,10 @@ import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @Suite("SSHTunnelError.authenticationFailed reason") struct AuthFailureReasonTests { @Test("Verification-code reason mentions the authenticator, not the password") @@ -60,6 +61,23 @@ struct AuthFailureReasonTests { #expect(!description.localizedCaseInsensitiveContains("verification code")) } + @Test("Keyboard-interactive reason points at the verification response") + func keyboardInteractiveMessage() { + let error = SSHTunnelError.authenticationFailed(reason: .keyboardInteractive) + let description = error.errorDescription ?? "" + + #expect(description.localizedCaseInsensitiveContains("verification")) + #expect(!description.localizedCaseInsensitiveContains("private key")) + } + + @Test("Cancelled reason says the attempt was cancelled") + func cancelledMessage() { + let error = SSHTunnelError.authenticationFailed(reason: .cancelled) + let description = error.errorDescription ?? "" + + #expect(description.localizedCaseInsensitiveContains("cancel")) + } + @Test("Generic reason keeps the original wording for unknown cases") func genericMessage() { let error = SSHTunnelError.authenticationFailed(reason: .generic) @@ -68,14 +86,9 @@ struct AuthFailureReasonTests { @Test("Each reason produces a distinct, non-empty message") func allReasonsHaveDistinctMessages() { - let messages: [String] = [ - SSHTunnelError.authenticationFailed(reason: .password).errorDescription ?? "", - SSHTunnelError.authenticationFailed(reason: .verificationCode).errorDescription ?? "", - SSHTunnelError.authenticationFailed(reason: .privateKey).errorDescription ?? "", - SSHTunnelError.authenticationFailed(reason: .agentRejected).errorDescription ?? "", - SSHTunnelError.authenticationFailed(reason: .passwordlessRejected).errorDescription ?? "", - SSHTunnelError.authenticationFailed(reason: .generic).errorDescription ?? "" - ] + let messages = AuthFailureReason.allCases.map { + SSHTunnelError.authenticationFailed(reason: $0).errorDescription ?? "" + } #expect(!messages.contains("")) #expect(Set(messages).count == messages.count) diff --git a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift index f4324692b..d40c58bcf 100644 --- a/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift +++ b/TableProTests/Core/SSH/Auth/BuildAuthenticatorTests.swift @@ -9,25 +9,31 @@ // `Verification code:` the password challenge was answered with an empty string and // authentication failed. See TableProApp/TablePro#1005. // +// #1920 extends the same composition to key and agent auth: every method except None now +// appends a keyboard-interactive authenticator so a `publickey,keyboard-interactive` +// server (private key first factor, verification code second) can complete its second step. +// import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @Suite("LibSSH2TunnelFactory.buildAuthenticator") struct BuildAuthenticatorTests { private func resolved( host: String = "ssh.example.com", username: String = "alice", - port: Int = 22 + port: Int = 22, + identityFiles: [String] = [] ) -> ResolvedSSHTarget { ResolvedSSHTarget( originalHost: host, host: host, port: port, username: username, - identityFiles: [], + identityFiles: identityFiles, agentSocketPath: "", identitiesOnly: false, useKeychain: false, @@ -36,46 +42,46 @@ struct BuildAuthenticatorTests { ) } - private func passwordTOTPConfig() -> SSHConfiguration { + private func config(authMethod: SSHAuthMethod, totpMode: TOTPMode) -> SSHConfiguration { var config = SSHConfiguration( enabled: true, host: "ssh.example.com", username: "alice", - authMethod: .password + authMethod: authMethod ) - config.totpMode = .promptAtConnect + config.totpMode = totpMode return config } - @Test("Password + TOTP returns a Composite authenticator") - func passwordPlusTotpIsComposite() throws { - let credentials = SSHTunnelCredentials( - sshPassword: "hunter2", + private func credentials( + sshPassword: String? = nil, + totpSecret: String? = nil + ) -> SSHTunnelCredentials { + SSHTunnelCredentials( + sshPassword: sshPassword, keyPassphrase: nil, - totpSecret: nil, - totpProvider: nil + totpSecret: totpSecret, + keyboardInteractivePromptProvider: nil ) + } + + @Test("Password + prompt-at-connect returns a Composite authenticator") + func passwordPlusPromptIsComposite() throws { let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( - config: passwordTOTPConfig(), + config: config(authMethod: .password, totpMode: .promptAtConnect), resolved: resolved(), - credentials: credentials + credentials: credentials(sshPassword: "hunter2") ) #expect(authenticator is CompositeAuthenticator) } - @Test("Password + TOTP keyboard-interactive fallback receives the SSH password") - func passwordPlusTotpFallbackHasPassword() throws { - let credentials = SSHTunnelCredentials( - sshPassword: "hunter2", - keyPassphrase: nil, - totpSecret: nil, - totpProvider: nil - ) + @Test("Password keyboard-interactive fallback receives the SSH password (#1005)") + func passwordFallbackHasPassword() throws { let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( - config: passwordTOTPConfig(), + config: config(authMethod: .password, totpMode: .promptAtConnect), resolved: resolved(), - credentials: credentials + credentials: credentials(sshPassword: "hunter2") ) let composite = try #require(authenticator as? CompositeAuthenticator) @@ -84,29 +90,15 @@ struct BuildAuthenticatorTests { let kbdint = try #require(composite.authenticators.last as? KeyboardInteractiveAuthenticator) #expect(kbdint.password == "hunter2") - #expect(kbdint.totpProvider != nil) + #expect(kbdint.totpProvider == nil) } @Test("Password without TOTP still falls through to keyboard-interactive with the SSH password") func passwordWithoutTotpFallsThroughToKeyboardInteractive() throws { - var config = SSHConfiguration( - enabled: true, - host: "ssh.example.com", - username: "alice", - authMethod: .password - ) - config.totpMode = .none - let credentials = SSHTunnelCredentials( - sshPassword: "hunter2", - keyPassphrase: nil, - totpSecret: nil, - totpProvider: nil - ) - let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( - config: config, + config: config(authMethod: .password, totpMode: .none), resolved: resolved(), - credentials: credentials + credentials: credentials(sshPassword: "hunter2") ) let composite = try #require(authenticator as? CompositeAuthenticator) @@ -118,26 +110,53 @@ struct BuildAuthenticatorTests { #expect(kbdint.totpProvider == nil) } - @Test("None auth method returns a NoneAuthenticator") - func noneReturnsNoneAuthenticator() throws { - var config = SSHConfiguration( - enabled: true, - host: "ssh.example.com", - username: "alice", - authMethod: .none + @Test("Auto-generate TOTP builds a generating provider for the fallback") + func autoGenerateProducesProvider() throws { + let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( + config: config(authMethod: .password, totpMode: .autoGenerate), + resolved: resolved(), + credentials: credentials(sshPassword: "hunter2", totpSecret: "JBSWY3DPEHPK3PXP") ) - config.totpMode = .none - let credentials = SSHTunnelCredentials( - sshPassword: nil, - keyPassphrase: nil, - totpSecret: nil, - totpProvider: nil + let composite = try #require(authenticator as? CompositeAuthenticator) + let kbdint = try #require(composite.authenticators.last as? KeyboardInteractiveAuthenticator) + + #expect(kbdint.totpProvider != nil) + } + + @Test("Private key auth appends a keyboard-interactive fallback even without TOTP (#1920)") + func privateKeyAppendsKeyboardInteractive() throws { + let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( + config: config(authMethod: .privateKey, totpMode: .none), + resolved: resolved(identityFiles: ["/home/alice/.ssh/id_ed25519"]), + credentials: credentials() ) + let composite = try #require(authenticator as? CompositeAuthenticator) + + let kbdint = try #require(composite.authenticators.last as? KeyboardInteractiveAuthenticator) + #expect(kbdint.password == nil) + #expect(kbdint.totpProvider == nil) + } + @Test("SSH agent auth appends a keyboard-interactive fallback even without TOTP (#1920)") + func sshAgentAppendsKeyboardInteractive() throws { let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( - config: config, + config: config(authMethod: .sshAgent, totpMode: .none), resolved: resolved(), - credentials: credentials + credentials: credentials() + ) + let composite = try #require(authenticator as? CompositeAuthenticator) + + #expect(composite.authenticators.first is AgentAuthenticator) + let kbdint = try #require(composite.authenticators.last as? KeyboardInteractiveAuthenticator) + #expect(kbdint.password == nil) + } + + @Test("None auth method returns a NoneAuthenticator") + func noneReturnsNoneAuthenticator() throws { + let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( + config: config(authMethod: .none, totpMode: .none), + resolved: resolved(), + credentials: credentials() ) #expect(authenticator is NoneAuthenticator) @@ -145,52 +164,24 @@ struct BuildAuthenticatorTests { @Test("Password auth method with no password throws before any libssh2 call") func passwordWithoutCredentialThrows() { - var config = SSHConfiguration( - enabled: true, - host: "ssh.example.com", - username: "alice", - authMethod: .password - ) - config.totpMode = .none - let credentials = SSHTunnelCredentials( - sshPassword: nil, - keyPassphrase: nil, - totpSecret: nil, - totpProvider: nil - ) - #expect(throws: SSHTunnelError.authenticationFailed(reason: .password)) { try LibSSH2TunnelFactory.buildAuthenticator( - config: config, + config: config(authMethod: .password, totpMode: .none), resolved: resolved(), - credentials: credentials + credentials: credentials() ) } } @Test("Keyboard-Interactive auth method passes the password through directly") func keyboardInteractivePassesPassword() throws { - var config = SSHConfiguration( - enabled: true, - host: "ssh.example.com", - username: "alice", - authMethod: .keyboardInteractive - ) - config.totpMode = .promptAtConnect - let credentials = SSHTunnelCredentials( - sshPassword: "hunter2", - keyPassphrase: nil, - totpSecret: nil, - totpProvider: nil - ) - let authenticator = try LibSSH2TunnelFactory.buildAuthenticator( - config: config, + config: config(authMethod: .keyboardInteractive, totpMode: .promptAtConnect), resolved: resolved(), - credentials: credentials + credentials: credentials(sshPassword: "hunter2") ) let kbdint = try #require(authenticator as? KeyboardInteractiveAuthenticator) #expect(kbdint.password == "hunter2") - #expect(kbdint.totpProvider != nil) + #expect(kbdint.totpProvider == nil) } } diff --git a/TableProTests/Core/SSH/Auth/CompositeAuthenticatorTests.swift b/TableProTests/Core/SSH/Auth/CompositeAuthenticatorTests.swift new file mode 100644 index 000000000..a1bb758a3 --- /dev/null +++ b/TableProTests/Core/SSH/Auth/CompositeAuthenticatorTests.swift @@ -0,0 +1,35 @@ +// +// CompositeAuthenticatorTests.swift +// TableProTests +// +// CompositeAuthenticator swallows a method failure and tries the next method (needed for the +// publickey partial-success chain), but a user cancellation must abort the whole chain instead +// of falling through to a stale password or agent. The abort hinges on this predicate. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("SSHTunnelError.isUserCancelledAuthentication") +struct CompositeAuthenticatorCancellationTests { + @Test("A cancelled auth failure is recognized as a user cancellation") + func cancelledReasonIsUserCancelled() { + #expect(SSHTunnelError.authenticationFailed(reason: .cancelled).isUserCancelledAuthentication) + } + + @Test("Every other auth failure reason is not a user cancellation") + func otherReasonsAreNotUserCancelled() { + for reason in AuthFailureReason.allCases where reason != .cancelled { + #expect(!SSHTunnelError.authenticationFailed(reason: reason).isUserCancelledAuthentication) + } + } + + @Test("Non-authentication tunnel errors are not user cancellations") + func nonAuthErrorsAreNotUserCancelled() { + #expect(!SSHTunnelError.connectionTimeout.isUserCancelledAuthentication) + #expect(!SSHTunnelError.channelOpenFailed.isUserCancelledAuthentication) + } +} diff --git a/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift b/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift index e4c8e8124..5d4f8023c 100644 --- a/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift +++ b/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift @@ -2,91 +2,247 @@ // KeyboardInteractiveContextTests.swift // TableProTests // -// Verifies the lazy TOTP fetch + retry counter behavior of KeyboardInteractiveContext. -// The C callback consults this context for every prompt the server sends; the upfront -// fetch (single NSAlert before kbd-int starts) was the source of the "code expired -// during handshake" race and prevented OpenSSH-style retry within a single session. +// Verifies how KeyboardInteractiveContext resolves each server prompt: the password / TOTP +// fast path, deferring unknown prompts to the interactive provider that shows the server's +// own text (#1920), and the cancel path that fills empty responses without re-prompting. The +// lazy TOTP fetch (a fresh code per challenge) avoids the "code expired during handshake" race. // import Foundation import TableProPluginKit -@testable import TablePro import Testing -@Suite("KeyboardInteractiveContext") -struct KeyboardInteractiveContextTests { - final class StubTOTPProvider: TOTPProvider, @unchecked Sendable { - private(set) var attemptsSeen: [Int] = [] - let codes: [String] - var errorOnAttempt: Int? - - init(codes: [String], errorOnAttempt: Int? = nil) { - self.codes = codes - self.errorOnAttempt = errorOnAttempt +@testable import TablePro + +private final class StubTOTPProvider: TOTPProvider, @unchecked Sendable { + private(set) var attemptsSeen: [Int] = [] + let codes: [String] + var errorOnAttempt: Int? + + init(codes: [String], errorOnAttempt: Int? = nil) { + self.codes = codes + self.errorOnAttempt = errorOnAttempt + } + + func provideCode(attempt: Int) throws -> String { + attemptsSeen.append(attempt) + if errorOnAttempt == attempt { + throw SSHTunnelError.authenticationFailed(reason: .verificationCode) } + return codes[min(attempt, codes.count - 1)] + } +} + +private final class StubKeyboardInteractivePromptProvider: KeyboardInteractivePromptProvider, @unchecked Sendable { + private(set) var callCount = 0 + private(set) var attemptsSeen: [Int] = [] + private(set) var lastChallenge: KeyboardInteractiveChallenge? + let answers: [String] + var shouldCancel: Bool + + init(answers: [String], shouldCancel: Bool = false) { + self.answers = answers + self.shouldCancel = shouldCancel + } - func provideCode(attempt: Int) throws -> String { - attemptsSeen.append(attempt) - if errorOnAttempt == attempt { - throw SSHTunnelError.authenticationFailed(reason: .verificationCode) - } - return codes[min(attempt, codes.count - 1)] + func provideResponses(for challenge: KeyboardInteractiveChallenge, attempt: Int) throws -> [String] { + callCount += 1 + attemptsSeen.append(attempt) + lastChallenge = challenge + if shouldCancel { + throw SSHTunnelError.authenticationFailed(reason: .cancelled) } + return answers } +} + +private func context( + password: String? = nil, + totpProvider: (any TOTPProvider)? = nil, + promptProvider: any KeyboardInteractivePromptProvider = StubKeyboardInteractivePromptProvider(answers: []) +) -> KeyboardInteractiveContext { + KeyboardInteractiveContext(password: password, totpProvider: totpProvider, promptProvider: promptProvider) +} +private func prompt(_ text: String, echo: Bool = false) -> KeyboardInteractivePrompt { + KeyboardInteractivePrompt(text: text, echo: echo) +} + +@Suite("KeyboardInteractiveContext TOTP fetch") +struct KeyboardInteractiveContextTests { @Test("nextTotpCode returns empty when no provider is configured") func noProviderReturnsEmpty() { - let context = KeyboardInteractiveContext(password: "p", totpProvider: nil) - #expect(context.nextTotpCode() == "") - #expect(context.totpAttemptCount == 0) + let ctx = context(password: "p") + #expect(ctx.nextTotpCode() == "") + #expect(ctx.totpAttemptCount == 0) } @Test("Each call asks the provider for a fresh code with an incrementing attempt index") func incrementsAttemptCounter() { let provider = StubTOTPProvider(codes: ["111111", "222222", "333333"]) - let context = KeyboardInteractiveContext(password: "p", totpProvider: provider) + let ctx = context(password: "p", totpProvider: provider) - #expect(context.nextTotpCode() == "111111") - #expect(context.nextTotpCode() == "222222") - #expect(context.nextTotpCode() == "333333") + #expect(ctx.nextTotpCode() == "111111") + #expect(ctx.nextTotpCode() == "222222") + #expect(ctx.nextTotpCode() == "333333") #expect(provider.attemptsSeen == [0, 1, 2]) - #expect(context.totpAttemptCount == 3) + #expect(ctx.totpAttemptCount == 3) } @Test("Provider error is captured and code falls back to empty string") func providerErrorIsStored() { let provider = StubTOTPProvider(codes: ["111111"], errorOnAttempt: 0) - let context = KeyboardInteractiveContext(password: "p", totpProvider: provider) + let ctx = context(password: "p", totpProvider: provider) + + #expect(ctx.nextTotpCode() == "") + #expect(ctx.lastError != nil) + #expect(ctx.totpAttemptCount == 1) + } +} + +@Suite("KeyboardInteractiveContext prompt resolution") +struct KeyboardInteractiveResponsesTests { + @Test("A password prompt is answered from the fast path without prompting the user") + func passwordFastPath() { + let stub = StubKeyboardInteractivePromptProvider(answers: []) + let ctx = context(password: "hunter2", promptProvider: stub) + + let result = ctx.responses(name: "", instruction: "", prompts: [prompt("Password: ")]) + + #expect(result == ["hunter2"]) + #expect(stub.callCount == 0) + } + + @Test("A verification-code prompt is answered from the auto-generate provider without prompting") + func totpFastPath() { + let stub = StubKeyboardInteractivePromptProvider(answers: []) + let ctx = context(totpProvider: StubTOTPProvider(codes: ["123456"]), promptProvider: stub) + + let result = ctx.responses(name: "", instruction: "", prompts: [prompt("Verification code: ")]) + + #expect(result == ["123456"]) + #expect(stub.callCount == 0) + } + + @Test("An unrecognized prompt goes to the user, never leaking the SSH password (#1920)") + func unmatchedPromptDefersToUser() { + let stub = StubKeyboardInteractivePromptProvider(answers: ["typed-answer"]) + let ctx = context(password: "hunter2", promptProvider: stub) - let result = context.nextTotpCode() - #expect(result == "") - #expect(context.lastTotpError != nil) - #expect(context.totpAttemptCount == 1) + let result = ctx.responses(name: "", instruction: "", prompts: [prompt("Enter your PIN: ")]) + + #expect(result == ["typed-answer"]) + #expect(result.first != "hunter2") + #expect(stub.callCount == 1) + } + + @Test("Fast-path and interactive answers map back to their original prompt indices") + func mixedFastPathAndInteractiveOrdering() { + let stub = StubKeyboardInteractivePromptProvider(answers: ["pin-answer"]) + let ctx = context(password: "hunter2", promptProvider: stub) + + let result = ctx.responses(name: "", instruction: "", prompts: [ + prompt("Password: "), + prompt("PIN: ") + ]) + + #expect(result == ["hunter2", "pin-answer"]) + #expect(stub.lastChallenge?.prompts.count == 1) + #expect(stub.lastChallenge?.prompts.first?.text == "PIN: ") + } + + @Test("Two unrecognized prompts are collected into one challenge and mapped in order") + func multiplePromptsInOneChallenge() { + let stub = StubKeyboardInteractivePromptProvider(answers: ["A", "B"]) + let ctx = context(promptProvider: stub) + + let result = ctx.responses(name: "", instruction: "", prompts: [ + prompt("First: ", echo: true), + prompt("Second: ") + ]) + + #expect(result == ["A", "B"]) + #expect(stub.callCount == 1) + #expect(stub.lastChallenge?.prompts.count == 2) + } + + @Test("Cancelling fills empty responses and records the cancellation") + func cancelFillsEmptyResponses() { + let stub = StubKeyboardInteractivePromptProvider(answers: [], shouldCancel: true) + let ctx = context(promptProvider: stub) + + let result = ctx.responses(name: "", instruction: "", prompts: [prompt("PIN: ")]) + + #expect(result == [""]) + #expect(ctx.userCancelled) + #expect(ctx.lastError != nil) + } + + @Test("After a cancel the same session does not prompt again") + func cancelSuppressesFurtherPrompts() { + let stub = StubKeyboardInteractivePromptProvider(answers: [], shouldCancel: true) + let ctx = context(promptProvider: stub) + + _ = ctx.responses(name: "", instruction: "", prompts: [prompt("PIN: ")]) + let second = ctx.responses(name: "", instruction: "", prompts: [prompt("PIN: ")]) + + #expect(second == [""]) + #expect(stub.callCount == 1) } - @Test("Counter still increments after a provider error so retry callbacks see attempt > 0") - func counterIncrementsThroughErrors() { - let provider = StubTOTPProvider(codes: ["111111", "222222"], errorOnAttempt: 0) - let context = KeyboardInteractiveContext(password: "p", totpProvider: provider) + @Test("The interactive attempt index increments across rounds for retry messaging") + func interactiveAttemptCounterIncrements() { + let stub = StubKeyboardInteractivePromptProvider(answers: ["x"]) + let ctx = context(promptProvider: stub) - _ = context.nextTotpCode() // first call errors - #expect(context.totpAttemptCount == 1) + _ = ctx.responses(name: "", instruction: "", prompts: [prompt("PIN: ")]) + _ = ctx.responses(name: "", instruction: "", prompts: [prompt("PIN: ")]) - provider.errorOnAttempt = nil - let second = context.nextTotpCode() - #expect(second == "222222") - #expect(provider.attemptsSeen == [0, 1]) + #expect(stub.attemptsSeen == [0, 1]) } } -@Suite("PromptTOTPProvider attempt-aware messaging") -struct PromptTOTPProviderShapeTests { - @Test("Conformance covers the attempt-bearing protocol method") - func providerConformsToProtocol() { - let provider: any TOTPProvider = PromptTOTPProvider() - // Just verify the protocol witness compiles. The alert UI path is not exercised - // here because runModal would block the test runner. - _ = provider +@Suite("KeyboardInteractivePrompt") +struct KeyboardInteractivePromptTests { + @Test("Length-delimited UTF-8 bytes decode without assuming NUL-termination") + func decodesUtf8Bytes() { + let bytes = Array("Código: ".utf8) + #expect(KeyboardInteractivePrompt(utf8Bytes: bytes, echo: false).text == "Código: ") + } + + @Test("Empty byte buffer decodes to an empty string") + func emptyBytesDecodeToEmptyString() { + #expect(KeyboardInteractivePrompt(utf8Bytes: [], echo: true).text == "") + } + + @Test("isSecure follows the echo flag: hidden when echo is off") + func isSecureFollowsEcho() { + #expect(KeyboardInteractivePrompt(text: "x", echo: false).isSecure) + #expect(!KeyboardInteractivePrompt(text: "x", echo: true).isSecure) + } +} + +@Suite("KeyboardInteractiveAuthenticator.classify") +struct KeyboardInteractiveClassifyTests { + @Test("A password prompt classifies as password") + func passwordPrompt() { + #expect(KeyboardInteractiveAuthenticator.classify("Password: ") == .password) + } + + @Test("A verification-code prompt classifies as TOTP") + func verificationCodePrompt() { + #expect(KeyboardInteractiveAuthenticator.classify("Verification code: ") == .totp) + } + + @Test("A prompt with no known keyword is unmatched") + func genericPromptIsUnmatched() { + #expect(KeyboardInteractiveAuthenticator.classify("Enter your PIN: ") == .unmatched) + #expect(KeyboardInteractiveAuthenticator.classify("Response: ") == .unmatched) + } + + @Test("One-time password classifies as a code, not the SSH password") + func oneTimePasswordClassifiesAsTotp() { + #expect(KeyboardInteractiveAuthenticator.classify("One-time password: ") == .totp) } } diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index 978870f2d..b0dbcca32 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -46,6 +46,8 @@ To reuse one SSH config across several connections, save it as a profile with ** | **Passphrase** | Key passphrase, if the key is encrypted | Leave **Key File** empty to auto-detect the key from `~/.ssh/config` and default key locations. + + If the server also requires a keyboard-interactive step such as a 2FA code, TablePro prompts for it after the key is accepted. Signing is delegated to an agent process; TablePro never reads the key. The **Agent Socket** picker offers: @@ -53,6 +55,8 @@ To reuse one SSH config across several connections, save it as a profile with ** - **SSH_AUTH_SOCK**: the system `SSH_AUTH_SOCK` environment variable - **1Password**: 1Password's socket at `~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock` - **Custom Path**: any other agent socket path (Secretive, custom `ssh-agent`) + + If the server also requires a keyboard-interactive step such as a 2FA code, TablePro prompts for it after the agent authenticates. Sends your password through SSH's keyboard-interactive challenge-response. Use this when the server rejects plain password auth, which is common with PAM-based setups. @@ -62,11 +66,13 @@ To reuse one SSH config across several connections, save it as a profile with ** -### Two-Factor Authentication (TOTP) +### Verification Prompts and Two-Factor Authentication + +If the SSH server issues a keyboard-interactive challenge during authentication, for example a verification code from `google-authenticator` or `duo_unix`, TablePro shows the server's prompt and lets you type the response. This works with every authentication method except **None**, including a private key or SSH agent followed by a second factor (`AuthenticationMethods publickey,keyboard-interactive`). -When the auth method is **Password** or **Keyboard Interactive**, a **Two-Factor Authentication** section appears for servers that require TOTP codes (PAM modules like `google-authenticator` or `duo_unix`): +The **Two-Factor Authentication** section lets you skip that prompt for TOTP codes: -- **Prompt at Connect**: TablePro asks for the code each time you connect. +- **None** or **Prompt at Connect**: TablePro asks for the code when the server requests it. - **Auto Generate**: TablePro computes the code from your base32 **TOTP Secret** (the key from your authenticator enrollment). Algorithm (SHA1, SHA256, SHA512), digits (6 or 8), and period (30s or 60s) are configurable; defaults match most setups. ## Host Keys diff --git a/docs/features/ssh-profiles.mdx b/docs/features/ssh-profiles.mdx index cd2928ce8..4b0357b55 100644 --- a/docs/features/ssh-profiles.mdx +++ b/docs/features/ssh-profiles.mdx @@ -35,7 +35,7 @@ Select a profile and click **Edit Profile...** to modify it. **Delete Profile** ## Testing a Profile -Click **Test Connection** in the profile editor to verify SSH settings without connecting to a database. TablePro performs the SSH handshake, verifies the host key, and authenticates. Green checkmark on success; error dialog on failure. +Click **Test Connection** in the profile editor to verify SSH settings without connecting to a database. TablePro performs the SSH handshake, verifies the host key, and authenticates. Green checkmark on success; error dialog on failure. If the server asks for a verification code or another interactive response, the same prompt you'd see on Connect appears during the test. ## iCloud Sync