Skip to content

Commit e857918

Browse files
authored
Merge branch 'main' into fix/cloudkit-record-name-length
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
2 parents 88e4044 + 1caab2a commit e857918

16 files changed

Lines changed: 643 additions & 53 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- Cell cursor left on the old column after `Tab` carried the editor to the next one.
3939
- Every data grid switching to its accessibility layout after one `Tab` press, with no assistive app attached.
4040
- Crash loop on every launch after resizing a column on a database with a long file path, with iCloud sync on. (#2575)
41+
- SSH Agent auth prompting for a private key passphrase instead of reporting that the agent was never reached. (#2583)
42+
- "SSH password rejected" on an SSH connection that has no password, when the server offers no keyboard-interactive.
4143

4244
## [0.69.0] - 2026-08-27
4345

TablePro/Core/SSH/Auth/AgentAuthenticator.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ internal struct AgentAuthenticator: SSHAuthenticator {
1212
private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator")
1313

1414
let socketPath: String?
15+
let socketOrigin: AgentSocketOrigin
1516

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

5354
guard let agent = libssh2_agent_init(session) else {
54-
throw SSHTunnelError.tunnelCreationFailed("Failed to initialize SSH agent")
55+
throw SSHTunnelError.authenticationFailed(reason: .agentUnavailable(socketOrigin))
5556
}
5657

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

7778
rc = libssh2_agent_list_identities(agent)
7879
guard rc == 0 else {
7980
Self.logger.error("Failed to list SSH agent identities (rc=\(rc))")
80-
throw SSHTunnelError.tunnelCreationFailed("Failed to list SSH agent identities")
81+
throw SSHTunnelError.authenticationFailed(reason: .agentUnavailable(socketOrigin))
8182
}
8283

8384
var previousIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
8485
var currentIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
86+
var offeredCount = 0
8587

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

98100
guard let identity = currentIdentity else {
99101
break
100102
}
101103

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

111-
Self.logger.error("SSH agent authentication failed: no identity accepted")
114+
// An agent that answered but offered nothing is a locked or empty agent, which the user
115+
// fixes somewhere entirely different from an agent whose keys the server refused.
116+
guard offeredCount > 0 else {
117+
Self.logger.error("SSH agent offered no identities")
118+
throw SSHTunnelError.authenticationFailed(reason: .agentNoIdentities(socketOrigin))
119+
}
120+
121+
Self.logger.error("SSH agent authentication failed: none of \(offeredCount) identities accepted")
112122
throw SSHTunnelError.authenticationFailed(reason: .agentRejected)
113123
}
114124
}

TablePro/Core/SSH/Auth/CompositeAuthenticator.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,22 @@ import CLibSSH2
1010

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

1621
let authenticators: [any SSHAuthenticator]
1722

23+
/// Failures after which the remaining steps are not worth running. The SSH Agent chain names
24+
/// the two agent failures that mean no first factor was ever supplied, because the
25+
/// keyboard-interactive step behind them is a second factor and would otherwise ask for a
26+
/// credential of its own instead of reporting the agent.
27+
var endsChainOn: Set<AuthFailureReason> = []
28+
1829
func authenticate(session: OpaquePointer, username: String) throws {
1930
var lastError: Error?
2031
for (index, authenticator) in authenticators.enumerated() {
@@ -25,7 +36,13 @@ internal struct CompositeAuthenticator: SSHAuthenticator {
2536
throw error
2637
} catch {
2738
Self.logger.debug("Authenticator \(index + 1) failed: \(error)")
28-
lastError = error
39+
if lastError == nil || Self.describesAnAttempt(error) {
40+
lastError = error
41+
}
42+
if Self.reason(of: error).map(endsChainOn.contains) == true {
43+
Self.logger.debug("Authenticator \(index + 1) ended the chain")
44+
throw error
45+
}
2946
}
3047

3148
if libssh2_userauth_authenticated(session) != 0 {
@@ -38,4 +55,14 @@ internal struct CompositeAuthenticator: SSHAuthenticator {
3855
throw lastError ?? SSHTunnelError.authenticationFailed(reason: .generic)
3956
}
4057
}
58+
59+
private static func describesAnAttempt(_ error: any Error) -> Bool {
60+
reason(of: error)?.describesAnAttempt ?? true
61+
}
62+
63+
private static func reason(of error: any Error) -> AuthFailureReason? {
64+
guard let tunnelError = error as? SSHTunnelError,
65+
case .authenticationFailed(let reason) = tunnelError else { return nil }
66+
return reason
67+
}
4168
}

TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal final class KeyboardInteractiveContext {
3232
let promptProvider: any KeyboardInteractivePromptProvider
3333
private(set) var totpAttemptCount = 0
3434
private(set) var interactiveAttemptCount = 0
35+
private(set) var passwordAnswerCount = 0
3536
private(set) var userCancelled = false
3637
var lastError: Error?
3738

@@ -45,6 +46,16 @@ internal final class KeyboardInteractiveContext {
4546
self.promptProvider = promptProvider
4647
}
4748

49+
/// What the rejection was about, named by whichever answer actually went to the server. A
50+
/// server that issued no prompt at all never took a credential from this method, so the
51+
/// failure says nothing about the user's own: it says keyboard-interactive was not on offer.
52+
var failureReason: AuthFailureReason {
53+
if interactiveAttemptCount > 0 { return .keyboardInteractive }
54+
if totpAttemptCount > 0 { return .verificationCode }
55+
if passwordAnswerCount > 0 { return .password }
56+
return .methodUnavailable
57+
}
58+
4859
func nextTotpCode() -> String {
4960
guard let totpProvider else { return "" }
5061
defer { totpAttemptCount += 1 }
@@ -67,6 +78,7 @@ internal final class KeyboardInteractiveContext {
6778
switch KeyboardInteractiveAuthenticator.classify(prompt.text) {
6879
case .password where password != nil:
6980
results[index] = password
81+
passwordAnswerCount += 1
7082
case .totp where totpProvider != nil:
7183
results[index] = nextTotpCode()
7284
default:
@@ -206,10 +218,7 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator {
206218
libssh2_session_last_error(session, &msgPtr, &msgLen, 0)
207219
let detail = msgPtr.map { String(cString: $0) } ?? "Unknown error"
208220
Self.logger.error("Keyboard-interactive authentication failed: \(detail)")
209-
let reason: AuthFailureReason = context.interactiveAttemptCount > 0
210-
? .keyboardInteractive
211-
: (context.totpAttemptCount > 0 ? .verificationCode : .password)
212-
throw SSHTunnelError.authenticationFailed(reason: reason)
221+
throw SSHTunnelError.authenticationFailed(reason: context.failureReason)
213222
}
214223

215224
Self.logger.info("Keyboard-interactive authentication succeeded")

TablePro/Core/SSH/LibSSH2TunnelFactory.swift

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ internal enum LibSSH2TunnelFactory {
3535

3636
// MARK: - Global Init
3737

38-
private static let initialized: Bool = {
38+
/// libssh2's own header says `libssh2_init` uses global state and must not be called
39+
/// concurrently, so every entry point in the process goes through this one lazy static.
40+
internal static let initialized: Bool = {
3941
libssh2_init(0)
4042
return true
4143
}()
@@ -501,8 +503,7 @@ internal enum LibSSH2TunnelFactory {
501503
buildKeyFileAuthenticator(
502504
keyPath: keyPath,
503505
providedPassphrase: credentials.keyPassphrase,
504-
resolved: resolved,
505-
canPrompt: true
506+
resolved: resolved
506507
)
507508
}
508509
authenticators.append(KeyboardInteractiveAuthenticator(
@@ -513,28 +514,28 @@ internal enum LibSSH2TunnelFactory {
513514
return CompositeAuthenticator(authenticators: authenticators)
514515

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

520-
var authenticators: [any SSHAuthenticator] = [AgentAuthenticator(socketPath: socketPath)]
521-
522-
for keyPath in effectiveKeyPaths(for: resolved) {
523-
authenticators.append(buildKeyFileAuthenticator(
524-
keyPath: keyPath,
525-
providedPassphrase: credentials.keyPassphrase,
526-
resolved: resolved,
527-
canPrompt: true
528-
))
529-
}
530-
531-
authenticators.append(KeyboardInteractiveAuthenticator(
532-
password: nil,
533-
totpProvider: buildTOTPProvider(config: config, credentials: credentials),
534-
promptProvider: promptProvider
535-
))
536-
537-
return CompositeAuthenticator(authenticators: authenticators)
525+
return CompositeAuthenticator(
526+
authenticators: [
527+
AgentAuthenticator(socketPath: socketPath, socketOrigin: resolved.agentSocketOrigin),
528+
KeyboardInteractiveAuthenticator(
529+
password: nil,
530+
totpProvider: buildTOTPProvider(config: config, credentials: credentials),
531+
promptProvider: promptProvider
532+
),
533+
],
534+
endsChainOn: Set(
535+
AgentSocketOrigin.allCases.map(AuthFailureReason.agentUnavailable)
536+
+ AgentSocketOrigin.allCases.map(AuthFailureReason.agentNoIdentities)
537+
)
538+
)
538539

539540
case .keyboardInteractive:
540541
return KeyboardInteractiveAuthenticator(
@@ -562,19 +563,16 @@ internal enum LibSSH2TunnelFactory {
562563
.filter { FileManager.default.isReadableFile(atPath: $0) }
563564
}
564565

565-
/// Passphrase resolution is deferred to auth time (not build time) so
566-
/// that, when this authenticator is used as an agent fallback, the user
567-
/// is only prompted if the agent actually fails.
566+
/// Passphrase resolution is deferred to auth time (not build time) so that a key later in
567+
/// the chain only prompts once the ones before it have actually been refused.
568568
private static func buildKeyFileAuthenticator(
569569
keyPath: String,
570570
providedPassphrase: String?,
571-
resolved: ResolvedSSHTarget,
572-
canPrompt: Bool
571+
resolved: ResolvedSSHTarget
573572
) -> any SSHAuthenticator {
574573
KeyFileAuthenticator(
575574
keyPath: keyPath,
576575
providedPassphrase: providedPassphrase,
577-
canPrompt: canPrompt,
578576
useKeychain: resolved.useKeychain,
579577
addKeysToAgent: resolved.addKeysToAgent
580578
)
@@ -586,7 +584,6 @@ internal enum LibSSH2TunnelFactory {
586584
private struct KeyFileAuthenticator: SSHAuthenticator {
587585
let keyPath: String
588586
let providedPassphrase: String?
589-
let canPrompt: Bool
590587
let useKeychain: Bool
591588
let addKeysToAgent: Bool
592589

@@ -617,9 +614,7 @@ internal enum LibSSH2TunnelFactory {
617614
}
618615
}
619616

620-
// 2. Prompt the user if allowed (key is encrypted, no stored passphrase)
621-
guard canPrompt else { throw SSHTunnelError.authenticationFailed(reason: .privateKey) }
622-
617+
// 2. Prompt the user (key is encrypted, no stored passphrase)
623618
let provider = PromptPassphraseProvider(keyPath: expandedPath)
624619
guard let promptResult = provider.providePassphrase() else {
625620
throw SSHTunnelError.authenticationFailed(reason: .privateKey)
@@ -668,7 +663,6 @@ internal enum LibSSH2TunnelFactory {
668663
KeyFileAuthenticator(
669664
keyPath: path,
670665
providedPassphrase: nil,
671-
canPrompt: true,
672666
useKeychain: resolved.useKeychain,
673667
addKeysToAgent: resolved.addKeysToAgent
674668
)
@@ -678,12 +672,11 @@ internal enum LibSSH2TunnelFactory {
678672
: CompositeAuthenticator(authenticators: authenticators)
679673
case .sshAgent:
680674
let socketPath: String? = resolved.agentSocketPath.isEmpty ? nil : resolved.agentSocketPath
681-
let agent = AgentAuthenticator(socketPath: socketPath)
675+
let agent = AgentAuthenticator(socketPath: socketPath, socketOrigin: resolved.agentSocketOrigin)
682676
if !jumpHost.privateKeyPath.isEmpty {
683677
let keyAuth = KeyFileAuthenticator(
684678
keyPath: jumpHost.privateKeyPath,
685679
providedPassphrase: nil,
686-
canPrompt: true,
687680
useKeychain: resolved.useKeychain,
688681
addKeysToAgent: resolved.addKeysToAgent
689682
)

TablePro/Core/SSH/ResolvedSSHTarget.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,26 @@
55

66
import Foundation
77

8+
/// Where the agent socket a connection will use came from. `agentSocketPath` collapses three
9+
/// sources into one string, and each is changed somewhere different, so an agent that does not
10+
/// answer can only be reported usefully alongside the source that named it.
11+
enum AgentSocketOrigin: Sendable, Hashable, CaseIterable {
12+
/// The Agent Socket control on the SSH Tunnel pane.
13+
case agentSocketSetting
14+
/// An `IdentityAgent` directive matching this host in `~/.ssh/config`.
15+
case identityAgentDirective
16+
/// `SSH_AUTH_SOCK`, from the process environment or launchd.
17+
case environment
18+
}
19+
820
struct ResolvedSSHTarget: Sendable, Hashable {
921
let originalHost: String
1022
let host: String
1123
let port: Int
1224
let username: String
1325
let identityFiles: [String]
1426
let agentSocketPath: String
27+
let agentSocketOrigin: AgentSocketOrigin
1528
let identitiesOnly: Bool
1629
let useKeychain: Bool
1730
let addKeysToAgent: Bool

TablePro/Core/SSH/SSHConfigResolver.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,18 @@ enum SSHConfigResolver {
117117

118118
let effectivePort = formPort ?? merged.port ?? 22
119119
let effectiveUser = !formUser.isEmpty ? formUser : (merged.user ?? "")
120-
let effectiveAgentSocket = !formAgentSocket.isEmpty
121-
? formAgentSocket
122-
: (merged.identityAgent ?? "")
120+
let effectiveAgentSocket: String
121+
let agentSocketOrigin: AgentSocketOrigin
122+
if !formAgentSocket.isEmpty {
123+
effectiveAgentSocket = formAgentSocket
124+
agentSocketOrigin = .agentSocketSetting
125+
} else if let identityAgent = merged.identityAgent, !identityAgent.isEmpty {
126+
effectiveAgentSocket = identityAgent
127+
agentSocketOrigin = .identityAgentDirective
128+
} else {
129+
effectiveAgentSocket = ""
130+
agentSocketOrigin = .environment
131+
}
123132

124133
let effectiveIdentityFiles: [String]
125134
if !formIdentityFile.isEmpty {
@@ -150,6 +159,7 @@ enum SSHConfigResolver {
150159
username: effectiveUser,
151160
identityFiles: effectiveIdentityFiles,
152161
agentSocketPath: effectiveAgentSocket,
162+
agentSocketOrigin: agentSocketOrigin,
153163
identitiesOnly: merged.identitiesOnly ?? false,
154164
useKeychain: merged.useKeychain ?? true,
155165
addKeysToAgent: merged.addKeysToAgent ?? false,

0 commit comments

Comments
 (0)