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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cell editor opening off screen when `Tab` wrapped onto a row below the visible ones.
- Cell cursor left on the old column after `Tab` carried the editor to the next one.
- Every data grid switching to its accessibility layout after one `Tab` press, with no assistive app attached.
- SSH Agent auth prompting for a private key passphrase instead of reporting that the agent was never reached. (#2583)
- "SSH password rejected" on an SSH connection that has no password, when the server offers no keyboard-interactive.

## [0.69.0] - 2026-08-27

Expand Down
20 changes: 15 additions & 5 deletions TablePro/Core/SSH/Auth/AgentAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal struct AgentAuthenticator: SSHAuthenticator {
private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator")

let socketPath: String?
let socketOrigin: AgentSocketOrigin

/// Resolve SSH_AUTH_SOCK via launchctl for GUI apps that don't inherit shell env.
private static func resolveSocketViaLaunchctl() -> String? {
Expand Down Expand Up @@ -51,7 +52,7 @@ internal struct AgentAuthenticator: SSHAuthenticator {
}

guard let agent = libssh2_agent_init(session) else {
throw SSHTunnelError.tunnelCreationFailed("Failed to initialize SSH agent")
throw SSHTunnelError.authenticationFailed(reason: .agentUnavailable(socketOrigin))
}

defer {
Expand All @@ -71,17 +72,18 @@ internal struct AgentAuthenticator: SSHAuthenticator {
var rc = libssh2_agent_connect(agent)
guard rc == 0 else {
Self.logger.error("Failed to connect to SSH agent (rc=\(rc))")
throw SSHTunnelError.tunnelCreationFailed("Failed to connect to SSH agent")
throw SSHTunnelError.authenticationFailed(reason: .agentUnavailable(socketOrigin))
}

rc = libssh2_agent_list_identities(agent)
guard rc == 0 else {
Self.logger.error("Failed to list SSH agent identities (rc=\(rc))")
throw SSHTunnelError.tunnelCreationFailed("Failed to list SSH agent identities")
throw SSHTunnelError.authenticationFailed(reason: .agentUnavailable(socketOrigin))
}

var previousIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
var currentIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
var offeredCount = 0

while true {
rc = libssh2_agent_get_identity(agent, &currentIdentity, previousIdentity)
Expand All @@ -92,13 +94,14 @@ internal struct AgentAuthenticator: SSHAuthenticator {
}
if rc < 0 {
Self.logger.error("Failed to get SSH agent identity (rc=\(rc))")
throw SSHTunnelError.tunnelCreationFailed("Failed to get SSH agent identity")
throw SSHTunnelError.authenticationFailed(reason: .agentUnavailable(socketOrigin))
}

guard let identity = currentIdentity else {
break
}

offeredCount += 1
let authRc = libssh2_agent_userauth(agent, username, identity)
if authRc == 0 {
Self.logger.info("SSH agent authentication succeeded")
Expand All @@ -108,7 +111,14 @@ internal struct AgentAuthenticator: SSHAuthenticator {
previousIdentity = identity
}

Self.logger.error("SSH agent authentication failed: no identity accepted")
// An agent that answered but offered nothing is a locked or empty agent, which the user
// fixes somewhere entirely different from an agent whose keys the server refused.
guard offeredCount > 0 else {
Self.logger.error("SSH agent offered no identities")
throw SSHTunnelError.authenticationFailed(reason: .agentNoIdentities(socketOrigin))
}

Self.logger.error("SSH agent authentication failed: none of \(offeredCount) identities accepted")
throw SSHTunnelError.authenticationFailed(reason: .agentRejected)
}
}
29 changes: 28 additions & 1 deletion TablePro/Core/SSH/Auth/CompositeAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ import CLibSSH2

/// Authenticator that tries multiple auth methods in sequence.
/// Used for servers requiring e.g. password + keyboard-interactive (TOTP).
///
/// The reported failure is the last step that actually offered the server a credential. A later
/// step the server never engaged (keyboard-interactive on a server that issues no prompt) would
/// otherwise bury the real reason: an SSH agent that never answered used to surface as
/// "SSH password rejected" on a connection that has no password.
internal struct CompositeAuthenticator: SSHAuthenticator {
private static let logger = Logger(subsystem: "com.TablePro", category: "CompositeAuthenticator")

let authenticators: [any SSHAuthenticator]

/// Failures after which the remaining steps are not worth running. The SSH Agent chain names
/// the two agent failures that mean no first factor was ever supplied, because the
/// keyboard-interactive step behind them is a second factor and would otherwise ask for a
/// credential of its own instead of reporting the agent.
var endsChainOn: Set<AuthFailureReason> = []

func authenticate(session: OpaquePointer, username: String) throws {
var lastError: Error?
for (index, authenticator) in authenticators.enumerated() {
Expand All @@ -25,7 +36,13 @@ internal struct CompositeAuthenticator: SSHAuthenticator {
throw error
} catch {
Self.logger.debug("Authenticator \(index + 1) failed: \(error)")
lastError = error
if lastError == nil || Self.describesAnAttempt(error) {
lastError = error
}
if Self.reason(of: error).map(endsChainOn.contains) == true {
Self.logger.debug("Authenticator \(index + 1) ended the chain")
throw error
}
}

if libssh2_userauth_authenticated(session) != 0 {
Expand All @@ -38,4 +55,14 @@ internal struct CompositeAuthenticator: SSHAuthenticator {
throw lastError ?? SSHTunnelError.authenticationFailed(reason: .generic)
}
}

private static func describesAnAttempt(_ error: any Error) -> Bool {
reason(of: error)?.describesAnAttempt ?? true
}

private static func reason(of error: any Error) -> AuthFailureReason? {
guard let tunnelError = error as? SSHTunnelError,
case .authenticationFailed(let reason) = tunnelError else { return nil }
return reason
}
}
17 changes: 13 additions & 4 deletions TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ internal final class KeyboardInteractiveContext {
let promptProvider: any KeyboardInteractivePromptProvider
private(set) var totpAttemptCount = 0
private(set) var interactiveAttemptCount = 0
private(set) var passwordAnswerCount = 0
private(set) var userCancelled = false
var lastError: Error?

Expand All @@ -45,6 +46,16 @@ internal final class KeyboardInteractiveContext {
self.promptProvider = promptProvider
}

/// What the rejection was about, named by whichever answer actually went to the server. A
/// server that issued no prompt at all never took a credential from this method, so the
/// failure says nothing about the user's own: it says keyboard-interactive was not on offer.
var failureReason: AuthFailureReason {
if interactiveAttemptCount > 0 { return .keyboardInteractive }
if totpAttemptCount > 0 { return .verificationCode }
if passwordAnswerCount > 0 { return .password }
return .methodUnavailable
}

func nextTotpCode() -> String {
guard let totpProvider else { return "" }
defer { totpAttemptCount += 1 }
Expand All @@ -67,6 +78,7 @@ internal final class KeyboardInteractiveContext {
switch KeyboardInteractiveAuthenticator.classify(prompt.text) {
case .password where password != nil:
results[index] = password
passwordAnswerCount += 1
case .totp where totpProvider != nil:
results[index] = nextTotpCode()
default:
Expand Down Expand Up @@ -206,10 +218,7 @@ 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)")
let reason: AuthFailureReason = context.interactiveAttemptCount > 0
? .keyboardInteractive
: (context.totpAttemptCount > 0 ? .verificationCode : .password)
throw SSHTunnelError.authenticationFailed(reason: reason)
throw SSHTunnelError.authenticationFailed(reason: context.failureReason)
}

Self.logger.info("Keyboard-interactive authentication succeeded")
Expand Down
61 changes: 27 additions & 34 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ internal enum LibSSH2TunnelFactory {

// MARK: - Global Init

private static let initialized: Bool = {
/// libssh2's own header says `libssh2_init` uses global state and must not be called
/// concurrently, so every entry point in the process goes through this one lazy static.
internal static let initialized: Bool = {
libssh2_init(0)
return true
}()
Expand Down Expand Up @@ -501,8 +503,7 @@ internal enum LibSSH2TunnelFactory {
buildKeyFileAuthenticator(
keyPath: keyPath,
providedPassphrase: credentials.keyPassphrase,
resolved: resolved,
canPrompt: true
resolved: resolved
)
}
authenticators.append(KeyboardInteractiveAuthenticator(
Expand All @@ -513,28 +514,28 @@ internal enum LibSSH2TunnelFactory {
return CompositeAuthenticator(authenticators: authenticators)

case .sshAgent:
// The agent is the credential, so there is no key-file fallback: authenticating with a
// key the user never chose put TablePro's own passphrase prompt over an agent that had
// simply not been reached (#2583). Keyboard-interactive stays, being a second factor the
// same server asked for rather than another credential.
let socketPath: String? = resolved.agentSocketPath.isEmpty
? nil
: SSHPathUtilities.expandTilde(resolved.agentSocketPath)

var authenticators: [any SSHAuthenticator] = [AgentAuthenticator(socketPath: socketPath)]

for keyPath in effectiveKeyPaths(for: resolved) {
authenticators.append(buildKeyFileAuthenticator(
keyPath: keyPath,
providedPassphrase: credentials.keyPassphrase,
resolved: resolved,
canPrompt: true
))
}

authenticators.append(KeyboardInteractiveAuthenticator(
password: nil,
totpProvider: buildTOTPProvider(config: config, credentials: credentials),
promptProvider: promptProvider
))

return CompositeAuthenticator(authenticators: authenticators)
return CompositeAuthenticator(
authenticators: [
AgentAuthenticator(socketPath: socketPath, socketOrigin: resolved.agentSocketOrigin),
KeyboardInteractiveAuthenticator(
password: nil,
totpProvider: buildTOTPProvider(config: config, credentials: credentials),
promptProvider: promptProvider
),
],
endsChainOn: Set(
AgentSocketOrigin.allCases.map(AuthFailureReason.agentUnavailable)
+ AgentSocketOrigin.allCases.map(AuthFailureReason.agentNoIdentities)
)
)

case .keyboardInteractive:
return KeyboardInteractiveAuthenticator(
Expand Down Expand Up @@ -562,19 +563,16 @@ internal enum LibSSH2TunnelFactory {
.filter { FileManager.default.isReadableFile(atPath: $0) }
}

/// Passphrase resolution is deferred to auth time (not build time) so
/// that, when this authenticator is used as an agent fallback, the user
/// is only prompted if the agent actually fails.
/// Passphrase resolution is deferred to auth time (not build time) so that a key later in
/// the chain only prompts once the ones before it have actually been refused.
private static func buildKeyFileAuthenticator(
keyPath: String,
providedPassphrase: String?,
resolved: ResolvedSSHTarget,
canPrompt: Bool
resolved: ResolvedSSHTarget
) -> any SSHAuthenticator {
KeyFileAuthenticator(
keyPath: keyPath,
providedPassphrase: providedPassphrase,
canPrompt: canPrompt,
useKeychain: resolved.useKeychain,
addKeysToAgent: resolved.addKeysToAgent
)
Expand All @@ -586,7 +584,6 @@ internal enum LibSSH2TunnelFactory {
private struct KeyFileAuthenticator: SSHAuthenticator {
let keyPath: String
let providedPassphrase: String?
let canPrompt: Bool
let useKeychain: Bool
let addKeysToAgent: Bool

Expand Down Expand Up @@ -617,9 +614,7 @@ internal enum LibSSH2TunnelFactory {
}
}

// 2. Prompt the user if allowed (key is encrypted, no stored passphrase)
guard canPrompt else { throw SSHTunnelError.authenticationFailed(reason: .privateKey) }

// 2. Prompt the user (key is encrypted, no stored passphrase)
let provider = PromptPassphraseProvider(keyPath: expandedPath)
guard let promptResult = provider.providePassphrase() else {
throw SSHTunnelError.authenticationFailed(reason: .privateKey)
Expand Down Expand Up @@ -668,7 +663,6 @@ internal enum LibSSH2TunnelFactory {
KeyFileAuthenticator(
keyPath: path,
providedPassphrase: nil,
canPrompt: true,
useKeychain: resolved.useKeychain,
addKeysToAgent: resolved.addKeysToAgent
)
Expand All @@ -678,12 +672,11 @@ internal enum LibSSH2TunnelFactory {
: CompositeAuthenticator(authenticators: authenticators)
case .sshAgent:
let socketPath: String? = resolved.agentSocketPath.isEmpty ? nil : resolved.agentSocketPath
let agent = AgentAuthenticator(socketPath: socketPath)
let agent = AgentAuthenticator(socketPath: socketPath, socketOrigin: resolved.agentSocketOrigin)
if !jumpHost.privateKeyPath.isEmpty {
let keyAuth = KeyFileAuthenticator(
keyPath: jumpHost.privateKeyPath,
providedPassphrase: nil,
canPrompt: true,
useKeychain: resolved.useKeychain,
addKeysToAgent: resolved.addKeysToAgent
)
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/SSH/ResolvedSSHTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@

import Foundation

/// Where the agent socket a connection will use came from. `agentSocketPath` collapses three
/// sources into one string, and each is changed somewhere different, so an agent that does not
/// answer can only be reported usefully alongside the source that named it.
enum AgentSocketOrigin: Sendable, Hashable, CaseIterable {
/// The Agent Socket control on the SSH Tunnel pane.
case agentSocketSetting
/// An `IdentityAgent` directive matching this host in `~/.ssh/config`.
case identityAgentDirective
/// `SSH_AUTH_SOCK`, from the process environment or launchd.
case environment
}

struct ResolvedSSHTarget: Sendable, Hashable {
let originalHost: String
let host: String
let port: Int
let username: String
let identityFiles: [String]
let agentSocketPath: String
let agentSocketOrigin: AgentSocketOrigin
let identitiesOnly: Bool
let useKeychain: Bool
let addKeysToAgent: Bool
Expand Down
16 changes: 13 additions & 3 deletions TablePro/Core/SSH/SSHConfigResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,18 @@ enum SSHConfigResolver {

let effectivePort = formPort ?? merged.port ?? 22
let effectiveUser = !formUser.isEmpty ? formUser : (merged.user ?? "")
let effectiveAgentSocket = !formAgentSocket.isEmpty
? formAgentSocket
: (merged.identityAgent ?? "")
let effectiveAgentSocket: String
let agentSocketOrigin: AgentSocketOrigin
if !formAgentSocket.isEmpty {
effectiveAgentSocket = formAgentSocket
agentSocketOrigin = .agentSocketSetting
} else if let identityAgent = merged.identityAgent, !identityAgent.isEmpty {
effectiveAgentSocket = identityAgent
agentSocketOrigin = .identityAgentDirective
} else {
effectiveAgentSocket = ""
agentSocketOrigin = .environment
}

let effectiveIdentityFiles: [String]
if !formIdentityFile.isEmpty {
Expand Down Expand Up @@ -150,6 +159,7 @@ enum SSHConfigResolver {
username: effectiveUser,
identityFiles: effectiveIdentityFiles,
agentSocketPath: effectiveAgentSocket,
agentSocketOrigin: agentSocketOrigin,
identitiesOnly: merged.identitiesOnly ?? false,
useKeychain: merged.useKeychain ?? true,
addKeysToAgent: merged.addKeysToAgent ?? false,
Expand Down
Loading
Loading