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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public struct SSHConfiguration: Codable, Hashable, Sendable {
case privateKey
case sshAgent
case keyboardInteractive
case none

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render None as None in the iOS tunnel summary

When a synced or imported connection uses this newly supported .none value, ConnectionInfoView.swift:107 treats every non-password method as "Private Key". The tunnel can now authenticate successfully without a key, but its connection details misleadingly report Private Key; add an explicit None display case (and ideally handle the other enum cases rather than collapsing them).

Useful? React with 👍 / 👎.


public init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions TablePro/Models/Connection/SSHTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Connection/ConnectionSSHTunnelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Connection/SSHProfileEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ struct SSHProfileEditorView: View {
serverSection
authenticationSection

if authMethod != .none {
if authMethod.supportsTwoFactorAuthentication {
totpSection
}

Expand Down
17 changes: 17 additions & 0 deletions TableProMobile/TableProMobile/SSH/SSHTunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
21 changes: 21 additions & 0 deletions TableProTests/Core/SSH/Auth/SSHAuthMethodTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading