Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/SSH/Auth/CompositeAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 115 additions & 63 deletions TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,61 +8,114 @@ 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<CChar>?, Int32,
UnsafePointer<CChar>?, Int32,
Int32,
UnsafePointer<LIBSSH2_USERAUTH_KBDINT_PROMPT>?,
UnsafeMutablePointer<LIBSSH2_USERAUTH_KBDINT_RESPONSE>?,
UnsafeMutablePointer<UnsafeMutableRawPointer?>?
) -> Void = { _, _, _, _, numPrompts, prompts, responses, abstract in
) -> Void = { namePtr, nameLen, instructionPtr, instructionLen, numPrompts, prompts, responses, abstract in
guard numPrompts > 0,
let prompts,
let responses,
Expand All @@ -74,32 +127,34 @@ private let kbdintCallback: @convention(c) (
let context = Unmanaged<KeyboardInteractiveContext>.fromOpaque(contextPtr)
.takeUnretainedValue()

for i in 0..<Int(numPrompts) {
let prompt = prompts[i]
let promptText: String
let name = decodeKbdintString(namePtr, length: nameLen)
let instruction = decodeKbdintString(instructionPtr, length: instructionLen)

let decodedPrompts = (0..<Int(numPrompts)).map { index -> 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..<Int(numPrompts) {
let answer = index < answers.count ? answers[index] : ""
let duplicated = strdup(answer) ?? strdup("")
responses[index].text = duplicated
responses[index].length = duplicated.map { UInt32(strlen($0)) } ?? 0
}
}

private func decodeKbdintString(_ pointer: UnsafePointer<CChar>?, 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
}
}

Expand All @@ -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<KeyboardInteractiveContext>.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
}

Expand All @@ -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
}

Expand All @@ -153,30 +206,29 @@ 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") ||
lower.contains("one-time") || lower.contains("factor") {
return .totp
}

return .unknown
if lower.contains("password") {
return .password
}

return .unmatched
}
}
31 changes: 31 additions & 0 deletions TablePro/Core/SSH/Auth/KeyboardInteractivePrompt.swift
Original file line number Diff line number Diff line change
@@ -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]
}
15 changes: 15 additions & 0 deletions TablePro/Core/SSH/Auth/KeyboardInteractivePromptProvider.swift
Original file line number Diff line number Diff line change
@@ -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]
}
Loading
Loading