From e016d3a031db8d764d956b342ba6d87b23806ce1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 21 Jul 2026 20:58:53 +0700 Subject: [PATCH] fix(ios): support None as an SSH tunnel auth method (#1912) --- CHANGELOG.md | 4 ++ .../TableProModels/SSHConfiguration.swift | 3 ++ .../SSHConfigurationTests.swift | 37 +++++++++++++++++++ TablePro/Models/Connection/SSHTypes.swift | 4 ++ .../Connection/ConnectionSSHTunnelView.swift | 2 +- .../Connection/SSHProfileEditorView.swift | 2 +- .../TableProMobile/SSH/SSHTunnel.swift | 17 +++++++++ .../TableProMobile/SSH/SSHTunnelFactory.swift | 3 ++ .../Services/IOSConnectionImportService.swift | 1 + .../Core/SSH/Auth/SSHAuthMethodTests.swift | 21 +++++++++++ 10 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift create mode 100644 TableProTests/Core/SSH/Auth/SSHAuthMethodTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d4eda38..8b8ef9b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- SSH tunnels using the None auth method now work on iPhone and iPad, not just the Mac. A connection synced from the Mac with None auth no longer fails to connect. (#1912) + ## [0.59.0] - 2026-07-21 ### Added diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index a2302e847..d91d3a866 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -14,6 +14,7 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { case privateKey case sshAgent case keyboardInteractive + case none public init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) @@ -26,6 +27,8 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { self = .sshAgent case "keyboardInteractive", "Keyboard Interactive": self = .keyboardInteractive + case "none", "None": + self = .none default: self = .password } diff --git a/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift new file mode 100644 index 000000000..f7f291eaf --- /dev/null +++ b/Packages/TableProCore/Tests/TableProModelsTests/SSHConfigurationTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import TableProModels + +@Suite("iOS SSHConfiguration auth method decoding") +struct SSHConfigurationTests { + private func decode(authMethod raw: String) throws -> SSHConfiguration { + let json = """ + {"host":"ssh.example.com","port":22,"username":"tailscale","authMethod":"\(raw)","jumpHosts":[]} + """ + return try JSONDecoder().decode(SSHConfiguration.self, from: Data(json.utf8)) + } + + @Test("decodes the macOS None raw value") + func decodesMacOSNone() throws { + #expect(try decode(authMethod: "None").authMethod == .none) + } + + @Test("decodes the lowercase none raw value") + func decodesLowercaseNone() throws { + #expect(try decode(authMethod: "none").authMethod == .none) + } + + @Test("None survives an encode and decode round trip") + func roundTripsNone() throws { + let config = SSHConfiguration(host: "ssh.example.com", username: "tailscale", authMethod: .none) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(SSHConfiguration.self, from: data) + #expect(decoded.authMethod == .none) + } + + @Test("an unrecognized auth method still falls back to password") + func unknownFallsBackToPassword() throws { + #expect(try decode(authMethod: "totp-only").authMethod == .password) + } +} diff --git a/TablePro/Models/Connection/SSHTypes.swift b/TablePro/Models/Connection/SSHTypes.swift index 20485cad6..a956194d9 100644 --- a/TablePro/Models/Connection/SSHTypes.swift +++ b/TablePro/Models/Connection/SSHTypes.swift @@ -33,6 +33,10 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable { case .none: return "key.slash" } } + + var supportsTwoFactorAuthentication: Bool { + self != .none + } } enum SSHAgentSocketOption: String, CaseIterable, Identifiable { diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift index 4407b1286..784442c9c 100644 --- a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift +++ b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift @@ -223,7 +223,7 @@ struct ConnectionSSHTunnelView: View { } } - if sshState.authMethod != .none { + if sshState.authMethod.supportsTwoFactorAuthentication { Section(String(localized: "Two-Factor Authentication")) { Picker(String(localized: "TOTP"), selection: $sshState.totpMode) { ForEach(TOTPMode.allCases) { mode in diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 5b0985955..992af355f 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -75,7 +75,7 @@ struct SSHProfileEditorView: View { serverSection authenticationSection - if authMethod != .none { + if authMethod.supportsTwoFactorAuthentication { totpSection } diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index 6f9a3d303..4f90260fb 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -207,6 +207,23 @@ actor SSHTunnel { Self.logger.debug("In-memory key authentication successful for \(username)") } + func authenticateNone(username: String) throws { + guard let session else { + throw SSHTunnelError.authenticationFailed("No active session") + } + + let authList = libssh2_userauth_list(session, username, UInt32(username.utf8.count)) + guard authList == nil else { + throw SSHTunnelError.authenticationFailed("Server requires credentials; passwordless authentication is not permitted") + } + + guard libssh2_userauth_authenticated(session) != 0 else { + throw SSHTunnelError.authenticationFailed("Passwordless authentication failed") + } + + Self.logger.debug("Passwordless authentication successful for \(username)") + } + // MARK: - Port Forwarding func startForwarding(remoteHost: String, remotePort: Int) throws { diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 9aa5055a9..559b08930 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -48,6 +48,9 @@ enum SSHTunnelFactory { throw SSHTunnelError.authenticationFailed("No private key provided") } + case .none: + try await tunnel.authenticateNone(username: config.username) + default: throw SSHTunnelError.authenticationFailed( "Auth method \(config.authMethod.rawValue) not supported on iOS" diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 14c1a86a1..3ba9aaeeb 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -214,6 +214,7 @@ enum IOSConnectionImportService { case "privatekey", "publickey", "private key": return .privateKey case "sshagent", "agent", "ssh agent": return .sshAgent case "keyboardinteractive", "keyboard interactive": return .keyboardInteractive + case "none": return .none default: return .password } } diff --git a/TableProTests/Core/SSH/Auth/SSHAuthMethodTests.swift b/TableProTests/Core/SSH/Auth/SSHAuthMethodTests.swift new file mode 100644 index 000000000..34de2e669 --- /dev/null +++ b/TableProTests/Core/SSH/Auth/SSHAuthMethodTests.swift @@ -0,0 +1,21 @@ +// +// SSHAuthMethodTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("SSHAuthMethod form contract") +struct SSHAuthMethodTests { + @Test("None is the only method without two-factor authentication") + func noneHidesTwoFactor() { + #expect(SSHAuthMethod.none.supportsTwoFactorAuthentication == false) + + for method in SSHAuthMethod.allCases where method != .none { + #expect(method.supportsTwoFactorAuthentication, "\(method.rawValue) should support two-factor") + } + } +}